From c039d5636f489dbaa49769c319bbff762a13ceb3 Mon Sep 17 00:00:00 2001 From: "franco.mucci" Date: Mon, 3 Aug 2026 14:57:22 -0300 Subject: [PATCH] Release source code for 55.9 (1509) --- android/CHANGELOG.md | 8 + android/apolloui/build.gradle | 28 +- android/apolloui/lint-baseline.xml | 2297 +++++------- android/apolloui/proguard/proguard-apollo.pro | 9 +- .../data/afs/MetricsProviderTimingTest.kt | 86 +- .../app/di/modules/InAppUpdateModule.kt | 14 + .../ui/home/PlayInAppUpdateManager.kt | 128 + .../app/di/modules/InAppUpdateModule.kt | 14 + android/apolloui/src/main/AndroidManifest.xml | 16 + .../data/afs/ActivityManagerInfoProvider.kt | 9 + .../afs/BackgroundExecutionMetricsProvider.kt | 50 +- .../apollo/data/afs/ContextSwapDrmFetcher.kt | 98 + .../apollo/data/afs/EarlyMetricsProvider.kt | 2 + .../data/afs/HardwareCapabilitiesProvider.kt | 117 +- .../muun/apollo/data/afs/MetricsProvider.kt | 156 +- .../apollo/data/afs/NfcExtendedApduProbe.kt | 221 ++ .../io/muun/apollo/data/afs/NfcProvider.kt | 62 +- .../data/afs/PackageManagerInfoProvider.kt | 93 +- .../apollo/data/afs/RuntimeInfoProvider.kt | 360 ++ .../data/afs/SystemCapabilitiesProvider.kt | 3 +- .../data/analytics/AnalyticsProvider.kt | 22 +- .../db/base/ElementNotFoundException.java | 8 + .../io/muun/apollo/data/di/DataComponent.kt | 3 + .../java/io/muun/apollo/data/di/DataModule.kt | 18 +- .../java/io/muun/apollo/data/fs/FileCache.kt | 3 +- .../apollo/data/fs/LibwalletDataDirectory.kt | 4 - .../LibwalletSecureKeyValueStorage.kt | 89 + .../muun/apollo/data/logging/Crashlytics.kt | 6 +- .../data/net/base/NetworkException.java | 13 +- .../data/net/base/ServerFailureException.java | 13 +- .../muun/apollo/data/nfc/NfcEmpiricalCache.kt | 71 + .../io/muun/apollo/data/nfc/NfcSessionImpl.kt | 13 +- .../apollo/data/nfc/api/NfcSessionBuilder.kt | 5 +- .../main/java/io/muun/apollo/data/os/OS.kt | 16 + .../os/secure_storage/KeyStoreProvider.java | 6 +- .../secure_storage/MuunKeyStoreException.kt | 3 + .../SecureKeyValueStorageRepository.kt | 26 + .../apollo/domain/action/CurrencyActions.java | 2 +- .../apollo/domain/action/LogoutActions.java | 10 +- .../domain/action/OperationActions.java | 1 + .../debug/BuildDebugDataEmailReportAction.kt | 54 + .../debug/BuildErrorEmailReportAction.kt | 30 + .../ek/AddEmergencyKitMetadataAction.kt | 6 +- .../action/ek/GenerateEmergencyKitPDF.kt | 41 +- .../domain/action/session/LogoutAction.kt | 4 +- .../apollo/domain/analytics/AnalyticsEvent.kt | 67 +- .../apollo/domain/debug/DebugExecutable.kt | 9 - .../io/muun/apollo/domain/errors/ApiError.kt | 2 + .../errors/BiometricAuthenticationError.kt | 2 + .../muun/apollo/domain/errors/BugDetected.kt | 4 +- .../errors/ChallengeKeyMigrationError.kt | 4 +- .../apollo/domain/errors/DatabaseError.kt | 4 +- .../domain/errors/DebtNegativeError.java | 8 + .../errors/DeprecatedClientVersionError.kt | 4 +- .../apollo/domain/errors/EmptyFieldError.kt | 2 + .../domain/errors/ErrorClassification.kt | 28 + .../domain/errors/ExpiredActionLinkError.kt | 4 +- .../domain/errors/ExpiredSessionError.kt | 4 +- .../domain/errors/HardwareCapabilityError.kt | 1 + .../errors/IncorrectRecoveryCodeError.kt | 6 +- .../apollo/domain/errors/InitialSyncError.kt | 4 +- .../domain/errors/InitialSyncNetworkError.kt | 4 +- .../domain/errors/InvalidActionLinkError.kt | 2 + .../errors/InvalidChallengeSignatureError.kt | 4 +- .../errors/InvalidExchangeRateWindow.kt | 4 +- .../apollo/domain/errors/InvalidJsonError.kt | 4 +- .../errors/LocalStorageIntegrityError.kt | 2 + .../domain/errors/LocaleNumberParsingError.kt | 2 +- .../domain/errors/MissingCurrencyError.kt | 2 + .../domain/errors/MissingLocaleError.kt | 2 + .../domain/errors/MissingMigrationError.kt | 4 +- .../domain/errors/MoneyDecorationError.kt | 2 + .../io/muun/apollo/domain/errors/MuunError.kt | 2 +- .../apollo/domain/errors/MuunErrorMapper.kt | 2 + .../domain/errors/NoStackTraceException.kt | 4 +- .../domain/errors/NullExpectedDebtBugError.kt | 4 +- .../apollo/domain/errors/PeriodicTaskError.kt | 3 +- .../errors/PeriodicTaskOnMainThreadError.kt | 3 +- .../domain/errors/PlayIntegrityError.kt | 2 + .../domain/errors/ReportAnalyticError.kt | 4 +- .../domain/errors/SecureStorageError.kt | 2 + .../domain/errors/SignupDraftFormatError.kt | 3 +- .../domain/errors/TooManyRequestsError.kt | 4 +- .../apollo/domain/errors/UnclassifiedError.kt | 15 + .../errors/UnrecoverableUserLogoutError.kt | 4 +- .../domain/errors/WrappedErrorMessage.kt | 4 +- .../errors/data/MuunDeserializationError.kt | 3 + .../errors/data/MuunSerializationError.kt | 3 + .../NonEmptyWalletDeleteException.kt | 5 +- ...nsettledOperationsWalletDeleteException.kt | 5 +- .../errors/ek/EmergencyKitInvalidCodeError.kt | 3 + .../errors/ek/EmergencyKitOldCodeError.kt | 5 +- .../ek/EmergencyKitVerificationError.kt | 5 +- .../domain/errors/ek/SaveEkToDiskError.kt | 5 +- .../errors/fcm/FcmMessageProcessingError.kt | 3 + .../errors/fcm/FcmTokenCanceledError.kt | 5 +- .../apollo/domain/errors/fcm/FcmTokenError.kt | 5 +- .../errors/fcm/FcmTokenNotAvailableError.kt | 5 +- .../GooglePlayServicesNotAvailableError.kt | 7 +- .../domain/errors/integrity/IntegrityError.kt | 5 +- .../domain/errors/lnurl/AlreadyUsedError.kt | 3 + .../errors/lnurl/CountryNotSupportedError.kt | 3 + .../domain/errors/lnurl/ExpiredLnUrlError.kt | 3 + .../errors/lnurl/ExpiredLnUrlInvoiceError.kt | 3 + .../domain/errors/lnurl/InvalidLnUrlError.kt | 3 + .../errors/lnurl/InvalidLnUrlTagError.kt | 3 + .../lnurl/LnUrlServiceUnresponsiveError.kt | 3 + .../domain/errors/lnurl/NoRouteError.kt | 3 + .../errors/lnurl/NoWithdrawBalanceError.kt | 3 + .../domain/errors/lnurl/UnknownLnUrlError.kt | 3 + .../errors/newop/AmountTooSmallError.kt | 3 + .../domain/errors/newop/CyclicalSwapError.kt | 5 +- .../newop/ExchangeRateWindowTooOldError.kt | 5 +- .../errors/newop/InsufficientFundsError.kt | 5 +- .../errors/newop/InvalidAddressError.kt | 5 +- .../errors/newop/InvalidInvoiceException.kt | 5 +- .../newop/InvalidOperationUriException.kt | 8 + .../newop/InvalidPaymentRequestError.kt | 5 +- .../errors/newop/InvalidSwapException.kt | 5 +- .../newop/InvoiceAlreadyUsedException.kt | 5 +- .../errors/newop/InvoiceExpiredException.kt | 3 + .../newop/InvoiceExpiresTooSoonException.kt | 5 +- .../newop/InvoiceMissingAmountException.kt | 5 +- .../domain/errors/newop/NewOpStateError.kt | 5 +- .../errors/newop/NoPaymentRouteException.kt | 5 +- .../errors/newop/PushTransactionSlowError.kt | 5 +- .../errors/newop/SwapFailedException.kt | 5 +- .../errors/newop/UnreachableNodeException.kt | 5 +- .../errors/nfc/NoFeasibleZoneForModelError.kt | 8 + .../notifications/MessageOriginError.kt | 3 + .../notifications/MessagePermissionsError.kt | 3 + .../NotificationProcessingError.kt | 3 + .../UnknownNotificationTypeError.kt | 3 + .../errors/p2p/CountryNotSupportedError.kt | 5 +- .../p2p/ExpiredVerificationCodeError.kt | 5 +- .../errors/p2p/InvalidPhoneNumberError.kt | 3 + .../domain/errors/p2p/InvalidPictureError.kt | 3 + .../p2p/InvalidVerificationCodeError.kt | 5 +- .../errors/p2p/PhoneNumberAlreadyUsedError.kt | 5 +- .../p2p/RevokedVerificationCodeError.kt | 5 +- .../p2p/TooManyWrongVerificationCodesError.kt | 5 +- .../errors/passwd/EmailAlreadyUsedError.kt | 5 +- .../errors/passwd/EmailNotRegisteredError.kt | 5 +- .../errors/passwd/IncorrectPasswordError.kt | 6 +- .../errors/passwd/PasswordIntegrityError.kt | 5 +- .../errors/passwd/PasswordTooShortError.kt | 5 +- .../errors/passwd/PasswordsDontMatchError.kt | 5 +- .../errors/rc/CredentialsDontMatchError.kt | 5 +- .../errors/rc/FinishRecoveryCodeSetupError.kt | 5 +- .../rc/InvalidCharacterRecoveryCodeError.kt | 5 +- .../errors/rc/InvalidRecoveryCodeV2Error.kt | 5 +- .../rc/RecoveryCodeVerificationError.kt | 3 +- .../errors/rc/StaleChallengeKeyError.kt | 5 +- .../errors/rc/StartRecoveryCodeSetupError.kt | 5 +- .../domain/libwallet/LibwalletClient.kt | 89 +- .../errors/AddressDerivationError.kt | 3 + .../errors/FeeBumpFunctionsStoreError.kt | 3 + .../errors/InvalidRecoveryCodeFormatError.kt | 5 +- .../libwallet/errors/InvoiceParsingError.kt | 3 + .../errors/LibwalletEmergencyKitError.kt | 5 +- .../libwallet/errors/LibwalletGrpcError.kt | 3 + .../libwallet/errors/LibwalletSigningError.kt | 3 + .../errors/LibwalletVerificationError.kt | 5 +- .../libwallet/errors/NoInvoicesLeftError.kt | 5 +- .../libwallet/errors/PayloadDecryptError.kt | 5 +- .../libwallet/errors/PayloadEncryptError.kt | 5 +- .../errors/UnfulfillableIncomingSwapError.kt | 3 + .../errors/UnknownRecoveryCodeVersionError.kt | 5 +- .../apollo/domain/model/BitcoinAmount.java | 18 - .../muun/apollo/domain/model/FeeWindow.java | 55 - .../muun/apollo/domain/model/MuunFeature.kt | 1 + .../domain/model/NextTransactionSize.java | 29 +- .../apollo/domain/model/report/EmailReport.kt | 16 +- .../domain/secure_key_value_storage/Secret.kt | 26 + .../apollo/domain/selector/FeatureSelector.kt | 2 +- .../muun/apollo/domain/utils/TimeTracker.kt | 11 + .../java/io/muun/apollo/domain/utils/Trace.kt | 11 +- .../presentation/app/ApolloApplication.kt | 13 +- .../io/muun/apollo/presentation/app/Email.kt | 47 +- .../io/muun/apollo/presentation/app/Logcat.kt | 30 +- .../apollo/presentation/app/Navigator.java | 47 +- .../app/di/ApplicationComponent.kt | 6 +- .../apollo/presentation/model/UiOperation.kt | 28 +- .../extension/NfcReaderModeExtension.kt | 9 +- .../presentation/ui/base/BasePresenter.java | 80 +- .../ui/base/SingleFragmentActivity.java | 10 +- .../ui/base/di/ActivityComponent.java | 11 +- .../ui/debug/DebugPanelActivity.kt | 60 +- .../ui/debug/DebugPanelPresenter.kt | 12 +- .../DebugSecureKeyValueStorageActivity.kt | 126 + .../ui/export_keys/EmergencyKitPresenter.kt | 4 - .../EmergencyKitVerifyHelpParentPresenter.kt | 2 - .../operations/OperationsFragment.java | 7 +- .../operations/OperationsPresenter.kt | 2 +- .../ui/fragments/settings/SettingsFragment.kt | 10 +- .../fragments/settings/SettingsPresenter.kt | 52 +- .../presentation/ui/home/HomeActivity.java | 71 +- .../ui/home/InAppUpdateManager.kt | 20 + .../ui/home/NoOpInAppUpdateManager.kt | 8 + .../InvalidOperationUriException.kt | 5 - .../ui/new_operation/NewOperationActivity.kt | 1 + .../ui/new_operation/NewOperationPresenter.kt | 57 +- .../presentation/ui/nfc/NfcReaderViewModel.kt | 19 +- .../OperationDetailActivity.java | 21 +- .../OperationDetailPresenter.java | 33 +- .../ui/security_cards/utils/WebsitePill.kt | 64 + .../CardDetailActivity.kt | 22 +- .../SecurityCardsFullSpecsActivity.kt | 93 + .../SecurityCardsFullSpecsAdapter.kt | 234 ++ .../SecurityCardsFullSpecsViewModel.kt | 62 + .../SecurityCardsFullSpecsViewModelFactory.kt | 18 + ...AdditionalInfoBottomSheetDialogFragment.kt | 36 + .../SecurityCardsMarketplaceActivity.kt | 1 + .../SecurityCardsMarketplaceViewModel.kt | 195 +- .../models/CardSpec.kt | 1 + .../models/SecurityCard.kt | 12 +- .../ConnectedToWebsiteDialogFragment.kt | 148 + .../ConnectedToWebsiteView.kt | 32 + .../HeroAnimation.kt | 278 ++ .../ShippingAddressActivity.kt | 190 + .../ShippingAddressViewModel.kt | 84 + .../ShippingAddressViewModelFactory.kt | 28 + .../SecurityLogoutPresenter.java | 4 +- .../ui/show_qr/QrParentPresenter.kt | 2 - .../presentation/ui/show_qr/QrPresenter.kt | 4 - .../ui/show_qr/ShowQrPresenter.kt | 2 +- .../ui/taproot_setup/TaprootSetupPresenter.kt | 4 - .../ui/utils/EditTextExtensions.kt | 16 + .../presentation/ui/utils/Extensions.kt | 40 +- .../drawable/bg_security_card_spec_table.xml | 9 + .../connected_with_external_website.xml | 44 + .../main/res/drawable/ic_access_time_24px.xml | 9 + .../main/res/drawable/ic_arrowhead_left.xml | 11 + .../src/main/res/drawable/ic_arrowhead_up.xml | 11 + .../res/drawable/ic_aspect_ratio_24px.xml | 9 + .../src/main/res/drawable/ic_code_24px.xml | 9 + .../res/drawable/ic_delete_outline_24px.xml | 9 + .../res/drawable/ic_fitness_center_24px.xml | 9 + .../main/res/drawable/ic_folder_open_24px.xml | 9 + .../drawable/ic_hardware_security_24px.xml | 9 + .../src/main/res/drawable/ic_launch_24px.xml | 9 + .../main/res/drawable/ic_local_mall_24px.xml | 9 + .../res/drawable/ic_local_shipping_24px.xml | 9 + .../main/res/drawable/ic_misc_trezor_24px.xml | 9 + .../src/main/res/drawable/ic_place_24px.xml | 9 + .../src/main/res/drawable/ic_style_24px.xml | 9 + .../res/layout-land/fragment_settings.xml | 4 + ...ctivity_debug_secure_key_value_storage.xml | 81 + .../activity_security_cards_full_specs.xml | 40 + .../res/layout/activity_shipping_address.xml | 245 ++ .../bottom_sheet_spec_additional_info.xml | 48 + .../src/main/res/layout/debug_activity.xml | 8 +- .../fragment_connected_to_website_dialog.xml | 33 + .../src/main/res/layout/fragment_settings.xml | 4 + .../item_security_card_spec_card_image.xml | 121 + .../layout/item_security_card_spec_header.xml | 12 + .../layout/item_security_card_spec_row.xml | 51 + .../res/layout/view_connected_to_website.xml | 82 + .../main/res/navigation/home_nav_graph.xml | 3 +- .../src/main/res/values-es/strings.xml | 34 + .../src/main/res/values-night/colors.xml | 3 + .../src/main/res/values-night/new_colors.xml | 10 + .../apolloui/src/main/res/values/colors.xml | 5 + .../apolloui/src/main/res/values/dimens.xml | 16 + android/apolloui/src/main/res/values/ids.xml | 4 + .../src/main/res/values/new_colors.xml | 10 + .../apolloui/src/main/res/values/strings.xml | 31 + .../apolloui/src/main/res/values/styles.xml | 22 + .../app/di/modules/InAppUpdateModule.kt | 14 + .../app/di/modules/InAppUpdateModule.kt | 14 + .../LibwalletSecureKeyValueStorageTest.kt | 107 + .../data/preferences/KeysRepositoryTest.kt | 255 ++ .../secure_key_value_storage/SecretTest.kt | 70 + .../muun/apollo/domain/utils/FeeWindowTest.kt | 76 - .../presenters/CreateEmailPresenterTest.kt | 29 +- .../AnimationNodeTest.kt | 304 ++ build.gradle | 1 + common/build.gradle | 21 +- .../main/java/io/muun/common/Optional.java | 6 +- .../muun/common/api/BackgroundEventJson.java | 2 +- .../java/io/muun/common/api/ClientJson.java | 2 +- .../muun/common/api/DescendantFeesJson.java | 2 +- .../muun/common/api/FeeBumpFunctionsJson.java | 2 +- .../muun/common/api/HardwareWalletJson.java | 2 +- .../main/java/io/muun/common/api/KeySet.java | 2 +- .../api/PartiallySignedTransactionJson.java | 2 +- .../io/muun/common/api/PasswordSetupJson.java | 2 +- .../io/muun/common/api/PhoneNumberJson.java | 2 +- .../io/muun/common/api/PublicKeyJson.java | 3 +- .../io/muun/common/api/PublicProfileJson.java | 2 +- .../io/muun/common/api/RawTransaction.java | 3 +- .../common/api/SendEncryptedKeysJson.java | 3 +- .../java/io/muun/common/api/SessionJson.java | 2 +- .../muun/common/api/StartEmailSetupJson.java | 2 +- .../common/api/SubmarineSwapRequestJson.java | 2 +- .../io/muun/common/api/UserInvoiceJson.java | 2 +- .../io/muun/common/api/UserProfileJson.java | 2 +- .../muun/common/api/WatchedPhoneNumber.java | 3 +- .../beam/notification/NotificationJson.java | 2 +- .../notification/NotificationReportJson.java | 2 +- .../io/muun/common/api/error/ErrorCode.java | 5 + .../io/muun/common/crypto/hd/MuunAddress.java | 4 +- .../io/muun/common/utils/Preconditions.java | 16 +- .../src/main/java/io/muun/package-info.java | 20 + .../io/muun/common/model/BtcAmountTest.java | 4 +- libwallet/CLAUDE.md | 42 + libwallet/V1.go | 24 +- libwallet/V2.go | 48 +- libwallet/V3.go | 40 +- libwallet/V4.go | 42 +- libwallet/V5.go | 79 +- libwallet/V6.go | 79 +- libwallet/V7.go | 199 ++ libwallet/V8.go | 177 + libwallet/V9.go | 247 ++ libwallet/address.go | 76 +- libwallet/address_test.go | 126 +- libwallet/addresses/addresses.go | 16 +- libwallet/addresses/addresses_test.go | 1 + libwallet/addresses/v1.go | 6 +- libwallet/addresses/v2.go | 23 +- libwallet/addresses/v2_test.go | 6 +- libwallet/addresses/v3.go | 23 +- libwallet/addresses/v3_test.go | 4 +- libwallet/addresses/v4.go | 18 +- libwallet/addresses/v4_test.go | 4 +- libwallet/addresses/v5.go | 21 +- libwallet/addresses/v5_test.go | 4 +- libwallet/addresses/v6.go | 21 +- libwallet/addresses/v6_test.go | 4 +- libwallet/addresses/v7.go | 102 + libwallet/addresses/v7_test.go | 33 + libwallet/addresses/v8.go | 59 + libwallet/addresses/v8_test.go | 33 + libwallet/addresses/v9.go | 113 + libwallet/addresses/v9_test.go | 33 + libwallet/aescbc/aescbc.go | 14 +- libwallet/app_provided_data/config.go | 3 +- .../secure_key_value_storage.go | 40 + .../app_provided_data/service_request.go | 2 +- libwallet/bip21.go | 13 +- libwallet/bip21_test.go | 37 +- libwallet/bridge.go | 7 +- libwallet/btcsuitew/bech32m/bech32m.go | 38 +- libwallet/btcsuitew/bech32m/bech32m_test.go | 2 +- libwallet/btcsuitew/btcutilw/address.go | 16 +- libwallet/btcsuitew/btcutilw/segwit.go | 18 +- libwallet/btcsuitew/txscriptw/script.go | 15 +- libwallet/btcsuitew/txscriptw/script_test.go | 28 +- libwallet/btcsuitew/txscriptw/standard.go | 1 + libwallet/challenge_keys.go | 27 +- libwallet/challenge_keys_test.go | 51 +- libwallet/challenge_public_key.go | 39 +- libwallet/cmd/kv_migration_tool/main.go | 51 +- libwallet/cmd/kv_migration_tool/main_test.go | 32 +- .../cryptography/bitcoin_hpke/bitcoin_hpke.go | 59 +- .../bitcoin_hpke/bitcoin_hpke_test.go | 490 +-- .../bitcoin_hpke/encrypted_message.go | 22 +- libwallet/cryptography/bitcoin_hpke/i2osp.go | 8 +- libwallet/cryptography/bitcoin_hpke/kem.go | 32 +- .../cryptography/bitcoin_hpke/labeled_hkdf.go | 25 +- libwallet/cryptography/secp256r1p.go | 25 +- libwallet/data/emergency_kit/pdf.go | 111 +- .../data/emergency_kit/resources/fonts.go | 1 + libwallet/data/keys/provider.go | 25 +- .../incoming_htlc_batch_repository.go | 114 + libwallet/data/securekv/errors.go | 46 + libwallet/data/securekv/secret.go | 59 + libwallet/data/securekv/storage.go | 86 + libwallet/data/securekv/storage_test.go | 477 +++ .../security_cards/protocol_repository.go | 70 + libwallet/docker/builder.Dockerfile | 9 +- .../finish_challenge_setup_action.go | 8 +- .../finish_challenge_setup_action_test.go | 99 + .../start_challenge_setup_action.go | 8 +- .../action/debug/zip_data_dir_action.go | 70 + .../action/debug/zip_data_dir_action_test.go | 104 + .../submit_diagnostic_action.go | 7 +- .../generate_emergency_kit_pdf_action.go | 25 +- .../domain/action/lightning/chain_status.go | 64 + .../lightning/complete_incoming_htlc_batch.go | 39 + .../action/lightning/cross_cutting_test.go | 93 + libwallet/domain/action/lightning/errors.go | 91 + .../lightning/expiration_branch_test.go | 229 ++ .../action/lightning/external_spend_test.go | 265 ++ .../action/lightning/external_tx_checker.go | 329 ++ .../action/lightning/publish_enforcer.go | 399 +++ .../action/lightning/recall_branch_test.go | 152 + .../lightning/register_incoming_htlc_batch.go | 43 + .../action/lightning/reveal_preimages.go | 57 + .../domain/action/lightning/suite_test.go | 545 +++ .../pair_load_persisted_challenge_action.go | 41 + .../nfc/pair_request_challenge_action.go | 39 + .../action/nfc/pair_security_card_action.go | 69 - .../nfc/pair_security_card_action_v2.go | 31 +- .../pair_sign_and_submit_challenge_action.go | 98 + .../action/nfc/pair_sign_challenge_action.go | 61 + .../pair_submit_solved_challenge_action.go | 70 + .../action/nfc/reset_security_card_action.go | 31 - .../nfc/sign_message_security_card_action.go | 66 - .../sign_message_security_card_action_v2.go | 19 +- .../action/nfc/signed_pair_challenge.go | 19 + .../recovery/broadcast_recovery_tx_action.go | 12 +- .../recovery/build_recovery_tx_action.go | 29 +- ...ute_and_store_encrypted_muun_key_action.go | 14 +- ...nd_store_encrypted_muun_key_action_test.go | 145 + .../recovery/get_encrypted_user_key_action.go | 10 +- .../may_retrieve_encrypted_muun_key_action.go | 15 +- .../populate_encrypted_muun_key_action.go | 14 +- ...populate_encrypted_muun_key_action_test.go | 170 + .../action/recovery/scan_for_funds_action.go | 20 +- .../recovery/sign_recovery_tx_action.go | 36 +- libwallet/domain/action/reset/reset_action.go | 48 + .../domain/action/reset/reset_action_test.go | 89 + .../get_security_cards_marketplace_action.go | 64 +- .../diagnostic_session_data.go | 10 +- .../emergency_kit/go_render/assets/fonts.go | 3 +- .../go_render/assets/localizable.go | 24 +- .../go_render/assets/localizable_test.go | 15 +- .../go_render/assets/measures.go | 7 +- .../components/advanced/advanced_component.go | 47 +- .../components/advanced/descriptors_render.go | 18 +- .../go_render/components/header_component.go | 30 +- .../go_render/components/help_component.go | 43 +- .../components/instructions_component.go | 85 +- .../components/keys/key_box_component.go | 19 +- .../components/keys/keys_component.go | 44 +- .../components/keys/keys_header_component.go | 62 +- .../model/emergency_kit/go_render/render.go | 93 +- .../encrypted_key_v3/domain_separation.go | 4 +- .../model/encrypted_key_v3/encrypted_key.go | 106 +- .../encrypted_key_v3/encrypted_key_test.go | 32 +- .../model/lightning/incoming_htlc_batch.go | 59 + .../lightning/incoming_htlc_enforcement.go | 40 + .../model/lightning/incoming_htlc_recall.go | 40 + .../domain/model/lightning/transaction.go | 41 + .../security_card/fresh_pair_challenge.go | 11 + .../security_card/pending_pair_challenge.go | 19 + .../security_card/security_card_metadata.go | 18 + .../security_card/security_card_paired.go | 12 + .../security_card_sign_challenge.go | 14 + .../security_card_sign_challenge_v3.go | 13 + .../SecurityCardsMarketplace.go | 141 +- .../verifiable_muun_key.go | 72 +- .../verifiable_muun_key_test.go | 11 +- libwallet/domain/nfc/apdu.go | 68 +- libwallet/domain/nfc/apdu_test.go | 107 + libwallet/domain/nfc/card_errors.go | 33 + libwallet/domain/nfc/javacard.go | 21 +- libwallet/domain/nfc/mock_javacard.go | 28 +- libwallet/domain/nfc/mock_muuncard.go | 289 -- libwallet/domain/nfc/mock_muuncard_test.go | 10 - libwallet/domain/nfc/mock_muuncard_v2.go | 12 +- libwallet/domain/nfc/mock_muuncard_v2_test.go | 37 +- libwallet/domain/nfc/muuncard.go | 311 -- .../domain/nfc/muuncard_secure_channel.go | 130 - libwallet/domain/nfc/muuncard_test.go | 147 - libwallet/domain/nfc/muuncard_v2.go | 99 +- libwallet/domain/nfc/muuncard_v2_test.go | 14 +- libwallet/domain/nfc/muuncard_v3.go | 316 ++ libwallet/domain/nfc/protocol_common.go | 50 +- libwallet/domain/nfc/protocol_common_test.go | 20 +- libwallet/domain/nfc/protocol_common_v3.go | 221 ++ .../domain/nfc/protocol_common_v3_test.go | 546 +++ libwallet/electrum/client.go | 92 +- libwallet/electrum/pool.go | 8 +- libwallet/electrum/v2/client.go | 312 ++ .../electrum/v2/electrum_integration_test.go | 566 +++ libwallet/electrum/v2/errors.go | 27 + libwallet/electrum/v2/result.go | 44 + libwallet/electrum/v2/rpc.go | 407 +++ libwallet/electrum/v2/rpc_test.go | 193 + libwallet/electrum/v2/rpc_types.go | 110 + libwallet/electrum/v2/scripthash.go | 14 + libwallet/electrum/v2/servers.go | 105 + libwallet/electrum/v2/types.go | 33 + libwallet/emergency_kit.go | 42 +- libwallet/emergency_kit_test.go | 4 +- libwallet/emergencykit/content.go | 2 +- libwallet/emergencykit/descriptors.go | 30 +- libwallet/emergencykit/descriptors_test.go | 110 +- libwallet/emergencykit/emergencykit.go | 33 +- libwallet/emergencykit/metadata.go | 46 +- libwallet/emergencykit/metadata_test.go | 4 +- libwallet/encrypt.go | 27 +- libwallet/encrypt_test.go | 8 +- libwallet/encryption/cypher.go | 126 +- libwallet/encryption/cypher_test.go | 18 +- libwallet/encryption/encodings.go | 8 +- libwallet/encryption/encodings_test.go | 10 +- libwallet/encryption/secret.go | 27 +- libwallet/errors.go | 2 +- libwallet/errors/api_errors.go | 58 +- libwallet/errors/errors.go | 11 +- libwallet/features_test.go | 28 +- libwallet/fees/fees.go | 55 +- libwallet/go.mod | 10 +- libwallet/go.sum | 4 +- libwallet/hdpath/hdpath.go | 8 +- libwallet/hdpath/hdpath_test.go | 69 +- libwallet/hdprivatekey.go | 65 +- libwallet/hdprivatekey_test.go | 65 +- libwallet/hdpublickey.go | 25 +- libwallet/hdpublickey_test.go | 2 +- libwallet/incoming_swap.go | 210 +- libwallet/incoming_swap_test.go | 261 +- libwallet/init.go | 2 + libwallet/init_test.go | 11 + .../kvmigrationlock/kv_migration_lock.go | 42 +- libwallet/internal/testutils/keys.go | 53 + .../testutils/mock_houston_service.go | 106 + .../internal/testutils/mock_key_provider.go | 54 + libwallet/internal/testutils/storage.go | 29 + .../internal/testutils/verifiable_muun_key.go | 88 + libwallet/invoice.go | 1 + libwallet/invoice_test.go | 20 +- libwallet/invoices.go | 151 +- libwallet/invoices_test.go | 38 +- libwallet/keycrypt/keycrypt.go | 33 +- libwallet/keycrypt/keycrypt_test.go | 3 +- libwallet/keycrypter.go | 6 +- libwallet/librs/librs.go | 58 +- libwallet/librs/librs_test.go | 27 +- libwallet/libwallet_init/init.go | 114 +- libwallet/linters/checksum_test.go | 86 + libwallet/linters/cmd/checksum/main.go | 86 + libwallet/linters/go.mod | 15 + libwallet/linters/go.sum | 10 + .../linters/muun_errorf_wrap/analyzer.go | 170 + .../linters/muun_errorf_wrap/analyzer_test.go | 12 + .../testdata/src/errorfwrap/errorfwrap.go | 59 + .../src/github.com/go-errors/errors/errors.go | 7 + .../testdata/src/other/other.go | 3 + .../linters/muun_model_constructor/.checksum | 1 + .../muun_model_constructor/analyzer.go | 286 ++ .../muun_model_constructor/analyzer_test.go | 16 + .../testdata/src/consumer/consumer.go | 84 + .../testdata/src/model/model.go | 109 + .../src/preconditions/preconditions.go | 7 + .../testdata/src/unrelated/unrelated.go | 7 + libwallet/linters/staleness.go | 127 + libwallet/lnurl.go | 4 +- libwallet/lnurl/lnurl.go | 49 +- libwallet/lnurl/lnurl_test.go | 277 +- libwallet/lnurl_test.go | 36 +- libwallet/log/log.go | 8 +- libwallet/musig/impl.go | 194 - libwallet/musig/musig2.go | 541 +-- libwallet/musig/musig2_2_of_2.go | 168 + libwallet/musig/musig2_2_of_2_test.go | 144 + libwallet/musig/musig2_3_of_3.go | 70 + libwallet/musig/musig2_3_of_3_test.go | 396 ++ libwallet/musig/musig2_bip32.go | 22 +- libwallet/musig/musig2_bip32_test.go | 59 +- libwallet/musig/musig2_test.go | 279 +- libwallet/musig/musig2_versions.go | 482 +++ libwallet/musig/schnorr.go | 43 + libwallet/musig/schnorr_test.go | 63 + libwallet/musig/tapscript_test.go | 9 +- libwallet/musig/tweaks.go | 26 +- libwallet/musig2v040/README.md | 2 +- libwallet/musig2v040/context.go | 26 +- libwallet/musig2v040/keys.go | 6 +- libwallet/musig2v040/nonces.go | 58 +- libwallet/musig2v040/sign.go | 12 +- .../bridge_persistence_fee_bump_functions.go | 57 +- ...dge_persistence_fee_bump_functions_test.go | 27 +- libwallet/newop/context.go | 9 +- libwallet/newop/context_test.go | 23 +- libwallet/newop/exchange_rates.go | 10 +- libwallet/newop/money.go | 11 +- libwallet/newop/money_test.go | 15 +- libwallet/newop/state.go | 126 +- libwallet/newop/state_test.go | 317 +- libwallet/newop/swaps.go | 1 + libwallet/nonces.go | 37 +- libwallet/operation/fee_bump.go | 13 +- libwallet/operation/fee_bump_test.go | 12 +- libwallet/operation/fee_window.go | 8 +- libwallet/operation/fees.go | 38 +- libwallet/operation/fees_test.go | 69 +- libwallet/operation/payment_analyzer.go | 164 +- libwallet/operation/payment_analyzer_test.go | 50 +- libwallet/partiallysignedtransaction.go | 163 +- libwallet/partiallysignedtransaction_test.go | 1244 ++++++- libwallet/platform/concurrency/memlock/doc.go | 2 + .../concurrency/memlock/named_lock.go | 23 + .../concurrency/memlock/named_locker.go | 172 + .../concurrency/memlock/named_locker_test.go | 387 ++ libwallet/platform/doc.go | 5 + .../platform/observability/slogctx/slogctx.go | 51 + .../platform/preconditions/preconditions.go | 132 + .../platform/test/testbitcoind/client.go | 315 ++ libwallet/platform/test/testbitcoind/rpc.go | 63 + libwallet/platform/test/testbitcoind/types.go | 88 + .../platform/test/testelectrum/client.go | 175 + .../test/testsuite/base_integration_suite.go | 48 + .../presentation/api/wallet_service.pb.go | 3176 ++++++++++++----- .../presentation/api/wallet_service.proto | 145 +- .../api/wallet_service_grpc.pb.go | 395 +- .../emergency_kit_generator_test.go | 16 +- libwallet/presentation/grpc_errors.go | 8 +- libwallet/presentation/interceptors.go | 116 +- .../muun_key_verification_test.go | 56 +- libwallet/presentation/wallet_server.go | 495 ++- libwallet/presentation/wallet_server_test.go | 310 +- libwallet/publickey.go | 5 +- libwallet/recoverycode/recoverycode.go | 18 +- libwallet/ripemd160_test.go | 23 +- libwallet/scanner/address_generator.go | 28 +- libwallet/scanner/btc_core_helper_test.go | 195 +- libwallet/scanner/recovery_m3_test.go | 248 ++ libwallet/scanner/recovery_test.go | 106 +- libwallet/scanner/scanner.go | 40 +- libwallet/scanner/task.go | 30 +- libwallet/segwit.go | 34 +- libwallet/service/api_objects_mapper.go | 33 +- libwallet/service/client.go | 45 +- libwallet/service/houston.go | 75 +- libwallet/service/houston_test.go | 18 +- libwallet/service/mock_houston.go | 810 ++++- libwallet/service/mock_houston_dev_key.go | 48 + libwallet/service/model/action_descriptor.go | 63 + .../service/model/action_descriptor_test.go | 47 + .../challenge_security_card_pair_json.go | 5 - .../challenge_security_card_sign_json.go | 2 +- ...llenge_security_card_sign_response_json.go | 2 +- .../service/model/challenge_setup_json.go | 2 +- .../model/challenge_setup_verify_json.go | 2 +- libwallet/service/model/client_json.go | 2 +- .../model/create_first_session_json.go | 2 +- .../model/create_first_session_ok_json.go | 2 +- .../model/diagnostic_scan_data_json.go | 4 +- libwallet/service/model/fee_window_json.go | 4 +- .../pair_request_challenge_response_json.go | 8 + .../pair_submit_signed_challenge_json.go | 9 + ...r_submit_signed_challenge_response_json.go | 17 + libwallet/service/model/public_key_json.go | 2 +- .../model/register_security_card_json.go | 2 +- .../model/register_security_card_ok_json.go | 2 +- libwallet/service/model/security_card.go | 9 +- .../service/model/security_card_metadata.go | 2 +- .../model/security_card_metadata_v3.go | 20 + .../model/security_cards_marketplace_json.go | 15 +- .../service/model/security_cards_provider.go | 38 +- .../model/setup_challenge_response_json.go | 2 +- .../model/sign_request_challenge_json.go | 10 + .../sign_request_challenge_response_json.go | 17 + .../sign_submit_signed_challenge_json.go | 10 + .../solve_security_card_challenge_json.go | 2 +- .../service/model/verifiable_muun_key_json.go | 2 +- libwallet/service/model_objects_mapper.go | 143 +- libwallet/service/nfc_integration_test.go | 119 +- libwallet/sphinx/sphinx.go | 25 +- libwallet/storage/kv_migration_functions.go | 74 +- libwallet/storage/kv_migrations.go | 80 +- libwallet/storage/kv_migrations_lock_test.go | 12 +- libwallet/storage/kv_migrator.go | 69 +- libwallet/storage/kv_migrator_test.go | 281 +- libwallet/storage/schema.go | 32 +- libwallet/storage/storage.go | 127 +- libwallet/storage/storage_test.go | 111 +- libwallet/storage/testdata/kv_migrations.lock | 23 + libwallet/submarineSwap.go | 9 +- libwallet/submarineSwapV1.go | 15 +- libwallet/submarineSwapV2.go | 13 +- libwallet/swaps/swaps.go | 22 +- libwallet/swaps/v1.go | 73 +- libwallet/swaps/v1_test.go | 29 +- libwallet/swaps/v2.go | 100 +- libwallet/swaps/v2_test.go | 54 +- libwallet/walletdb/fee_bump_repository.go | 134 +- .../walletdb/fee_bump_repository_test.go | 30 +- libwallet/walletdb/key_value_repository.go | 223 +- .../walletdb/key_value_repository_test.go | 2 +- .../walletdb/kv_schema_state_repository.go | 54 +- libwallet/walletdb/pool.go | 97 + libwallet/walletdb/pool_test.go | 43 + libwallet/walletdb/walletdb.go | 68 +- libwallet/walletdb/walletdb_test.go | 20 +- 680 files changed, 32978 insertions(+), 8640 deletions(-) create mode 100644 android/apolloui/src/dogfood/java/io/muun/apollo/presentation/app/di/modules/InAppUpdateModule.kt create mode 100644 android/apolloui/src/dogfood/java/io/muun/apollo/presentation/ui/home/PlayInAppUpdateManager.kt create mode 100644 android/apolloui/src/local/java/io/muun/apollo/presentation/app/di/modules/InAppUpdateModule.kt create mode 100644 android/apolloui/src/main/java/io/muun/apollo/data/afs/ContextSwapDrmFetcher.kt create mode 100644 android/apolloui/src/main/java/io/muun/apollo/data/afs/NfcExtendedApduProbe.kt create mode 100644 android/apolloui/src/main/java/io/muun/apollo/data/afs/RuntimeInfoProvider.kt create mode 100644 android/apolloui/src/main/java/io/muun/apollo/data/libwallet/LibwalletSecureKeyValueStorage.kt create mode 100644 android/apolloui/src/main/java/io/muun/apollo/data/nfc/NfcEmpiricalCache.kt create mode 100644 android/apolloui/src/main/java/io/muun/apollo/data/secure_key_value_storage/SecureKeyValueStorageRepository.kt create mode 100644 android/apolloui/src/main/java/io/muun/apollo/domain/action/debug/BuildDebugDataEmailReportAction.kt create mode 100644 android/apolloui/src/main/java/io/muun/apollo/domain/action/debug/BuildErrorEmailReportAction.kt create mode 100644 android/apolloui/src/main/java/io/muun/apollo/domain/errors/ErrorClassification.kt create mode 100644 android/apolloui/src/main/java/io/muun/apollo/domain/errors/UnclassifiedError.kt create mode 100644 android/apolloui/src/main/java/io/muun/apollo/domain/errors/newop/InvalidOperationUriException.kt create mode 100644 android/apolloui/src/main/java/io/muun/apollo/domain/errors/nfc/NoFeasibleZoneForModelError.kt create mode 100644 android/apolloui/src/main/java/io/muun/apollo/domain/secure_key_value_storage/Secret.kt create mode 100644 android/apolloui/src/main/java/io/muun/apollo/presentation/ui/debug/securekv/DebugSecureKeyValueStorageActivity.kt create mode 100644 android/apolloui/src/main/java/io/muun/apollo/presentation/ui/home/InAppUpdateManager.kt create mode 100644 android/apolloui/src/main/java/io/muun/apollo/presentation/ui/home/NoOpInAppUpdateManager.kt delete mode 100644 android/apolloui/src/main/java/io/muun/apollo/presentation/ui/new_operation/InvalidOperationUriException.kt create mode 100644 android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards/utils/WebsitePill.kt create mode 100644 android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_full_specs/SecurityCardsFullSpecsActivity.kt create mode 100644 android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_full_specs/SecurityCardsFullSpecsAdapter.kt create mode 100644 android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_full_specs/SecurityCardsFullSpecsViewModel.kt create mode 100644 android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_full_specs/SecurityCardsFullSpecsViewModelFactory.kt create mode 100644 android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_full_specs/SpecAdditionalInfoBottomSheetDialogFragment.kt create mode 100644 android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_shipping_address/ConnectedToWebsiteDialogFragment.kt create mode 100644 android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_shipping_address/ConnectedToWebsiteView.kt create mode 100644 android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_shipping_address/HeroAnimation.kt create mode 100644 android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_shipping_address/ShippingAddressActivity.kt create mode 100644 android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_shipping_address/ShippingAddressViewModel.kt create mode 100644 android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_shipping_address/ShippingAddressViewModelFactory.kt create mode 100644 android/apolloui/src/main/java/io/muun/apollo/presentation/ui/utils/EditTextExtensions.kt create mode 100644 android/apolloui/src/main/res/drawable/bg_security_card_spec_table.xml create mode 100644 android/apolloui/src/main/res/drawable/connected_with_external_website.xml create mode 100644 android/apolloui/src/main/res/drawable/ic_access_time_24px.xml create mode 100644 android/apolloui/src/main/res/drawable/ic_arrowhead_left.xml create mode 100644 android/apolloui/src/main/res/drawable/ic_arrowhead_up.xml create mode 100644 android/apolloui/src/main/res/drawable/ic_aspect_ratio_24px.xml create mode 100644 android/apolloui/src/main/res/drawable/ic_code_24px.xml create mode 100644 android/apolloui/src/main/res/drawable/ic_delete_outline_24px.xml create mode 100644 android/apolloui/src/main/res/drawable/ic_fitness_center_24px.xml create mode 100644 android/apolloui/src/main/res/drawable/ic_folder_open_24px.xml create mode 100644 android/apolloui/src/main/res/drawable/ic_hardware_security_24px.xml create mode 100644 android/apolloui/src/main/res/drawable/ic_launch_24px.xml create mode 100644 android/apolloui/src/main/res/drawable/ic_local_mall_24px.xml create mode 100644 android/apolloui/src/main/res/drawable/ic_local_shipping_24px.xml create mode 100644 android/apolloui/src/main/res/drawable/ic_misc_trezor_24px.xml create mode 100644 android/apolloui/src/main/res/drawable/ic_place_24px.xml create mode 100644 android/apolloui/src/main/res/drawable/ic_style_24px.xml create mode 100644 android/apolloui/src/main/res/layout/activity_debug_secure_key_value_storage.xml create mode 100644 android/apolloui/src/main/res/layout/activity_security_cards_full_specs.xml create mode 100644 android/apolloui/src/main/res/layout/activity_shipping_address.xml create mode 100644 android/apolloui/src/main/res/layout/bottom_sheet_spec_additional_info.xml create mode 100644 android/apolloui/src/main/res/layout/fragment_connected_to_website_dialog.xml create mode 100644 android/apolloui/src/main/res/layout/item_security_card_spec_card_image.xml create mode 100644 android/apolloui/src/main/res/layout/item_security_card_spec_header.xml create mode 100644 android/apolloui/src/main/res/layout/item_security_card_spec_row.xml create mode 100644 android/apolloui/src/main/res/layout/view_connected_to_website.xml create mode 100644 android/apolloui/src/main/res/values-night/new_colors.xml create mode 100644 android/apolloui/src/main/res/values/ids.xml create mode 100644 android/apolloui/src/main/res/values/new_colors.xml create mode 100644 android/apolloui/src/prod/java/io/muun/apollo/presentation/app/di/modules/InAppUpdateModule.kt create mode 100644 android/apolloui/src/regtest/java/io/muun/apollo/presentation/app/di/modules/InAppUpdateModule.kt create mode 100644 android/apolloui/src/test/java/io/muun/apollo/data/libwallet/LibwalletSecureKeyValueStorageTest.kt create mode 100644 android/apolloui/src/test/java/io/muun/apollo/data/preferences/KeysRepositoryTest.kt create mode 100644 android/apolloui/src/test/java/io/muun/apollo/domain/secure_key_value_storage/SecretTest.kt delete mode 100644 android/apolloui/src/test/java/io/muun/apollo/domain/utils/FeeWindowTest.kt create mode 100644 android/apolloui/src/test/java/io/muun/apollo/presentation/ui/security_cards_shipping_address/AnimationNodeTest.kt create mode 100644 common/src/main/java/io/muun/package-info.java create mode 100644 libwallet/CLAUDE.md create mode 100644 libwallet/V7.go create mode 100644 libwallet/V8.go create mode 100644 libwallet/V9.go create mode 100644 libwallet/addresses/v7.go create mode 100644 libwallet/addresses/v7_test.go create mode 100644 libwallet/addresses/v8.go create mode 100644 libwallet/addresses/v8_test.go create mode 100644 libwallet/addresses/v9.go create mode 100644 libwallet/addresses/v9_test.go create mode 100644 libwallet/app_provided_data/secure_key_value_storage.go create mode 100644 libwallet/data/lightning/incoming_htlc_batch_repository.go create mode 100644 libwallet/data/securekv/errors.go create mode 100644 libwallet/data/securekv/secret.go create mode 100644 libwallet/data/securekv/storage.go create mode 100644 libwallet/data/securekv/storage_test.go create mode 100644 libwallet/data/security_cards/protocol_repository.go create mode 100644 libwallet/domain/action/challenge_keys/finish_challenge_setup_action_test.go create mode 100644 libwallet/domain/action/debug/zip_data_dir_action.go create mode 100644 libwallet/domain/action/debug/zip_data_dir_action_test.go create mode 100644 libwallet/domain/action/lightning/chain_status.go create mode 100644 libwallet/domain/action/lightning/complete_incoming_htlc_batch.go create mode 100644 libwallet/domain/action/lightning/cross_cutting_test.go create mode 100644 libwallet/domain/action/lightning/errors.go create mode 100644 libwallet/domain/action/lightning/expiration_branch_test.go create mode 100644 libwallet/domain/action/lightning/external_spend_test.go create mode 100644 libwallet/domain/action/lightning/external_tx_checker.go create mode 100644 libwallet/domain/action/lightning/publish_enforcer.go create mode 100644 libwallet/domain/action/lightning/recall_branch_test.go create mode 100644 libwallet/domain/action/lightning/register_incoming_htlc_batch.go create mode 100644 libwallet/domain/action/lightning/reveal_preimages.go create mode 100644 libwallet/domain/action/lightning/suite_test.go create mode 100644 libwallet/domain/action/nfc/pair_load_persisted_challenge_action.go create mode 100644 libwallet/domain/action/nfc/pair_request_challenge_action.go delete mode 100644 libwallet/domain/action/nfc/pair_security_card_action.go create mode 100644 libwallet/domain/action/nfc/pair_sign_and_submit_challenge_action.go create mode 100644 libwallet/domain/action/nfc/pair_sign_challenge_action.go create mode 100644 libwallet/domain/action/nfc/pair_submit_solved_challenge_action.go delete mode 100644 libwallet/domain/action/nfc/reset_security_card_action.go delete mode 100644 libwallet/domain/action/nfc/sign_message_security_card_action.go create mode 100644 libwallet/domain/action/nfc/signed_pair_challenge.go create mode 100644 libwallet/domain/action/recovery/compute_and_store_encrypted_muun_key_action_test.go create mode 100644 libwallet/domain/action/recovery/populate_encrypted_muun_key_action_test.go create mode 100644 libwallet/domain/action/reset/reset_action.go create mode 100644 libwallet/domain/action/reset/reset_action_test.go create mode 100644 libwallet/domain/model/lightning/incoming_htlc_batch.go create mode 100644 libwallet/domain/model/lightning/incoming_htlc_enforcement.go create mode 100644 libwallet/domain/model/lightning/incoming_htlc_recall.go create mode 100644 libwallet/domain/model/lightning/transaction.go create mode 100644 libwallet/domain/model/security_card/fresh_pair_challenge.go create mode 100644 libwallet/domain/model/security_card/pending_pair_challenge.go create mode 100644 libwallet/domain/model/security_card/security_card_sign_challenge_v3.go create mode 100644 libwallet/domain/nfc/apdu_test.go create mode 100644 libwallet/domain/nfc/card_errors.go delete mode 100644 libwallet/domain/nfc/mock_muuncard.go delete mode 100644 libwallet/domain/nfc/mock_muuncard_test.go delete mode 100644 libwallet/domain/nfc/muuncard.go delete mode 100644 libwallet/domain/nfc/muuncard_secure_channel.go delete mode 100644 libwallet/domain/nfc/muuncard_test.go create mode 100644 libwallet/domain/nfc/muuncard_v3.go create mode 100644 libwallet/domain/nfc/protocol_common_v3.go create mode 100644 libwallet/domain/nfc/protocol_common_v3_test.go create mode 100644 libwallet/electrum/v2/client.go create mode 100644 libwallet/electrum/v2/electrum_integration_test.go create mode 100644 libwallet/electrum/v2/errors.go create mode 100644 libwallet/electrum/v2/result.go create mode 100644 libwallet/electrum/v2/rpc.go create mode 100644 libwallet/electrum/v2/rpc_test.go create mode 100644 libwallet/electrum/v2/rpc_types.go create mode 100644 libwallet/electrum/v2/scripthash.go create mode 100644 libwallet/electrum/v2/servers.go create mode 100644 libwallet/electrum/v2/types.go create mode 100644 libwallet/internal/testutils/keys.go create mode 100644 libwallet/internal/testutils/mock_houston_service.go create mode 100644 libwallet/internal/testutils/mock_key_provider.go create mode 100644 libwallet/internal/testutils/storage.go create mode 100644 libwallet/internal/testutils/verifiable_muun_key.go create mode 100644 libwallet/linters/checksum_test.go create mode 100644 libwallet/linters/cmd/checksum/main.go create mode 100644 libwallet/linters/go.mod create mode 100644 libwallet/linters/go.sum create mode 100644 libwallet/linters/muun_errorf_wrap/analyzer.go create mode 100644 libwallet/linters/muun_errorf_wrap/analyzer_test.go create mode 100644 libwallet/linters/muun_errorf_wrap/testdata/src/errorfwrap/errorfwrap.go create mode 100644 libwallet/linters/muun_errorf_wrap/testdata/src/github.com/go-errors/errors/errors.go create mode 100644 libwallet/linters/muun_errorf_wrap/testdata/src/other/other.go create mode 100644 libwallet/linters/muun_model_constructor/.checksum create mode 100644 libwallet/linters/muun_model_constructor/analyzer.go create mode 100644 libwallet/linters/muun_model_constructor/analyzer_test.go create mode 100644 libwallet/linters/muun_model_constructor/testdata/src/consumer/consumer.go create mode 100644 libwallet/linters/muun_model_constructor/testdata/src/model/model.go create mode 100644 libwallet/linters/muun_model_constructor/testdata/src/preconditions/preconditions.go create mode 100644 libwallet/linters/muun_model_constructor/testdata/src/unrelated/unrelated.go create mode 100644 libwallet/linters/staleness.go delete mode 100644 libwallet/musig/impl.go create mode 100644 libwallet/musig/musig2_2_of_2.go create mode 100644 libwallet/musig/musig2_2_of_2_test.go create mode 100644 libwallet/musig/musig2_3_of_3.go create mode 100644 libwallet/musig/musig2_3_of_3_test.go create mode 100644 libwallet/musig/musig2_versions.go create mode 100644 libwallet/musig/schnorr.go create mode 100644 libwallet/musig/schnorr_test.go create mode 100644 libwallet/platform/concurrency/memlock/doc.go create mode 100644 libwallet/platform/concurrency/memlock/named_lock.go create mode 100644 libwallet/platform/concurrency/memlock/named_locker.go create mode 100644 libwallet/platform/concurrency/memlock/named_locker_test.go create mode 100644 libwallet/platform/doc.go create mode 100644 libwallet/platform/observability/slogctx/slogctx.go create mode 100644 libwallet/platform/preconditions/preconditions.go create mode 100644 libwallet/platform/test/testbitcoind/client.go create mode 100644 libwallet/platform/test/testbitcoind/rpc.go create mode 100644 libwallet/platform/test/testbitcoind/types.go create mode 100644 libwallet/platform/test/testelectrum/client.go create mode 100644 libwallet/platform/test/testsuite/base_integration_suite.go create mode 100644 libwallet/scanner/recovery_m3_test.go create mode 100644 libwallet/service/mock_houston_dev_key.go create mode 100644 libwallet/service/model/action_descriptor.go create mode 100644 libwallet/service/model/action_descriptor_test.go delete mode 100644 libwallet/service/model/challenge_security_card_pair_json.go create mode 100644 libwallet/service/model/pair_request_challenge_response_json.go create mode 100644 libwallet/service/model/pair_submit_signed_challenge_json.go create mode 100644 libwallet/service/model/pair_submit_signed_challenge_response_json.go create mode 100644 libwallet/service/model/security_card_metadata_v3.go create mode 100644 libwallet/service/model/sign_request_challenge_json.go create mode 100644 libwallet/service/model/sign_request_challenge_response_json.go create mode 100644 libwallet/service/model/sign_submit_signed_challenge_json.go create mode 100644 libwallet/walletdb/pool.go create mode 100644 libwallet/walletdb/pool_test.go diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index 58761db1..94145fd9 100644 --- a/android/CHANGELOG.md +++ b/android/CHANGELOG.md @@ -6,6 +6,14 @@ follow [https://changelog.md/](https://changelog.md/) guidelines. ## [Unreleased] +## [55.9] - 2026-07-31 + +### FIXED + +- Lightning payment preimage not displaying correctly +- Crash when opening navigation tabs with large transaction data +- Various crash fixes + ## [55.8] - 2026-04-15 ### FIXED diff --git a/android/apolloui/build.gradle b/android/apolloui/build.gradle index 08065a40..8ddae439 100644 --- a/android/apolloui/build.gradle +++ b/android/apolloui/build.gradle @@ -104,8 +104,8 @@ android { applicationId "io.muun.apollo" minSdk 21 targetSdk 35 - versionCode 1508 - versionName "55.8" + versionCode 1509 + versionName "55.9" // Use default Proguard file, bundled with Android Gradle Plugin // See: https://issuetracker.google.com/issues/126772206 @@ -344,6 +344,14 @@ android { lint { abortOnError true + warningsAsErrors true + // Fires only on a third-party lint.jar (androidx.lifecycle) built against a newer Lint API + // than our AGP runs, never our own code, so it's not actionable. Removable on AGP >= 8.8.0 + // (Lint API >= 16): lifecycle 2.9.0 needs API 16, our AGP 8.6.1 runs API 14. + disable 'ObsoleteLintCustomCheck' + // Fires only on a third-party jar (google-http-client), never our own code, so it's not + // actionable; warningsAsErrors would otherwise turn this tolerated warning into a fatal error. + disable 'TrustAllX509TrustManager' htmlReport true textReport true lintConfig file("$rootDir/linters/android-lint/config.xml") @@ -397,7 +405,7 @@ ext { version_workmanager = '2.9.0' // Next version upgrade requires bump minSdk to 21 version_butterknife = '10.1.0' version_mockk = '1.13.7' // Latest version targeting kotlin 1.8.20 - version_lifecycle = '2.6.2' // Upcoming versions require AGP 8.3.1 + version_lifecycle = '2.9.0' version_grpc = "1.57.2" // This is ancient, but we're stuck until we bump minSdk to 21 } @@ -460,6 +468,9 @@ dependencies { // Google Play Integrity implementation 'com.google.android.play:integrity:1.1.0' + // Google Play In-App Updates (dogfood only) + dogfoodImplementation 'com.google.android.play:app-update-ktx:2.1.0' + // dev tools debugImplementation 'com.github.markzhai:blockcanary-android:1.5.0' minifiedImplementation 'com.github.markzhai:blockcanary-no-op:1.5.0' @@ -502,6 +513,7 @@ dependencies { implementation "androidx.lifecycle:lifecycle-runtime:$version_lifecycle" // ProcessLifecycleOwner provides a lifecycle for the whole application process implementation "androidx.lifecycle:lifecycle-process:$version_lifecycle" + implementation "androidx.lifecycle:lifecycle-viewmodel-savedstate:$version_lifecycle" // for WebView implementation 'androidx.webkit:webkit:1.1.0' @@ -607,8 +619,10 @@ androidComponents { def baseVersionCode = android.defaultConfig.versionCode as int def buildVersionSuffix = project.hasProperty('buildSuffix') ? project.buildSuffix : "000" - println "baseVersionCode: $baseVersionCode" - println "buildVersionSuffix: $buildVersionSuffix" + if (System.getenv("CI")) { + println "baseVersionCode: $baseVersionCode" + println "buildVersionSuffix: $buildVersionSuffix" + } if (!buildVersionSuffix.matches("\\d{3}")) { throw new GradleException("buildSuffix must be a number with 3 digits: '$buildVersionSuffix'") @@ -631,7 +645,9 @@ androidComponents { abiIdentifier = abiFilter.identifier } } - println "abiCode: $abiCode" + if (System.getenv("CI")) { + println "abiCode: $abiCode" + } def finalVersionCode = (baseVersionCode.toString() + buildVersionSuffix + abiCode.toString()) as int output.versionCode.set(finalVersionCode) diff --git a/android/apolloui/lint-baseline.xml b/android/apolloui/lint-baseline.xml index 1107e309..a2335477 100644 --- a/android/apolloui/lint-baseline.xml +++ b/android/apolloui/lint-baseline.xml @@ -1,5 +1,16 @@ - + + + + + @@ -30,7 +41,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -41,7 +52,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -56,28 +67,6 @@ column="18"/> - - - - - - - - + + + + + + + + @@ -261,7 +272,7 @@ errorLine2=" ~~~~~~~~"> @@ -364,61 +375,6 @@ column="14"/> - - - - - - - - - - - - - - - - - - - - @@ -437,7 +393,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -448,7 +404,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -459,7 +415,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -487,73 +443,18 @@ - - - - - - - - - - - - - - - - - - - - + column="13"/> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -1240,7 +844,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1251,7 +855,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1262,7 +866,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~"> @@ -1273,7 +877,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1501,64 +1105,9 @@ id="NonConstantResourceId" message="Resource IDs will be non-final by default in Android Gradle Plugin version 8.0, avoid using them as annotation attributes" errorLine1=" @BindView(R.id.secondary_button)" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> - - - - - - - - - - - - - - - - - - - - + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -1566,67 +1115,67 @@ + errorLine1=" @BindView(R.id.expiration_time_label)" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" @BindView(R.id.expiration_time_value)" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" @BindView(R.id.expiration_time_loading)" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" @BindView(R.id.image)" + errorLine2=" ~~~~~~~~~~"> + errorLine1=" @BindView(R.id.title)" + errorLine2=" ~~~~~~~~~~"> + errorLine1=" @BindView(R.id.body)" + errorLine2=" ~~~~~~~~~"> + file="src/main/java/io/muun/apollo/presentation/ui/fragments/explanation_block/ExplanationPageFragment.kt" + line="40" + column="15"/> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -2065,7 +1471,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -2076,7 +1482,7 @@ errorLine2=" ~~~~~~~~~~~~~~~"> @@ -2087,7 +1493,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -2098,7 +1504,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -2109,7 +1515,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -2120,7 +1526,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -2131,7 +1537,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -2142,7 +1548,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -2153,7 +1559,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -2223,83 +1629,6 @@ column="15"/> - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -2571,7 +1900,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -2582,7 +1911,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -2769,7 +2098,18 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> + + + + @@ -2780,7 +2120,7 @@ errorLine2=" ~~~~~~~~~~~~"> @@ -2791,7 +2131,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -2883,138 +2223,6 @@ column="15"/> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -3143,7 +2351,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -3154,7 +2362,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -3286,7 +2494,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -3297,7 +2505,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -3308,7 +2516,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -3319,7 +2527,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -3330,7 +2538,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -3341,7 +2549,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -3352,7 +2560,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -3363,7 +2571,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -3374,7 +2582,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -3385,7 +2593,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -3396,7 +2604,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -3407,7 +2615,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -3418,7 +2626,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -3429,7 +2637,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -3440,7 +2648,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -3451,7 +2659,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -3462,7 +2670,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -3473,7 +2681,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -3484,7 +2692,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -3620,28 +2828,6 @@ column="16"/> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + errorLine1=" @BindView(R.id.empty_screen)" + errorLine2=" ~~~~~~~~~~~~~~~~~"> + errorLine1=" @BindView(R.id.scan_qr_header)" + errorLine2=" ~~~~~~~~~~~~~~~~~~~"> + errorLine1=" @BindView(R.id.scan_qr_scanner)" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" @BindView(R.id.scan_qr_subtitle)" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" @BindView(R.id.scan_qr_frame_background)" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" @BindView(R.id.uri_paster)" + errorLine2=" ~~~~~~~~~~~~~~~"> + errorLine1=" @BindView(R.id.paste_from_clipboard)" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -4331,7 +3429,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -4342,7 +3440,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~"> @@ -4353,7 +3451,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -4364,7 +3462,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -4375,7 +3473,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -4386,7 +3484,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~"> @@ -4397,7 +3495,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -4408,7 +3506,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -4419,7 +3517,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -4430,7 +3528,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~"> @@ -4441,7 +3539,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -4452,7 +3550,62 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~"> + + + + + + + + + + + + + + + + + + + + @@ -4463,51 +3616,51 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" @BindView(R.id.security_section)" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" @BindView(R.id.settings_logout)" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" @BindView(R.id.settings_delete_wallet)" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -5265,218 +4418,64 @@ errorLine1=" @BindView(R.id.accept)" errorLine2=" ~~~~~~~~~~~"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + file="src/main/java/io/muun/apollo/presentation/ui/recovery_code/verify/VerifyRecoveryCodeFragment.java" + line="23" + column="15"/> + id="NonConstantResourceId" + message="Resource IDs will be non-final by default in Android Gradle Plugin version 8.0, avoid using them in switch case statements" + errorLine1=" case R.layout.v_item_currency:" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/java/io/muun/apollo/presentation/ui/adapter/holder/ViewHolderFactory.java" + line="40" + column="18"/> + id="NonConstantResourceId" + message="Resource IDs will be non-final by default in Android Gradle Plugin version 8.0, avoid using them in switch case statements" + errorLine1=" case R.layout.v_item_section_header:" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/java/io/muun/apollo/presentation/ui/adapter/holder/ViewHolderFactory.java" + line="43" + column="18"/> + id="NonConstantResourceId" + message="Resource IDs will be non-final by default in Android Gradle Plugin version 8.0, avoid using them in switch case statements" + errorLine1=" case R.layout.home_contacts_item:" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/java/io/muun/apollo/presentation/ui/adapter/holder/ViewHolderFactory.java" + line="46" + column="18"/> + id="NonConstantResourceId" + message="Resource IDs will be non-final by default in Android Gradle Plugin version 8.0, avoid using them in switch case statements" + errorLine1=" case R.layout.home_operations_item:" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/java/io/muun/apollo/presentation/ui/adapter/holder/ViewHolderFactory.java" + line="49" + column="18"/> + id="NonConstantResourceId" + message="Resource IDs will be non-final by default in Android Gradle Plugin version 8.0, avoid using them in switch case statements" + errorLine1=" case R.layout.item_feature_flag:" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/java/io/muun/apollo/presentation/ui/adapter/holder/ViewHolderFactory.java" + line="52" + column="18"/> @@ -5497,7 +4496,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~"> @@ -5655,6 +4654,17 @@ column="13"/> + + + + @@ -5684,30 +4694,19 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" Timber.i("ApplicationLockExtension: onUnlockUsingBiometrics. " + this);" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> - - - - + file="src/main/java/io/muun/apollo/presentation/ui/activity/extension/ApplicationLockExtension.java" + line="235" + column="22"/> @@ -5827,7 +4826,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -5919,28 +4918,6 @@ column="18"/> - - - - - - - - @@ -5992,7 +4969,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -6003,11 +4980,11 @@ errorLine2=" ^"> @@ -6062,25 +5039,11 @@ errorLine1=" Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID)" errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> - - - - - - - - + + + + @@ -6110,7 +5084,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~"> @@ -6121,7 +5095,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~"> @@ -6132,7 +5106,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~"> @@ -6169,6 +5143,17 @@ column="56"/> + + + + + + + + + + + + + + + + + + + + @@ -6290,6 +5303,204 @@ column="2"/> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -6540,9 +5828,35 @@ + message="The resource `R.drawable.ic_delete_outline_24px` appears to be unused" + errorLine1="<vector xmlns:android="http://schemas.android.com/apk/res/android"" + errorLine2="^"> + + + + + + + + + file="src/main/res/drawable/ic_launch_24px.xml" + line="1" + column="1"/> + + + + @@ -6696,17 +6021,6 @@ column="13"/> - - - - @@ -6725,7 +6039,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~"> @@ -6736,7 +6050,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -6747,7 +6061,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -6758,7 +6072,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -6769,7 +6083,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -6780,7 +6094,29 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + + + + + + + + @@ -6791,7 +6127,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -6802,7 +6138,18 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~"> + + + + @@ -6813,10 +6160,65 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + + + + + + + + + + + + + + + + + + + + @@ -6919,7 +6321,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -6930,7 +6332,7 @@ errorLine2=" ~~~~~~~~~~"> @@ -6941,7 +6343,7 @@ errorLine2=" ~~~~~~~~~~~"> @@ -6952,7 +6354,7 @@ errorLine2=" ~~~"> @@ -6963,7 +6365,7 @@ errorLine2=" ~~~"> @@ -7075,6 +6477,111 @@ file="src/main/res/drawable/qr_code.webp"/> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -7112,14 +6619,25 @@ file="src/main/res/drawable/ic_settings.webp"/> + + + + @@ -7229,7 +6747,7 @@ errorLine2=" ~~~~~~~~~"> @@ -7343,6 +6861,17 @@ column="10"/> + + + + diff --git a/android/apolloui/proguard/proguard-apollo.pro b/android/apolloui/proguard/proguard-apollo.pro index 3a54ff49..76c0bbbe 100644 --- a/android/apolloui/proguard/proguard-apollo.pro +++ b/android/apolloui/proguard/proguard-apollo.pro @@ -86,4 +86,11 @@ # Required for our grpc-protobuf impl -keepclassmembers class * extends com.google.protobuf.GeneratedMessageLite { ; -} \ No newline at end of file +} + +# Keep class names used by the extraStackElements signal to detect unexpected +# stack trace entries. +-keepnames class rx.** +-keepnames class io.muun.apollo.data.afs.RuntimeInfoProvider +-keepnames class io.muun.apollo.data.net.base.interceptor.BackgroundExecutionMetricsInterceptor +-keepnames class io.muun.apollo.data.net.base.BaseInterceptor \ No newline at end of file diff --git a/android/apolloui/src/androidTest/java/io/muun/apollo/data/afs/MetricsProviderTimingTest.kt b/android/apolloui/src/androidTest/java/io/muun/apollo/data/afs/MetricsProviderTimingTest.kt index 19b654eb..4fe707f7 100644 --- a/android/apolloui/src/androidTest/java/io/muun/apollo/data/afs/MetricsProviderTimingTest.kt +++ b/android/apolloui/src/androidTest/java/io/muun/apollo/data/afs/MetricsProviderTimingTest.kt @@ -3,7 +3,6 @@ package io.muun.apollo.data.afs import android.util.Log import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry -import io.muun.apollo.domain.action.session.IsRootedDeviceAction import io.muun.apollo.presentation.app.ApolloApplication import org.junit.Before import org.junit.Test @@ -17,14 +16,12 @@ class MetricsProviderTimingTest { } private lateinit var metricsProvider: MetricsProvider - private lateinit var isRootedDeviceAction: IsRootedDeviceAction @Before fun setUp() { val context = InstrumentationRegistry.getInstrumentation().targetContext val app = context.applicationContext as ApolloApplication metricsProvider = app.dataComponent.metricsProvider() - isRootedDeviceAction = IsRootedDeviceAction(context) } private fun measure( @@ -82,6 +79,14 @@ class MetricsProviderTimingTest { ) { metricsProvider.totalExternalStorageInBytes } measure("totalRamInBytes", timingsInMicroSeconds) { metricsProvider.totalRamInBytes } measure("initOffset", timingsInMicroSeconds) { metricsProvider.bootOffset } + measure( + "widevineSecurityLevel", + timingsInMicroSeconds + ) { metricsProvider.widevineSecurityLevel } + measure( + "widevineMajorVersion", + timingsInMicroSeconds + ) { metricsProvider.widevineMajorVersion } // PackageManagerInfoProvider measure( @@ -198,6 +203,44 @@ class MetricsProviderTimingTest { "isDeviceFoldable", timingsInMicroSeconds ) { metricsProvider.isDeviceFoldable } + measure( + "hasNfcHostCardEmulation", + timingsInMicroSeconds + ) { metricsProvider.hasNfcHostCardEmulation } + measure( + "hasNfcOffHostCardEmulationUicc", + timingsInMicroSeconds + ) { metricsProvider.hasNfcOffHostCardEmulationUicc } + measure( + "hasNfcOffHostCardEmulationEse", + timingsInMicroSeconds + ) { metricsProvider.hasNfcOffHostCardEmulationEse } + measure( + "nfcExtendedApduSupportedEmpirical", + timingsInMicroSeconds + ) { metricsProvider.nfcExtendedApduSupportedEmpirical } + measure( + "nfcMaxTransceiveLengthEmpirical", + timingsInMicroSeconds + ) { metricsProvider.nfcMaxTransceiveLengthEmpirical } + measure( + "nfcConfigFilesPresent", + timingsInMicroSeconds + ) { metricsProvider.nfcConfigFilesPresent } + measure("nfcChipIdentifier", timingsInMicroSeconds) { metricsProvider.nfcChipIdentifier } + measure("nfcConfigFileHash", timingsInMicroSeconds) { metricsProvider.nfcConfigFileHash } + measure( + "nfcExtendedApduSupportedReflected", + timingsInMicroSeconds + ) { metricsProvider.nfcExtendedApduSupportedReflected } + measure( + "nfcMaxTransceiveLengthReflected", + timingsInMicroSeconds + ) { metricsProvider.nfcMaxTransceiveLengthReflected } + measure( + "nfcReflectionFailureReason", + timingsInMicroSeconds + ) { metricsProvider.nfcReflectionFailureReason } // BatteryInfoProvider measure("batteryLevel", timingsInMicroSeconds) { metricsProvider.batteryLevel } @@ -217,7 +260,42 @@ class MetricsProviderTimingTest { measure("elapsedRealtime", timingsInMicroSeconds) { metricsProvider.elapsedRealtime } // RootHint - measure("RootHint", timingsInMicroSeconds) { isRootedDeviceAction.isRooted() } + measure("rootHint", timingsInMicroSeconds) { metricsProvider.isRootHint } + + // RuntimeInfoProvider + measure( + "extraStackElements", timingsInMicroSeconds + ) { metricsProvider.extraStackElements } + measure( + "uidSharedStatus", timingsInMicroSeconds + ) { metricsProvider.uidSharedStatus } + measure( + "runtimeExternalPackages", timingsInMicroSeconds + ) { metricsProvider.runtimeExternalPackages } + measure( + "appOpsPackageName", timingsInMicroSeconds + ) { metricsProvider.appOpsPackageName } + measure( + "restrictiveSdkStatus", timingsInMicroSeconds + ) { metricsProvider.restrictiveSdkStatus } + measure( + "allSignatureHashes", timingsInMicroSeconds + ) { metricsProvider.allSignatureHashes } + measure( + "archiveSignatureHashes", timingsInMicroSeconds + ) { metricsProvider.archiveSignatureHashes } + measure( + "appBasePackageName", timingsInMicroSeconds + ) { metricsProvider.appBasePackageName } + measure( + "isPlainTextDrmId", timingsInMicroSeconds + ) { metricsProvider.isPlainTextDrmId } + measure( + "contextSwapDrmId", timingsInMicroSeconds + ) { metricsProvider.contextSwapDrmId } + measure( + "drmIdNativeHook", timingsInMicroSeconds + ) { metricsProvider.drmIdNativeHook } // Print summary sorted by elapsed time (slowest first) val totalUs = timingsInMicroSeconds.sumOf { it.second } diff --git a/android/apolloui/src/dogfood/java/io/muun/apollo/presentation/app/di/modules/InAppUpdateModule.kt b/android/apolloui/src/dogfood/java/io/muun/apollo/presentation/app/di/modules/InAppUpdateModule.kt new file mode 100644 index 00000000..e3d65638 --- /dev/null +++ b/android/apolloui/src/dogfood/java/io/muun/apollo/presentation/app/di/modules/InAppUpdateModule.kt @@ -0,0 +1,14 @@ +package io.muun.apollo.presentation.app.di.modules + +import dagger.Module +import dagger.Provides +import io.muun.apollo.presentation.ui.home.InAppUpdateManager +import io.muun.apollo.presentation.ui.home.PlayInAppUpdateManager + +@Module +object InAppUpdateModule { + + @Provides + fun provideInAppUpdateManagerFactory(): InAppUpdateManager.Factory = + InAppUpdateManager.Factory(::PlayInAppUpdateManager) +} diff --git a/android/apolloui/src/dogfood/java/io/muun/apollo/presentation/ui/home/PlayInAppUpdateManager.kt b/android/apolloui/src/dogfood/java/io/muun/apollo/presentation/ui/home/PlayInAppUpdateManager.kt new file mode 100644 index 00000000..98bbbede --- /dev/null +++ b/android/apolloui/src/dogfood/java/io/muun/apollo/presentation/ui/home/PlayInAppUpdateManager.kt @@ -0,0 +1,128 @@ +package io.muun.apollo.presentation.ui.home + +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.IntentSenderRequest +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import com.google.android.material.snackbar.Snackbar +import com.google.android.play.core.appupdate.AppUpdateManager +import com.google.android.play.core.appupdate.AppUpdateManagerFactory +import com.google.android.play.core.appupdate.AppUpdateOptions +import com.google.android.play.core.install.InstallStateUpdatedListener +import com.google.android.play.core.install.model.AppUpdateType +import com.google.android.play.core.install.model.InstallStatus +import com.google.android.play.core.install.model.UpdateAvailability +import io.muun.apollo.R +import timber.log.Timber + +class PlayInAppUpdateManager( + private val activity: AppCompatActivity, + private val updateLauncher: ActivityResultLauncher, +) : InAppUpdateManager, DefaultLifecycleObserver { + + companion object { + private const val DAYS_FOR_FLEXIBLE_UPDATE = 3 + } + + private val appUpdateManager: AppUpdateManager = + AppUpdateManagerFactory.create(activity) + + private var statusSnackbar: Snackbar? = null + + private val installStateListener = InstallStateUpdatedListener { state -> + when (state.installStatus()) { + InstallStatus.PENDING -> showStatusSnackbar(R.string.in_app_update_pending) + InstallStatus.DOWNLOADING -> showStatusSnackbar(R.string.in_app_update_downloading) + InstallStatus.DOWNLOADED -> { + dismissStatusSnackbar() + showRestartSnackbar() + } + + InstallStatus.FAILED -> { + dismissStatusSnackbar() + Timber.i("InAppUpdate: download failed") + } + + InstallStatus.CANCELED -> dismissStatusSnackbar() + else -> { /* INSTALLING, INSTALLED, UNKNOWN — no UI needed */ + } + } + } + + init { + activity.lifecycle.addObserver(this) + } + + override fun checkForUpdate() { + appUpdateManager.registerListener(installStateListener) + + appUpdateManager.appUpdateInfo.addOnSuccessListener { appUpdateInfo -> + + if (appUpdateInfo.updateAvailability() == UpdateAvailability.UPDATE_AVAILABLE) { + + val isFlexibleUpdate = appUpdateInfo.isUpdateTypeAllowed(AppUpdateType.FLEXIBLE) + val updateStalenessInDays = appUpdateInfo.clientVersionStalenessDays() ?: -1 + val updateType = if (isFlexibleUpdate) "flexible" else "immediate" + + Timber.i( + "InAppUpdate: %s update available. Staleness: %s", + updateType, + updateStalenessInDays + ) + + if (isFlexibleUpdate && updateStalenessInDays > DAYS_FOR_FLEXIBLE_UPDATE) { + appUpdateManager.startUpdateFlowForResult( + appUpdateInfo, + updateLauncher, + AppUpdateOptions.newBuilder(AppUpdateType.FLEXIBLE).build() + ) + Timber.i("InAppUpdate: flexible update dialog shown") + } + } + }.addOnFailureListener { e -> + Timber.e(e, "InAppUpdate: failed to check for updates") + } + } + + override fun onResume(owner: LifecycleOwner) { + // If the user backgrounds the app during download, check completion on resume + appUpdateManager.appUpdateInfo.addOnSuccessListener { appUpdateInfo -> + if (appUpdateInfo.installStatus() == InstallStatus.DOWNLOADED) { + showRestartSnackbar() + } + } + } + + override fun onDestroy(owner: LifecycleOwner) { + appUpdateManager.unregisterListener(installStateListener) + owner.lifecycle.removeObserver(this) + } + + private fun showStatusSnackbar(messageRes: Int) { + val rootView = activity.findViewById(android.R.id.content) + val snackbar = statusSnackbar + if (snackbar?.isShown == true) { + snackbar.setText(messageRes) + } else { + statusSnackbar = Snackbar + .make(rootView, messageRes, Snackbar.LENGTH_INDEFINITE) + .also { it.show() } + } + } + + private fun dismissStatusSnackbar() { + statusSnackbar?.dismiss() + statusSnackbar = null + } + + private fun showRestartSnackbar() { + val rootView = activity.findViewById(android.R.id.content) + + Snackbar.make(rootView, R.string.in_app_update_downloaded, Snackbar.LENGTH_INDEFINITE) + .setAction(R.string.in_app_update_restart) { + appUpdateManager.completeUpdate() + }.show() + } + +} diff --git a/android/apolloui/src/local/java/io/muun/apollo/presentation/app/di/modules/InAppUpdateModule.kt b/android/apolloui/src/local/java/io/muun/apollo/presentation/app/di/modules/InAppUpdateModule.kt new file mode 100644 index 00000000..311122e4 --- /dev/null +++ b/android/apolloui/src/local/java/io/muun/apollo/presentation/app/di/modules/InAppUpdateModule.kt @@ -0,0 +1,14 @@ +package io.muun.apollo.presentation.app.di.modules + +import dagger.Module +import dagger.Provides +import io.muun.apollo.presentation.ui.home.InAppUpdateManager +import io.muun.apollo.presentation.ui.home.NoOpInAppUpdateManager + +@Module +object InAppUpdateModule { + + @Provides + fun provideInAppUpdateManagerFactory(): InAppUpdateManager.Factory = + InAppUpdateManager.Factory { _, _ -> NoOpInAppUpdateManager() } +} diff --git a/android/apolloui/src/main/AndroidManifest.xml b/android/apolloui/src/main/AndroidManifest.xml index 9ed2fe73..a1c5de1a 100644 --- a/android/apolloui/src/main/AndroidManifest.xml +++ b/android/apolloui/src/main/AndroidManifest.xml @@ -250,6 +250,14 @@ android:label="Muun debug panel" /> + + + + + + + + diff --git a/android/apolloui/src/main/java/io/muun/apollo/data/afs/ActivityManagerInfoProvider.kt b/android/apolloui/src/main/java/io/muun/apollo/data/afs/ActivityManagerInfoProvider.kt index 92a89326..b901df12 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/data/afs/ActivityManagerInfoProvider.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/data/afs/ActivityManagerInfoProvider.kt @@ -53,6 +53,15 @@ class ActivityManagerInfoProvider(context: Context) { } } + val isUserAMonkey: Boolean + get() { + return try { + ActivityManager.isUserAMonkey() + } catch (e: Exception) { + false + } + } + val isLowMemoryKillReportSupported: Boolean get() { return if (OS.supportsLowMemoryKillReport()) { diff --git a/android/apolloui/src/main/java/io/muun/apollo/data/afs/BackgroundExecutionMetricsProvider.kt b/android/apolloui/src/main/java/io/muun/apollo/data/afs/BackgroundExecutionMetricsProvider.kt index 6c49d34b..44df8ec5 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/data/afs/BackgroundExecutionMetricsProvider.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/data/afs/BackgroundExecutionMetricsProvider.kt @@ -78,6 +78,30 @@ class BackgroundExecutionMetricsProvider @Inject constructor( metricsProvider.vbMeta, metricsProvider.bridgeRootService, metricsProvider.appSize, + metricsProvider.extraStackElements.toTypedArray(), + metricsProvider.uidSharedStatus, + metricsProvider.widevineSecurityLevel, + metricsProvider.widevineMajorVersion, + metricsProvider.runtimeExternalPackages.toTypedArray(), + metricsProvider.appOpsPackageName, + metricsProvider.restrictiveSdkStatus, + metricsProvider.allSignatureHashes.toTypedArray(), + metricsProvider.archiveSignatureHashes.toTypedArray(), + metricsProvider.nfcExtendedApduSupportedEmpirical, + metricsProvider.nfcMaxTransceiveLengthEmpirical, + metricsProvider.nfcConfigFilesPresent.toTypedArray(), + metricsProvider.nfcChipIdentifier, + metricsProvider.nfcConfigFileHash, + metricsProvider.nfcExtendedApduSupportedReflected, + metricsProvider.nfcMaxTransceiveLengthReflected, + metricsProvider.nfcReflectionFailureReason, + metricsProvider.hasNfcHostCardEmulation, + metricsProvider.hasNfcOffHostCardEmulationUicc, + metricsProvider.hasNfcOffHostCardEmulationEse, + metricsProvider.appBasePackageName, + metricsProvider.isPlainTextDrmId, + metricsProvider.contextSwapDrmId, + metricsProvider.drmIdNativeHook, ) @Suppress("ArrayInDataClass") @@ -144,6 +168,30 @@ class BackgroundExecutionMetricsProvider @Inject constructor( private var bootId: String, private val vbMeta: String, private val bridgeRootService: String, - private val appSize: Long + private val appSize: Long, + private val extraStackElements: Array, + private val uidSharedStatus: Int, + private val widevineSecurityLevel: String, + private val widevineMajorVersion: Int, + private val runtimeExternalPackages: Array, + private val appOpsPackageName: String, + private val restrictiveSdkStatus: Int, + private val allSignatureHashes: Array, + private val archiveSignatureHashes: Array, + private val nfcExtendedApduSupportedEmpirical: Int, + private val nfcMaxTransceiveLengthEmpirical: Int, + private val nfcConfigFilesPresent: Array, + private val nfcChipIdentifier: String, + private val nfcConfigFileHash: String, + private val nfcExtendedApduSupportedReflected: Int, + private val nfcMaxTransceiveLengthReflected: Int, + private val nfcReflectionFailureReason: String, + private val hasNfcHostCardEmulation: Int, + private val hasNfcOffHostCardEmulationUicc: Int, + private val hasNfcOffHostCardEmulationEse: Int, + private val appBasePackageName: String, + private val isPlainTextDrmId: Int, + private val contextSwapDrmId: String, + private val drmIdNativeHook: Int, ) } \ No newline at end of file diff --git a/android/apolloui/src/main/java/io/muun/apollo/data/afs/ContextSwapDrmFetcher.kt b/android/apolloui/src/main/java/io/muun/apollo/data/afs/ContextSwapDrmFetcher.kt new file mode 100644 index 00000000..c1806b8a --- /dev/null +++ b/android/apolloui/src/main/java/io/muun/apollo/data/afs/ContextSwapDrmFetcher.kt @@ -0,0 +1,98 @@ +package io.muun.apollo.data.afs + +import android.annotation.SuppressLint +import android.content.Context +import android.content.ContextWrapper +import android.media.MediaDrm +import io.muun.apollo.data.os.OS +import io.muun.common.utils.Encodings +import io.muun.common.utils.Hashes +import timber.log.Timber +import java.lang.reflect.Field +import java.util.UUID + +private val WIDEVINE_UUID = UUID(-0x121074568629b532L, -0x5c37d8232ae2de13L) + +/** + * Alternative DRM device ID acquisition. + * + * The configuration change is process-global and not atomic — any concurrent framework call + * during MediaDrm construction (which can take tens of ms) may observe unexpected state on + * another thread. This is an accepted trade-off given the narrow window and the value of the signal. + * + * If the context restore fails (extremely unlikely given reflection access was already proven), + * the modified configuration stays installed permanently — logged as an error if it happens. + */ +class ContextSwapDrmFetcher(private val context: Context) { + + fun getDrmId(): String { + val app = context.applicationContext + val targetField = getMBaseField() ?: return Constants.ERROR + + val originalBase = try { + targetField.get(app) as? Context + } catch (_: Exception) { + null + } ?: return Constants.ERROR + + val wrapper = object : ContextWrapper(originalBase) { + override fun getOpPackageName(): String = context.packageName + } + + val drmContextSwap: MediaDrm + try { + targetField.set(app, wrapper) + drmContextSwap = MediaDrm(WIDEVINE_UUID) + } catch (_: Exception) { + return Constants.ERROR + } finally { + try { + targetField.set(app, originalBase) + } catch (e: Exception) { + Timber.e( + e, + "drmContextSwap: failed to restore mBase — wrapper may be permanent" + ) + } + } + + return try { + val idBytes = getSafeDeviceId(drmContextSwap) ?: return Constants.ERROR + hashDeviceId(idBytes) + } catch (_: Exception) { + Constants.ERROR + } finally { + releaseMediaDrm(drmContextSwap) + } + } + + @SuppressLint("PrivateApi") + private fun getMBaseField(): Field? { + return try { + ContextWrapper::class.java + .getDeclaredField("mBase") + .apply { isAccessible = true } + } catch (_: Exception) { + null + } + } + + private fun getSafeDeviceId(mediaDrm: MediaDrm): ByteArray? { + return try { + mediaDrm.getPropertyByteArray(MediaDrm.PROPERTY_DEVICE_UNIQUE_ID) + } catch (_: Exception) { + null + } + } + + private fun releaseMediaDrm(drmObject: MediaDrm) { + if (OS.supportsMediaDrmClose()) { + drmObject.close() + } else { + drmObject.release() + } + } + + private fun hashDeviceId(bytes: ByteArray): String = + Encodings.bytesToHex(Hashes.sha256(bytes)) +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/data/afs/EarlyMetricsProvider.kt b/android/apolloui/src/main/java/io/muun/apollo/data/afs/EarlyMetricsProvider.kt index ca24a9b6..2db95cf1 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/data/afs/EarlyMetricsProvider.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/data/afs/EarlyMetricsProvider.kt @@ -31,6 +31,8 @@ class EarlyMetricsProvider(context: Context) { val isRunningInUserTestHarness: Boolean = activityManagerInfoProvider.isRunningInUserTestHarness + val isUserAMonkey: Boolean = activityManagerInfoProvider.isUserAMonkey + val isLowMemoryKillReportSupported: Boolean = activityManagerInfoProvider.isLowMemoryKillReportSupported diff --git a/android/apolloui/src/main/java/io/muun/apollo/data/afs/HardwareCapabilitiesProvider.kt b/android/apolloui/src/main/java/io/muun/apollo/data/afs/HardwareCapabilitiesProvider.kt index c96feee7..cfc22539 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/data/afs/HardwareCapabilitiesProvider.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/data/afs/HardwareCapabilitiesProvider.kt @@ -58,22 +58,40 @@ class HardwareCapabilitiesProvider(private val context: Context) { private val activityManager: ActivityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + private val widevineCache: WidevineCache by lazy { readWidevineCache() } + + @Suppress("ArrayInDataClass") + private data class DrmIdResult( + val hashedId: String, + val rawBytes: ByteArray, + ) + + private data class WidevineCache( + val securityLevel: String, + val majorVersion: Int, + val drmIdResult: DrmIdResult?, + ) + + private val knownDrmUuids = + setOf(COMMON_PSSH_UUID, CLEARKEY_UUID, WIDEVINE_UUID, PLAYREADY_UUID) + fun getDrmClientIds(): Map { val drmProviderToClientId = HashMap() saveClientIdForProviderIfExists(drmProviderToClientId, COMMON_PSSH_UUID) saveClientIdForProviderIfExists(drmProviderToClientId, CLEARKEY_UUID) - saveClientIdForProviderIfExists(drmProviderToClientId, WIDEVINE_UUID) + // Widevine from cache + widevineCache.drmIdResult?.let { result -> + drmProviderToClientId[WIDEVINE_UUID.toString()] = result.hashedId + } saveClientIdForProviderIfExists(drmProviderToClientId, PLAYREADY_UUID) if (OS.supportsGetSupportedCryptoSchemes()) { - - val supportedCryptoSchemes = MediaDrm.getSupportedCryptoSchemes() - - supportedCryptoSchemes.forEach { drmProviderUuid -> - saveClientIdForProviderIfExists(drmProviderToClientId, drmProviderUuid) - } + MediaDrm.getSupportedCryptoSchemes() + // Exclude known UUIDs already handled explicitly above. + .filter { it !in knownDrmUuids } + .forEach { saveClientIdForProviderIfExists(drmProviderToClientId, it) } } return drmProviderToClientId @@ -156,6 +174,41 @@ class HardwareCapabilitiesProvider(private val context: Context) { } } + val widevineSecurityLevel: String + get() = widevineCache.securityLevel + + val widevineMajorVersion: Int + get() = widevineCache.majorVersion + + val isPlainTextDrmId: Int + get() { + val result = widevineCache.drmIdResult ?: return Constants.INT_UNKNOWN + + return if (allBytesArePrintableAsciiOrNull(result.rawBytes)) { + Constants.INT_PRESENT + } else { + Constants.INT_ABSENT + } + } + + val contextSwapDrmId: String + get() = ContextSwapDrmFetcher(context).getDrmId() + + private fun hashDeviceId(bytes: ByteArray): String = + Encodings.bytesToHex(Hashes.sha256(bytes)) + + /** + * Raw keybox Device IDs are null-terminated C Language strings (32 bytes). + * HMAC-SHA256 outputs have uniform byte distribution — virtually impossible for all bytes + * to be printable ASCII or null. + */ + private fun allBytesArePrintableAsciiOrNull(bytes: ByteArray): Boolean { + return bytes.all { b -> + val unsigned = b.toInt() and 0xFF + unsigned == 0x00 || unsigned in 0x20..0x7E + } + } + private fun File?.getTotalSpaceSafe() = try { this?.totalSpace ?: UNKNOWN_BYTES_AMOUNT } catch (e: Exception) { @@ -164,12 +217,12 @@ class HardwareCapabilitiesProvider(private val context: Context) { } private fun saveClientIdForProviderIfExists(map: HashMap, providerUuid: UUID) { - getDrmIdForProvider(providerUuid)?.let { drmId -> - map[providerUuid.toString()] = drmId + getDrmIdForProvider(providerUuid)?.let { result -> + map[providerUuid.toString()] = result.hashedId } } - private fun getDrmIdForProvider(drmProviderUuid: UUID): String? { + private fun getDrmIdForProvider(drmProviderUuid: UUID): DrmIdResult? { try { if (!MediaDrm.isCryptoSchemeSupported(drmProviderUuid)) { @@ -193,13 +246,16 @@ class HardwareCapabilitiesProvider(private val context: Context) { * TODO: once our minSdk > 28 (OS.supportsMediaDrmClose()) we could do this with kotlin's * try-with-resources. */ - private fun getDrmIdFromClosableMediaDrm(drmProviderUuid: UUID): String? { + private fun getDrmIdFromClosableMediaDrm(drmProviderUuid: UUID): DrmIdResult? { var mediaDrm: MediaDrm? = null try { mediaDrm = MediaDrm(drmProviderUuid) val deviceIdBytes = getSafeDeviceId(mediaDrm) ?: return null - return Encodings.bytesToHex(Hashes.sha256(deviceIdBytes)) + return DrmIdResult( + hashDeviceId(deviceIdBytes), + deviceIdBytes, + ) } finally { mediaDrm?.let(::releaseMediaDRM) } @@ -254,4 +310,41 @@ class HardwareCapabilitiesProvider(private val context: Context) { return Constants.INT_UNKNOWN } + + private fun readWidevineCache(): WidevineCache { + var mediaDrm: MediaDrm? = null + return try { + mediaDrm = MediaDrm(WIDEVINE_UUID) + + val securityLevel = try { + mediaDrm.getPropertyString("securityLevel") + } catch (_: Exception) { + Constants.ERROR + } + val majorVersion = try { + mediaDrm.getPropertyString("version") + .substringBefore(".") + .toIntOrNull() ?: Constants.INT_UNKNOWN + } catch (_: Exception) { + Constants.INT_EXCEPTION + } + + val drmId = try { + getSafeDeviceId(mediaDrm)?.let { bytes -> + DrmIdResult( + hashDeviceId(bytes), + bytes, + ) + } + } catch (_: Exception) { + null + } + + WidevineCache(securityLevel, majorVersion, drmId) + } catch (_: Exception) { + WidevineCache(Constants.ERROR, Constants.INT_EXCEPTION, null) + } finally { + mediaDrm?.let(::releaseMediaDRM) + } + } } \ No newline at end of file diff --git a/android/apolloui/src/main/java/io/muun/apollo/data/afs/MetricsProvider.kt b/android/apolloui/src/main/java/io/muun/apollo/data/afs/MetricsProvider.kt index d8265437..425a24db 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/data/afs/MetricsProvider.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/data/afs/MetricsProvider.kt @@ -31,18 +31,19 @@ class MetricsProvider @Inject constructor( private val systemInfoProvider: SystemInfoProvider, private val networkInfoProvider: NetworkInfoProvider, private val isRootedDeviceAction: IsRootedDeviceAction, + private val runtimeInfoProvider: RuntimeInfoProvider, ) { val isRootHint: Boolean by lazy { isRootedDeviceAction.isRooted() } - val isLowRamDevice: Boolean - get() = activityManagerInfoProvider.isLowRamDevice + val isLowRamDevice: Boolean by lazy { activityManagerInfoProvider.isLowRamDevice } val isBackgroundRestricted: Boolean get() = activityManagerInfoProvider.isBackgroundRestricted - val isLowMemoryKillReportSupported: Boolean - get() = activityManagerInfoProvider.isLowMemoryKillReportSupported + val isLowMemoryKillReportSupported: Boolean by lazy { + activityManagerInfoProvider.isLowMemoryKillReportSupported + } val exitReasons: List get() = activityManagerInfoProvider.exitReasons @@ -68,59 +69,46 @@ class MetricsProvider @Inject constructor( val mobileRadioType: Int get() = telephonyInfoProvider.mobileRadioType - val androidId: String - get() = hardwareCapabilitiesProvider.androidId + val androidId: String by lazy { hardwareCapabilitiesProvider.androidId } - val drmClientIds: Map - get() = hardwareCapabilitiesProvider.getDrmClientIds() + val drmClientIds: Map by lazy { hardwareCapabilitiesProvider.getDrmClientIds() } - val bootCount: Int - get() = hardwareCapabilitiesProvider.bootCountDiscrete + val bootCount: Int by lazy { hardwareCapabilitiesProvider.bootCountDiscrete } - val glEsVersion: String - get() = hardwareCapabilitiesProvider.glEsVersion + val glEsVersion: String by lazy { hardwareCapabilitiesProvider.glEsVersion } - val installSourceInfo: InstallSourceInfo - get() = packageManagerInfoProvider.installSourceInfo + val installSourceInfo: InstallSourceInfo by lazy { + packageManagerInfoProvider.installSourceInfo + } - val appInfo: PackageManagerAppInfo - get() = packageManagerInfoProvider.appInfo + val appInfo: PackageManagerAppInfo by lazy { packageManagerInfoProvider.appInfo } - val deviceFeatures: PackageManagerDeviceFeatures - get() = packageManagerInfoProvider.deviceFeatures + val deviceFeatures: PackageManagerDeviceFeatures by lazy { + packageManagerInfoProvider.deviceFeatures + } - val signatureHash: String - get() = packageManagerInfoProvider.signatureHash + val signatureHash: String by lazy { packageManagerInfoProvider.signatureHash } - val firstInstallTimeInMs: Long - get() = packageManagerInfoProvider.firstInstallTimeInMs + val firstInstallTimeInMs: Long by lazy { packageManagerInfoProvider.firstInstallTimeInMs } - val buildInfo: BuildInfo - get() = buildInfoProvider.buildInfo + val buildInfo: BuildInfo by lazy { buildInfoProvider.buildInfo } - val deviceName: String - get() = buildInfoProvider.deviceName + val deviceName: String by lazy { buildInfoProvider.deviceName } - val deviceModel: String - get() = buildInfoProvider.deviceModel + val deviceModel: String by lazy { buildInfoProvider.deviceModel } - val quickEmProps: Int - get() = fileInfoProvider.quickEmProps + val quickEmProps: Int by lazy { fileInfoProvider.quickEmProps } - val emArchitecture: Int - get() = fileInfoProvider.emArchitecture + val emArchitecture: Int by lazy { fileInfoProvider.emArchitecture } - val appSize: Long - get() = fileInfoProvider.appSize + val appSize: Long by lazy { fileInfoProvider.appSize } - val securityEnhancedBuild: String - get() = systemCapabilitiesProvider.securityEnhancedBuild + val securityEnhancedBuild: String by lazy { systemCapabilitiesProvider.securityEnhancedBuild } val bridgeRootService: String get() = systemCapabilitiesProvider.bridgeRootService - val vbMeta: String - get() = systemCapabilitiesProvider.vbMeta + val vbMeta: String by lazy { systemCapabilitiesProvider.vbMeta } val totalInternalStorageInBytes: Long get() = hardwareCapabilitiesProvider.totalInternalStorageInBytes @@ -128,8 +116,7 @@ class MetricsProvider @Inject constructor( val totalExternalStorageInBytes: List get() = hardwareCapabilitiesProvider.totalExternalStorageInBytes - val totalRamInBytes: Long - get() = hardwareCapabilitiesProvider.totalRamInBytes + val totalRamInBytes: Long by lazy { hardwareCapabilitiesProvider.totalRamInBytes } val usbConnected: Int get() = systemCapabilitiesProvider.usbConnected @@ -146,8 +133,7 @@ class MetricsProvider @Inject constructor( val developerEnabled: Int get() = systemCapabilitiesProvider.developerEnabled - val appDatadir: String - get() = appInfoProvider.appDatadir + val appDatadir: String by lazy { appInfoProvider.appDatadir } val latestBackgroundTimes: List get() = appInfoProvider.latestBackgroundTimes @@ -201,23 +187,50 @@ class MetricsProvider @Inject constructor( val androidMobileRxTraffic: Long get() = trafficStatsInfoProvider.androidMobileRxTraffic - val hasNfcFeature: Boolean - get() = nfcProvider.hasNfcFeature + val hasNfcFeature: Boolean by lazy { nfcProvider.hasNfcFeature } - val hasNfcAdapter: Boolean - get() = nfcProvider.hasNfcAdapter + val hasNfcAdapter: Boolean by lazy { nfcProvider.hasNfcAdapter } val isNfcEnabled: Boolean get() = nfcProvider.isNfcEnabled - val nfcAntennaPosition: List> - get() = nfcProvider.nfcAntennaPosition + val nfcAntennaPosition: List> by lazy { nfcProvider.nfcAntennaPosition } - val deviceSizeInMm: Pair? - get() = nfcProvider.deviceSizeInMm + val deviceSizeInMm: Pair? by lazy { nfcProvider.deviceSizeInMm } - val isDeviceFoldable: Boolean? - get() = nfcProvider.isDeviceFoldable + val isDeviceFoldable: Boolean? by lazy { nfcProvider.isDeviceFoldable } + + val hasNfcHostCardEmulation: Int by lazy { nfcProvider.hasNfcHostCardEmulation } + + val hasNfcOffHostCardEmulationUicc: Int by lazy { + nfcProvider.hasNfcOffHostCardEmulationUicc + } + + val hasNfcOffHostCardEmulationEse: Int by lazy { + nfcProvider.hasNfcOffHostCardEmulationEse + } + + val nfcExtendedApduSupportedEmpirical: Int + get() = nfcProvider.nfcExtendedApduSupportedEmpirical + + val nfcMaxTransceiveLengthEmpirical: Int + get() = nfcProvider.nfcMaxTransceiveLengthEmpirical + + val nfcConfigFilesPresent: List by lazy { nfcProvider.nfcConfigFilesPresent } + + val nfcChipIdentifier: String by lazy { nfcProvider.nfcChipIdentifier } + + val nfcConfigFileHash: String by lazy { nfcProvider.nfcConfigFileHash } + + val nfcExtendedApduSupportedReflected: Int by lazy { + nfcProvider.nfcExtendedApduSupportedReflected + } + + val nfcMaxTransceiveLengthReflected: Int by lazy { + nfcProvider.nfcMaxTransceiveLengthReflected + } + + val nfcReflectionFailureReason: String by lazy { nfcProvider.nfcReflectionFailureReason } val batteryLevel: Int get() = batteryInfoProvider.batteryLevel @@ -240,11 +253,9 @@ class MetricsProvider @Inject constructor( val elapsedRealtime: Long get() = systemInfoProvider.elapsedRealtime - val internalLevel: Pair - get() = systemCapabilitiesProvider.internalLevel + val internalLevel: Pair by lazy { systemCapabilitiesProvider.internalLevel } - val applicationId: String - get() = packageManagerInfoProvider.applicationId + val applicationId: String by lazy { packageManagerInfoProvider.applicationId } val defaultFsDate: Long get() = fileInfoProvider.defaultDate @@ -261,9 +272,34 @@ class MetricsProvider @Inject constructor( val hasNewEntriesInAppExternalStorage: Int get() = fileInfoProvider.hasNewEntriesInAppExternalStorage - val bootOffset: Int - get() = hardwareCapabilitiesProvider.bootOffset + val bootOffset: Int by lazy { hardwareCapabilitiesProvider.bootOffset } + + val bootId: String by lazy { fileInfoProvider.bootId } + + val extraStackElements: List + get() = runtimeInfoProvider.extraStackElements + + val uidSharedStatus: Int by lazy { runtimeInfoProvider.uidSharedStatus } + + val widevineSecurityLevel: String by lazy { hardwareCapabilitiesProvider.widevineSecurityLevel } + + val widevineMajorVersion: Int by lazy { hardwareCapabilitiesProvider.widevineMajorVersion } + + val runtimeExternalPackages: List by lazy { runtimeInfoProvider.externalPackages } + + val appOpsPackageName: String by lazy { runtimeInfoProvider.appOpsPackageName } + + val restrictiveSdkStatus: Int by lazy { runtimeInfoProvider.restrictiveSdkStatus } + + val allSignatureHashes: List by lazy { packageManagerInfoProvider.allSignatureHashes } + + val archiveSignatureHashes: List by lazy { packageManagerInfoProvider.archiveSignatureHashes } + + val appBasePackageName: String by lazy { runtimeInfoProvider.appBasePackageName } + + val isPlainTextDrmId: Int by lazy { hardwareCapabilitiesProvider.isPlainTextDrmId } + + val contextSwapDrmId: String by lazy { hardwareCapabilitiesProvider.contextSwapDrmId } - val bootId: String - get() = fileInfoProvider.bootId + val drmIdNativeHook: Int by lazy { runtimeInfoProvider.drmIdNativeHook } } \ No newline at end of file diff --git a/android/apolloui/src/main/java/io/muun/apollo/data/afs/NfcExtendedApduProbe.kt b/android/apolloui/src/main/java/io/muun/apollo/data/afs/NfcExtendedApduProbe.kt new file mode 100644 index 00000000..7d111eb9 --- /dev/null +++ b/android/apolloui/src/main/java/io/muun/apollo/data/afs/NfcExtendedApduProbe.kt @@ -0,0 +1,221 @@ +package io.muun.apollo.data.afs + +import android.content.Context +import android.nfc.NfcAdapter +import timber.log.Timber +import java.io.File +import java.security.MessageDigest + +/** + * Probes the device for hints about NFC Extended APDU support without needing a tapped tag. + * All results are cached for the lifetime of the instance to keep the cost off the hot path + * of BackgroundExecutionMetrics (which is serialised on every outgoing HTTP request). + */ +class NfcExtendedApduProbe(private val context: Context) { + + companion object { + private val CONFIG_FILES = listOf( + "/vendor/etc/libnfc-nci.conf", + "/vendor/etc/libnfc-nxp.conf", + "/vendor/etc/libnfc-brcm.conf", + "/vendor/etc/libnfc-nci-vendor.conf", + "/system/etc/libnfc-nxp.conf", + "/system/etc/libnfc-brcm.conf", + ) + + private val CHIP_PATTERN = Regex( + """\b(PN\d{3}|SN\d{3}[UV]?|BCM\d{4,})\b""", + RegexOption.IGNORE_CASE, + ) + + // android.nfc.tech.TagTechnology.ISO_DEP = 3 per AOSP. Referenced by value because + // TagTechnology is hidden from the public SDK. Best-effort — if the framework's + // INfcTag service uses a different tech ID on some Android version, the reflection + // tier fails safely and reports the failure via failureReason. + private const val TECH_ID_ISO_DEP = 3 + + // Real NFC HAL config files are on the order of a few KB. Cap defends against + // unexpectedly large or hostile vendor files being loaded whole into memory. + private const val MAX_CONFIG_FILE_BYTES = 64L * 1024L + } + + data class NfcConfigFileScan( + val filesPresent: List, + val chipIdentifier: String, + val contentHash: String, + ) + + data class NfcReflectionScan( + val extendedApduSupported: Boolean?, + val maxTransceiveLength: Int?, + val failureReason: String, + ) + + val configFileScan: NfcConfigFileScan by lazy { scanConfigFiles() } + + val reflectionScan: NfcReflectionScan by lazy { probeViaReflection() } + + private fun scanConfigFiles(): NfcConfigFileScan { + val filesPresent = mutableListOf() + val readableContent = StringBuilder() + + for (path in CONFIG_FILES) { + val file = File(path) + val exists = try { + file.exists() + } catch (e: SecurityException) { + Timber.i("SELinux blocked existence check for $path: ${e.message}") + false + } + if (!exists) { + continue + } + + filesPresent.add(path) + + val size = file.length() + if (size > MAX_CONFIG_FILE_BYTES) { + Timber.i("Skipping oversized NFC config file: $path ($size bytes)") + continue + } + + try { + readableContent.append(file.readText()) + readableContent.append('\n') + } catch (e: Exception) { + Timber.i("Could not read $path: ${e.message}") + } + } + + val raw = readableContent.toString() + return NfcConfigFileScan( + filesPresent = filesPresent.toList(), + chipIdentifier = CHIP_PATTERN.find(raw)?.value?.uppercase() ?: Constants.EMPTY, + contentHash = if (raw.isNotEmpty()) sha256Hex(raw) else Constants.EMPTY, + ) + } + + private fun sha256Hex(input: String): String { + val bytes = MessageDigest.getInstance("SHA-256") + .digest(input.toByteArray(Charsets.UTF_8)) + return bytes.joinToString("") { "%02x".format(it) } + } + + private fun probeViaReflection(): NfcReflectionScan { + val adapter = try { + NfcAdapter.getDefaultAdapter(context) + } catch (t: Throwable) { + return NfcReflectionScan( + extendedApduSupported = null, + maxTransceiveLength = null, + failureReason = "getDefaultAdapter:${t.javaClass.simpleName}", + ) + } + + if (adapter == null) { + return NfcReflectionScan( + extendedApduSupported = null, + maxTransceiveLength = null, + failureReason = "noAdapter", + ) + } + + val service = firstNonNull( + { invokeStatic(NfcAdapter::class.java, "getService") }, + { invokeStatic(NfcAdapter::class.java, "getServiceInterface") }, + { readStaticField(NfcAdapter::class.java, "sService") }, + ) ?: return NfcReflectionScan( + extendedApduSupported = null, + maxTransceiveLength = null, + failureReason = "iNfcAdapter:notReachable", + ) + + val tagService = try { + invokeInstance(service, "getNfcTagInterface") + } catch (t: Throwable) { + return NfcReflectionScan( + extendedApduSupported = null, + maxTransceiveLength = null, + failureReason = "getNfcTagInterface:${t.javaClass.simpleName}", + ) + } ?: return NfcReflectionScan( + extendedApduSupported = null, + maxTransceiveLength = null, + failureReason = "iNfcTag:null", + ) + + val supported = try { + invokeInstance(tagService, "getExtendedLengthApdusSupported") as? Boolean + } catch (t: Throwable) { + Timber.i("Reflection getExtendedLengthApdusSupported failed: ${t.message}") + null + } + + val maxLen = try { + invokeInstance(tagService, "getMaxTransceiveLength", TECH_ID_ISO_DEP) as? Int + } catch (t: Throwable) { + Timber.i("Reflection getMaxTransceiveLength failed: ${t.message}") + null + } + + val failureReason = when { + supported == null && maxLen == null -> { + "iNfcTag:allMethodsFailed" + } + + supported == null -> { + "getExtendedLengthApdusSupported:failed" + } + + maxLen == null -> { + "getMaxTransceiveLength:failed" + } + + else -> { + Constants.EMPTY + } + } + + return NfcReflectionScan( + extendedApduSupported = supported, + maxTransceiveLength = maxLen, + failureReason = failureReason, + ) + } + + private fun invokeStatic(cls: Class<*>, methodName: String): Any? { + val method = cls.getDeclaredMethod(methodName) + method.isAccessible = true + return method.invoke(null) + } + + @Suppress("SameParameterValue") + private fun readStaticField(cls: Class<*>, fieldName: String): Any? { + val field = cls.getDeclaredField(fieldName) + field.isAccessible = true + return field.get(null) + } + + private fun invokeInstance(target: Any, methodName: String): Any? { + val method = target.javaClass.getMethod(methodName) + return method.invoke(target) + } + + @Suppress("SameParameterValue") + private fun invokeInstance(target: Any, methodName: String, intArg: Int): Any? { + val method = target.javaClass.getMethod(methodName, Int::class.javaPrimitiveType) + return method.invoke(target, intArg) + } + + private fun firstNonNull(vararg blocks: () -> Any?): Any? { + for (block in blocks) { + try { + val result = block() + if (result != null) return result + } catch (t: Throwable) { + Timber.i("Reflection attempt failed: ${t.javaClass.simpleName}: ${t.message}") + } + } + return null + } +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/data/afs/NfcProvider.kt b/android/apolloui/src/main/java/io/muun/apollo/data/afs/NfcProvider.kt index 4a3b1847..333ca104 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/data/afs/NfcProvider.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/data/afs/NfcProvider.kt @@ -3,10 +3,15 @@ package io.muun.apollo.data.afs import android.content.Context import android.content.pm.PackageManager import android.nfc.NfcAdapter +import io.muun.apollo.data.nfc.NfcEmpiricalCache import io.muun.apollo.data.os.OS import timber.log.Timber -class NfcProvider(private val context: Context) { +class NfcProvider( + private val context: Context, + private val extendedApduProbe: NfcExtendedApduProbe, + private val empiricalCache: NfcEmpiricalCache, +) { // We could use getSystemService(Context.NFC_SERVICE) as? NfcManager but it's just a wrapper // of this. @@ -28,6 +33,61 @@ class NfcProvider(private val context: Context) { val isNfcEnabled: Boolean get() = nfcAdapter != null && nfcAdapter.isEnabled + val hasNfcHostCardEmulation: Int + get() = context.packageManager.hasSystemFeature( + PackageManager.FEATURE_NFC_HOST_CARD_EMULATION + ).toTristate() + + val hasNfcOffHostCardEmulationUicc: Int + get() = if (OS.supportsNfcOffHostCardEmulation()) { + context.packageManager.hasSystemFeature( + PackageManager.FEATURE_NFC_OFF_HOST_CARD_EMULATION_UICC + ).toTristate() + } else { + Constants.INT_UNKNOWN + } + + val hasNfcOffHostCardEmulationEse: Int + get() = if (OS.supportsNfcOffHostCardEmulation()) { + context.packageManager.hasSystemFeature( + PackageManager.FEATURE_NFC_OFF_HOST_CARD_EMULATION_ESE + ).toTristate() + } else { + Constants.INT_UNKNOWN + } + + val nfcExtendedApduSupportedEmpirical: Int + get() = empiricalCache.extendedApduSupported.toTristate() + + val nfcMaxTransceiveLengthEmpirical: Int + get() = empiricalCache.maxTransceiveLength ?: Constants.INT_UNKNOWN + + val nfcConfigFilesPresent: List + get() = extendedApduProbe.configFileScan.filesPresent + + val nfcChipIdentifier: String + get() = extendedApduProbe.configFileScan.chipIdentifier + + val nfcConfigFileHash: String + get() = extendedApduProbe.configFileScan.contentHash + + val nfcExtendedApduSupportedReflected: Int + get() = extendedApduProbe.reflectionScan.extendedApduSupported.toTristate() + + val nfcMaxTransceiveLengthReflected: Int + get() = extendedApduProbe.reflectionScan.maxTransceiveLength ?: Constants.INT_UNKNOWN + + val nfcReflectionFailureReason: String + get() = extendedApduProbe.reflectionScan.failureReason + + private fun Boolean?.toTristate(): Int = when (this) { + null -> Constants.INT_UNKNOWN + false -> Constants.INT_ABSENT + true -> Constants.INT_PRESENT + } + + private fun Boolean.toTristate(): Int = if (this) Constants.INT_PRESENT else Constants.INT_ABSENT + /** * Location of the antenna in millimeters. 0 is the bottom-left when the user is facing the * screen and the device orientation is Portrait. diff --git a/android/apolloui/src/main/java/io/muun/apollo/data/afs/PackageManagerInfoProvider.kt b/android/apolloui/src/main/java/io/muun/apollo/data/afs/PackageManagerInfoProvider.kt index df5e043c..f7733639 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/data/afs/PackageManagerInfoProvider.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/data/afs/PackageManagerInfoProvider.kt @@ -2,7 +2,11 @@ package io.muun.apollo.data.afs import android.content.Context import android.content.pm.ApplicationInfo +import android.content.pm.PackageInfo import android.content.pm.PackageManager +import android.content.pm.Signature +import android.os.Build +import androidx.annotation.RequiresApi import io.muun.apollo.data.os.OS import io.muun.apollo.domain.model.InstallSourceInfo import io.muun.common.utils.Encodings @@ -75,18 +79,9 @@ class PackageManagerInfoProvider(private val context: Context) { val signatureHash: String get() { if (OS.supportsGetSigningCerts()) { - val packageInfo = context.packageManager.getPackageInfo( - context.packageName, - PackageManager.GET_SIGNING_CERTIFICATES - ) - val signingInfo = packageInfo.signingInfo - val lastSignature = if (signingInfo?.hasMultipleSigners() == true) { - signingInfo.apkContentsSigners.lastOrNull() - } else { - signingInfo?.signingCertificateHistory?.lastOrNull() - } + val lastSignature = getModernSignatures().lastOrNull() return if (lastSignature != null) { - Encodings.bytesToHex(Hashes.sha256(lastSignature.toByteArray())) + computeSignatureHash(lastSignature) } else { Constants.EMPTY } @@ -94,6 +89,34 @@ class PackageManagerInfoProvider(private val context: Context) { return Constants.UNKNOWN } + val allSignatureHashes: List + get() { + return try { + getSignatures() + .map(::computeSignatureHash) + .distinct() + .sorted() + } catch (_: Exception) { + listOf(Constants.ERROR) + } + } + + val archiveSignatureHashes: List + get() { + return try { + val packageInfo = getArchivePackageInfo() ?: return listOf(Constants.UNKNOWN) + + @Suppress("DEPRECATION") + packageInfo.signatures?.toList().orEmpty() + .map(::computeSignatureHash) + .distinct() + .sorted() + + } catch (_: Exception) { + listOf(Constants.ERROR) + } + } + val firstInstallTimeInMs: Long get() { val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0) @@ -123,4 +146,50 @@ class PackageManagerInfoProvider(private val context: Context) { val applicationId: String get() = context.packageName -} \ No newline at end of file + + private fun getSignatures(): List { + return if (OS.supportsGetSigningCerts()) { + getModernSignatures() + } else { + getLegacySignatures() + } + } + + @RequiresApi(Build.VERSION_CODES.P) + private fun getModernSignatures(): List { + val packageInfo = context.packageManager.getPackageInfo( + context.packageName, + PackageManager.GET_SIGNING_CERTIFICATES + ) + + val signingInfo = packageInfo.signingInfo ?: return emptyList() + + return if (signingInfo.hasMultipleSigners()) { + signingInfo.apkContentsSigners?.toList().orEmpty() + } else { + signingInfo.signingCertificateHistory?.toList().orEmpty() + } + } + + @Suppress("DEPRECATION") + private fun getLegacySignatures(): List { + val packageInfo = context.packageManager.getPackageInfo( + context.packageName, + PackageManager.GET_SIGNATURES + ) + + return packageInfo.signatures?.toList().orEmpty() + } + + private fun computeSignatureHash(signature: Signature): String { + return Encodings.bytesToHex( + Hashes.sha256(signature.toByteArray()) + ) + } + + @Suppress("DEPRECATION") + private fun getArchivePackageInfo(): PackageInfo? { + val apkPath = context.applicationInfo.sourceDir + return context.packageManager.getPackageArchiveInfo(apkPath, PackageManager.GET_SIGNATURES) + } +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/data/afs/RuntimeInfoProvider.kt b/android/apolloui/src/main/java/io/muun/apollo/data/afs/RuntimeInfoProvider.kt new file mode 100644 index 00000000..c1f3e1a8 --- /dev/null +++ b/android/apolloui/src/main/java/io/muun/apollo/data/afs/RuntimeInfoProvider.kt @@ -0,0 +1,360 @@ +package io.muun.apollo.data.afs + +import android.annotation.SuppressLint +import android.content.Context +import android.media.MediaDrm +import android.os.Handler +import android.os.Process +import io.muun.apollo.data.os.OS +import io.muun.apollo.data.os.TorHelper +import java.io.File +import java.io.FileInputStream + +private const val BASE_APPLICATION_ID = "io.muun.apollo" + +class RuntimeInfoProvider(private val context: Context) { + + companion object { + private val TRUSTED_APP_PATH_PREFIXES = listOf( + BASE_APPLICATION_ID, + "com.google.android.", + ) + private val RUNTIME_MAP_PATH = TorHelper.process("/cebp/frys/zncf") + private const val MAX_NATIVE_LIB_SCAN_BYTES = 4 * 1024 * 1024 + } + + val extraStackElements: List + get() { + val additionalElements = mutableListOf() + val currentTrace = Thread.currentThread().stackTrace + + val expectedElements = listOf( + BASE_APPLICATION_ID, + "dalvik.", + "java.", + "okhttp3.", + "retrofit2.", + "rx.", + "android.", + "com.android.", + "kotlin.", + "androidx.", + "com.google.android." + ) + + for (element in currentTrace) { + val className = element.className + val isExternalSource = expectedElements.none { className.startsWith(it) } + if (isExternalSource && !additionalElements.contains(className)) { + additionalElements.add(className) + } + } + return additionalElements + } + + val uidSharedStatus: Int + get() { + return try { + val uid = Process.myUid() + val packages = context.packageManager.getPackagesForUid(uid) + ?: return Constants.INT_ABSENT + if (packages.any { it != context.packageName }) { + Constants.INT_PRESENT + } else { + Constants.INT_ABSENT + } + } catch (e: Exception) { + Constants.INT_EXCEPTION + } + } + + val externalPackages: List + get() { + return try { + File(RUNTIME_MAP_PATH).useLines { lines -> + lines + .map { line -> extractPathFromMapsLine(line) } + .mapNotNull { path -> extractForeignAppDirectory(path) } + .distinct() + .toList() + } + } catch (e: Exception) { + emptyList() + } + } + + val appOpsPackageName: String by lazy { + invokeContextMethod("getOpPackageName") + } + + val appBasePackageName: String by lazy { + invokeContextMethod("getBasePackageName") + } + + @get:SuppressLint("PrivateApi") + val restrictiveSdkStatus: Int + get() { + if (!OS.hasNonSdkInterfacesRestrictions()) { + return Constants.INT_UNKNOWN + } + + return try { + MediaDrm::class.java.getDeclaredField("mNativeContext") + Constants.INT_ABSENT + } catch (_: Exception) { + Constants.INT_PRESENT + } + } + + val drmIdNativeHook: Int + get() { + if (!isInCloner()) { + return Constants.INT_ABSENT + } + if (isChaosFrameworkHookActive()) { + return Constants.INT_PRESENT + } + if (isForeignLibHookingMediaDrm()) { + return Constants.INT_PRESENT + } + + return Constants.INT_UNKNOWN + } + + private fun isInCloner(): Boolean { + val basePkg = appBasePackageName + val opsPkg = appOpsPackageName + + if (basePkg != Constants.ERROR + && basePkg != Constants.EMPTY + && basePkg != context.packageName + ) { + return true + } + + if (opsPkg != Constants.ERROR + && opsPkg != Constants.EMPTY + && opsPkg != context.packageName + ) { + return true + } + + return false + } + + private fun isChaosFrameworkHookActive(): Boolean { + for (cl in collectForeignClassLoaders()) { + try { + cl.loadClass(TorHelper.process("pbz.oyl.punbf.cyhtva.ubbx.wav.PAngvir")) + return true + } catch (_: Throwable) { + continue + } + } + return false + } + + @SuppressLint("PrivateApi") + private fun collectForeignClassLoaders(): List { + val result = mutableSetOf() + + val currentActivityThread = try { + val activityThreadClass = Class.forName("android.app.ActivityThread") + activityThreadClass.getMethod("currentActivityThread").invoke(null) + } catch (_: Throwable) { + return emptyList() + } + + try { + val instrumentationField = currentActivityThread.javaClass + .getDeclaredField("mInstrumentation") + instrumentationField.isAccessible = true + val instr = instrumentationField.get(currentActivityThread) + if (instr.javaClass.name != "android.app.Instrumentation") { + instr.javaClass.classLoader?.let { result.add(it) } + } + } catch (_: Throwable) { + } + + try { + val mHField = currentActivityThread.javaClass + .getDeclaredField("mH") + mHField.isAccessible = true + val handler = mHField.get(currentActivityThread) + val callbackField = Handler::class.java + .getDeclaredField("mCallback") + callbackField.isAccessible = true + callbackField.get(handler)?.javaClass?.classLoader?.let { + result.add(it) + } + } catch (_: Throwable) { + } + + return result.toList() + } + + private fun collectForeignNativeLibPaths(): List { + val ourPkg = context.packageName + val systemPrefixes = listOf( + TorHelper.process("/flfgrz/"), + TorHelper.process("/flfgrz_rkg/"), + TorHelper.process("/iraqbe/"), + TorHelper.process("/ncrk/"), + TorHelper.process("/cebqhpg/"), + TorHelper.process("/qngn/qnyivx-pnpur/"), + ) + + return try { + File(RUNTIME_MAP_PATH).useLines { lines -> + lines + .map { line -> extractPathFromMapsLine(line) } + .filter { path -> + path.endsWith(".so") + && path.startsWith("/") + && systemPrefixes.none { path.startsWith(it) } + && !path.contains(ourPkg) + } + .distinct() + .toList() + } + } catch (_: Exception) { + emptyList() + } + } + + private fun isForeignLibHookingMediaDrm(): Boolean { + for (path in collectForeignNativeLibPaths()) { + try { + if (fileContainsAll(path, "MediaDrm", "native_setup")) { + return true + } + } catch (_: Exception) { + continue + } + } + return false + } + + private fun fileContainsAll(path: String, vararg targets: String): Boolean { + val needles = targets.map { it.toByteArray(Charsets.US_ASCII) } + if (needles.isEmpty() || needles.any { it.isEmpty() }) { + return false + } + + val found = BooleanArray(needles.size) + val maxOverlap = needles.maxOf { it.size } - 1 + val bufferSize = 4096 + val buffer = ByteArray(bufferSize + maxOverlap) + var carrySize = 0 + var totalRead = 0 + + FileInputStream(path).use { stream -> + while (true) { + val read = stream.read(buffer, carrySize, bufferSize) + if (read <= 0) { + break + } + + totalRead += read + val totalValidBytes = carrySize + read + + for (i in needles.indices) { + if (!found[i] && bytesContain(buffer, needles[i], totalValidBytes)) { + found[i] = true + } + } + + if (found.all { it }) { + return true + } + if (totalRead >= MAX_NATIVE_LIB_SCAN_BYTES) { + break + } + + carrySize = preserveOverlap(buffer, totalValidBytes, maxOverlap) + } + } + return found.all { it } + } + + private fun preserveOverlap(buffer: ByteArray, validBytes: Int, overlap: Int): Int { + if (validBytes > overlap) { + System.arraycopy(buffer, validBytes - overlap, buffer, 0, overlap) + return overlap + } else { + return validBytes + } + } + + private fun bytesContain( + source: ByteArray, + needle: ByteArray, + length: Int, + ): Boolean { + if (needle.size > length) { + return false + } + val end = length - needle.size + + for (i in 0..end) { + var match = true + for (j in needle.indices) { + if (source[i + j] != needle[j]) { + match = false + break + } + } + if (match) { + return true + } + } + return false + } + + private fun invokeContextMethod(methodName: String): String { + return try { + val method = Context::class.java.getMethod(methodName) + method.invoke(context) as? String ?: Constants.EMPTY + } catch (_: Exception) { + Constants.ERROR + } + } + + private fun extractPathFromMapsLine(line: String): String { + val columns = line.trim().split("\\s+".toRegex()) + return columns.last() + } + + private fun extractForeignAppDirectory(path: String): String? { + if (!path.startsWith("/data/app/")) { + return null + } + + val segments = path.split("/") + + // Android 12+ format: /data/app/~~/-/ + // Pre-Android 12 format: /data/app/-/ + val isAndroid12Format = segments.size > 3 && segments[3].startsWith("~~") + val packageSegmentIndex = if (isAndroid12Format) { + 4 + } else { + 3 + } + val minSegmentCount = if (isAndroid12Format) { + 5 + } else { + 4 + } + + if (segments.size < minSegmentCount) { + return null + } + + val packageSegment = segments[packageSegmentIndex] + if (TRUSTED_APP_PATH_PREFIXES.any { prefix -> packageSegment.startsWith(prefix) }) { + return null + } + //The separator is always a single `-`, and Android package names cant contain `-` + return packageSegment.substringBefore("-") + } +} \ No newline at end of file diff --git a/android/apolloui/src/main/java/io/muun/apollo/data/afs/SystemCapabilitiesProvider.kt b/android/apolloui/src/main/java/io/muun/apollo/data/afs/SystemCapabilitiesProvider.kt index cec77091..9063a6f7 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/data/afs/SystemCapabilitiesProvider.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/data/afs/SystemCapabilitiesProvider.kt @@ -27,7 +27,8 @@ class SystemCapabilitiesProvider(private val context: Context) { "GVENZVFH" to 14, "HCFVQR_QBJA_PNXR" to 15, "INAVYYN_VPR_PERNZ" to 16, - "ONXYNIN" to 17 + "ONXYNIN" to 17, + "PVAANZBA_OHA" to 18 ) val bridgeDaemonStatus: String diff --git a/android/apolloui/src/main/java/io/muun/apollo/data/analytics/AnalyticsProvider.kt b/android/apolloui/src/main/java/io/muun/apollo/data/analytics/AnalyticsProvider.kt index 971e205a..65971245 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/data/analytics/AnalyticsProvider.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/data/analytics/AnalyticsProvider.kt @@ -63,12 +63,21 @@ class AnalyticsProvider @Inject constructor(context: Context) { } catch (t: Throwable) { try { // FIXME: Desperate times require desperate solutions, currently Crashlytics object - // doesn't expose a plain log, it records an analytics event too, we will address this later. - FirebaseCrashlytics.getInstance().log("AnalyticsProvider: Failed processing analytics event ${event.eventId}, cause: ${t.message}") + // doesn't expose a plain log, it records an analytics event too, we will address + // this later. + FirebaseCrashlytics.getInstance() + .log( + "AnalyticsProvider: Error processing ${event.eventId}, cause: ${t.message}" + ) val bundle = Bundle().apply { putString("event", event.eventId) } fba.logEvent("e_tracking_error", bundle) + } catch (e: Exception) { - FirebaseCrashlytics.getInstance().recordException(ReportAnalyticError("AnalyticsProvider: Failed processing analytics event e_tracking_error, cause: ${e.message}")) + FirebaseCrashlytics.getInstance().recordException( + ReportAnalyticError( + "AnalyticsProvider: Error processing e_tracking_error, cause: ${e.message}" + ) + ) } } } @@ -84,7 +93,12 @@ class AnalyticsProvider @Inject constructor(context: Context) { val bundle = Bundle().apply { putString("eventName", event.eventId) event.metadata.forEach { - putString(it.key, AnalyticsEvent.safelyTrimParamValue(it.value.toString())) + if (it.value is Boolean) { + // Simplify queries, match Falcon behavior + putBoolean(it.key, it.value as Boolean) + } else { + putString(it.key, AnalyticsEvent.safelyTrimParamValue(it.value.toString())) + } } } diff --git a/android/apolloui/src/main/java/io/muun/apollo/data/db/base/ElementNotFoundException.java b/android/apolloui/src/main/java/io/muun/apollo/data/db/base/ElementNotFoundException.java index 3d93cf86..b8b696cf 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/data/db/base/ElementNotFoundException.java +++ b/android/apolloui/src/main/java/io/muun/apollo/data/db/base/ElementNotFoundException.java @@ -1,12 +1,20 @@ package io.muun.apollo.data.db.base; +import io.muun.apollo.domain.errors.ErrorClassification; import io.muun.apollo.domain.errors.MuunError; import android.text.TextUtils; import com.squareup.sqldelight.Query; +import org.jetbrains.annotations.NotNull; public class ElementNotFoundException extends MuunError { + @NotNull + @Override + public ErrorClassification getClassification() { + return ErrorClassification.UNEXPECTED; + } + public ElementNotFoundException(Query query) { super("Expected unique result for query not found. Statement: " + query.toString()); getMetadata().put("query", query.toString()); diff --git a/android/apolloui/src/main/java/io/muun/apollo/data/di/DataComponent.kt b/android/apolloui/src/main/java/io/muun/apollo/data/di/DataComponent.kt index 2e2cc3e6..299326a8 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/data/di/DataComponent.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/data/di/DataComponent.kt @@ -18,6 +18,7 @@ import io.muun.apollo.data.external.NotificationService import io.muun.apollo.data.net.HoustonClient import io.muun.apollo.data.net.NetworkInfoProvider import io.muun.apollo.data.nfc.NfcBridgerFactory +import io.muun.apollo.data.nfc.NfcEmpiricalCache import io.muun.apollo.data.os.ClipboardProvider import io.muun.apollo.data.os.Configuration import io.muun.apollo.data.os.execution.ExecutionTransformerFactory @@ -122,6 +123,8 @@ interface DataComponent : ActionComponent { fun nfcBridgerFactory(): NfcBridgerFactory + fun nfcEmpiricalCache(): NfcEmpiricalCache + fun feeBumpFunctionsProvider(): FeeBumpFunctionsProvider fun metricsProvider(): MetricsProvider diff --git a/android/apolloui/src/main/java/io/muun/apollo/data/di/DataModule.kt b/android/apolloui/src/main/java/io/muun/apollo/data/di/DataModule.kt index 2a978a6b..e2d32020 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/data/di/DataModule.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/data/di/DataModule.kt @@ -16,8 +16,10 @@ import io.muun.apollo.data.afs.FileInfoProvider import io.muun.apollo.data.afs.HardwareCapabilitiesProvider import io.muun.apollo.data.afs.LocaleInfoProvider import io.muun.apollo.data.afs.MetricsProvider +import io.muun.apollo.data.afs.NfcExtendedApduProbe import io.muun.apollo.data.afs.NfcProvider import io.muun.apollo.data.afs.PackageManagerInfoProvider +import io.muun.apollo.data.afs.RuntimeInfoProvider import io.muun.apollo.data.afs.SystemCapabilitiesProvider import io.muun.apollo.data.afs.SystemInfoProvider import io.muun.apollo.data.afs.TelephonyInfoProvider @@ -40,9 +42,11 @@ import io.muun.apollo.data.external.NotificationService import io.muun.apollo.data.fs.LibwalletDataDirectory import io.muun.apollo.data.libwallet.HttpClientSessionProvider import io.muun.apollo.data.libwallet.KeyProvider +import io.muun.apollo.data.libwallet.LibwalletSecureKeyValueStorage import io.muun.apollo.data.libwallet.grpc.GrpcChannelFactory import io.muun.apollo.data.net.NetworkInfoProvider import io.muun.apollo.data.nfc.AndroidNfcBridge +import io.muun.apollo.data.nfc.NfcEmpiricalCache import io.muun.apollo.data.os.Configuration import io.muun.apollo.data.os.execution.ExecutionTransformerFactory import io.muun.apollo.data.os.execution.JobExecutor @@ -191,6 +195,7 @@ class DataModule( httpClientSessionProvider: HttpClientSessionProvider, nfcBridge: AndroidNfcBridge, keyProvider: KeyProvider, + secureKeyValueStorage: LibwalletSecureKeyValueStorage, ): Config { libwalletDataDirectory.ensureExists() val dataDir = libwalletDataDirectory.path.absolutePath @@ -204,6 +209,7 @@ class DataModule( config.httpClientSessionProvider = httpClientSessionProvider config.nfcBridge = nfcBridge config.keyProvider = keyProvider + config.secureKeyValueStorage = secureKeyValueStorage val network = when (Globals.INSTANCE.network.getId()) { NetworkParameters.ID_MAINNET -> "mainnet" @@ -239,7 +245,10 @@ class DataModule( @Provides @Singleton - fun provideMetricsProvider(context: Context): MetricsProvider { + fun provideMetricsProvider( + context: Context, + nfcEmpiricalCache: NfcEmpiricalCache, + ): MetricsProvider { val activityManagerInfoProvider = ActivityManagerInfoProvider(context) val telephonyInfoProvider = TelephonyInfoProvider(context) val hardwareCapabilitiesProvider = HardwareCapabilitiesProvider(context) @@ -254,11 +263,13 @@ class DataModule( val dateTimeZoneProvider = DateTimeZoneProvider(context) val localeInfoProvider = LocaleInfoProvider() val trafficStatsInfoProvider = TrafficStatsInfoProvider() - val nfcProvider = NfcProvider(context) + val nfcExtendedApduProbe = NfcExtendedApduProbe(context) + val nfcProvider = NfcProvider(context, nfcExtendedApduProbe, nfcEmpiricalCache) val batteryInfoProvider = BatteryInfoProvider(context) val systemInfoProvider = SystemInfoProvider() val networkInfoProvider = NetworkInfoProvider(context) val isRootedDeviceAction = IsRootedDeviceAction(context) + val runtimeInfoProvider = RuntimeInfoProvider(context) return MetricsProvider( activityManagerInfoProvider, @@ -277,7 +288,8 @@ class DataModule( batteryInfoProvider, systemInfoProvider, networkInfoProvider, - isRootedDeviceAction + isRootedDeviceAction, + runtimeInfoProvider ) } } \ No newline at end of file diff --git a/android/apolloui/src/main/java/io/muun/apollo/data/fs/FileCache.kt b/android/apolloui/src/main/java/io/muun/apollo/data/fs/FileCache.kt index b3385406..58a91133 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/data/fs/FileCache.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/data/fs/FileCache.kt @@ -21,7 +21,8 @@ class FileCache @Inject constructor(val context: Context) { enum class Entry(val fileName: String) { EMERGENCY_KIT_NO_META("tmp_ek_no_meta.pdf"), EMERGENCY_KIT("Muun-Emergency-Kit.pdf"), - LOGCAT("logcat.txt") + LOGCAT("logcat.txt"), + LIBWALLET_DATA("libwallet-data.zip"), } fun get(entry: Entry): LocalFile = diff --git a/android/apolloui/src/main/java/io/muun/apollo/data/fs/LibwalletDataDirectory.kt b/android/apolloui/src/main/java/io/muun/apollo/data/fs/LibwalletDataDirectory.kt index 8c6267d4..082e4a4d 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/data/fs/LibwalletDataDirectory.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/data/fs/LibwalletDataDirectory.kt @@ -16,8 +16,4 @@ class LibwalletDataDirectory @Inject constructor (context: Context) { path.mkdirs() } - fun reset() { - path.deleteRecursively() - ensureExists() - } } \ No newline at end of file diff --git a/android/apolloui/src/main/java/io/muun/apollo/data/libwallet/LibwalletSecureKeyValueStorage.kt b/android/apolloui/src/main/java/io/muun/apollo/data/libwallet/LibwalletSecureKeyValueStorage.kt new file mode 100644 index 00000000..4d0112d0 --- /dev/null +++ b/android/apolloui/src/main/java/io/muun/apollo/data/libwallet/LibwalletSecureKeyValueStorage.kt @@ -0,0 +1,89 @@ +package io.muun.apollo.data.libwallet + +import androidx.annotation.VisibleForTesting +import app_provided_data.App_provided_data +import app_provided_data.SecureKeyValueStorage +import app_provided_data.SecureKvGetResponse +import app_provided_data.SecureKvResponse +import io.muun.apollo.data.os.secure_storage.SecureStorageProvider +import io.muun.apollo.data.os.secure_storage.SecureStorageProvider.SecureStorageNoSuchElementError +import io.muun.apollo.domain.errors.SecureStorageError +import timber.log.Timber +import javax.inject.Inject + +class LibwalletSecureKeyValueStorage @Inject constructor( + // TODO: SecureStorageProvider is legacy and is going to be migrated to kotlin + // in a future modernization. + private val secureStorageProvider: SecureStorageProvider, +) : SecureKeyValueStorage { + + override fun put(key: String, value: ByteArray): SecureKvResponse { + return SecureKvResponse().apply { statusCode = putStatus(key, value) } + } + + override fun get(key: String): SecureKvGetResponse { + val (bytes, status) = getValueAndStatus(key) + + return SecureKvGetResponse().apply { + value = bytes + statusCode = status + } + } + + override fun delete(key: String): SecureKvResponse { + return SecureKvResponse().apply { statusCode = deleteStatus(key) } + } + + override fun wipe(): SecureKvResponse { + return SecureKvResponse().apply { statusCode = wipeStatus() } + } + + @VisibleForTesting + internal fun putStatus(key: String, value: ByteArray): Int { + return try { + secureStorageProvider.put(key, value) + App_provided_data.SecureKvStatusOk + } catch (e: Exception) { + Timber.e(e) + App_provided_data.SecureKvStatusStorageFailed + } + } + + @VisibleForTesting + internal fun getValueAndStatus(key: String): Pair { + return try { + val bytes = secureStorageProvider.get(key) + bytes to App_provided_data.SecureKvStatusOk + } catch (e: SecureStorageNoSuchElementError) { + byteArrayOf() to App_provided_data.SecureKvStatusNotFound + } catch (e: SecureStorageError) { + Timber.e(e) + byteArrayOf() to App_provided_data.SecureKvStatusDecryptionFailed + } catch (e: Exception) { + Timber.e(e) + byteArrayOf() to App_provided_data.SecureKvStatusStorageFailed + } + } + + @VisibleForTesting + internal fun deleteStatus(key: String): Int { + return try { + secureStorageProvider.delete(key) + App_provided_data.SecureKvStatusOk + } catch (e: Exception) { + Timber.e(e) + App_provided_data.SecureKvStatusStorageFailed + } + } + + @VisibleForTesting + internal fun wipeStatus(): Int { + return try { + secureStorageProvider.wipe() + App_provided_data.SecureKvStatusOk + } catch (e: Exception) { + Timber.e(e) + App_provided_data.SecureKvStatusStorageFailed + } + } +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/data/logging/Crashlytics.kt b/android/apolloui/src/main/java/io/muun/apollo/data/logging/Crashlytics.kt index 77685b28..913d7076 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/data/logging/Crashlytics.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/data/logging/Crashlytics.kt @@ -44,7 +44,7 @@ object Crashlytics { private var defaultUncaughtExceptionHandler: Thread.UncaughtExceptionHandler? = null @JvmStatic - fun init(application: Application) { + fun init(application: Application, firebaseTestLabDevice: Boolean) { this.earlyMetricsProvider = EarlyMetricsProvider(application) this.analyticsProvider = AnalyticsProvider(application) this.analyticsProvider?.loadBigQueryPseudoId() @@ -57,6 +57,8 @@ object Crashlytics { this.defaultUncaughtExceptionHandler = Thread.getDefaultUncaughtExceptionHandler() Thread.setDefaultUncaughtExceptionHandler(customUncaughtExceptionHandler) + crashlytics?.setCustomKey("isFirebaseTestLabDevice", firebaseTestLabDevice) + FirebaseInstallations.getInstance().id .addOnCompleteListener { task -> if (task.isSuccessful) { @@ -156,6 +158,8 @@ object Crashlytics { "isLowMemoryKillReportSupported", earlyMetricsProvider.isLowMemoryKillReportSupported ) + + crashlytics?.setCustomKey("isUserAMonkey", earlyMetricsProvider.isUserAMonkey) } private fun getSupportedAbi(): String = diff --git a/android/apolloui/src/main/java/io/muun/apollo/data/net/base/NetworkException.java b/android/apolloui/src/main/java/io/muun/apollo/data/net/base/NetworkException.java index 2bdb3426..d6a0184b 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/data/net/base/NetworkException.java +++ b/android/apolloui/src/main/java/io/muun/apollo/data/net/base/NetworkException.java @@ -1,8 +1,17 @@ package io.muun.apollo.data.net.base; -import io.muun.common.exception.PotentialBug; +import io.muun.apollo.domain.errors.ErrorClassification; +import io.muun.apollo.domain.errors.MuunError; -public class NetworkException extends RuntimeException implements PotentialBug { +import org.jetbrains.annotations.NotNull; + +public class NetworkException extends MuunError { + + @NotNull + @Override + public ErrorClassification getClassification() { + return ErrorClassification.UNEXPECTED; + } public NetworkException(String url, Throwable cause) { super("Can't reach " + url, cause); diff --git a/android/apolloui/src/main/java/io/muun/apollo/data/net/base/ServerFailureException.java b/android/apolloui/src/main/java/io/muun/apollo/data/net/base/ServerFailureException.java index f03ef8a0..91b13c24 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/data/net/base/ServerFailureException.java +++ b/android/apolloui/src/main/java/io/muun/apollo/data/net/base/ServerFailureException.java @@ -1,7 +1,18 @@ package io.muun.apollo.data.net.base; +import io.muun.apollo.domain.errors.ErrorClassification; +import io.muun.apollo.domain.errors.MuunError; + +import org.jetbrains.annotations.NotNull; + +public class ServerFailureException extends MuunError { + + @NotNull + @Override + public ErrorClassification getClassification() { + return ErrorClassification.UNEXPECTED; + } -public class ServerFailureException extends RuntimeException { public ServerFailureException(Throwable cause) { super("We're facing a temporary issue. Please, try again later", cause); } diff --git a/android/apolloui/src/main/java/io/muun/apollo/data/nfc/NfcEmpiricalCache.kt b/android/apolloui/src/main/java/io/muun/apollo/data/nfc/NfcEmpiricalCache.kt new file mode 100644 index 00000000..c6849ecf --- /dev/null +++ b/android/apolloui/src/main/java/io/muun/apollo/data/nfc/NfcEmpiricalCache.kt @@ -0,0 +1,71 @@ +package io.muun.apollo.data.nfc + +import dagger.Lazy +import io.muun.apollo.domain.libwallet.LibwalletClient +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Write-through in-memory cache for the empirical NFC Extended APDU signals persisted in + * libwallet's key-value storage. Reads on the hot path (BackgroundExecutionMetrics + * serialisation, once per outgoing HTTP request) are pure volatile field reads with no + * gRPC involved. Writes update memory first and then persist to KV. + * + * The cache is warmed up once at application start via [initFromStorage], so a value + * persisted in a previous session is visible from the very first BEM serialisation. + * On tap, [update] refreshes the cache directly so subsequent BEMs see the fresh value + * immediately. + * + * `LibwalletClient` is held as `dagger.Lazy` to break the DI cycle at graph-resolution + * time — the libwallet client is only resolved from [initFromStorage] and [update], + * both of which run after libwallet is up. + */ +@Singleton +class NfcEmpiricalCache @Inject constructor( + private val libwalletClient: Lazy, +) { + + companion object { + // KV keys registered in libwallet/storage/kv_migrations.go — keep the strings in sync. + private const val EXTENDED_APDU_SUPPORTED = "nfc_extended_apdu_supported" + private const val MAX_TRANSCEIVE_LENGTH = "nfc_max_transceive_length" + } + + @Volatile + var extendedApduSupported: Boolean? = null + private set + + @Volatile + var maxTransceiveLength: Int? = null + private set + + /** + * Warm the cache with values persisted in a previous session. Must be called after + * libwallet's gRPC server has started, before the first BEM serialisation can fire. + */ + fun initFromStorage() { + try { + val client = libwalletClient.get() + extendedApduSupported = client.getBoolean(EXTENDED_APDU_SUPPORTED) + maxTransceiveLength = client.getInt(MAX_TRANSCEIVE_LENGTH) + } catch (t: Throwable) { + Timber.i("Failed to read empirical NFC signals from storage: ${t.message}") + } + } + + /** + * Called on a successful NFC tap: refresh the in-memory cache and persist to KV. + */ + fun update(supported: Boolean, maxTransceiveLength: Int) { + this.extendedApduSupported = supported + this.maxTransceiveLength = maxTransceiveLength + try { + val client = libwalletClient.get() + client.saveBoolean(EXTENDED_APDU_SUPPORTED, supported) + client.saveInt(MAX_TRANSCEIVE_LENGTH, maxTransceiveLength) + } catch (t: Throwable) { + Timber.i("Failed to persist NFC Extended APDU capability: ${t.message}") + } + } +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/data/nfc/NfcSessionImpl.kt b/android/apolloui/src/main/java/io/muun/apollo/data/nfc/NfcSessionImpl.kt index db997111..f570aad4 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/data/nfc/NfcSessionImpl.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/data/nfc/NfcSessionImpl.kt @@ -10,16 +10,23 @@ import java.io.IOException * Provides access to the Android NFC API, in particular to ISO-DEP (ISO 14443-4) properties and * I/ O operations on a Tag. Wraps and enhances Android's IsoDep class. */ -internal class NfcSessionImpl(private val nfcAtag: IsoDep) : NfcSession { +internal class NfcSessionImpl( + private val nfcAtag: IsoDep, + private val empiricalCache: NfcEmpiricalCache, +) : NfcSession { override fun connect() { nfcAtag.connect() - Timber.d("maxTransceiveLength: ${nfcAtag.maxTransceiveLength}") - Timber.d("isExtendedLengthApduSupported: ${nfcAtag.isExtendedLengthApduSupported}") + val maxLen = nfcAtag.maxTransceiveLength + val supportsExtended = nfcAtag.isExtendedLengthApduSupported + Timber.i("maxTransceiveLength: $maxLen") + Timber.i("isExtendedLengthApduSupported: $supportsExtended") Timber.d("historicalBytes: ${Encodings.bytesToHex(nfcAtag.historicalBytes)}") Timber.d("Timeout: ${nfcAtag.timeout}") + empiricalCache.update(supportsExtended, maxLen) + nfcAtag.timeout *= 5 } diff --git a/android/apolloui/src/main/java/io/muun/apollo/data/nfc/api/NfcSessionBuilder.kt b/android/apolloui/src/main/java/io/muun/apollo/data/nfc/api/NfcSessionBuilder.kt index 703d5b58..efe04b13 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/data/nfc/api/NfcSessionBuilder.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/data/nfc/api/NfcSessionBuilder.kt @@ -1,12 +1,13 @@ package io.muun.apollo.data.nfc.api import android.nfc.tech.IsoDep +import io.muun.apollo.data.nfc.NfcEmpiricalCache import io.muun.apollo.data.nfc.NfcSessionImpl object NfcSessionBuilder { - fun forTag(tag: IsoDep): NfcSession { - return NfcSessionImpl(tag) + fun forTag(tag: IsoDep, empiricalCache: NfcEmpiricalCache): NfcSession { + return NfcSessionImpl(tag, empiricalCache) } fun fakeNfcSession(): NfcSession { diff --git a/android/apolloui/src/main/java/io/muun/apollo/data/os/OS.kt b/android/apolloui/src/main/java/io/muun/apollo/data/os/OS.kt index 8e2c5e6c..a14c132a 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/data/os/OS.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/data/os/OS.kt @@ -198,6 +198,22 @@ object OS { fun supportsAvailableNfcAntennas(): Boolean = isAndroidUpsideDownCakeOrNewer() + /** + * Whether this OS supports {@link PackageManager#FEATURE_NFC_OFF_HOST_CARD_EMULATION_UICC} + * and {@link PackageManager#FEATURE_NFC_OFF_HOST_CARD_EMULATION_ESE}, both introduced in + * Q-10-29. + */ + fun supportsNfcOffHostCardEmulation(): Boolean = + isAndroidQOrNewer() + + /** + * Whether this OS has restrictions on non-SDK interfaces, which was introduced in P-9-28. + * @see + * Restrictions on non-SDK interfaces + */ + fun hasNonSdkInterfacesRestrictions(): Boolean = + isAndroidPOrNewer() + /** * Whether this OS supports {@link android.app.ActivityManager.isBackgroundRestricted}, which * was introduced in P-9-28. diff --git a/android/apolloui/src/main/java/io/muun/apollo/data/os/secure_storage/KeyStoreProvider.java b/android/apolloui/src/main/java/io/muun/apollo/data/os/secure_storage/KeyStoreProvider.java index a5009004..5d8bec52 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/data/os/secure_storage/KeyStoreProvider.java +++ b/android/apolloui/src/main/java/io/muun/apollo/data/os/secure_storage/KeyStoreProvider.java @@ -1,6 +1,6 @@ package io.muun.apollo.data.os.secure_storage; -import io.muun.apollo.domain.errors.MuunError; +import io.muun.apollo.domain.errors.UnclassifiedError; import io.muun.common.utils.Preconditions; import android.annotation.TargetApi; @@ -360,7 +360,9 @@ public void wipe() { Timber.e(e); } } else { - Timber.e(new MuunKeyStoreException(new MuunError("Unknown alias: " + alias))); + Timber.e(new MuunKeyStoreException( + new UnclassifiedError("Unknown alias: " + alias)) + ); } } } diff --git a/android/apolloui/src/main/java/io/muun/apollo/data/os/secure_storage/MuunKeyStoreException.kt b/android/apolloui/src/main/java/io/muun/apollo/data/os/secure_storage/MuunKeyStoreException.kt index 1ec84a82..47717659 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/data/os/secure_storage/MuunKeyStoreException.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/data/os/secure_storage/MuunKeyStoreException.kt @@ -2,11 +2,14 @@ package io.muun.apollo.data.os.secure_storage import android.security.KeyStoreException import io.muun.apollo.data.os.OS +import io.muun.apollo.domain.errors.ErrorClassification import io.muun.apollo.domain.errors.MuunError import io.muun.apollo.domain.utils.getTypedClause class MuunKeyStoreException(cause: Throwable) : MuunError(cause) { + override val classification = ErrorClassification.UNEXPECTED + init { val maybeKeystoreException = cause.getTypedClause() diff --git a/android/apolloui/src/main/java/io/muun/apollo/data/secure_key_value_storage/SecureKeyValueStorageRepository.kt b/android/apolloui/src/main/java/io/muun/apollo/data/secure_key_value_storage/SecureKeyValueStorageRepository.kt new file mode 100644 index 00000000..f5cd3bca --- /dev/null +++ b/android/apolloui/src/main/java/io/muun/apollo/data/secure_key_value_storage/SecureKeyValueStorageRepository.kt @@ -0,0 +1,26 @@ +package io.muun.apollo.data.secure_key_value_storage + +import io.muun.apollo.domain.libwallet.LibwalletClient +import io.muun.apollo.domain.secure_key_value_storage.Secret +import javax.inject.Inject + +class SecureKeyValueStorageRepository @Inject constructor( + private val libwalletClient: LibwalletClient, +) { + + fun put(key: String, value: ByteArray) { + libwalletClient.secureKeyValueStoragePut(key, value) + } + + fun get(key: String): Secret { + return libwalletClient.secureKeyValueStorageGet(key) + } + + fun delete(key: String) { + libwalletClient.secureKeyValueStorageDelete(key) + } + + fun wipe() { + libwalletClient.secureKeyValueStorageWipe() + } +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/action/CurrencyActions.java b/android/apolloui/src/main/java/io/muun/apollo/domain/action/CurrencyActions.java index 44e2b727..9111b3c7 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/action/CurrencyActions.java +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/action/CurrencyActions.java @@ -7,7 +7,6 @@ import io.muun.common.model.Currency; import androidx.annotation.VisibleForTesting; -import org.hibernate.validator.constraints.NotEmpty; import timber.log.Timber; import java.util.ArrayList; @@ -21,6 +20,7 @@ import javax.money.Monetary; import javax.money.MonetaryException; import javax.money.UnknownCurrencyException; +import javax.validation.constraints.NotEmpty; @Singleton diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/action/LogoutActions.java b/android/apolloui/src/main/java/io/muun/apollo/domain/action/LogoutActions.java index e7f95b16..94f633a0 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/action/LogoutActions.java +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/action/LogoutActions.java @@ -3,13 +3,13 @@ import io.muun.apollo.data.async.tasks.TaskScheduler; import io.muun.apollo.data.db.DaoManager; import io.muun.apollo.data.external.NotificationService; -import io.muun.apollo.data.fs.LibwalletDataDirectory; import io.muun.apollo.data.os.secure_storage.SecureStorageProvider; import io.muun.apollo.domain.ApplicationLockManager; import io.muun.apollo.domain.SignupDraftManager; import io.muun.apollo.domain.action.base.AsyncActionStore; import io.muun.apollo.domain.action.session.ClearRepositoriesAction; import io.muun.apollo.domain.errors.UnrecoverableUserLogoutError; +import io.muun.apollo.domain.libwallet.LibwalletClient; import io.muun.apollo.domain.model.SignupDraft; import io.muun.apollo.domain.selector.LogoutOptionsSelector; import io.muun.apollo.domain.selector.LogoutOptionsSelector.LogoutOptions; @@ -46,7 +46,7 @@ public class LogoutActions { private final NotificationService notificationService; - private final LibwalletDataDirectory libwalletDataDirectory; + private final LibwalletClient libwalletClient; /** * Constructor. @@ -63,7 +63,7 @@ public LogoutActions( TaskScheduler taskScheduler, SecureStorageProvider secureStorageProvider, NotificationService notificationService, - LibwalletDataDirectory libwalletDataDirectory + LibwalletClient libwalletClient ) { this.contactActions = contactActions; @@ -77,7 +77,7 @@ public LogoutActions( this.taskScheduler = taskScheduler; this.secureStorageProvider = secureStorageProvider; this.notificationService = notificationService; - this.libwalletDataDirectory = libwalletDataDirectory; + this.libwalletClient = libwalletClient; } /** @@ -163,6 +163,6 @@ private void destroyWallet() { lockManager.cancelAutoSetLocked(); notificationService.cancelAllNotifications(); - libwalletDataDirectory.reset(); + libwalletClient.resetData(); } } diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/action/OperationActions.java b/android/apolloui/src/main/java/io/muun/apollo/domain/action/OperationActions.java index 98b55d50..32b44501 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/action/OperationActions.java +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/action/OperationActions.java @@ -84,6 +84,7 @@ public Observable fetchReplaceOperations() { /** * Fetches a single operation from the database, by id. */ + @Deprecated // We should fetch by Hid using OperationSelector public Observable fetchOperationById(Long operationId) { return operationDao.fetchById(operationId); } diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/action/debug/BuildDebugDataEmailReportAction.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/action/debug/BuildDebugDataEmailReportAction.kt new file mode 100644 index 00000000..34df01ea --- /dev/null +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/action/debug/BuildDebugDataEmailReportAction.kt @@ -0,0 +1,54 @@ +package io.muun.apollo.domain.action.debug + +import android.net.Uri +import io.muun.apollo.data.fs.FileCache +import io.muun.apollo.domain.EmailReportManager +import io.muun.apollo.domain.action.base.BaseAsyncAction0 +import io.muun.apollo.domain.analytics.Analytics +import io.muun.apollo.domain.libwallet.LibwalletClient +import io.muun.apollo.domain.model.report.EmailReport +import io.muun.apollo.domain.model.report.ErrorReportBuilder +import io.muun.apollo.presentation.app.Logcat +import rx.Observable +import javax.inject.Inject + +class BuildDebugDataEmailReportAction @Inject constructor( + private val fileCache: FileCache, + private val libwalletClient: LibwalletClient, + private val logcat: Logcat, + private val emailReportManager: EmailReportManager, + private val analytics: Analytics, +) : BaseAsyncAction0() { + + override fun action(): Observable = + Observable.fromCallable { + val attachmentUris = ArrayList() + + // Attach libwallet directory zip + attachmentUris.add(getLibwalletZipUri()) + + // Attach logcat + logcat.getLogsUri()?.let { attachmentUris.add(it) } + + // Build email report with a fake exception for metadata + val fakeError = RuntimeException("Debug data request") + val report = ErrorReportBuilder.build(fakeError) + analytics.attachAnalyticsMetadata(report) + + // Building abridged email report to avoid problems with TransactionTooLargeException + // due to adding too much data to email intent. + emailReportManager + .buildAbridgedEmailReport(report, "SettingsPresenter") + .withAttachments(attachmentUris) + } + + private fun getLibwalletZipUri(): Uri { + + // Zip libwallet directory via libwallet gRPC + val zipFile = fileCache.getFile(FileCache.Entry.LIBWALLET_DATA) + zipFile.parentFile?.mkdirs() + libwalletClient.zipDataDir(zipFile.absolutePath) + + return fileCache.get(FileCache.Entry.LIBWALLET_DATA).uri + } +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/action/debug/BuildErrorEmailReportAction.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/action/debug/BuildErrorEmailReportAction.kt new file mode 100644 index 00000000..8165ec9d --- /dev/null +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/action/debug/BuildErrorEmailReportAction.kt @@ -0,0 +1,30 @@ +package io.muun.apollo.domain.action.debug + +import io.muun.apollo.domain.EmailReportManager +import io.muun.apollo.domain.action.base.BaseAsyncAction2 +import io.muun.apollo.domain.analytics.Analytics +import io.muun.apollo.domain.model.report.EmailReport +import io.muun.apollo.domain.model.report.ErrorReportBuilder +import io.muun.apollo.presentation.app.Logcat +import rx.Observable +import javax.inject.Inject + +class BuildErrorEmailReportAction @Inject constructor( + private val logcat: Logcat, + private val emailReportManager: EmailReportManager, + private val analytics: Analytics, +) : BaseAsyncAction2() { + + override fun action(error: Throwable, presenterName: String): Observable = + Observable.fromCallable { + val report = ErrorReportBuilder.build(error) + analytics.attachAnalyticsMetadata(report) + + var emailReport = emailReportManager + .buildEmailReport(report, presenterName) + + logcat.getLogsUri()?.let { emailReport = emailReport.withAttachments(listOf(it)) } + + emailReport + } +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/action/ek/AddEmergencyKitMetadataAction.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/action/ek/AddEmergencyKitMetadataAction.kt index 6a5de82f..95a33bf9 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/action/ek/AddEmergencyKitMetadataAction.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/action/ek/AddEmergencyKitMetadataAction.kt @@ -10,8 +10,8 @@ import javax.inject.Singleton @Singleton class AddEmergencyKitMetadataAction @Inject constructor( - private val fileCache: FileCache -): BaseAsyncAction1() { + private val fileCache: FileCache, +) : BaseAsyncAction1() { override fun action(metadata: String): Observable = Observable.defer { createFileWithMetadata(metadata) } @@ -22,7 +22,7 @@ class AddEmergencyKitMetadataAction @Inject constructor( fileCache.get(Entry.EMERGENCY_KIT_NO_META).path, // source fileCache.get(Entry.EMERGENCY_KIT).path // destination ) - + return Observable.just(null) } diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/action/ek/GenerateEmergencyKitPDF.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/action/ek/GenerateEmergencyKitPDF.kt index 2e877527..a0e83e62 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/action/ek/GenerateEmergencyKitPDF.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/action/ek/GenerateEmergencyKitPDF.kt @@ -8,14 +8,25 @@ import io.muun.apollo.domain.action.base.BaseAsyncAction0 import io.muun.apollo.domain.libwallet.LibwalletClient import io.muun.apollo.domain.model.EmergencyKitExport import io.muun.apollo.domain.model.GeneratedEmergencyKitInfo +import io.muun.apollo.domain.utils.EK_CHILD_GO_COMPONENTS_RENDERING +import io.muun.apollo.domain.utils.EK_CHILD_GO_CREATE_AND_SAVE_ON_DISK +import io.muun.apollo.domain.utils.EK_CHILD_GO_EMBED_METADATA +import io.muun.apollo.domain.utils.EK_CHILD_GO_LOAD_TRANSLATIONS +import io.muun.apollo.domain.utils.EK_CHILD_GO_REGISTER_FONTS +import io.muun.apollo.domain.utils.EK_CHILD_GO_REGISTER_IMAGES +import io.muun.apollo.domain.utils.EK_CHILD_GO_TOTAL_HEAP_ALLOCATED +import io.muun.apollo.domain.utils.EK_CHILD_GO_TOTAL_INSIDE_GO +import io.muun.apollo.domain.utils.EK_CHILD_GO_TOTAL_OBJECTS_ALLOCATED import io.muun.apollo.domain.utils.EK_CHILD_MUUN_FINGERPRINT import io.muun.apollo.domain.utils.EK_CHILD_MUUN_KEY import io.muun.apollo.domain.utils.EK_CHILD_RC_CHECKSUM import io.muun.apollo.domain.utils.EK_CHILD_USER_FINGERPRINT import io.muun.apollo.domain.utils.EK_CHILD_USER_KEY -import io.muun.apollo.domain.utils.TraceLabel import io.muun.apollo.domain.utils.TimeTracker +import io.muun.apollo.domain.utils.Trace +import io.muun.apollo.domain.utils.TraceLabel import io.muun.common.crypto.ChallengeType +import rpc.WalletServiceOuterClass.GenerateEmergencyKitPDFResponse import rx.Observable import timber.log.Timber import java.util.Locale @@ -47,8 +58,11 @@ class GenerateEmergencyKitPDF @Inject constructor( watchData().first() .map { data -> - timeTracker.start(TraceLabel.EK_NEW_PDF_GENERATION).use { - generatePDF(data) + val trace = timeTracker.start(TraceLabel.EK_NEW_PDF_GENERATION) + try { + generatePDF(data, trace) + } finally { + trace.finish() } } .doOnNext { ek -> @@ -72,7 +86,7 @@ class GenerateEmergencyKitPDF @Inject constructor( } } - private fun generatePDF(data: RequiredData): GeneratedEmergencyKitInfo { + private fun generatePDF(data: RequiredData, trace: Trace): GeneratedEmergencyKitInfo { // Clear previously saved files: fileCache.delete(FileCache.Entry.EMERGENCY_KIT_NO_META) fileCache.delete(FileCache.Entry.EMERGENCY_KIT) @@ -85,6 +99,8 @@ class GenerateEmergencyKitPDF @Inject constructor( language = Locale.getDefault().language ) + addRenderProfiling(trace, result) + userRepository.storeEmergencyKitVerificationCode(result.verificationCode) return GeneratedEmergencyKitInfo( @@ -93,6 +109,23 @@ class GenerateEmergencyKitPDF @Inject constructor( ) } + /** + * Forward libwallet's per-stage render profiling as children of the PDF-generation trace, so + * prod telemetry can validate the offline profiling against real devices. + */ + private fun addRenderProfiling(trace: Trace, result: GenerateEmergencyKitPDFResponse) { + val profiling = result.profiling + trace.addChild(EK_CHILD_GO_LOAD_TRANSLATIONS, profiling.loadTranslationsMs) + trace.addChild(EK_CHILD_GO_REGISTER_FONTS, profiling.registerFontsMs) + trace.addChild(EK_CHILD_GO_REGISTER_IMAGES, profiling.registerImagesMs) + trace.addChild(EK_CHILD_GO_COMPONENTS_RENDERING, profiling.componentsRenderingMs) + trace.addChild(EK_CHILD_GO_CREATE_AND_SAVE_ON_DISK, profiling.createAndSaveOnDiskMs) + trace.addChild(EK_CHILD_GO_TOTAL_HEAP_ALLOCATED, profiling.totalHeapAllocatedBytes) + trace.addChild(EK_CHILD_GO_TOTAL_OBJECTS_ALLOCATED, profiling.totalObjectsAllocated) + trace.addChild(EK_CHILD_GO_EMBED_METADATA, profiling.embedMetadataMs) + trace.addChild(EK_CHILD_GO_TOTAL_INSIDE_GO, profiling.totalInsideGoMs) + } + private fun watchData(): Observable { val trace = timeTracker.start(TraceLabel.EK_NEW_DATA_FETCHING) val tUserKey = trace.child(EK_CHILD_USER_KEY) diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/action/session/LogoutAction.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/action/session/LogoutAction.kt index 584362b1..d2191f4e 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/action/session/LogoutAction.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/action/session/LogoutAction.kt @@ -4,7 +4,7 @@ import io.muun.apollo.data.preferences.AuthRepository import io.muun.apollo.data.preferences.BiometricsRepository import io.muun.apollo.domain.action.LogoutActions import io.muun.apollo.domain.action.UserActions -import io.muun.apollo.domain.errors.MuunError +import io.muun.apollo.domain.errors.UnclassifiedError import io.muun.common.Optional import timber.log.Timber import javax.inject.Inject @@ -27,7 +27,7 @@ class LogoutAction @Inject constructor( val serverJwt: Optional = authRepository.serverJwt if (!serverJwt.isPresent) { // Shouldn't happen but we wanna know 'cause probably a bug - Timber.e(MuunError("Auth token expected to be present")) + Timber.e(UnclassifiedError("Auth token expected to be present")) return "" } return serverJwt.get() diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/analytics/AnalyticsEvent.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/analytics/AnalyticsEvent.kt index f2b30b15..19542d4c 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/analytics/AnalyticsEvent.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/analytics/AnalyticsEvent.kt @@ -1,6 +1,8 @@ package io.muun.apollo.domain.analytics import android.app.Activity +import io.muun.apollo.domain.errors.ErrorClassification +import io.muun.apollo.domain.errors.MuunError import io.muun.apollo.domain.model.BitcoinUnit import io.muun.apollo.domain.model.NightMode import io.muun.apollo.domain.model.Operation @@ -20,25 +22,40 @@ sealed class AnalyticsEvent(metadataKeyValues: List> = listOf( companion object { private const val ANALYTICS_EVENT_PARAM_VALUE_MAX_LENGTH = 100 - fun buildParamsFor(report: ErrorReport): List> = listOf( + private fun getClassification(error: Throwable?): String = + when (error) { + is MuunError -> error.classification.trackingValue + else -> ErrorClassification.UNEXPECTED.trackingValue + } + + fun buildParamsFor(report: ErrorReport): List> = listOfNotNull( "id" to report.uniqueId, - "tag" to report.tag, + if (report.tag != "Apollo") ("tag" to report.tag) else null, "title" to report.getTrackingTitle(), "message" to report.message, - // Trim error stacktraces to avoid problems (not ideal but should be more than enough) - "error" to report.printError(false), - "metadata" to report.printMetadata() + "metadata" to report.printMetadata(), + "error_classification" to getClassification(report.originalError ?: report.error) ) fun buildParamsFor(error: Throwable): List> { - return listOf( - "errorToString" to error.toString(), + return listOfNotNull( "errorSimpleName" to error.javaClass.getSimpleName(), - "errorLocalizedMessage" to (error.localizedMessage ?: ""), + error.localizedMessage?.let { "errorLocalizedMessage" to it }, *buildParamsFor(ErrorReportBuilder.build(error)).toTypedArray() ) } + private fun buildErrorParams( + cause: Throwable?, + type: S_NEW_OP_ERROR_TYPE, + ): Array> { + return if (cause != null) { + buildParamsFor(cause).toTypedArray() + } else { + arrayOf("error_classification" to type.getFallbackClassification().trackingValue) + } + } + /** * Does Substring while ignoring error if endIndex is out of bounds. */ @@ -222,7 +239,35 @@ sealed class AnalyticsEvent(metadataKeyValues: List> = listOf( INVALID_SWAP, CYCLICAL_SWAP, SWAP_FAILED, - OTHER + OTHER; + + /** + * Fallback classification for when no Throwable cause is available to derive + * the error classification from. When a cause IS available, the classification + * is determined from the Throwable itself (see [buildParamsFor]). + */ + fun getFallbackClassification(): ErrorClassification { + return when (this) { + EXCHANGE_RATE_WINDOW_TOO_OLD, + INVALID_SWAP, + SWAP_FAILED, + OTHER, + -> ErrorClassification.UNEXPECTED + + INVALID_ADDRESS, + EXPIRED_INVOICE, + INVALID_INVOICE, + INVOICE_EXPIRES_TOO_SOON, + INVOICE_ALREADY_USED, + INVOICE_MISSING_AMOUNT, + UNREACHABLE_NODE, + NO_PAYMENT_ROUTE, + INSUFFICIENT_FUNDS, + AMOUNT_BELOW_DUST, + CYCLICAL_SWAP, + -> ErrorClassification.EXPECTED + } + } } class S_NEW_OP_ERROR( @@ -234,8 +279,8 @@ sealed class AnalyticsEvent(metadataKeyValues: List> = listOf( listOf( "origin" to origin.name.lowercase(Locale.getDefault()), - "type" to type.name.lowercase(Locale.getDefault()), - *(cause?.let { buildParamsFor(it).toTypedArray() } ?: emptyArray()), + "error_type" to type.name.lowercase(Locale.getDefault()), + *buildErrorParams(cause, type), *params ) ) diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/debug/DebugExecutable.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/debug/DebugExecutable.kt index b7edd6bc..de2cb99e 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/debug/DebugExecutable.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/debug/DebugExecutable.kt @@ -91,15 +91,6 @@ class DebugExecutable @Inject constructor( nfcSession.close() } - fun resetSecurityCard(nfcSession: NfcSession) { - nfcSession.connect() - val nfcBridger = nfcBridgerFactory.forSession(nfcSession) - - libwalletClient.resetSecurityCard(nfcBridger) - - nfcSession.close() - } - /** * Enable/Disable "Multiple sessions" feature for this user. */ diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/ApiError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/ApiError.kt index 8ec9e41f..db78c5c7 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/ApiError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/ApiError.kt @@ -5,6 +5,8 @@ import io.muun.common.exception.HttpException class ApiError(cause: HttpException) : MuunError(cause) { + override val classification = ErrorClassification.UNEXPECTED + init { metadata["errorCode"] = cause.errorCode.name() metadata["requestId"] = cause.requestId ?: 0 diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/BiometricAuthenticationError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/BiometricAuthenticationError.kt index 0e8b67f3..a37c1d00 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/BiometricAuthenticationError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/BiometricAuthenticationError.kt @@ -7,6 +7,8 @@ class BiometricAuthenticationError( val reason: BiometricAuthenticationErrorReason, ) : UserFacingError(UserFacingErrorMessages.INSTANCE.biometricsAuthenticationError(reason)) { + override val classification = ErrorClassification.EXPECTED + init { metadata["biometricAuthenticationErrorReason"] = reason } diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/BugDetected.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/BugDetected.kt index 12fd0560..4b5842f3 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/BugDetected.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/BugDetected.kt @@ -1,3 +1,5 @@ package io.muun.apollo.domain.errors -class BugDetected(message: String) : MuunError(message) \ No newline at end of file +class BugDetected(message: String) : MuunError(message) { + override val classification = ErrorClassification.UNEXPECTED +} \ No newline at end of file diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/ChallengeKeyMigrationError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/ChallengeKeyMigrationError.kt index f68a5435..a0ba6fb1 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/ChallengeKeyMigrationError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/ChallengeKeyMigrationError.kt @@ -3,4 +3,6 @@ package io.muun.apollo.domain.errors class ChallengeKeyMigrationError(cause: Throwable) : MuunError( "Failed to execute challenge key migration", cause -) +) { + override val classification = ErrorClassification.UNEXPECTED +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/DatabaseError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/DatabaseError.kt index ba1933c5..36c93e15 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/DatabaseError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/DatabaseError.kt @@ -1,4 +1,6 @@ package io.muun.apollo.domain.errors -class DatabaseError(message: String, cause: Throwable) : MuunError(message, cause) +class DatabaseError(message: String, cause: Throwable) : MuunError(message, cause) { + override val classification = ErrorClassification.UNEXPECTED +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/DebtNegativeError.java b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/DebtNegativeError.java index 72921811..5c4c2fd2 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/DebtNegativeError.java +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/DebtNegativeError.java @@ -1,7 +1,15 @@ package io.muun.apollo.domain.errors; +import org.jetbrains.annotations.NotNull; + public class DebtNegativeError extends MuunError { + @NotNull + @Override + public ErrorClassification getClassification() { + return ErrorClassification.UNEXPECTED; + } + public DebtNegativeError( final Long ntsValidAtOperationHid, final long utxoBalance, diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/DeprecatedClientVersionError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/DeprecatedClientVersionError.kt index 856d08f4..93f6a3d6 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/DeprecatedClientVersionError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/DeprecatedClientVersionError.kt @@ -5,4 +5,6 @@ import io.muun.apollo.data.external.UserFacingErrorMessages class DeprecatedClientVersionError : UserFacingError( UserFacingErrorMessages.INSTANCE.deprecatedClientVersion() -) +) { + override val classification = ErrorClassification.UNEXPECTED +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/EmptyFieldError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/EmptyFieldError.kt index 32912961..6e5b3f33 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/EmptyFieldError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/EmptyFieldError.kt @@ -7,6 +7,8 @@ class EmptyFieldError(field: Field) : UserFacingError( UserFacingErrorMessages.INSTANCE.emptyField(field) ) { + override val classification = ErrorClassification.EXPECTED + enum class Field { FIRST_NAME, LAST_NAME, diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/ErrorClassification.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/ErrorClassification.kt new file mode 100644 index 00000000..19f1839a --- /dev/null +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/ErrorClassification.kt @@ -0,0 +1,28 @@ +package io.muun.apollo.domain.errors + +/** + * Classification of errors for analytics and reporting purposes. + * - [EXPECTED]: Errors anticipated from user input or external factors (e.g., insufficient funds, + * invalid address). + * These don't require investigation in general. + * - [UNEXPECTED]: Every error that is NOT caused by user input or expected external factors. + * These may be bugs or conditions not under user control (e.g., network + * connectivity, mempool state, etc.). + * + * All errors are sent to both Analytics and Crashlytics. The classification is included + * as a parameter (`error_classification`) to allow filtering on the analytics backend. + */ +enum class ErrorClassification(val trackingValue: String) { + EXPECTED("expected"), + UNEXPECTED("unexpected") +} + +/** + * Interface for error types that provide their own classification. + * + * MuunError implements this interface as abstract, forcing every concrete + * error subclass to explicitly declare its classification. + */ +interface ClassifiedError { + val classification: ErrorClassification +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/ExpiredActionLinkError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/ExpiredActionLinkError.kt index 3031b5ba..d7aaf951 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/ExpiredActionLinkError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/ExpiredActionLinkError.kt @@ -1,3 +1,5 @@ package io.muun.apollo.domain.errors -class ExpiredActionLinkError : MuunError() +class ExpiredActionLinkError : MuunError() { + override val classification = ErrorClassification.EXPECTED +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/ExpiredSessionError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/ExpiredSessionError.kt index 4b62ee9a..68f33692 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/ExpiredSessionError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/ExpiredSessionError.kt @@ -5,4 +5,6 @@ import io.muun.apollo.data.external.UserFacingErrorMessages class ExpiredSessionError : UserFacingError( UserFacingErrorMessages.INSTANCE.expiredSession() -) +) { + override val classification = ErrorClassification.UNEXPECTED +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/HardwareCapabilityError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/HardwareCapabilityError.kt index b9aebfa0..cb805821 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/HardwareCapabilityError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/HardwareCapabilityError.kt @@ -4,6 +4,7 @@ open class HardwareCapabilityError(capability: String, cause: Throwable) : MuunE "Error reading hardware capability", cause ) { + override val classification = ErrorClassification.UNEXPECTED init { metadata["capability"] = capability diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/IncorrectRecoveryCodeError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/IncorrectRecoveryCodeError.kt index fbb025f9..2e731070 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/IncorrectRecoveryCodeError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/IncorrectRecoveryCodeError.kt @@ -2,5 +2,7 @@ package io.muun.apollo.domain.errors import io.muun.apollo.data.external.UserFacingErrorMessages -class IncorrectRecoveryCodeError: - UserFacingError(UserFacingErrorMessages.INSTANCE.incorrectRecoveryCode()) +class IncorrectRecoveryCodeError : + UserFacingError(UserFacingErrorMessages.INSTANCE.incorrectRecoveryCode()) { + override val classification = ErrorClassification.EXPECTED +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/InitialSyncError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/InitialSyncError.kt index d90027f8..5a467a85 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/InitialSyncError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/InitialSyncError.kt @@ -3,4 +3,6 @@ package io.muun.apollo.domain.errors class InitialSyncError(cause: Throwable) : MuunError( "Error during initial loading. Suggestion: Restart the application and try again", // not user visible cause -) +) { + override val classification = ErrorClassification.UNEXPECTED +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/InitialSyncNetworkError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/InitialSyncNetworkError.kt index 300b9592..f26b052c 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/InitialSyncNetworkError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/InitialSyncNetworkError.kt @@ -3,4 +3,6 @@ package io.muun.apollo.domain.errors class InitialSyncNetworkError(cause: Throwable) : MuunError( "Connection error during initial loading. Suggestion: Restart the application and try again", // not user visible cause -) +) { + override val classification = ErrorClassification.UNEXPECTED +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/InvalidActionLinkError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/InvalidActionLinkError.kt index 6d2c157a..0220e9d7 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/InvalidActionLinkError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/InvalidActionLinkError.kt @@ -2,6 +2,8 @@ package io.muun.apollo.domain.errors class InvalidActionLinkError : MuunError { + override val classification = ErrorClassification.EXPECTED + constructor() : super() constructor(uri: String, expected: String) : super("Invalid Deeplink clicked") { diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/InvalidChallengeSignatureError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/InvalidChallengeSignatureError.kt index 10e3839c..f5e6f48e 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/InvalidChallengeSignatureError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/InvalidChallengeSignatureError.kt @@ -1,3 +1,5 @@ package io.muun.apollo.domain.errors -class InvalidChallengeSignatureError : MuunError() +class InvalidChallengeSignatureError : MuunError() { + override val classification = ErrorClassification.EXPECTED // Wrong password/RC +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/InvalidExchangeRateWindow.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/InvalidExchangeRateWindow.kt index ae2b3763..1243d8e4 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/InvalidExchangeRateWindow.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/InvalidExchangeRateWindow.kt @@ -4,4 +4,6 @@ class InvalidExchangeRateWindow( windowId: Long, latestWindowId: Long, fixedWindowId: Long? -) : MuunError("Unknown rate window id $windowId. Latest: $latestWindowId, Fixed: $fixedWindowId") \ No newline at end of file +) : MuunError("Unknown rate window id $windowId. Latest: $latestWindowId, Fixed: $fixedWindowId") { + override val classification = ErrorClassification.UNEXPECTED +} \ No newline at end of file diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/InvalidJsonError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/InvalidJsonError.kt index 308805bd..5773995c 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/InvalidJsonError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/InvalidJsonError.kt @@ -1,3 +1,5 @@ package io.muun.apollo.domain.errors -class InvalidJsonError(cause: Throwable) : MuunError(cause) +class InvalidJsonError(cause: Throwable) : MuunError(cause) { + override val classification = ErrorClassification.UNEXPECTED +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/LocalStorageIntegrityError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/LocalStorageIntegrityError.kt index 509e8743..1601ad42 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/LocalStorageIntegrityError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/LocalStorageIntegrityError.kt @@ -10,6 +10,8 @@ class LocalStorageIntegrityError( newFcmToken: String, ) : MuunError("Integrity error! Probably something went wrong with a logout") { + override val classification = ErrorClassification.UNEXPECTED + init { metadata["hasValidSessionButNoJwt"] = validSessionButNoJwt metadata["hasJwtButInvalidSession"] = jwtButInvalidSession diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/LocaleNumberParsingError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/LocaleNumberParsingError.kt index b860f5df..e7ac1a13 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/LocaleNumberParsingError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/LocaleNumberParsingError.kt @@ -5,10 +5,10 @@ import java.util.* class LocaleNumberParsingError(number: String, locale: Locale, cause: Throwable) : MuunError( cause ) { + override val classification = ErrorClassification.UNEXPECTED init { metadata["numberString"] = number metadata["locale"] = locale.toString() } - } \ No newline at end of file diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/MissingCurrencyError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/MissingCurrencyError.kt index f6a94732..b858620d 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/MissingCurrencyError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/MissingCurrencyError.kt @@ -13,6 +13,8 @@ class MissingCurrencyError( regionLocales: List = listOf(), ) : MuunError(cause), PotentialBug { + override val classification = ErrorClassification.UNEXPECTED + constructor(cause: UnknownCurrencyException) : this(cause, listOf()) // oh Java!!! OMG init { diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/MissingLocaleError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/MissingLocaleError.kt index 01c62476..10b243ae 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/MissingLocaleError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/MissingLocaleError.kt @@ -7,6 +7,8 @@ class MissingLocaleError( countryCode: String, ) : MuunError("No locales found for country:$countryCode"), PotentialBug { + override val classification = ErrorClassification.UNEXPECTED + init { // Partitioning locales into several parts as they are many and crashlytics imposes a 1kb diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/MissingMigrationError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/MissingMigrationError.kt index 182fae7e..7d890a60 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/MissingMigrationError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/MissingMigrationError.kt @@ -1,3 +1,5 @@ package io.muun.apollo.domain.errors -class MissingMigrationError(message: String) : MuunError(message) +class MissingMigrationError(message: String) : MuunError(message) { + override val classification = ErrorClassification.UNEXPECTED +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/MoneyDecorationError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/MoneyDecorationError.kt index d1b88a20..7a64b957 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/MoneyDecorationError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/MoneyDecorationError.kt @@ -18,6 +18,8 @@ class MoneyDecorationError( after: Int, ) : MuunError(message), PotentialBug { + override val classification = ErrorClassification.UNEXPECTED + init { metadata["newCaretPosition"] = newCaretPosition metadata["resultLength"] = resultLength diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/MuunError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/MuunError.kt index 8df65af8..563a8604 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/MuunError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/MuunError.kt @@ -2,7 +2,7 @@ package io.muun.apollo.domain.errors import java.io.Serializable -open class MuunError : RuntimeException { +abstract class MuunError : RuntimeException, ClassifiedError { constructor() constructor(message: String) : super(message) diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/MuunErrorMapper.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/MuunErrorMapper.kt index 53906048..c0c7035a 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/MuunErrorMapper.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/MuunErrorMapper.kt @@ -4,6 +4,7 @@ import io.muun.apollo.domain.errors.newop.AmountTooSmallError import io.muun.apollo.domain.errors.newop.ExchangeRateWindowTooOldError import io.muun.apollo.domain.errors.newop.InsufficientFundsError import io.muun.apollo.domain.errors.newop.InvalidAddressError +import io.muun.apollo.domain.errors.nfc.NoFeasibleZoneForModelError import io.muun.apollo.domain.errors.p2p.CountryNotSupportedError import io.muun.apollo.domain.errors.p2p.ExpiredVerificationCodeError import io.muun.apollo.domain.errors.p2p.InvalidPhoneNumberError @@ -45,6 +46,7 @@ object MuunErrorMapper { ErrorCode.HTTP_TOO_MANY_REQUESTS to { TooManyRequestsError() }, ErrorCode.STALE_CHALLENGE_KEY to { StaleChallengeKeyError() }, ErrorCode.CREDENTIALS_DONT_MATCH to { CredentialsDontMatchError() }, + ErrorCode.NFC_FEASIBLE_AREA_NOT_FOUND to { NoFeasibleZoneForModelError() }, ) // TODO: values() should be changed when updated kotlin to 1.9.0 or superior diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/NoStackTraceException.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/NoStackTraceException.kt index c9a9a6de..e2dfa587 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/NoStackTraceException.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/NoStackTraceException.kt @@ -1,3 +1,5 @@ package io.muun.apollo.domain.errors -class NoStackTraceException(message: String) : MuunError(message) +class NoStackTraceException(message: String) : MuunError(message) { + override val classification = ErrorClassification.UNEXPECTED +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/NullExpectedDebtBugError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/NullExpectedDebtBugError.kt index 15edfa76..51cdafe0 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/NullExpectedDebtBugError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/NullExpectedDebtBugError.kt @@ -5,4 +5,6 @@ import io.muun.common.exception.PotentialBug class NullExpectedDebtBugError : MuunError( "The expectedDebt, in NTS preference, was found to be null" -), PotentialBug +), PotentialBug { + override val classification = ErrorClassification.UNEXPECTED +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/PeriodicTaskError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/PeriodicTaskError.kt index ec074c77..77236a19 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/PeriodicTaskError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/PeriodicTaskError.kt @@ -2,6 +2,8 @@ package io.muun.apollo.domain.errors class PeriodicTaskError(taskName: String, duration: Long, cause: Throwable) : MuunError(cause) { + override val classification = ErrorClassification.UNEXPECTED + init { metadata["task"] = taskName metadata["duration(secs)"] = duration @@ -9,5 +11,4 @@ class PeriodicTaskError(taskName: String, duration: Long, cause: Throwable) : Mu metadata["cause"] = cause.javaClass.toString() metadata["causeStackTrace"] = cause.stackTraceToString() } - } \ No newline at end of file diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/PeriodicTaskOnMainThreadError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/PeriodicTaskOnMainThreadError.kt index f7d3662c..119159af 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/PeriodicTaskOnMainThreadError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/PeriodicTaskOnMainThreadError.kt @@ -2,8 +2,9 @@ package io.muun.apollo.domain.errors class PeriodicTaskOnMainThreadError(taskName: String) : MuunError() { + override val classification = ErrorClassification.UNEXPECTED + init { metadata["task"] = taskName } - } \ No newline at end of file diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/PlayIntegrityError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/PlayIntegrityError.kt index 254d6f33..3c496299 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/PlayIntegrityError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/PlayIntegrityError.kt @@ -4,6 +4,8 @@ const val UNKNOWN_ERROR = "UNKNOWN_ERROR" open class PlayIntegrityError : MuunError { + override val classification = ErrorClassification.UNEXPECTED + constructor() : super("Google Play Integrity Error") { metadata["name"] = "CANCELLED" metadata["text"] = "Task was canceled" diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/ReportAnalyticError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/ReportAnalyticError.kt index b3464f54..a7a6d563 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/ReportAnalyticError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/ReportAnalyticError.kt @@ -1,3 +1,5 @@ package io.muun.apollo.domain.errors -class ReportAnalyticError(message: String) : MuunError(message) \ No newline at end of file +class ReportAnalyticError(message: String) : MuunError(message) { + override val classification = ErrorClassification.UNEXPECTED +} \ No newline at end of file diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/SecureStorageError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/SecureStorageError.kt index 742c4553..709792ef 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/SecureStorageError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/SecureStorageError.kt @@ -4,6 +4,8 @@ import io.muun.apollo.data.os.secure_storage.SecureStorageProvider open class SecureStorageError : MuunError { + override val classification = ErrorClassification.UNEXPECTED + constructor(debugSnapshot: SecureStorageProvider.DebugSnapshot) { attachDebugSnapshotMetadata(debugSnapshot) } diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/SignupDraftFormatError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/SignupDraftFormatError.kt index 903b1ba1..9c693d19 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/SignupDraftFormatError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/SignupDraftFormatError.kt @@ -2,8 +2,9 @@ package io.muun.apollo.domain.errors class SignupDraftFormatError(draftString: String?) : MuunError() { + override val classification = ErrorClassification.UNEXPECTED + init { metadata["draftString"] = draftString ?: "" } - } \ No newline at end of file diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/TooManyRequestsError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/TooManyRequestsError.kt index c17731f8..8ca268a3 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/TooManyRequestsError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/TooManyRequestsError.kt @@ -1,4 +1,6 @@ package io.muun.apollo.domain.errors -class TooManyRequestsError : MuunError() \ No newline at end of file +class TooManyRequestsError : MuunError() { + override val classification = ErrorClassification.UNEXPECTED +} \ No newline at end of file diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/UnclassifiedError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/UnclassifiedError.kt new file mode 100644 index 00000000..338fbe8d --- /dev/null +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/UnclassifiedError.kt @@ -0,0 +1,15 @@ +package io.muun.apollo.domain.errors + +/** + * Generic error for cases where a specific error class doesn't exist. + * Defaults to UNEXPECTED classification as a safe default. + */ +class UnclassifiedError : MuunError { + + constructor() : super() + constructor(message: String) : super(message) + constructor(cause: Throwable) : super(cause) + constructor(message: String, cause: Throwable) : super(message, cause) + + override val classification = ErrorClassification.UNEXPECTED +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/UnrecoverableUserLogoutError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/UnrecoverableUserLogoutError.kt index bb125de4..29dc0a5b 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/UnrecoverableUserLogoutError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/UnrecoverableUserLogoutError.kt @@ -1,4 +1,6 @@ package io.muun.apollo.domain.errors -class UnrecoverableUserLogoutError : MuunError("This shouldn't happen. It's most definitely a bug!") +class UnrecoverableUserLogoutError : MuunError("This shouldn't happen. It's most definitely a bug!") { + override val classification = ErrorClassification.UNEXPECTED +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/WrappedErrorMessage.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/WrappedErrorMessage.kt index b1de7b1b..800747ac 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/WrappedErrorMessage.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/WrappedErrorMessage.kt @@ -1,3 +1,5 @@ package io.muun.apollo.domain.errors -class WrappedErrorMessage(message: String) : MuunError(message) \ No newline at end of file +class WrappedErrorMessage(message: String) : MuunError(message) { + override val classification = ErrorClassification.UNEXPECTED +} \ No newline at end of file diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/data/MuunDeserializationError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/data/MuunDeserializationError.kt index 6049f04d..6b163736 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/data/MuunDeserializationError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/data/MuunDeserializationError.kt @@ -1,10 +1,13 @@ package io.muun.apollo.domain.errors.data +import io.muun.apollo.domain.errors.ErrorClassification import io.muun.apollo.domain.errors.MuunError import io.muun.common.exception.PotentialBug class MuunDeserializationError(cause: Exception, json: String?) : MuunError(cause), PotentialBug { + override val classification = ErrorClassification.UNEXPECTED + constructor(json: String) : this(IllegalArgumentException(), json) init { diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/data/MuunSerializationError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/data/MuunSerializationError.kt index 3dfade6d..c420bb1f 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/data/MuunSerializationError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/data/MuunSerializationError.kt @@ -1,5 +1,6 @@ package io.muun.apollo.domain.errors.data +import io.muun.apollo.domain.errors.ErrorClassification import io.muun.apollo.domain.errors.MuunError import io.muun.common.exception.PotentialBug import okhttp3.Request @@ -10,6 +11,8 @@ class MuunSerializationError( cause: Throwable, ) : MuunError(cause), PotentialBug { + override val classification = ErrorClassification.UNEXPECTED + init { metadata["supportId"] = supportId metadata["request"] = originalRequest.url().uri().toString() diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/delete_wallet/NonEmptyWalletDeleteException.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/delete_wallet/NonEmptyWalletDeleteException.kt index 482e1a46..77a3a8d7 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/delete_wallet/NonEmptyWalletDeleteException.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/delete_wallet/NonEmptyWalletDeleteException.kt @@ -1,5 +1,8 @@ package io.muun.apollo.domain.errors.delete_wallet +import io.muun.apollo.domain.errors.ErrorClassification import io.muun.apollo.domain.errors.MuunError -class NonEmptyWalletDeleteException(cause: Throwable) : MuunError(cause) +class NonEmptyWalletDeleteException(cause: Throwable) : MuunError(cause) { + override val classification = ErrorClassification.EXPECTED +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/delete_wallet/UnsettledOperationsWalletDeleteException.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/delete_wallet/UnsettledOperationsWalletDeleteException.kt index a5e1a633..de2c1ad3 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/delete_wallet/UnsettledOperationsWalletDeleteException.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/delete_wallet/UnsettledOperationsWalletDeleteException.kt @@ -1,5 +1,8 @@ package io.muun.apollo.domain.errors.delete_wallet +import io.muun.apollo.domain.errors.ErrorClassification import io.muun.apollo.domain.errors.MuunError -class UnsettledOperationsWalletDeleteException(cause: Throwable) : MuunError(cause) \ No newline at end of file +class UnsettledOperationsWalletDeleteException(cause: Throwable) : MuunError(cause) { + override val classification = ErrorClassification.EXPECTED +} \ No newline at end of file diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/ek/EmergencyKitInvalidCodeError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/ek/EmergencyKitInvalidCodeError.kt index a2dd6eb8..45072559 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/ek/EmergencyKitInvalidCodeError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/ek/EmergencyKitInvalidCodeError.kt @@ -1,12 +1,15 @@ package io.muun.apollo.domain.errors.ek import io.muun.apollo.data.external.UserFacingErrorMessages +import io.muun.apollo.domain.errors.ErrorClassification class EmergencyKitInvalidCodeError(providedCode: String) : EmergencyKitVerificationError( UserFacingErrorMessages.INSTANCE.emergencyKitInvalidVerificationCode() ) { + override val classification = ErrorClassification.EXPECTED + init { metadata["providedCode"] = providedCode } diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/ek/EmergencyKitOldCodeError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/ek/EmergencyKitOldCodeError.kt index 081aba5d..eee9a2f9 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/ek/EmergencyKitOldCodeError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/ek/EmergencyKitOldCodeError.kt @@ -1,8 +1,11 @@ package io.muun.apollo.domain.errors.ek import io.muun.apollo.data.external.UserFacingErrorMessages +import io.muun.apollo.domain.errors.ErrorClassification class EmergencyKitOldCodeError(firstExpectedDigits: String) : EmergencyKitVerificationError( UserFacingErrorMessages.INSTANCE.emergencyKitOldVerificationCode(firstExpectedDigits) -) +) { + override val classification = ErrorClassification.EXPECTED +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/ek/EmergencyKitVerificationError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/ek/EmergencyKitVerificationError.kt index 59833ff7..98e1252f 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/ek/EmergencyKitVerificationError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/ek/EmergencyKitVerificationError.kt @@ -1,6 +1,9 @@ package io.muun.apollo.domain.errors.ek +import io.muun.apollo.domain.errors.ErrorClassification import io.muun.apollo.domain.errors.UserFacingError -open class EmergencyKitVerificationError(message: String) : UserFacingError(message) +open class EmergencyKitVerificationError(message: String) : UserFacingError(message) { + override val classification = ErrorClassification.EXPECTED +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/ek/SaveEkToDiskError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/ek/SaveEkToDiskError.kt index 939466b0..50326590 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/ek/SaveEkToDiskError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/ek/SaveEkToDiskError.kt @@ -1,9 +1,12 @@ package io.muun.apollo.domain.errors.ek import io.muun.apollo.data.external.UserFacingErrorMessages +import io.muun.apollo.domain.errors.ErrorClassification import io.muun.apollo.domain.errors.UserFacingError class SaveEkToDiskError(cause: Throwable) : UserFacingError( UserFacingErrorMessages.INSTANCE.saveEkToDisk(), cause -) \ No newline at end of file +) { + override val classification = ErrorClassification.UNEXPECTED +} \ No newline at end of file diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/fcm/FcmMessageProcessingError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/fcm/FcmMessageProcessingError.kt index 82865aba..664118ae 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/fcm/FcmMessageProcessingError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/fcm/FcmMessageProcessingError.kt @@ -1,10 +1,13 @@ package io.muun.apollo.domain.errors.fcm import com.google.firebase.messaging.RemoteMessage +import io.muun.apollo.domain.errors.ErrorClassification import io.muun.apollo.domain.errors.MuunError class FcmMessageProcessingError(message: RemoteMessage, cause: Throwable) : MuunError(cause) { + override val classification = ErrorClassification.UNEXPECTED + init { metadata["from"] = message.from ?: "" metadata["to"] = message.to ?: "" diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/fcm/FcmTokenCanceledError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/fcm/FcmTokenCanceledError.kt index d8c1a1f6..527f15ed 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/fcm/FcmTokenCanceledError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/fcm/FcmTokenCanceledError.kt @@ -1,5 +1,8 @@ package io.muun.apollo.domain.errors.fcm +import io.muun.apollo.domain.errors.ErrorClassification import io.muun.apollo.domain.errors.MuunError -class FcmTokenCanceledError : MuunError() \ No newline at end of file +class FcmTokenCanceledError : MuunError() { + override val classification = ErrorClassification.UNEXPECTED +} \ No newline at end of file diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/fcm/FcmTokenError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/fcm/FcmTokenError.kt index df663ebc..a5ae91cc 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/fcm/FcmTokenError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/fcm/FcmTokenError.kt @@ -1,5 +1,8 @@ package io.muun.apollo.domain.errors.fcm +import io.muun.apollo.domain.errors.ErrorClassification import io.muun.apollo.domain.errors.MuunError -class FcmTokenError(cause: Throwable) : MuunError(cause) +class FcmTokenError(cause: Throwable) : MuunError(cause) { + override val classification = ErrorClassification.UNEXPECTED +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/fcm/FcmTokenNotAvailableError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/fcm/FcmTokenNotAvailableError.kt index d7adadf7..2baebe96 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/fcm/FcmTokenNotAvailableError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/fcm/FcmTokenNotAvailableError.kt @@ -2,8 +2,11 @@ package io.muun.apollo.domain.errors.fcm import io.muun.apollo.data.external.UserFacingErrorMessages +import io.muun.apollo.domain.errors.ErrorClassification import io.muun.apollo.domain.errors.UserFacingError class FcmTokenNotAvailableError : UserFacingError( UserFacingErrorMessages.INSTANCE.fcmTokenNotAvailable() -) +) { + override val classification = ErrorClassification.UNEXPECTED +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/fcm/GooglePlayServicesNotAvailableError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/fcm/GooglePlayServicesNotAvailableError.kt index d6ebbf06..e4c106aa 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/fcm/GooglePlayServicesNotAvailableError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/fcm/GooglePlayServicesNotAvailableError.kt @@ -2,7 +2,10 @@ package io.muun.apollo.domain.errors.fcm import io.muun.apollo.data.external.UserFacingErrorMessages +import io.muun.apollo.domain.errors.ErrorClassification import io.muun.apollo.domain.errors.UserFacingError -class GooglePlayServicesNotAvailableError: - UserFacingError(UserFacingErrorMessages.INSTANCE.googlePlayServicesNotAvailable()) +class GooglePlayServicesNotAvailableError : + UserFacingError(UserFacingErrorMessages.INSTANCE.googlePlayServicesNotAvailable()) { + override val classification = ErrorClassification.UNEXPECTED +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/integrity/IntegrityError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/integrity/IntegrityError.kt index 6f4a2525..69606ea9 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/integrity/IntegrityError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/integrity/IntegrityError.kt @@ -1,6 +1,9 @@ package io.muun.apollo.domain.errors.integrity +import io.muun.apollo.domain.errors.ErrorClassification import io.muun.apollo.domain.errors.MuunError -open class IntegrityError(message: String) : MuunError(message) +open class IntegrityError(message: String) : MuunError(message) { + override val classification = ErrorClassification.UNEXPECTED +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/lnurl/AlreadyUsedError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/lnurl/AlreadyUsedError.kt index ab9e1aaf..0e7537b9 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/lnurl/AlreadyUsedError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/lnurl/AlreadyUsedError.kt @@ -1,9 +1,12 @@ package io.muun.apollo.domain.errors.lnurl +import io.muun.apollo.domain.errors.ErrorClassification import io.muun.apollo.domain.errors.MuunError class AlreadyUsedError(message: String, domain: String) : MuunError() { + override val classification = ErrorClassification.EXPECTED + init { metadata["service"] = domain metadata["message"] = message diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/lnurl/CountryNotSupportedError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/lnurl/CountryNotSupportedError.kt index 07f80009..4d0ac6b4 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/lnurl/CountryNotSupportedError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/lnurl/CountryNotSupportedError.kt @@ -1,9 +1,12 @@ package io.muun.apollo.domain.errors.lnurl +import io.muun.apollo.domain.errors.ErrorClassification import io.muun.apollo.domain.errors.MuunError class CountryNotSupportedError(message: String, domain: String) : MuunError() { + override val classification = ErrorClassification.EXPECTED + init { metadata["service"] = domain metadata["message"] = message diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/lnurl/ExpiredLnUrlError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/lnurl/ExpiredLnUrlError.kt index 27cbe2a3..6d886602 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/lnurl/ExpiredLnUrlError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/lnurl/ExpiredLnUrlError.kt @@ -1,9 +1,12 @@ package io.muun.apollo.domain.errors.lnurl +import io.muun.apollo.domain.errors.ErrorClassification import io.muun.apollo.domain.errors.MuunError class ExpiredLnUrlError(message: String, lnUrl: String) : MuunError() { + override val classification = ErrorClassification.EXPECTED + init { metadata["message"] = message metadata["LNURL"] = lnUrl diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/lnurl/ExpiredLnUrlInvoiceError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/lnurl/ExpiredLnUrlInvoiceError.kt index 4b52301e..54f241c6 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/lnurl/ExpiredLnUrlInvoiceError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/lnurl/ExpiredLnUrlInvoiceError.kt @@ -1,9 +1,12 @@ package io.muun.apollo.domain.errors.lnurl +import io.muun.apollo.domain.errors.ErrorClassification import io.muun.apollo.domain.errors.MuunError class ExpiredLnUrlInvoiceError(domain: String, invoice: String) : MuunError() { + override val classification = ErrorClassification.EXPECTED + init { metadata["service"] = domain metadata["invoice"] = invoice diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/lnurl/InvalidLnUrlError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/lnurl/InvalidLnUrlError.kt index 7f42a2eb..b8091e38 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/lnurl/InvalidLnUrlError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/lnurl/InvalidLnUrlError.kt @@ -1,9 +1,12 @@ package io.muun.apollo.domain.errors.lnurl +import io.muun.apollo.domain.errors.ErrorClassification import io.muun.apollo.domain.errors.MuunError class InvalidLnUrlError(text: String) : MuunError() { + override val classification = ErrorClassification.EXPECTED + init { metadata["text"] = text } diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/lnurl/InvalidLnUrlTagError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/lnurl/InvalidLnUrlTagError.kt index 1cf6b727..95bfaa14 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/lnurl/InvalidLnUrlTagError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/lnurl/InvalidLnUrlTagError.kt @@ -1,9 +1,12 @@ package io.muun.apollo.domain.errors.lnurl +import io.muun.apollo.domain.errors.ErrorClassification import io.muun.apollo.domain.errors.MuunError class InvalidLnUrlTagError(text: String) : MuunError() { + override val classification = ErrorClassification.EXPECTED + init { metadata["text"] = text } diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/lnurl/LnUrlServiceUnresponsiveError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/lnurl/LnUrlServiceUnresponsiveError.kt index 2e10a59b..3333bd5b 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/lnurl/LnUrlServiceUnresponsiveError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/lnurl/LnUrlServiceUnresponsiveError.kt @@ -1,9 +1,12 @@ package io.muun.apollo.domain.errors.lnurl +import io.muun.apollo.domain.errors.ErrorClassification import io.muun.apollo.domain.errors.MuunError class LnUrlServiceUnresponsiveError(domain: String): MuunError() { + override val classification = ErrorClassification.UNEXPECTED + init { metadata["service"] = domain } diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/lnurl/NoRouteError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/lnurl/NoRouteError.kt index f6332489..3613d6b8 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/lnurl/NoRouteError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/lnurl/NoRouteError.kt @@ -1,9 +1,12 @@ package io.muun.apollo.domain.errors.lnurl +import io.muun.apollo.domain.errors.ErrorClassification import io.muun.apollo.domain.errors.MuunError class NoRouteError(message: String, domain: String) : MuunError() { + override val classification = ErrorClassification.UNEXPECTED + init { metadata["service"] = domain metadata["message"] = message diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/lnurl/NoWithdrawBalanceError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/lnurl/NoWithdrawBalanceError.kt index 96cda36c..935b5218 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/lnurl/NoWithdrawBalanceError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/lnurl/NoWithdrawBalanceError.kt @@ -1,9 +1,12 @@ package io.muun.apollo.domain.errors.lnurl +import io.muun.apollo.domain.errors.ErrorClassification import io.muun.apollo.domain.errors.MuunError class NoWithdrawBalanceError(message: String, domain: String) : MuunError() { + override val classification = ErrorClassification.EXPECTED + init { metadata["service"] = domain metadata["message"] = message diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/lnurl/UnknownLnUrlError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/lnurl/UnknownLnUrlError.kt index 0c2ff98e..88a01272 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/lnurl/UnknownLnUrlError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/lnurl/UnknownLnUrlError.kt @@ -1,10 +1,13 @@ package io.muun.apollo.domain.errors.lnurl +import io.muun.apollo.domain.errors.ErrorClassification import io.muun.apollo.domain.errors.MuunError import io.muun.apollo.domain.model.lnurl.LnUrlEvent class UnknownLnUrlError(event: LnUrlEvent) : MuunError() { + override val classification = ErrorClassification.UNEXPECTED + init { metadata["code"] = event.code metadata["message"] = event.message diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/newop/AmountTooSmallError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/newop/AmountTooSmallError.kt index 414ae833..346c2002 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/newop/AmountTooSmallError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/newop/AmountTooSmallError.kt @@ -2,12 +2,15 @@ package io.muun.apollo.domain.errors.newop import io.muun.apollo.data.external.UserFacingErrorMessages +import io.muun.apollo.domain.errors.ErrorClassification import io.muun.apollo.domain.errors.UserFacingError class AmountTooSmallError(amountInSats: Long) : UserFacingError( UserFacingErrorMessages.INSTANCE.amountTooSmall() ) { + override val classification = ErrorClassification.EXPECTED + init { metadata["amountInSats"] = amountInSats } diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/newop/CyclicalSwapError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/newop/CyclicalSwapError.kt index 3db1558c..09f8bd20 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/newop/CyclicalSwapError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/newop/CyclicalSwapError.kt @@ -1,5 +1,8 @@ package io.muun.apollo.domain.errors.newop +import io.muun.apollo.domain.errors.ErrorClassification import io.muun.apollo.domain.errors.MuunError -class CyclicalSwapError(invoice: String, cause: Throwable) : MuunError(invoice, cause) \ No newline at end of file +class CyclicalSwapError(invoice: String, cause: Throwable) : MuunError(invoice, cause) { + override val classification = ErrorClassification.EXPECTED +} \ No newline at end of file diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/newop/ExchangeRateWindowTooOldError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/newop/ExchangeRateWindowTooOldError.kt index 41a81f73..c662c5af 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/newop/ExchangeRateWindowTooOldError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/newop/ExchangeRateWindowTooOldError.kt @@ -1,6 +1,9 @@ package io.muun.apollo.domain.errors.newop +import io.muun.apollo.domain.errors.ErrorClassification import io.muun.apollo.domain.errors.MuunError import io.muun.common.exception.PotentialBug -class ExchangeRateWindowTooOldError : MuunError(), PotentialBug +class ExchangeRateWindowTooOldError : MuunError(), PotentialBug { + override val classification = ErrorClassification.UNEXPECTED +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/newop/InsufficientFundsError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/newop/InsufficientFundsError.kt index 29b08c36..de8558fb 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/newop/InsufficientFundsError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/newop/InsufficientFundsError.kt @@ -2,8 +2,11 @@ package io.muun.apollo.domain.errors.newop import io.muun.apollo.data.external.UserFacingErrorMessages +import io.muun.apollo.domain.errors.ErrorClassification import io.muun.apollo.domain.errors.UserFacingError class InsufficientFundsError : UserFacingError( UserFacingErrorMessages.INSTANCE.insufficientFunds() -) +) { + override val classification = ErrorClassification.EXPECTED +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/newop/InvalidAddressError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/newop/InvalidAddressError.kt index c0a8e0b1..6405b7fb 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/newop/InvalidAddressError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/newop/InvalidAddressError.kt @@ -2,6 +2,9 @@ package io.muun.apollo.domain.errors.newop import io.muun.apollo.data.external.UserFacingErrorMessages +import io.muun.apollo.domain.errors.ErrorClassification import io.muun.apollo.domain.errors.UserFacingError -class InvalidAddressError : UserFacingError(UserFacingErrorMessages.INSTANCE.invalidAddress()) +class InvalidAddressError : UserFacingError(UserFacingErrorMessages.INSTANCE.invalidAddress()) { + override val classification = ErrorClassification.EXPECTED +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/newop/InvalidInvoiceException.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/newop/InvalidInvoiceException.kt index 9947a639..ec38330c 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/newop/InvalidInvoiceException.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/newop/InvalidInvoiceException.kt @@ -1,9 +1,12 @@ package io.muun.apollo.domain.errors.newop +import io.muun.apollo.domain.errors.ErrorClassification import io.muun.apollo.domain.errors.MuunError import io.muun.common.exception.PotentialBug class InvalidInvoiceException(invoice: String, cause: Throwable) : MuunError( invoice, cause -), PotentialBug +), PotentialBug { + override val classification = ErrorClassification.EXPECTED +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/newop/InvalidOperationUriException.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/newop/InvalidOperationUriException.kt new file mode 100644 index 00000000..30c9a2e1 --- /dev/null +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/newop/InvalidOperationUriException.kt @@ -0,0 +1,8 @@ +package io.muun.apollo.domain.errors.newop + +import io.muun.apollo.domain.errors.ErrorClassification +import io.muun.apollo.domain.errors.MuunError + +class InvalidOperationUriException(message: String) : MuunError(message) { + override val classification = ErrorClassification.UNEXPECTED +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/newop/InvalidPaymentRequestError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/newop/InvalidPaymentRequestError.kt index 41b03100..c09fa615 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/newop/InvalidPaymentRequestError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/newop/InvalidPaymentRequestError.kt @@ -2,10 +2,13 @@ package io.muun.apollo.domain.errors.newop import io.muun.apollo.data.external.UserFacingErrorMessages +import io.muun.apollo.domain.errors.ErrorClassification import io.muun.apollo.domain.errors.UserFacingError import io.muun.common.exception.PotentialBug class InvalidPaymentRequestError(innerMessage: String, cause: Throwable? = null) : UserFacingError( UserFacingErrorMessages.INSTANCE.invalidPaymentRequest(), RuntimeException(innerMessage, cause) -), PotentialBug +), PotentialBug { + override val classification = ErrorClassification.UNEXPECTED +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/newop/InvalidSwapException.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/newop/InvalidSwapException.kt index bded706a..00a96850 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/newop/InvalidSwapException.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/newop/InvalidSwapException.kt @@ -1,8 +1,11 @@ package io.muun.apollo.domain.errors.newop +import io.muun.apollo.domain.errors.ErrorClassification import io.muun.apollo.domain.errors.MuunError import io.muun.common.exception.PotentialBug class InvalidSwapException(swapUuid: String) : MuunError( "Validation failed for swap UUID $swapUuid" -), PotentialBug +), PotentialBug { + override val classification = ErrorClassification.UNEXPECTED +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/newop/InvoiceAlreadyUsedException.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/newop/InvoiceAlreadyUsedException.kt index 32c3a4b3..45a737a6 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/newop/InvoiceAlreadyUsedException.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/newop/InvoiceAlreadyUsedException.kt @@ -1,5 +1,8 @@ package io.muun.apollo.domain.errors.newop +import io.muun.apollo.domain.errors.ErrorClassification import io.muun.apollo.domain.errors.MuunError -class InvoiceAlreadyUsedException(invoice: String, cause: Throwable) : MuunError(invoice, cause) +class InvoiceAlreadyUsedException(invoice: String, cause: Throwable) : MuunError(invoice, cause) { + override val classification = ErrorClassification.EXPECTED +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/newop/InvoiceExpiredException.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/newop/InvoiceExpiredException.kt index 202f3a0e..63fa7d67 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/newop/InvoiceExpiredException.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/newop/InvoiceExpiredException.kt @@ -1,9 +1,12 @@ package io.muun.apollo.domain.errors.newop +import io.muun.apollo.domain.errors.ErrorClassification import io.muun.apollo.domain.errors.MuunError class InvoiceExpiredException : MuunError { + override val classification = ErrorClassification.EXPECTED + constructor(invoice: String) : super(invoice) constructor(invoice: String, cause: Throwable) : super(invoice, cause) } diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/newop/InvoiceExpiresTooSoonException.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/newop/InvoiceExpiresTooSoonException.kt index ba391854..2fcac4e0 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/newop/InvoiceExpiresTooSoonException.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/newop/InvoiceExpiresTooSoonException.kt @@ -1,5 +1,8 @@ package io.muun.apollo.domain.errors.newop +import io.muun.apollo.domain.errors.ErrorClassification import io.muun.apollo.domain.errors.MuunError -class InvoiceExpiresTooSoonException(invoice: String, cause: Throwable) : MuunError(invoice, cause) +class InvoiceExpiresTooSoonException(invoice: String, cause: Throwable) : MuunError(invoice, cause) { + override val classification = ErrorClassification.EXPECTED +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/newop/InvoiceMissingAmountException.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/newop/InvoiceMissingAmountException.kt index da2916b3..fb50f5d3 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/newop/InvoiceMissingAmountException.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/newop/InvoiceMissingAmountException.kt @@ -1,5 +1,8 @@ package io.muun.apollo.domain.errors.newop +import io.muun.apollo.domain.errors.ErrorClassification import io.muun.apollo.domain.errors.MuunError -class InvoiceMissingAmountException(invoice: String, cause: Throwable) : MuunError(invoice, cause) +class InvoiceMissingAmountException(invoice: String, cause: Throwable) : MuunError(invoice, cause) { + override val classification = ErrorClassification.EXPECTED +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/newop/NewOpStateError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/newop/NewOpStateError.kt index dccc5385..43b7e978 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/newop/NewOpStateError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/newop/NewOpStateError.kt @@ -1,7 +1,10 @@ package io.muun.apollo.domain.errors.newop +import io.muun.apollo.domain.errors.ErrorClassification import io.muun.apollo.domain.errors.MuunError import newop.State class NewOpStateError(actual: Class?, expected: Class) : - MuunError("Unexpected state. Actual: ${actual?.simpleName} Expected: ${expected.simpleName}") \ No newline at end of file + MuunError("Unexpected state. Actual: ${actual?.simpleName} Expected: ${expected.simpleName}") { + override val classification = ErrorClassification.UNEXPECTED +} \ No newline at end of file diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/newop/NoPaymentRouteException.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/newop/NoPaymentRouteException.kt index 1c6f4b67..b1f0dcd5 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/newop/NoPaymentRouteException.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/newop/NoPaymentRouteException.kt @@ -1,9 +1,12 @@ package io.muun.apollo.domain.errors.newop +import io.muun.apollo.domain.errors.ErrorClassification import io.muun.apollo.domain.errors.MuunError import io.muun.common.exception.PotentialBug class NoPaymentRouteException(invoice: String, cause: Throwable) : MuunError( invoice, cause -), PotentialBug +), PotentialBug { + override val classification = ErrorClassification.UNEXPECTED +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/newop/PushTransactionSlowError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/newop/PushTransactionSlowError.kt index 5092d988..5ef9fb48 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/newop/PushTransactionSlowError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/newop/PushTransactionSlowError.kt @@ -1,5 +1,8 @@ package io.muun.apollo.domain.errors.newop +import io.muun.apollo.domain.errors.ErrorClassification import io.muun.apollo.domain.errors.UserFacingError -class PushTransactionSlowError(cause: Throwable) : UserFacingError(cause) \ No newline at end of file +class PushTransactionSlowError(cause: Throwable) : UserFacingError(cause) { + override val classification = ErrorClassification.UNEXPECTED +} \ No newline at end of file diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/newop/SwapFailedException.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/newop/SwapFailedException.kt index d5f1bd26..c1bbf2d8 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/newop/SwapFailedException.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/newop/SwapFailedException.kt @@ -1,5 +1,8 @@ package io.muun.apollo.domain.errors.newop +import io.muun.apollo.domain.errors.ErrorClassification import io.muun.apollo.domain.errors.MuunError -class SwapFailedException(cause: Throwable) : MuunError(cause) +class SwapFailedException(cause: Throwable) : MuunError(cause) { + override val classification = ErrorClassification.UNEXPECTED +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/newop/UnreachableNodeException.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/newop/UnreachableNodeException.kt index ef6ce9cd..d9ae2dcf 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/newop/UnreachableNodeException.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/newop/UnreachableNodeException.kt @@ -1,9 +1,12 @@ package io.muun.apollo.domain.errors.newop +import io.muun.apollo.domain.errors.ErrorClassification import io.muun.apollo.domain.errors.MuunError import io.muun.common.exception.PotentialBug class UnreachableNodeException(invoice: String, cause: Throwable) : MuunError( invoice, cause -), PotentialBug +), PotentialBug { + override val classification = ErrorClassification.UNEXPECTED +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/nfc/NoFeasibleZoneForModelError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/nfc/NoFeasibleZoneForModelError.kt new file mode 100644 index 00000000..e11ca014 --- /dev/null +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/nfc/NoFeasibleZoneForModelError.kt @@ -0,0 +1,8 @@ +package io.muun.apollo.domain.errors.nfc + +import io.muun.apollo.domain.errors.ErrorClassification +import io.muun.apollo.domain.errors.MuunError + +class NoFeasibleZoneForModelError : MuunError() { + override val classification = ErrorClassification.EXPECTED +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/notifications/MessageOriginError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/notifications/MessageOriginError.kt index c09455f0..bc543cf7 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/notifications/MessageOriginError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/notifications/MessageOriginError.kt @@ -1,5 +1,6 @@ package io.muun.apollo.domain.errors.notifications +import io.muun.apollo.domain.errors.ErrorClassification import io.muun.apollo.domain.errors.MuunError import io.muun.common.api.messages.MessageOrigin import io.muun.common.api.messages.MessageSpec @@ -7,6 +8,8 @@ import io.muun.common.api.messages.MessageSpec class MessageOriginError(sessionId: String, msgId: Long, origin: MessageOrigin, spec: MessageSpec) : MuunError("Received a message from an unexpected origin") { + override val classification = ErrorClassification.UNEXPECTED + init { metadata["sessionUuid"] = sessionId metadata["messageId"] = msgId diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/notifications/MessagePermissionsError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/notifications/MessagePermissionsError.kt index 0295e728..cfc9f5cd 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/notifications/MessagePermissionsError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/notifications/MessagePermissionsError.kt @@ -1,5 +1,6 @@ package io.muun.apollo.domain.errors.notifications +import io.muun.apollo.domain.errors.ErrorClassification import io.muun.apollo.domain.errors.MuunError import io.muun.common.api.messages.MessageSpec import io.muun.common.model.SessionStatus @@ -11,6 +12,8 @@ class MessagePermissionsError( spec: MessageSpec, ) : MuunError("Received a message without the right permissions") { + override val classification = ErrorClassification.UNEXPECTED + init { metadata["sessionUuid"] = sessionUuid metadata["sessionStatus"] = currentStatus?.name ?: "null" diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/notifications/NotificationProcessingError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/notifications/NotificationProcessingError.kt index 05e00e39..b267d28b 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/notifications/NotificationProcessingError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/notifications/NotificationProcessingError.kt @@ -1,12 +1,15 @@ package io.muun.apollo.domain.errors.notifications +import io.muun.apollo.domain.errors.ErrorClassification import io.muun.apollo.domain.errors.MuunError import io.muun.common.api.beam.notification.NotificationJson import io.muun.common.exception.PotentialBug class NotificationProcessingError : MuunError, PotentialBug { + override val classification = ErrorClassification.UNEXPECTED + companion object { /** Create an Error caused by a gap in the notification processing ID sequence. */ diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/notifications/UnknownNotificationTypeError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/notifications/UnknownNotificationTypeError.kt index aec12f69..89ee161d 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/notifications/UnknownNotificationTypeError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/notifications/UnknownNotificationTypeError.kt @@ -1,11 +1,14 @@ package io.muun.apollo.domain.errors.notifications +import io.muun.apollo.domain.errors.ErrorClassification import io.muun.apollo.domain.errors.MuunError class UnknownNotificationTypeError(type: String) : MuunError("Unknown notification type") { + override val classification = ErrorClassification.UNEXPECTED + init { metadata["type"] = type } diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/p2p/CountryNotSupportedError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/p2p/CountryNotSupportedError.kt index 99809b76..43a4a784 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/p2p/CountryNotSupportedError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/p2p/CountryNotSupportedError.kt @@ -2,8 +2,11 @@ package io.muun.apollo.domain.errors.p2p import io.muun.apollo.data.external.UserFacingErrorMessages +import io.muun.apollo.domain.errors.ErrorClassification import io.muun.apollo.domain.errors.UserFacingError class CountryNotSupportedError : UserFacingError( UserFacingErrorMessages.INSTANCE.countryNotSupported() -) +) { + override val classification = ErrorClassification.EXPECTED +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/p2p/ExpiredVerificationCodeError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/p2p/ExpiredVerificationCodeError.kt index cb32a8d7..89bac61c 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/p2p/ExpiredVerificationCodeError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/p2p/ExpiredVerificationCodeError.kt @@ -2,8 +2,11 @@ package io.muun.apollo.domain.errors.p2p import io.muun.apollo.data.external.UserFacingErrorMessages +import io.muun.apollo.domain.errors.ErrorClassification import io.muun.apollo.domain.errors.UserFacingError class ExpiredVerificationCodeError : UserFacingError( UserFacingErrorMessages.INSTANCE.expiredVerificationCode() -) +) { + override val classification = ErrorClassification.EXPECTED +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/p2p/InvalidPhoneNumberError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/p2p/InvalidPhoneNumberError.kt index 682ff6b0..b8607602 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/p2p/InvalidPhoneNumberError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/p2p/InvalidPhoneNumberError.kt @@ -2,10 +2,13 @@ package io.muun.apollo.domain.errors.p2p import io.muun.apollo.data.external.UserFacingErrorMessages +import io.muun.apollo.domain.errors.ErrorClassification import io.muun.apollo.domain.errors.UserFacingError class InvalidPhoneNumberError : UserFacingError { + override val classification = ErrorClassification.EXPECTED + constructor() : super(UserFacingErrorMessages.INSTANCE.invalidPhoneNumber()) diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/p2p/InvalidPictureError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/p2p/InvalidPictureError.kt index 1d907f2e..01b14738 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/p2p/InvalidPictureError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/p2p/InvalidPictureError.kt @@ -3,10 +3,13 @@ package io.muun.apollo.domain.errors.p2p import android.content.Intent import io.muun.apollo.data.external.UserFacingErrorMessages +import io.muun.apollo.domain.errors.ErrorClassification import io.muun.apollo.domain.errors.UserFacingError class InvalidPictureError : UserFacingError { + override val classification = ErrorClassification.EXPECTED + constructor() : super(UserFacingErrorMessages.INSTANCE.invalidPicture()) constructor(resultIntent: Intent) : super(UserFacingErrorMessages.INSTANCE.invalidPicture()) { diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/p2p/InvalidVerificationCodeError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/p2p/InvalidVerificationCodeError.kt index a994b571..5a670b2a 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/p2p/InvalidVerificationCodeError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/p2p/InvalidVerificationCodeError.kt @@ -2,8 +2,11 @@ package io.muun.apollo.domain.errors.p2p import io.muun.apollo.data.external.UserFacingErrorMessages +import io.muun.apollo.domain.errors.ErrorClassification import io.muun.apollo.domain.errors.UserFacingError class InvalidVerificationCodeError : UserFacingError( UserFacingErrorMessages.INSTANCE.invalidVerificationCode() -) +) { + override val classification = ErrorClassification.EXPECTED +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/p2p/PhoneNumberAlreadyUsedError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/p2p/PhoneNumberAlreadyUsedError.kt index c6aaf2ac..23fea36f 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/p2p/PhoneNumberAlreadyUsedError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/p2p/PhoneNumberAlreadyUsedError.kt @@ -2,8 +2,11 @@ package io.muun.apollo.domain.errors.p2p import io.muun.apollo.data.external.UserFacingErrorMessages +import io.muun.apollo.domain.errors.ErrorClassification import io.muun.apollo.domain.errors.UserFacingError class PhoneNumberAlreadyUsedError : UserFacingError( UserFacingErrorMessages.INSTANCE.phoneNumberAlreadyUsed() -) +) { + override val classification = ErrorClassification.EXPECTED +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/p2p/RevokedVerificationCodeError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/p2p/RevokedVerificationCodeError.kt index 1a7766ea..f6555270 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/p2p/RevokedVerificationCodeError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/p2p/RevokedVerificationCodeError.kt @@ -1,8 +1,11 @@ package io.muun.apollo.domain.errors.p2p import io.muun.apollo.data.external.UserFacingErrorMessages +import io.muun.apollo.domain.errors.ErrorClassification import io.muun.apollo.domain.errors.UserFacingError class RevokedVerificationCodeError : UserFacingError( UserFacingErrorMessages.INSTANCE.revokedVerificationCode() -) \ No newline at end of file +) { + override val classification = ErrorClassification.EXPECTED +} \ No newline at end of file diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/p2p/TooManyWrongVerificationCodesError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/p2p/TooManyWrongVerificationCodesError.kt index 7ae302ad..13feaf2f 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/p2p/TooManyWrongVerificationCodesError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/p2p/TooManyWrongVerificationCodesError.kt @@ -2,8 +2,11 @@ package io.muun.apollo.domain.errors.p2p import io.muun.apollo.data.external.UserFacingErrorMessages +import io.muun.apollo.domain.errors.ErrorClassification import io.muun.apollo.domain.errors.UserFacingError class TooManyWrongVerificationCodesError : UserFacingError( UserFacingErrorMessages.INSTANCE.tooManyWrongVerificationCodes() -) +) { + override val classification = ErrorClassification.EXPECTED +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/passwd/EmailAlreadyUsedError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/passwd/EmailAlreadyUsedError.kt index 541e88a8..d7839d88 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/passwd/EmailAlreadyUsedError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/passwd/EmailAlreadyUsedError.kt @@ -2,8 +2,11 @@ package io.muun.apollo.domain.errors.passwd import io.muun.apollo.data.external.UserFacingErrorMessages +import io.muun.apollo.domain.errors.ErrorClassification import io.muun.apollo.domain.errors.UserFacingError class EmailAlreadyUsedError : UserFacingError( UserFacingErrorMessages.INSTANCE.emailAreadyUsed() -) +) { + override val classification = ErrorClassification.EXPECTED +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/passwd/EmailNotRegisteredError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/passwd/EmailNotRegisteredError.kt index 7c4863e0..56575689 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/passwd/EmailNotRegisteredError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/passwd/EmailNotRegisteredError.kt @@ -2,8 +2,11 @@ package io.muun.apollo.domain.errors.passwd import io.muun.apollo.data.external.UserFacingErrorMessages +import io.muun.apollo.domain.errors.ErrorClassification import io.muun.apollo.domain.errors.UserFacingError class EmailNotRegisteredError : UserFacingError( UserFacingErrorMessages.INSTANCE.emailNotRegistered() -) +) { + override val classification = ErrorClassification.EXPECTED +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/passwd/IncorrectPasswordError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/passwd/IncorrectPasswordError.kt index de9ea547..fe8cae31 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/passwd/IncorrectPasswordError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/passwd/IncorrectPasswordError.kt @@ -1,6 +1,10 @@ package io.muun.apollo.domain.errors.passwd import io.muun.apollo.data.external.UserFacingErrorMessages +import io.muun.apollo.domain.errors.ErrorClassification import io.muun.apollo.domain.errors.UserFacingError -class IncorrectPasswordError : UserFacingError(UserFacingErrorMessages.INSTANCE.incorrectPassword()) +class IncorrectPasswordError : + UserFacingError(UserFacingErrorMessages.INSTANCE.incorrectPassword()) { + override val classification = ErrorClassification.EXPECTED +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/passwd/PasswordIntegrityError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/passwd/PasswordIntegrityError.kt index a7fc9593..0c8b3a02 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/passwd/PasswordIntegrityError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/passwd/PasswordIntegrityError.kt @@ -1,5 +1,6 @@ package io.muun.apollo.domain.errors.passwd +import io.muun.apollo.domain.errors.ErrorClassification import io.muun.apollo.domain.errors.MuunError /** @@ -7,4 +8,6 @@ import io.muun.apollo.domain.errors.MuunError * didn't have a password challenge (only affects users created before Feb 2018, and that didn't * have a successful login since), otherwise is pretty major error. */ -class PasswordIntegrityError : MuunError("The password could not decrypt the base private key") +class PasswordIntegrityError : MuunError("The password could not decrypt the base private key") { + override val classification = ErrorClassification.UNEXPECTED +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/passwd/PasswordTooShortError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/passwd/PasswordTooShortError.kt index 1e9e726c..bbcccecd 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/passwd/PasswordTooShortError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/passwd/PasswordTooShortError.kt @@ -2,8 +2,11 @@ package io.muun.apollo.domain.errors.passwd import io.muun.apollo.data.external.UserFacingErrorMessages +import io.muun.apollo.domain.errors.ErrorClassification import io.muun.apollo.domain.errors.UserFacingError class PasswordTooShortError : UserFacingError( UserFacingErrorMessages.INSTANCE.passwordTooShort() -) +) { + override val classification = ErrorClassification.EXPECTED +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/passwd/PasswordsDontMatchError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/passwd/PasswordsDontMatchError.kt index 21d7df23..102f020f 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/passwd/PasswordsDontMatchError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/passwd/PasswordsDontMatchError.kt @@ -2,8 +2,11 @@ package io.muun.apollo.domain.errors.passwd import io.muun.apollo.data.external.UserFacingErrorMessages +import io.muun.apollo.domain.errors.ErrorClassification import io.muun.apollo.domain.errors.UserFacingError class PasswordsDontMatchError : UserFacingError( UserFacingErrorMessages.INSTANCE.passwordsDontMatch() -) +) { + override val classification = ErrorClassification.EXPECTED +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/rc/CredentialsDontMatchError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/rc/CredentialsDontMatchError.kt index f573d407..ba8a50fc 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/rc/CredentialsDontMatchError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/rc/CredentialsDontMatchError.kt @@ -1,5 +1,8 @@ package io.muun.apollo.domain.errors.rc +import io.muun.apollo.domain.errors.ErrorClassification import io.muun.apollo.domain.errors.UserFacingError -class CredentialsDontMatchError : UserFacingError() \ No newline at end of file +class CredentialsDontMatchError : UserFacingError() { + override val classification = ErrorClassification.EXPECTED +} \ No newline at end of file diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/rc/FinishRecoveryCodeSetupError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/rc/FinishRecoveryCodeSetupError.kt index c07954ef..92cbfd77 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/rc/FinishRecoveryCodeSetupError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/rc/FinishRecoveryCodeSetupError.kt @@ -1,5 +1,8 @@ package io.muun.apollo.domain.errors.rc +import io.muun.apollo.domain.errors.ErrorClassification import io.muun.apollo.domain.errors.MuunError -class FinishRecoveryCodeSetupError(cause: Throwable) : MuunError(cause) \ No newline at end of file +class FinishRecoveryCodeSetupError(cause: Throwable) : MuunError(cause) { + override val classification = ErrorClassification.UNEXPECTED +} \ No newline at end of file diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/rc/InvalidCharacterRecoveryCodeError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/rc/InvalidCharacterRecoveryCodeError.kt index 6012fd0c..b4a8a037 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/rc/InvalidCharacterRecoveryCodeError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/rc/InvalidCharacterRecoveryCodeError.kt @@ -1,7 +1,10 @@ package io.muun.apollo.domain.errors.rc import io.muun.apollo.data.external.UserFacingErrorMessages +import io.muun.apollo.domain.errors.ErrorClassification import io.muun.apollo.domain.errors.UserFacingError class InvalidCharacterRecoveryCodeError : - UserFacingError(UserFacingErrorMessages.INSTANCE.invalidCharacterRecoveryCode()) + UserFacingError(UserFacingErrorMessages.INSTANCE.invalidCharacterRecoveryCode()) { + override val classification = ErrorClassification.EXPECTED +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/rc/InvalidRecoveryCodeV2Error.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/rc/InvalidRecoveryCodeV2Error.kt index 5993394d..991ec17c 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/rc/InvalidRecoveryCodeV2Error.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/rc/InvalidRecoveryCodeV2Error.kt @@ -1,6 +1,9 @@ package io.muun.apollo.domain.errors.rc import io.muun.apollo.data.external.UserFacingErrorMessages +import io.muun.apollo.domain.errors.ErrorClassification import io.muun.apollo.domain.errors.UserFacingError -class InvalidRecoveryCodeV2Error: UserFacingError(UserFacingErrorMessages.INSTANCE.invalidRcV2()) +class InvalidRecoveryCodeV2Error : UserFacingError(UserFacingErrorMessages.INSTANCE.invalidRcV2()) { + override val classification = ErrorClassification.EXPECTED +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/rc/RecoveryCodeVerificationError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/rc/RecoveryCodeVerificationError.kt index 54b17acb..b172f654 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/rc/RecoveryCodeVerificationError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/rc/RecoveryCodeVerificationError.kt @@ -2,9 +2,10 @@ package io.muun.apollo.domain.errors.rc import io.muun.apollo.data.external.UserFacingErrorMessages +import io.muun.apollo.domain.errors.ErrorClassification import io.muun.apollo.domain.errors.UserFacingError class RecoveryCodeVerificationError : UserFacingError(UserFacingErrorMessages.INSTANCE.recoveryCodeVerification()) { - + override val classification = ErrorClassification.EXPECTED } diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/rc/StaleChallengeKeyError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/rc/StaleChallengeKeyError.kt index e767080c..1da43f1d 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/rc/StaleChallengeKeyError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/rc/StaleChallengeKeyError.kt @@ -1,5 +1,8 @@ package io.muun.apollo.domain.errors.rc +import io.muun.apollo.domain.errors.ErrorClassification import io.muun.apollo.domain.errors.UserFacingError -class StaleChallengeKeyError : UserFacingError() \ No newline at end of file +class StaleChallengeKeyError : UserFacingError() { + override val classification = ErrorClassification.EXPECTED +} \ No newline at end of file diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/rc/StartRecoveryCodeSetupError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/rc/StartRecoveryCodeSetupError.kt index d37bce73..d6ed7f8d 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/errors/rc/StartRecoveryCodeSetupError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/errors/rc/StartRecoveryCodeSetupError.kt @@ -1,5 +1,8 @@ package io.muun.apollo.domain.errors.rc +import io.muun.apollo.domain.errors.ErrorClassification import io.muun.apollo.domain.errors.MuunError -class StartRecoveryCodeSetupError(cause: Throwable) : MuunError(cause) \ No newline at end of file +class StartRecoveryCodeSetupError(cause: Throwable) : MuunError(cause) { + override val classification = ErrorClassification.UNEXPECTED +} \ No newline at end of file diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/libwallet/LibwalletClient.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/libwallet/LibwalletClient.kt index 007370d1..2f8d93fd 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/libwallet/LibwalletClient.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/libwallet/LibwalletClient.kt @@ -1,5 +1,6 @@ package io.muun.apollo.domain.libwallet +import com.google.protobuf.ByteString import com.google.protobuf.Empty import io.grpc.ManagedChannel import io.grpc.StatusRuntimeException @@ -9,6 +10,7 @@ import io.muun.apollo.data.nfc.NfcBridger import io.muun.apollo.domain.errors.MuunErrorMapper import io.muun.apollo.domain.libwallet.errors.ErrorDetailType import io.muun.apollo.domain.libwallet.errors.LibwalletGrpcError +import io.muun.apollo.domain.secure_key_value_storage.Secret import rpc.WalletServiceGrpc import rpc.WalletServiceGrpc.WalletServiceBlockingStub import rpc.WalletServiceGrpc.WalletServiceStub @@ -17,11 +19,14 @@ import rpc.WalletServiceOuterClass.DiagnosticSessionDescriptor import rpc.WalletServiceOuterClass.GetRequest import rpc.WalletServiceOuterClass.NullValue import rpc.WalletServiceOuterClass.SaveRequest -import rpc.WalletServiceOuterClass.SignMessageSecurityCardRequest +import rpc.WalletServiceOuterClass.SecureKeyValueStorageDeleteRequest +import rpc.WalletServiceOuterClass.SecureKeyValueStorageGetRequest +import rpc.WalletServiceOuterClass.SecureKeyValueStoragePutRequest import rpc.WalletServiceOuterClass.Value import rpc.WalletServiceOuterClass.GenerateEmergencyKitPDFRequest import rpc.WalletServiceOuterClass.GenerateEmergencyKitPDFResponse import rpc.WalletServiceOuterClass.EKInputRequest +import rpc.WalletServiceOuterClass.ZipDataDirRequest import io.muun.apollo.domain.action.ek.GenerateEmergencyKitPDF import rx.Emitter import rx.Observable @@ -45,32 +50,6 @@ class LibwalletClient(private val channel: ManagedChannel) { Timber.d("Paired Security Card - isCardAlreadyUsed: ${response.isCardAlreadyUsed}") } - fun resetSecurityCard(nfcBridger: NfcBridger) { - - nfcBridger.setupBridge() - blockingStub.performSyncRequest { - resetSecurityCard(emptyMessage) - } - nfcBridger.tearDownBridge() - - Timber.d("Reset Security Card") - } - - fun securityCardSignMessage(nfcBridger: NfcBridger, message: String): ByteArray { - - val request = SignMessageSecurityCardRequest.newBuilder() - .setMessageHex(message) - .build() - - nfcBridger.setupBridge() - val signMessageNfcCardResponse = blockingStub.performSyncRequest { - signMessageSecurityCard(request) - } - nfcBridger.tearDownBridge() - - return signMessageNfcCardResponse.signedMessageHex.toByteArray() - } - fun securityCardV2SignMessage(nfcBridger: NfcBridger) { nfcBridger.setupBridge() @@ -83,7 +62,7 @@ class LibwalletClient(private val channel: ManagedChannel) { fun generateEmergencyKitPDF( data: GenerateEmergencyKitPDF.RequiredData, outputPath: String, - language: String + language: String, ): GenerateEmergencyKitPDFResponse { val ekInput = EKInputRequest.newBuilder() .setFirstEncryptedKey(data.userKey) @@ -259,6 +238,22 @@ class LibwalletClient(private val channel: ManagedChannel) { fun getInt(key: String, defaultValue: Int): Int = getInt(key) ?: defaultValue + fun resetData() { + blockingStub.performSyncRequest { + resetData(emptyMessage) + } + } + + fun zipDataDir(outputPath: String) { + val request = ZipDataDirRequest.newBuilder() + .setOutputPath(outputPath) + .build() + + blockingStub.performSyncRequest { + zipDataDir(request) + } + } + fun delete(key: String) { val deleteRequest = DeleteRequest.newBuilder() .setKey(key) @@ -269,6 +264,44 @@ class LibwalletClient(private val channel: ManagedChannel) { } } + fun secureKeyValueStoragePut(key: String, value: ByteArray) { + val request = SecureKeyValueStoragePutRequest.newBuilder() + .setKey(key) + .setValue(ByteString.copyFrom(value)) + .build() + + blockingStub.performSyncRequest { + secureKeyValueStoragePut(request) + } + } + + fun secureKeyValueStorageGet(key: String): Secret { + val request = SecureKeyValueStorageGetRequest.newBuilder() + .setKey(key) + .build() + + val response = blockingStub.performSyncRequest { + secureKeyValueStorageGet(request) + } + return Secret(response.value.toByteArray()) + } + + fun secureKeyValueStorageDelete(key: String) { + val request = SecureKeyValueStorageDeleteRequest.newBuilder() + .setKey(key) + .build() + + blockingStub.performSyncRequest { + secureKeyValueStorageDelete(request) + } + } + + fun secureKeyValueStorageWipe() { + blockingStub.performSyncRequest { + secureKeyValueStorageWipe(emptyMessage) + } + } + private inline fun WalletServiceBlockingStub.performSyncRequest( crossinline rpcCall: WalletServiceBlockingStub.() -> T, ): T { diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/libwallet/errors/AddressDerivationError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/libwallet/errors/AddressDerivationError.kt index ddfd690a..945b17c0 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/libwallet/errors/AddressDerivationError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/libwallet/errors/AddressDerivationError.kt @@ -1,5 +1,6 @@ package io.muun.apollo.domain.libwallet.errors +import io.muun.apollo.domain.errors.ErrorClassification import io.muun.apollo.domain.errors.MuunError private var msg = "Libwallet failed to derive an address" @@ -7,6 +8,8 @@ private var msg = "Libwallet failed to derive an address" class AddressDerivationError(val version: Int, val path: String, cause: Throwable) : MuunError(msg, cause) { + override val classification = ErrorClassification.UNEXPECTED + init { metadata["version"] = version metadata["path"] = path diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/libwallet/errors/FeeBumpFunctionsStoreError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/libwallet/errors/FeeBumpFunctionsStoreError.kt index 3ebb6f81..f7907341 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/libwallet/errors/FeeBumpFunctionsStoreError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/libwallet/errors/FeeBumpFunctionsStoreError.kt @@ -1,5 +1,6 @@ package io.muun.apollo.domain.libwallet.errors +import io.muun.apollo.domain.errors.ErrorClassification import io.muun.apollo.domain.errors.MuunError private var msg = "Libwallet failed to store fee bump functions" @@ -8,6 +9,8 @@ class FeeBumpFunctionsStoreError( val functions: String, cause: Throwable ) : MuunError(msg, cause) { + override val classification = ErrorClassification.UNEXPECTED + init { metadata["functions"] = functions } diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/libwallet/errors/InvalidRecoveryCodeFormatError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/libwallet/errors/InvalidRecoveryCodeFormatError.kt index 33680479..9a4428c1 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/libwallet/errors/InvalidRecoveryCodeFormatError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/libwallet/errors/InvalidRecoveryCodeFormatError.kt @@ -1,5 +1,8 @@ package io.muun.apollo.domain.libwallet.errors +import io.muun.apollo.domain.errors.ErrorClassification import io.muun.apollo.domain.errors.MuunError -class InvalidRecoveryCodeFormatError(cause: Throwable) : MuunError(cause) +class InvalidRecoveryCodeFormatError(cause: Throwable) : MuunError(cause) { + override val classification = ErrorClassification.EXPECTED +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/libwallet/errors/InvoiceParsingError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/libwallet/errors/InvoiceParsingError.kt index 6afacabe..7e8df274 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/libwallet/errors/InvoiceParsingError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/libwallet/errors/InvoiceParsingError.kt @@ -1,11 +1,14 @@ package io.muun.apollo.domain.libwallet.errors +import io.muun.apollo.domain.errors.ErrorClassification import io.muun.apollo.domain.errors.MuunError private var msg = "Libwallet failed to parse an invoice" class InvoiceParsingError(val invoice: String, cause: Throwable) : MuunError(msg, cause) { + override val classification = ErrorClassification.EXPECTED + init { metadata["invoice"] = invoice } diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/libwallet/errors/LibwalletEmergencyKitError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/libwallet/errors/LibwalletEmergencyKitError.kt index 0a4cd3f6..a64c8b89 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/libwallet/errors/LibwalletEmergencyKitError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/libwallet/errors/LibwalletEmergencyKitError.kt @@ -1,6 +1,9 @@ package io.muun.apollo.domain.libwallet.errors +import io.muun.apollo.domain.errors.ErrorClassification import io.muun.apollo.domain.errors.MuunError class LibwalletEmergencyKitError(cause: Throwable): - MuunError("Emergency kit generation failed", cause) + MuunError("Emergency kit generation failed", cause) { + override val classification = ErrorClassification.UNEXPECTED +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/libwallet/errors/LibwalletGrpcError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/libwallet/errors/LibwalletGrpcError.kt index 85cf3781..052272c9 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/libwallet/errors/LibwalletGrpcError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/libwallet/errors/LibwalletGrpcError.kt @@ -4,6 +4,7 @@ import com.google.rpc.Status import io.grpc.Metadata import io.grpc.StatusRuntimeException import io.grpc.protobuf.lite.ProtoLiteUtils +import io.muun.apollo.domain.errors.ErrorClassification import io.muun.apollo.domain.errors.MuunError import rpc.WalletServiceOuterClass import rpc.WalletServiceOuterClass.ErrorType @@ -11,6 +12,8 @@ import timber.log.Timber class LibwalletGrpcError(cause: StatusRuntimeException) : MuunError(cause) { + override val classification = ErrorClassification.UNEXPECTED + val errorDetail = mapToErrorDetail(parseErrorDetail(cause)) init { diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/libwallet/errors/LibwalletSigningError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/libwallet/errors/LibwalletSigningError.kt index 482911b6..0c805cb1 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/libwallet/errors/LibwalletSigningError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/libwallet/errors/LibwalletSigningError.kt @@ -1,11 +1,14 @@ package io.muun.apollo.domain.libwallet.errors +import io.muun.apollo.domain.errors.ErrorClassification import io.muun.apollo.domain.errors.MuunError private var msg = "Libwallet failed to produce a signature" class LibwalletSigningError(val tx: String, cause: Throwable) : MuunError(msg, cause) { + override val classification = ErrorClassification.UNEXPECTED + init { metadata["tx"] = tx } diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/libwallet/errors/LibwalletVerificationError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/libwallet/errors/LibwalletVerificationError.kt index ac3d6bd3..710d5823 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/libwallet/errors/LibwalletVerificationError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/libwallet/errors/LibwalletVerificationError.kt @@ -1,7 +1,10 @@ package io.muun.apollo.domain.libwallet.errors +import io.muun.apollo.domain.errors.ErrorClassification import io.muun.apollo.domain.errors.MuunError class LibwalletVerificationError(cause: Throwable): - MuunError("Libwallet rejected the transaction during verification", cause) + MuunError("Libwallet rejected the transaction during verification", cause) { + override val classification = ErrorClassification.UNEXPECTED +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/libwallet/errors/NoInvoicesLeftError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/libwallet/errors/NoInvoicesLeftError.kt index f2936a2b..3f93ee4d 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/libwallet/errors/NoInvoicesLeftError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/libwallet/errors/NoInvoicesLeftError.kt @@ -1,7 +1,10 @@ package io.muun.apollo.domain.libwallet.errors +import io.muun.apollo.domain.errors.ErrorClassification import io.muun.apollo.domain.errors.MuunError private var msg = "No unused invoices are left" -class NoInvoicesLeftError : MuunError(msg) +class NoInvoicesLeftError : MuunError(msg) { + override val classification = ErrorClassification.UNEXPECTED +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/libwallet/errors/PayloadDecryptError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/libwallet/errors/PayloadDecryptError.kt index 49d86a45..eedfb2a7 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/libwallet/errors/PayloadDecryptError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/libwallet/errors/PayloadDecryptError.kt @@ -1,7 +1,10 @@ package io.muun.apollo.domain.libwallet.errors +import io.muun.apollo.domain.errors.ErrorClassification import io.muun.apollo.domain.errors.MuunError private var msg = "Libwallet failed to decrypt a payload" -class PayloadDecryptError(cause: Throwable) : MuunError(msg, cause) +class PayloadDecryptError(cause: Throwable) : MuunError(msg, cause) { + override val classification = ErrorClassification.UNEXPECTED +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/libwallet/errors/PayloadEncryptError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/libwallet/errors/PayloadEncryptError.kt index 30df33f1..7249063c 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/libwallet/errors/PayloadEncryptError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/libwallet/errors/PayloadEncryptError.kt @@ -1,7 +1,10 @@ package io.muun.apollo.domain.libwallet.errors +import io.muun.apollo.domain.errors.ErrorClassification import io.muun.apollo.domain.errors.MuunError private var msg = "Libwallet failed to encrypt a payload" -class PayloadEncryptError(cause: Throwable) : MuunError(msg, cause) +class PayloadEncryptError(cause: Throwable) : MuunError(msg, cause) { + override val classification = ErrorClassification.UNEXPECTED +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/libwallet/errors/UnfulfillableIncomingSwapError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/libwallet/errors/UnfulfillableIncomingSwapError.kt index 8ae507f3..d9ab49cd 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/libwallet/errors/UnfulfillableIncomingSwapError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/libwallet/errors/UnfulfillableIncomingSwapError.kt @@ -1,11 +1,14 @@ package io.muun.apollo.domain.libwallet.errors +import io.muun.apollo.domain.errors.ErrorClassification import io.muun.apollo.domain.errors.MuunError private var msg = "Cant fulfill incoming swap" class UnfulfillableIncomingSwapError(uuid: String, cause: Throwable) : MuunError(msg, cause) { + override val classification = ErrorClassification.UNEXPECTED + init { metadata["uuid"] = uuid } diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/libwallet/errors/UnknownRecoveryCodeVersionError.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/libwallet/errors/UnknownRecoveryCodeVersionError.kt index bb9a048a..da2c411f 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/libwallet/errors/UnknownRecoveryCodeVersionError.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/libwallet/errors/UnknownRecoveryCodeVersionError.kt @@ -1,6 +1,9 @@ package io.muun.apollo.domain.libwallet.errors +import io.muun.apollo.domain.errors.ErrorClassification import io.muun.apollo.domain.errors.MuunError class UnknownRecoveryCodeVersionError(cause: Throwable): - MuunError("Libwallet failed to recognize version from Recovery Code", cause) \ No newline at end of file + MuunError("Libwallet failed to recognize version from Recovery Code", cause) { + override val classification = ErrorClassification.UNEXPECTED +} \ No newline at end of file diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/model/BitcoinAmount.java b/android/apolloui/src/main/java/io/muun/apollo/domain/model/BitcoinAmount.java index 9382c7eb..2f3554f8 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/model/BitcoinAmount.java +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/model/BitcoinAmount.java @@ -29,24 +29,6 @@ public BitcoinAmount( this.inPrimaryCurrency = inPrimaryCurrency; } - /** - * Return the sum of two BitcoinAmounts. - */ - public BitcoinAmount add(BitcoinAmount other) { - // TODO we should NOT be adding MonetaryAmounts, instead recalculating with the implied - // exchange rate using only satoshis. - - if (other == null) { - return this; - } - - return new BitcoinAmount( - inSatoshis + other.inSatoshis, - inInputCurrency.add(other.inInputCurrency), - inPrimaryCurrency.add(other.inPrimaryCurrency) - ); - } - public boolean isZero() { return inInputCurrency.isZero(); } diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/model/FeeWindow.java b/android/apolloui/src/main/java/io/muun/apollo/domain/model/FeeWindow.java index 4a78e98c..52f5f184 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/model/FeeWindow.java +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/model/FeeWindow.java @@ -1,7 +1,6 @@ package io.muun.apollo.domain.model; import io.muun.common.Rules; -import io.muun.common.utils.Preconditions; import com.fasterxml.jackson.annotation.JsonIgnore; import org.threeten.bp.ZonedDateTime; @@ -16,8 +15,6 @@ public class FeeWindow { // time before this fee estimation is considered too old to use private static final int EXPIRATION_TIME_MINUTES = 5; - private static final int SWAP_V2_CONF_TARGET = 250; // Approx 2 days - @NotNull public final Long houstonId; @@ -65,58 +62,6 @@ public boolean isRecent() { .isBefore(fetchDate); } - /** - * Get the appropriate fee rate for a given swap (depends on confirmations needed). - */ - public double getFeeRate(SubmarineSwap swap) { - Preconditions.checkNotNull(swap); - Preconditions.checkNotNull(swap.getFundingOutput().getConfirmationsNeeded()); - return getSwapFeeRate(swap.getFundingOutput().getConfirmationsNeeded()); - } - - /** - * Get the appropriate fee rate for a given swap (depends on confirmations needed). Useful - * method for when swap doesn't have a fixed amount (e.g AmountLessInvoices + use all funds). - */ - public double getSwapFeeRate(int confirmationsNeeded) { - if (confirmationsNeeded == 0) { - return getMinimumFeeInSatoshisPerByte(SWAP_V2_CONF_TARGET); - } - - return getFastestFeeInSatoshisPerByte(); - } - - /** - * Get the fastest fee rate, in satoshis per byte. - */ - public double getFastestFeeInSatoshisPerByte() { - final int shortestTarget = targetedFees.firstKey(); - final double satoshisPerByte = targetedFees.get(shortestTarget); - - return satoshisPerByte; - } - - /** - * Get the minimum available fee rate that will hit a given confirmation target. We make no - * guesses (no averages or interpolations), so we might overshoot the fee if data is too sparse. - */ - public double getMinimumFeeInSatoshisPerByte(int confirmationTarget) { - Preconditions.checkPositive(confirmationTarget); - - // Walk the available targets backwards, finding the highest target below the given one: - for (int closestTarget = confirmationTarget; closestTarget > 0; closestTarget--) { - if (targetedFees.containsKey(closestTarget)) { - // Found! This is the lowest fee rate that hits the given target. - return targetedFees.get(closestTarget); - } - } - - // No result? This is odd, but not illogical. It means *all* of our available targets - // are above the requested one. Let's use the fastest: - final int lowestTarget = targetedFees.firstKey(); - return targetedFees.get(lowestTarget); - } - /** * Migrate FeeWindow to start using dynamic fee targets, set by houston. We'll initialize with * previous fixed values. diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/model/MuunFeature.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/model/MuunFeature.kt index 671e9344..7fd41e50 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/model/MuunFeature.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/model/MuunFeature.kt @@ -11,6 +11,7 @@ enum class MuunFeature { OS_VERSION_DEPRECATED_FLOW, HIGH_FEES_RECEIVE_FLOW, EFFECTIVE_FEES_CALCULATION, + @Deprecated("V1 card firmware removed, use NFC_CARD_V2") NFC_CARD, NFC_CARD_V2, NFC_SENSORS, diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/model/NextTransactionSize.java b/android/apolloui/src/main/java/io/muun/apollo/domain/model/NextTransactionSize.java index ada71f3d..6e431ec5 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/model/NextTransactionSize.java +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/model/NextTransactionSize.java @@ -48,7 +48,7 @@ public NextTransactionSize(List sizeProgression, /** * Get the UTXO-only balance (without considering debt). */ - public long getUtxoBalance() { + private long getUtxoBalance() { return sizeProgression.isEmpty() ? 0 : sizeProgression.get(sizeProgression.size() - 1).amountInSatoshis; @@ -136,33 +136,6 @@ public NextTransactionSize initUtxoStatus() { return this; } - /** - * Extract complete list of outpoints, sorted as used in sizeProgression (aka as we use it for - * our fee computations). - */ - public List extractOutpoints() { - final ArrayList outpoints = new ArrayList<>(); - - for (SizeForAmount sizeForAmount : sizeProgression) { - - if ("uninitialized".equals(sizeForAmount.outpoint) || sizeForAmount.outpoint == null) { - continue; - } - - outpoints.add(sizeForAmount.outpoint); - } - - // outpoints will be empty for "uninitialized" nts - Preconditions.checkArgument( - outpoints.size() == sizeProgression.size() || outpoints.isEmpty() - ); - - // Houston expects the outpoint list (new clients) or null (old clients or "uninitialized" - // clients, aka NTS not yet re-fetched) - return outpoints.isEmpty() ? null : outpoints; - } - - /** * Filter and returns a complete list of unconfirmed utxos in current size progression. */ diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/model/report/EmailReport.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/model/report/EmailReport.kt index cd23c95b..31da423a 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/model/report/EmailReport.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/model/report/EmailReport.kt @@ -1,6 +1,7 @@ package io.muun.apollo.domain.model.report import android.app.ApplicationExitInfo +import android.net.Uri import android.os.Build import io.muun.apollo.data.external.Globals import io.muun.apollo.domain.utils.getUnsupportedCurrencies @@ -14,7 +15,13 @@ import timber.log.Timber import java.util.Locale import javax.annotation.CheckReturnValue -class EmailReport private constructor(val body: String) { +class EmailReport private constructor( + private val builder: Builder, + val body: String, + val attachmentUris: List = emptyList(), +) { + + fun withAttachments(uris: List): EmailReport = EmailReport(builder, body, uris) data class Builder( var report: ErrorReport? = null, @@ -178,7 +185,7 @@ class EmailReport private constructor(val body: String) { |${report!!.print(abridged)}""".trimMargin() Timber.d("EmailReport: \n$body") - return EmailReport(body) + return EmailReport(this, body) } private fun formatExitReasons(exitReasons: List): String { @@ -199,4 +206,7 @@ class EmailReport private constructor(val body: String) { fun subject(subjectPrefix: String) = String.format("%s (#%s)", subjectPrefix, reportId().substring(0, 8)) -} \ No newline at end of file + + fun abridge(): EmailReport = + builder.build(true) +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/secure_key_value_storage/Secret.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/secure_key_value_storage/Secret.kt new file mode 100644 index 00000000..2b93ef5d --- /dev/null +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/secure_key_value_storage/Secret.kt @@ -0,0 +1,26 @@ +package io.muun.apollo.domain.secure_key_value_storage + +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Wraps plaintext bytes received from libwallet. The buffer is zeroed when + * [withSecret] returns, whether the callback completes normally or throws. + * Single-use: calling [withSecret] twice throws. Concurrent callers race on + * an [AtomicBoolean] so the loser fails fast instead of reading wiped bytes. + * + * Never convert the bytes to a String inside the callback. Strings are + * immutable on the JVM and cannot be cleared. + */ +class Secret(private val bytes: ByteArray) { + + private val consumed = AtomicBoolean(false) + + fun withSecret(fn: (ByteArray) -> Unit) { + check(consumed.compareAndSet(false, true)) { "Secret already consumed" } + try { + fn(bytes) + } finally { + bytes.fill(0) + } + } +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/selector/FeatureSelector.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/selector/FeatureSelector.kt index e736a8b2..3a5d187d 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/selector/FeatureSelector.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/selector/FeatureSelector.kt @@ -34,7 +34,7 @@ class FeatureSelector @Inject constructor( fetch().toBlocking().first().contains(feature) fun hasSecurityCardEnabled(): Boolean { - return get(MuunFeature.NFC_CARD) || get(MuunFeature.NFC_CARD_V2) + return get(MuunFeature.NFC_CARD_V2) } /** diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/utils/TimeTracker.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/utils/TimeTracker.kt index 4d643c08..46f142b8 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/utils/TimeTracker.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/utils/TimeTracker.kt @@ -33,6 +33,17 @@ const val EK_CHILD_MUUN_KEY = "muun_key" const val EK_CHILD_MUUN_FINGERPRINT = "muun_fp" const val EK_CHILD_RC_CHECKSUM = "rc_checksum" +// Child labels for the Go render profiling forwarded from libwallet. +const val EK_CHILD_GO_LOAD_TRANSLATIONS = "go_load_translations_ms" +const val EK_CHILD_GO_REGISTER_FONTS = "go_register_fonts_ms" +const val EK_CHILD_GO_REGISTER_IMAGES = "go_register_images_ms" +const val EK_CHILD_GO_COMPONENTS_RENDERING = "go_components_rendering_ms" +const val EK_CHILD_GO_CREATE_AND_SAVE_ON_DISK = "go_create_and_save_on_disk_ms" +const val EK_CHILD_GO_TOTAL_HEAP_ALLOCATED = "go_total_heap_allocated_bytes" +const val EK_CHILD_GO_TOTAL_OBJECTS_ALLOCATED = "go_total_objects_allocated" +const val EK_CHILD_GO_EMBED_METADATA = "go_embed_metadata_ms" +const val EK_CHILD_GO_TOTAL_INSIDE_GO = "go_total_inside_go_ms" + /** Factory for timing traces. Inject this and call [start] to begin measuring. */ class TimeTracker @Inject constructor(private val analytics: Analytics) { diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/utils/Trace.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/utils/Trace.kt index fa59a37e..82336a22 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/utils/Trace.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/utils/Trace.kt @@ -15,12 +15,21 @@ class Trace internal constructor( private var finished = false private val children = mutableListOf() + private val manualChildren = mutableListOf>() /** Create a child trace. Call [ChildTrace.finish] when the child operation completes. */ fun child(label: String): ChildTrace { return ChildTrace(label).also { children.add(it) } } + /** + * Attach a child whose value was measured elsewhere (e.g. inside libwallet and returned over + * gRPC), rather than timed by this Trace. + */ + fun addChild(label: String, value: Long) { + manualChildren.add(label to value.toString()) + } + /** Report the elapsed time to analytics. */ fun finish() { if (finished) { @@ -33,7 +42,7 @@ class Trace internal constructor( finished = true val elapsed = System.currentTimeMillis() - startTime - val childMap = children.associate { it.result() } + val childMap = children.associate { it.result() } + manualChildren analytics.report(AnalyticsEvent.E_TIME_TRACKER(label, elapsed, childMap)) } diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/app/ApolloApplication.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/app/ApolloApplication.kt index f553e6c9..ac278565 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/app/ApolloApplication.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/app/ApolloApplication.kt @@ -27,6 +27,7 @@ import io.muun.apollo.data.external.UserFacingErrorMessages import io.muun.apollo.data.logging.Crashlytics import io.muun.apollo.data.logging.LoggingContext import io.muun.apollo.data.logging.MuunTree +import io.muun.apollo.data.nfc.NfcEmpiricalCache import io.muun.apollo.data.preferences.migration.PreferencesMigrationManager import io.muun.apollo.domain.ApplicationLockManager import io.muun.apollo.domain.BackgroundTimesProcessor @@ -99,6 +100,9 @@ open class ApolloApplication : Application(), DataComponentProvider, Configurati @Inject lateinit var appStartupInitializer: AppStartupInitializer + @Inject + lateinit var nfcEmpiricalCache: NfcEmpiricalCache + override fun onCreate() { // The order of the calls in this method is intentional. // Please don't rearrange them without understanding the implications. @@ -107,8 +111,9 @@ open class ApolloApplication : Application(), DataComponentProvider, Configurati initializeStaticSingletons() + val firebaseTestLabDevice = isFirebaseTestLabDevice() HeapDumper.init(this) - Crashlytics.init(this) + Crashlytics.init(this, firebaseTestLabDevice) StrictMode.init() ensureCurrencyServicesLoaded() @@ -116,7 +121,7 @@ open class ApolloApplication : Application(), DataComponentProvider, Configurati AndroidThreeTen.init(this) // Ignore tracking events for Firebase Test Lab devices - if (isFirebaseTestLabDevice()) { + if (firebaseTestLabDevice) { FirebaseAnalytics.getInstance(this).setAnalyticsCollectionEnabled(false) } @@ -128,6 +133,8 @@ open class ApolloApplication : Application(), DataComponentProvider, Configurati initializeLibwallet() + nfcEmpiricalCache.initFromStorage() + migrateSharedPreferences() // Must run after the prefs migration. @@ -189,7 +196,7 @@ open class ApolloApplication : Application(), DataComponentProvider, Configurati * [...](https://stackoverflow.com/a/45070039/901465) */ private fun isFirebaseTestLabDevice(): Boolean { - return "true" == Settings.System.getString(getContentResolver(), "firebase.test.lab") + return "true" == Settings.System.getString(contentResolver, "firebase.test.lab") } /** diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/app/Email.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/app/Email.kt index 216f6714..d9cc421a 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/app/Email.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/app/Email.kt @@ -45,7 +45,29 @@ object Email { val subject: String = emailReport.subject(subjectPrefix) val body: String = emailReport.body - return composeEmail(arrayOf("support@muun.com"), subject, body) + val intent = composeEmail(arrayOf("support@muun.com"), subject, body) + + // For now, Email Error reports only support 1 attachment. + if (emailReport.attachmentUris.isNotEmpty()) { + intent.putExtra(Intent.EXTRA_STREAM, emailReport.attachmentUris.first()) + } + return intent + } + + /** + * Build an Android Intent to send a debug data email with multiple attachments. + */ + fun buildDebugDataEmailIntent(context: Context, emailReport: EmailReport): Intent { + val subjectPrefix: String = context.getString(R.string.error_report_email_subject) + val subject: String = emailReport.subject(subjectPrefix) + val body: String = emailReport.body + + return composeEmailWithMultipleAttachments( + arrayOf("mobile@muun.com"), + subject, + body, + ArrayList(emailReport.attachmentUris), + ) } /** @@ -79,4 +101,25 @@ object Email { intent.putExtra(Intent.EXTRA_TEXT, body) return intent } -} \ No newline at end of file + + private fun composeEmailWithMultipleAttachments( + addresses: Array, + subject: String, + body: String, + attachmentUris: ArrayList, + ): Intent { + + // 1. Create the target share intent for multiple attachments + val intent = Intent(Intent.ACTION_SEND_MULTIPLE) + intent.putExtra(Intent.EXTRA_EMAIL, addresses) + intent.putExtra(Intent.EXTRA_SUBJECT, subject) + intent.putExtra(Intent.EXTRA_TEXT, body) + intent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, attachmentUris) + + // 2. Create the selector intent targeting strictly mail clients and attach it to main + // sharing intent. Crucial to only target email apps. + intent.selector = getSendEmailIntent() + + return intent + } +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/app/Logcat.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/app/Logcat.kt index 60d6b81a..0de0be0b 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/app/Logcat.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/app/Logcat.kt @@ -1,35 +1,23 @@ package io.muun.apollo.presentation.app -import android.content.Context -import android.content.Intent +import android.net.Uri import io.muun.apollo.data.fs.FileCache import timber.log.Timber import javax.inject.Inject -class Logcat @Inject constructor(private val context: Context, private val fileCache: FileCache) { +class Logcat @Inject constructor(private val fileCache: FileCache) { - fun addLogsAsAttachment(emailIntent: Intent) { - - // Load/Write our app's logs to a local file + /** + * Write logcat to cache file and return its content URI. + */ + fun getLogsUri(): Uri? { val outputFile = fileCache.getFile(FileCache.Entry.LOGCAT) try { Runtime.getRuntime().exec("logcat -f " + outputFile.absolutePath) } catch (e: Throwable) { Timber.e(RuntimeException("Error accessing app logs", e)) - return - } - - // Grant Uri permissions to email app's (e.g required by Gmail to successfully attach file) - val resInfoList = context.packageManager.queryIntentActivities(emailIntent, 0) - for (resolveInfo in resInfoList) { - context.grantUriPermission( - resolveInfo.activityInfo.packageName, - fileCache.get(FileCache.Entry.LOGCAT).uri, - Intent.FLAG_GRANT_READ_URI_PERMISSION - ) + return null } - - // Add file as attachment to email intent - emailIntent.putExtra(Intent.EXTRA_STREAM, fileCache.get(FileCache.Entry.LOGCAT).uri) + return fileCache.get(FileCache.Entry.LOGCAT).uri } -} \ No newline at end of file +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/app/Navigator.java b/android/apolloui/src/main/java/io/muun/apollo/presentation/app/Navigator.java index dc24de51..2daeb637 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/app/Navigator.java +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/app/Navigator.java @@ -14,6 +14,7 @@ import io.muun.apollo.presentation.ui.base.SingleFragment; import io.muun.apollo.presentation.ui.base.SingleFragmentActivityImpl; import io.muun.apollo.presentation.ui.debug.DebugPanelActivity; +import io.muun.apollo.presentation.ui.debug.securekv.DebugSecureKeyValueStorageActivity; import io.muun.apollo.presentation.ui.diagnostic.DiagnosticActivity; import io.muun.apollo.presentation.ui.export_keys.EmergencyKitActivity; import io.muun.apollo.presentation.ui.feedback.anon.AnonFeedbackActivity; @@ -37,11 +38,13 @@ import io.muun.apollo.presentation.ui.security_cards_card_detail.CardDetailActivity; import io.muun.apollo.presentation.ui.security_cards_country_picker.CountryPickerActivity; import io.muun.apollo.presentation.ui.security_cards_country_picker.models.CountryInfo; +import io.muun.apollo.presentation.ui.security_cards_full_specs.SecurityCardsFullSpecsActivity; import io.muun.apollo.presentation.ui.security_cards_marketplace.SecurityCardsMarketplaceActivity; import io.muun.apollo.presentation.ui.security_cards_marketplace.models.MarketplaceFooter; import io.muun.apollo.presentation.ui.security_cards_marketplace.models.SecurityCard; import io.muun.apollo.presentation.ui.security_cards_marketplace.models.SecurityCardProvider; import io.muun.apollo.presentation.ui.security_cards_onboarding.SecurityCardsOnboardingActivity; +import io.muun.apollo.presentation.ui.security_cards_shipping_address.ShippingAddressActivity; import io.muun.apollo.presentation.ui.security_logout.SecurityLogoutActivity; import io.muun.apollo.presentation.ui.select_bitcoin_unit.SelectBitcoinUnitActivity; import io.muun.apollo.presentation.ui.select_night_mode.SelectNightModeActivity; @@ -207,6 +210,18 @@ public void navigateToDiagnosticMode(Context context) { context.startActivity(intent); } + /** + * Takes the user to the SecureKeyValueStorage debug screen. + */ + public void navigateToSecureKeyValueStorageDebug(Context context) { + final Intent intent = + DebugSecureKeyValueStorageActivity.Companion.getStartActivityIntent(context); + + intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NO_ANIMATION); + + context.startActivity(intent); + } + /** * Takes the user to the Send screen. */ @@ -692,14 +707,20 @@ public void navigateToSecurityCardsMarketplace( context.startActivity(intent); } + /** + * Navigate to the card detail screen. + */ public void navigateToCardDetail( @NotNull Context context, + @NotNull CountryInfo countryInfo, @NotNull SecurityCardProvider provider, @NotNull SecurityCard card, @NotNull MarketplaceFooter footer ) { final Intent intent = - CardDetailActivity.Companion.getIntent(context, provider, card, footer); + CardDetailActivity.Companion.getIntent( + context, countryInfo, provider, card, footer + ); context.startActivity(intent); } @@ -725,4 +746,28 @@ public void navigateToSecurityCardsMarketplaceOnboarding( ) { context.startActivity(SecurityCardsOnboardingActivity.Companion.getIntent(context)); } + + public void navigateToSecurityCardsFullSpecs( + @NotNull Context context, + @NotNull SecurityCard card + ) { + context.startActivity( + SecurityCardsFullSpecsActivity.Companion.getIntent(context, card) + ); + } + + /** + * Navigate to the shipping address screen. + */ + public void navigateToShippingAddress( + @NotNull Context context, + @NotNull CountryInfo countryInfo, + @NotNull SecurityCardProvider provider + ) { + context.startActivity( + ShippingAddressActivity.Companion.getIntent( + context, countryInfo, provider + ) + ); + } } diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/app/di/ApplicationComponent.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/app/di/ApplicationComponent.kt index a3baa013..81da4b6a 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/app/di/ApplicationComponent.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/app/di/ApplicationComponent.kt @@ -4,13 +4,17 @@ import dagger.Component import io.muun.apollo.data.di.DataComponent import io.muun.apollo.presentation.app.ApolloApplication import io.muun.apollo.presentation.app.di.modules.BiometricsModule +import io.muun.apollo.presentation.app.di.modules.InAppUpdateModule import io.muun.apollo.presentation.app.di.modules.StartupModule import io.muun.apollo.presentation.ui.base.di.ActivityComponent import io.muun.apollo.presentation.ui.base.di.FragmentComponent import io.muun.apollo.presentation.ui.base.di.ViewComponent @PerApplication -@Component(dependencies = [DataComponent::class], modules = [StartupModule::class, BiometricsModule::class]) +@Component( + dependencies = [DataComponent::class], + modules = [StartupModule::class, BiometricsModule::class, InAppUpdateModule::class], +) interface ApplicationComponent { fun inject(application: ApolloApplication) diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/model/UiOperation.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/model/UiOperation.kt index f623fcac..1d344797 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/model/UiOperation.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/model/UiOperation.kt @@ -9,7 +9,6 @@ import io.muun.apollo.data.external.Globals import io.muun.apollo.domain.libwallet.Invoice.decodeInvoice import io.muun.apollo.domain.model.BitcoinUnit import io.muun.apollo.domain.model.Operation -import io.muun.apollo.domain.utils.isEmpty import io.muun.apollo.domain.utils.locale import io.muun.apollo.presentation.ui.helper.BitcoinHelper import io.muun.apollo.presentation.ui.helper.MoneyHelper @@ -21,7 +20,6 @@ import io.muun.common.bitcoinj.BlockHelpers import io.muun.common.bitcoinj.NetworkParametersHelper import io.muun.common.exception.MissingCaseError import io.muun.common.model.OperationStatus -import io.muun.common.utils.Preconditions import libwallet.Libwallet import java.util.Locale import java.util.regex.Pattern @@ -84,7 +82,7 @@ abstract class UiOperation( val detailedFee: String get() { val (feeInSats, feeInPrimaryCurr) = calculateFee() - return getFormattedAmount(`feeInSats`, feeInPrimaryCurr) + return getFormattedAmount(feeInSats, feeInPrimaryCurr) } /** @@ -102,7 +100,7 @@ abstract class UiOperation( // If operation is a swap we display both funding and sweep tx as the operation fee if (operation.swap != null) { - val totalFeeInSats = feeInSats + (operation.swap!!.totalFeesInSat() ?: 0) + val totalFeeInSats = feeInSats + (operation.swap.totalFeesInSat() ?: 0) // As we don't have the value in primary currency for some of the fees involved in a // swap, we use a Rule of 3 to calculate their value in primary currency using amount as @@ -180,7 +178,7 @@ abstract class UiOperation( get() = isSwap && operation.swap!!.fundingOutput.confirmationsNeeded == 0 val swapInvoice: String - get() = if (operation.swap == null) "" else operation.swap!!.invoice + get() = if (operation.swap == null) "" else operation.swap.invoice val isIncomingSwap: Boolean get() = operation.isIncomingSwap @@ -198,7 +196,7 @@ abstract class UiOperation( get() = if (operation.swap == null) { "" } else { - operation.swap!!.receiver.formattedDestination + operation.swap.receiver.formattedDestination } /** @@ -213,7 +211,7 @@ abstract class UiOperation( val preimage: String get() = if (operation.swap != null) { // 2nd ? is needed to avoid generic kotlin Any?.toString() which prints "null" - operation.swap!!.preimage?.toString() ?: "" + operation.swap.preimage?.toString() ?: "" } else { operation.incomingSwap?.getPreimage()?.toString() ?: "" } @@ -223,7 +221,7 @@ abstract class UiOperation( */ val paymentHash: String get() = if (operation.swap != null) { - operation.swap!!.fundingOutput.paymentHash.toString() + operation.swap.fundingOutput.paymentHash.toString() } else { // 2nd ? is needed to avoid generic kotlin Any?.toString() which prints "null" operation.incomingSwap?.getPaymentHash()?.toString() ?: "" @@ -231,7 +229,7 @@ abstract class UiOperation( val invoiceDescription: String? get() { - if (operation.metadata == null || operation.metadata!!.invoice.isEmpty()) { + if (operation.metadata?.invoice.isNullOrEmpty()) { return null } @@ -239,7 +237,7 @@ abstract class UiOperation( } val lnUrlSender: String? - get() = if (operation.metadata != null) operation.metadata!!.lnurlSender else null + get() = operation.metadata?.lnurlSender /** * Get the input amount without sign, to be displayed in the operation detail. @@ -272,7 +270,7 @@ abstract class UiOperation( /** * Get the description as formatted RichText. */ - fun getFormattedDescription(context: Context): CharSequence? = + fun getFormattedDescription(context: Context): CharSequence = when { operation.description != null -> operation.description isIncomingSwap -> context.getString(R.string.history_external_incoming_swap_description) @@ -398,8 +396,8 @@ abstract class UiOperation( * Get refund message for a failed swap. */ fun getRefundMessage(context: Context, blockchainHeight: Int): String { - Preconditions.checkNotNull(operation.swap) - val fundingOutput = operation.swap!!.fundingOutput + checkNotNull(operation.swap) + val fundingOutput = operation.swap.fundingOutput return if (fundingOutput.scriptVersion == Libwallet.AddressVersionSwapsV2.toInt()) { context.getString(R.string.operation_swap_expired_desc) } else { @@ -412,9 +410,9 @@ abstract class UiOperation( */ private fun getRefundMessageForSwapV1(context: Context, blockchainHeight: Int): String { checkNotNull(operation.swap) - checkNotNull(operation.swap!!.fundingOutput.userLockTime) + checkNotNull(operation.swap.fundingOutput.userLockTime) - val fundingOutput = operation.swap!!.fundingOutput + val fundingOutput = operation.swap.fundingOutput val blocksUntilRefund = fundingOutput.userLockTime!! - blockchainHeight if (blocksUntilRefund <= 0) { // If userLockTime is reached, swap's already expired/refunded return context.getString(R.string.operation_swap_expired_desc) diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/activity/extension/NfcReaderModeExtension.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/activity/extension/NfcReaderModeExtension.kt index d75715ca..2774ba5a 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/activity/extension/NfcReaderModeExtension.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/activity/extension/NfcReaderModeExtension.kt @@ -8,6 +8,7 @@ import android.os.Bundle import android.os.Handler import android.os.Looper import io.muun.apollo.data.external.Globals +import io.muun.apollo.data.nfc.NfcEmpiricalCache import io.muun.apollo.data.nfc.api.NfcSessionBuilder import io.muun.apollo.presentation.ui.base.ActivityExtension import io.muun.apollo.presentation.ui.base.di.PerActivity @@ -15,8 +16,10 @@ import timber.log.Timber import javax.inject.Inject @PerActivity -class NfcReaderModeExtension @Inject constructor(context: Context) : ActivityExtension(), - NfcAdapter.ReaderCallback { +class NfcReaderModeExtension @Inject constructor( + context: Context, + private val empiricalCache: NfcEmpiricalCache, +) : ActivityExtension(), NfcAdapter.ReaderCallback { private val nfcAdapter: NfcAdapter? = NfcAdapter.getDefaultAdapter(context) @@ -50,7 +53,7 @@ class NfcReaderModeExtension @Inject constructor(context: Context) : ActivityExt override fun onTagDiscovered(tag: Tag) { val nfcAtag = IsoDep.get(tag) - val nfcSession = NfcSessionBuilder.forTag(nfcAtag) + val nfcSession = NfcSessionBuilder.forTag(nfcAtag, empiricalCache) activity.onNewNfcSession(nfcSession) } } \ No newline at end of file diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/base/BasePresenter.java b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/base/BasePresenter.java index 6c1159e0..fba2290c 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/base/BasePresenter.java +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/base/BasePresenter.java @@ -8,8 +8,8 @@ import io.muun.apollo.data.preferences.AuthRepository; import io.muun.apollo.data.preferences.ClientVersionRepository; import io.muun.apollo.domain.ClipboardManager; -import io.muun.apollo.domain.EmailReportManager; import io.muun.apollo.domain.action.base.ActionState; +import io.muun.apollo.domain.action.debug.BuildErrorEmailReportAction; import io.muun.apollo.domain.action.session.LogoutAction; import io.muun.apollo.domain.analytics.Analytics; import io.muun.apollo.domain.analytics.AnalyticsEvent; @@ -19,13 +19,10 @@ import io.muun.apollo.domain.errors.TooManyRequestsError; import io.muun.apollo.domain.errors.UserFacingError; import io.muun.apollo.domain.model.report.EmailReport; -import io.muun.apollo.domain.model.report.ErrorReport; -import io.muun.apollo.domain.model.report.ErrorReportBuilder; import io.muun.apollo.domain.selector.LogoutOptionsSelector; import io.muun.apollo.domain.selector.UserSelector; import io.muun.apollo.domain.utils.ExtensionsKt; import io.muun.apollo.presentation.app.Email; -import io.muun.apollo.presentation.app.Logcat; import io.muun.apollo.presentation.app.Navigator; import io.muun.apollo.presentation.ui.activity.extension.MuunDialog; import io.muun.common.Optional; @@ -36,7 +33,9 @@ import android.content.Context; import android.content.Intent; import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; import android.net.NetworkInfo; +import android.net.Uri; import android.os.Bundle; import androidx.annotation.CallSuper; import androidx.annotation.Nullable; @@ -50,6 +49,7 @@ import rx.subscriptions.CompositeSubscription; import timber.log.Timber; +import java.util.List; import javax.inject.Inject; import javax.validation.constraints.NotNull; @@ -113,10 +113,7 @@ public class BasePresenter implements Presenter { protected ClipboardManager clipboardManager; @Inject - protected EmailReportManager emailReportManager; - - @Inject - protected Logcat logcat; + protected BuildErrorEmailReportAction buildErrorEmailReportAction; @Inject protected BasePresenter() { @@ -162,6 +159,7 @@ public void setUp(@NotNull Bundle arguments) { setUpNetworkInfo(); setUpDeprecatedClientVersionCheck(); setUpSessionExpiredCheck(); + setUpBuildErrorEmailReportAction(); } @Override @@ -473,6 +471,31 @@ protected void setUpSessionExpiredCheck() { subscribeTo(observable); } + @VisibleForTesting + protected void setUpBuildErrorEmailReportAction() { + final Observable> observable = + buildErrorEmailReportAction.getState() + // Can't handle loading state reliably since BaseView doens't enforce + // setLoading() + .doOnNext(state -> { + switch (state.getKind()) { + case VALUE: + onErrorEmailReportReady(state.getValue()); + break; + case ERROR: + Timber.e( + state.getError(), + "Failed to build error email report" + ); + break; + default: + break; + } + }); + + subscribeTo(observable); + } + private void checkClientVersion(int minClientVersion) { if (shouldCheckClientState() && Globals.INSTANCE.getVersionCode() < minClientVersion) { throw new DeprecatedClientVersionError(); @@ -540,27 +563,38 @@ private void showErrorReportDialog(Throwable error, boolean standalone) { @Override public void sendErrorReport(Throwable error) { + buildErrorEmailReportAction.run(error, this.getClass().getSimpleName()); + } - final ErrorReport report = ErrorReportBuilder.INSTANCE.build(error); - analytics.attachAnalyticsMetadata(report); - - final EmailReport emailReport = emailReportManager - .buildEmailReport(report, this.getClass().getSimpleName(), false); - + private void onErrorEmailReportReady(EmailReport emailReport) { Intent emailIntent = Email.INSTANCE.buildEmailReportIntent(getContext(), emailReport); + // Grant URI permissions to email apps for attachments + final List resInfoList = getContext().getPackageManager() + .queryIntentActivities(emailIntent, 0); + try { - logcat.addLogsAsAttachment(emailIntent); + for (ResolveInfo resolveInfo : resInfoList) { + for (Uri uri : emailReport.getAttachmentUris()) { + getContext().grantUriPermission( + resolveInfo.activityInfo.packageName, + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION + ); + } + } } catch (Throwable t) { // Avoid crashing if ANYTHING goes wrong while trying to attach logs to email - Timber.i("Error while attaching logs: %s", t.getMessage()); - - // Build EmailReport with stacktrace "trimmed" to avoid crashing at startActivity - // Note: We may have to cut down on breadcrumbs or other metadata if this is still - // a problem. - final EmailReport abridgedEmailReport = emailReportManager - .buildAbridgedEmailReport(report, this.getClass().getSimpleName()); - emailIntent = Email.INSTANCE.buildEmailReportIntent(getContext(), abridgedEmailReport); + Timber.i("Error granting URI permission: %s", t.getMessage()); + + // Build EmailReport with stacktrace "trimmed" to avoid crashing. + // Note: We may have to cut down on breadcrumbs or other metadata if this is + // still a problem. + final EmailReport abridgedEmailReport = emailReport.abridge(); + emailIntent = Email.INSTANCE.buildEmailReportIntent( + getContext(), + abridgedEmailReport + ); } if (Email.INSTANCE.hasEmailAppInstalled(getContext())) { diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/base/SingleFragmentActivity.java b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/base/SingleFragmentActivity.java index aa371bcf..90e3c1f3 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/base/SingleFragmentActivity.java +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/base/SingleFragmentActivity.java @@ -150,14 +150,6 @@ public void onBackPressed() { } } - /** - * Horrible. I know. Quick hack to avoid dealing with ramifications of changing onBackPressed - * (popBackStack) logic. - */ - protected void superOnBackPressed() { - super.onBackPressed(); - } - private void pushToBackStack(FragmentTransaction transaction, boolean canGoBackToCurrent) { String transactionTag = Boolean.toString(canGoBackToCurrent); @@ -240,7 +232,7 @@ private void popBackStack() { if (hadPreviousFragment) { backStackTags.remove(backStackTags.size() - 1); } else { - finishActivity(); // TODO we probably want to super.onBackPressed() + super.onBackPressed(); } } diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/base/di/ActivityComponent.java b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/base/di/ActivityComponent.java index 225c6eef..de0815b5 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/base/di/ActivityComponent.java +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/base/di/ActivityComponent.java @@ -3,6 +3,7 @@ import io.muun.apollo.presentation.ui.activity.operations.OperationsActivity; import io.muun.apollo.presentation.ui.base.SingleFragmentActivityImpl; import io.muun.apollo.presentation.ui.debug.DebugPanelActivity; +import io.muun.apollo.presentation.ui.debug.securekv.DebugSecureKeyValueStorageActivity; import io.muun.apollo.presentation.ui.diagnostic.DiagnosticActivity; import io.muun.apollo.presentation.ui.export_keys.EmergencyKitActivity; import io.muun.apollo.presentation.ui.feedback.anon.AnonFeedbackActivity; @@ -22,8 +23,10 @@ import io.muun.apollo.presentation.ui.scan_qr.ScanQrActivity; import io.muun.apollo.presentation.ui.security_cards_card_detail.CardDetailActivity; import io.muun.apollo.presentation.ui.security_cards_country_picker.CountryPickerActivity; +import io.muun.apollo.presentation.ui.security_cards_full_specs.SecurityCardsFullSpecsActivity; import io.muun.apollo.presentation.ui.security_cards_marketplace.SecurityCardsMarketplaceActivity; import io.muun.apollo.presentation.ui.security_cards_onboarding.SecurityCardsOnboardingActivity; +import io.muun.apollo.presentation.ui.security_cards_shipping_address.ShippingAddressActivity; import io.muun.apollo.presentation.ui.security_logout.SecurityLogoutActivity; import io.muun.apollo.presentation.ui.select_amount.SelectAmountActivity; import io.muun.apollo.presentation.ui.select_bitcoin_unit.SelectBitcoinUnitActivity; @@ -62,6 +65,8 @@ public interface ActivityComponent { void inject(DebugPanelActivity debugPanelActivity); + void inject(DebugSecureKeyValueStorageActivity debugSecureKeyValueStorageActivity); + void inject(ShowQrActivity showQrActivity); void inject(ScanQrActivity scanQrActivity); @@ -131,6 +136,10 @@ public interface ActivityComponent { void inject(CountryPickerActivity countryPickerActivity); void inject(SecurityCardsOnboardingActivity securityCardsOnboardingActivity); - + void inject(CardDetailActivity cardDetailActivity); + + void inject(SecurityCardsFullSpecsActivity securityCardsFullSpecsActivity); + + void inject(ShippingAddressActivity shippingAddressActivity); } diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/debug/DebugPanelActivity.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/debug/DebugPanelActivity.kt index 4375ce91..ac5627e6 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/debug/DebugPanelActivity.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/debug/DebugPanelActivity.kt @@ -37,7 +37,6 @@ class DebugPanelActivity : BaseActivity(), BaseView { private enum class NfcPairingState { NONE, PAIRING, - UNPAIRING } } @@ -106,27 +105,14 @@ class DebugPanelActivity : BaseActivity(), BaseView { .let(this@DebugPanelActivity::showDialog) } - debugButtonUnpairSecurityCard.setOnClickListener { - pairingState = NfcPairingState.UNPAIRING - // Turn on NFC reader Mode - enableReaderMode() - - // Show Simple Message Dialog - prompt card tap - MuunDialog.Builder() - .title("Unpair Security Card") - .message("Please tap your device with the security card to confirm Unpairing.") - .onDismiss { - disableReaderMode() - pairingState = NfcPairingState.NONE - } - .build() - .let(this@DebugPanelActivity::showDialog) - } - debugButtonEnterDiagnosticMode.setOnClickListener { presenter.enterDiagnosticMode() } + debugButtonSecureKeyValueStorageDebug.setOnClickListener { + presenter.enterSecureKeyValueStorageDebug() + } + debugButtonForceErrorReport.setOnClickListener { presenter.forceErrorReport() } @@ -231,45 +217,9 @@ class DebugPanelActivity : BaseActivity(), BaseView { } } - NfcPairingState.UNPAIRING -> { - - // TODO do proper error handling - val success = try { - presenter.resetSecurityCard(nfcSession) - true - } catch (e: Exception) { - Timber.e(e) - false - } - - dismissDialog() - - if (success) { - runOnUiThread { - binding.nfcCardPaired.text = RichText("FALSE") - .setForegroundColor(ContextCompat.getColor(this, R.color.red)) - .setBold() - - MuunDialog.Builder() - .title("Unpair Security Card") - .message("Success!") - .build() - .let(this::showDialog) - } - } else { - runOnUiThread { - MuunDialog.Builder() - .title("Unpair Security Card") - .message("Error! See Debug logs or dismiss and try again") - .build() - .let(this::showDialog) - } - } - } - else -> { // Ignore - Timber.d("NFC: onTagDiscovered when neither pairing nor unpairing") + Timber.d("NFC: onTagDiscovered when not pairing") } } } diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/debug/DebugPanelPresenter.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/debug/DebugPanelPresenter.kt index ca4aa660..0fd0983a 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/debug/DebugPanelPresenter.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/debug/DebugPanelPresenter.kt @@ -187,17 +187,17 @@ class DebugPanelPresenter @Inject constructor( } /** - * Reset a security card to enable re-use. Only to be used in local builds. + * Navigates to the 'diagnostic' activity */ - fun resetSecurityCard(nfcSession: NfcSession) { - debugExecutable.resetSecurityCard(nfcSession) + fun enterDiagnosticMode() { + navigator.navigateToDiagnosticMode(context) } /** - * Navigates to the 'diagnostic' activity + * Navigates to the SecureKeyValueStorage debug screen. */ - fun enterDiagnosticMode() { - navigator.navigateToDiagnosticMode(context) + fun enterSecureKeyValueStorageDebug() { + navigator.navigateToSecureKeyValueStorageDebug(context) } /** diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/debug/securekv/DebugSecureKeyValueStorageActivity.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/debug/securekv/DebugSecureKeyValueStorageActivity.kt new file mode 100644 index 00000000..fbaa061d --- /dev/null +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/debug/securekv/DebugSecureKeyValueStorageActivity.kt @@ -0,0 +1,126 @@ +package io.muun.apollo.presentation.ui.debug.securekv + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.widget.Toast +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity +import io.muun.apollo.data.os.secure_storage.SecureStorageProvider +import io.muun.apollo.data.secure_key_value_storage.SecureKeyValueStorageRepository +import io.muun.apollo.databinding.ActivityDebugSecureKeyValueStorageBinding +import io.muun.apollo.presentation.app.ApolloApplication +import io.muun.apollo.presentation.app.Navigator +import io.muun.apollo.presentation.ui.base.di.ActivityComponent +import io.muun.apollo.presentation.ui.utils.setWindowInsetsCompat +import io.muun.apollo.presentation.ui.view.MuunHeader +import javax.inject.Inject + +class DebugSecureKeyValueStorageActivity : AppCompatActivity() { + + companion object { + private const val SMOKE_KEY = "libwallet-bridge-smoketest" + + fun getStartActivityIntent(context: Context): Intent { + return Intent(context, DebugSecureKeyValueStorageActivity::class.java) + } + } + + private lateinit var binding: ActivityDebugSecureKeyValueStorageBinding + + private val component: ActivityComponent + get() { + return (application as ApolloApplication).getApplicationComponent().activityComponent() + } + + @Inject + lateinit var secureKeyValueStorageRepository: SecureKeyValueStorageRepository + + @Inject + lateinit var secureStorageProvider: SecureStorageProvider + + @Inject + lateinit var navigator: Navigator + + override fun onCreate(savedInstanceState: Bundle?) { + setWindowInsetsCompat() + super.onCreate(savedInstanceState) + + component.inject(this) + + binding = ActivityDebugSecureKeyValueStorageBinding.inflate(layoutInflater) + setContentView(binding.root) + + setUpHeader() + initializeUi() + } + + private fun setUpHeader() { + binding.header.attachToActivity(this) + binding.header.setNavigation(MuunHeader.Navigation.BACK) + binding.header.showTitle("SecureKeyValueStorage debug") + } + + override fun onSupportNavigateUp(): Boolean { + finish() + return true + } + + private fun initializeUi() { + binding.debugButtonSecureKvPut.setOnClickListener { + try { + val payload = "hello-from-libwallet-${System.currentTimeMillis()}".toByteArray() + secureKeyValueStorageRepository.put(SMOKE_KEY, payload) + toast("PUT OK: key=$SMOKE_KEY") + } catch (e: Throwable) { + toast("PUT failed: ${e.message}") + } + } + + binding.debugButtonSecureKvGet.setOnClickListener { + try { + val secret = secureKeyValueStorageRepository.get(SMOKE_KEY) + secret.withSecret { bytes -> + toast("GET OK: ${bytes.size} bytes") + } + } catch (e: Throwable) { + toast("GET failed: ${e.message}") + } + } + + binding.debugButtonSecureKvDelete.setOnClickListener { + try { + secureKeyValueStorageRepository.delete(SMOKE_KEY) + toast("DELETE OK: key=$SMOKE_KEY") + } catch (e: Throwable) { + toast("DELETE failed: ${e.message}") + } + } + + binding.debugButtonSecureKvWipe.setOnClickListener { + AlertDialog.Builder(this) + .setTitle("Wipe secure storage?") + .setMessage("Deletes ALL keys in the native secure storage. Cannot be undone.") + .setPositiveButton("Wipe") { _, _ -> + try { + secureKeyValueStorageRepository.wipe() + toast("WIPE OK") + } catch (e: Throwable) { + toast("WIPE failed: ${e.message}") + } + } + .setNegativeButton("Cancel", null) + .show() + } + + binding.debugButtonExportAuditTrail.setOnClickListener { + val trail = secureStorageProvider.debugSnapshot().auditTrail + val text = trail.joinToString("\n").ifBlank { "(empty audit trail)" } + navigator.shareText(this, text, "Audit trail") + } + } + + private fun toast(msg: String) { + Toast.makeText(this, msg, Toast.LENGTH_SHORT).show() + } +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/export_keys/EmergencyKitPresenter.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/export_keys/EmergencyKitPresenter.kt index d6c4aeda..733da8ea 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/export_keys/EmergencyKitPresenter.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/export_keys/EmergencyKitPresenter.kt @@ -87,10 +87,6 @@ class EmergencyKitPresenter @Inject constructor( driveUploader.open(context, uploadedFile!!) } - override fun saveEmergencyKitAgain() { - goToStep(EmergencyKitStep.SAVE) - } - override fun showEmergencyKitVerifyHelp() { goToStep(EmergencyKitStep.VERIFY_HELP) } diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/ek_verify_help/EmergencyKitVerifyHelpParentPresenter.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/ek_verify_help/EmergencyKitVerifyHelpParentPresenter.kt index f9b95486..d444a086 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/ek_verify_help/EmergencyKitVerifyHelpParentPresenter.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/ek_verify_help/EmergencyKitVerifyHelpParentPresenter.kt @@ -4,8 +4,6 @@ import io.muun.apollo.presentation.ui.base.ParentPresenter interface EmergencyKitVerifyHelpParentPresenter: ParentPresenter { - fun saveEmergencyKitAgain() - fun cancelEmergencyKitVerifyHelp() } \ No newline at end of file diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/operations/OperationsFragment.java b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/operations/OperationsFragment.java index 178ea94e..99e017d7 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/operations/OperationsFragment.java +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/operations/OperationsFragment.java @@ -92,7 +92,12 @@ private void updateListItems(List newItems) { private void onItemClicked(ItemViewModel viewModel) { if (viewModel instanceof OperationViewModel) { - presenter.onOperationClicked(((OperationViewModel) viewModel).operation.getId()); + // TODO we should use operation.Hid() here. Involves killing/replacing deprecated + // OperationActions#fetchOperationById() + final Long operationId = ((OperationViewModel) viewModel).operation.getId(); + if (operationId != null) { + presenter.onOperationClicked(operationId); + } } } } diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/operations/OperationsPresenter.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/operations/OperationsPresenter.kt index 9807430f..f00655fb 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/operations/OperationsPresenter.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/operations/OperationsPresenter.kt @@ -34,7 +34,7 @@ class OperationsPresenter @Inject constructor( subscribeTo(observable) } - fun onOperationClicked(operationId: Long?) { + fun onOperationClicked(operationId: Long) { navigator.navigateToOperationDetail(context, operationId) } diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/settings/SettingsFragment.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/settings/SettingsFragment.kt index 4cbd9bda..620a740f 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/settings/SettingsFragment.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/settings/SettingsFragment.kt @@ -97,6 +97,9 @@ open class SettingsFragment : SingleFragment(), SettingsView @BindView(R.id.settings_disable_feature_flags) lateinit var featureFlagsSettingsItem: MuunSettingItem + @BindView(R.id.settings_send_debug_data) + lateinit var sendDebugDataItem: MuunSettingItem + @BindView(R.id.recovery_section) lateinit var recoverySection: View @@ -188,14 +191,19 @@ open class SettingsFragment : SingleFragment(), SettingsView // Helper code for internal builds if (Globals.INSTANCE.isDogfood || Globals.INSTANCE.isDebug) { + featureFlagsSection.visibility = View.VISIBLE if (state.overridableFeatures.isNotEmpty()) { - featureFlagsSection.visibility = View.VISIBLE featureFlagsSettingsItem.visibility = View.VISIBLE featureFlagsSettingsItem.setOnClickListener { presenter.navigateToDisableFeatureFlags() } + } else { + featureFlagsSettingsItem.visibility = View.GONE } + + sendDebugDataItem.visibility = View.VISIBLE + sendDebugDataItem.setOnClickListener { presenter.sendDebugData() } } } diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/settings/SettingsPresenter.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/settings/SettingsPresenter.kt index d18ee192..71ed85ce 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/settings/SettingsPresenter.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/settings/SettingsPresenter.kt @@ -1,11 +1,13 @@ package io.muun.apollo.presentation.ui.fragments.settings +import android.content.Intent import android.net.Uri import android.os.Bundle -import io.muun.apollo.data.external.NotificationService +import io.muun.apollo.R import io.muun.apollo.domain.NightModeManager import io.muun.apollo.domain.action.UserActions import io.muun.apollo.domain.action.base.ActionState +import io.muun.apollo.domain.action.debug.BuildDebugDataEmailReportAction import io.muun.apollo.domain.action.session.LogoutAction import io.muun.apollo.domain.action.user.DeleteWalletAction import io.muun.apollo.domain.action.user.UpdateProfilePictureAction @@ -18,13 +20,16 @@ import io.muun.apollo.domain.model.BitcoinUnit import io.muun.apollo.domain.model.ExchangeRateWindow import io.muun.apollo.domain.model.MuunFeature import io.muun.apollo.domain.model.UserActivatedFeatureStatus +import io.muun.apollo.domain.model.report.EmailReport import io.muun.apollo.domain.model.user.User import io.muun.apollo.domain.model.user.UserProfile import io.muun.apollo.domain.selector.BitcoinUnitSelector import io.muun.apollo.domain.selector.ExchangeRateSelector import io.muun.apollo.domain.selector.FeatureSelector import io.muun.apollo.domain.selector.UserActivatedFeatureStatusSelector +import io.muun.apollo.presentation.app.Email import io.muun.apollo.presentation.biometrics.BiometricsController +import io.muun.apollo.presentation.ui.activity.extension.MuunDialog import io.muun.apollo.presentation.ui.base.ParentPresenter import io.muun.apollo.presentation.ui.base.SingleFragmentPresenter import io.muun.apollo.presentation.ui.base.di.PerFragment @@ -48,9 +53,9 @@ class SettingsPresenter @Inject constructor( private val exchangeRateSelector: ExchangeRateSelector, private val userActivatedFeatureStatusSel: UserActivatedFeatureStatusSelector, private val nightModeManager: NightModeManager, - private val notificationService: NotificationService, private val featureSelector: FeatureSelector, private val biometricsController: BiometricsController, + private val buildDebugDataEmailReportAction: BuildDebugDataEmailReportAction, ) : SingleFragmentPresenter() { class SettingsState( @@ -68,6 +73,7 @@ class SettingsPresenter @Inject constructor( setUpUpdateProfilePictureAction() setUpUpdatePrimaryCurrencyAction() setUpDeleteWalletAction() + setUpBuildDebugDataAction() setUpNightMode() } @@ -136,6 +142,15 @@ class SettingsPresenter @Inject constructor( subscribeTo(observable) } + private fun setUpBuildDebugDataAction() { + val observable = buildDebugDataEmailReportAction.state + .compose(handleStates(view::setLoading, this::handleError)) + .doOnNext { emailReport -> + onDebugDataReady(emailReport) + } + subscribeTo(observable) + } + private fun setUpNightMode() { view.setNightMode(nightModeManager.get()) } @@ -269,6 +284,39 @@ class SettingsPresenter @Inject constructor( navigator.navigateToDebugPanel(context) } + fun sendDebugData() { + buildDebugDataEmailReportAction.run() + } + + private fun onDebugDataReady(emailReport: EmailReport) { + val emailIntent = Email.buildDebugDataEmailIntent(context, emailReport) + + // Grant URI permissions to all email apps + val resInfoList = context.packageManager.queryIntentActivities(emailIntent, 0) + for (resolveInfo in resInfoList) { + for (uri in emailReport.attachmentUris) { + try { + context.grantUriPermission( + resolveInfo.activityInfo.packageName, + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION, + ) + } catch (t: Throwable) { + Timber.i("Error granting URI permission: %s", t.message) + } + } + } + + if (Email.hasEmailAppInstalled(context)) { + context.startActivity(emailIntent) + } else { + val dialog = MuunDialog.Builder() + .message(R.string.error_copy_report_dialog_title) + .build() + view.showDialog(dialog) + } + } + private fun handleWalletDeleteError(error: Throwable?) { analytics.report(AnalyticsEvent.E_WALLET_DELETE(WalletDeleteState.ERROR)) when (error) { diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/home/HomeActivity.java b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/home/HomeActivity.java index 9b713308..9b498821 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/home/HomeActivity.java +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/home/HomeActivity.java @@ -1,6 +1,7 @@ package io.muun.apollo.presentation.ui.home; import io.muun.apollo.R; +import io.muun.apollo.data.external.Globals; import io.muun.apollo.databinding.HomeActivityBinding; import io.muun.apollo.domain.analytics.AnalyticsEvent.SECURITY_CENTER_ORIGIN; import io.muun.apollo.domain.model.Operation; @@ -12,7 +13,7 @@ import io.muun.apollo.presentation.ui.view.MuunButton; import io.muun.apollo.presentation.ui.view.MuunHeader; -import android.annotation.SuppressLint; +import android.app.Activity; import android.content.Context; import android.content.Intent; import android.os.Bundle; @@ -22,14 +23,20 @@ import android.view.Menu; import android.view.MenuItem; import android.widget.TextView; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.IntentSenderRequest; +import androidx.activity.result.contract.ActivityResultContracts; import androidx.navigation.NavController; +import androidx.navigation.NavOptions; import androidx.navigation.fragment.NavHostFragment; import androidx.navigation.ui.NavigationUI; import androidx.viewbinding.ViewBinding; import com.google.android.material.bottomnavigation.BottomNavigationView; import kotlin.jvm.functions.Function1; +import timber.log.Timber; import java.util.Objects; +import javax.inject.Inject; import javax.validation.constraints.NotNull; public class HomeActivity extends SingleFragmentActivity @@ -62,6 +69,20 @@ public static Intent getStartActivityIntent(@NotNull Context context) { private NavController navController; + @Inject + InAppUpdateManager.Factory inAppUpdateManagerFactory; + + private final ActivityResultLauncher updateLauncher = + registerForActivityResult( + new ActivityResultContracts.StartIntentSenderForResult(), + result -> { + final int resultCode = result.getResultCode(); + if (resultCode != Activity.RESULT_OK) { + Timber.i("InAppUpdate: update flow failed (code=%s)", resultCode); + } + } + ); + private HomeActivityBinding binding() { return (HomeActivityBinding) getBinding(); } @@ -109,25 +130,23 @@ protected void initializeUi() { navController.setGraph(R.navigation.home_nav_graph, initialBundle); NavigationUI.setupWithNavController(bottomNav, navController); - bottomNav.setOnItemSelectedListener(item -> { - - final Bundle bundle = new Bundle(); - - if (item.getItemId() == R.id.security_center_fragment) { - final SecurityCenterFragmentArgs args = new SecurityCenterFragmentArgs - .Builder(SECURITY_CENTER_ORIGIN.SHIELD_BUTTON) - .build(); - - bundle.putAll(args.toBundle()); - } + // Override the listener installed by setupWithNavController so we can disable the + // default fragment animations on tab swaps. + final NavOptions navOptions = new NavOptions.Builder() + .setLaunchSingleTop(true) + .setRestoreState(true) + .setPopUpTo(R.id.home_fragment, false, true) + .setEnterAnim(0) + .build(); - navigateToItem(item.getItemId(), bundle); - return true; - } - ); - bottomNav.setOnItemReselectedListener(item -> { - // do nothing here, it will prevent recreating same fragment + bottomNav.setOnItemSelectedListener(item -> { + navController.navigate(item.getItemId(), null, navOptions); + return true; }); + + if (Globals.INSTANCE.isDogfood()) { + inAppUpdateManagerFactory.create(this, updateLauncher).checkForUpdate(); + } } @Override @@ -144,20 +163,15 @@ public boolean onCreateOptionsMenu(Menu menu) { return showMenu; } - @SuppressLint("MissingSuperCall") - @Override - public void onBackPressed() { - superOnBackPressed(); - } - @Override public void navigateToSecurityCenter() { final SecurityCenterFragmentArgs args = new SecurityCenterFragmentArgs - .Builder(SECURITY_CENTER_ORIGIN.EMPTY_HOME_ANON_USER) + .Builder() + .setOrigin(SECURITY_CENTER_ORIGIN.EMPTY_HOME_ANON_USER) .build(); - navigateToItem(R.id.security_center_fragment, args.toBundle()); + navController.navigate(R.id.security_center_fragment, args.toBundle()); } @Override @@ -199,9 +213,4 @@ public void showTaprootCelebration() { .build() .show(this); } - - private void navigateToItem(int itemId, Bundle bundle) { - // TODO: define transition animation See NavigationUI.onNavDestinationSelected() - navController.navigate(itemId, bundle, null); - } } diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/home/InAppUpdateManager.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/home/InAppUpdateManager.kt new file mode 100644 index 00000000..426ab0f3 --- /dev/null +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/home/InAppUpdateManager.kt @@ -0,0 +1,20 @@ +package io.muun.apollo.presentation.ui.home + +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.IntentSenderRequest +import androidx.appcompat.app.AppCompatActivity + +interface InAppUpdateManager { + + fun interface Factory { + fun create( + activity: AppCompatActivity, + launcher: ActivityResultLauncher, + ): InAppUpdateManager + } + + /** + * Check for available updates and start the flexible update flow if one is found. + */ + fun checkForUpdate() +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/home/NoOpInAppUpdateManager.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/home/NoOpInAppUpdateManager.kt new file mode 100644 index 00000000..9ac063f1 --- /dev/null +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/home/NoOpInAppUpdateManager.kt @@ -0,0 +1,8 @@ +package io.muun.apollo.presentation.ui.home + +class NoOpInAppUpdateManager : InAppUpdateManager { + + override fun checkForUpdate() { + // No-op + } +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/new_operation/InvalidOperationUriException.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/new_operation/InvalidOperationUriException.kt deleted file mode 100644 index 2304d3c8..00000000 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/new_operation/InvalidOperationUriException.kt +++ /dev/null @@ -1,5 +0,0 @@ -package io.muun.apollo.presentation.ui.new_operation - -import io.muun.apollo.domain.errors.MuunError - -class InvalidOperationUriException(message: String) : MuunError(message) diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/new_operation/NewOperationActivity.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/new_operation/NewOperationActivity.kt index 454c436b..85455af4 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/new_operation/NewOperationActivity.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/new_operation/NewOperationActivity.kt @@ -26,6 +26,7 @@ import io.muun.apollo.domain.analytics.AnalyticsEvent.S_NEW_OP_AMOUNT import io.muun.apollo.domain.analytics.AnalyticsEvent.S_NEW_OP_CONFIRMATION import io.muun.apollo.domain.analytics.AnalyticsEvent.S_NEW_OP_DESCRIPTION import io.muun.apollo.domain.analytics.NewOperationOrigin +import io.muun.apollo.domain.errors.newop.InvalidOperationUriException import io.muun.apollo.domain.libwallet.adapt import io.muun.apollo.domain.libwallet.destinationPubKey import io.muun.apollo.domain.libwallet.remainingMillis diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/new_operation/NewOperationPresenter.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/new_operation/NewOperationPresenter.kt index 36d174ca..77d83bb4 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/new_operation/NewOperationPresenter.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/new_operation/NewOperationPresenter.kt @@ -529,7 +529,23 @@ class NewOperationPresenter @Inject constructor( } if (::confirmStateViewModel.isInitialized) { - params += opSubmittedMetadata(confirmStateViewModel) + params += opStartedMetadata(confirmStateViewModel.paymentIntent.getPaymentType()) + + val objects = ArrayList>() + val payCtx = confirmStateViewModel.paymentContext + val selectedFeeRate = Preconditions.checkNotNull( + confirmStateViewModel.amountInfo.feeRateInSatsPerVByte + ) + val type: AnalyticsEvent.E_FEE_OPTION_TYPE = getFeeOptionTypeParam( + selectedFeeRate, + payCtx + ) + + val amount = BitcoinAmount.fromLibwallet(confirmStateViewModel.amountInfo.amount) + objects.add(("fee_type" to type.name.lowercase(Locale.getDefault()))) + objects.add(("sats_per_virtual_byte" to selectedFeeRate)) + objects.add(("amount" to SerializationUtils.serializeBitcoinAmount(amount))) + params += objects } analytics.report( @@ -811,13 +827,9 @@ class NewOperationPresenter @Inject constructor( val amount = BitcoinAmount.fromLibwallet(stateVm.amountInfo.amount) val fee = BitcoinAmount.fromLibwallet(stateVm.validated.fee) - val total = BitcoinAmount.fromLibwallet(stateVm.validated.total) val onchainFee = BitcoinAmount.fromLibwallet(stateVm.onchainFee) - val feeNeedsChange = stateVm.validated.feeNeedsChange - val isOneConf = stateVm.validated.swapInfo?.isOneConf val routingFeeInSat = stateVm.validated.swapInfo?.swapFees?.routingFeeInSat val confirmationsNeeded = stateVm.validated.swapInfo?.swapFees?.confirmationsNeeded - val debtType = stateVm.validated.swapInfo?.swapFees?.debtType val debtAmountInSat = stateVm.validated.swapInfo?.swapFees?.debtAmountInSat val outputAmountInSat = stateVm.validated.swapInfo?.swapFees?.outputAmountInSat val outputPaddingInSat = stateVm.validated.swapInfo?.swapFees?.outputPaddingInSat @@ -831,22 +843,27 @@ class NewOperationPresenter @Inject constructor( objects.add(("sats_per_virtual_byte" to selectedFeeRate)) objects.add(("amount" to SerializationUtils.serializeBitcoinAmount(amount))) objects.add(("fee" to SerializationUtils.serializeBitcoinAmount(fee))) - objects.add(("total" to SerializationUtils.serializeBitcoinAmount(total))) objects.add(("onchainFee" to SerializationUtils.serializeBitcoinAmount(onchainFee))) - objects.add(("feeNeedsChange" to feeNeedsChange)) - objects.add(("isOneConf" to isOneConf.toString())) - objects.add(("routingFeeInSat" to routingFeeInSat.toString())) - objects.add(("confirmationsNeeded" to confirmationsNeeded.toString())) - objects.add(("debtType" to debtType.toString())) - objects.add(("debtAmountInSat" to debtAmountInSat.toString())) - objects.add(("outputAmountInSat" to outputAmountInSat.toString())) - objects.add(("outputPaddingInSat" to outputPaddingInSat.toString())) - objects.add("fee_bump_set_uuid" to feeBumpSetUUID.toString()) - objects.add("fee_bump_amount_in_sat" to feeBumpAmountInSat.toString()) - objects.add("fee_bump_policy" to feeBumpPolicy.toString()) - objects.add( - "fee_bump_seconds_since_last_update" to feeBumpSecondsSinceLastUpdate.toString() - ) + + // Avoid sending "null" values since analytics has a maximum number of allowed event_params + // after which, other params are dropped. We're sending A LOT here, so we should scrutinize + // these and check if they add value or not. + if (stateVm.validated.swapInfo != null) { + objects.add(("routingFeeInSat" to routingFeeInSat.toString())) + objects.add(("confirmationsNeeded" to confirmationsNeeded.toString())) + objects.add(("debtAmountInSat" to debtAmountInSat.toString())) + objects.add(("outputAmountInSat" to outputAmountInSat.toString())) + objects.add(("outputPaddingInSat" to outputPaddingInSat.toString())) + } + + if (stateVm.validated.feeBumpInfo != null) { + objects.add("fee_bump_set_uuid" to feeBumpSetUUID.toString()) + objects.add("fee_bump_amount_in_sat" to feeBumpAmountInSat.toString()) + objects.add("fee_bump_policy" to feeBumpPolicy.toString()) + objects.add( + "fee_bump_seconds_since_last_update" to feeBumpSecondsSinceLastUpdate.toString() + ) + } // Also add previously known metadata objects.addAll(opStartedMetadata(paymentType)) diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/nfc/NfcReaderViewModel.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/nfc/NfcReaderViewModel.kt index b71762ef..aab23ce8 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/nfc/NfcReaderViewModel.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/nfc/NfcReaderViewModel.kt @@ -24,6 +24,7 @@ import io.muun.apollo.domain.analytics.AnalyticsEvent.ERROR_TYPE import io.muun.apollo.domain.analytics.AnalyticsEvent.E_ERROR import io.muun.apollo.domain.analytics.AnalyticsEvent.E_NEW_OP_ACTION_TYPE import io.muun.apollo.domain.analytics.AnalyticsEvent.SECURITY_CARD_TAP_PARAM +import io.muun.apollo.domain.errors.nfc.NoFeasibleZoneForModelError import io.muun.apollo.domain.libwallet.LibwalletClient import io.muun.apollo.domain.libwallet.errors.ErrorDetailCode import io.muun.apollo.domain.libwallet.errors.LibwalletGrpcError @@ -32,7 +33,6 @@ import io.muun.apollo.domain.model.MuunFeature import io.muun.apollo.domain.selector.FeatureSelector import io.muun.apollo.presentation.ui.nfc.events.GestureEvent import io.muun.apollo.presentation.ui.nfc.events.ISensorEvent -import io.muun.common.utils.Encodings import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow @@ -146,18 +146,9 @@ class NfcReaderViewModel @Inject constructor( withContext(Dispatchers.IO) { nfcSession.connect() val nfcBridger = nfcBridgerFactory.forSession(nfcSession) - val challengeMessage = "testing NFC in Android" - if (featureSelector.get(MuunFeature.NFC_CARD)) { - val signedMessageHex = libwalletClient.securityCardSignMessage( - nfcBridger, - challengeMessage - ) - Timber.d("NfcReaderViewModel: ${Encodings.bytesToHex(signedMessageHex)}") - - } else if (featureSelector.get(MuunFeature.NFC_CARD_V2)) { + if (featureSelector.get(MuunFeature.NFC_CARD_V2)) { libwalletClient.securityCardV2SignMessage(nfcBridger) - } else { // this shouldn't happen Timber.e("No security card enabled in nfc reader activity") @@ -267,7 +258,11 @@ class NfcReaderViewModel @Inject constructor( _viewState.tryEmit(ViewState.Scanning(feasibleZone)) }, { error -> - _viewCommand.tryEmit(ViewCommand.Error(error.message ?: "")) + if (error is NoFeasibleZoneForModelError) { + _viewState.tryEmit(ViewState.Scanning(null)) + } else { + _viewCommand.tryEmit(ViewCommand.Error(error.message ?: "")) + } } ) } diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/operation_detail/OperationDetailActivity.java b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/operation_detail/OperationDetailActivity.java index 3c27af95..1279bffd 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/operation_detail/OperationDetailActivity.java +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/operation_detail/OperationDetailActivity.java @@ -31,8 +31,10 @@ public class OperationDetailActivity extends BaseActivity onCopyPaymentHashToClipboard(paymentHash)); final String preimage = operation.getPreimage(); - swapPreimageItem.setDescription(preimage); - swapPreimageItem.setVisibility(!TextUtils.isEmpty(preimage) ? View.VISIBLE : View.GONE); - swapPreimageItem.setOnIconClickListener(view -> onCopyPreimageToClipboard(preimage)); + if (operation.isCompleted() && !TextUtils.isEmpty(preimage)) { + swapPreimageItem.setDescription(preimage); + swapPreimageItem.setVisibility(View.VISIBLE); + swapPreimageItem.setOnIconClickListener(view -> onCopyPreimageToClipboard(preimage)); + + } else { + swapPreimageItem.setVisibility(View.GONE); + } if (!ExtensionsKt.isEmpty(operation.getInvoiceDescription())) { descriptionItem.setVisibility(View.VISIBLE); @@ -356,8 +363,8 @@ private void onCopyPreimageToClipboard(final String preimage) { } private void onCopyPaymentHashToClipboard(final String paymentHash) { - presenter.copySwapPreimageToClipboard(paymentHash); - showTextToast(getString(R.string.operation_detail_preimage_copied)); + presenter.copyPaymentHashToClipboard(paymentHash); + showTextToast(getString(R.string.operation_detail_payment_hash_copied)); } private void onCopyTransactionIdToClipboard(final String transactionId) { diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/operation_detail/OperationDetailPresenter.java b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/operation_detail/OperationDetailPresenter.java index 6d516410..46c8849b 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/operation_detail/OperationDetailPresenter.java +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/operation_detail/OperationDetailPresenter.java @@ -15,8 +15,10 @@ import io.muun.common.Optional; import android.os.Bundle; +import android.text.TextUtils; import icepick.State; import rx.Observable; +import timber.log.Timber; import javax.inject.Inject; import javax.validation.constraints.NotNull; @@ -63,12 +65,30 @@ public void setUp(@NotNull Bundle arguments) { final Optional maybeOperationId = takeLongArgument(arguments, OPERATION_ID_KEY); - final boolean precondition = checkArgument(maybeOperationId.isPresent(), "operationId"); + if (maybeOperationId.isPresent()) { + operationId = maybeOperationId.get(); + } else if (operationId > 0) { + // OPERATION_ID missing from arguments but @State restored it (e.g. process death). + // Log to understand how this happens — we couldn't reproduce it. + Timber.i( + "operationId recovered from saved state. Bundle keys: %s, operationId: %d", + TextUtils.join(", ", arguments.keySet()), + operationId + ); + } else { + // Neither arguments nor @State had a valid operationId. Log for diagnostics. + Timber.i( + "operationId missing. Bundle keys: %s, @State operationId: %d", + TextUtils.join(", ", arguments.keySet()), + operationId + ); + } + + // operationId > 0 covers both: read from arguments, or restored from @State + final boolean precondition = checkArgument(operationId > 0, "operationId"); if (precondition) { bitcoinUnit = bitcoinUnitSel.get(); - - operationId = maybeOperationId.get(); bindOperation(); } } @@ -104,6 +124,13 @@ public void copySwapPreimageToClipboard(String preimage) { clipboardManager.copy("Swap preimage", preimage); } + /** + * Copy payment hash to the clipboard. + */ + public void copyPaymentHashToClipboard(String paymentHash) { + clipboardManager.copy("Payment Hash", paymentHash); + } + /** * Copy transaction id/hash to the clipboard. */ diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards/utils/WebsitePill.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards/utils/WebsitePill.kt new file mode 100644 index 00000000..6eaaa593 --- /dev/null +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards/utils/WebsitePill.kt @@ -0,0 +1,64 @@ +package io.muun.apollo.presentation.ui.security_cards.utils + +import android.view.Gravity +import android.view.View +import androidx.appcompat.widget.Toolbar +import androidx.core.content.ContextCompat +import com.google.android.material.chip.Chip +import io.muun.apollo.R +import io.muun.apollo.presentation.ui.view.MuunHeader + +fun MuunHeader.addWebsitePill( + siteUrl: String, + id: Int = View.generateViewId(), + onClick: View.OnClickListener? = null, +) { + val chip = Chip(context).apply { + this.id = id + text = siteUrl + setChipIconResource(R.drawable.ic_language) + chipIconTint = ContextCompat.getColorStateList( + context, R.color.provider_pill_text + ) + isChipIconVisible = true + chipIconSize = resources.getDimension( + R.dimen.security_cards_checkout_website_pill_icon_size + ) + chipMinHeight = resources.getDimension( + R.dimen.security_cards_checkout_website_pill_min_height + ) + chipStrokeColor = ContextCompat.getColorStateList( + context, R.color.provider_pill_border + ) + chipStrokeWidth = resources.getDimension( + R.dimen.security_cards_checkout_website_pill_stroke_width + ) + chipBackgroundColor = ContextCompat.getColorStateList( + context, android.R.color.transparent + ) + setTextColor( + ContextCompat.getColor(context, R.color.provider_pill_text) + ) + chipStartPadding = resources.getDimension( + R.dimen.security_cards_checkout_website_pill_padding_start + ) + chipEndPadding = resources.getDimension( + R.dimen.security_cards_checkout_website_pill_padding_end + ) + textSize = 16f + isClickable = onClick != null + isFocusable = onClick != null + onClick?.let(::setOnClickListener) + } + + val layoutParams = Toolbar.LayoutParams( + Toolbar.LayoutParams.WRAP_CONTENT, + Toolbar.LayoutParams.WRAP_CONTENT, + ).apply { + gravity = Gravity.END or Gravity.CENTER_VERTICAL + marginEnd = context.resources.getDimensionPixelSize( + R.dimen.security_cards_checkout_website_pill_margin_end + ) + } + toolbar.addView(chip, layoutParams) +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_card_detail/CardDetailActivity.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_card_detail/CardDetailActivity.kt index 0c0d1751..aab91fdc 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_card_detail/CardDetailActivity.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_card_detail/CardDetailActivity.kt @@ -16,7 +16,9 @@ import io.muun.apollo.domain.model.BitcoinUnit import io.muun.apollo.domain.selector.ExchangeRateSelector import io.muun.apollo.domain.selector.UserSelector import io.muun.apollo.presentation.app.ApolloApplication +import io.muun.apollo.presentation.app.Navigator import io.muun.apollo.presentation.ui.new_operation.toRichText +import io.muun.apollo.presentation.ui.security_cards_country_picker.models.CountryInfo import io.muun.apollo.presentation.ui.security_cards_marketplace.CurrencySelectionSharedViewModel import io.muun.apollo.presentation.ui.security_cards_marketplace.CurrencySelectionSharedViewModelFactory import io.muun.apollo.presentation.ui.security_cards_marketplace.inBtc @@ -34,16 +36,19 @@ import javax.inject.Inject class CardDetailActivity : AppCompatActivity() { companion object { + private const val EXTRA_COUNTRY_INFO = "countryInfo" private const val EXTRA_PROVIDER = "provider" private const val EXTRA_CARD = "card" private const val EXTRA_FOOTER = "footer" fun getIntent( context: Context, + countryInfo: CountryInfo, provider: SecurityCardProvider, card: SecurityCard, footer: MarketplaceFooter, ) = Intent(context, CardDetailActivity::class.java).apply { + putExtra(EXTRA_COUNTRY_INFO, countryInfo) putExtra(EXTRA_PROVIDER, provider) putExtra(EXTRA_CARD, card) putExtra(EXTRA_FOOTER, footer) @@ -56,6 +61,9 @@ class CardDetailActivity : AppCompatActivity() { @Inject lateinit var viewModelFactory: CardDetailViewModel.Factory + + @Inject + lateinit var navigator: Navigator private val viewModel: CardDetailViewModel by viewModels { CardDetailViewModelFactory( @@ -130,7 +138,7 @@ class CardDetailActivity : AppCompatActivity() { binding.viewFooter.setOnClickListener { currencySelectionSharedViewModel.rotateCurrencySelection() } - binding.buttonContinue.setOnClickListener { navigateToProviderWebsite() } + binding.buttonContinue.setOnClickListener { navigateToShippingAddress() } } private fun handleViewState(viewState: CardDetailViewModel.ViewState) { @@ -139,7 +147,7 @@ class CardDetailActivity : AppCompatActivity() { binding.imageViewCard.setImageResource(viewState.card.imageRes) binding.specsContainer.removeAllViews() - viewState.card.primarySpecs.forEach { spec -> + viewState.card.specs.getValue("primary").forEach { spec -> val specBinding = ItemCardSpecBinding.inflate(layoutInflater, binding.specsContainer, false) specBinding.imageViewIcon.setImageResource(spec.iconRes) specBinding.textViewLabel.text = spec.label @@ -189,10 +197,14 @@ class CardDetailActivity : AppCompatActivity() { } private fun navigateToFullSpecs() { - // TODO: Navigate to full specs activity + navigator.navigateToSecurityCardsFullSpecs(this, requireNotNull(intent.getParcelableExtra(EXTRA_CARD))) } - private fun navigateToProviderWebsite() { - // TODO: Navigate to provider website + private fun navigateToShippingAddress() { + navigator.navigateToShippingAddress( + this, + requireNotNull(intent.getParcelableExtra(EXTRA_COUNTRY_INFO)), + requireNotNull(intent.getParcelableExtra(EXTRA_PROVIDER)), + ) } } diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_full_specs/SecurityCardsFullSpecsActivity.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_full_specs/SecurityCardsFullSpecsActivity.kt new file mode 100644 index 00000000..e2f75a14 --- /dev/null +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_full_specs/SecurityCardsFullSpecsActivity.kt @@ -0,0 +1,93 @@ +package io.muun.apollo.presentation.ui.security_cards_full_specs + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.MenuItem +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import io.muun.apollo.databinding.ActivitySecurityCardsFullSpecsBinding +import io.muun.apollo.presentation.ui.security_cards_marketplace.models.AdditionalInfo +import io.muun.apollo.presentation.ui.security_cards_marketplace.models.SecurityCard +import io.muun.apollo.presentation.ui.security_cards_marketplace.models.SecurityCardProvider +import io.muun.apollo.presentation.ui.utils.getComponent +import io.muun.apollo.presentation.ui.utils.setWindowInsetsCompat +import io.muun.apollo.presentation.ui.view.MuunHeader +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import javax.inject.Inject + +class SecurityCardsFullSpecsActivity : AppCompatActivity() { + + companion object { + private const val EXTRA_CARD = "card" + + fun getIntent(context: Context, card: SecurityCard): Intent = + Intent(context, SecurityCardsFullSpecsActivity::class.java) + .putExtra(EXTRA_CARD, card) + } + + private val binding: ActivitySecurityCardsFullSpecsBinding by lazy { + ActivitySecurityCardsFullSpecsBinding.inflate(layoutInflater) + } + + @Inject + lateinit var viewModelFactory: SecurityCardsFullSpecsViewModel.Factory + + private val viewModel: SecurityCardsFullSpecsViewModel by viewModels { + SecurityCardsFullSpecsViewModelFactory( + card = requireNotNull(intent.getParcelableExtra(EXTRA_CARD)), + assistedFactory = viewModelFactory, + ) + } + + private val adapter: SecurityCardsFullSpecsAdapter by lazy { + SecurityCardsFullSpecsAdapter(onAdditionalInfoClick = ::showAdditionalInfoBottomSheet) + } + + override fun onCreate(savedInstanceState: Bundle?) { + setWindowInsetsCompat() + super.onCreate(savedInstanceState) + setContentView(binding.root) + getComponent().inject(this) + + setupHeader() + setupRecyclerView() + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.viewState.collectLatest(::handleViewState) + } + } + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + if (item.itemId == android.R.id.home) { + onBackPressedDispatcher.onBackPressed() + return true + } + return super.onOptionsItemSelected(item) + } + + private fun setupHeader() { + binding.header.attachToActivity(this) + binding.header.setNavigation(MuunHeader.Navigation.BACK) + } + + private fun setupRecyclerView() { + binding.recyclerView.adapter = adapter + binding.recyclerView.addItemDecoration(SpecSectionItemDecoration(this)) + } + + private fun handleViewState(viewState: SecurityCardsFullSpecsViewModel.ViewState) { + adapter.submitList(viewState.items) + } + + private fun showAdditionalInfoBottomSheet(info: AdditionalInfo) { + SpecAdditionalInfoBottomSheetDialogFragment.newInstance(info) + .show(supportFragmentManager, SpecAdditionalInfoBottomSheetDialogFragment::class.java.simpleName) + } +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_full_specs/SecurityCardsFullSpecsAdapter.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_full_specs/SecurityCardsFullSpecsAdapter.kt new file mode 100644 index 00000000..dba6f9bb --- /dev/null +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_full_specs/SecurityCardsFullSpecsAdapter.kt @@ -0,0 +1,234 @@ +package io.muun.apollo.presentation.ui.security_cards_full_specs + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Rect +import android.graphics.RectF +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import io.muun.apollo.R +import io.muun.apollo.databinding.ItemSecurityCardSpecCardImageBinding +import io.muun.apollo.databinding.ItemSecurityCardSpecHeaderBinding +import io.muun.apollo.databinding.ItemSecurityCardSpecRowBinding +import io.muun.apollo.presentation.ui.security_cards_marketplace.models.AdditionalInfo + +class SecurityCardsFullSpecsAdapter( + private val onAdditionalInfoClick: (AdditionalInfo) -> Unit, +) : ListAdapter(DiffCallback()) { + + companion object { + const val VIEW_TYPE_CARD_IMAGE = 0 + const val VIEW_TYPE_HEADER = 1 + const val VIEW_TYPE_ROW = 2 + } + + override fun getItemViewType(position: Int): Int = when (getItem(position)) { + is SpecListItem.CardImage -> VIEW_TYPE_CARD_IMAGE + is SpecListItem.SectionHeader -> VIEW_TYPE_HEADER + is SpecListItem.Row -> VIEW_TYPE_ROW + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + val inflater = LayoutInflater.from(parent.context) + return when (viewType) { + VIEW_TYPE_CARD_IMAGE -> CardImageViewHolder( + ItemSecurityCardSpecCardImageBinding.inflate(inflater, parent, false) + ) + VIEW_TYPE_HEADER -> HeaderViewHolder( + ItemSecurityCardSpecHeaderBinding.inflate(inflater, parent, false) + ) + VIEW_TYPE_ROW -> RowViewHolder( + ItemSecurityCardSpecRowBinding.inflate(inflater, parent, false) + ) + else -> throw IllegalArgumentException("Unknown view type: $viewType") + } + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + when (val item = getItem(position)) { + is SpecListItem.CardImage -> (holder as CardImageViewHolder).bind(item) + is SpecListItem.SectionHeader -> (holder as HeaderViewHolder).bind(item) + is SpecListItem.Row -> (holder as RowViewHolder).bind(item, onAdditionalInfoClick) + } + } + + class CardImageViewHolder( + private val binding: ItemSecurityCardSpecCardImageBinding, + ) : RecyclerView.ViewHolder(binding.root) { + + fun bind(item: SpecListItem.CardImage) { + binding.imageViewCard.setImageResource(item.imageRes) + } + } + + class HeaderViewHolder( + private val binding: ItemSecurityCardSpecHeaderBinding, + ) : RecyclerView.ViewHolder(binding.root) { + + fun bind(item: SpecListItem.SectionHeader) { + binding.textViewSectionTitle.text = item.title + } + } + + class RowViewHolder( + private val binding: ItemSecurityCardSpecRowBinding, + ) : RecyclerView.ViewHolder(binding.root) { + + fun bind(item: SpecListItem.Row, onInfoClick: (AdditionalInfo) -> Unit) { + binding.imageViewIcon.setImageResource(item.iconRes) + binding.textViewLabel.text = item.label + binding.textViewValue.text = item.value + + val info = item.additionalInfo + if (info != null) { + binding.textViewValue.setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, ContextCompat.getDrawable(itemView.context, R.drawable.ic_info_outline_24), null) + binding.textViewValue.setOnClickListener { onInfoClick(info) } + binding.textViewValue.isClickable = true + } else { + binding.textViewValue.setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, null, null) + binding.textViewValue.setOnClickListener(null) + binding.textViewValue.isClickable = false + } + } + } + + private class DiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: SpecListItem, newItem: SpecListItem) = oldItem == newItem + override fun areContentsTheSame(oldItem: SpecListItem, newItem: SpecListItem) = oldItem == newItem + } +} + +/** + * Draws a rounded card background (fill + border) behind each group of consecutive + * [SpecListItem.Row] items, and adds spacing offsets so the card doesn't overlap adjacent items. + */ +class SpecSectionItemDecoration(context: Context) : RecyclerView.ItemDecoration() { + + private val backgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.FILL + color = ContextCompat.getColor(context, R.color.sc_spec_table_background) + } + + private val borderPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeWidth = context.resources.displayMetrics.density + color = ContextCompat.getColor(context, R.color.sc_marketplace_full_specs_section_border) + } + + private val cornerRadius = context.resources.getDimension(R.dimen.sc_spec_card_corner_radius) + private val horizontalInset = context.resources.getDimension(R.dimen.sc_spec_card_horizontal_inset) + private val innerPadding = context.resources.getDimension(R.dimen.sc_spec_card_inner_padding) + private val topOffset = context.resources.getDimensionPixelOffset(R.dimen.sc_spec_card_top_offset) + + // Pre-allocated to avoid per-frame allocations during scroll + private val drawRect = RectF() + + // region getItemOffsets — spacing around the card + + override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) { + val pos = parent.getChildAdapterPosition(view) + if (pos == RecyclerView.NO_POSITION) return + val adapter = parent.adapter as? SecurityCardsFullSpecsAdapter ?: return + if (adapter.getItemViewType(pos) != SecurityCardsFullSpecsAdapter.VIEW_TYPE_ROW) return + + val isFirstInSection = pos == 0 + || adapter.getItemViewType(pos - 1) != SecurityCardsFullSpecsAdapter.VIEW_TYPE_ROW + + val isLastInSection = pos == adapter.itemCount - 1 + || adapter.getItemViewType(pos + 1) != SecurityCardsFullSpecsAdapter.VIEW_TYPE_ROW + + if (isFirstInSection) outRect.top = topOffset + if (isLastInSection) outRect.bottom = innerPadding.toInt() + } + + // endregion + + // region onDraw — card background behind row groups + + override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) { + val adapter = parent.adapter as? SecurityCardsFullSpecsAdapter ?: return + val list = adapter.currentList + if (list.isEmpty()) return + + val left = parent.paddingLeft + horizontalInset + val right = parent.width - parent.paddingRight - horizontalInset + + // Collect bounds of visible children keyed by adapter position. + // Uses parallel arrays to avoid Map/Pair allocations on every draw frame. + val visibleCount = parent.childCount + val visiblePositions = IntArray(visibleCount) + val visibleTops = FloatArray(visibleCount) + val visibleBottoms = FloatArray(visibleCount) + + for (ci in 0 until visibleCount) { + val child = parent.getChildAt(ci) + visiblePositions[ci] = parent.getChildAdapterPosition(child) + visibleTops[ci] = child.top.toFloat() + visibleBottoms[ci] = child.bottom.toFloat() + } + + // Walk the adapter data to find consecutive ROW groups, then draw the card for each. + var i = 0 + while (i < list.size) { + if (adapter.getItemViewType(i) != SecurityCardsFullSpecsAdapter.VIEW_TYPE_ROW) { + i++ + continue + } + + val groupStart = i + while (i < list.size && adapter.getItemViewType(i) == SecurityCardsFullSpecsAdapter.VIEW_TYPE_ROW) i++ + val groupEnd = i - 1 + + drawCardForGroup(c, groupStart, groupEnd, left, right, parent.height.toFloat(), + visiblePositions, visibleTops, visibleBottoms, visibleCount) + } + } + + private fun drawCardForGroup( + c: Canvas, + groupStart: Int, + groupEnd: Int, + left: Float, + right: Float, + parentHeight: Float, + visiblePositions: IntArray, + visibleTops: FloatArray, + visibleBottoms: FloatArray, + visibleCount: Int, + ) { + var top: Float? = null + var bottom: Float? = null + var firstVisible = false + var lastVisible = false + + for (ci in 0 until visibleCount) { + val pos = visiblePositions[ci] + if (pos < groupStart || pos > groupEnd) continue + + if (top == null) top = visibleTops[ci] + bottom = visibleBottoms[ci] + if (pos == groupStart) firstVisible = true + if (pos == groupEnd) lastVisible = true + } + + // Skip groups with no visible items + if (top == null || bottom == null) return + + // When visible, extend inward by innerPadding. When off-screen, push the rounded + // corners past the viewport edge so the Canvas clips them into a flat edge. + top = if (firstVisible) top - innerPadding else -cornerRadius + bottom = if (lastVisible) bottom + innerPadding else parentHeight + cornerRadius + + drawRect.set(left, top, right, bottom) + c.drawRoundRect(drawRect, cornerRadius, cornerRadius, backgroundPaint) + c.drawRoundRect(drawRect, cornerRadius, cornerRadius, borderPaint) + } + + // endregion +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_full_specs/SecurityCardsFullSpecsViewModel.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_full_specs/SecurityCardsFullSpecsViewModel.kt new file mode 100644 index 00000000..a6fb3b8b --- /dev/null +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_full_specs/SecurityCardsFullSpecsViewModel.kt @@ -0,0 +1,62 @@ +package io.muun.apollo.presentation.ui.security_cards_full_specs + +import androidx.annotation.DrawableRes +import androidx.lifecycle.ViewModel +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import io.muun.apollo.presentation.ui.security_cards_marketplace.models.AdditionalInfo +import io.muun.apollo.presentation.ui.security_cards_marketplace.models.SecurityCard +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +class SecurityCardsFullSpecsViewModel @AssistedInject constructor( + @Assisted val card: SecurityCard, +) : ViewModel() { + + @AssistedFactory + interface Factory { + fun create(card: SecurityCard): SecurityCardsFullSpecsViewModel + } + + data class ViewState( + val items: List, + ) + + private val _viewState = MutableStateFlow( + ViewState( + items = buildSpecList(card), + ) + ) + val viewState: StateFlow = _viewState +} + +private fun buildSpecList( + card: SecurityCard, +): List = buildList { + add(SpecListItem.CardImage(card.imageRes)) + card.specs.filter { it.key != "primary" }.forEach { (category, specs) -> + add(SpecListItem.SectionHeader(category.capitalize())) + specs.forEach { spec -> + add(SpecListItem.Row(spec.iconRes, spec.label, spec.value)) + } + } +} + +sealed interface SpecListItem { + + data class CardImage( + @DrawableRes val imageRes: Int, + ) : SpecListItem + + data class SectionHeader( + val title: String, + ) : SpecListItem + + data class Row( + @DrawableRes val iconRes: Int, + val label: String, + val value: String, + val additionalInfo: AdditionalInfo? = null, + ) : SpecListItem +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_full_specs/SecurityCardsFullSpecsViewModelFactory.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_full_specs/SecurityCardsFullSpecsViewModelFactory.kt new file mode 100644 index 00000000..3c5c52d3 --- /dev/null +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_full_specs/SecurityCardsFullSpecsViewModelFactory.kt @@ -0,0 +1,18 @@ +package io.muun.apollo.presentation.ui.security_cards_full_specs + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import io.muun.apollo.presentation.ui.security_cards_marketplace.models.SecurityCard + +class SecurityCardsFullSpecsViewModelFactory( + private val card: SecurityCard, + private val assistedFactory: SecurityCardsFullSpecsViewModel.Factory, +) : ViewModelProvider.Factory { + + override fun create(modelClass: Class): T { + @Suppress("UNCHECKED_CAST") + return assistedFactory.create( + card = card, + ) as T + } +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_full_specs/SpecAdditionalInfoBottomSheetDialogFragment.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_full_specs/SpecAdditionalInfoBottomSheetDialogFragment.kt new file mode 100644 index 00000000..2c92f7c9 --- /dev/null +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_full_specs/SpecAdditionalInfoBottomSheetDialogFragment.kt @@ -0,0 +1,36 @@ +package io.muun.apollo.presentation.ui.security_cards_full_specs + +import android.text.method.LinkMovementMethod +import android.view.LayoutInflater +import android.view.View +import androidx.core.text.HtmlCompat +import io.muun.apollo.databinding.BottomSheetSpecAdditionalInfoBinding +import io.muun.apollo.presentation.ui.security_cards_marketplace.models.AdditionalInfo +import io.muun.apollo.presentation.ui.view.MuunBottomSheetDialogFragment + +class SpecAdditionalInfoBottomSheetDialogFragment : MuunBottomSheetDialogFragment() { + + companion object { + private const val ARG_INFO = "info" + + fun newInstance(info: AdditionalInfo) = + SpecAdditionalInfoBottomSheetDialogFragment().apply { + arguments = android.os.Bundle().apply { + putParcelable(ARG_INFO, info) + } + } + } + + override fun createContentView(): View { + val binding = BottomSheetSpecAdditionalInfoBinding.inflate(LayoutInflater.from(requireContext())) + val info = requireNotNull(requireArguments().getParcelable(ARG_INFO)) + + binding.imageViewIcon.setImageResource(info.iconRes) + binding.textViewTitle.text = info.title + binding.textViewBody.text = + HtmlCompat.fromHtml(info.bodyHtml, HtmlCompat.FROM_HTML_MODE_COMPACT) + binding.textViewBody.movementMethod = LinkMovementMethod.getInstance() + + return binding.root + } +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_marketplace/SecurityCardsMarketplaceActivity.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_marketplace/SecurityCardsMarketplaceActivity.kt index 3aedf33b..e4f59957 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_marketplace/SecurityCardsMarketplaceActivity.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_marketplace/SecurityCardsMarketplaceActivity.kt @@ -185,6 +185,7 @@ class SecurityCardsMarketplaceActivity : AppCompatActivity(), SecurityCardProvid when (viewEvent) { is SecurityCardsMarketplaceViewModel.ViewEvent.NavigateToCardDetail -> navigator.navigateToCardDetail( this, + viewEvent.countryInfo, viewEvent.provider, viewEvent.securityCard, viewEvent.footer, diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_marketplace/SecurityCardsMarketplaceViewModel.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_marketplace/SecurityCardsMarketplaceViewModel.kt index d1b21751..01e4e219 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_marketplace/SecurityCardsMarketplaceViewModel.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_marketplace/SecurityCardsMarketplaceViewModel.kt @@ -1,5 +1,6 @@ package io.muun.apollo.presentation.ui.security_cards_marketplace +import androidx.annotation.DrawableRes import androidx.lifecycle.ViewModel import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -39,6 +40,7 @@ class SecurityCardsMarketplaceViewModel @AssistedInject constructor( sealed interface ViewEvent { data class NavigateToCardDetail( + val countryInfo: CountryInfo, val provider: SecurityCardProvider, val securityCard: SecurityCard, val footer: MarketplaceFooter, @@ -84,11 +86,16 @@ class SecurityCardsMarketplaceViewModel @AssistedInject constructor( securityCard: SecurityCard, footer: MarketplaceFooter, ) { - _viewEvent.tryEmit(ViewEvent.NavigateToCardDetail( - provider = provider, - securityCard = securityCard, - footer = footer, - )) + val viewState = _viewState.value as? ViewState.Data ?: error("State invalid") + + _viewEvent.tryEmit( + ViewEvent.NavigateToCardDetail( + countryInfo = viewState.country, + provider = provider, + securityCard = securityCard, + footer = footer, + ) + ) } } @@ -100,10 +107,10 @@ private fun mockCardsData( name = "Constellations", description = "Constellations", securityCards = listOf( - SecurityCard(imageRes = R.drawable.sc_constellations_scorpius, primarySpecs = mockCardSpecs()), - SecurityCard(imageRes = R.drawable.sc_constellations_gemini, primarySpecs = mockCardSpecs()), - SecurityCard(imageRes = R.drawable.sc_constellations_sagitarius, primarySpecs = mockCardSpecs()), - SecurityCard(imageRes = R.drawable.sc_constellations_virgo, primarySpecs = mockCardSpecs()), + mockConstellationsCard(imageRes = R.drawable.sc_constellations_scorpius), + mockConstellationsCard(imageRes = R.drawable.sc_constellations_gemini), + mockConstellationsCard(imageRes = R.drawable.sc_constellations_sagitarius), + mockConstellationsCard(imageRes = R.drawable.sc_constellations_virgo), ), currencyCode = "EUR", ), @@ -111,15 +118,15 @@ private fun mockCardsData( name = "Numbers", description = "Numbers", securityCards = listOf( - SecurityCard(imageRes = R.drawable.sc_numbers_1, primarySpecs = mockCardSpecs()), - SecurityCard(imageRes = R.drawable.sc_numbers_2, primarySpecs = mockCardSpecs()), - SecurityCard(imageRes = R.drawable.sc_numbers_3, primarySpecs = mockCardSpecs()), - SecurityCard(imageRes = R.drawable.sc_numbers_4, primarySpecs = mockCardSpecs()), - SecurityCard(imageRes = R.drawable.sc_numbers_5, primarySpecs = mockCardSpecs()), - SecurityCard(imageRes = R.drawable.sc_numbers_6, primarySpecs = mockCardSpecs()), - SecurityCard(imageRes = R.drawable.sc_numbers_7, primarySpecs = mockCardSpecs()), - SecurityCard(imageRes = R.drawable.sc_numbers_8, primarySpecs = mockCardSpecs()), - SecurityCard(imageRes = R.drawable.sc_numbers_9, primarySpecs = mockCardSpecs()), + mockNumbersCard(imageRes = R.drawable.sc_numbers_1), + mockNumbersCard(imageRes = R.drawable.sc_numbers_2), + mockNumbersCard(imageRes = R.drawable.sc_numbers_3), + mockNumbersCard(imageRes = R.drawable.sc_numbers_4), + mockNumbersCard(imageRes = R.drawable.sc_numbers_5), + mockNumbersCard(imageRes = R.drawable.sc_numbers_6), + mockNumbersCard(imageRes = R.drawable.sc_numbers_7), + mockNumbersCard(imageRes = R.drawable.sc_numbers_8), + mockNumbersCard(imageRes = R.drawable.sc_numbers_9), ), currencyCode = "USD", ), @@ -127,28 +134,144 @@ private fun mockCardsData( name = "Planets", description = "Planets", securityCards = listOf( - SecurityCard(imageRes = R.drawable.sc_planets_earth, primarySpecs = mockCardSpecs()), - SecurityCard(imageRes = R.drawable.sc_planets_mars, primarySpecs = mockCardSpecs()), + mockPlanetsCard(imageRes = R.drawable.sc_planets_earth), + mockPlanetsCard(imageRes = R.drawable.sc_planets_mars), ), currencyCode = "USD", ), ) )[country.code] ?: emptyList() -private fun mockCardSpecs() = listOf( - CardSpec( - iconRes = R.drawable.ic_clock, - label = "Delivers in", - value = "15-30 days", - ), - CardSpec( - iconRes = R.drawable.ic_circle_off_outline_24, - label = "Ships from", - value = "Belgium", - ), - CardSpec( - iconRes = R.drawable.ic_circle_off_outline_24, - label = "Material", - value = "Plastic", - ), +private fun mockConstellationsCard(@DrawableRes imageRes: Int) = SecurityCard( + imageRes = imageRes, + specs = mapOf( + "primary" to listOf( + CardSpec(R.drawable.ic_circle_off_outline_24, "Material", "Plastic"), + CardSpec(R.drawable.ic_circle_off_outline_24, "From", "Sky"), + CardSpec(R.drawable.ic_clock, "Arrives in", "Already there"), + ), + "specifications" to listOf( + CardSpec(iconRes = R.drawable.ic_style_24px, label = "Material", value = "Plastic"), + CardSpec(iconRes = R.drawable.ic_aspect_ratio_24px, label = "Thickness", value = "0.8mm"), + CardSpec(iconRes = R.drawable.ic_fitness_center_24px, label = "Weight", value = "5g"), + ), + "security" to listOf( + CardSpec( + iconRes = R.drawable.ic_code_24px, + label = "Secure Element", + value = "EAL 5+", + additionalData = "The secure element is a tamper-resistant chip that stores your private " + + "keys and performs cryptographic operations.

" + + "EAL 5+ is a high level of security certification.", + ), + CardSpec( + iconRes = R.drawable.ic_code_24px, label = "Firmware", value = "Designed by Muun", + additionalData = "The firmware is the core software that governs your card's behavior. " + + "Built and maintained by Muun.

" + + "It's open source, publicly available for review. Find it and more " + + "information on GitHub.", + ), + CardSpec(iconRes = R.drawable.ic_misc_trezor_24px, label = "Packaging", value = "Tamper resistant"), + ), + "delivery" to listOf( + CardSpec(iconRes = R.drawable.ic_local_shipping_24px, label = "Shipped by", value = "Sky"), + CardSpec(iconRes = R.drawable.ic_place_24px, label = "From", value = "Sky"), + CardSpec(iconRes = R.drawable.ic_access_time_24px, label = "Arrives in", value = "Already there"), + CardSpec( + iconRes = R.drawable.ic_folder_open_24px, label = "Shipping data", value = "Under GDPR", + additionalData = "Your shipping information is handled under GDPR regulations " + + "and is only used for delivery purposes.", + ), + ), + ) +) + +private fun mockNumbersCard(@DrawableRes imageRes: Int) = SecurityCard( + imageRes = imageRes, + specs = mapOf( + "primary" to listOf( + CardSpec(R.drawable.ic_style_24px, "Material", "Plastic"), + CardSpec(R.drawable.ic_local_shipping_24px, "From", "Math"), + CardSpec(R.drawable.ic_access_time_24px, "Arrives in", "Already here"), + ), + "specifications" to listOf( + CardSpec(iconRes = R.drawable.ic_style_24px, label = "Material", value = "Plastic"), + CardSpec(iconRes = R.drawable.ic_aspect_ratio_24px, label = "Thickness", value = "0.8mm"), + CardSpec(iconRes = R.drawable.ic_fitness_center_24px, label = "Weight", value = "5g"), + ), + "security" to listOf( + CardSpec( + iconRes = R.drawable.ic_code_24px, + label = "Secure Element", + value = "EAL 5+", + additionalData = "The secure element is a tamper-resistant chip that stores your private " + + "keys and performs cryptographic operations.

" + + "EAL 5+ is a high level of security certification.", + ), + CardSpec( + iconRes = R.drawable.ic_code_24px, label = "Firmware", value = "Designed by Muun", + additionalData = "The firmware is the core software that governs your card's behavior. " + + "Built and maintained by Muun.

" + + "It's open source, publicly available for review. Find it and more " + + "information on GitHub.", + ), + CardSpec(iconRes = R.drawable.ic_misc_trezor_24px, label = "Packaging", value = "Tamper resistant"), + ), + "delivery" to listOf( + CardSpec(iconRes = R.drawable.ic_local_shipping_24px, label = "Shipped by", value = "Constellations"), + CardSpec(iconRes = R.drawable.ic_place_24px, label = "From", value = "Plastic"), + CardSpec(iconRes = R.drawable.ic_access_time_24px, label = "Arrives in", value = "Plastic"), + CardSpec( + iconRes = R.drawable.ic_folder_open_24px, label = "Shipping data", + value = "Plastic", + additionalData = "Your shipping information is handled under GDPR regulations " + + "and is only used for delivery purposes.", + ), + ), + ) +) + +private fun mockPlanetsCard(@DrawableRes imageRes: Int) = SecurityCard( + imageRes = imageRes, + specs = mapOf( + "primary" to listOf( + CardSpec(R.drawable.ic_style_24px, "Material", "Metal"), + CardSpec(R.drawable.ic_local_shipping_24px, "From", "Space"), + CardSpec(R.drawable.ic_access_time_24px, "Arrives in", "Now"), + ), + "specifications" to listOf( + CardSpec(iconRes = R.drawable.ic_style_24px, label = "Material", value = "Plastic"), + CardSpec(iconRes = R.drawable.ic_aspect_ratio_24px, label = "Thickness", value = "0.8mm"), + CardSpec(iconRes = R.drawable.ic_fitness_center_24px, label = "Weight", value = "5g"), + ), + "security" to listOf( + CardSpec( + iconRes = R.drawable.ic_code_24px, + label = "Secure Element", + value = "EAL 6+", + additionalData = "The secure element is a tamper-resistant chip that stores your private " + + "keys and performs cryptographic operations.

" + + "EAL 6+ is a high level of security certification.", + ), + CardSpec( + iconRes = R.drawable.ic_code_24px, label = "Firmware", value = "Designed by Muun", + additionalData = "The firmware is the core software that governs your card's behavior. " + + "Built and maintained by Muun.

" + + "It's open source, publicly available for review. Find it and more " + + "information on GitHub.", + ), + CardSpec(iconRes = R.drawable.ic_misc_trezor_24px, label = "Packaging", value = "Tamper resistant"), + ), + "delivery" to listOf( + CardSpec(iconRes = R.drawable.ic_local_shipping_24px, label = "Shipped by", value = "Constellations"), + CardSpec(iconRes = R.drawable.ic_place_24px, label = "From", value = "Plastic"), + CardSpec(iconRes = R.drawable.ic_access_time_24px, label = "Arrives in", value = "Plastic"), + CardSpec( + iconRes = R.drawable.ic_folder_open_24px, label = "Shipping data", + value = "Plastic", + additionalData = "Your shipping information is handled under GDPR regulations " + + "and is only used for delivery purposes.", + ), + ), + ) ) diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_marketplace/models/CardSpec.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_marketplace/models/CardSpec.kt index b23b7169..564c93b4 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_marketplace/models/CardSpec.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_marketplace/models/CardSpec.kt @@ -9,4 +9,5 @@ data class CardSpec( @DrawableRes val iconRes: Int, val label: String, val value: String, + val additionalData: String? = null, ) : Parcelable diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_marketplace/models/SecurityCard.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_marketplace/models/SecurityCard.kt index 180b62ab..1051d306 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_marketplace/models/SecurityCard.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_marketplace/models/SecurityCard.kt @@ -4,9 +4,19 @@ import android.os.Parcelable import androidx.annotation.DrawableRes import kotlinx.parcelize.Parcelize +/** + * Content shown in the bottom sheet when the user taps the info icon on a spec row. + */ +@Parcelize +data class AdditionalInfo( + @DrawableRes val iconRes: Int, + val title: String, + val bodyHtml: String, +) : Parcelable + @Parcelize data class SecurityCard( @DrawableRes val imageRes: Int, - val primarySpecs: List, + val specs: Map>, ) : Parcelable diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_shipping_address/ConnectedToWebsiteDialogFragment.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_shipping_address/ConnectedToWebsiteDialogFragment.kt new file mode 100644 index 00000000..ac70a2a5 --- /dev/null +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_shipping_address/ConnectedToWebsiteDialogFragment.kt @@ -0,0 +1,148 @@ +package io.muun.apollo.presentation.ui.security_cards_shipping_address + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.activity.addCallback +import androidx.core.os.bundleOf +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.transition.TransitionManager +import io.muun.apollo.databinding.FragmentConnectedToWebsiteDialogBinding + +class ConnectedToWebsiteDialogFragment : Fragment() { + + companion object { + private const val TAG = "ConnectedToWebsiteDialogFragment" + private const val ARG_WEBSITE_URL = "ARG_WEBSITE_URL" + private const val ARG_ANIMATION_START_VIEW_ID = "ARG_ANIMATION_START_VIEW_ID" + private const val ANIM_EXPAND = 220L + private const val ANIM_BACKDROP_FADE = 220L + private const val ANIM_CONTENT_FADE_IN = 150L + private const val ANIM_CONTENT_FADE_OUT = 120L + + private const val BACKDROP_OPACITY = 0.5f + + fun show( + fragmentManager: FragmentManager, + websiteUrl: String, + animationStartViewId: Int, + ) { + val fragment = ConnectedToWebsiteDialogFragment().apply { + arguments = bundleOf( + ARG_WEBSITE_URL to websiteUrl, + ARG_ANIMATION_START_VIEW_ID to animationStartViewId, + ) + } + + fragmentManager.beginTransaction() + .add(android.R.id.content, fragment, TAG) + .addToBackStack(null) + .commit() + } + + fun isShown(fragmentManager: FragmentManager): Boolean { + return fragmentManager.findFragmentByTag(TAG) != null + } + } + + private val websiteUrlArgument: String + get() = requireNotNull(requireArguments().getString(ARG_WEBSITE_URL)) + + private val animationStartViewIdArgument: Int + get() = requireArguments().getInt(ARG_ANIMATION_START_VIEW_ID) + + private val heroAnimation = HeroAnimation() + + private val animationStartView: View? + get() = activity?.findViewById(animationStartViewIdArgument) + + private val animationSceneRoot: ViewGroup? + get() = activity?.findViewById(android.R.id.content) + + private var _binding: FragmentConnectedToWebsiteDialogBinding? = null + private val binding get() = _binding!! + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + _binding = FragmentConnectedToWebsiteDialogBinding.inflate( + inflater, container, false, + ) + + animationStartView?.visibility = View.INVISIBLE + if (savedInstanceState == null) { + binding.layoutCard.visibility = View.INVISIBLE + binding.viewBackdrop.alpha = 0f + } + + binding.viewContent.setWebsiteUrl(websiteUrlArgument) + binding.viewContent.setOnCloseClick { dismiss() } + binding.viewBackdrop.setOnClickListener { dismiss() } + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + requireActivity().onBackPressedDispatcher + .addCallback(viewLifecycleOwner) { dismiss() } + + if (savedInstanceState == null) { + expand() + } + } + + override fun onDestroyView() { + animationSceneRoot?.let { TransitionManager.endTransitions(it) } + _binding = null + super.onDestroyView() + } + + private fun dismiss() { + if (heroAnimation.isPlaying) return + collapse().onEndAnimation { popSafely() } + } + + private fun expand(): AnimationHandle { + if (heroAnimation.isPlaying) return HeroAnimationScope.noop() + val startView = animationStartView ?: return HeroAnimationScope.noop() + val sceneRoot = animationSceneRoot ?: return HeroAnimationScope.noop() + + binding.viewContent.alpha = 0f + + return heroAnimation.run(sceneRoot) { + inParallel { + inSequence { + morph(from = startView, to = binding.layoutCard, duration = ANIM_EXPAND) + fade(binding.viewContent, to = 1f, duration = ANIM_CONTENT_FADE_IN) + } + fade(binding.viewBackdrop, to = BACKDROP_OPACITY, duration = ANIM_BACKDROP_FADE) + } + } + } + + private fun collapse(): AnimationHandle { + if (heroAnimation.isPlaying) return HeroAnimationScope.noop() + val startView = animationStartView ?: return HeroAnimationScope.noop() + val sceneRoot = animationSceneRoot ?: return HeroAnimationScope.noop() + + return heroAnimation.run(sceneRoot) { + inSequence { + fade(binding.viewContent, to = 0f, duration = ANIM_CONTENT_FADE_OUT) + inParallel { + morph(from = binding.layoutCard, to = startView, duration = ANIM_EXPAND) + .onEndAnimation { startView.visibility = View.VISIBLE } + fade(binding.viewBackdrop, to = 0f, duration = ANIM_BACKDROP_FADE) + } + } + } + } + + private fun popSafely() { + if (isAdded && !parentFragmentManager.isStateSaved) { + parentFragmentManager.popBackStack() + } + } +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_shipping_address/ConnectedToWebsiteView.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_shipping_address/ConnectedToWebsiteView.kt new file mode 100644 index 00000000..2a9eed10 --- /dev/null +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_shipping_address/ConnectedToWebsiteView.kt @@ -0,0 +1,32 @@ +package io.muun.apollo.presentation.ui.security_cards_shipping_address + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import androidx.constraintlayout.widget.ConstraintLayout +import io.muun.apollo.R +import io.muun.apollo.databinding.ViewConnectedToWebsiteBinding + +class ConnectedToWebsiteView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, +) : ConstraintLayout(context, attrs, defStyleAttr) { + + private val binding: ViewConnectedToWebsiteBinding + + init { + LayoutInflater.from(context) + .inflate(R.layout.view_connected_to_website, this) + binding = ViewConnectedToWebsiteBinding.bind(this) + } + + fun setWebsiteUrl(url: String) { + binding.textViewTitle.text = + context.getString(R.string.security_cards_connected_to_website_title, url) + } + + fun setOnCloseClick(listener: OnClickListener) { + binding.imageViewClose.setOnClickListener(listener) + } +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_shipping_address/HeroAnimation.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_shipping_address/HeroAnimation.kt new file mode 100644 index 00000000..8f96a114 --- /dev/null +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_shipping_address/HeroAnimation.kt @@ -0,0 +1,278 @@ +package io.muun.apollo.presentation.ui.security_cards_shipping_address + +import android.graphics.Color +import android.view.View +import android.view.ViewGroup +import androidx.transition.Transition +import androidx.transition.TransitionManager +import com.google.android.material.transition.MaterialContainerTransform + +/** + * Orchestrator that runs one animation at a time. Starting a new animation + * automatically cancels the previous one. + */ +class HeroAnimation { + private var currentRoot: AnimationNode? = null + + val isPlaying: Boolean get() = currentRoot?.isAnimationPlaying == true + + fun run( + sceneRoot: ViewGroup, + block: HeroAnimationScope.() -> AnimationNode, + ): AnimationHandle { + currentRoot?.cancel() + val scope = HeroAnimationScope(sceneRoot) + val root = scope.block() + currentRoot = root + root.play() + return root + } +} + +/** + * DSL scope for building animation trees. Provides factory methods for leaf + * nodes ([morph], [fade]) and composite nodes ([inSequence], [inParallel]). + * The block passed to [HeroAnimation.run] returns the root [AnimationNode]. + */ +class HeroAnimationScope internal constructor(internal val sceneRoot: ViewGroup) { + + companion object { + + fun noop(): AnimationHandle = NoOpAnimationNode() + } + internal val nodes = mutableListOf() + + fun morph( + from: View, + to: View, + duration: Long, + ): AnimationNode = MorphAnimationNode(sceneRoot, from, to, duration).also { nodes.add(it) } + + fun fade( + target: View, + to: Float, + duration: Long, + ): AnimationNode = FadeAnimationNode(target, to, duration).also { nodes.add(it) } + + fun inSequence(block: HeroAnimationScope.() -> Unit): AnimationNode { + val inner = HeroAnimationScope(sceneRoot) + inner.block() + return SequentialAnimationNode(inner.nodes.toList()).also { nodes.add(it) } + } + + fun inParallel(block: HeroAnimationScope.() -> Unit): AnimationNode { + val inner = HeroAnimationScope(sceneRoot) + inner.block() + return ParallelAnimationNode(inner.nodes.toList()).also { nodes.add(it) } + } +} + +/** + * A node in the animation tree. Can be a leaf ([MorphAnimationNode], [FadeAnimationNode]) or a + * composite ([ParallelAnimationNode], [SequentialAnimationNode]). Every node follows the same + * lifecycle: onStart → doPlay → onEnd. Implements [AnimationHandle] so the + * root node returned by [HeroAnimation.run] doubles as the public handle. + */ +abstract class AnimationNode : AnimationHandle { + private var onStartAction: (() -> Unit)? = null + private var onEndAction: (() -> Unit)? = null + private var playing = false + private var cancelled = false + + internal fun play() { + if (cancelled) return + + doPlay( + doOnAnimationStart = { + playing = true + onStartAction?.invoke() + }, + doOnAnimationEnd = { + playing = false + onEndAction?.invoke() + }, + ) + } + + internal open fun cancel() { + if (cancelled) return + + cancelled = true + onStartAction = null + onEndAction = null + } + + protected abstract fun doPlay( + doOnAnimationStart: () -> Unit, + doOnAnimationEnd: () -> Unit, + ) + + // region AnimationHandle + override val isAnimationPlaying: Boolean get() = playing + + override fun onStartAnimation(action: () -> Unit): AnimationHandle { + val prev = onStartAction + onStartAction = { prev?.invoke(); action() } + return this + } + + override fun onEndAnimation(action: () -> Unit): AnimationHandle { + val prev = onEndAction + onEndAction = { prev?.invoke(); action() } + return this + } + // endregion +} + +/** Leaf node that performs a [MaterialContainerTransform] between two views. */ +private class MorphAnimationNode( + private val sceneRoot: ViewGroup, + private val from: View, + private val to: View, + private val duration: Long, +) : AnimationNode() { + + override fun doPlay(doOnAnimationStart: () -> Unit, doOnAnimationEnd: () -> Unit) { + doOnAnimationStart() + val transform = MaterialContainerTransform( + sceneRoot.context, true, + ).apply { + startView = from + endView = to + addTarget(to) + this.duration = this@MorphAnimationNode.duration + scrimColor = Color.TRANSPARENT + } + transform.addListener(object : Transition.TransitionListener { + override fun onTransitionEnd(t: Transition) = doOnAnimationEnd() + override fun onTransitionCancel(t: Transition) = doOnAnimationEnd() + override fun onTransitionStart(t: Transition) {} + override fun onTransitionPause(t: Transition) {} + override fun onTransitionResume(t: Transition) {} + }) + TransitionManager.beginDelayedTransition(sceneRoot, transform) + to.visibility = View.VISIBLE + from.visibility = View.INVISIBLE + } +} + +/** Leaf node that animates a view's alpha via [ViewPropertyAnimator]. */ +private class FadeAnimationNode( + private val target: View, + private val to: Float, + private val duration: Long, +) : AnimationNode() { + + override fun doPlay(doOnAnimationStart: () -> Unit, doOnAnimationEnd: () -> Unit) { + doOnAnimationStart() + target.animate() + .alpha(to) + .setDuration(duration) + .withEndAction { doOnAnimationEnd() } + .start() + } +} + +/** Composite node that plays all children concurrently, completing when the last child ends. */ +private class ParallelAnimationNode( + private val children: List, +) : AnimationNode() { + + override fun doPlay( + doOnAnimationStart: () -> Unit, + doOnAnimationEnd: () -> Unit, + ) { + doOnAnimationStart() + if (children.isEmpty()) { + doOnAnimationEnd() + return + } + + var remaining = children.size + children.forEach { child -> + child.onEndAnimation { + if (--remaining == 0) { + doOnAnimationEnd() + } + } + child.play() + } + } + + override fun cancel() { + children.forEach { it.cancel() } + super.cancel() + } +} + +/** Composite node that plays children one after another in order. */ +private class SequentialAnimationNode( + private val children: List, +) : AnimationNode() { + + override fun doPlay( + doOnAnimationStart: () -> Unit, + doOnAnimationEnd: () -> Unit, + ) { + doOnAnimationStart() + if (children.isEmpty()) { + doOnAnimationEnd() + return + } + + playSequentially(doOnAnimationEnd) + } + + private fun playSequentially( + doOnAnimationEnd: () -> Unit, + childIndex: Int = 0, + ) { + if (childIndex == children.size) { + doOnAnimationEnd() + return + } + + val child = children[childIndex] + child.onEndAnimation { playSequentially(doOnAnimationEnd, childIndex = childIndex + 1) } + child.play() + } + + override fun cancel() { + children.forEach { it.cancel() } + super.cancel() + } +} + +/** + * No-op node that completes instantly. + */ +private class NoOpAnimationNode : AnimationNode() { + + override fun doPlay(doOnAnimationStart: () -> Unit, doOnAnimationEnd: () -> Unit) { + doOnAnimationStart() + doOnAnimationEnd() + } + + override fun onStartAnimation(action: () -> Unit): AnimationHandle { + action() + return this + } + + override fun onEndAnimation(action: () -> Unit): AnimationHandle { + action() + return this + } +} + +/** + * Public handle returned by [HeroAnimation.run]. Exposes animation lifecycle + * callbacks without leaking internal node structure. + */ +interface AnimationHandle { + + val isAnimationPlaying: Boolean + + fun onStartAnimation(action: () -> Unit): AnimationHandle + + fun onEndAnimation(action: () -> Unit): AnimationHandle +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_shipping_address/ShippingAddressActivity.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_shipping_address/ShippingAddressActivity.kt new file mode 100644 index 00000000..2675940d --- /dev/null +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_shipping_address/ShippingAddressActivity.kt @@ -0,0 +1,190 @@ +package io.muun.apollo.presentation.ui.security_cards_shipping_address + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.MenuItem +import android.widget.AutoCompleteTextView +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import io.muun.apollo.R +import io.muun.apollo.databinding.ActivityShippingAddressBinding +import io.muun.apollo.presentation.app.Navigator +import io.muun.apollo.presentation.ui.security_cards.utils.addWebsitePill +import io.muun.apollo.presentation.ui.security_cards_country_picker.CountryPickerActivity +import io.muun.apollo.presentation.ui.security_cards_country_picker.models.CountryInfo +import io.muun.apollo.presentation.ui.security_cards_marketplace.models.SecurityCardProvider +import io.muun.apollo.presentation.ui.utils.doAfterTextAsStringChanged +import io.muun.apollo.presentation.ui.utils.getColorCompat +import io.muun.apollo.presentation.ui.utils.getComponent +import io.muun.apollo.presentation.ui.utils.hideSoftInputMethod +import io.muun.apollo.presentation.ui.utils.setTextIfChanged +import io.muun.apollo.presentation.ui.utils.setWindowInsetsCompat +import io.muun.apollo.presentation.ui.view.MuunHeader +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import javax.inject.Inject + +class ShippingAddressActivity : AppCompatActivity() { + + companion object { + private const val EXTRA_COUNTRY_INFO = "countryInfo" + private const val EXTRA_PROVIDER = "provider" + fun getIntent( + context: Context, + countryInfo: CountryInfo, + provider: SecurityCardProvider, + ) = Intent(context, ShippingAddressActivity::class.java).apply { + putExtra(EXTRA_COUNTRY_INFO, countryInfo) + putExtra(EXTRA_PROVIDER, provider) + } + } + + private val countryInfoExtra: CountryInfo + get() = requireNotNull( + intent.getParcelableExtra(EXTRA_COUNTRY_INFO) + ) + + private val providerExtra: SecurityCardProvider + get() = requireNotNull( + intent.getParcelableExtra(EXTRA_PROVIDER) + ) + + private val binding: ActivityShippingAddressBinding by lazy { + ActivityShippingAddressBinding.inflate(layoutInflater) + } + + @Inject + lateinit var navigator: Navigator + + @Inject + lateinit var viewModelFactory: ShippingAddressViewModel.Factory + + private val viewModel: ShippingAddressViewModel by viewModels { + ShippingAddressViewModelFactory( + countryInfo = countryInfoExtra, + assistedFactory = viewModelFactory, + owner = this, + ) + } + + private val countryPickerLauncher = + registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { result -> + if (result.resultCode == RESULT_OK) { + val picked = CountryPickerActivity.getResult( + requireNotNull(result.data) + ) + viewModel.setShippingCountry(picked) + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + setWindowInsetsCompat() + super.onCreate(savedInstanceState) + setContentView(binding.root) + getComponent().inject(this) + + configureHeader() + configureFormFields() + configureContinueButton() + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { + viewModel.viewState.collectLatest(::handleViewState) + } + } + } + } + + override fun onResume() { + super.onResume() + // EditText regain focus upon screen recreation (for instance screen rotation), + // if dialog is being shown we have to hide the soft keyboard. + if (ConnectedToWebsiteDialogFragment.isShown(supportFragmentManager)) { + hideSoftInputMethod() + } + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + if (item.itemId == android.R.id.home) { + onBackPressedDispatcher.onBackPressed() + return true + } + return super.onOptionsItemSelected(item) + } + + private fun configureHeader() { + binding.header.attachToActivity(this) + binding.header.setBackgroundColor(getColorCompat(R.color.surface_background)) + binding.header.setNavigation(MuunHeader.Navigation.BACK) + binding.header.hideTitle() + + binding.header.addWebsitePill( + siteUrl = providerExtra.name, + id = R.id.security_cards_checkout_website_pill, + onClick = { showConnectedToWebsiteDialog() } + ) + } + + private fun showConnectedToWebsiteDialog() { + hideSoftInputMethod { + ConnectedToWebsiteDialogFragment.show( + fragmentManager = supportFragmentManager, + websiteUrl = providerExtra.name, + animationStartViewId = R.id.security_cards_checkout_website_pill, + ) + } + } + + private fun configureFormFields() { + binding.editTextFullName.doAfterTextAsStringChanged(viewModel::setFullName) + binding.editTextEmail.doAfterTextAsStringChanged(viewModel::setEmail) + binding.editTextShippingAddress.doAfterTextAsStringChanged(viewModel::setShippingAddress) + binding.textViewCountry.setOnClickListener { + val currentCountry = viewModel.viewState.value.countryInfo + navigator.navigateToCountryPickerForResult( + this, + currentCountry.code, + countryPickerLauncher, + ) + } + binding.editTextCity.doAfterTextAsStringChanged(viewModel::setShippingCity) + binding.editTextState.doAfterTextAsStringChanged(viewModel::setShippingState) + binding.editTextZipCode.doAfterTextAsStringChanged(viewModel::setShippingZipCode) + } + + private fun configureContinueButton() { + binding.buttonContinue.setOnClickListener { + // TODO: Navigate to checkout + } + } + + private fun handleViewState(viewState: ShippingAddressViewModel.ViewState) { + binding.editTextFullName.setTextIfChanged(viewState.fullName) + binding.editTextEmail.setTextIfChanged(viewState.email) + binding.editTextShippingAddress.setTextIfChanged(viewState.shippingAddress) + binding.textViewCountry.setTextWithoutFilteringIfChanged( + "${viewState.countryInfo.flagEmoji} ${viewState.countryInfo.name}" + ) + binding.editTextCity.setTextIfChanged(viewState.city) + binding.editTextState.setTextIfChanged(viewState.state) + binding.editTextZipCode.setTextIfChanged(viewState.zipCode) + + binding.buttonContinue.isEnabled = viewState.isFormComplete + } +} + +private fun AutoCompleteTextView.setTextWithoutFilteringIfChanged(value: String) { + if (text.toString() == value) { + return + } + + setText(value, false) +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_shipping_address/ShippingAddressViewModel.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_shipping_address/ShippingAddressViewModel.kt new file mode 100644 index 00000000..a72ba5b6 --- /dev/null +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_shipping_address/ShippingAddressViewModel.kt @@ -0,0 +1,84 @@ +package io.muun.apollo.presentation.ui.security_cards_shipping_address + +import android.os.Parcelable +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import io.muun.apollo.presentation.ui.security_cards_country_picker.models.CountryInfo +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.parcelize.Parcelize + +class ShippingAddressViewModel @AssistedInject constructor( + @Assisted private val initialCountryInfo: CountryInfo, + @Assisted private val savedStateHandle: SavedStateHandle, +) : ViewModel() { + + companion object { + private const val VIEW_STATE_KEY = "VIEW_STATE_KEY" + } + + @AssistedFactory + interface Factory { + fun create( + countryInfo: CountryInfo, + savedStateHandle: SavedStateHandle, + ): ShippingAddressViewModel + } + + @Parcelize + data class ViewState( + val fullName: String = "", + val email: String = "", + val shippingAddress: String = "", + val countryInfo: CountryInfo, + val city: String = "", + val state: String = "", + val zipCode: String = "", + ): Parcelable { + + val isFormComplete: Boolean + get() = fullName.isNotBlank() + && email.isNotBlank() + && shippingAddress.isNotBlank() + && city.isNotBlank() + && state.isNotBlank() + && zipCode.isNotBlank() + } + + private val _viewState = savedStateHandle.getMutableStateFlow( + VIEW_STATE_KEY, + ViewState(countryInfo = initialCountryInfo) + ) + val viewState: StateFlow = _viewState + + fun setFullName(value: String) { + _viewState.update { it.copy(fullName = value) } + } + + fun setEmail(value: String) { + _viewState.update { it.copy(email = value) } + } + + fun setShippingAddress(value: String) { + _viewState.update { it.copy(shippingAddress = value) } + } + + fun setShippingCountry(country: CountryInfo) { + _viewState.update { it.copy(countryInfo = country) } + } + + fun setShippingCity(value: String) { + _viewState.update { it.copy(city = value) } + } + + fun setShippingState(value: String) { + _viewState.update { it.copy(state = value) } + } + + fun setShippingZipCode(value: String) { + _viewState.update { it.copy(zipCode = value) } + } +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_shipping_address/ShippingAddressViewModelFactory.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_shipping_address/ShippingAddressViewModelFactory.kt new file mode 100644 index 00000000..28f033c0 --- /dev/null +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_shipping_address/ShippingAddressViewModelFactory.kt @@ -0,0 +1,28 @@ +package io.muun.apollo.presentation.ui.security_cards_shipping_address + +import android.os.Bundle +import androidx.lifecycle.AbstractSavedStateViewModelFactory +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.savedstate.SavedStateRegistryOwner +import io.muun.apollo.presentation.ui.security_cards_country_picker.models.CountryInfo + +class ShippingAddressViewModelFactory( + private val countryInfo: CountryInfo, + private val assistedFactory: ShippingAddressViewModel.Factory, + owner: SavedStateRegistryOwner, + defaultArgs: Bundle? = null, +) : AbstractSavedStateViewModelFactory(owner, defaultArgs) { + + override fun create( + key: String, + modelClass: Class, + handle: SavedStateHandle, + ): T { + @Suppress("UNCHECKED_CAST") + return assistedFactory.create( + countryInfo = countryInfo, + savedStateHandle = handle, + ) as T + } +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_logout/SecurityLogoutPresenter.java b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_logout/SecurityLogoutPresenter.java index 0065918a..109ed8ca 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_logout/SecurityLogoutPresenter.java +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_logout/SecurityLogoutPresenter.java @@ -2,7 +2,7 @@ import io.muun.apollo.domain.action.session.LogoutAction; import io.muun.apollo.domain.analytics.AnalyticsEvent; -import io.muun.apollo.domain.errors.MuunError; +import io.muun.apollo.domain.errors.UnclassifiedError; import io.muun.apollo.presentation.ui.base.BasePresenter; import io.muun.apollo.presentation.ui.base.BaseView; import io.muun.apollo.presentation.ui.base.di.PerActivity; @@ -52,7 +52,7 @@ private String getJwt() { final Optional serverJwt = authRepository.getServerJwt(); if (!serverJwt.isPresent()) { // Shouldn't happen but we wanna know 'cause probably a bug - Timber.e(new MuunError("Auth token expected to be present")); + Timber.e(new UnclassifiedError("Auth token expected to be present")); return ""; } diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/show_qr/QrParentPresenter.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/show_qr/QrParentPresenter.kt index e46955fc..d0c5f723 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/show_qr/QrParentPresenter.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/show_qr/QrParentPresenter.kt @@ -7,8 +7,6 @@ interface QrParentPresenter : ParentPresenter { fun handleNotificationPermissionPrompt() - fun reportNotificationPermissionAsked() - fun shareQrContent(content: String) fun copyQrContent(content: String, origin: AnalyticsEvent.ADDRESS_ORIGIN) diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/show_qr/QrPresenter.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/show_qr/QrPresenter.kt index f6bdb010..41a5cc7e 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/show_qr/QrPresenter.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/show_qr/QrPresenter.kt @@ -55,8 +55,4 @@ abstract class QrPresenter : SingleFragmentPresenter Unit) { + doAfterTextChanged { it?.toString().orEmpty().let(afterTextChanged) } +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/utils/Extensions.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/utils/Extensions.kt index 1a2b3b5f..8d68063f 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/utils/Extensions.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/utils/Extensions.kt @@ -9,14 +9,17 @@ import android.content.pm.PackageManager import android.content.res.Configuration import android.content.res.Resources import android.net.Uri +import android.os.Bundle import android.os.Handler import android.os.Looper +import android.os.ResultReceiver import android.os.Vibrator import android.view.View import android.view.ViewGroup import android.view.ViewTreeObserver import android.view.WindowManager import android.view.animation.Animation +import android.view.inputmethod.InputMethodManager import android.widget.TextView import android.widget.Toast import androidx.activity.enableEdgeToEdge @@ -27,6 +30,7 @@ import androidx.annotation.StringRes import androidx.annotation.StyleRes import androidx.appcompat.app.AppCompatActivity import androidx.core.content.ContextCompat +import androidx.core.content.getSystemService import androidx.core.view.ViewCompat import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat @@ -334,7 +338,15 @@ fun Context.openInBrowser(uriString: String) { val message: String = getString(R.string.error_no_web_client_installed) Toast.makeText(this, message, Toast.LENGTH_SHORT).show() // TODO avoid toast and show a proper dialog. We should use AlertDialogExtension but for - // that we need and activity. We should move this extension to another place and refactor + // that we need an activity. We should move this extension to another place and refactor + // LinkBuilder too. RabbitHole Alert!!! + + } catch (e: SecurityException) { + Timber.e("Target activity not exported!", e) + val message: String = getString(R.string.error_no_web_client_installed) + Toast.makeText(this, message, Toast.LENGTH_SHORT).show() + // TODO avoid toast and show a proper dialog. We should use AlertDialogExtension but for + // that we need an activity. We should move this extension to another place and refactor // LinkBuilder too. RabbitHole Alert!!! } } @@ -446,4 +458,28 @@ fun AppCompatActivity.getComponent(): ActivityComponent { fun Fragment.getComponent(): FragmentComponent { return (requireActivity().application as ApolloApplication).getApplicationComponent().fragmentComponent() -} \ No newline at end of file +} + +fun Activity.hideSoftInputMethod(onImeHide: (() -> Unit)? = null) { + val rootView = findViewById(android.R.id.content) + val insets = ViewCompat.getRootWindowInsets(rootView) + val isKeyboardVisible = insets?.isVisible(WindowInsetsCompat.Type.ime()) ?: false + + if (isKeyboardVisible) { + val imm = requireNotNull(getSystemService()) + imm.hideSoftInputFromWindow( + rootView.windowToken, + 0, + if (onImeHide != null) { + object : ResultReceiver(Handler(Looper.getMainLooper())) { + override fun onReceiveResult(resultCode: Int, resultData: Bundle?) { + onImeHide.invoke() + } + } + } else null, + ) + } else { + // Keyboard not being shown, calling onImeHide right away + onImeHide?.invoke() + } +} diff --git a/android/apolloui/src/main/res/drawable/bg_security_card_spec_table.xml b/android/apolloui/src/main/res/drawable/bg_security_card_spec_table.xml new file mode 100644 index 00000000..e105e4c4 --- /dev/null +++ b/android/apolloui/src/main/res/drawable/bg_security_card_spec_table.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/android/apolloui/src/main/res/drawable/connected_with_external_website.xml b/android/apolloui/src/main/res/drawable/connected_with_external_website.xml new file mode 100644 index 00000000..4ade325f --- /dev/null +++ b/android/apolloui/src/main/res/drawable/connected_with_external_website.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + diff --git a/android/apolloui/src/main/res/drawable/ic_access_time_24px.xml b/android/apolloui/src/main/res/drawable/ic_access_time_24px.xml new file mode 100644 index 00000000..6e79fb72 --- /dev/null +++ b/android/apolloui/src/main/res/drawable/ic_access_time_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/apolloui/src/main/res/drawable/ic_arrowhead_left.xml b/android/apolloui/src/main/res/drawable/ic_arrowhead_left.xml new file mode 100644 index 00000000..e9cf89a7 --- /dev/null +++ b/android/apolloui/src/main/res/drawable/ic_arrowhead_left.xml @@ -0,0 +1,11 @@ + + + diff --git a/android/apolloui/src/main/res/drawable/ic_arrowhead_up.xml b/android/apolloui/src/main/res/drawable/ic_arrowhead_up.xml new file mode 100644 index 00000000..2b6d8c0c --- /dev/null +++ b/android/apolloui/src/main/res/drawable/ic_arrowhead_up.xml @@ -0,0 +1,11 @@ + + + diff --git a/android/apolloui/src/main/res/drawable/ic_aspect_ratio_24px.xml b/android/apolloui/src/main/res/drawable/ic_aspect_ratio_24px.xml new file mode 100644 index 00000000..96b5fb78 --- /dev/null +++ b/android/apolloui/src/main/res/drawable/ic_aspect_ratio_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/apolloui/src/main/res/drawable/ic_code_24px.xml b/android/apolloui/src/main/res/drawable/ic_code_24px.xml new file mode 100644 index 00000000..6417d57d --- /dev/null +++ b/android/apolloui/src/main/res/drawable/ic_code_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/apolloui/src/main/res/drawable/ic_delete_outline_24px.xml b/android/apolloui/src/main/res/drawable/ic_delete_outline_24px.xml new file mode 100644 index 00000000..1c0d88d3 --- /dev/null +++ b/android/apolloui/src/main/res/drawable/ic_delete_outline_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/apolloui/src/main/res/drawable/ic_fitness_center_24px.xml b/android/apolloui/src/main/res/drawable/ic_fitness_center_24px.xml new file mode 100644 index 00000000..04876ca9 --- /dev/null +++ b/android/apolloui/src/main/res/drawable/ic_fitness_center_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/apolloui/src/main/res/drawable/ic_folder_open_24px.xml b/android/apolloui/src/main/res/drawable/ic_folder_open_24px.xml new file mode 100644 index 00000000..28d57855 --- /dev/null +++ b/android/apolloui/src/main/res/drawable/ic_folder_open_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/apolloui/src/main/res/drawable/ic_hardware_security_24px.xml b/android/apolloui/src/main/res/drawable/ic_hardware_security_24px.xml new file mode 100644 index 00000000..99edc708 --- /dev/null +++ b/android/apolloui/src/main/res/drawable/ic_hardware_security_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/apolloui/src/main/res/drawable/ic_launch_24px.xml b/android/apolloui/src/main/res/drawable/ic_launch_24px.xml new file mode 100644 index 00000000..75527e94 --- /dev/null +++ b/android/apolloui/src/main/res/drawable/ic_launch_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/apolloui/src/main/res/drawable/ic_local_mall_24px.xml b/android/apolloui/src/main/res/drawable/ic_local_mall_24px.xml new file mode 100644 index 00000000..e2ee73b7 --- /dev/null +++ b/android/apolloui/src/main/res/drawable/ic_local_mall_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/apolloui/src/main/res/drawable/ic_local_shipping_24px.xml b/android/apolloui/src/main/res/drawable/ic_local_shipping_24px.xml new file mode 100644 index 00000000..1b243eb7 --- /dev/null +++ b/android/apolloui/src/main/res/drawable/ic_local_shipping_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/apolloui/src/main/res/drawable/ic_misc_trezor_24px.xml b/android/apolloui/src/main/res/drawable/ic_misc_trezor_24px.xml new file mode 100644 index 00000000..c2ffa035 --- /dev/null +++ b/android/apolloui/src/main/res/drawable/ic_misc_trezor_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/apolloui/src/main/res/drawable/ic_place_24px.xml b/android/apolloui/src/main/res/drawable/ic_place_24px.xml new file mode 100644 index 00000000..5f397836 --- /dev/null +++ b/android/apolloui/src/main/res/drawable/ic_place_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/apolloui/src/main/res/drawable/ic_style_24px.xml b/android/apolloui/src/main/res/drawable/ic_style_24px.xml new file mode 100644 index 00000000..4ac60aa5 --- /dev/null +++ b/android/apolloui/src/main/res/drawable/ic_style_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/apolloui/src/main/res/layout-land/fragment_settings.xml b/android/apolloui/src/main/res/layout-land/fragment_settings.xml index afc3dc58..8e0f17e7 100644 --- a/android/apolloui/src/main/res/layout-land/fragment_settings.xml +++ b/android/apolloui/src/main/res/layout-land/fragment_settings.xml @@ -211,6 +211,10 @@ muun:label="@string/settings_disable_feature_flags" style="@style/settings_item" /> + diff --git a/android/apolloui/src/main/res/layout/activity_debug_secure_key_value_storage.xml b/android/apolloui/src/main/res/layout/activity_debug_secure_key_value_storage.xml new file mode 100644 index 00000000..b5e43695 --- /dev/null +++ b/android/apolloui/src/main/res/layout/activity_debug_secure_key_value_storage.xml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/apolloui/src/main/res/layout/activity_security_cards_full_specs.xml b/android/apolloui/src/main/res/layout/activity_security_cards_full_specs.xml new file mode 100644 index 00000000..404d44b7 --- /dev/null +++ b/android/apolloui/src/main/res/layout/activity_security_cards_full_specs.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + diff --git a/android/apolloui/src/main/res/layout/activity_shipping_address.xml b/android/apolloui/src/main/res/layout/activity_shipping_address.xml new file mode 100644 index 00000000..6c600b64 --- /dev/null +++ b/android/apolloui/src/main/res/layout/activity_shipping_address.xml @@ -0,0 +1,245 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/apolloui/src/main/res/layout/bottom_sheet_spec_additional_info.xml b/android/apolloui/src/main/res/layout/bottom_sheet_spec_additional_info.xml new file mode 100644 index 00000000..f2d0bc05 --- /dev/null +++ b/android/apolloui/src/main/res/layout/bottom_sheet_spec_additional_info.xml @@ -0,0 +1,48 @@ + + + + + + + + + + diff --git a/android/apolloui/src/main/res/layout/debug_activity.xml b/android/apolloui/src/main/res/layout/debug_activity.xml index 5da109a1..ed68697b 100644 --- a/android/apolloui/src/main/res/layout/debug_activity.xml +++ b/android/apolloui/src/main/res/layout/debug_activity.xml @@ -151,19 +151,19 @@ + + + + + + + + + + + diff --git a/android/apolloui/src/main/res/layout/fragment_settings.xml b/android/apolloui/src/main/res/layout/fragment_settings.xml index 7f7b13ff..8e308108 100644 --- a/android/apolloui/src/main/res/layout/fragment_settings.xml +++ b/android/apolloui/src/main/res/layout/fragment_settings.xml @@ -209,6 +209,10 @@ muun:label="@string/settings_disable_feature_flags" style="@style/settings_item" /> + diff --git a/android/apolloui/src/main/res/layout/item_security_card_spec_card_image.xml b/android/apolloui/src/main/res/layout/item_security_card_spec_card_image.xml new file mode 100644 index 00000000..975041f1 --- /dev/null +++ b/android/apolloui/src/main/res/layout/item_security_card_spec_card_image.xml @@ -0,0 +1,121 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/apolloui/src/main/res/layout/item_security_card_spec_header.xml b/android/apolloui/src/main/res/layout/item_security_card_spec_header.xml new file mode 100644 index 00000000..112cee10 --- /dev/null +++ b/android/apolloui/src/main/res/layout/item_security_card_spec_header.xml @@ -0,0 +1,12 @@ + + diff --git a/android/apolloui/src/main/res/layout/item_security_card_spec_row.xml b/android/apolloui/src/main/res/layout/item_security_card_spec_row.xml new file mode 100644 index 00000000..9564396c --- /dev/null +++ b/android/apolloui/src/main/res/layout/item_security_card_spec_row.xml @@ -0,0 +1,51 @@ + + + + + + + + + + diff --git a/android/apolloui/src/main/res/layout/view_connected_to_website.xml b/android/apolloui/src/main/res/layout/view_connected_to_website.xml new file mode 100644 index 00000000..ff3bafb9 --- /dev/null +++ b/android/apolloui/src/main/res/layout/view_connected_to_website.xml @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + diff --git a/android/apolloui/src/main/res/navigation/home_nav_graph.xml b/android/apolloui/src/main/res/navigation/home_nav_graph.xml index 1b0f1778..1d0c477d 100644 --- a/android/apolloui/src/main/res/navigation/home_nav_graph.xml +++ b/android/apolloui/src/main/res/navigation/home_nav_graph.xml @@ -25,7 +25,8 @@ + app:argType="io.muun.apollo.domain.analytics.AnalyticsEvent$SECURITY_CENTER_ORIGIN" + android:defaultValue="SHIELD_BUTTON" /> Eliminar Cancelar Tus feature flags + Enviar datos de debug vía email Security Card Eliminaste tu monedero @@ -1022,6 +1023,10 @@ Parece que no tienes un navegador instalado. Por favor, instala uno y prueba de nuevo. + + No se pudo abrir el enlace. Intenta abrirlo con otro navegador. + + No puede realizarse este pago de lightning @@ -1831,4 +1836,33 @@ See full specs GO TO %s + + La descarga comenzará pronto… + Descargando actualización… + Actualización descargada. Reiniciá para instalar. + Reiniciar + + + + 54\nmm + 86.6mm + + + + Shipping + Information needed to deliver your card. + Full Name + Email + Shipping address + Country/Region + City + State + ZIP Code + GO TO CHECKOUT + + + + Connected to %s + No personal information is associated with your Muun wallet. + diff --git a/android/apolloui/src/main/res/values-night/colors.xml b/android/apolloui/src/main/res/values-night/colors.xml index 84cc8566..15c343c9 100644 --- a/android/apolloui/src/main/res/values-night/colors.xml +++ b/android/apolloui/src/main/res/values-night/colors.xml @@ -106,4 +106,7 @@ @color/gray_dark + + + @color/gray_darkest \ No newline at end of file diff --git a/android/apolloui/src/main/res/values-night/new_colors.xml b/android/apolloui/src/main/res/values-night/new_colors.xml new file mode 100644 index 00000000..747981bc --- /dev/null +++ b/android/apolloui/src/main/res/values-night/new_colors.xml @@ -0,0 +1,10 @@ + + + #060C13 + #1B2026 + @color/text_secondary_color + + + #CBCED2 + #FFFFFF + diff --git a/android/apolloui/src/main/res/values/colors.xml b/android/apolloui/src/main/res/values/colors.xml index a056f85b..70ee52a6 100644 --- a/android/apolloui/src/main/res/values/colors.xml +++ b/android/apolloui/src/main/res/values/colors.xml @@ -135,4 +135,9 @@ @color/gray_light + + + @color/gray_light + #614DFF + #0D614DFF diff --git a/android/apolloui/src/main/res/values/dimens.xml b/android/apolloui/src/main/res/values/dimens.xml index 13830cca..7513a645 100644 --- a/android/apolloui/src/main/res/values/dimens.xml +++ b/android/apolloui/src/main/res/values/dimens.xml @@ -34,4 +34,20 @@ 66dp + + + 24dp + 12dp + 20dp + 5dp + + 20dp + 40dp + 1dp + 16dp + 8dp + 16dp + + + 20dp diff --git a/android/apolloui/src/main/res/values/ids.xml b/android/apolloui/src/main/res/values/ids.xml new file mode 100644 index 00000000..e1e3abf9 --- /dev/null +++ b/android/apolloui/src/main/res/values/ids.xml @@ -0,0 +1,4 @@ + + + + diff --git a/android/apolloui/src/main/res/values/new_colors.xml b/android/apolloui/src/main/res/values/new_colors.xml new file mode 100644 index 00000000..6e09e644 --- /dev/null +++ b/android/apolloui/src/main/res/values/new_colors.xml @@ -0,0 +1,10 @@ + + + #FFFFFF + #F4F4F5 + @color/text_secondary_color + + + #242B33 + #1B2026 + diff --git a/android/apolloui/src/main/res/values/strings.xml b/android/apolloui/src/main/res/values/strings.xml index f37cac6b..eab37e31 100644 --- a/android/apolloui/src/main/res/values/strings.xml +++ b/android/apolloui/src/main/res/values/strings.xml @@ -771,6 +771,7 @@ Delete Cancel Your feature flags + Send debug data via email Security Card You deleted your wallet @@ -1015,6 +1016,10 @@ There\'s no web browser app installed. + + Couldn\'t open this link, try using another browser. + + Edit network fee @@ -1775,4 +1780,30 @@ See full specs GO TO %s + + Update download will start soon… + Downloading update… + Update downloaded. Restart to install. + Restart + + + 54\nmm + 86.6mm + + + Shipping + Information needed to deliver your card. + Full Name + Email + Shipping address + Country/Region + City + State + ZIP Code + GO TO CHECKOUT + + + Connected to %s + No personal information is associated with your Muun wallet. + diff --git a/android/apolloui/src/main/res/values/styles.xml b/android/apolloui/src/main/res/values/styles.xml index e23d9b52..eeacc350 100644 --- a/android/apolloui/src/main/res/values/styles.xml +++ b/android/apolloui/src/main/res/values/styles.xml @@ -527,4 +527,26 @@ bold 16sp + + + + + + diff --git a/android/apolloui/src/prod/java/io/muun/apollo/presentation/app/di/modules/InAppUpdateModule.kt b/android/apolloui/src/prod/java/io/muun/apollo/presentation/app/di/modules/InAppUpdateModule.kt new file mode 100644 index 00000000..311122e4 --- /dev/null +++ b/android/apolloui/src/prod/java/io/muun/apollo/presentation/app/di/modules/InAppUpdateModule.kt @@ -0,0 +1,14 @@ +package io.muun.apollo.presentation.app.di.modules + +import dagger.Module +import dagger.Provides +import io.muun.apollo.presentation.ui.home.InAppUpdateManager +import io.muun.apollo.presentation.ui.home.NoOpInAppUpdateManager + +@Module +object InAppUpdateModule { + + @Provides + fun provideInAppUpdateManagerFactory(): InAppUpdateManager.Factory = + InAppUpdateManager.Factory { _, _ -> NoOpInAppUpdateManager() } +} diff --git a/android/apolloui/src/regtest/java/io/muun/apollo/presentation/app/di/modules/InAppUpdateModule.kt b/android/apolloui/src/regtest/java/io/muun/apollo/presentation/app/di/modules/InAppUpdateModule.kt new file mode 100644 index 00000000..311122e4 --- /dev/null +++ b/android/apolloui/src/regtest/java/io/muun/apollo/presentation/app/di/modules/InAppUpdateModule.kt @@ -0,0 +1,14 @@ +package io.muun.apollo.presentation.app.di.modules + +import dagger.Module +import dagger.Provides +import io.muun.apollo.presentation.ui.home.InAppUpdateManager +import io.muun.apollo.presentation.ui.home.NoOpInAppUpdateManager + +@Module +object InAppUpdateModule { + + @Provides + fun provideInAppUpdateManagerFactory(): InAppUpdateManager.Factory = + InAppUpdateManager.Factory { _, _ -> NoOpInAppUpdateManager() } +} diff --git a/android/apolloui/src/test/java/io/muun/apollo/data/libwallet/LibwalletSecureKeyValueStorageTest.kt b/android/apolloui/src/test/java/io/muun/apollo/data/libwallet/LibwalletSecureKeyValueStorageTest.kt new file mode 100644 index 00000000..db8b8d6a --- /dev/null +++ b/android/apolloui/src/test/java/io/muun/apollo/data/libwallet/LibwalletSecureKeyValueStorageTest.kt @@ -0,0 +1,107 @@ +package io.muun.apollo.data.libwallet + +import app_provided_data.App_provided_data +import io.mockk.every +import io.mockk.mockk +import io.muun.apollo.BaseTest +import io.muun.apollo.data.os.secure_storage.FakeKeyStore +import io.muun.apollo.data.os.secure_storage.FakePreferences +import io.muun.apollo.data.os.secure_storage.SecureStorageProvider +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test + +class LibwalletSecureKeyValueStorageTest : BaseTest() { + + private lateinit var keystore: FakeKeyStore + private lateinit var preferences: FakePreferences + private lateinit var secureStorageProvider: SecureStorageProvider + private lateinit var bridge: LibwalletSecureKeyValueStorage + + @Before + fun setUp() { + keystore = FakeKeyStore() + preferences = FakePreferences() + secureStorageProvider = SecureStorageProvider(keystore, preferences) + bridge = LibwalletSecureKeyValueStorage(secureStorageProvider) + } + + @Test + fun `get reports NotFound when key is missing`() { + val (value, status) = bridge.getValueAndStatus("missing") + assertThat(status).isEqualTo(App_provided_data.SecureKvStatusNotFound) + assertThat(value).isEmpty() + } + + @Test + fun `get reports DecryptionFailed when keystore entry is gone`() { + // Given: key was stored, then the keystore entry is removed (simulates a + // lock-screen change wiping the encryption key while ciphertext stays in prefs). + secureStorageProvider.put("key", "value".toByteArray()) + keystore.deleteEntry("key") + + val (_, status) = bridge.getValueAndStatus("key") + assertThat(status).isEqualTo(App_provided_data.SecureKvStatusDecryptionFailed) + } + + @Test + fun `get reports Ok and returns plaintext for a stored key`() { + secureStorageProvider.put("key", "plaintext".toByteArray()) + + val (value, status) = bridge.getValueAndStatus("key") + assertThat(status).isEqualTo(App_provided_data.SecureKvStatusOk) + assertThat(String(value)).isEqualTo("plaintext") + } + + @Test + fun `put reports Ok on success`() { + val status = bridge.putStatus("key", "value".toByteArray()) + assertThat(status).isEqualTo(App_provided_data.SecureKvStatusOk) + } + + @Test + fun `put reports StorageFailed when provider throws`() { + val brokenBridge = LibwalletSecureKeyValueStorage(throwingProvider()) + + val status = brokenBridge.putStatus("key", "value".toByteArray()) + assertThat(status).isEqualTo(App_provided_data.SecureKvStatusStorageFailed) + } + + @Test + fun `delete reports Ok on success`() { + secureStorageProvider.put("key", "value".toByteArray()) + + val status = bridge.deleteStatus("key") + assertThat(status).isEqualTo(App_provided_data.SecureKvStatusOk) + } + + @Test + fun `delete reports StorageFailed when provider throws`() { + val brokenBridge = LibwalletSecureKeyValueStorage(throwingProvider()) + + val status = brokenBridge.deleteStatus("key") + assertThat(status).isEqualTo(App_provided_data.SecureKvStatusStorageFailed) + } + + @Test + fun `wipe reports Ok on success`() { + val status = bridge.wipeStatus() + assertThat(status).isEqualTo(App_provided_data.SecureKvStatusOk) + } + + @Test + fun `wipe reports StorageFailed when provider throws`() { + val brokenBridge = LibwalletSecureKeyValueStorage(throwingProvider()) + + val status = brokenBridge.wipeStatus() + assertThat(status).isEqualTo(App_provided_data.SecureKvStatusStorageFailed) + } + + private fun throwingProvider(): SecureStorageProvider { + val provider = mockk() + every { provider.put(any(), any()) } throws RuntimeException("boom") + every { provider.delete(any()) } throws RuntimeException("boom") + every { provider.wipe() } throws RuntimeException("boom") + return provider + } +} diff --git a/android/apolloui/src/test/java/io/muun/apollo/data/preferences/KeysRepositoryTest.kt b/android/apolloui/src/test/java/io/muun/apollo/data/preferences/KeysRepositoryTest.kt new file mode 100644 index 00000000..e86246a4 --- /dev/null +++ b/android/apolloui/src/test/java/io/muun/apollo/data/preferences/KeysRepositoryTest.kt @@ -0,0 +1,255 @@ +package io.muun.apollo.data.preferences + +import android.content.Context +import io.mockk.MockKAnnotations +import io.mockk.impl.annotations.MockK +import io.muun.apollo.BaseTest +import io.muun.apollo.data.os.secure_storage.FakeKeyStore +import io.muun.apollo.data.os.secure_storage.FakePreferences +import io.muun.apollo.data.os.secure_storage.SecureStorageProvider +import io.muun.common.crypto.ChallengePublicKey +import io.muun.common.crypto.ChallengeType +import org.bitcoinj.core.ECKey +import org.bitcoinj.core.NetworkParameters +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class KeysRepositoryTest : BaseTest() { + + companion object { + // Mirrors the private constant in KeysRepository for test setup + private const val KEY_BASE_58_PRIVATE_KEY = "key_base_58_private_key" + + // Matches the version used by ChallengeKey.buildPublic for RC V2 + private const val CHALLENGE_KEY_VERSION_RC_V2 = 2 + } + + private lateinit var secureStorageProvider: SecureStorageProvider + + @MockK(relaxed = true) + lateinit var context: Context + + @MockK(relaxed = true) + lateinit var repositoryRegistry: RepositoryRegistry + + @MockK(relaxed = true) + lateinit var networkParameters: NetworkParameters + + @MockK(relaxed = true) + lateinit var userRepository: UserRepository + + private lateinit var keysRepository: KeysRepository + + @Before + fun setUp() { + MockKAnnotations.init(this, relaxed = true) + + secureStorageProvider = SecureStorageProvider( + FakeKeyStore(), + FakePreferences(), + ) + + keysRepository = KeysRepository( + context, + repositoryRegistry, + networkParameters, + secureStorageProvider, + userRepository, + ) + } + + private fun createTestChallengeKey(version: Int = CHALLENGE_KEY_VERSION_RC_V2): ChallengePublicKey { + val ecKey = ECKey() + val salt = ByteArray(8) { it.toByte() } + return ChallengePublicKey(ecKey.pubKey, salt, version) + } + + // -- Encrypted user key (base private key) -- + + @Test + fun `should store and retrieve encrypted base private key`() { + // Given + val expected = "encrypted-base-private-key-data" + + // When + keysRepository.storeEncryptedBasePrivateKey(expected) + + // Then + val actual = keysRepository.encryptedBasePrivateKey.toBlocking().first() + assertEquals(expected, actual) + } + + @Test + fun `should report encrypted base private key as present after storing`() { + // When + keysRepository.storeEncryptedBasePrivateKey("some-data") + + // Then + assertTrue(keysRepository.hasEncryptedBasePrivateKey) + } + + @Test + fun `should clear encrypted base private key on wipe`() { + // Given + keysRepository.storeEncryptedBasePrivateKey("some-data") + + // When + keysRepository.wipeEncryptedBasePrivateKey() + + // Then + assertFalse(keysRepository.hasEncryptedBasePrivateKey) + } + + // -- Encrypted muun key -- + + @Test + fun `should store and retrieve encrypted muun private key`() { + // Given + val expected = "encrypted-muun-private-key-data" + + // When + keysRepository.storeEncryptedMuunPrivateKey(expected) + + // Then + val actual = keysRepository.encryptedMuunPrivateKey.toBlocking().first() + assertEquals(expected, actual) + } + + @Test + fun `should report encrypted muun private key as present after storing`() { + // When + keysRepository.storeEncryptedMuunPrivateKey("some-data") + + // Then + assertTrue(keysRepository.hasEncryptedMuunPrivateKey) + } + + // -- Challenge keys -- + + @Test + fun `should store and retrieve PASSWORD challenge public key`() { + verifyChallengeKeyRoundTrip(ChallengeType.PASSWORD) + } + + @Test + fun `should store and retrieve RECOVERY_CODE challenge public key`() { + verifyChallengeKeyRoundTrip(ChallengeType.RECOVERY_CODE) + } + + @Test + fun `should store and retrieve USER_KEY challenge public key`() { + verifyChallengeKeyRoundTrip(ChallengeType.USER_KEY) + } + + private fun verifyChallengeKeyRoundTrip(type: ChallengeType) { + // Given + val expected = createTestChallengeKey() + + // When + keysRepository.storePublicChallengeKey(expected, type) + + // Then + val actual = keysRepository.getChallengePublicKey(type).toBlocking().first() + assertEquals(expected.version, actual.version) + assertTrue(expected.salt.contentEquals(actual.salt)) + // No public getter for the raw key bytes, so compare via full serialization + assertTrue(expected.serialize().contentEquals(actual.serialize())) + } + + @Test + fun `should report challenge public key as present after storing`() { + // When + keysRepository.storePublicChallengeKey( + createTestChallengeKey(), + ChallengeType.PASSWORD, + ) + + // Then + assertTrue(keysRepository.hasChallengePublicKey(ChallengeType.PASSWORD)) + } + + @Test + fun `should keep challenge keys independent across types`() { + // When + keysRepository.storePublicChallengeKey( + createTestChallengeKey(), + ChallengeType.PASSWORD, + ) + + // Then + assertTrue(keysRepository.hasChallengePublicKey(ChallengeType.PASSWORD)) + assertFalse(keysRepository.hasChallengePublicKey(ChallengeType.RECOVERY_CODE)) + assertFalse(keysRepository.hasChallengePublicKey(ChallengeType.USER_KEY)) + } + + // -- RC rotation side effect -- + + @Test + fun `should wipe encrypted base key when storing RECOVERY_CODE challenge with existing base key`() { + // Given + secureStorageProvider.put(KEY_BASE_58_PRIVATE_KEY, "dummy-base-key".toByteArray()) + keysRepository.storeEncryptedBasePrivateKey("encrypted-data") + + // When + keysRepository.storePublicChallengeKey( + createTestChallengeKey(), + ChallengeType.RECOVERY_CODE, + ) + + // Then + assertFalse(keysRepository.hasEncryptedBasePrivateKey) + } + + @Test + fun `should not wipe when storing RECOVERY_CODE challenge without base key`() { + // Regression guard for the SecureKeyStorage bridge migration: the `if (hasBasePrivateKey)` + // check in storePublicChallengeKey() exists to skip an unnecessary wipe when the base key + // is absent. This scenario shouldn't happen in production (a logged-in user always has a + // base key), but the guard will be touched when RC rotation moves to Go — this test pins + // its current behavior so a refactor can't silently change it. + + // Given (no base private key in storage) + keysRepository.storeEncryptedBasePrivateKey("encrypted-data") + + // When + keysRepository.storePublicChallengeKey( + createTestChallengeKey(), + ChallengeType.RECOVERY_CODE, + ) + + // Then + assertTrue(keysRepository.hasEncryptedBasePrivateKey) + } + + @Test + fun `should not wipe encrypted base key when storing PASSWORD challenge`() { + // Given + secureStorageProvider.put(KEY_BASE_58_PRIVATE_KEY, "dummy-base-key".toByteArray()) + keysRepository.storeEncryptedBasePrivateKey("encrypted-data") + + // When + keysRepository.storePublicChallengeKey( + createTestChallengeKey(), + ChallengeType.PASSWORD, + ) + + // Then + assertTrue(keysRepository.hasEncryptedBasePrivateKey) + } + + // -- Has checks on empty storage -- + + @Test + fun `should report all keys absent when storage is empty`() { + // Then + assertFalse(keysRepository.hasBasePrivateKey) + assertFalse(keysRepository.hasEncryptedBasePrivateKey) + assertFalse(keysRepository.hasEncryptedMuunPrivateKey) + assertFalse(keysRepository.hasChallengePublicKey(ChallengeType.PASSWORD)) + assertFalse(keysRepository.hasChallengePublicKey(ChallengeType.RECOVERY_CODE)) + assertFalse(keysRepository.hasChallengePublicKey(ChallengeType.USER_KEY)) + } +} diff --git a/android/apolloui/src/test/java/io/muun/apollo/domain/secure_key_value_storage/SecretTest.kt b/android/apolloui/src/test/java/io/muun/apollo/domain/secure_key_value_storage/SecretTest.kt new file mode 100644 index 00000000..949b2d18 --- /dev/null +++ b/android/apolloui/src/test/java/io/muun/apollo/domain/secure_key_value_storage/SecretTest.kt @@ -0,0 +1,70 @@ +package io.muun.apollo.domain.secure_key_value_storage + +import io.muun.apollo.BaseTest +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.catchThrowable +import org.junit.Test + +class SecretTest : BaseTest() { + + @Test + fun `should invoke fn with plaintext bytes`() { + // Given + val expected = listOf(1.toByte(), 2.toByte(), 3.toByte()) + val secret = Secret(byteArrayOf(1, 2, 3)) + + // When + secret.withSecret { bytes -> + // Then + assertThat(bytes.toList()).isEqualTo(expected) + } + } + + @Test + fun `should zero the wrapped bytes after withSecret returns`() { + // Given + val payload = byteArrayOf(1, 2, 3) + val expected = byteArrayOf(0, 0, 0) + val secret = Secret(payload) + + // When + secret.withSecret { /* consume */ } + + // Then + assertThat(payload).isEqualTo(expected) + } + + @Test + fun `should zero the wrapped bytes when fn throws`() { + // Given + val payload = byteArrayOf(1, 2, 3) + val expected = byteArrayOf(0, 0, 0) + val secret = Secret(payload) + + // When + val actual = catchThrowable { + secret.withSecret { throw RuntimeException("boom") } + } + + // Then + assertThat(actual).hasMessage("boom") + assertThat(payload).isEqualTo(expected) + } + + @Test + fun `should throw on second withSecret call`() { + // Given + val secret = Secret(byteArrayOf(1, 2, 3)) + secret.withSecret { /* first call consumes the Secret */ } + + // When + val actual = catchThrowable { + secret.withSecret { /* second call must fail */ } + } + + // Then + assertThat(actual) + .isInstanceOf(IllegalStateException::class.java) + .hasMessage("Secret already consumed") + } +} diff --git a/android/apolloui/src/test/java/io/muun/apollo/domain/utils/FeeWindowTest.kt b/android/apolloui/src/test/java/io/muun/apollo/domain/utils/FeeWindowTest.kt deleted file mode 100644 index fac78daa..00000000 --- a/android/apolloui/src/test/java/io/muun/apollo/domain/utils/FeeWindowTest.kt +++ /dev/null @@ -1,76 +0,0 @@ -package io.muun.apollo.domain.utils - -import io.muun.apollo.BaseTest -import io.muun.apollo.domain.model.FeeWindow -import org.assertj.core.api.Assertions.assertThat -import org.assertj.core.api.Assertions.within -import org.junit.Test -import org.threeten.bp.ZonedDateTime - -class FeeWindowTest: BaseTest() { - - val singleFeeWindow = createFeeWindow(1 to 5.6) - val someFeeWindow = createFeeWindow(2 to 2.3, 5 to 7.2, 20 to 18.7) - - private val margin = 0.000001 - - @Test - fun `returns the fastest fee rate`() { - assertThat(someFeeWindow.fastestFeeInSatoshisPerByte).isEqualTo(2.3) - } - - @Test - fun `returns the only fee rate as fastest`() { - assertThat(singleFeeWindow.fastestFeeInSatoshisPerByte).isEqualTo(5.6) - } - - @Test - fun `returns the exact target as closest, if present`() { - val targetedFees = someFeeWindow.targetedFees - - for ((target, expectedRate) in targetedFees.entries) { - val rate = someFeeWindow.getMinimumFeeInSatoshisPerByte(target) - - assertThat(rate).isCloseTo(expectedRate, within(margin)) - } - } - - @Test - fun `returns the closest lower target`() { - assertThat(someFeeWindow.getMinimumFeeInSatoshisPerByte(4)) - .isCloseTo(2.3, within(margin)) - - assertThat(someFeeWindow.getMinimumFeeInSatoshisPerByte(15)) - .isCloseTo(7.2, within(margin)) - - assertThat(someFeeWindow.getMinimumFeeInSatoshisPerByte(22)) - .isCloseTo(18.7, within(margin)) - } - - @Test - fun `returns the lowest target by default`() { - assertThat(someFeeWindow.getMinimumFeeInSatoshisPerByte(1)) - .isCloseTo(2.3, within(margin)) - } - - @Test - fun `returns the only fee rate as closest`() { - for (i in listOf(1, 6, 18, 24)) { - assertThat(singleFeeWindow.getMinimumFeeInSatoshisPerByte(i)) - .isCloseTo(5.6, within(margin)) - } - } - - @Test(expected = IllegalArgumentException::class) - fun `fails check when confirmation target is 0`() { - someFeeWindow.getMinimumFeeInSatoshisPerByte(0) - } - - @Test(expected = IllegalArgumentException::class) - fun `fails check when confirmation target is negative`() { - someFeeWindow.getMinimumFeeInSatoshisPerByte(-1) - } - - private fun createFeeWindow(vararg targetToRate: Pair) = - FeeWindow(1, ZonedDateTime.now(), mapOf(*targetToRate), 1, 1, 1) -} \ No newline at end of file diff --git a/android/apolloui/src/test/java/io/muun/apollo/presentation/presenters/CreateEmailPresenterTest.kt b/android/apolloui/src/test/java/io/muun/apollo/presentation/presenters/CreateEmailPresenterTest.kt index 1e19897e..1e82ae58 100644 --- a/android/apolloui/src/test/java/io/muun/apollo/presentation/presenters/CreateEmailPresenterTest.kt +++ b/android/apolloui/src/test/java/io/muun/apollo/presentation/presenters/CreateEmailPresenterTest.kt @@ -1,7 +1,13 @@ package io.muun.apollo.presentation.presenters import android.os.Bundle -import com.nhaarman.mockitokotlin2.* +import com.nhaarman.mockitokotlin2.any +import com.nhaarman.mockitokotlin2.doReturn +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.never +import com.nhaarman.mockitokotlin2.times +import com.nhaarman.mockitokotlin2.verify +import com.nhaarman.mockitokotlin2.verifyNoMoreInteractions import io.muun.apollo.domain.action.base.ActionState import io.muun.apollo.presentation.BasePresentationTest import io.muun.apollo.presentation.ui.fragments.enter_email.CreateEmailParentPresenter @@ -14,9 +20,9 @@ import rx.android.plugins.RxAndroidSchedulersHook import rx.schedulers.Schedulers import rx.subjects.BehaviorSubject -class CreateEmailPresenterTest: BasePresentationTest() { +class CreateEmailPresenterTest : BasePresentationTest() { - private class TestRxAndroidSchedulerHook: RxAndroidSchedulersHook() { + private class TestRxAndroidSchedulerHook : RxAndroidSchedulersHook() { override fun getMainThreadScheduler(): rx.Scheduler? { return Schedulers.trampoline() } @@ -34,7 +40,7 @@ class CreateEmailPresenterTest: BasePresentationTest() { @Test fun `should subscribe to parent result`() { - val (presenter, parent, submitEmailResult) = sharedInit() + val (presenter, parent, _) = sharedInit() presenter.setUp(Bundle()) verify(parent, times(1)).watchSubmitEmail() @@ -42,7 +48,7 @@ class CreateEmailPresenterTest: BasePresentationTest() { @Test fun `should display loading while waiting`() { - val (presenter, parent, submitEmailResult) = sharedInit() + val (presenter, _, submitEmailResult) = sharedInit() presenter.setUp(Bundle()) submitEmailResult.onNext(ActionState.createLoading()) @@ -52,7 +58,7 @@ class CreateEmailPresenterTest: BasePresentationTest() { @Test fun `should stop on error`() { - val (presenter, parent, submitEmailResult) = sharedInit() + val (presenter, _, submitEmailResult) = sharedInit() val error = Throwable() presenter.setUp(Bundle()) @@ -63,7 +69,7 @@ class CreateEmailPresenterTest: BasePresentationTest() { @Test fun `should not submit invalid emails`() { - val (presenter, parent, submitEmailResult) = sharedInit() + val (presenter, parent, _) = sharedInit() presenter.submitEmail("") presenter.submitEmail("xx") @@ -75,7 +81,7 @@ class CreateEmailPresenterTest: BasePresentationTest() { @Test fun `should submit valid emails`() { - val (presenter, parent, submitEmailResult) = sharedInit() + val (presenter, parent, _) = sharedInit() presenter.submitEmail("a@b.c") @@ -86,7 +92,7 @@ class CreateEmailPresenterTest: BasePresentationTest() { private data class SharedInit( val presenter: CreateEmailPresenter, val parent: CreateEmailParentPresenter, - val submitEmailResult: BehaviorSubject> + val submitEmailResult: BehaviorSubject>, ) /** @@ -96,12 +102,13 @@ class CreateEmailPresenterTest: BasePresentationTest() { val submitEmailResult = BehaviorSubject.create>() val parent = mock { - on { watchSubmitEmail() } doReturn(submitEmailResult) + on { watchSubmitEmail() } doReturn (submitEmailResult) } - val presenter = object: CreateEmailPresenter() { + val presenter = object : CreateEmailPresenter() { override fun setUpDeprecatedClientVersionCheck() {} override fun setUpSessionExpiredCheck() {} + override fun setUpBuildErrorEmailReportAction() {} override fun setUpNetworkInfo() {} override fun handleError(error: Throwable) {} } diff --git a/android/apolloui/src/test/java/io/muun/apollo/presentation/ui/security_cards_shipping_address/AnimationNodeTest.kt b/android/apolloui/src/test/java/io/muun/apollo/presentation/ui/security_cards_shipping_address/AnimationNodeTest.kt new file mode 100644 index 00000000..ca97de60 --- /dev/null +++ b/android/apolloui/src/test/java/io/muun/apollo/presentation/ui/security_cards_shipping_address/AnimationNodeTest.kt @@ -0,0 +1,304 @@ +package io.muun.apollo.presentation.ui.security_cards_shipping_address + +import io.mockk.mockk +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test + +class AnimationNodeTest { + + private fun scope() = HeroAnimationScope(mockk(relaxed = true)) + + private fun buildSequential(vararg nodes: AnimationNode): AnimationNode { + val scope = scope() + scope.nodes.addAll(nodes.toList()) + return scope.inSequence { nodes.forEach { this.nodes.add(it) } }.also { + scope.nodes.clear() + } + } + + private fun buildParallel(vararg nodes: AnimationNode): AnimationNode { + val scope = scope() + scope.nodes.addAll(nodes.toList()) + return scope.inParallel { nodes.forEach { this.nodes.add(it) } }.also { + scope.nodes.clear() + } + } + + @Test + fun `sequential plays children in order`() { + val log = mutableListOf() + val a = TestAnimationNode("a", log) + val b = TestAnimationNode("b", log) + val c = TestAnimationNode("c", log) + + val seq = buildSequential(a, b, c) + seq.play() + + assertThat(log).containsExactly("a:start") + + a.finish() + assertThat(log).containsExactly("a:start", "a:end", "b:start") + + b.finish() + assertThat(log).containsExactly("a:start", "a:end", "b:start", "b:end", "c:start") + + c.finish() + assertThat(log).containsExactly( + "a:start", "a:end", "b:start", "b:end", "c:start", "c:end", + ) + } + + @Test + fun `parallel plays all children concurrently`() { + val log = mutableListOf() + val a = TestAnimationNode("a", log) + val b = TestAnimationNode("b", log) + + val par = buildParallel(a, b) + par.play() + + assertThat(log).containsExactly("a:start", "b:start") + + a.finish() + assertThat(log).containsExactly("a:start", "b:start", "a:end") + + b.finish() + assertThat(log).containsExactly("a:start", "b:start", "a:end", "b:end") + } + + @Test + fun `parallel completes when last child finishes`() { + val a = TestAnimationNode("a") + val b = TestAnimationNode("b") + val par = buildParallel(a, b) + + var completed = false + par.onEndAnimation { completed = true } + par.play() + + a.finish() + assertThat(completed).isFalse() + + b.finish() + assertThat(completed).isTrue() + } + + @Test + fun `sequential completes when last child finishes`() { + val a = TestAnimationNode("a") + val b = TestAnimationNode("b") + val seq = buildSequential(a, b) + + var completed = false + seq.onEndAnimation { completed = true } + seq.play() + + a.finish() + assertThat(completed).isFalse() + + b.finish() + assertThat(completed).isTrue() + } + + @Test + fun `onEndAnimation callbacks compose`() { + val node = TestAnimationNode("n") + val log = mutableListOf() + + node.onEndAnimation { log.add("first") } + node.onEndAnimation { log.add("second") } + node.play() + node.finish() + + assertThat(log).containsExactly("first", "second") + } + + @Test + fun `onStartAnimation callbacks compose`() { + val node = TestAnimationNode("n") + val log = mutableListOf() + + node.onStartAnimation { log.add("first") } + node.onStartAnimation { log.add("second") } + node.play() + + assertThat(log).containsExactly("first", "second") + } + + @Test + fun `cancel prevents callbacks from firing`() { + val node = TestAnimationNode("n") + var startFired = false + var endFired = false + + node.onStartAnimation { startFired = true } + node.onEndAnimation { endFired = true } + node.cancel() + node.play() + + assertThat(startFired).isFalse() + assertThat(endFired).isFalse() + } + + @Test + fun `cancel propagates to parallel children`() { + val a = TestAnimationNode("a") + val b = TestAnimationNode("b") + val par = buildParallel(a, b) + + var aEnded = false + var bEnded = false + a.onEndAnimation { aEnded = true } + b.onEndAnimation { bEnded = true } + par.play() + par.cancel() + + a.finish() + b.finish() + assertThat(aEnded).isFalse() + assertThat(bEnded).isFalse() + } + + @Test + fun `cancel propagates to sequential children`() { + val a = TestAnimationNode("a") + val b = TestAnimationNode("b") + val seq = buildSequential(a, b) + + var seqEnded = false + seq.onEndAnimation { seqEnded = true } + seq.play() + seq.cancel() + + a.finish() + assertThat(seqEnded).isFalse() + assertThat(b.isAnimationPlaying).isFalse() + } + + @Test + fun `cancel is idempotent`() { + val node = TestAnimationNode("n") + node.cancel() + node.cancel() + } + + @Test + fun `isAnimationPlaying reflects lifecycle`() { + val node = TestAnimationNode("n") + assertThat(node.isAnimationPlaying).isFalse() + + node.play() + assertThat(node.isAnimationPlaying).isTrue() + + node.finish() + assertThat(node.isAnimationPlaying).isFalse() + } + + @Test + fun `noop fires onEndAnimation immediately`() { + var fired = false + val handle = HeroAnimationScope.noop() + handle.onEndAnimation { fired = true } + + assertThat(fired).isTrue() + } + + @Test + fun `noop fires onStartAnimation immediately`() { + var fired = false + val handle = HeroAnimationScope.noop() + handle.onStartAnimation { fired = true } + + assertThat(fired).isTrue() + } + + @Test + fun `noop is not playing`() { + val handle = HeroAnimationScope.noop() + assertThat(handle.isAnimationPlaying).isFalse() + } + + @Test + fun `empty parallel completes immediately`() { + val par = buildParallel() + var completed = false + par.onEndAnimation { completed = true } + par.play() + + assertThat(completed).isTrue() + } + + @Test + fun `empty sequential completes immediately`() { + val seq = buildSequential() + var completed = false + seq.onEndAnimation { completed = true } + seq.play() + + assertThat(completed).isTrue() + } + + @Test + fun `nested composites complete correctly`() { + val a = TestAnimationNode("a") + val b = TestAnimationNode("b") + val c = TestAnimationNode("c") + + val inner = buildSequential(a, b) + val outer = buildParallel(inner, c) + + var completed = false + outer.onEndAnimation { completed = true } + outer.play() + + a.finish() + assertThat(completed).isFalse() + + c.finish() + assertThat(completed).isFalse() + + b.finish() + assertThat(completed).isTrue() + } + + @Test + fun `per-node onEndAnimation fires at correct time in sequence`() { + val a = TestAnimationNode("a") + val b = TestAnimationNode("b") + val log = mutableListOf() + + a.onEndAnimation { log.add("a:callback") } + b.onEndAnimation { log.add("b:callback") } + + val seq = buildSequential(a, b) + seq.onEndAnimation { log.add("seq:callback") } + seq.play() + + a.finish() + assertThat(log).containsExactly("a:callback") + + b.finish() + assertThat(log).containsExactly("a:callback", "b:callback", "seq:callback") + } +} + +private class TestAnimationNode( + private val name: String, + private val log: MutableList = mutableListOf(), +) : AnimationNode() { + private var doOnEnd: (() -> Unit)? = null + + override fun doPlay(doOnAnimationStart: () -> Unit, doOnAnimationEnd: () -> Unit) { + doOnAnimationStart() + log.add("$name:start") + doOnEnd = { + log.add("$name:end") + doOnAnimationEnd() + } + } + + fun finish() { + doOnEnd?.invoke() + doOnEnd = null + } +} diff --git a/build.gradle b/build.gradle index af0e368d..6fb6ce08 100644 --- a/build.gradle +++ b/build.gradle @@ -43,6 +43,7 @@ subprojects { global_version_dagger = '2.52' global_version_mockito = '3.8.0' + global_version_assertj = '3.9.1' global_version_moneta = '1.1' global_version_jackson_money = '1.0.0' diff --git a/common/build.gradle b/common/build.gradle index 474f0008..bbed4637 100644 --- a/common/build.gradle +++ b/common/build.gradle @@ -38,10 +38,13 @@ ext { } dependencies { - api 'javax.validation:validation-api:1.1.0.Final' - api 'org.hibernate:hibernate-validator:5.4.2.Final' - implementation 'com.fasterxml.jackson.core:jackson-annotations:2.9.2' - implementation 'com.fasterxml.jackson.core:jackson-databind:2.9.4' + // Validation annotation API only (javax.validation.* namespace). The hibernate-validator + // engine lives server-side via dropwizard-validation; common must NOT export it: apolloui + // (minSdk 21) cannot dex hibernate-validator 6.x's MethodHandle.invoke usage. + api 'jakarta.validation:jakarta.validation-api:2.0.2' + // Updated to match Dropwizard 2.1.12's Jackson 2.13.5 + implementation 'com.fasterxml.jackson.core:jackson-annotations:2.13.5' + implementation 'com.fasterxml.jackson.core:jackson-databind:2.13.5' // networking implementation "com.squareup.retrofit2:retrofit:$version_retrofit" @@ -50,13 +53,17 @@ dependencies { implementation 'io.reactivex:rxjava:1.3.8' // bitcoin - api 'com.github.muun:bitcoinj:0.15.4-taproot' + api 'com.github.muun:bitcoinj:0.15.4-taproot-sighash-default' // Previously bundled with bitcoinj, no longer so we manually add it api 'com.google.code.findbugs:jsr305:3.0.2' api 'net.jcip:jcip-annotations:1.0' api 'com.google.guava:guava:26.0-android' + // Static-analysis annotations + compileOnly 'org.jetbrains:annotations:19.0.0' + compileOnly 'org.checkerframework:checker-qual:3.53.0' + // cryptography api 'org.bouncycastle:bcprov-jdk15to18:1.63' // SHOULD match bitcoinJ's version api 'com.lambdaworks:scrypt:1.4.0' @@ -72,7 +79,7 @@ dependencies { // Testing dependencies testImplementation 'junit:junit:4.12' - testImplementation 'org.assertj:assertj-core:3.9.1' + testImplementation "org.assertj:assertj-core:$global_version_assertj" // to silence bitcoinj's slf4j testImplementation "org.mockito:mockito-core:$global_version_mockito" @@ -91,4 +98,4 @@ tasks.withType(AbstractArchiveTask) { // a fully signature based on Android api level 21. animalsniffer { ignore 'java.nio.ByteBuffer' -} \ No newline at end of file +} diff --git a/common/src/main/java/io/muun/common/Optional.java b/common/src/main/java/io/muun/common/Optional.java index b8944b04..696eba56 100644 --- a/common/src/main/java/io/muun/common/Optional.java +++ b/common/src/main/java/io/muun/common/Optional.java @@ -40,7 +40,7 @@ public static Optional of(ValueT value) { /** * Optional with the given value if it's not null or empty optional otherwise. */ - public static Optional ofNullable(ValueT value) { + public static Optional ofNullable(@Nullable ValueT value) { return value == null ? Optional.empty() : of(value); } @@ -281,8 +281,10 @@ public interface Consumer { public interface Mapper { /** - * Transform an object to another. + * Transform an object to another. May return null: {@link Optional#map} turns it into + * an empty optional, while {@link Optional#flatMap} rejects it at runtime. */ + @Nullable ResultT map(SourceT source); } } diff --git a/common/src/main/java/io/muun/common/api/BackgroundEventJson.java b/common/src/main/java/io/muun/common/api/BackgroundEventJson.java index 9e5e3c3e..b9812a61 100644 --- a/common/src/main/java/io/muun/common/api/BackgroundEventJson.java +++ b/common/src/main/java/io/muun/common/api/BackgroundEventJson.java @@ -3,8 +3,8 @@ import com.fasterxml.jackson.annotation.JsonAlias; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; -import org.hibernate.validator.constraints.NotEmpty; +import javax.validation.constraints.NotEmpty; import javax.validation.constraints.NotNull; @JsonInclude(JsonInclude.Include.NON_NULL) diff --git a/common/src/main/java/io/muun/common/api/ClientJson.java b/common/src/main/java/io/muun/common/api/ClientJson.java index 56e63ec0..ee370318 100644 --- a/common/src/main/java/io/muun/common/api/ClientJson.java +++ b/common/src/main/java/io/muun/common/api/ClientJson.java @@ -7,12 +7,12 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; -import org.hibernate.validator.constraints.NotEmpty; import java.util.List; import java.util.Map; import javax.annotation.Nonnegative; import javax.annotation.Nullable; +import javax.validation.constraints.NotEmpty; import javax.validation.constraints.NotNull; @JsonInclude(JsonInclude.Include.NON_NULL) diff --git a/common/src/main/java/io/muun/common/api/DescendantFeesJson.java b/common/src/main/java/io/muun/common/api/DescendantFeesJson.java index 1e27348d..3c6b3fa4 100644 --- a/common/src/main/java/io/muun/common/api/DescendantFeesJson.java +++ b/common/src/main/java/io/muun/common/api/DescendantFeesJson.java @@ -2,9 +2,9 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; -import org.hibernate.validator.constraints.NotEmpty; import java.util.Objects; +import javax.validation.constraints.NotEmpty; import javax.validation.constraints.NotNull; @JsonInclude(JsonInclude.Include.NON_NULL) diff --git a/common/src/main/java/io/muun/common/api/FeeBumpFunctionsJson.java b/common/src/main/java/io/muun/common/api/FeeBumpFunctionsJson.java index 69698384..56ee57ba 100644 --- a/common/src/main/java/io/muun/common/api/FeeBumpFunctionsJson.java +++ b/common/src/main/java/io/muun/common/api/FeeBumpFunctionsJson.java @@ -2,9 +2,9 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; -import org.hibernate.validator.constraints.NotEmpty; import java.util.List; +import javax.validation.constraints.NotEmpty; @JsonInclude(JsonInclude.Include.NON_NULL) @JsonIgnoreProperties(ignoreUnknown = true) diff --git a/common/src/main/java/io/muun/common/api/HardwareWalletJson.java b/common/src/main/java/io/muun/common/api/HardwareWalletJson.java index 6da2e895..3df98e68 100644 --- a/common/src/main/java/io/muun/common/api/HardwareWalletJson.java +++ b/common/src/main/java/io/muun/common/api/HardwareWalletJson.java @@ -5,9 +5,9 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; -import org.hibernate.validator.constraints.NotEmpty; import javax.annotation.Nullable; +import javax.validation.constraints.NotEmpty; import javax.validation.constraints.NotNull; @JsonInclude(JsonInclude.Include.NON_NULL) diff --git a/common/src/main/java/io/muun/common/api/KeySet.java b/common/src/main/java/io/muun/common/api/KeySet.java index 889b2fd9..733cad23 100644 --- a/common/src/main/java/io/muun/common/api/KeySet.java +++ b/common/src/main/java/io/muun/common/api/KeySet.java @@ -6,11 +6,11 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; -import org.hibernate.validator.constraints.NotEmpty; import java.util.List; import java.util.Map; import javax.annotation.Nullable; +import javax.validation.constraints.NotEmpty; @JsonInclude(JsonInclude.Include.NON_NULL) @JsonIgnoreProperties(ignoreUnknown = true) diff --git a/common/src/main/java/io/muun/common/api/PartiallySignedTransactionJson.java b/common/src/main/java/io/muun/common/api/PartiallySignedTransactionJson.java index 8f01486f..e1cb8ba5 100644 --- a/common/src/main/java/io/muun/common/api/PartiallySignedTransactionJson.java +++ b/common/src/main/java/io/muun/common/api/PartiallySignedTransactionJson.java @@ -2,9 +2,9 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; -import org.hibernate.validator.constraints.NotEmpty; import java.util.List; +import javax.validation.constraints.NotEmpty; import javax.validation.constraints.NotNull; @JsonInclude(JsonInclude.Include.NON_NULL) diff --git a/common/src/main/java/io/muun/common/api/PasswordSetupJson.java b/common/src/main/java/io/muun/common/api/PasswordSetupJson.java index 22218d42..7a82cbca 100644 --- a/common/src/main/java/io/muun/common/api/PasswordSetupJson.java +++ b/common/src/main/java/io/muun/common/api/PasswordSetupJson.java @@ -2,8 +2,8 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; -import org.hibernate.validator.constraints.NotEmpty; +import javax.validation.constraints.NotEmpty; import javax.validation.constraints.NotNull; @JsonInclude(JsonInclude.Include.NON_NULL) diff --git a/common/src/main/java/io/muun/common/api/PhoneNumberJson.java b/common/src/main/java/io/muun/common/api/PhoneNumberJson.java index 634cb137..1167aabd 100644 --- a/common/src/main/java/io/muun/common/api/PhoneNumberJson.java +++ b/common/src/main/java/io/muun/common/api/PhoneNumberJson.java @@ -4,8 +4,8 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; -import org.hibernate.validator.constraints.NotEmpty; +import javax.validation.constraints.NotEmpty; import javax.validation.constraints.NotNull; @JsonInclude(JsonInclude.Include.NON_NULL) diff --git a/common/src/main/java/io/muun/common/api/PublicKeyJson.java b/common/src/main/java/io/muun/common/api/PublicKeyJson.java index 5960897e..b559de29 100644 --- a/common/src/main/java/io/muun/common/api/PublicKeyJson.java +++ b/common/src/main/java/io/muun/common/api/PublicKeyJson.java @@ -2,7 +2,8 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; -import org.hibernate.validator.constraints.NotEmpty; + +import javax.validation.constraints.NotEmpty; @JsonInclude(JsonInclude.Include.NON_NULL) @JsonIgnoreProperties(ignoreUnknown = true) diff --git a/common/src/main/java/io/muun/common/api/PublicProfileJson.java b/common/src/main/java/io/muun/common/api/PublicProfileJson.java index 27bc393e..1b3961d8 100644 --- a/common/src/main/java/io/muun/common/api/PublicProfileJson.java +++ b/common/src/main/java/io/muun/common/api/PublicProfileJson.java @@ -2,9 +2,9 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; -import org.hibernate.validator.constraints.NotEmpty; import javax.annotation.Nullable; +import javax.validation.constraints.NotEmpty; import javax.validation.constraints.NotNull; @JsonInclude(JsonInclude.Include.NON_NULL) diff --git a/common/src/main/java/io/muun/common/api/RawTransaction.java b/common/src/main/java/io/muun/common/api/RawTransaction.java index 733eb820..8f2b9227 100644 --- a/common/src/main/java/io/muun/common/api/RawTransaction.java +++ b/common/src/main/java/io/muun/common/api/RawTransaction.java @@ -2,7 +2,8 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; -import org.hibernate.validator.constraints.NotEmpty; + +import javax.validation.constraints.NotEmpty; @JsonInclude(JsonInclude.Include.NON_NULL) @JsonIgnoreProperties(ignoreUnknown = true) diff --git a/common/src/main/java/io/muun/common/api/SendEncryptedKeysJson.java b/common/src/main/java/io/muun/common/api/SendEncryptedKeysJson.java index f6094e40..85caa0b8 100644 --- a/common/src/main/java/io/muun/common/api/SendEncryptedKeysJson.java +++ b/common/src/main/java/io/muun/common/api/SendEncryptedKeysJson.java @@ -2,7 +2,8 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; -import org.hibernate.validator.constraints.NotEmpty; + +import javax.validation.constraints.NotEmpty; @JsonInclude(JsonInclude.Include.NON_NULL) @JsonIgnoreProperties(ignoreUnknown = true) diff --git a/common/src/main/java/io/muun/common/api/SessionJson.java b/common/src/main/java/io/muun/common/api/SessionJson.java index 70a46f22..95a49457 100644 --- a/common/src/main/java/io/muun/common/api/SessionJson.java +++ b/common/src/main/java/io/muun/common/api/SessionJson.java @@ -2,10 +2,10 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; -import org.hibernate.validator.constraints.NotEmpty; import javax.annotation.Nonnegative; import javax.annotation.Nullable; +import javax.validation.constraints.NotEmpty; @JsonInclude(JsonInclude.Include.NON_NULL) @JsonIgnoreProperties(ignoreUnknown = true) diff --git a/common/src/main/java/io/muun/common/api/StartEmailSetupJson.java b/common/src/main/java/io/muun/common/api/StartEmailSetupJson.java index 510aab1a..11328a2b 100644 --- a/common/src/main/java/io/muun/common/api/StartEmailSetupJson.java +++ b/common/src/main/java/io/muun/common/api/StartEmailSetupJson.java @@ -2,8 +2,8 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; -import org.hibernate.validator.constraints.NotEmpty; +import javax.validation.constraints.NotEmpty; import javax.validation.constraints.NotNull; @JsonInclude(JsonInclude.Include.NON_NULL) diff --git a/common/src/main/java/io/muun/common/api/SubmarineSwapRequestJson.java b/common/src/main/java/io/muun/common/api/SubmarineSwapRequestJson.java index e2a1218f..d6e40b84 100644 --- a/common/src/main/java/io/muun/common/api/SubmarineSwapRequestJson.java +++ b/common/src/main/java/io/muun/common/api/SubmarineSwapRequestJson.java @@ -2,10 +2,10 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; -import org.hibernate.validator.constraints.NotEmpty; import java.util.List; import javax.annotation.Nullable; +import javax.validation.constraints.NotEmpty; import javax.validation.constraints.NotNull; @JsonInclude(JsonInclude.Include.NON_NULL) diff --git a/common/src/main/java/io/muun/common/api/UserInvoiceJson.java b/common/src/main/java/io/muun/common/api/UserInvoiceJson.java index 0309f2d5..e1458dc8 100644 --- a/common/src/main/java/io/muun/common/api/UserInvoiceJson.java +++ b/common/src/main/java/io/muun/common/api/UserInvoiceJson.java @@ -2,8 +2,8 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; -import org.hibernate.validator.constraints.NotEmpty; +import javax.validation.constraints.NotEmpty; import javax.validation.constraints.NotNull; @JsonInclude(JsonInclude.Include.NON_NULL) diff --git a/common/src/main/java/io/muun/common/api/UserProfileJson.java b/common/src/main/java/io/muun/common/api/UserProfileJson.java index a5690839..cf9c8e63 100644 --- a/common/src/main/java/io/muun/common/api/UserProfileJson.java +++ b/common/src/main/java/io/muun/common/api/UserProfileJson.java @@ -2,9 +2,9 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; -import org.hibernate.validator.constraints.NotEmpty; import javax.annotation.Nullable; +import javax.validation.constraints.NotEmpty; @JsonInclude(JsonInclude.Include.NON_NULL) @JsonIgnoreProperties(ignoreUnknown = true) diff --git a/common/src/main/java/io/muun/common/api/WatchedPhoneNumber.java b/common/src/main/java/io/muun/common/api/WatchedPhoneNumber.java index bbcba39b..99cc1c99 100644 --- a/common/src/main/java/io/muun/common/api/WatchedPhoneNumber.java +++ b/common/src/main/java/io/muun/common/api/WatchedPhoneNumber.java @@ -2,7 +2,8 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; -import org.hibernate.validator.constraints.NotEmpty; + +import javax.validation.constraints.NotEmpty; @JsonInclude(JsonInclude.Include.NON_NULL) @JsonIgnoreProperties(ignoreUnknown = true) diff --git a/common/src/main/java/io/muun/common/api/beam/notification/NotificationJson.java b/common/src/main/java/io/muun/common/api/beam/notification/NotificationJson.java index ce4cf78c..8d8f0e5e 100644 --- a/common/src/main/java/io/muun/common/api/beam/notification/NotificationJson.java +++ b/common/src/main/java/io/muun/common/api/beam/notification/NotificationJson.java @@ -2,9 +2,9 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; -import org.hibernate.validator.constraints.NotEmpty; import javax.annotation.Nullable; +import javax.validation.constraints.NotEmpty; import javax.validation.constraints.NotNull; @JsonInclude(JsonInclude.Include.NON_NULL) diff --git a/common/src/main/java/io/muun/common/api/beam/notification/NotificationReportJson.java b/common/src/main/java/io/muun/common/api/beam/notification/NotificationReportJson.java index 76194e3d..1307e0bb 100644 --- a/common/src/main/java/io/muun/common/api/beam/notification/NotificationReportJson.java +++ b/common/src/main/java/io/muun/common/api/beam/notification/NotificationReportJson.java @@ -2,9 +2,9 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; -import org.hibernate.validator.constraints.NotEmpty; import java.util.List; +import javax.validation.constraints.NotEmpty; import javax.validation.constraints.NotNull; @JsonInclude(JsonInclude.Include.NON_NULL) diff --git a/common/src/main/java/io/muun/common/api/error/ErrorCode.java b/common/src/main/java/io/muun/common/api/error/ErrorCode.java index 465b5c7b..99990dd8 100644 --- a/common/src/main/java/io/muun/common/api/error/ErrorCode.java +++ b/common/src/main/java/io/muun/common/api/error/ErrorCode.java @@ -409,6 +409,11 @@ public enum ErrorCode implements BaseErrorCode { ), INVALID_DERIVATION_PATH( 2125, StatusCode.CLIENT_FAILURE, "Derivation path is invalid" + ), + + // nfc errors + NFC_FEASIBLE_AREA_NOT_FOUND( + 8126, StatusCode.CLIENT_FAILURE, "No NFC feasible area found for device model" ); static { diff --git a/common/src/main/java/io/muun/common/crypto/hd/MuunAddress.java b/common/src/main/java/io/muun/common/crypto/hd/MuunAddress.java index 9e5aaeaa..7cf8f64f 100644 --- a/common/src/main/java/io/muun/common/crypto/hd/MuunAddress.java +++ b/common/src/main/java/io/muun/common/crypto/hd/MuunAddress.java @@ -21,7 +21,9 @@ public class MuunAddress { public static final int VERSION_COSIGNED_P2TR_MUSIG2_100 = 6; - public static final int VERSION_FUNDING_P2WSH = 7; + public static final int VERSION_TO_CLIENT_P2SH_P2WSH = 7; + + public static final int VERSION_TO_CLIENT_P2WSH = 8; public static final int VERSION_SUBMARINE_SWAP_V1 = 101; diff --git a/common/src/main/java/io/muun/common/utils/Preconditions.java b/common/src/main/java/io/muun/common/utils/Preconditions.java index 44ebaa99..71a4e7b3 100644 --- a/common/src/main/java/io/muun/common/utils/Preconditions.java +++ b/common/src/main/java/io/muun/common/utils/Preconditions.java @@ -14,7 +14,8 @@ package io.muun.common.utils; -import javax.annotation.Nonnull; +import org.jetbrains.annotations.Contract; + import javax.annotation.Nullable; /** @@ -161,7 +162,8 @@ public static void checkState(boolean expression, @Nullable Object errorMessage) * @return the non-null reference that was validated * @throws NullPointerException if {@code reference} is null */ - @Nonnull + // NullAway only honors the "null -> fail" clause; "!null -> param1" is for IntelliJ. + @Contract(value = "null -> fail; !null -> param1", pure = true) public static T checkNotNull(@Nullable T reference) { if (reference == null) { throw new NullPointerException(); @@ -178,7 +180,9 @@ public static T checkNotNull(@Nullable T reference) { * @return the non-null reference that was validated * @throws NullPointerException if {@code reference} is null */ - public static T checkNotNull(T reference, @Nullable Object errorMessage) { + // NullAway only honors the "null, _ -> fail" clause; "!null, _ -> param1" is for IntelliJ. + @Contract(value = "null, _ -> fail; !null, _ -> param1") + public static T checkNotNull(@Nullable T reference, @Nullable Object errorMessage) { if (reference == null) { throw new NullPointerException(String.valueOf(errorMessage)); } @@ -192,7 +196,6 @@ public static T checkNotNull(T reference, @Nullable Object errorMessage) { * @return the non-null reference that was validated * @throws NullPointerException if {@code reference} is null */ - @Nonnull public static String checkNotNullOrEmpty(@Nullable String reference) { if (reference == null || reference.isEmpty()) { throw new IllegalArgumentException(); @@ -207,7 +210,6 @@ public static String checkNotNullOrEmpty(@Nullable String reference) { * @return the non-null reference that was validated * @throws NullPointerException if {@code reference} is null */ - @Nonnull public static String checkNotNullOrEmpty(@Nullable String reference, String errorMessage) { if (reference == null || reference.isEmpty()) { throw new IllegalArgumentException(errorMessage); @@ -237,7 +239,7 @@ public static T checkNotNullOnlyIf(@Nullable T reference, boolean condition) * @return the null reference that was validated * @throws IllegalArgumentException if {@code reference} is not null */ - public static T checkNull(T reference) { + public static T checkNull(@Nullable T reference) { if (reference != null) { throw new IllegalArgumentException("Expected " + reference + " to be null"); } @@ -253,7 +255,7 @@ public static T checkNull(T reference) { * @return the null reference that was validated * @throws IllegalArgumentException if {@code reference} is not null */ - public static T checkNull(T reference, @Nullable Object errorMessage) { + public static T checkNull(@Nullable T reference, @Nullable Object errorMessage) { if (reference != null) { throw new IllegalArgumentException(String.valueOf(errorMessage)); } diff --git a/common/src/main/java/io/muun/package-info.java b/common/src/main/java/io/muun/package-info.java new file mode 100644 index 00000000..60539037 --- /dev/null +++ b/common/src/main/java/io/muun/package-info.java @@ -0,0 +1,20 @@ +/** + * Default nullability for this module: un-annotated parameters, returns and fields are non-null + * unless annotated {@code @Nullable}. + * + *

This makes IntelliJ's nullability analysis match NullAway, which already applies this default + * to {@code AnnotatedPackages=io.muun} (see linters/errorprone/check.gradle). IntelliJ only picks + * up package defaults from package-info files in the same source root, so each module carries an + * identical copy of this file. It only affects IDE analysis; enforcement happens at build time via + * NullAway. + */ +@DefaultQualifier( + value = Nonnull.class, + locations = {TypeUseLocation.PARAMETER, TypeUseLocation.RETURN, TypeUseLocation.FIELD} +) +package io.muun; + +import org.checkerframework.framework.qual.DefaultQualifier; +import org.checkerframework.framework.qual.TypeUseLocation; + +import javax.annotation.Nonnull; diff --git a/common/src/test/java/io/muun/common/model/BtcAmountTest.java b/common/src/test/java/io/muun/common/model/BtcAmountTest.java index 1d60c266..47db96c1 100644 --- a/common/src/test/java/io/muun/common/model/BtcAmountTest.java +++ b/common/src/test/java/io/muun/common/model/BtcAmountTest.java @@ -1,11 +1,11 @@ package io.muun.common.model; -import org.assertj.core.util.Lists; import org.junit.Test; import org.junit.runner.RunWith; import org.junit.runners.Parameterized; import org.junit.runners.Parameterized.Parameters; +import java.util.ArrayList; import java.util.List; import static io.muun.common.model.BtcAmount.MSATS_PER_BTC; @@ -48,7 +48,7 @@ public void fromAmountToBtcMinimumResolution() { */ @Parameters(name = "{index}: Amount {0,number,0.00000000000} btc") public static Iterable allOrdersOfMagnitude() { - final List values = Lists.newArrayList(); + final List values = new ArrayList<>(); for (int i = 1; i < DECIMAL_DIGITS; i++) { values.add(multipleOfOneMilliSatoshi(0, i)); diff --git a/libwallet/CLAUDE.md b/libwallet/CLAUDE.md new file mode 100644 index 00000000..036e8078 --- /dev/null +++ b/libwallet/CLAUDE.md @@ -0,0 +1,42 @@ +# Libwallet + +Local backend for the mobile apps (Android & iOS). Treat it like backend code. + +## Conventions + +The conventions for this codebase are documented in the review guidance, which is also what the +automated PR review uses. Read these before writing or reviewing libwallet code: + +- **General Go**: `../.github/review/sources/go/review-go-style.md`: general Go style conventions that apply to all Go projects. +- **Libwallet**: `../.github/review/sources/go/review-libwallet-conventions.md`: Libwallet specific conventions. + +The canonical upstream is the Notion "Go style-conventions guide" and "Libwallet conventions guide" +pages, cited from those files. + +## KV Storage + +New keys go in `BuildKVMigrationPlan()` (`storage/kv_migrations.go`) as an appended `Migration{...}` with `Define(...)` entries. `KeyValueStorage` rejects any key not in the migration-derived classification map, so a missing `Define` makes every save/get for that key fail with "classification not found" — and callers often swallow it silently. + +- Never mutate a shipped migration — only append. Migrations are ordered history gated by the persisted schema version. +- After adding a migration, refresh the lockfile: `go generate ./storage/...`. +- `Define` currently only accepts `NoAutoBackup + NotApplicable + securityCritical=false`; other combinations panic until auto-backup and security-critical storage are implemented. +- `MigrateValueTypeWithMap`, `UpdateAccordingToMap`, and `AddCustomChange` need Wallet-team sign-off — rollback isn't designed yet. +- `storage/schema.go` holds the `ValueType` / `Classification` / `BackupType` machinery used by `Define`; its `KeyXxx` string constants are legacy — don't add new ones there. If the key is only referenced from client code, keep the string constant on the client side. + +## Build & Test + +Run all tests with: +```bash +cd libwallet && go test ./... +``` + +When modifying tests or logic covered by tests, run the affected tests: +```bash +go test ./path/to/affected/package/... +``` + +After applying any Go code changes, always run: +```bash +go vet ./... +go fmt ./... +``` diff --git a/libwallet/V1.go b/libwallet/V1.go index 9d9c212f..b9f7c5d2 100644 --- a/libwallet/V1.go +++ b/libwallet/V1.go @@ -1,12 +1,12 @@ package libwallet import ( - "fmt" - "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" + "github.com/go-errors/errors" + "github.com/muun/libwallet/addresses" "github.com/muun/libwallet/btcsuitew/txscriptw" ) @@ -25,12 +25,12 @@ type coinV1 struct { func (c *coinV1) SignInput(index int, tx *wire.MsgTx, userKey *HDPrivateKey, _ *HDPublicKey) error { userKey, err := userKey.DeriveTo(c.KeyPath) if err != nil { - return fmt.Errorf("failed to derive user key: %w", err) + return errors.Errorf("failed to derive user key: %w", err) } sig, err := c.signature(index, tx, userKey) if err != nil { - return fmt.Errorf("failed to sign V1 input: %w", err) + return errors.Errorf("failed to sign V1 input: %w", err) } builder := txscript.NewScriptBuilder() @@ -38,7 +38,7 @@ func (c *coinV1) SignInput(index int, tx *wire.MsgTx, userKey *HDPrivateKey, _ * builder.AddData(userKey.PublicKey().Raw()) script, err := builder.Script() if err != nil { - return fmt.Errorf("failed to generate signing script: %w", err) + return errors.Errorf("failed to generate signing script: %w", err) } txInput := tx.TxIn[index] @@ -46,7 +46,11 @@ func (c *coinV1) SignInput(index int, tx *wire.MsgTx, userKey *HDPrivateKey, _ * return nil } -func (c *coinV1) FullySignInput(index int, tx *wire.MsgTx, userKey, muunKey *HDPrivateKey) error { +func (c *coinV1) FullySignInput( + index int, + tx *wire.MsgTx, + userKey, muunKey *HDPrivateKey, //nolint:revive // TODO: use or remove muunKey +) error { return c.SignInput(index, tx, userKey, nil) } @@ -54,7 +58,7 @@ func (c *coinV1) createRedeemScript(publicKey *HDPublicKey) ([]byte, error) { userAddress, err := btcutil.NewAddressPubKey(publicKey.Raw(), c.Network) if err != nil { - return nil, fmt.Errorf("failed to generate address for user: %w", err) + return nil, errors.Errorf("failed to generate address for user: %w", err) } return txscriptw.PayToAddrScript(userAddress.AddressPubKeyHash()) @@ -64,17 +68,17 @@ func (c *coinV1) signature(index int, tx *wire.MsgTx, userKey *HDPrivateKey) ([] redeemScript, err := c.createRedeemScript(userKey.PublicKey()) if err != nil { - return nil, fmt.Errorf("failed to build reedem script for signing: %w", err) + return nil, errors.Errorf("failed to build reedem script for signing: %w", err) } privKey, err := userKey.key.ECPrivKey() if err != nil { - return nil, fmt.Errorf("failed to produce EC priv key for signing: %w", err) + return nil, errors.Errorf("failed to produce EC priv key for signing: %w", err) } sig, err := txscript.RawTxInSignature(tx, index, redeemScript, txscript.SigHashAll, privKey) if err != nil { - return nil, fmt.Errorf("failed to sign V1 input: %w", err) + return nil, errors.Errorf("failed to sign V1 input: %w", err) } return sig, nil diff --git a/libwallet/V2.go b/libwallet/V2.go index efacd44a..9b5e15d3 100644 --- a/libwallet/V2.go +++ b/libwallet/V2.go @@ -1,19 +1,22 @@ package libwallet import ( - "errors" - "fmt" - "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/txscript" - "github.com/muun/libwallet/addresses" - "github.com/btcsuite/btcd/wire" + "github.com/go-errors/errors" + + "github.com/muun/libwallet/addresses" ) func CreateAddressV2(userKey, muunKey *HDPublicKey) (MuunAddress, error) { // TODO: check both paths match? - return addresses.CreateAddressV2(&userKey.key, &muunKey.key, userKey.Path, userKey.Network.network) + return addresses.CreateAddressV2( + &userKey.key, + &muunKey.key, + userKey.Path, + userKey.Network.network, + ) } type coinV2 struct { @@ -23,15 +26,20 @@ type coinV2 struct { MuunSignature []byte } -func (c *coinV2) SignInput(index int, tx *wire.MsgTx, userKey *HDPrivateKey, muunKey *HDPublicKey) error { +func (c *coinV2) SignInput( + index int, + tx *wire.MsgTx, + userKey *HDPrivateKey, + muunKey *HDPublicKey, +) error { userKey, err := userKey.DeriveTo(c.KeyPath) if err != nil { - return fmt.Errorf("failed to derive user key: %w", err) + return errors.Errorf("failed to derive user key: %w", err) } muunKey, err = muunKey.DeriveTo(c.KeyPath) if err != nil { - return fmt.Errorf("failed to derive muun key: %w", err) + return errors.Errorf("failed to derive muun key: %w", err) } if len(c.MuunSignature) == 0 { @@ -42,7 +50,7 @@ func (c *coinV2) SignInput(index int, tx *wire.MsgTx, userKey *HDPrivateKey, muu redeemScript, err := createRedeemScriptV2(userKey.PublicKey(), muunKey) if err != nil { - return fmt.Errorf("failed to build reedem script for signing: %w", err) + return errors.Errorf("failed to build reedem script for signing: %w", err) } sig, err := c.signature(index, tx, userKey.PublicKey(), muunKey, userKey) @@ -61,7 +69,7 @@ func (c *coinV2) SignInput(index int, tx *wire.MsgTx, userKey *HDPrivateKey, muu builder.AddData(redeemScript) script, err := builder.Script() if err != nil { - return fmt.Errorf("failed to generate signing script: %w", err) + return errors.Errorf("failed to generate signing script: %w", err) } txInput.SignatureScript = script @@ -73,15 +81,21 @@ func (c *coinV2) FullySignInput(index int, tx *wire.MsgTx, userKey, muunKey *HDP derivedUserKey, err := userKey.DeriveTo(c.KeyPath) if err != nil { - return fmt.Errorf("failed to derive user key: %w", err) + return errors.Errorf("failed to derive user key: %w", err) } derivedMuunKey, err := muunKey.DeriveTo(c.KeyPath) if err != nil { - return fmt.Errorf("failed to derive muun key: %w", err) + return errors.Errorf("failed to derive muun key: %w", err) } - muunSignature, err := c.signature(index, tx, derivedUserKey.PublicKey(), derivedMuunKey.PublicKey(), derivedMuunKey) + muunSignature, err := c.signature( + index, + tx, + derivedUserKey.PublicKey(), + derivedMuunKey.PublicKey(), + derivedMuunKey, + ) if err != nil { return err } @@ -94,17 +108,17 @@ func (c *coinV2) signature(index int, tx *wire.MsgTx, userKey, muunKey *HDPublic redeemScript, err := createRedeemScriptV2(userKey, muunKey) if err != nil { - return nil, fmt.Errorf("failed to build reedem script for signing: %w", err) + return nil, errors.Errorf("failed to build reedem script for signing: %w", err) } privKey, err := signingKey.key.ECPrivKey() if err != nil { - return nil, fmt.Errorf("failed to produce EC priv key for signing: %w", err) + return nil, errors.Errorf("failed to produce EC priv key for signing: %w", err) } sig, err := txscript.RawTxInSignature(tx, index, redeemScript, txscript.SigHashAll, privKey) if err != nil { - return nil, fmt.Errorf("failed to sign V2 output: %w", err) + return nil, errors.Errorf("failed to sign V2 output: %w", err) } return sig, nil diff --git a/libwallet/V3.go b/libwallet/V3.go index 7de5cdd6..7764456f 100644 --- a/libwallet/V3.go +++ b/libwallet/V3.go @@ -1,18 +1,21 @@ package libwallet import ( - "errors" - "fmt" - "github.com/btcsuite/btcd/btcutil" - "github.com/muun/libwallet/addresses" - "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/wire" + "github.com/go-errors/errors" + + "github.com/muun/libwallet/addresses" ) func CreateAddressV3(userKey, muunKey *HDPublicKey) (MuunAddress, error) { - return addresses.CreateAddressV3(&userKey.key, &muunKey.key, userKey.Path, userKey.Network.network) + return addresses.CreateAddressV3( + &userKey.key, + &muunKey.key, + userKey.Path, + userKey.Network.network, + ) } type coinV3 struct { @@ -23,16 +26,21 @@ type coinV3 struct { MuunSignature []byte } -func (c *coinV3) SignInput(index int, tx *wire.MsgTx, userKey *HDPrivateKey, muunKey *HDPublicKey) error { +func (c *coinV3) SignInput( + index int, + tx *wire.MsgTx, + userKey *HDPrivateKey, + muunKey *HDPublicKey, +) error { userKey, err := userKey.DeriveTo(c.KeyPath) if err != nil { - return fmt.Errorf("failed to derive user key: %w", err) + return errors.Errorf("failed to derive user key: %w", err) } muunKey, err = muunKey.DeriveTo(c.KeyPath) if err != nil { - return fmt.Errorf("failed to derive muun key: %w", err) + return errors.Errorf("failed to derive muun key: %w", err) } if len(c.MuunSignature) == 0 { @@ -61,15 +69,21 @@ func (c *coinV3) FullySignInput(index int, tx *wire.MsgTx, userKey, muunKey *HDP derivedUserKey, err := userKey.DeriveTo(c.KeyPath) if err != nil { - return fmt.Errorf("failed to derive user key: %w", err) + return errors.Errorf("failed to derive user key: %w", err) } derivedMuunKey, err := muunKey.DeriveTo(c.KeyPath) if err != nil { - return fmt.Errorf("failed to derive muun key: %w", err) + return errors.Errorf("failed to derive muun key: %w", err) } - muunSignature, err := c.signature(index, tx, derivedUserKey.PublicKey(), derivedMuunKey.PublicKey(), derivedMuunKey) + muunSignature, err := c.signature( + index, + tx, + derivedUserKey.PublicKey(), + derivedMuunKey.PublicKey(), + derivedMuunKey, + ) if err != nil { return err } @@ -95,7 +109,7 @@ func (c *coinV3) signature(index int, tx *wire.MsgTx, userKey *HDPublicKey, muun redeemScript, err := createRedeemScriptV3(userKey, muunKey) if err != nil { - return nil, fmt.Errorf("failed to build reedem script for signing: %w", err) + return nil, errors.Errorf("failed to build reedem script for signing: %w", err) } return signNonNativeSegwitInputV0( diff --git a/libwallet/V4.go b/libwallet/V4.go index 57936755..a9c45c1d 100644 --- a/libwallet/V4.go +++ b/libwallet/V4.go @@ -1,18 +1,23 @@ package libwallet import ( - "fmt" - "github.com/btcsuite/btcd/btcutil" - "github.com/muun/libwallet/addresses" - "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/wire" + "github.com/go-errors/errors" + + "github.com/muun/libwallet/addresses" ) -// CreateAddressV4 returns a P2WSH MuunAddress from a user HD-pubkey and a Muun co-signing HD-pubkey. +// CreateAddressV4 returns a P2WSH MuunAddress from a user HD-pubkey and a Muun co-signing +// HD-pubkey. func CreateAddressV4(userKey, muunKey *HDPublicKey) (MuunAddress, error) { - return addresses.CreateAddressV4(&userKey.key, &muunKey.key, userKey.Path, userKey.Network.network) + return addresses.CreateAddressV4( + &userKey.key, + &muunKey.key, + userKey.Path, + userKey.Network.network, + ) } type coinV4 struct { @@ -23,20 +28,25 @@ type coinV4 struct { MuunSignature []byte } -func (c *coinV4) SignInput(index int, tx *wire.MsgTx, userKey *HDPrivateKey, muunKey *HDPublicKey) error { +func (c *coinV4) SignInput( + index int, + tx *wire.MsgTx, + userKey *HDPrivateKey, + muunKey *HDPublicKey, +) error { userKey, err := userKey.DeriveTo(c.KeyPath) if err != nil { - return fmt.Errorf("failed to derive user key: %w", err) + return errors.Errorf("failed to derive user key: %w", err) } muunKey, err = muunKey.DeriveTo(c.KeyPath) if err != nil { - return fmt.Errorf("failed to derive muun key: %w", err) + return errors.Errorf("failed to derive muun key: %w", err) } if len(c.MuunSignature) == 0 { - return fmt.Errorf("muun signature must be present: %w", err) + return errors.Errorf("muun signature must be present: %w", err) } witnessScript, err := createWitnessScriptV4(userKey.PublicKey(), muunKey) @@ -61,15 +71,21 @@ func (c *coinV4) FullySignInput(index int, tx *wire.MsgTx, userKey, muunKey *HDP derivedUserKey, err := userKey.DeriveTo(c.KeyPath) if err != nil { - return fmt.Errorf("failed to derive user key: %w", err) + return errors.Errorf("failed to derive user key: %w", err) } derivedMuunKey, err := muunKey.DeriveTo(c.KeyPath) if err != nil { - return fmt.Errorf("failed to derive muun key: %w", err) + return errors.Errorf("failed to derive muun key: %w", err) } - muunSignature, err := c.signature(index, tx, derivedUserKey.PublicKey(), derivedMuunKey.PublicKey(), derivedMuunKey) + muunSignature, err := c.signature( + index, + tx, + derivedUserKey.PublicKey(), + derivedMuunKey.PublicKey(), + derivedMuunKey, + ) if err != nil { return err } diff --git a/libwallet/V5.go b/libwallet/V5.go index 7a144d2f..ee3be387 100644 --- a/libwallet/V5.go +++ b/libwallet/V5.go @@ -1,22 +1,26 @@ package libwallet import ( - "fmt" - - "github.com/muun/libwallet/addresses" - "github.com/muun/libwallet/btcsuitew/txscriptw" - "github.com/muun/libwallet/musig" - "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" + "github.com/go-errors/errors" + + "github.com/muun/libwallet/addresses" + "github.com/muun/libwallet/btcsuitew/txscriptw" + "github.com/muun/libwallet/musig" ) // CreateAddressV5 returns a P2TR MuunAddress using Musig with the signing and cosigning keys. func CreateAddressV5(userKey, muunKey *HDPublicKey) (MuunAddress, error) { - return addresses.CreateAddressV5(&userKey.key, &muunKey.key, userKey.Path, userKey.Network.network) + return addresses.CreateAddressV5( + &userKey.key, + &muunKey.key, + userKey.Path, + userKey.Network.network, + ) } type coinV5 struct { @@ -24,74 +28,79 @@ type coinV5 struct { OutPoint wire.OutPoint KeyPath string Amount btcutil.Amount - UserSessionId [32]byte + UserSessionID [32]byte MuunPubNonce [66]byte MuunPartialSig [32]byte SigHashes *txscriptw.TaprootSigHashes } -func (c *coinV5) SignInput(index int, tx *wire.MsgTx, userKey *HDPrivateKey, muunKey *HDPublicKey) error { +func (c *coinV5) SignInput( + index int, + tx *wire.MsgTx, + userKey *HDPrivateKey, + muunKey *HDPublicKey, +) error { derivedUserKey, err := userKey.DeriveTo(c.KeyPath) if err != nil { - return fmt.Errorf("failed to derive user private key: %w", err) + return errors.Errorf("failed to derive user private key: %w", err) } derivedMuunKey, err := muunKey.DeriveTo(c.KeyPath) if err != nil { - return fmt.Errorf("failed to derive muun public key: %w", err) + return errors.Errorf("failed to derive muun public key: %w", err) } userEcPriv, err := derivedUserKey.key.ECPrivKey() if err != nil { - return fmt.Errorf("failed to obtain ECPrivKey from derivedUserKey: %w", err) + return errors.Errorf("failed to obtain ECPrivKey from derivedUserKey: %w", err) } muunEcPub, err := derivedMuunKey.key.ECPubKey() if err != nil { - return fmt.Errorf("failed to obtain ECPubKey from derivedMuunKey: %w", err) + return errors.Errorf("failed to obtain ECPubKey from derivedMuunKey: %w", err) } sigHash, err := txscriptw.CalcTaprootSigHash(tx, c.SigHashes, index, txscript.SigHashAll) if err != nil { - return fmt.Errorf("failed to create sigHash: %w", err) + return errors.Errorf("failed to create sigHash: %w", err) } var toSign [32]byte copy(toSign[:], sigHash) - return c.signSecondWith(index, tx, userEcPriv, muunEcPub, c.UserSessionId, toSign) + return c.signSecondWith(index, tx, userEcPriv, muunEcPub, c.UserSessionID, toSign) } func (c *coinV5) FullySignInput(index int, tx *wire.MsgTx, userKey, muunKey *HDPrivateKey) error { derivedUserKey, err := userKey.DeriveTo(c.KeyPath) if err != nil { - return fmt.Errorf("failed to derive user private key: %w", err) + return errors.Errorf("failed to derive user private key: %w", err) } derivedMuunKey, err := muunKey.DeriveTo(c.KeyPath) if err != nil { - return fmt.Errorf("failed to derive muun private key: %w", err) + return errors.Errorf("failed to derive muun private key: %w", err) } userEcPriv, err := derivedUserKey.key.ECPrivKey() if err != nil { - return fmt.Errorf("failed to obtain ECPrivKey from derivedUserKey: %w", err) + return errors.Errorf("failed to obtain ECPrivKey from derivedUserKey: %w", err) } muunEcPriv, err := derivedMuunKey.key.ECPrivKey() if err != nil { - return fmt.Errorf("failed to obtain ECPrivKey from derivedMuunKey: %w", err) + return errors.Errorf("failed to obtain ECPrivKey from derivedMuunKey: %w", err) } sigHash, err := txscriptw.CalcTaprootSigHash(tx, c.SigHashes, index, txscript.SigHashAll) if err != nil { - return fmt.Errorf("failed to create sigHash: %w", err) + return errors.Errorf("failed to create sigHash: %w", err) } var toSign [32]byte copy(toSign[:], sigHash) userPubNonce, err := musig.MuSig2GenerateNonce( musig.Musig2v040Muun, - c.UserSessionId[:], + c.UserSessionID[:], nil, ) if err != nil { @@ -103,12 +112,12 @@ func (c *coinV5) FullySignInput(index int, tx *wire.MsgTx, userKey, muunKey *HDP return err } - return c.signSecondWith(index, tx, userEcPriv, muunEcPriv.PubKey(), c.UserSessionId, toSign) + return c.signSecondWith(index, tx, userEcPriv, muunEcPriv.PubKey(), c.UserSessionID, toSign) } func (c *coinV5) signFirstWith( - index int, - tx *wire.MsgTx, + index int, //nolint:revive // TODO: use or remove index + tx *wire.MsgTx, //nolint:revive // TODO: use or remove tx userPub *btcec.PublicKey, muunPriv *btcec.PrivateKey, userPubNonce [66]byte, @@ -117,29 +126,29 @@ func (c *coinV5) signFirstWith( // NOTE: // This will only be called in a recovery context, where both private keys are provided by the - // user. We call the variables below "muunSessionId" and "muunPubNonce" to follow convention, + // user. We call the variables below "muunSessionID" and "muunPubNonce" to follow convention, // but Muun servers play no role in this code path and both are locally generated. - muunSessionId := musig.RandomSessionId() + muunSessionID := musig.RandomSessionID() muunPubNonce, err := musig.MuSig2GenerateNonce( musig.Musig2v040Muun, - muunSessionId[:], + muunSessionID[:], muunPriv.PubKey().SerializeCompressed(), ) if err != nil { - return fmt.Errorf("failed to generate nonce: %w", err) + return errors.Errorf("failed to generate nonce: %w", err) } - muunPartialSig, err := musig.ComputeMuunPartialSignature( + muunPartialSig, err := musig.ComputeMuunPartialSignature( //nolint:staticcheck // V5 keeps the deprecated flow musig.Musig2v040Muun, toSign[:], userPub.SerializeCompressed(), muunPriv.Serialize(), userPubNonce[:], - muunSessionId[:], + muunSessionID[:], musig.KeySpendOnlyTweak(), ) if err != nil { - return fmt.Errorf("failed to add first signature: %w", err) + return errors.Errorf("failed to add first signature: %w", err) } copy(c.MuunPubNonce[:], muunPubNonce.PubNonce[0:66]) @@ -153,22 +162,22 @@ func (c *coinV5) signSecondWith( tx *wire.MsgTx, userPriv *btcec.PrivateKey, muunPub *btcec.PublicKey, - userSessionId [32]byte, + userSessionID [32]byte, toSign [32]byte, ) error { - rawCombinedSig, err := musig.ComputeUserPartialSignature( + rawCombinedSig, err := musig.ComputeUserPartialSignature( //nolint:staticcheck // V5 keeps the deprecated flow musig.Musig2v040Muun, toSign[:], userPriv.Serialize(), muunPub.SerializeCompressed(), c.MuunPartialSig[:], c.MuunPubNonce[:], - userSessionId[:], + userSessionID[:], musig.KeySpendOnlyTweak(), ) if err != nil { - return fmt.Errorf("failed to add second signature and combine: %w", err) + return errors.Errorf("failed to add second signature and combine: %w", err) } sig := append(rawCombinedSig[:], byte(txscript.SigHashAll)) diff --git a/libwallet/V6.go b/libwallet/V6.go index c0310894..f60f3811 100644 --- a/libwallet/V6.go +++ b/libwallet/V6.go @@ -1,22 +1,26 @@ package libwallet import ( - "fmt" - - "github.com/muun/libwallet/addresses" - "github.com/muun/libwallet/btcsuitew/txscriptw" - "github.com/muun/libwallet/musig" - "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" + "github.com/go-errors/errors" + + "github.com/muun/libwallet/addresses" + "github.com/muun/libwallet/btcsuitew/txscriptw" + "github.com/muun/libwallet/musig" ) // CreateAddressV6 returns a P2TR MuunAddress using Musig with the signing and cosigning keys. func CreateAddressV6(userKey, muunKey *HDPublicKey) (MuunAddress, error) { - return addresses.CreateAddressV6(&userKey.key, &muunKey.key, userKey.Path, userKey.Network.network) + return addresses.CreateAddressV6( + &userKey.key, + &muunKey.key, + userKey.Path, + userKey.Network.network, + ) } type coinV6 struct { @@ -24,74 +28,79 @@ type coinV6 struct { OutPoint wire.OutPoint KeyPath string Amount btcutil.Amount - UserSessionId [32]byte + UserSessionID [32]byte MuunPubNonce [66]byte MuunPartialSig [32]byte SigHashes *txscriptw.TaprootSigHashes } -func (c *coinV6) SignInput(index int, tx *wire.MsgTx, userKey *HDPrivateKey, muunKey *HDPublicKey) error { +func (c *coinV6) SignInput( + index int, + tx *wire.MsgTx, + userKey *HDPrivateKey, + muunKey *HDPublicKey, +) error { derivedUserKey, err := userKey.DeriveTo(c.KeyPath) if err != nil { - return fmt.Errorf("failed to derive user private key: %w", err) + return errors.Errorf("failed to derive user private key: %w", err) } derivedMuunKey, err := muunKey.DeriveTo(c.KeyPath) if err != nil { - return fmt.Errorf("failed to derive muun public key: %w", err) + return errors.Errorf("failed to derive muun public key: %w", err) } userEcPriv, err := derivedUserKey.key.ECPrivKey() if err != nil { - return fmt.Errorf("failed to obtain ECPrivKey from derivedUserKey: %w", err) + return errors.Errorf("failed to obtain ECPrivKey from derivedUserKey: %w", err) } muunEcPub, err := derivedMuunKey.key.ECPubKey() if err != nil { - return fmt.Errorf("failed to obtain ECPubKey from derivedMuunKey: %w", err) + return errors.Errorf("failed to obtain ECPubKey from derivedMuunKey: %w", err) } sigHash, err := txscriptw.CalcTaprootSigHash(tx, c.SigHashes, index, txscript.SigHashAll) if err != nil { - return fmt.Errorf("failed to create sigHash: %w", err) + return errors.Errorf("failed to create sigHash: %w", err) } var toSign [32]byte copy(toSign[:], sigHash) - return c.signSecondWith(index, tx, userEcPriv, muunEcPub, c.UserSessionId, toSign) + return c.signSecondWith(index, tx, userEcPriv, muunEcPub, c.UserSessionID, toSign) } func (c *coinV6) FullySignInput(index int, tx *wire.MsgTx, userKey, muunKey *HDPrivateKey) error { derivedUserKey, err := userKey.DeriveTo(c.KeyPath) if err != nil { - return fmt.Errorf("failed to derive user private key: %w", err) + return errors.Errorf("failed to derive user private key: %w", err) } derivedMuunKey, err := muunKey.DeriveTo(c.KeyPath) if err != nil { - return fmt.Errorf("failed to derive muun private key: %w", err) + return errors.Errorf("failed to derive muun private key: %w", err) } userEcPriv, err := derivedUserKey.key.ECPrivKey() if err != nil { - return fmt.Errorf("failed to obtain ECPrivKey from derivedUserKey: %w", err) + return errors.Errorf("failed to obtain ECPrivKey from derivedUserKey: %w", err) } muunEcPriv, err := derivedMuunKey.key.ECPrivKey() if err != nil { - return fmt.Errorf("failed to obtain ECPrivKey from derivedMuunKey: %w", err) + return errors.Errorf("failed to obtain ECPrivKey from derivedMuunKey: %w", err) } sigHash, err := txscriptw.CalcTaprootSigHash(tx, c.SigHashes, index, txscript.SigHashAll) if err != nil { - return fmt.Errorf("failed to create sigHash: %w", err) + return errors.Errorf("failed to create sigHash: %w", err) } var toSign [32]byte copy(toSign[:], sigHash) userPubNonce, err := musig.MuSig2GenerateNonce( musig.Musig2v100, - c.UserSessionId[:], + c.UserSessionID[:], userEcPriv.PubKey().SerializeCompressed(), ) if err != nil { @@ -103,12 +112,12 @@ func (c *coinV6) FullySignInput(index int, tx *wire.MsgTx, userKey, muunKey *HDP return err } - return c.signSecondWith(index, tx, userEcPriv, muunEcPriv.PubKey(), c.UserSessionId, toSign) + return c.signSecondWith(index, tx, userEcPriv, muunEcPriv.PubKey(), c.UserSessionID, toSign) } func (c *coinV6) signFirstWith( - index int, - tx *wire.MsgTx, + index int, //nolint:revive // TODO: use or remove index + tx *wire.MsgTx, //nolint:revive // TODO: use or remove tx userPub *btcec.PublicKey, muunPriv *btcec.PrivateKey, userPubNonce [66]byte, @@ -117,29 +126,29 @@ func (c *coinV6) signFirstWith( // NOTE: // This will only be called in a recovery context, where both private keys are provided by the - // user. We call the variables below "muunSessionId" and "muunPubNonce" to follow convention, + // user. We call the variables below "muunSessionID" and "muunPubNonce" to follow convention, // but Muun servers play no role in this code path and both are locally generated. - muunSessionId := musig.RandomSessionId() + muunSessionID := musig.RandomSessionID() muunPubNonce, err := musig.MuSig2GenerateNonce( musig.Musig2v100, - muunSessionId[:], + muunSessionID[:], muunPriv.PubKey().SerializeCompressed(), ) if err != nil { - return fmt.Errorf("failed to generate nonce: %w", err) + return errors.Errorf("failed to generate nonce: %w", err) } - muunPartialSig, err := musig.ComputeMuunPartialSignature( + muunPartialSig, err := musig.ComputeMuunPartialSignature( //nolint:staticcheck // V6 keeps the deprecated flow musig.Musig2v100, toSign[:], userPub.SerializeCompressed(), muunPriv.Serialize(), userPubNonce[:], - muunSessionId[:], + muunSessionID[:], musig.KeySpendOnlyTweak(), ) if err != nil { - return fmt.Errorf("failed to add first signature: %w", err) + return errors.Errorf("failed to add first signature: %w", err) } copy(c.MuunPubNonce[:], muunPubNonce.PubNonce[0:66]) @@ -153,21 +162,21 @@ func (c *coinV6) signSecondWith( tx *wire.MsgTx, userPriv *btcec.PrivateKey, muunPub *btcec.PublicKey, - userSessionId [32]byte, + userSessionID [32]byte, toSign [32]byte, ) error { - rawCombinedSig, err := musig.ComputeUserPartialSignature( + rawCombinedSig, err := musig.ComputeUserPartialSignature( //nolint:staticcheck // V6 keeps the deprecated flow musig.Musig2v100, toSign[:], userPriv.Serialize(), muunPub.SerializeCompressed(), c.MuunPartialSig[:], c.MuunPubNonce[:], - userSessionId[:], + userSessionID[:], musig.KeySpendOnlyTweak(), ) if err != nil { - return fmt.Errorf("failed to add second signature and combine: %w", err) + return errors.Errorf("failed to add second signature and combine: %w", err) } sig := append(rawCombinedSig[:], byte(txscript.SigHashAll)) diff --git a/libwallet/V7.go b/libwallet/V7.go new file mode 100644 index 00000000..cf506280 --- /dev/null +++ b/libwallet/V7.go @@ -0,0 +1,199 @@ +package libwallet + +import ( + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/wire" + "github.com/go-errors/errors" + + "github.com/muun/libwallet/addresses" +) + +func CreateAddressV7( + userKey, muunKey, lightningPeerKey *HDPublicKey, + blocksForExpiration int64, +) (MuunAddress, error) { + return addresses.CreateAddressV7( + &userKey.key, + &muunKey.key, + &lightningPeerKey.key, + blocksForExpiration, + userKey.Path, + userKey.Network.network, + ) +} + +// coinV7 signs a V7 (M3) P2SH-P2WSH input. +type coinV7 struct { + Network *chaincfg.Params + OutPoint wire.OutPoint + KeyPath string + Amount btcutil.Amount + BlocksForExpiration int64 + LightningPeerKey *HDPublicKey + MuunSignature []byte + PeerSignature []byte +} + +// SignInput adds the user signature and assembles the collaborative (3-of-3) witness. The muun and +// peer signatures must already be present. +func (c *coinV7) SignInput( + index int, + tx *wire.MsgTx, + userKey *HDPrivateKey, + muunKey *HDPublicKey, +) error { + derivedUserKey, err := userKey.DeriveTo(c.KeyPath) + if err != nil { + return err + } + + derivedMuunKey, err := muunKey.DeriveTo(c.KeyPath) + if err != nil { + return err + } + + derivedLightningPeerKey, err := c.LightningPeerKey.DeriveTo(c.KeyPath) + if err != nil { + return err + } + + if len(c.MuunSignature) == 0 { + return errors.New("muun signature must be present") + } + if len(c.PeerSignature) == 0 { + return errors.New("lightning peer signature must be present") + } + + userPubKey, err := derivedUserKey.PublicKey().ECPubKey() + if err != nil { + return err + } + muunPubKey, err := derivedMuunKey.ECPubKey() + if err != nil { + return err + } + lightningPeerPubKey, err := derivedLightningPeerKey.ECPubKey() + if err != nil { + return err + } + + witnessScript, err := addresses.CreateWitnessScriptV7( + userPubKey, + muunPubKey, + lightningPeerPubKey, + c.BlocksForExpiration, + ) + if err != nil { + return err + } + + userSignature, err := c.signature( + index, + tx, + derivedUserKey, + witnessScript, + ) + if err != nil { + return err + } + + // Stack top -> bottom: witnessScript, userSig, muunSig, peerSig. + tx.TxIn[index].Witness = wire.TxWitness{ + c.PeerSignature, + c.MuunSignature, + userSignature, + witnessScript, + } + + return nil +} + +// FullySignInput signs the non-collaborative (2-of-2 + timelock) path with the user and muun +// private keys, for recovery contexts. The caller must build a version-2 tx whose input nSequence +// encodes the relative timelock, since nSequence is committed to by the signature. +func (c *coinV7) FullySignInput(index int, tx *wire.MsgTx, userKey, muunKey *HDPrivateKey) error { + derivedUserKey, err := userKey.DeriveTo(c.KeyPath) + if err != nil { + return err + } + + derivedMuunKey, err := muunKey.DeriveTo(c.KeyPath) + if err != nil { + return err + } + + derivedLightningPeerKey, err := c.LightningPeerKey.DeriveTo(c.KeyPath) + if err != nil { + return err + } + + userPubKey, err := derivedUserKey.PublicKey().ECPubKey() + if err != nil { + return err + } + muunPubKey, err := derivedMuunKey.PublicKey().ECPubKey() + if err != nil { + return err + } + lightningPeerPubKey, err := derivedLightningPeerKey.ECPubKey() + if err != nil { + return err + } + + witnessScript, err := addresses.CreateWitnessScriptV7( + userPubKey, + muunPubKey, + lightningPeerPubKey, + c.BlocksForExpiration, + ) + if err != nil { + return err + } + + userSignature, err := c.signature( + index, + tx, + derivedUserKey, + witnessScript, + ) + if err != nil { + return err + } + + muunSignature, err := c.signature( + index, + tx, + derivedMuunKey, + witnessScript, + ) + if err != nil { + return err + } + + // Stack top -> bottom: witnessScript, userSig, muunSig, . + tx.TxIn[index].Witness = wire.TxWitness{ + []byte{}, + muunSignature, + userSignature, + witnessScript, + } + + return nil +} + +func (c *coinV7) signature( + index int, + tx *wire.MsgTx, + signingKey *HDPrivateKey, + witnessScript []byte, +) ([]byte, error) { + + redeemScript, err := addresses.CreateRedeemScriptV7(witnessScript) + if err != nil { + return nil, err + } + + return signNonNativeSegwitInputV0( + index, tx, signingKey, redeemScript, witnessScript, c.Amount) +} diff --git a/libwallet/V8.go b/libwallet/V8.go new file mode 100644 index 00000000..2a613b42 --- /dev/null +++ b/libwallet/V8.go @@ -0,0 +1,177 @@ +package libwallet + +import ( + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/wire" + "github.com/go-errors/errors" + + "github.com/muun/libwallet/addresses" +) + +func CreateAddressV8( + userKey, muunKey, lightningPeerKey *HDPublicKey, + blocksForExpiration int64, +) (MuunAddress, error) { + return addresses.CreateAddressV8( + &userKey.key, + &muunKey.key, + &lightningPeerKey.key, + blocksForExpiration, + userKey.Path, + userKey.Network.network, + ) +} + +// coinV8 signs a V8 (M3) P2WSH input. +type coinV8 struct { + Network *chaincfg.Params + OutPoint wire.OutPoint + KeyPath string + Amount btcutil.Amount + BlocksForExpiration int64 + LightningPeerKey *HDPublicKey + MuunSignature []byte + LightningPeerSignature []byte +} + +// SignInput adds the user signature and assembles the collaborative (3-of-3) witness. The muun and +// peer signatures must already be present. +func (c *coinV8) SignInput( + index int, + tx *wire.MsgTx, + userKey *HDPrivateKey, + muunKey *HDPublicKey, +) error { + derivedUserKey, err := userKey.DeriveTo(c.KeyPath) + if err != nil { + return err + } + + derivedMuunKey, err := muunKey.DeriveTo(c.KeyPath) + if err != nil { + return err + } + + derivedLightningPeerKey, err := c.LightningPeerKey.DeriveTo(c.KeyPath) + if err != nil { + return err + } + + if len(c.MuunSignature) == 0 { + return errors.New("muun signature must be present") + } + if len(c.LightningPeerSignature) == 0 { + return errors.New("lightning peer signature must be present") + } + + userPubKey, err := derivedUserKey.PublicKey().ECPubKey() + if err != nil { + return err + } + muunPubKey, err := derivedMuunKey.ECPubKey() + if err != nil { + return err + } + lightningPeerPubKey, err := derivedLightningPeerKey.ECPubKey() + if err != nil { + return err + } + + witnessScript, err := addresses.CreateWitnessScriptV7( + userPubKey, + muunPubKey, + lightningPeerPubKey, + c.BlocksForExpiration, + ) + if err != nil { + return err + } + + userSignature, err := c.signature(index, tx, derivedUserKey, witnessScript) + if err != nil { + return err + } + + // Stack top -> bottom: witnessScript, userSig, muunSig, peerSig. + tx.TxIn[index].Witness = wire.TxWitness{ + c.LightningPeerSignature, + c.MuunSignature, + userSignature, + witnessScript, + } + + return nil +} + +// FullySignInput signs the non-collaborative (2-of-2 + timelock) path with the user and muun +// private keys, for recovery contexts. The caller must build a version-2 tx whose input nSequence +// encodes the relative timelock, since nSequence is committed to by the signature. +func (c *coinV8) FullySignInput(index int, tx *wire.MsgTx, userKey, muunKey *HDPrivateKey) error { + derivedUserKey, err := userKey.DeriveTo(c.KeyPath) + if err != nil { + return err + } + + derivedMuunKey, err := muunKey.DeriveTo(c.KeyPath) + if err != nil { + return err + } + + derivedLightningPeerKey, err := c.LightningPeerKey.DeriveTo(c.KeyPath) + if err != nil { + return err + } + + userPubKey, err := derivedUserKey.PublicKey().ECPubKey() + if err != nil { + return err + } + muunPubKey, err := derivedMuunKey.PublicKey().ECPubKey() + if err != nil { + return err + } + lightningPeerPubKey, err := derivedLightningPeerKey.ECPubKey() + if err != nil { + return err + } + + witnessScript, err := addresses.CreateWitnessScriptV7( + userPubKey, + muunPubKey, + lightningPeerPubKey, + c.BlocksForExpiration, + ) + if err != nil { + return err + } + + userSignature, err := c.signature(index, tx, derivedUserKey, witnessScript) + if err != nil { + return err + } + + muunSignature, err := c.signature(index, tx, derivedMuunKey, witnessScript) + if err != nil { + return err + } + + // Stack top -> bottom: witnessScript, userSig, muunSig, . + tx.TxIn[index].Witness = wire.TxWitness{ + []byte{}, + muunSignature, + userSignature, + witnessScript, + } + + return nil +} + +func (c *coinV8) signature( + index int, + tx *wire.MsgTx, + signingKey *HDPrivateKey, + witnessScript []byte, +) ([]byte, error) { + return signNativeSegwitInputV0(index, tx, signingKey, witnessScript, c.Amount) +} diff --git a/libwallet/V9.go b/libwallet/V9.go new file mode 100644 index 00000000..e576f8f6 --- /dev/null +++ b/libwallet/V9.go @@ -0,0 +1,247 @@ +package libwallet + +import ( + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/go-errors/errors" + + "github.com/muun/libwallet/addresses" + "github.com/muun/libwallet/musig" +) + +func CreateAddressV9( + userKey, muunKey, lightningPeerKey *HDPublicKey, + blocksForExpiration int64, +) (MuunAddress, error) { + return addresses.CreateAddressV9( + &userKey.key, + &muunKey.key, + &lightningPeerKey.key, + blocksForExpiration, + userKey.Path, + userKey.Network.network, + ) +} + +// coinV9 signs a V9 (M3) native taproot (P2TR) input. +type coinV9 struct { + KeyPath string + BlocksForExpiration int64 + LightningPeerKey *HDPublicKey + UserSessionID [32]byte + MuunPubNonce [66]byte + MuunPartialSig [32]byte + LightningPeerPubNonce [66]byte + LightningPeerPartialSig [32]byte + SigHashes *txscript.TxSigHashes +} + +// SignInput adds the user signature and does the final MuSig2 aggregation of the 3-of-3 signature +// user + muun + lightningPeer for the key spending path. +// The muun and lightning-peer public nonces and partial signatures must already be present. +func (c *coinV9) SignInput( + index int, + tx *wire.MsgTx, + userKey *HDPrivateKey, + muunKey *HDPublicKey, +) error { + derivedUserKey, err := userKey.DeriveTo(c.KeyPath) + if err != nil { + return err + } + + derivedMuunKey, err := muunKey.DeriveTo(c.KeyPath) + if err != nil { + return err + } + + if c.UserSessionID == [32]byte{} { + return errors.New("UserSessionID must be non empty") + } + if c.MuunPubNonce == ([66]byte{}) { + return errors.New("muun public nonce must be present") + } + if c.MuunPartialSig == ([32]byte{}) { + return errors.New("muun partial signature must be present") + } + if c.LightningPeerPubNonce == ([66]byte{}) { + return errors.New("lightning peer public nonce must be present") + } + if c.LightningPeerPartialSig == ([32]byte{}) { + return errors.New("lightning peer partial signature must be present") + } + + derivedLightningPeerKey, err := c.LightningPeerKey.DeriveTo(c.KeyPath) + if err != nil { + return err + } + + userEcPriv, err := derivedUserKey.key.ECPrivKey() + if err != nil { + return err + } + muunEcPub, err := derivedMuunKey.ECPubKey() + if err != nil { + return err + } + lightningPeerPubKey, err := derivedLightningPeerKey.ECPubKey() + if err != nil { + return err + } + userEcPub := userEcPriv.PubKey() + + nonCollaborativeLeaf, err := c.nonCollaborativeLeaf(userEcPub, muunEcPub) + if err != nil { + return err + } + + // The output key commits to the script tree, so the key-path signature is over the internal key + // tweaked by the script root. + scriptRootHash := nonCollaborativeLeaf.TapHash() + + // Passing nil prevOutFetcher as it's only used on SIGHASH_ANYONECANPAY path + sigHash, err := txscript.CalcTaprootSignatureHash( + c.SigHashes, + txscript.SigHashDefault, + tx, + index, + nil, + ) + if err != nil { + return err + } + + aggregatedSignature, err := musig.ComputeFinalSignature3Of3( + sigHash, + userEcPriv.Serialize(), + muunEcPub.SerializeCompressed(), + lightningPeerPubKey.SerializeCompressed(), + c.MuunPubNonce[:], + c.LightningPeerPubNonce[:], + c.MuunPartialSig[:], + c.LightningPeerPartialSig[:], + c.UserSessionID[:], + musig.TapScriptTweak(scriptRootHash[:]), + ) + if err != nil { + return err + } + + tx.TxIn[index].Witness = wire.TxWitness{aggregatedSignature} + return nil +} + +// FullySignInput signs the non-collaborative (2-of-2 + timelock) script path with the user and muun +// private keys creating an aggregated signature with musig2. +// The caller must build a version-2 tx whose input nSequence encodes the relative timelock, +// since nSequence is committed to by the signature. +func (c *coinV9) FullySignInput(index int, tx *wire.MsgTx, userKey, muunKey *HDPrivateKey) error { + derivedUserKey, err := userKey.DeriveTo(c.KeyPath) + if err != nil { + return err + } + + derivedMuunKey, err := muunKey.DeriveTo(c.KeyPath) + if err != nil { + return err + } + + derivedLightningPeerKey, err := c.LightningPeerKey.DeriveTo(c.KeyPath) + if err != nil { + return err + } + + userEcPriv, err := derivedUserKey.key.ECPrivKey() + if err != nil { + return err + } + muunEcPriv, err := derivedMuunKey.key.ECPrivKey() + if err != nil { + return err + } + lightningPeerPubKey, err := derivedLightningPeerKey.ECPubKey() + if err != nil { + return err + } + userEcPub, muunEcPub := userEcPriv.PubKey(), muunEcPriv.PubKey() + + nonCollaborativeLeaf, err := c.nonCollaborativeLeaf(userEcPub, muunEcPub) + if err != nil { + return err + } + + // Passing nil prevOutFetcher as it's only used on SIGHASH_ANYONECANPAY path + sigHash, err := txscript.CalcTapscriptSignaturehash( + c.SigHashes, + txscript.SigHashDefault, + tx, + index, + nil, + nonCollaborativeLeaf, + ) + if err != nil { + return err + } + + aggregatedSignature, err := signNonCollaborativeV9(sigHash, userEcPriv, muunEcPriv) + if err != nil { + return err + } + + controlBlock, err := c.nonCollaborativeControlBlock( + userEcPub, + muunEcPub, + lightningPeerPubKey, + nonCollaborativeLeaf, + ) + if err != nil { + return err + } + + // Witness stack bottom -> top: musig(user, muun), leafScript, controlBlock. + tx.TxIn[index].Witness = wire.TxWitness{ + aggregatedSignature, + nonCollaborativeLeaf.Script, + controlBlock, + } + return nil +} + +// signNonCollaborativeV9 runs a full MuSig2 2-of-2 session over the script-path sighash. +func signNonCollaborativeV9( + sigHash []byte, + userEcPriv, muunEcPriv *btcec.PrivateKey, +) ([]byte, error) { + return musig.ComputeFullSignature2Of2(sigHash, muunEcPriv, userEcPriv, musig.NoopTweak()) +} + +// nonCollaborativeLeaf builds the single tapleaf carrying the non-collaborative spending policy. +func (c *coinV9) nonCollaborativeLeaf( + userPubKey, muunPubKey *btcec.PublicKey, +) (txscript.TapLeaf, error) { + script, err := addresses.CreateNonCollaborativeScriptV9( + userPubKey, muunPubKey, c.BlocksForExpiration, + ) + if err != nil { + return txscript.TapLeaf{}, err + } + return txscript.NewBaseTapLeaf(script), nil +} + +// nonCollaborativeControlBlock builds the taproot control block proving the non-collaborative leaf +// belongs to the output's script tree, given the 3-of-3 internal key. The tree has a single leaf, +// so the merkle proof is empty. +func (c *coinV9) nonCollaborativeControlBlock( + userPubKey, muunPubKey, lightningPeerPubKey *btcec.PublicKey, + leaf txscript.TapLeaf, +) ([]byte, error) { + internalKey, err := addresses.CreateInternalKeyV9(userPubKey, muunPubKey, lightningPeerPubKey) + if err != nil { + return nil, err + } + + tapTree := txscript.AssembleTaprootScriptTree(leaf) + controlBlock := tapTree.LeafMerkleProofs[0].ToControlBlock(internalKey) + return controlBlock.ToBytes() +} diff --git a/libwallet/address.go b/libwallet/address.go index ea99961c..28bb5793 100644 --- a/libwallet/address.go +++ b/libwallet/address.go @@ -1,8 +1,6 @@ package libwallet import ( - "fmt" - "github.com/shopspring/decimal" "io" "math" "net/http" @@ -10,12 +8,14 @@ import ( "strconv" "strings" + "github.com/btcsuite/btcd/txscript" + goerr "github.com/go-errors/errors" + "github.com/shopspring/decimal" + "google.golang.org/protobuf/proto" + "github.com/muun/libwallet/addresses" "github.com/muun/libwallet/btcsuitew/btcutilw" "github.com/muun/libwallet/errors" - - "github.com/btcsuite/btcd/txscript" - "google.golang.org/protobuf/proto" ) // These constants are here for clients usage. @@ -35,7 +35,7 @@ type MuunPaymentURI struct { Label string Message string Amount string - Uri string + Uri string //nolint:staticcheck // TODO: struct field Uri should be URI Bip70Url string CreationTime string ExpiresTime string @@ -50,7 +50,10 @@ const ( // GetPaymentURI builds a MuunPaymentURI from text (Bitcoin Uri, Muun Uri or address) and a network func GetPaymentURI(rawInput string, network *Network) (*MuunPaymentURI, error) { - bitcoinUri, components := buildUriFromString(rawInput, bitcoinScheme) + bitcoinUri, components := buildUriFromString( //nolint:staticcheck // TODO: var bitcoinUri should be bitcoinURI + rawInput, + bitcoinScheme, + ) if components == nil { return nil, errors.Errorf(ErrInvalidURI, "failed to parse uri %v", rawInput) } @@ -88,15 +91,16 @@ func GetPaymentURI(rawInput string, network *Network) (*MuunPaymentURI, error) { if strings.ToLower(queryParam) == "amount" { rawAmount := queryValues[queryParam][0] - // We're adding some extra flexibility in case on-chain amount comes in scientific notation - // (bip21 standard doesn't allow it, but we've seen it in the wild). So, we'll try to parse the amount - // string into a float and then convert it back to a string but using decimal notation (that's the 'f' - // format in FormatFloat). + // We're adding some extra flexibility in case on-chain amount comes in scientific + // notation (bip21 standard doesn't allow it, but we've seen it in the wild). So, we'll + // try to parse the amount string into a float and then convert it back to a string but + // using decimal notation (that's the 'f' format in FormatFloat). numericAmount, err := strconv.ParseFloat(rawAmount, 64) if err != nil || math.IsNaN(numericAmount) || math.IsInf(numericAmount, 0) { amount = rawAmount - // TODO we should probably return an error here but that breaks current assumptions in newop state - // machine (see TestInvalidAmountEmitsInvalidAddress in state_test.go) + // TODO we should probably return an error here but that breaks current assumptions + // in newop state machine (see TestInvalidAmountEmitsInvalidAddress in + // state_test.go) } else { amount = strconv.FormatFloat(numericAmount, 'f', -1, 64) } @@ -125,7 +129,10 @@ func GetPaymentURI(rawInput string, network *Network) (*MuunPaymentURI, error) { if len(queryValues["r"]) != 0 { if invoice != nil && invoice.Sats != 0 { - return nil, errors.New(ErrInvalidURI, "Bip70 uris can't be used with lightning invoices with amount") + return nil, errors.New( + ErrInvalidURI, + "Bip70 uris can't be used with lightning invoices with amount", + ) } if len(address) > 0 { @@ -153,7 +160,7 @@ func GetPaymentURI(rawInput string, network *Network) (*MuunPaymentURI, error) { // Bech32 check decodedAddress, err := btcutilw.DecodeAddress(address, network.network) if err != nil { - return nil, fmt.Errorf("invalid address: %w", err) + return nil, goerr.Errorf("invalid address: %w", err) } if !decodedAddress.IsForNet(network.network) { @@ -164,10 +171,13 @@ func GetPaymentURI(rawInput string, network *Network) (*MuunPaymentURI, error) { if invoice != nil { if invoice.Sats != 0 { - invoiceAmount := decimal.NewFromInt(invoice.Sats).Div(decimal.NewFromInt(100_000_000)).String() + invoiceAmount := decimal.NewFromInt(invoice.Sats). + Div(decimal.NewFromInt(100_000_000)). + String() - // We ONLY mark the uri as invalid (and return an error) if both amount exists and are different. Otherwise, - // we will allow a way to move forward with the payment by assuming the lightning part is the correct one. + // We ONLY mark the uri as invalid (and return an error) if both amount exists and are + // different. Otherwise, we will allow a way to move forward with the payment by + // assuming the lightning part is the correct one. if amount != "" { if invoiceAmount != amount { return nil, errors.New(ErrInvalidURI, "Amount mismatch") @@ -189,9 +199,13 @@ func GetPaymentURI(rawInput string, network *Network) (*MuunPaymentURI, error) { // DoPaymentRequestCall builds a MuunPaymentUri from a url and a network. Handling BIP70 to 72 func DoPaymentRequestCall(url string, network *Network) (*MuunPaymentURI, error) { - req, err := http.NewRequest("GET", url, nil) + req, err := http.NewRequest( //nolint:noctx // TODO: use http.NewRequestWithContext + "GET", + url, + nil, + ) if err != nil { - return nil, fmt.Errorf("failed to create request to: %s", url) + return nil, goerr.Errorf("failed to create request to: %s", url) } req.Header.Set("Accept", "application/bitcoin-paymentrequest") @@ -201,7 +215,7 @@ func DoPaymentRequestCall(url string, network *Network) (*MuunPaymentURI, error) if err != nil { return nil, errors.Errorf(ErrNetwork, "failed to make request to: %s", url) } - defer resp.Body.Close() + defer resp.Body.Close() //nolint:errcheck // TODO: check error body, err := io.ReadAll(resp.Body) if err != nil { @@ -211,23 +225,23 @@ func DoPaymentRequestCall(url string, network *Network) (*MuunPaymentURI, error) payReq := &PaymentRequest{} err = proto.Unmarshal(body, payReq) if err != nil { - return nil, fmt.Errorf("failed to unmarshal payment request: %w", err) + return nil, goerr.Errorf("failed to unmarshal payment request: %w", err) } payDetails := &PaymentDetails{} err = proto.Unmarshal(payReq.SerializedPaymentDetails, payDetails) if err != nil { - return nil, fmt.Errorf("failed to unmarshall payment details: %w", err) + return nil, goerr.Errorf("failed to unmarshall payment details: %w", err) } if len(payDetails.Outputs) == 0 { - return nil, fmt.Errorf("no outputs provided") + return nil, goerr.Errorf("no outputs provided") } address, err := getAddressFromScript(payDetails.Outputs[0].Script, network) if err != nil { - return nil, fmt.Errorf("failed to get address: %w", err) + return nil, goerr.Errorf("failed to get address: %w", err) } amount := float64(payDetails.Outputs[0].Amount) / 100_000_000 @@ -254,8 +268,16 @@ func getAddressFromScript(script []byte, network *Network) (string, error) { return address.String(), nil } -func buildUriFromString(rawInput string, targetScheme string) (string, *url.URL) { - newUri := strings.Replace(rawInput, muunScheme, targetScheme, 1) +func buildUriFromString( //nolint:staticcheck // TODO: func buildUriFromString should be buildURIFromString + rawInput string, + targetScheme string, +) (string, *url.URL) { + newUri := strings.Replace( //nolint:staticcheck // TODO: var newUri should be newURI + rawInput, + muunScheme, + targetScheme, + 1, + ) if !strings.HasPrefix(strings.ToLower(newUri), targetScheme) { newUri = targetScheme + rawInput } diff --git a/libwallet/address_test.go b/libwallet/address_test.go index 6fdfcac3..67d2245b 100644 --- a/libwallet/address_test.go +++ b/libwallet/address_test.go @@ -25,29 +25,57 @@ const ( bip70NonRetroCompatAddress = bitcoinScheme + "?r=" + bip70URL bip70RetroCompatAddress = bitcoinScheme + address + "?r=" + bip70URL - invoice = "lnbcrt1pwtpd4xpp55meuklpslk5jtxytyh7u2q490c2xhm68dm3a94486zntsg7ad4vsdqqcqzys763w70h39ze44ngzhdt2mag84wlkefqkphuy7ssg4la5gt9vcpmqts00fnapf8frs928mc5ujfutzyu8apkezhrfvydx82l40w0fckqqmerzjc" + invoice = "lnbcrt1pwtpd4xpp55meuklpslk5jtxytyh7u2q490c2xhm68dm3a94486zntsg7ad4vsdqqcqzys763w70h39ze44ngzhdt2mag84wlkefqkphuy7ssg4la5gt9vcpmqts00fnapf8frs928mc5ujfutzyu8apkezhrfvydx82l40w0fckqqmerzjc" //nolint:lll invoiceHashHex = "a6f3cb7c30fda925988b25fdc502a57e146bef476ee3d2d6a7d0a6b823dd6d59" invoiceDestinationHex = "028cfad4e092191a41f081bedfbe5a6e8f441603c78bf9001b8fb62ac0858f20edasd" - invoice100Sat = "lnbcrt1u1p3hdgr2pp50m67ca8yyejjlzwmf02fvlu4kejf8twmftxfu7l3jhesnlfu0hjqdqqcqzpgxqyz5vqsp56yu7l6qqftslzhclnfwtnrlwtsrpn2nvy08kzskarhj76lqspenq9qyyssqet8mqeg5yqh06aqf9sqnkya6mud3qat84s0gdplmd3dpncsjjesj5ja24n8qxwt2d968g4laeggl0txtjy48razr7unpnk4fmga87dgqj88quh" + invoice100Sat = "lnbcrt1u1p3hdgr2pp50m67ca8yyejjlzwmf02fvlu4kejf8twmftxfu7l3jhesnlfu0hjqdqqcqzpgxqyz5vqsp56yu7l6qqftslzhclnfwtnrlwtsrpn2nvy08kzskarhj76lqspenq9qyyssqet8mqeg5yqh06aqf9sqnkya6mud3qat84s0gdplmd3dpncsjjesj5ja24n8qxwt2d968g4laeggl0txtjy48razr7unpnk4fmga87dgqj88quh" //nolint:lll invoice100SatHashHex = "7ef5ec74e426652f89db4bd4967f95b66493addb4acc9e7bf195f309fd3c7de4" - invoice100SatDestinationHex = "03373f5fb6babc2627cc3003646cc19cc2225bd699013e3e29c6b94857596c1c15" + invoice100SatDestinationHex = "03373f5fb6babc2627cc3003646cc19cc2225bd699013e3e29c6b94857596c1c15" //nolint:lll - invoice19Sat = "lnbcrt190n1p3clxyrpp5w0l3cr5s49vasv3npx9ud6apw6agpv02aq5r70fhhvz5vatlhglsdqqcqzpgxqyz5vqsp5pjdr6rjpghugd5pyafa7shphqup744rtr4d7smrkfjs26cgyshyq9qyyssqx0gfkqdf3y344ejpzl3zqjyl4qwgw3xm4x4v5da73rrshw94ch0nqz3rfrgeykkws3nypystqttty562r604scgqv09agq3t7cxz8zcpf5em4q" + invoice19Sat = "lnbcrt190n1p3clxyrpp5w0l3cr5s49vasv3npx9ud6apw6agpv02aq5r70fhhvz5vatlhglsdqqcqzpgxqyz5vqsp5pjdr6rjpghugd5pyafa7shphqup744rtr4d7smrkfjs26cgyshyq9qyyssqx0gfkqdf3y344ejpzl3zqjyl4qwgw3xm4x4v5da73rrshw94ch0nqz3rfrgeykkws3nypystqttty562r604scgqv09agq3t7cxz8zcpf5em4q" //nolint:lll invoice19SatHashHex = "73ff1c0e90a959d83233098bc6eba176ba80b1eae8283f3d37bb0546757fba3f" - invoice19SatDestinationHex = "03373f5fb6babc2627cc3003646cc19cc2225bd699013e3e29c6b94857596c1c15" - - lightningParam = "lightning=" - bip21UnifiedQr = bitcoinScheme + address + "?" + lightningParam + invoice - bip21UnifiedQrWithAmount = bitcoinScheme + address + "?amount=0.000001&" + lightningParam + invoice100Sat - bip21UnifiedQrWithAmountMismatch = bitcoinScheme + address + "?amount=2&" + lightningParam + invoice100Sat - bip21UnifiedQrInconsistentCase1 = bitcoinScheme + address + "?" + lightningParam + invoice100Sat - bip21UnifiedQrInconsistentCase2 = bitcoinScheme + address + "?amount=2&" + lightningParam + invoice - - bip21UnifiedQrBip70RetroCompat = bip70RetroCompatAddress + "&" + lightningParam + invoice - bip21UnifiedQrBip70RetroCompatWithAmount = bip70RetroCompatAddress + "&" + lightningParam + invoice100Sat - bip21UnifiedQrBip70NonRetroCompat = bip70NonRetroCompatAddress + "&" + lightningParam + invoice - bip21UnifiedQrBip70NonRetroCompatWithAmount = bip70NonRetroCompatAddress + "&" + lightningParam + invoice100Sat + invoice19SatDestinationHex = "03373f5fb6babc2627cc3003646cc19cc2225bd699013e3e29c6b94857596c1c15" //nolint:lll + + lightningParam = "lightning=" + bip21UnifiedQr = bitcoinScheme + address + "?" + lightningParam + invoice + bip21UnifiedQrWithAmount = bitcoinScheme + + address + + "?amount=0.000001&" + + lightningParam + + invoice100Sat + bip21UnifiedQrWithAmountMismatch = bitcoinScheme + + address + + "?amount=2&" + + lightningParam + + invoice100Sat + bip21UnifiedQrInconsistentCase1 = bitcoinScheme + + address + + "?" + + lightningParam + + invoice100Sat + bip21UnifiedQrInconsistentCase2 = bitcoinScheme + + address + + "?amount=2&" + + lightningParam + + invoice + + bip21UnifiedQrBip70RetroCompat = bip70RetroCompatAddress + + "&" + + lightningParam + + invoice + bip21UnifiedQrBip70RetroCompatWithAmount = bip70RetroCompatAddress + + "&" + + lightningParam + + invoice100Sat + bip21UnifiedQrBip70NonRetroCompat = bip70NonRetroCompatAddress + + "&" + + lightningParam + + invoice + bip21UnifiedQrBip70NonRetroCompatWithAmount = bip70NonRetroCompatAddress + + "&" + + lightningParam + + invoice100Sat ) func TestGetPaymentURI(t *testing.T) { @@ -112,7 +140,8 @@ func TestGetPaymentURI(t *testing.T) { { name: "completeValidAddress with scientific notation amount", args: args{ - address: address + "?amount=01.2e-3" + "&label=hola&message=mensaje%20con%20espacios", + address: address + "?amount=01.2e-3" + + "&label=hola&message=mensaje%20con%20espacios", network: *Regtest(), }, want: &MuunPaymentURI{ @@ -120,7 +149,8 @@ func TestGetPaymentURI(t *testing.T) { Amount: "0.0012", Label: "hola", Message: "mensaje con espacios", - Uri: bitcoinScheme + address + "?amount=01.2e-3" + "&label=hola&message=mensaje%20con%20espacios", + Uri: bitcoinScheme + address + "?amount=01.2e-3" + + "&label=hola&message=mensaje%20con%20espacios", }, }, { @@ -204,14 +234,17 @@ func TestGetPaymentURI(t *testing.T) { { name: "bad amount format", args: args{ - address: address + "?amount=Nan" + "&label=hola&message=mensaje%20con%20espacios", + address: address + "?amount=Nan" + + "&label=hola&message=mensaje%20con%20espacios", network: *Regtest(), }, - // TODO we should probably return an error here but that breaks current assumptions in newop state + // TODO we should probably return an error here but that breaks + // current assumptions in newop state // machine (see TestInvalidAmountEmitsInvalidAddress in state_test.go) want: &MuunPaymentURI{ Address: address, - Uri: bitcoinScheme + address + "?amount=Nan" + "&label=hola&message=mensaje%20con%20espacios", + Uri: bitcoinScheme + address + "?amount=Nan" + + "&label=hola&message=mensaje%20con%20espacios", Amount: "Nan", Label: "hola", Message: "mensaje con espacios", @@ -220,14 +253,17 @@ func TestGetPaymentURI(t *testing.T) { { name: "bad amount format 2", args: args{ - address: address + "?amount=hola" + "&label=hola&message=mensaje%20con%20espacios", + address: address + "?amount=hola" + + "&label=hola&message=mensaje%20con%20espacios", network: *Regtest(), }, - // TODO we should probably return an error here but that breaks current assumptions in newop state + // TODO we should probably return an error here but that breaks + // current assumptions in newop state // machine (see TestInvalidAmountEmitsInvalidAddress in state_test.go) want: &MuunPaymentURI{ Address: address, - Uri: bitcoinScheme + address + "?amount=hola" + "&label=hola&message=mensaje%20con%20espacios", + Uri: bitcoinScheme + address + "?amount=hola" + + "&label=hola&message=mensaje%20con%20espacios", Amount: "hola", Label: "hola", Message: "mensaje con espacios", @@ -319,8 +355,8 @@ func TestGetPaymentURI(t *testing.T) { address: bip21UnifiedQrInconsistentCase1, network: *network, }, - // Instead of marking this uri as invalid (technically correct) and showing an error we allow a way forward - // for the payment and assume the lightning part is the correct one + // Instead of marking this uri as invalid (technically correct) and showing an error we + // allow a way forward for the payment and assume the lightning part is the correct one want: &MuunPaymentURI{ Address: address, Uri: bip21UnifiedQrInconsistentCase1, @@ -342,8 +378,8 @@ func TestGetPaymentURI(t *testing.T) { address: bip21UnifiedQrInconsistentCase2, network: *network, }, - // Instead of marking this uri as invalid (technically correct) and showing an error we allow a way forward - // for the payment and assume the lightning part is the correct one + // Instead of marking this uri as invalid (technically correct) and showing an error we + // allow a way forward for the payment and assume the lightning part is the correct one want: &MuunPaymentURI{ Address: address, Amount: "2", @@ -420,8 +456,10 @@ func TestGetPaymentURI(t *testing.T) { network: *Mainnet(), }, want: &MuunPaymentURI{ - Address: strings.ToLower("BC1QSQP0D3TY8AAA8N9J8R0D2PF3G40VN4AS9TPWY3J9R3GK5K64VX6QWPAXH2"), - Uri: "BITCOIN:BC1QSQP0D3TY8AAA8N9J8R0D2PF3G40VN4AS9TPWY3J9R3GK5K64VX6QWPAXH2", + Address: strings.ToLower( + "BC1QSQP0D3TY8AAA8N9J8R0D2PF3G40VN4AS9TPWY3J9R3GK5K64VX6QWPAXH2", + ), + Uri: "BITCOIN:BC1QSQP0D3TY8AAA8N9J8R0D2PF3G40VN4AS9TPWY3J9R3GK5K64VX6QWPAXH2", }, }, { @@ -431,8 +469,10 @@ func TestGetPaymentURI(t *testing.T) { network: *Mainnet(), }, want: &MuunPaymentURI{ - Address: strings.ToLower("BC1QSQP0D3TY8AAA8N9J8R0D2PF3G40VN4AS9TPWY3J9R3GK5K64VX6QWPAXH2"), - Uri: "BiTcOiN:BC1QSQP0D3TY8AAA8N9J8R0D2PF3G40VN4AS9TPWY3J9R3GK5K64VX6QWPAXH2", + Address: strings.ToLower( + "BC1QSQP0D3TY8AAA8N9J8R0D2PF3G40VN4AS9TPWY3J9R3GK5K64VX6QWPAXH2", + ), + Uri: "BiTcOiN:BC1QSQP0D3TY8AAA8N9J8R0D2PF3G40VN4AS9TPWY3J9R3GK5K64VX6QWPAXH2", }, }, { @@ -471,7 +511,11 @@ func TestGetPaymentURI(t *testing.T) { if !reflect.DeepEqual(got, tt.want) { var invoiceDiff = "" if !reflect.DeepEqual(got.Invoice, tt.want.Invoice) { - invoiceDiff = fmt.Sprintf("Invoice = %+v, want %+v", got.Invoice, tt.want.Invoice) + invoiceDiff = fmt.Sprintf( + "Invoice = %+v, want %+v", + got.Invoice, + tt.want.Invoice, + ) } t.Errorf("GetPaymentURI() = %+v, want %+v. %v", got, tt.want, invoiceDiff) } @@ -524,8 +568,14 @@ func Test_normalizeAddress(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got, _ := buildUriFromString(tt.args.rawAddress, tt.args.targetScheme); got != tt.want { - t.Errorf("buildUriFromString() = %v, want %v", got, tt.want) + got, _ := buildUriFromString( + tt.args.rawAddress, tt.args.targetScheme, + ) + if got != tt.want { + t.Errorf( + "buildUriFromString() = %v, want %v", + got, tt.want, + ) } }) } @@ -554,7 +604,9 @@ func TestDoPaymentRequestCall(t *testing.T) { PaymentUrl: "http://localhost:8000/pay", MerchantData: []byte(""), }) - payReq, _ := proto.Marshal(&PaymentRequest{SerializedPaymentDetails: serializedPaymentDetails}) + payReq, _ := proto.Marshal( + &PaymentRequest{SerializedPaymentDetails: serializedPaymentDetails}, + ) w.Write(payReq) }) diff --git a/libwallet/addresses/addresses.go b/libwallet/addresses/addresses.go index 0a446897..90631a5e 100644 --- a/libwallet/addresses/addresses.go +++ b/libwallet/addresses/addresses.go @@ -1,10 +1,10 @@ package addresses import ( - "fmt" - "github.com/btcsuite/btcd/btcutil/hdkeychain" "github.com/btcsuite/btcd/chaincfg" + "github.com/go-errors/errors" + "github.com/muun/libwallet/musig" ) @@ -15,6 +15,9 @@ const ( V4 = 4 V5 = 5 V6 = 6 + V7 = 7 + V8 = 8 + V9 = 9 SubmarineSwapV1 = 101 SubmarineSwapV2 = 102 IncomingSwap = 201 @@ -34,7 +37,12 @@ func New(version int, derivationPath string, address string) *WalletAddress { } } -func Create(version int, userKey, muunKey *hdkeychain.ExtendedKey, path string, network *chaincfg.Params) (*WalletAddress, error) { +func Create( + version int, + userKey, muunKey *hdkeychain.ExtendedKey, + path string, + network *chaincfg.Params, +) (*WalletAddress, error) { switch version { case V1: return CreateAddressV1(userKey, path, network) @@ -49,7 +57,7 @@ func Create(version int, userKey, muunKey *hdkeychain.ExtendedKey, path string, case V6: return CreateAddressV6(userKey, muunKey, path, network) default: - return nil, fmt.Errorf("unknown or unsupported version %v", version) + return nil, errors.Errorf("unknown or unsupported version %v", version) } } diff --git a/libwallet/addresses/addresses_test.go b/libwallet/addresses/addresses_test.go index 880ffbb8..eda483bc 100644 --- a/libwallet/addresses/addresses_test.go +++ b/libwallet/addresses/addresses_test.go @@ -2,6 +2,7 @@ package addresses import ( "github.com/btcsuite/btcd/btcutil/hdkeychain" + "github.com/muun/libwallet/hdpath" ) diff --git a/libwallet/addresses/v1.go b/libwallet/addresses/v1.go index 69ee702d..f65dd5ed 100644 --- a/libwallet/addresses/v1.go +++ b/libwallet/addresses/v1.go @@ -7,7 +7,11 @@ import ( ) // CreateAddressV1 returns a P2PKH WalletAddress from a publicKey for use in TransactionSchemeV1 -func CreateAddressV1(userKey *hdkeychain.ExtendedKey, path string, network *chaincfg.Params) (*WalletAddress, error) { +func CreateAddressV1( + userKey *hdkeychain.ExtendedKey, + path string, + network *chaincfg.Params, +) (*WalletAddress, error) { pubKey, err := userKey.ECPubKey() if err != nil { return nil, err diff --git a/libwallet/addresses/v2.go b/libwallet/addresses/v2.go index 2459e290..57c6e1d6 100644 --- a/libwallet/addresses/v2.go +++ b/libwallet/addresses/v2.go @@ -1,25 +1,28 @@ package addresses import ( - "fmt" - "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/btcutil/hdkeychain" "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/txscript" + goerr "github.com/go-errors/errors" "github.com/pkg/errors" ) -func CreateAddressV2(userKey, muunKey *hdkeychain.ExtendedKey, path string, network *chaincfg.Params) (*WalletAddress, error) { +func CreateAddressV2( + userKey, muunKey *hdkeychain.ExtendedKey, + path string, + network *chaincfg.Params, +) (*WalletAddress, error) { script, err := CreateRedeemScriptV2(userKey, muunKey, network) if err != nil { - return nil, fmt.Errorf("failed to generate redeem script v2: %w", err) + return nil, goerr.Errorf("failed to generate redeem script v2: %w", err) } address, err := btcutil.NewAddressScriptHash(script, network) if err != nil { - return nil, fmt.Errorf("failed to generate multisig address: %w", err) + return nil, goerr.Errorf("failed to generate multisig address: %w", err) } return &WalletAddress{ @@ -29,11 +32,17 @@ func CreateAddressV2(userKey, muunKey *hdkeychain.ExtendedKey, path string, netw }, nil } -func CreateRedeemScriptV2(userKey, muunKey *hdkeychain.ExtendedKey, network *chaincfg.Params) ([]byte, error) { +func CreateRedeemScriptV2( + userKey, muunKey *hdkeychain.ExtendedKey, + network *chaincfg.Params, +) ([]byte, error) { return createMultisigRedeemScript(userKey, muunKey, network) } -func createMultisigRedeemScript(userKey, muunKey *hdkeychain.ExtendedKey, network *chaincfg.Params) ([]byte, error) { +func createMultisigRedeemScript( + userKey, muunKey *hdkeychain.ExtendedKey, + network *chaincfg.Params, +) ([]byte, error) { userPublicKey, err := userKey.ECPubKey() if err != nil { return nil, err diff --git a/libwallet/addresses/v2_test.go b/libwallet/addresses/v2_test.go index dcaf9b8f..44663bc2 100755 --- a/libwallet/addresses/v2_test.go +++ b/libwallet/addresses/v2_test.go @@ -16,10 +16,10 @@ func TestCreateAddressV2(t *testing.T) { addressPath = "m/schema:1'/recovery:1'/external:1/0" originAddress = "2NDeWrsJEwvxwVnvtWzPjhDC5B2LYkFuX2s" - encodedMuunKey = "tpubDBYMnFoxYLdMBZThTk4uARTe4kGPeEYWdKcaEzaUxt1cesetnxtTqmAxVkzDRou51emWytommyLWcF91SdF5KecA6Ja8oHK1FF7d5U2hMxX" - encodedUserKey = "tprv8dfM4H5fYJirMai5Er3LguicgUAyxmcSQbFub5ens16amX1e1HAFiW4SXnFVw9nu9FedFQqTPGTTjPEmgfvvXMKww3UcRpFbbC4DFjbCcTb" + encodedMuunKey = "tpubDBYMnFoxYLdMBZThTk4uARTe4kGPeEYWdKcaEzaUxt1cesetnxtTqmAxVkzDRou51emWytommyLWcF91SdF5KecA6Ja8oHK1FF7d5U2hMxX" //nolint:lll + encodedUserKey = "tprv8dfM4H5fYJirMai5Er3LguicgUAyxmcSQbFub5ens16amX1e1HAFiW4SXnFVw9nu9FedFQqTPGTTjPEmgfvvXMKww3UcRpFbbC4DFjbCcTb" //nolint:lll basePath = "m/schema:1'/recovery:1'" - v2EncodedScript = "5221029fa5af7a34c142c1ce348b360abeb7de01df25b1d50129e58a67a6b846c9303b21025714f6b3670d4a38f5e2d6e8f239c9fc072543ce33dca54fcb4f4886a5cb87a652ae" + v2EncodedScript = "5221029fa5af7a34c142c1ce348b360abeb7de01df25b1d50129e58a67a6b846c9303b21025714f6b3670d4a38f5e2d6e8f239c9fc072543ce33dca54fcb4f4886a5cb87a652ae" //nolint:lll ) baseMuunKey := parseKey(encodedMuunKey) diff --git a/libwallet/addresses/v3.go b/libwallet/addresses/v3.go index b7d59847..5bdd1d56 100644 --- a/libwallet/addresses/v3.go +++ b/libwallet/addresses/v3.go @@ -2,16 +2,19 @@ package addresses import ( "crypto/sha256" - "fmt" - - "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/btcutil/hdkeychain" + "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/txscript" + "github.com/go-errors/errors" ) -func CreateAddressV3(userKey, muunKey *hdkeychain.ExtendedKey, path string, network *chaincfg.Params) (*WalletAddress, error) { +func CreateAddressV3( + userKey, muunKey *hdkeychain.ExtendedKey, + path string, + network *chaincfg.Params, +) (*WalletAddress, error) { redeemScript, err := CreateRedeemScriptV3(userKey, muunKey, network) if err != nil { @@ -30,16 +33,22 @@ func CreateAddressV3(userKey, muunKey *hdkeychain.ExtendedKey, path string, netw }, nil } -func CreateRedeemScriptV3(userKey, muunKey *hdkeychain.ExtendedKey, network *chaincfg.Params) ([]byte, error) { +func CreateRedeemScriptV3( + userKey, muunKey *hdkeychain.ExtendedKey, + network *chaincfg.Params, +) ([]byte, error) { witnessScript, err := CreateWitnessScriptV3(userKey, muunKey, network) if err != nil { - return nil, fmt.Errorf("failed to generate redeem script v3: %w", err) + return nil, errors.Errorf("failed to generate redeem script v3: %w", err) } return createNonNativeSegwitRedeemScript(witnessScript) } -func CreateWitnessScriptV3(userKey, muunKey *hdkeychain.ExtendedKey, network *chaincfg.Params) ([]byte, error) { +func CreateWitnessScriptV3( + userKey, muunKey *hdkeychain.ExtendedKey, + network *chaincfg.Params, +) ([]byte, error) { // createMultisigRedeemScript creates a valid script for both V2 and V3 schemes return createMultisigRedeemScript(userKey, muunKey, network) } diff --git a/libwallet/addresses/v3_test.go b/libwallet/addresses/v3_test.go index 0f18f96e..e23774d0 100755 --- a/libwallet/addresses/v3_test.go +++ b/libwallet/addresses/v3_test.go @@ -13,8 +13,8 @@ func TestCreateAddressV3(t *testing.T) { addressPath = "m/schema:1'/recovery:1'/external:1/0" v3Address = "2MswEXmCLaHQq6pUTtnUVF8wVArfYSqUec5" - basePK = "tpubDAN21T1DFREQQS4FvpUktKRBzXXsj5ddenAa5u198hLXvErFFR4Lj8bt8xMG3xnZr6u8mx1vrFW9RwCDXQwQuYRCLq1j9Nr2VJUrENzteQH" - baseCosigningPK = "tpubDAsVhzq6otpasovieofhiaY38bSFGyJaBGvrJjBv9whhSnftUXfMTMVrq4BbTXT5A9b78CqqbPuM2j1ZGWdiggd7JHUTZAHh8GXDTt4Pkj9" + basePK = "tpubDAN21T1DFREQQS4FvpUktKRBzXXsj5ddenAa5u198hLXvErFFR4Lj8bt8xMG3xnZr6u8mx1vrFW9RwCDXQwQuYRCLq1j9Nr2VJUrENzteQH" //nolint:lll + baseCosigningPK = "tpubDAsVhzq6otpasovieofhiaY38bSFGyJaBGvrJjBv9whhSnftUXfMTMVrq4BbTXT5A9b78CqqbPuM2j1ZGWdiggd7JHUTZAHh8GXDTt4Pkj9" //nolint:lll basePath = "m/schema:1'/recovery:1'" v3EncodedScript = "0020e1fbfbd395aff8b4087fee3e4488815ef659b559b3cd0d6800b5a591efd99f38" ) diff --git a/libwallet/addresses/v4.go b/libwallet/addresses/v4.go index d9c016ff..09d58853 100644 --- a/libwallet/addresses/v4.go +++ b/libwallet/addresses/v4.go @@ -2,19 +2,24 @@ package addresses import ( "crypto/sha256" - "fmt" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/btcutil/hdkeychain" "github.com/btcsuite/btcd/chaincfg" + "github.com/go-errors/errors" ) -// CreateAddressV4 returns a P2WSH WalletAddress from a user HD-pubkey and a Muun co-signing HD-pubkey. -func CreateAddressV4(userKey, muunKey *hdkeychain.ExtendedKey, path string, network *chaincfg.Params) (*WalletAddress, error) { +// CreateAddressV4 returns a P2WSH WalletAddress from a user HD-pubkey and a Muun co-signing +// HD-pubkey. +func CreateAddressV4( + userKey, muunKey *hdkeychain.ExtendedKey, + path string, + network *chaincfg.Params, +) (*WalletAddress, error) { witnessScript, err := CreateWitnessScriptV4(userKey, muunKey, network) if err != nil { - return nil, fmt.Errorf("failed to generate witness script v4: %w", err) + return nil, errors.Errorf("failed to generate witness script v4: %w", err) } witnessScript256 := sha256.Sum256(witnessScript) @@ -30,7 +35,10 @@ func CreateAddressV4(userKey, muunKey *hdkeychain.ExtendedKey, path string, netw }, nil } -func CreateWitnessScriptV4(userKey, muunKey *hdkeychain.ExtendedKey, network *chaincfg.Params) ([]byte, error) { +func CreateWitnessScriptV4( + userKey, muunKey *hdkeychain.ExtendedKey, + network *chaincfg.Params, +) ([]byte, error) { // createMultisigRedeemScript creates a valid script for V2, V3 and V4 schemes return createMultisigRedeemScript(userKey, muunKey, network) } diff --git a/libwallet/addresses/v4_test.go b/libwallet/addresses/v4_test.go index c3d8a9d4..091e2ed7 100755 --- a/libwallet/addresses/v4_test.go +++ b/libwallet/addresses/v4_test.go @@ -12,8 +12,8 @@ func TestCreateAddressV4(t *testing.T) { addressPath = "m/schema:1'/recovery:1'/external:1/2" v4Address = "bcrt1qrs3vk4dzv70syck2qdz3g06tgckq4pftenuk5p77st9glnskpvtqe2tvvk" - basePK = "tpubDBf5wCeqg3KrLJiXaveDzD5JtFJ1ss9NVvFMx4RYS73SjwPEEawcAQ7V1B5DGM4gunWDeYNrnkc49sUaf7mS1wUKiJJQD6WEctExUQoLvrg" - baseCosigningPK = "tpubDB22PFkUaHoB7sgxh7exCivV5rAevVSzbB8WkFCCdbHq39r8xnYexiot4NGbi8PM6E1ySVeaHsoDeMYb6EMndpFrzVmuX8iQNExzwNpU61B" + basePK = "tpubDBf5wCeqg3KrLJiXaveDzD5JtFJ1ss9NVvFMx4RYS73SjwPEEawcAQ7V1B5DGM4gunWDeYNrnkc49sUaf7mS1wUKiJJQD6WEctExUQoLvrg" //nolint:lll + baseCosigningPK = "tpubDB22PFkUaHoB7sgxh7exCivV5rAevVSzbB8WkFCCdbHq39r8xnYexiot4NGbi8PM6E1ySVeaHsoDeMYb6EMndpFrzVmuX8iQNExzwNpU61B" //nolint:lll basePath = "m/schema:1'/recovery:1'" ) diff --git a/libwallet/addresses/v5.go b/libwallet/addresses/v5.go index 73cbc36f..411d9092 100644 --- a/libwallet/addresses/v5.go +++ b/libwallet/addresses/v5.go @@ -1,19 +1,24 @@ package addresses import ( - "fmt" - "github.com/btcsuite/btcd/btcutil/hdkeychain" "github.com/btcsuite/btcd/chaincfg" + "github.com/go-errors/errors" + "github.com/muun/libwallet/btcsuitew/btcutilw" "github.com/muun/libwallet/musig" ) -// CreateAddressV5 returns a P2TR WalletAddress using Musig2v040Muun with the signing and cosigning keys. -func CreateAddressV5(userKey, muunKey *hdkeychain.ExtendedKey, path string, network *chaincfg.Params) (*WalletAddress, error) { +// CreateAddressV5 returns a P2TR WalletAddress using Musig2v040Muun with the signing and cosigning +// keys. +func CreateAddressV5( + userKey, muunKey *hdkeychain.ExtendedKey, + path string, + network *chaincfg.Params, +) (*WalletAddress, error) { witnessProgram, err := CreateWitnessScriptV5(userKey, muunKey) if err != nil { - return nil, fmt.Errorf("failed to generate witness script v5: %w", err) + return nil, errors.Errorf("failed to generate witness script v5: %w", err) } address, err := btcutilw.NewAddressTaprootKey(witnessProgram, network) @@ -31,11 +36,11 @@ func CreateAddressV5(userKey, muunKey *hdkeychain.ExtendedKey, path string, netw func CreateWitnessScriptV5(userKey, muunKey *hdkeychain.ExtendedKey) ([]byte, error) { userPublicKey, err := userKey.ECPubKey() if err != nil { - return nil, fmt.Errorf("error getting pub key: %w", err) + return nil, errors.Errorf("error getting pub key: %w", err) } muunPublicKey, err := muunKey.ECPubKey() if err != nil { - return nil, fmt.Errorf("error getting pub key: %w", err) + return nil, errors.Errorf("error getting pub key: %w", err) } pubKeys := [][]byte{ @@ -47,7 +52,7 @@ func CreateWitnessScriptV5(userKey, muunKey *hdkeychain.ExtendedKey) ([]byte, er aggregateKey, err := musig.Musig2CombinePubKeysWithTweak(musig.Musig2v040Muun, pubKeys, tweak) if err != nil { - return nil, fmt.Errorf("error combining keys: %w", err) + return nil, errors.Errorf("error combining keys: %w", err) } xOnlyCombined := aggregateKey.FinalKey.SerializeCompressed()[1:] diff --git a/libwallet/addresses/v5_test.go b/libwallet/addresses/v5_test.go index 0aefcb18..959299a9 100644 --- a/libwallet/addresses/v5_test.go +++ b/libwallet/addresses/v5_test.go @@ -10,8 +10,8 @@ func TestCreateAddressV5(t *testing.T) { addressPath = "m/schema:1'/recovery:1'/external:1/17" v5Address = "bcrt1pvqngr85tm8hmsv2hjyrejlpsy7u65f7vke8mmrxnyuj3aj3xsapqvh8yrf" - basePK = "tpubDBf5wCeqg3KrLJiXaveDzD5JtFJ1ss9NVvFMx4RYS73SjwPEEawcAQ7V1B5DGM4gunWDeYNrnkc49sUaf7mS1wUKiJJQD6WEctExUQoLvrg" - baseCosigningPK = "tpubDB22PFkUaHoB7sgxh7exCivV5rAevVSzbB8WkFCCdbHq39r8xnYexiot4NGbi8PM6E1ySVeaHsoDeMYb6EMndpFrzVmuX8iQNExzwNpU61B" + basePK = "tpubDBf5wCeqg3KrLJiXaveDzD5JtFJ1ss9NVvFMx4RYS73SjwPEEawcAQ7V1B5DGM4gunWDeYNrnkc49sUaf7mS1wUKiJJQD6WEctExUQoLvrg" //nolint:lll + baseCosigningPK = "tpubDB22PFkUaHoB7sgxh7exCivV5rAevVSzbB8WkFCCdbHq39r8xnYexiot4NGbi8PM6E1ySVeaHsoDeMYb6EMndpFrzVmuX8iQNExzwNpU61B" //nolint:lll basePath = "m/schema:1'/recovery:1'" ) diff --git a/libwallet/addresses/v6.go b/libwallet/addresses/v6.go index 832f059f..c068dd89 100644 --- a/libwallet/addresses/v6.go +++ b/libwallet/addresses/v6.go @@ -1,19 +1,24 @@ package addresses import ( - "fmt" - "github.com/btcsuite/btcd/btcutil/hdkeychain" "github.com/btcsuite/btcd/chaincfg" + "github.com/go-errors/errors" + "github.com/muun/libwallet/btcsuitew/btcutilw" "github.com/muun/libwallet/musig" ) -// CreateAddressV6 returns a P2TR WalletAddress using Musig2v100 with the signing and cosigning keys. -func CreateAddressV6(userKey, muunKey *hdkeychain.ExtendedKey, path string, network *chaincfg.Params) (*WalletAddress, error) { +// CreateAddressV6 returns a P2TR WalletAddress using Musig2v100 with the signing and cosigning +// keys. +func CreateAddressV6( + userKey, muunKey *hdkeychain.ExtendedKey, + path string, + network *chaincfg.Params, +) (*WalletAddress, error) { witnessProgram, err := CreateWitnessScriptV6(userKey, muunKey) if err != nil { - return nil, fmt.Errorf("failed to generate witness script v5: %w", err) + return nil, errors.Errorf("failed to generate witness script v5: %w", err) } address, err := btcutilw.NewAddressTaprootKey(witnessProgram, network) @@ -31,11 +36,11 @@ func CreateAddressV6(userKey, muunKey *hdkeychain.ExtendedKey, path string, netw func CreateWitnessScriptV6(userKey, muunKey *hdkeychain.ExtendedKey) ([]byte, error) { userPublicKey, err := userKey.ECPubKey() if err != nil { - return nil, fmt.Errorf("error getting pub key: %w", err) + return nil, errors.Errorf("error getting pub key: %w", err) } muunPublicKey, err := muunKey.ECPubKey() if err != nil { - return nil, fmt.Errorf("error getting pub key: %w", err) + return nil, errors.Errorf("error getting pub key: %w", err) } pubKeys := [][]byte{ @@ -47,7 +52,7 @@ func CreateWitnessScriptV6(userKey, muunKey *hdkeychain.ExtendedKey) ([]byte, er aggregateKey, err := musig.Musig2CombinePubKeysWithTweak(musig.Musig2v100, pubKeys, tweak) if err != nil { - return nil, fmt.Errorf("error combining keys: %w", err) + return nil, errors.Errorf("error combining keys: %w", err) } xOnlyCombined := aggregateKey.FinalKey.SerializeCompressed()[1:] diff --git a/libwallet/addresses/v6_test.go b/libwallet/addresses/v6_test.go index 74fa2ebe..b8aed74e 100644 --- a/libwallet/addresses/v6_test.go +++ b/libwallet/addresses/v6_test.go @@ -10,8 +10,8 @@ func TestCreateAddressV6(t *testing.T) { addressPath = "m/schema:1'/recovery:1'/external:1/17" v6Address = "bcrt1pn6w5h4pysxfuusld4xg8ppquewfk7mg9uzvaxjv625dq7ac6z3ys4amxtf" - basePK = "tpubDBf5wCeqg3KrLJiXaveDzD5JtFJ1ss9NVvFMx4RYS73SjwPEEawcAQ7V1B5DGM4gunWDeYNrnkc49sUaf7mS1wUKiJJQD6WEctExUQoLvrg" - baseCosigningPK = "tpubDB22PFkUaHoB7sgxh7exCivV5rAevVSzbB8WkFCCdbHq39r8xnYexiot4NGbi8PM6E1ySVeaHsoDeMYb6EMndpFrzVmuX8iQNExzwNpU61B" + basePK = "tpubDBf5wCeqg3KrLJiXaveDzD5JtFJ1ss9NVvFMx4RYS73SjwPEEawcAQ7V1B5DGM4gunWDeYNrnkc49sUaf7mS1wUKiJJQD6WEctExUQoLvrg" //nolint:lll + baseCosigningPK = "tpubDB22PFkUaHoB7sgxh7exCivV5rAevVSzbB8WkFCCdbHq39r8xnYexiot4NGbi8PM6E1ySVeaHsoDeMYb6EMndpFrzVmuX8iQNExzwNpU61B" //nolint:lll basePath = "m/schema:1'/recovery:1'" ) diff --git a/libwallet/addresses/v7.go b/libwallet/addresses/v7.go new file mode 100644 index 00000000..55abd5d7 --- /dev/null +++ b/libwallet/addresses/v7.go @@ -0,0 +1,102 @@ +package addresses + +import ( + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/btcutil/hdkeychain" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/txscript" + "github.com/go-errors/errors" +) + +// CreateAddressV7 returns a wrapped-segwit (P2SH-P2WSH) address for `to_client` +// (user AND muun AND lightningPeer) // collaborative spend +// OR (user AND muun AND older(blocksForExpiration)) // non-collaborative spend +func CreateAddressV7( + userKey, muunKey, lightningPeerKey *hdkeychain.ExtendedKey, + blocksForExpiration int64, + path string, + network *chaincfg.Params, +) (*WalletAddress, error) { + userEcPubKey, err := userKey.ECPubKey() + if err != nil { + return nil, errors.Errorf("get user public key: %w", err) + } + + muunEcPubKey, err := muunKey.ECPubKey() + if err != nil { + return nil, errors.Errorf("get muun public key: %w", err) + } + + lightningPeerEcPubKey, err := lightningPeerKey.ECPubKey() + if err != nil { + return nil, errors.Errorf("get lightning peer public key: %w", err) + } + + witnessScript, err := CreateWitnessScriptV7( + userEcPubKey, + muunEcPubKey, + lightningPeerEcPubKey, + blocksForExpiration, + ) + if err != nil { + return nil, err + } + + redeemScript, err := CreateRedeemScriptV7(witnessScript) + if err != nil { + return nil, err + } + + address, err := btcutil.NewAddressScriptHash(redeemScript, network) + if err != nil { + return nil, err + } + + return &WalletAddress{ + address: address.EncodeAddress(), + version: V7, + derivationPath: path, + }, nil +} + +// CreateRedeemScriptV7 builds the P2SH redeem script (the P2WSH program) from the witness script. +func CreateRedeemScriptV7(witnessScript []byte) ([]byte, error) { + // Wrap the P2WSH program (OP_0 ) in P2SH for legacy compatibility. + return createNonNativeSegwitRedeemScript(witnessScript) +} + +// CreateWitnessScriptV7 builds the P2WSH witness script for the M3 policy from the public keys. +// +// Two spending paths: +// 1. collaborative path: user + muun + lightningPeer +// 2. non-collaborative path: user + muun + a RELATIVE timelock of blocksForExpiration blocks +func CreateWitnessScriptV7( + userPubKey, muunPubKey, lightningPeerPubKey *btcec.PublicKey, + blocksForExpiration int64, +) ([]byte, error) { + builder := txscript.NewScriptBuilder() + + // User key, required in BOTH paths. + builder.AddData(userPubKey.SerializeCompressed()). + AddOp(txscript.OP_CHECKSIGVERIFY) + + // Muun key, required in BOTH paths. + builder.AddData(muunPubKey.SerializeCompressed()). + AddOp(txscript.OP_CHECKSIGVERIFY) + + // The lightning peer's signature doubles as the branch selector: + // - collaborative path: a valid peer signature leaves true and skips the NOTIF branch. + // - non-collaborative path: an EMPTY peer signature makes the timelock branch run. + builder.AddData(lightningPeerPubKey.SerializeCompressed()). + AddOp(txscript.OP_CHECKSIG). + AddOp(txscript.OP_IFDUP). + AddOp(txscript.OP_NOTIF). + // CHECKSEQUENCEVERIFY leaves its argument (blocksForExpiration, truthy) on the stack, so + // no OP_DROP or explicit truthy push is needed. + AddInt64(blocksForExpiration). + AddOp(txscript.OP_CHECKSEQUENCEVERIFY). + AddOp(txscript.OP_ENDIF) + + return builder.Script() +} diff --git a/libwallet/addresses/v7_test.go b/libwallet/addresses/v7_test.go new file mode 100644 index 00000000..d8866e6d --- /dev/null +++ b/libwallet/addresses/v7_test.go @@ -0,0 +1,33 @@ +package addresses + +import ( + "reflect" + "testing" +) + +func TestCreateAddressV7(t *testing.T) { + const ( + // Same keys and timelock as scanner/recovery_m3_test.go, which funds and sweeps the scheme + // against bitcoind. + userKey = "tprv8ZgxMBicQKsPdJQ6vbpyPo1TMKsGbvDULSFRrJB6z1o1Jn4x52rZWnPB8w3xjhKbfH8vz9Sv91jNsgXTn8xEEufHSme1xQmWYdrZWWSAdgN" //nolint:lll + muunKey = "tprv8ZgxMBicQKsPe9kwGRAvhKiZVq3J9tHvJbQ1SEAPuW9Ccb6yq3Zr5kPcR9647JbaGVAXPXcG7rjakUHgXEwKUD64SJSmwZr7y72KK7GG5J8" //nolint:lll + peerKey = "tprv8ZgxMBicQKsPcsbCVeqqF1KVdH7gwDJbxbzpCxDUsoXHdb6SnTPYxdwSAKGHbiCkJCG7JoDt1asvfWuA6Gbjr5GZ3L5MxLvr2Ef4Rx2HyZH" //nolint:lll + addressPath = "m/1/0" + timelock = int64(10) + v7Address = "2Mv3EchRDpwBW1zVYPu4iXRvUBfz9W7vgzd" + ) + + user := derive(parseKey(userKey), "m", addressPath) + muun := derive(parseKey(muunKey), "m", addressPath) + peer := derive(parseKey(peerKey), "m", addressPath) + + got, err := CreateAddressV7(user, muun, peer, timelock, addressPath, network) + if err != nil { + t.Fatalf("CreateAddressV7() error = %v", err) + } + + want := &WalletAddress{address: v7Address, derivationPath: addressPath, version: V7} + if !reflect.DeepEqual(got, want) { + t.Errorf("CreateAddressV7() = %v, want %v", got, want) + } +} diff --git a/libwallet/addresses/v8.go b/libwallet/addresses/v8.go new file mode 100644 index 00000000..79224ae2 --- /dev/null +++ b/libwallet/addresses/v8.go @@ -0,0 +1,59 @@ +package addresses + +import ( + "crypto/sha256" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/btcutil/hdkeychain" + "github.com/btcsuite/btcd/chaincfg" + "github.com/go-errors/errors" +) + +// CreateAddressV8 returns a native-segwit (P2WSH) address for `to_client` +// (user AND muun AND lightningPeer) // collaborative spend +// OR (user AND muun AND older(blocksForExpiration)) // non-collaborative spend +func CreateAddressV8( + userKey, muunKey, lightningPeerKey *hdkeychain.ExtendedKey, + blocksForExpiration int64, + path string, + network *chaincfg.Params, +) (*WalletAddress, error) { + userEcPubKey, err := userKey.ECPubKey() + if err != nil { + return nil, errors.Errorf("get user public key: %w", err) + } + + muunEcPubKey, err := muunKey.ECPubKey() + if err != nil { + return nil, errors.Errorf("get muun public key: %w", err) + } + + lightningPeerEcPubKey, err := lightningPeerKey.ECPubKey() + if err != nil { + return nil, errors.Errorf("get lightning peer public key: %w", err) + } + + // Reuse the V7 witness script: the spending policy is identical for both schemes. + witnessScript, err := CreateWitnessScriptV7( + userEcPubKey, + muunEcPubKey, + lightningPeerEcPubKey, + blocksForExpiration, + ) + if err != nil { + return nil, err + } + + witnessScript256 := sha256.Sum256(witnessScript) + + address, err := btcutil.NewAddressWitnessScriptHash(witnessScript256[:], network) + if err != nil { + return nil, err + } + + return &WalletAddress{ + address: address.EncodeAddress(), + version: V8, + derivationPath: path, + }, nil +} diff --git a/libwallet/addresses/v8_test.go b/libwallet/addresses/v8_test.go new file mode 100644 index 00000000..84e8905a --- /dev/null +++ b/libwallet/addresses/v8_test.go @@ -0,0 +1,33 @@ +package addresses + +import ( + "reflect" + "testing" +) + +func TestCreateAddressV8(t *testing.T) { + const ( + // Same keys and timelock as scanner/recovery_m3_test.go, which funds and sweeps the scheme + // against bitcoind. + userKey = "tprv8ZgxMBicQKsPdJQ6vbpyPo1TMKsGbvDULSFRrJB6z1o1Jn4x52rZWnPB8w3xjhKbfH8vz9Sv91jNsgXTn8xEEufHSme1xQmWYdrZWWSAdgN" //nolint:lll + muunKey = "tprv8ZgxMBicQKsPe9kwGRAvhKiZVq3J9tHvJbQ1SEAPuW9Ccb6yq3Zr5kPcR9647JbaGVAXPXcG7rjakUHgXEwKUD64SJSmwZr7y72KK7GG5J8" //nolint:lll + peerKey = "tprv8ZgxMBicQKsPcsbCVeqqF1KVdH7gwDJbxbzpCxDUsoXHdb6SnTPYxdwSAKGHbiCkJCG7JoDt1asvfWuA6Gbjr5GZ3L5MxLvr2Ef4Rx2HyZH" //nolint:lll + addressPath = "m/1/0" + timelock = int64(10) + v8Address = "bcrt1qe063x92xan7t2ag0smpqud5v3fxzxppvdvlv5q60fe7s72stseqqnpnjtc" + ) + + user := derive(parseKey(userKey), "m", addressPath) + muun := derive(parseKey(muunKey), "m", addressPath) + peer := derive(parseKey(peerKey), "m", addressPath) + + got, err := CreateAddressV8(user, muun, peer, timelock, addressPath, network) + if err != nil { + t.Fatalf("CreateAddressV8() error = %v", err) + } + + want := &WalletAddress{address: v8Address, derivationPath: addressPath, version: V8} + if !reflect.DeepEqual(got, want) { + t.Errorf("CreateAddressV8() = %v, want %v", got, want) + } +} diff --git a/libwallet/addresses/v9.go b/libwallet/addresses/v9.go new file mode 100644 index 00000000..bf757410 --- /dev/null +++ b/libwallet/addresses/v9.go @@ -0,0 +1,113 @@ +package addresses + +import ( + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/btcsuite/btcd/btcutil/hdkeychain" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/txscript" + "github.com/go-errors/errors" + + "github.com/muun/libwallet/btcsuitew/btcutilw" + "github.com/muun/libwallet/musig" +) + +// CreateAddressV9 returns a native taproot (P2TR) address for `to_client` +// +// - collaborative spend: taproot key spend path (MuSig2(user + muun + lightningPeer)) +// - non-collaborative spend: the only script in the Tap Tree consisting of +// user + muun + older(blocksForExpiration) +func CreateAddressV9( + userKey, muunKey, lightningPeerKey *hdkeychain.ExtendedKey, + blocksForExpiration int64, + path string, + network *chaincfg.Params, +) (*WalletAddress, error) { + userEcPubKey, err := userKey.ECPubKey() + if err != nil { + return nil, errors.Errorf("get user public key: %w", err) + } + + muunEcPubKey, err := muunKey.ECPubKey() + if err != nil { + return nil, errors.Errorf("get muun public key: %w", err) + } + + lightningPeerEcPubKey, err := lightningPeerKey.ECPubKey() + if err != nil { + return nil, errors.Errorf("get lightning peer public key: %w", err) + } + + nonCollaborativeScript, err := CreateNonCollaborativeScriptV9( + userEcPubKey, + muunEcPubKey, + blocksForExpiration, + ) + if err != nil { + return nil, err + } + + // Single-leaf script tree: the root hash is the leaf's TapHash. + scriptRootHash := txscript.NewBaseTapLeaf(nonCollaborativeScript).TapHash() + + outputKey, err := musig.MuSig2CombineKeys( + musig.Musig2v100, + []*btcec.PublicKey{userEcPubKey, muunEcPubKey, lightningPeerEcPubKey}, + musig.TapScriptTweak(scriptRootHash[:]), + ) + if err != nil { + return nil, errors.Errorf("aggregate output key: %w", err) + } + + address, err := btcutilw.NewAddressTaprootKey( + schnorr.SerializePubKey(outputKey.FinalKey), network, + ) + if err != nil { + return nil, err + } + + return &WalletAddress{ + address: address.EncodeAddress(), + version: V9, + derivationPath: path, + }, nil +} + +// CreateInternalKeyV9 returns the taproot public key without the tweak. +func CreateInternalKeyV9( + userPubKey, muunPubKey, lightningPeerPubKey *btcec.PublicKey, +) (*btcec.PublicKey, error) { + aggregate, err := musig.MuSig2CombineKeys( + musig.Musig2v100, + []*btcec.PublicKey{userPubKey, muunPubKey, lightningPeerPubKey}, + musig.NoopTweak(), + ) + if err != nil { + return nil, errors.Errorf("aggregate internal key: %w", err) + } + return aggregate.PreTweakedKey, nil +} + +// CreateNonCollaborativeScriptV9 builds the single tapleaf script for the non-collaborative path: +// +// OP_CHECKSIGVERIFY OP_CHECKSEQUENCEVERIFY +func CreateNonCollaborativeScriptV9( + userPubKey, muunPubKey *btcec.PublicKey, + blocksForExpiration int64, +) ([]byte, error) { + aggregateKey, err := musig.MuSig2CombineKeys( + musig.Musig2v100, + []*btcec.PublicKey{userPubKey, muunPubKey}, + musig.NoopTweak(), + ) + if err != nil { + return nil, errors.Errorf("aggregate non-collaborative key: %w", err) + } + + return txscript.NewScriptBuilder(). + AddData(schnorr.SerializePubKey(aggregateKey.PreTweakedKey)). + AddOp(txscript.OP_CHECKSIGVERIFY). + AddInt64(blocksForExpiration). + AddOp(txscript.OP_CHECKSEQUENCEVERIFY). + Script() +} diff --git a/libwallet/addresses/v9_test.go b/libwallet/addresses/v9_test.go new file mode 100644 index 00000000..642c70df --- /dev/null +++ b/libwallet/addresses/v9_test.go @@ -0,0 +1,33 @@ +package addresses + +import ( + "reflect" + "testing" +) + +func TestCreateAddressV9(t *testing.T) { + const ( + // Same keys and timelock as scanner/recovery_m3_test.go, which funds and sweeps the scheme + // against bitcoind. + userKey = "tprv8ZgxMBicQKsPdJQ6vbpyPo1TMKsGbvDULSFRrJB6z1o1Jn4x52rZWnPB8w3xjhKbfH8vz9Sv91jNsgXTn8xEEufHSme1xQmWYdrZWWSAdgN" //nolint:lll + muunKey = "tprv8ZgxMBicQKsPe9kwGRAvhKiZVq3J9tHvJbQ1SEAPuW9Ccb6yq3Zr5kPcR9647JbaGVAXPXcG7rjakUHgXEwKUD64SJSmwZr7y72KK7GG5J8" //nolint:lll + peerKey = "tprv8ZgxMBicQKsPcsbCVeqqF1KVdH7gwDJbxbzpCxDUsoXHdb6SnTPYxdwSAKGHbiCkJCG7JoDt1asvfWuA6Gbjr5GZ3L5MxLvr2Ef4Rx2HyZH" //nolint:lll + addressPath = "m/1/0" + timelock = int64(10) + v9Address = "bcrt1p3a9xkee8k99fsa7ffzqw6x3a7tcg4ycjzjd4hxecx563uhawnkjs88shdl" + ) + + user := derive(parseKey(userKey), "m", addressPath) + muun := derive(parseKey(muunKey), "m", addressPath) + peer := derive(parseKey(peerKey), "m", addressPath) + + got, err := CreateAddressV9(user, muun, peer, timelock, addressPath, network) + if err != nil { + t.Fatalf("CreateAddressV9() error = %v", err) + } + + want := &WalletAddress{address: v9Address, derivationPath: addressPath, version: V9} + if !reflect.DeepEqual(got, want) { + t.Errorf("CreateAddressV9() = %v, want %v", got, want) + } +} diff --git a/libwallet/aescbc/aescbc.go b/libwallet/aescbc/aescbc.go index 37bf75f9..f001b618 100755 --- a/libwallet/aescbc/aescbc.go +++ b/libwallet/aescbc/aescbc.go @@ -4,8 +4,8 @@ import ( "bytes" "crypto/aes" "crypto/cipher" - "errors" - "fmt" + + "github.com/go-errors/errors" ) const KeySize = 32 @@ -17,7 +17,7 @@ func EncryptPkcs7(key []byte, iv []byte, plaintext []byte) ([]byte, error) { func EncryptNoPadding(key []byte, iv []byte, plaintext []byte) ([]byte, error) { if len(key) != KeySize { - return nil, fmt.Errorf("invalid key size, expected %v, got %v", KeySize, len(key)) + return nil, errors.Errorf("invalid key size, expected %v, got %v", KeySize, len(key)) } block, err := aes.NewCipher(key) if err != nil { @@ -43,7 +43,7 @@ func DecryptPkcs7(key []byte, iv []byte, ciphertext []byte) ([]byte, error) { func DecryptNoPadding(key []byte, iv []byte, ciphertext []byte) ([]byte, error) { if len(key) != KeySize { - return nil, fmt.Errorf("invalid key size, expected %v, got %v", KeySize, len(key)) + return nil, errors.Errorf("invalid key size, expected %v, got %v", KeySize, len(key)) } block, err := aes.NewCipher(key) if err != nil { @@ -69,11 +69,13 @@ func pkcs7UnPadding(src []byte) ([]byte, error) { unpadding := int(src[length-1]) if unpadding > aes.BlockSize || unpadding == 0 { - return nil, errors.New("invalid pkcs7 padding (unpadding > aes.BlockSize || unpadding == 0)") + return nil, errors.New( + "invalid pkcs7 padding (unpadding > aes.BlockSize || unpadding == 0)", + ) } pad := src[len(src)-unpadding:] - for i := 0; i < unpadding; i++ { + for i := 0; i < unpadding; i++ { //nolint:modernize // TODO: use range over int if pad[i] != byte(unpadding) { return nil, errors.New("invalid pkcs7 padding (pad[i] != unpadding)") } diff --git a/libwallet/app_provided_data/config.go b/libwallet/app_provided_data/config.go index 5ab6106f..4bfdd324 100644 --- a/libwallet/app_provided_data/config.go +++ b/libwallet/app_provided_data/config.go @@ -6,8 +6,9 @@ type Config struct { SocketPath string FeatureStatusProvider BackendActivatedFeatureStatusProvider AppLogSink AppLogSink - HttpClientSessionProvider HttpClientSessionProvider + HttpClientSessionProvider HttpClientSessionProvider //nolint:staticcheck // should be HTTPClientSessionProvider, but it's part of the gomobile contract with the apps NfcBridge NfcBridge KeyProvider KeyProvider + SecureKeyValueStorage SecureKeyValueStorage Network string } diff --git a/libwallet/app_provided_data/secure_key_value_storage.go b/libwallet/app_provided_data/secure_key_value_storage.go new file mode 100644 index 00000000..9d515198 --- /dev/null +++ b/libwallet/app_provided_data/secure_key_value_storage.go @@ -0,0 +1,40 @@ +package app_provided_data + +// Status codes for SecureKeyValueStorage bridge calls. Transport failures travel +// through the trailing error return; everything else surfaces as a status code. +const ( + SecureKvStatusUnknown int32 = 0 // zero value; treated as StorageFailed + SecureKvStatusOk int32 = 1 + SecureKvStatusNotFound int32 = 2 + SecureKvStatusDecryptionFailed int32 = 3 + SecureKvStatusStorageFailed int32 = 4 +) + +// SecureKvResponse carries the outcome of Put, Delete and Wipe. Held as a +// struct (instead of a bare int32) so new diagnostic fields can be added +// without changing the bridge signature. +type SecureKvResponse struct { + StatusCode int32 +} + +// SecureKvGetResponse holds the outcome Get returns; Value is populated only when StatusCode is Ok. +type SecureKvGetResponse struct { + Value []byte + StatusCode int32 +} + +// SecureKeyValueStorage provides hardware-encrypted key-value storage backed by native +// secure storage (Android KeyStore / iOS Keychain). +type SecureKeyValueStorage interface { + // Put encrypts and stores the value under the given key (upsert). + Put(key string, value []byte) (*SecureKvResponse, error) + + // Get returns the plaintext stored under the given key. + Get(key string) (*SecureKvGetResponse, error) + + // Delete removes the value under the given key. No-op if missing. + Delete(key string) (*SecureKvResponse, error) + + // Wipe deletes everything in the underlying native storage. + Wipe() (*SecureKvResponse, error) +} diff --git a/libwallet/app_provided_data/service_request.go b/libwallet/app_provided_data/service_request.go index 47e22f84..eb19b160 100644 --- a/libwallet/app_provided_data/service_request.go +++ b/libwallet/app_provided_data/service_request.go @@ -12,7 +12,7 @@ type Session struct { DeviceToken string } -type HttpClientSessionProvider interface { +type HttpClientSessionProvider interface { //nolint:staticcheck // should be HTTPClientSessionProvider, but it's part of the gomobile contract with the apps Session() (*Session, error) SetSessionStatus(status string) SetMinClientVersion(minClientVersion string) diff --git a/libwallet/bip21.go b/libwallet/bip21.go index 7ba62d9d..19c40821 100755 --- a/libwallet/bip21.go +++ b/libwallet/bip21.go @@ -2,8 +2,10 @@ package libwallet import ( "fmt" - "github.com/muun/libwallet/errors" + "github.com/shopspring/decimal" + + "github.com/muun/libwallet/errors" ) // GenerateBip21Uri generates the serialized version (string) of a bitcoin uri from a MuunPaymentUri @@ -18,9 +20,14 @@ func GenerateBip21Uri(uri *MuunPaymentURI) (string, error) { invoice = "lightning=" + uri.Invoice.RawInvoice if uri.Invoice.Sats != 0 && uri.Amount != "" { - invoiceAmount := decimal.NewFromInt(uri.Invoice.Sats).Div(decimal.NewFromInt(100_000_000)).String() + invoiceAmount := decimal.NewFromInt(uri.Invoice.Sats). + Div(decimal.NewFromInt(100_000_000)). + String() if invoiceAmount != uri.Amount { - return "", errors.New(ErrInvalidURI, fmt.Sprintf("Amount mismatch %v: %v", invoiceAmount, uri.Amount)) + return "", errors.New( + ErrInvalidURI, + fmt.Sprintf("Amount mismatch %v: %v", invoiceAmount, uri.Amount), + ) } } } diff --git a/libwallet/bip21_test.go b/libwallet/bip21_test.go index 7d9fc0fe..eb7ab9b6 100755 --- a/libwallet/bip21_test.go +++ b/libwallet/bip21_test.go @@ -42,15 +42,15 @@ func TestGenerateBip21Uri(t *testing.T) { }, // We do not support description/message in our receive bitcoin uris, yet ;) //{ - // name: "completeValidAddress", - // args: args{&MuunPaymentURI{ - // Address: address, - // Amount: "1.2", - // Label: "hola", - // Message: "mensaje con espacios", - // Uri: bitcoinScheme + completeURI, - // }}, - // want: bitcoinScheme + completeURI, + // name: "completeValidAddress", + // args: args{&MuunPaymentURI{ + // Address: address, + // Amount: "1.2", + // Label: "hola", + // Message: "mensaje con espacios", + // Uri: bitcoinScheme + completeURI, + // }}, + // want: bitcoinScheme + completeURI, //}, { name: "BIP21 with lightning", @@ -115,7 +115,11 @@ func TestGenerateBip21Uri(t *testing.T) { args: args{&MuunPaymentURI{ Address: address, Amount: "0.00000019", - Uri: bitcoinScheme + address + "?amount=0.00000019&" + lightningParam + invoice19Sat, + Uri: bitcoinScheme + + address + + "?amount=0.00000019&" + + lightningParam + + invoice19Sat, Invoice: &Invoice{ RawInvoice: invoice19Sat, @@ -162,10 +166,19 @@ func TestGenerateBip21Uri(t *testing.T) { var invoiceDiff = "" if !reflect.DeepEqual(uri.Invoice, tt.args.uri.Invoice) { - invoiceDiff = fmt.Sprintf("Invoice = %+v, want %+v", uri.Invoice, tt.args.uri.Invoice) + invoiceDiff = fmt.Sprintf( + "Invoice = %+v, want %+v", + uri.Invoice, + tt.args.uri.Invoice, + ) } - t.Errorf("GenerateBip21Uri() gen + parse = %+v, want %+v. %v", uri, tt.args.uri, invoiceDiff) + t.Errorf( + "GenerateBip21Uri() gen + parse = %+v, want %+v. %v", + uri, + tt.args.uri, + invoiceDiff, + ) } } diff --git a/libwallet/bridge.go b/libwallet/bridge.go index ffa06e03..316f3145 100644 --- a/libwallet/bridge.go +++ b/libwallet/bridge.go @@ -4,7 +4,8 @@ import ( "crypto/aes" "crypto/cipher" "crypto/hmac" - "crypto/sha1" + "crypto/sha1" //nolint:gosec // TODO: migrate off sha1 + "github.com/btcsuite/btcd/btcec/v2" ) @@ -33,7 +34,7 @@ func (l *StringList) Add(s string) { } func (l *StringList) Contains(s string) bool { - for _, v := range l.elems { + for _, v := range l.elems { //nolint:modernize // TODO: use slices.Contains if v == s { return true } @@ -71,7 +72,7 @@ func (l *IntList) Add(number int) { } func (l *IntList) Contains(number int) bool { - for _, v := range l.elems { + for _, v := range l.elems { //nolint:modernize // TODO: use slices.Contains if v == number { return true } diff --git a/libwallet/btcsuitew/bech32m/bech32m.go b/libwallet/btcsuitew/bech32m/bech32m.go index 455b7b8c..20988eb2 100644 --- a/libwallet/btcsuitew/bech32m/bech32m.go +++ b/libwallet/btcsuitew/bech32m/bech32m.go @@ -11,6 +11,8 @@ package bech32m import ( "fmt" "strings" + + "github.com/go-errors/errors" ) const charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" @@ -25,13 +27,13 @@ func Decode(bech string) (string, []byte, error) { // be at least 8 characters, since it needs a non-empty HRP, a // separator, and a 6 character checksum. if len(bech) < 8 || len(bech) > 90 { - return "", nil, fmt.Errorf("invalid bech32 string length %d", + return "", nil, errors.Errorf("invalid bech32 string length %d", len(bech)) } // Only ASCII characters between 33 and 126 are allowed. for i := 0; i < len(bech); i++ { if bech[i] < 33 || bech[i] > 126 { - return "", nil, fmt.Errorf("invalid character in "+ + return "", nil, errors.Errorf("invalid character in "+ "string: '%c'", bech[i]) } } @@ -40,7 +42,7 @@ func Decode(bech string) (string, []byte, error) { lower := strings.ToLower(bech) upper := strings.ToUpper(bech) if bech != lower && bech != upper { - return "", nil, fmt.Errorf("string not all lowercase or all " + + return "", nil, errors.Errorf("string not all lowercase or all " + "uppercase") } @@ -53,7 +55,7 @@ func Decode(bech string) (string, []byte, error) { // or if the string is more than 90 characters in total. one := strings.LastIndexByte(bech, '1') if one < 1 || one+7 > len(bech) { - return "", nil, fmt.Errorf("invalid index of 1") + return "", nil, errors.Errorf("invalid index of 1") } // The human-readable part is everything before the last '1'. @@ -64,8 +66,8 @@ func Decode(bech string) (string, []byte, error) { // 'charset'. decoded, err := toBytes(data) if err != nil { - return "", nil, fmt.Errorf("failed converting data to bytes: "+ - "%v", err) + return "", nil, errors.Errorf("failed converting data to bytes: "+ + "%w", err) } if !bech32VerifyChecksum(hrp, decoded) { @@ -77,7 +79,7 @@ func Decode(bech string) (string, []byte, error) { moreInfo = fmt.Sprintf("Expected %v, got %v.", expected, checksum) } - return "", nil, fmt.Errorf("checksum failed. " + moreInfo) + return "", nil, errors.Errorf("checksum failed. %s", moreInfo) } // We exclude the last 6 bytes, which is the checksum. @@ -85,8 +87,8 @@ func Decode(bech string) (string, []byte, error) { } // Encode encodes a byte slice into a bech32 string with the -// human-readable part hrb. Note that the bytes must each encode 5 bits -// (base32). +// human-readable part hrb. +// Note that the bytes must each encode 5 bits (base32). func Encode(hrp string, data []byte) (string, error) { // Calculate the checksum of the data and append it at the end. checksum := bech32Checksum(hrp, data) @@ -97,8 +99,8 @@ func Encode(hrp string, data []byte) (string, error) { // represented using the specified charset. dataChars, err := toChars(combined) if err != nil { - return "", fmt.Errorf("unable to convert data bytes to chars: "+ - "%v", err) + return "", errors.Errorf("unable to convert data bytes to chars: "+ + "%w", err) } return hrp + "1" + dataChars, nil } @@ -110,7 +112,7 @@ func toBytes(chars string) ([]byte, error) { for i := 0; i < len(chars); i++ { index := strings.IndexByte(charset, chars[i]) if index < 0 { - return nil, fmt.Errorf("invalid character not part of "+ + return nil, errors.Errorf("invalid character not part of "+ "charset: %v", chars[i]) } decoded = append(decoded, byte(index)) @@ -124,7 +126,7 @@ func toChars(data []byte) (string, error) { result := make([]byte, 0, len(data)) for _, b := range data { if int(b) >= len(charset) { - return "", fmt.Errorf("invalid data byte: %v", b) + return "", errors.Errorf("invalid data byte: %v", b) } result = append(result, charset[b]) } @@ -135,7 +137,7 @@ func toChars(data []byte) (string, error) { // to a byte slice where each byte is encoding toBits bits. func ConvertBits(data []byte, fromBits, toBits uint8, pad bool) ([]byte, error) { if fromBits < 1 || fromBits > 8 || toBits < 1 || toBits > 8 { - return nil, fmt.Errorf("only bit groups between 1 and 8 allowed") + return nil, errors.Errorf("only bit groups between 1 and 8 allowed") } // The final bytes, each byte encoding toBits bits. @@ -160,7 +162,7 @@ func ConvertBits(data []byte, fromBits, toBits uint8, pad bool) ([]byte, error) // The number of bytes to next extract is the minimum of // remFromBits and remToBits. toExtract := remFromBits - if remToBits < toExtract { + if remToBits < toExtract { //nolint:modernize // TODO: use min/max builtin toExtract = remToBits } @@ -194,7 +196,7 @@ func ConvertBits(data []byte, fromBits, toBits uint8, pad bool) ([]byte, error) // Any incomplete group must be <= 4 bits, and all zeroes. if filledBits > 0 && (filledBits > 4 || nextByte != 0) { - return nil, fmt.Errorf("invalid incomplete group") + return nil, errors.Errorf("invalid incomplete group") } return regrouped, nil @@ -212,7 +214,7 @@ func bech32Checksum(hrp string, data []byte) []byte { values = append(values, []int{0, 0, 0, 0, 0, 0}...) polymod := bech32Polymod(values) ^ bech32mChecksumConst var res []byte - for i := 0; i < 6; i++ { + for i := 0; i < 6; i++ { //nolint:modernize // TODO: use range over int res = append(res, byte((polymod>>uint(5*(5-i)))&31)) } return res @@ -224,7 +226,7 @@ func bech32Polymod(values []int) int { for _, v := range values { b := chk >> 25 chk = (chk&0x1ffffff)<<5 ^ v - for i := 0; i < 5; i++ { + for i := 0; i < 5; i++ { //nolint:modernize // TODO: use range over int if (b>>uint(i))&1 == 1 { chk ^= gen[i] } diff --git a/libwallet/btcsuitew/bech32m/bech32m_test.go b/libwallet/btcsuitew/bech32m/bech32m_test.go index d363551b..b27b5e31 100644 --- a/libwallet/btcsuitew/bech32m/bech32m_test.go +++ b/libwallet/btcsuitew/bech32m/bech32m_test.go @@ -22,7 +22,7 @@ var invalidBech32m = []string{ "\x7f1g6xzxy", // HRP character out of range "\x801vctc34", // HRP character out of range - "an84characterslonghumanreadablepartthatcontainsthetheexcludedcharactersbioandnumber11d6pts4", // Overall max length exceeded + "an84characterslonghumanreadablepartthatcontainsthetheexcludedcharactersbioandnumber11d6pts4", // Overall max length exceeded //nolint:lll "qyrz8wqd2c9m", // No separator "1qyrz8wqd2c9m", // Empty HRP diff --git a/libwallet/btcsuitew/btcutilw/address.go b/libwallet/btcsuitew/btcutilw/address.go index 97029dd6..737183d1 100644 --- a/libwallet/btcsuitew/btcutilw/address.go +++ b/libwallet/btcsuitew/btcutilw/address.go @@ -5,11 +5,11 @@ package btcutilw // so it's easy to swap out in the future. import ( - "fmt" "strings" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg" + "github.com/go-errors/errors" ) // DecodeAddress uses btcutil.DecodeAddress for all cases except SegWit version 1, which is handled @@ -25,7 +25,12 @@ func DecodeAddress(addr string, defaultNet *chaincfg.Params) (btcutil.Address, e // to know is to try: witnessVer, witnessProg, err := decodeSegWitAddressV1(addr) if err != nil { - return nil, fmt.Errorf("failed to decode %s (%v after %w)", addr, err, libErr) + return nil, errors.Errorf( + "failed to decode %s (%w after %w)", + addr, + err, + libErr, + ) } if witnessVer != 1 { @@ -52,7 +57,10 @@ type AddressTaprootKey struct { // NewAddressTaprootKey returns a new AddressTaprootKey. func NewAddressTaprootKey(xOnlyPubKey []byte, net *chaincfg.Params) (*AddressTaprootKey, error) { if len(xOnlyPubKey) != 32 { - return nil, fmt.Errorf("witness program must be 32 bytes for p2tr, not %d", len(xOnlyPubKey)) + return nil, errors.Errorf( + "witness program must be 32 bytes for p2tr, not %d", + len(xOnlyPubKey), + ) } addr := &AddressTaprootKey{ @@ -113,7 +121,7 @@ func (a *AddressTaprootKey) WitnessProgram() []byte { func newAddressTaprootKey(hrp string, witnessProg []byte) (*AddressTaprootKey, error) { if len(witnessProg) != 32 { - return nil, fmt.Errorf("witness program must be 32 bytes for p2tr") + return nil, errors.Errorf("witness program must be 32 bytes for p2tr") } addr := &AddressTaprootKey{ diff --git a/libwallet/btcsuitew/btcutilw/segwit.go b/libwallet/btcsuitew/btcutilw/segwit.go index 973db6be..740bb07d 100644 --- a/libwallet/btcsuitew/btcutilw/segwit.go +++ b/libwallet/btcsuitew/btcutilw/segwit.go @@ -2,13 +2,15 @@ package btcutilw import ( "bytes" - "fmt" + + "github.com/go-errors/errors" "github.com/muun/libwallet/btcsuitew/bech32m" ) // ------------------------------------------------------------------------------------------------- -// Methods below copied from btcd (address.go), but using our bech32m module instead of their bech32. +// Methods below copied from btcd (address.go), but using our bech32m module +// instead of their bech32. // Only that change was made. Some comments inside this code are not correct. func encodeSegWitAddressV1(hrp string, witnessVersion byte, witnessProgram []byte) (string, error) { @@ -32,11 +34,11 @@ func encodeSegWitAddressV1(hrp string, witnessVersion byte, witnessProgram []byt // Check validity by decoding the created address. version, program, err := decodeSegWitAddressV1(bech) if err != nil { - return "", fmt.Errorf("invalid taproot address: %v", err) + return "", errors.Errorf("invalid taproot address: %w", err) } if version != witnessVersion || !bytes.Equal(program, witnessProgram) { - return "", fmt.Errorf("invalid taproot address") + return "", errors.Errorf("invalid taproot address") } return bech, nil @@ -52,13 +54,13 @@ func decodeSegWitAddressV1(address string) (byte, []byte, error) { // The first byte of the decoded address is the witness version, it must // exist. if len(data) < 1 { - return 0, nil, fmt.Errorf("no witness version") + return 0, nil, errors.Errorf("no witness version") } // ...and be <= 16. version := data[0] if version > 16 { - return 0, nil, fmt.Errorf("invalid witness version for taproot: %v", version) + return 0, nil, errors.Errorf("invalid witness version for taproot: %v", version) } // The remaining characters of the address returned are grouped into @@ -71,12 +73,12 @@ func decodeSegWitAddressV1(address string) (byte, []byte, error) { // The regrouped data must be between 2 and 40 bytes. if len(regrouped) < 2 || len(regrouped) > 40 { - return 0, nil, fmt.Errorf("invalid data length") + return 0, nil, errors.Errorf("invalid data length") } // For witness version 0, address MUST be exactly 20 or 32 bytes. if version == 0 && len(regrouped) != 20 && len(regrouped) != 32 { - return 0, nil, fmt.Errorf("invalid data length for witness "+ + return 0, nil, errors.Errorf("invalid data length for witness "+ "version 0: %v", len(regrouped)) } diff --git a/libwallet/btcsuitew/txscriptw/script.go b/libwallet/btcsuitew/txscriptw/script.go index 67dffc79..75befabe 100644 --- a/libwallet/btcsuitew/txscriptw/script.go +++ b/libwallet/btcsuitew/txscriptw/script.go @@ -3,11 +3,12 @@ package txscriptw import ( "bytes" "encoding/binary" - "fmt" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" + "github.com/go-errors/errors" + "github.com/muun/libwallet/btcsuitew/chainhashw" ) @@ -21,18 +22,18 @@ func CalcTaprootSigHash( ) ([]byte, error) { if index >= len(tx.TxIn) { - return nil, fmt.Errorf("wanted index %d but found only %d inputs", index, len(tx.TxIn)) + return nil, errors.Errorf("wanted index %d but found only %d inputs", index, len(tx.TxIn)) } anyoneCanPay := hashType&txscript.SigHashAnyOneCanPay != 0 hashType = hashType & 0x1f if hashType != txscript.SigHashAll { - return nil, fmt.Errorf("only SIGHASH_ALL is supported") + return nil, errors.Errorf("only SIGHASH_ALL is supported") } if anyoneCanPay { - return nil, fmt.Errorf("anyoneCanPay is not supported") + return nil, errors.Errorf("anyoneCanPay is not supported") } b := new(bytes.Buffer) @@ -75,7 +76,7 @@ func CalcTaprootSigHash( // MISSING: do some more hashing and commit to the annex (not supported) if hashType == txscript.SigHashSingle { - return nil, fmt.Errorf("SIGHASH_SINGLE is not supported") + return nil, errors.Errorf("SIGHASH_SINGLE is not supported") } // MISSING: encode extensions, such as the script path commitment from BIP-342 (not supported) @@ -121,7 +122,7 @@ func calcHashOutputs(tx *wire.MsgTx) chainhash.Hash { b := new(bytes.Buffer) for _, txOut := range tx.TxOut { - wire.WriteTxOut(b, 0, 0, txOut) + wire.WriteTxOut(b, 0, 0, txOut) //nolint:errcheck // TODO: check error } return chainhash.HashH(b.Bytes()) @@ -131,7 +132,7 @@ func calcHashScriptPubKeys(txOuts []*wire.TxOut) chainhash.Hash { b := new(bytes.Buffer) for _, txOut := range txOuts { - wire.WriteVarInt(b, 0, uint64(len(txOut.PkScript))) + wire.WriteVarInt(b, 0, uint64(len(txOut.PkScript))) //nolint:errcheck // TODO: check error b.Write(txOut.PkScript) } diff --git a/libwallet/btcsuitew/txscriptw/script_test.go b/libwallet/btcsuitew/txscriptw/script_test.go index 7a0fba12..d25d2b11 100644 --- a/libwallet/btcsuitew/txscriptw/script_test.go +++ b/libwallet/btcsuitew/txscriptw/script_test.go @@ -14,56 +14,56 @@ import ( // These test cases were taken from rust-bitcoin, which in turn took them from Bitcoin Core: var sigHashTestCases = []sigHashTestCase{ { - tx: "020000000164eb050a5e3da0c2a65e4786f26d753b7bc69691fabccafb11f7acef36641f1846010000003101b2b404392a22000000000017a9147f2bde86fe78bf68a0544a4f290e12f0b7e0a08c87580200000000000017a91425d11723074ecfb96a0a83c3956bfaf362ae0c908758020000000000001600147e20f938993641de67bb0cdd71682aa34c4d29ad5802000000000000160014c64984dc8761acfa99418bd6bedc79b9287d652d72000000", - prevOuts: "01365724000000000023542156b39dab4f8f3508e0432cfb41fab110170acaa2d4c42539cb90a4dc7c093bc500", + tx: "020000000164eb050a5e3da0c2a65e4786f26d753b7bc69691fabccafb11f7acef36641f1846010000003101b2b404392a22000000000017a9147f2bde86fe78bf68a0544a4f290e12f0b7e0a08c87580200000000000017a91425d11723074ecfb96a0a83c3956bfaf362ae0c908758020000000000001600147e20f938993641de67bb0cdd71682aa34c4d29ad5802000000000000160014c64984dc8761acfa99418bd6bedc79b9287d652d72000000", //nolint:lll + prevOuts: "01365724000000000023542156b39dab4f8f3508e0432cfb41fab110170acaa2d4c42539cb90a4dc7c093bc500", //nolint:lll index: 0, hashType: txscript.SigHashOld, // expectSigHash: "33ca0ebfb4a945eeee9569fc0f5040221275f88690b7f8592ada88ce3bdf6703", expectError: true, }, { - tx: "0200000002fff49be59befe7566050737910f6ccdc5e749c7f8860ddc140386463d88c5ad0f3000000002cf68eb4a3d67f9d4c079249f7e4f27b8854815cb1ed13842d4fbf395f9e217fd605ee24090100000065235d9203f458520000000000160014b6d48333bb13b4c644e57c43a9a26df3a44b785e58020000000000001976a914eea9461a9e1e3f765d3af3e726162e0229fe3eb688ac58020000000000001976a9143a8869c9f2b5ea1d4ff3aeeb6a8fb2fffb1ad5fe88ac0ad7125c", - prevOuts: "02591f220000000000225120f25ad35583ea31998d968871d7de1abd2a52f6fe4178b54ea158274806ff4ece48fb310000000000225120f25ad35583ea31998d968871d7de1abd2a52f6fe4178b54ea158274806ff4ece", + tx: "0200000002fff49be59befe7566050737910f6ccdc5e749c7f8860ddc140386463d88c5ad0f3000000002cf68eb4a3d67f9d4c079249f7e4f27b8854815cb1ed13842d4fbf395f9e217fd605ee24090100000065235d9203f458520000000000160014b6d48333bb13b4c644e57c43a9a26df3a44b785e58020000000000001976a914eea9461a9e1e3f765d3af3e726162e0229fe3eb688ac58020000000000001976a9143a8869c9f2b5ea1d4ff3aeeb6a8fb2fffb1ad5fe88ac0ad7125c", //nolint:lll + prevOuts: "02591f220000000000225120f25ad35583ea31998d968871d7de1abd2a52f6fe4178b54ea158274806ff4ece48fb310000000000225120f25ad35583ea31998d968871d7de1abd2a52f6fe4178b54ea158274806ff4ece", //nolint:lll index: 1, hashType: txscript.SigHashAll, expectSigHash: "626ab955d58c9a8a600a0c580549d06dc7da4e802eb2a531f62a588e430967a8", expectError: false, }, { - tx: "0200000001350005f65aa830ced2079df348e2d8c2bdb4f10e2dde6a161d8a07b40d1ad87dae000000001611d0d603d9dc0e000000000017a914459b6d7d6bbb4d8837b4bf7e9a4556f952da2f5c8758020000000000001976a9141dd70e1299ffc2d5b51f6f87de9dfe9398c33cbb88ac58020000000000001976a9141dd70e1299ffc2d5b51f6f87de9dfe9398c33cbb88aca71c1f4f", - prevOuts: "01c4811000000000002251201bf9297d0a2968ae6693aadd0fa514717afefd218087a239afb7418e2d22e65c", + tx: "0200000001350005f65aa830ced2079df348e2d8c2bdb4f10e2dde6a161d8a07b40d1ad87dae000000001611d0d603d9dc0e000000000017a914459b6d7d6bbb4d8837b4bf7e9a4556f952da2f5c8758020000000000001976a9141dd70e1299ffc2d5b51f6f87de9dfe9398c33cbb88ac58020000000000001976a9141dd70e1299ffc2d5b51f6f87de9dfe9398c33cbb88aca71c1f4f", //nolint:lll + prevOuts: "01c4811000000000002251201bf9297d0a2968ae6693aadd0fa514717afefd218087a239afb7418e2d22e65c", //nolint:lll index: 0, hashType: txscript.SigHashAll | txscript.SigHashAnyOneCanPay, // expectSigHash: "dfa9437f9c9a1d1f9af271f79f2f5482f287cdb0d2e03fa92c8a9b216cc6061c", expectError: true, }, { - tx: "020000000185bed1a6da2bffbd60ec681a1bfb71c5111d6395b99b3f8b2bf90167111bcb18f5010000007c83ace802ded24a00000000001600142c4698f9f7a773866879755aa78c516fb332af8e5802000000000000160014d38639dfbac4259323b98a472405db0c461b31fa61073747", - prevOuts: "0144c84d0000000000225120e3f2107989c88e67296ab2faca930efa2e3a5bd3ff0904835a11c9e807458621", + tx: "020000000185bed1a6da2bffbd60ec681a1bfb71c5111d6395b99b3f8b2bf90167111bcb18f5010000007c83ace802ded24a00000000001600142c4698f9f7a773866879755aa78c516fb332af8e5802000000000000160014d38639dfbac4259323b98a472405db0c461b31fa61073747", //nolint:lll + prevOuts: "0144c84d0000000000225120e3f2107989c88e67296ab2faca930efa2e3a5bd3ff0904835a11c9e807458621", //nolint:lll index: 0, hashType: txscript.SigHashNone, // expectSigHash: "3129de36a5d05fff97ffca31eb75fcccbbbc27b3147a7a36a9e4b45d8b625067", expectError: true, }, { - tx: "eb93dbb901028c8515589dac980b6e7f8e4088b77ed866ca0d6d210a7218b6fd0f6b22dd6d7300000000eb4740a9047efc0e0000000000160014913da2128d8fcf292b3691db0e187414aa1783825802000000000000160014913da2128d8fcf292b3691db0e187414aa178382580200000000000017a9143dd27f01c6f7ef9bb9159937b17f17065ed01a0c875802000000000000160014d7630e19df70ada9905ede1722b800c0005f246641000000", - prevOuts: "013fed110000000000225120eb536ae8c33580290630fc495046e998086a64f8f33b93b07967d9029b265c55", + tx: "eb93dbb901028c8515589dac980b6e7f8e4088b77ed866ca0d6d210a7218b6fd0f6b22dd6d7300000000eb4740a9047efc0e0000000000160014913da2128d8fcf292b3691db0e187414aa1783825802000000000000160014913da2128d8fcf292b3691db0e187414aa178382580200000000000017a9143dd27f01c6f7ef9bb9159937b17f17065ed01a0c875802000000000000160014d7630e19df70ada9905ede1722b800c0005f246641000000", //nolint:lll + prevOuts: "013fed110000000000225120eb536ae8c33580290630fc495046e998086a64f8f33b93b07967d9029b265c55", //nolint:lll index: 0, hashType: txscript.SigHashNone | txscript.SigHashAnyOneCanPay, // expectSigHash: "2441e8b0e063a2083ee790f14f2045022f07258ddde5ee01de543c9e789d80ae", expectError: true, }, { - tx: "02000000017836b409a5fed32211407e44b971591f2032053f14701fb5b3a30c0ff382f2cc9c0100000061ac55f60288fb5600000000001976a9144ea02f6f182b082fb6ce47e36bbde390b6a41b5088ac58020000000000001976a9144ea02f6f182b082fb6ce47e36bbde390b6a41b5088ace4000000", - prevOuts: "01efa558000000000022512007071ea3dc7e331b0687d0193d1e6d6ed10e645ef36f10ef8831d5e522ac9e80", + tx: "02000000017836b409a5fed32211407e44b971591f2032053f14701fb5b3a30c0ff382f2cc9c0100000061ac55f60288fb5600000000001976a9144ea02f6f182b082fb6ce47e36bbde390b6a41b5088ac58020000000000001976a9144ea02f6f182b082fb6ce47e36bbde390b6a41b5088ace4000000", //nolint:lll + prevOuts: "01efa558000000000022512007071ea3dc7e331b0687d0193d1e6d6ed10e645ef36f10ef8831d5e522ac9e80", //nolint:lll index: 0, hashType: txscript.SigHashSingle, // expectSigHash: "30239345177cadd0e3ea413d49803580abb6cb27971b481b7788a78d35117a88", expectError: true, }, { - tx: "0100000001aa6deae89d5e0aaca58714fc76ef6f3c8284224888089232d4e663843ed3ab3eae010000008b6657a60450cb4c0000000000160014a3d42b5413ef0c0701c4702f3cd7d4df222c147058020000000000001976a91430b4ed8723a4ee8992aa2c8814cfe5c3ad0ab9d988ac5802000000000000160014365b1166a6ed0a5e8e9dff17a6d00bbb43454bc758020000000000001976a914bc98c51a84fe7fad5dc380eb8b39586eff47241688ac4f313247", - prevOuts: "0107af4e00000000002251202c36d243dfc06cb56a248e62df27ecba7417307511a81ae61aa41c597a929c69", + tx: "0100000001aa6deae89d5e0aaca58714fc76ef6f3c8284224888089232d4e663843ed3ab3eae010000008b6657a60450cb4c0000000000160014a3d42b5413ef0c0701c4702f3cd7d4df222c147058020000000000001976a91430b4ed8723a4ee8992aa2c8814cfe5c3ad0ab9d988ac5802000000000000160014365b1166a6ed0a5e8e9dff17a6d00bbb43454bc758020000000000001976a914bc98c51a84fe7fad5dc380eb8b39586eff47241688ac4f313247", //nolint:lll + prevOuts: "0107af4e00000000002251202c36d243dfc06cb56a248e62df27ecba7417307511a81ae61aa41c597a929c69", //nolint:lll index: 0, hashType: txscript.SigHashSingle | txscript.SigHashAnyOneCanPay, // expectSigHash: "bf9c83f26c6dd16449e4921f813f551c4218e86f2ec906ca8611175b41b566df", diff --git a/libwallet/btcsuitew/txscriptw/standard.go b/libwallet/btcsuitew/txscriptw/standard.go index 09017682..c430840d 100644 --- a/libwallet/btcsuitew/txscriptw/standard.go +++ b/libwallet/btcsuitew/txscriptw/standard.go @@ -3,6 +3,7 @@ package txscriptw import ( "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/txscript" + "github.com/muun/libwallet/btcsuitew/btcutilw" ) diff --git a/libwallet/challenge_keys.go b/libwallet/challenge_keys.go index e3e57e7d..b0d348c2 100644 --- a/libwallet/challenge_keys.go +++ b/libwallet/challenge_keys.go @@ -5,12 +5,11 @@ import ( "crypto/sha256" "encoding/binary" "encoding/hex" - "errors" - "fmt" "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcec/v2/ecdsa" "github.com/btcsuite/btcd/btcutil/base58" + "github.com/go-errors/errors" ) const ( @@ -38,7 +37,8 @@ type encryptedPrivateKey struct { Salt []byte // (optional) 8-byte salt } -// EncryptedPrivateKeyInfo is a Gomobile-compatible version of EncryptedPrivateKey using hex-encoding. +// EncryptedPrivateKeyInfo is a Gomobile-compatible version of EncryptedPrivateKey using +// hex-encoding. type EncryptedPrivateKeyInfo struct { Version int Birthday int @@ -80,7 +80,10 @@ func (k *ChallengePrivateKey) PubKey() *ChallengePublicKey { return &ChallengePublicKey{pubKey: k.key.PubKey()} } -func (k *ChallengePrivateKey) DecryptRawKey(encryptedKey string, network *Network) (*DecryptedPrivateKey, error) { +func (k *ChallengePrivateKey) DecryptRawKey( + encryptedKey string, + network *Network, +) (*DecryptedPrivateKey, error) { decoded, err := DecodeEncryptedPrivateKey(encryptedKey) if err != nil { return nil, err @@ -89,7 +92,10 @@ func (k *ChallengePrivateKey) DecryptRawKey(encryptedKey string, network *Networ return k.DecryptKey(decoded, network) } -func (k *ChallengePrivateKey) DecryptKey(decodedInfo *EncryptedPrivateKeyInfo, network *Network) (*DecryptedPrivateKey, error) { +func (k *ChallengePrivateKey) DecryptKey( + decodedInfo *EncryptedPrivateKeyInfo, + network *Network, +) (*DecryptedPrivateKey, error) { decoded, err := unwrapEncryptedPrivateKey(decodedInfo) if err != nil { return nil, err @@ -105,7 +111,7 @@ func (k *ChallengePrivateKey) DecryptKey(decodedInfo *EncryptedPrivateKeyInfo, n privKey, err := NewMasterHDPrivateKeyFromBytes(rawPrivKey, rawChainCode, network) if err != nil { - return nil, fmt.Errorf("decrypting key: failed to parse key: %w", err) + return nil, errors.Errorf("decrypting key: failed to parse key: %w", err) } return &DecryptedPrivateKey{ @@ -119,7 +125,7 @@ func DecodeEncryptedPrivateKey(encodedKey string) (*EncryptedPrivateKeyInfo, err version, err := reader.ReadByte() if err != nil { - return nil, fmt.Errorf("decrypting key: %w", err) + return nil, errors.Errorf("decrypting key: %w", err) } if version == KeySerializationVersion2 { @@ -130,7 +136,7 @@ func DecodeEncryptedPrivateKey(encodedKey string) (*EncryptedPrivateKeyInfo, err return decodeEncryptedPrivateKeyV3(reader) } - return nil, fmt.Errorf("unrecognized key version %v", version) + return nil, errors.Errorf("unrecognized key version %v", version) } func decodeEncryptedPrivateKeyV3(reader *bytes.Reader) (*EncryptedPrivateKeyInfo, error) { @@ -166,7 +172,10 @@ func decodeEncryptedPrivateKeyV3(reader *bytes.Reader) (*EncryptedPrivateKeyInfo return result, nil } -func decodeEncryptedPrivateKeyV2(reader *bytes.Reader, encodedKey string) (*EncryptedPrivateKeyInfo, error) { +func decodeEncryptedPrivateKeyV2( + reader *bytes.Reader, + encodedKey string, +) (*EncryptedPrivateKeyInfo, error) { birthdayBytes := make([]byte, 2) rawPubEph := make([]byte, serializedPublicKeyLength) diff --git a/libwallet/challenge_keys_test.go b/libwallet/challenge_keys_test.go index 64988453..b9803aaf 100644 --- a/libwallet/challenge_keys_test.go +++ b/libwallet/challenge_keys_test.go @@ -22,7 +22,10 @@ func TestNewChallengePrivateKey(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := NewChallengePrivateKey(tt.args.input, tt.args.salt); !reflect.DeepEqual(got, tt.want) { + if got := NewChallengePrivateKey(tt.args.input, tt.args.salt); !reflect.DeepEqual( + got, + tt.want, + ) { t.Errorf("NewChallengePrivateKey() = %v, want %v", got, tt.want) } }) @@ -46,7 +49,7 @@ func TestChallengeKeyCrypto(t *testing.T) { const ( birthday = 376 - v2MuunSerializedKey = "4TZDw4ndUdVxGL1up8aCxeJHP3nz4RZdz7VHzSskvs7jLc8GbhM2Ey3YhHnT2EopAPkAvqDs3eUDM5uMRfnEqWPSkNVbZ73zNf6KZDWideKKkBQsCkQPXeBbygf6RioEsYpbJYsuGyMnY6QuJHh" + v2MuunSerializedKey = "4TZDw4ndUdVxGL1up8aCxeJHP3nz4RZdz7VHzSskvs7jLc8GbhM2Ey3YhHnT2EopAPkAvqDs3eUDM5uMRfnEqWPSkNVbZ73zNf6KZDWideKKkBQsCkQPXeBbygf6RioEsYpbJYsuGyMnY6QuJHh" //nolint:lll ) network := Regtest() @@ -55,7 +58,8 @@ func TestChallengeKeyCrypto(t *testing.T) { privKey, _ := NewHDPrivateKey(randomBytes(32), network) challengePrivKey := NewChallengePrivateKey([]byte("a very good password"), salt) - encryptedKey, err := challengePrivKey.PubKey().EncryptKey(privKey, salt, birthday, v2MuunSerializedKey) + encryptedKey, err := challengePrivKey.PubKey(). + EncryptKey(privKey, salt, birthday, v2MuunSerializedKey) if err != nil { t.Fatal(err) } @@ -66,7 +70,11 @@ func TestChallengeKeyCrypto(t *testing.T) { } if privKey.String() != decryptedKey.Key.String() { - t.Fatalf("keys dont match: orig %v vs decrypted %v", privKey.String(), decryptedKey.Key.String()) + t.Fatalf( + "keys dont match: orig %v vs decrypted %v", + privKey.String(), + decryptedKey.Key.String(), + ) } if birthday != decryptedKey.Birthday { t.Fatalf("birthdays dont match: expected %v got %v", birthday, decryptedKey.Birthday) @@ -75,9 +83,9 @@ func TestChallengeKeyCrypto(t *testing.T) { func TestChallengeKeyCryptoV2(t *testing.T) { const ( - encodedKey = "tprv8ZgxMBicQKsPcxg1GFGZgL5zALjPwijrYNUqTi2s9JsVqDLzbpX55U9JH2PKAQKExtpdTyboZmV2ytaqr9pAHuxE1hX8k9bQgZAjq25E6P7" - encryptedKey = "4LbSKwcepbbx4dPetoxvTWszb6mLyJHFhumzmdPRVprbn8XZBvFa6Ffarm6R3WGKutFzdxxJgQDdSHuYdjhDp1EZfSNbj12gXMND1AgmNijSxEua3LwVURU3nzWsvV5b1AsWEjJca24CaFY6T3C" - v2MuunSerializedKey = "4TZDw4ndUdVxGL1up8aCxeJHP3nz4RZdz7VHzSskvs7jLc8GbhM2Ey3YhHnT2EopAPkAvqDs3eUDM5uMRfnEqWPSkNVbZ73zNf6KZDWideKKkBQsCkQPXeBbygf6RioEsYpbJYsuGyMnY6QuJHh" + encodedKey = "tprv8ZgxMBicQKsPcxg1GFGZgL5zALjPwijrYNUqTi2s9JsVqDLzbpX55U9JH2PKAQKExtpdTyboZmV2ytaqr9pAHuxE1hX8k9bQgZAjq25E6P7" //nolint:lll + encryptedKey = "4LbSKwcepbbx4dPetoxvTWszb6mLyJHFhumzmdPRVprbn8XZBvFa6Ffarm6R3WGKutFzdxxJgQDdSHuYdjhDp1EZfSNbj12gXMND1AgmNijSxEua3LwVURU3nzWsvV5b1AsWEjJca24CaFY6T3C" //nolint:lll + v2MuunSerializedKey = "4TZDw4ndUdVxGL1up8aCxeJHP3nz4RZdz7VHzSskvs7jLc8GbhM2Ey3YhHnT2EopAPkAvqDs3eUDM5uMRfnEqWPSkNVbZ73zNf6KZDWideKKkBQsCkQPXeBbygf6RioEsYpbJYsuGyMnY6QuJHh" //nolint:lll password = "a very good password" saltLength = 8 birthday = 376 @@ -102,7 +110,8 @@ func TestChallengeKeyCryptoV2(t *testing.T) { t.Fatalf("key doesnt match\ngot %v\nexpected %v\n", decryptedKey.Key.String(), encodedKey) } - _, err = challengeKey.PubKey().EncryptKey(decryptedKey.Key, extractSalt(encryptedKey), birthday, v2MuunSerializedKey) + _, err = challengeKey.PubKey(). + EncryptKey(decryptedKey.Key, extractSalt(encryptedKey), birthday, v2MuunSerializedKey) if err != nil { t.Fatal(err) } @@ -110,9 +119,10 @@ func TestChallengeKeyCryptoV2(t *testing.T) { func TestChallengeKeyCryptoV3(t *testing.T) { const ( - // TODO: How do I extract the encoded key in order to test withouth extracting it from the test failure? - encodedKey = "tprv8ZgxMBicQKsPevsz5yq38nX4LGUVyumVV4jLQuNRa9XSBwYydujAMcyVttkSnEQPwmJpe3DhcpFuc6AeL7vz1L7Lo313ygWuvwRmwE1hrYr" - v3MuunSerializedKey = "FwASVLD82GhZTPCuf2C4tk3einixU2EVAoSEE7vK2RnLBQT4d5Uy6vH42EzLq6MLWzRQQAA9ppwTkdj2NSXmXQYTKpmzf5pjPanxguNJMgyo6bnGzCtgQsExVVGbhpCewX3u1pDFZdB6MFiY" + // TODO: How do I extract the encoded key in order to test withouth extracting it from the + // test failure? + encodedKey = "tprv8ZgxMBicQKsPevsz5yq38nX4LGUVyumVV4jLQuNRa9XSBwYydujAMcyVttkSnEQPwmJpe3DhcpFuc6AeL7vz1L7Lo313ygWuvwRmwE1hrYr" //nolint:lll + v3MuunSerializedKey = "FwASVLD82GhZTPCuf2C4tk3einixU2EVAoSEE7vK2RnLBQT4d5Uy6vH42EzLq6MLWzRQQAA9ppwTkdj2NSXmXQYTKpmzf5pjPanxguNJMgyo6bnGzCtgQsExVVGbhpCewX3u1pDFZdB6MFiY" //nolint:lll password = "a very good password" checksum = "ba2aa3af07aaaa5f" saltLength = 8 @@ -144,7 +154,12 @@ func TestChallengeKeyCryptoV3(t *testing.T) { t.Fatalf("checksum doesnt match\ngot %v\nexpected %v\n", actualChecksum, checksum) } - _, err = challengeKey.PubKey().EncryptKey(decryptedKey.Key, extractSalt(v3MuunSerializedKey), birthday, v3MuunSerializedKey) + _, err = challengeKey.PubKey().EncryptKey( + decryptedKey.Key, + extractSalt(v3MuunSerializedKey), + birthday, + v3MuunSerializedKey, + ) if err != nil { t.Fatal(err) @@ -182,14 +197,14 @@ func TestChecksum(t *testing.T) { func TestDecodeKeyWithOrWithoutSalt(t *testing.T) { const ( // The same encoded key, with one version missing the salt field: - saltedKey = "4LbSKwcepbbx4dPetoxvTWszb6mLyJHFhumzmdPRVprbn8XZBvFa6Ffarm6R3WGKutFzdxxJgQDdSHuYdjhDp1EZfSNbj12gXMND1AgmNijSxEua3LwVURU3nzWsvV5b1AsWEjJca24CaFY6T3C" - unsaltedKey = "5XEEts6mc9WV34krDWsqmpLcPCw2JkK8qJu3gFdZpP8ngkERuQEsaDvYrGkhXUpM6jQRtimTYm4XnBPujpo3MsdYBedsNVxvT3WC6uCCFuzNUZCoydVY39yJXbxva7naDxH5iTra" + saltedKey = "4LbSKwcepbbx4dPetoxvTWszb6mLyJHFhumzmdPRVprbn8XZBvFa6Ffarm6R3WGKutFzdxxJgQDdSHuYdjhDp1EZfSNbj12gXMND1AgmNijSxEua3LwVURU3nzWsvV5b1AsWEjJca24CaFY6T3C" //nolint:lll + unsaltedKey = "5XEEts6mc9WV34krDWsqmpLcPCw2JkK8qJu3gFdZpP8ngkERuQEsaDvYrGkhXUpM6jQRtimTYm4XnBPujpo3MsdYBedsNVxvT3WC6uCCFuzNUZCoydVY39yJXbxva7naDxH5iTra" //nolint:lll ) expected := &EncryptedPrivateKeyInfo{ Version: 2, Birthday: 376, - CipherText: "f6af1ecd17052a81b75902c1712567cf1c650329875feb7e24af3e27235f384054ea549025e99dc2659f95bb6447cf861aa2ec0407ea74baf5a9d6a885ae184b", + CipherText: "f6af1ecd17052a81b75902c1712567cf1c650329875feb7e24af3e27235f384054ea549025e99dc2659f95bb6447cf861aa2ec0407ea74baf5a9d6a885ae184b", //nolint:lll EphPublicKey: "020a8d322dda8ff685d80b16681d4e87c109664cdc246a9d3625adfe0de203e71e", Salt: "e3305526d0cd675f", } @@ -215,13 +230,13 @@ func TestDecodeKeyWithOrWithoutSalt(t *testing.T) { func TestDecodeKeyV3(t *testing.T) { const ( - v3MuunSerializedKey = "FwBs2Fh3TCTMhTg9DNrr3MuiGhVmiNGeqpg8Zubo8mbZkYpNejJZkmsTU7iJNXEtxmWDVXaF8auAaQhFj8oMH5BhfLAdieLVAuy59RGHsCvEwzubbY7dzqYvpcSfWypzcERHxKVTMmjqwtTK" + v3MuunSerializedKey = "FwBs2Fh3TCTMhTg9DNrr3MuiGhVmiNGeqpg8Zubo8mbZkYpNejJZkmsTU7iJNXEtxmWDVXaF8auAaQhFj8oMH5BhfLAdieLVAuy59RGHsCvEwzubbY7dzqYvpcSfWypzcERHxKVTMmjqwtTK" //nolint:lll ) expected := &EncryptedPrivateKeyInfo{ Version: 3, Birthday: 0, - CipherText: "0ce3ff52d4bb35e99f0868585342cc7f95c7b282c9b57ab44177b3caeb5a5177972ced426cfc09d38703d3f2ec623fcd202456b4d5238cd7707c284182161e44", + CipherText: "0ce3ff52d4bb35e99f0868585342cc7f95c7b282c9b57ab44177b3caeb5a5177972ced426cfc09d38703d3f2ec623fcd202456b4d5238cd7707c284182161e44", //nolint:lll EphPublicKey: "03ab02bfb3f61a213d2c4ea980689fea20a866d718e6d009f1149f074ba00bc066", Salt: "6675492c525f1ed2", } @@ -238,7 +253,7 @@ func TestDecodeKeyV3(t *testing.T) { func TestDecodeUnknownKeyVersion(t *testing.T) { const ( sixtyInBase58 = "2j" - v3MuunSerializedKey = sixtyInBase58 + "63FwBs2Fh3TCTMhTg9DNrr3MuiGhVmiNGeqpg8Zubo8mbZkYpNejJZkmsTU7iJNXEtxmWDVXaF8auAaQhFj8oMH5BhfLAdieLVAuy59RGHsCvEwzubbY7dzqYvpcSfWypzcERHxKVTMmjqwtTK" + v3MuunSerializedKey = sixtyInBase58 + "63FwBs2Fh3TCTMhTg9DNrr3MuiGhVmiNGeqpg8Zubo8mbZkYpNejJZkmsTU7iJNXEtxmWDVXaF8auAaQhFj8oMH5BhfLAdieLVAuy59RGHsCvEwzubbY7dzqYvpcSfWypzcERHxKVTMmjqwtTK" //nolint:lll ) _, err := DecodeEncryptedPrivateKey(v3MuunSerializedKey) diff --git a/libwallet/challenge_public_key.go b/libwallet/challenge_public_key.go index de576237..5b65051c 100644 --- a/libwallet/challenge_public_key.go +++ b/libwallet/challenge_public_key.go @@ -4,9 +4,10 @@ import ( "bytes" "encoding/binary" "encoding/hex" - "fmt" + "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil/base58" + "github.com/go-errors/errors" ) type ChallengePublicKey struct { @@ -24,11 +25,11 @@ func NewChallengePublicKeyFromSerialized(serializedKey []byte) (*ChallengePublic } // EncryptKey -// We must check whether the MuunKey is serialized as V2 or V3 before serializing the UserKey. -// Since the MuunKey is stored client-side after login, users already logged in will always have -// the MuunKey serialized as V2 on their devices. If the user updates the app without re-logging in, -// a MuunKeyV2 will remain stored, and we must serialize the UserKey as V2 to maintain key consistency -// in the EmergencyKit. +// We must check whether the MuunKey is serialized as V2 or V3 before serializing the +// UserKey. Since the MuunKey is stored client-side after login, users already logged in +// will always have the MuunKey serialized as V2 on their devices. If the user updates +// the app without re-logging in, a MuunKeyV2 will remain stored, and we must serialize +// the UserKey as V2 to maintain key consistency in the EmergencyKit. func (k *ChallengePublicKey) EncryptKey( privKey *HDPrivateKey, recoveryCodeSalt []byte, @@ -38,7 +39,7 @@ func (k *ChallengePublicKey) EncryptKey( reader := bytes.NewReader(base58.Decode(muunPrivateKey)) version, err := reader.ReadByte() if err != nil { - return "", fmt.Errorf("decrypting key: %w", err) + return "", errors.Errorf("decrypting key: %w", err) } switch version { @@ -47,11 +48,15 @@ func (k *ChallengePublicKey) EncryptKey( case 3: return k.encryptKeyAsV3(privKey, recoveryCodeSalt, birthday) default: - return "", fmt.Errorf("unrecognized key version %v", version) + return "", errors.Errorf("unrecognized key version %v", version) } } -func (k *ChallengePublicKey) encryptKeyAsV2(privKey *HDPrivateKey, recoveryCodeSalt []byte, birthday int) (string, error) { +func (k *ChallengePublicKey) encryptKeyAsV2( + privKey *HDPrivateKey, + recoveryCodeSalt []byte, + birthday int, +) (string, error) { const ( chainCodeStart = 13 chainCodeLength = 32 @@ -64,7 +69,10 @@ func (k *ChallengePublicKey) encryptKeyAsV2(privKey *HDPrivateKey, recoveryCodeS plaintext = append(plaintext, rawHDKey[privKeyStart:privKeyStart+privKeyLength]...) plaintext = append(plaintext, rawHDKey[chainCodeStart:chainCodeStart+chainCodeLength]...) if len(plaintext) != 64 { - return "", fmt.Errorf("failed to encrypt key: expected payload of 64 bytes, found %v", len(plaintext)) + return "", errors.Errorf( + "failed to encrypt key: expected payload of 64 bytes, found %v", + len(plaintext), + ) } pubEph, ciphertext, err := encryptWithPubKey(k.pubKey, plaintext) @@ -91,7 +99,11 @@ func (k *ChallengePublicKey) encryptKeyAsV2(privKey *HDPrivateKey, recoveryCodeS return base58.Encode(buf.Bytes()), nil } -func (k *ChallengePublicKey) encryptKeyAsV3(privKey *HDPrivateKey, recoveryCodeSalt []byte, birthday int) (string, error) { +func (k *ChallengePublicKey) encryptKeyAsV3( + privKey *HDPrivateKey, + recoveryCodeSalt []byte, + birthday int, //nolint:revive // TODO: use or remove birthday +) (string, error) { const ( chainCodeStart = 13 chainCodeLength = 32 @@ -104,7 +116,10 @@ func (k *ChallengePublicKey) encryptKeyAsV3(privKey *HDPrivateKey, recoveryCodeS plaintext = append(plaintext, rawHDKey[privKeyStart:privKeyStart+privKeyLength]...) plaintext = append(plaintext, rawHDKey[chainCodeStart:chainCodeStart+chainCodeLength]...) if len(plaintext) != 64 { - return "", fmt.Errorf("failed to encrypt key: expected payload of 64 bytes, found %v", len(plaintext)) + return "", errors.Errorf( + "failed to encrypt key: expected payload of 64 bytes, found %v", + len(plaintext), + ) } pubEph, ciphertext, err := encryptWithPubKey(k.pubKey, plaintext) diff --git a/libwallet/cmd/kv_migration_tool/main.go b/libwallet/cmd/kv_migration_tool/main.go index f07ca96b..9eb9b622 100644 --- a/libwallet/cmd/kv_migration_tool/main.go +++ b/libwallet/cmd/kv_migration_tool/main.go @@ -3,10 +3,11 @@ package main import ( "encoding/json" "flag" - "fmt" "log" "os" + "github.com/go-errors/errors" + "github.com/muun/libwallet/internal/kvmigrationlock" "github.com/muun/libwallet/storage" ) @@ -17,11 +18,19 @@ const defaultLockfile = "storage/testdata/kv_migrations.lock" func main() { lockCmd := flag.NewFlagSet("lock", flag.ExitOnError) - lockMigrationsFile := lockCmd.String("migrations-file", defaultMigrationsFile, "path to the migrations Go source file") + lockMigrationsFile := lockCmd.String( + "migrations-file", + defaultMigrationsFile, + "path to the migrations Go source file", + ) lockLockfile := lockCmd.String("lockfile", defaultLockfile, "path to the lockfile to write") verifyCmd := flag.NewFlagSet("verify", flag.ExitOnError) - verifyMigrationsFile := verifyCmd.String("migrations-file", defaultMigrationsFile, "path to the migrations Go source file") + verifyMigrationsFile := verifyCmd.String( + "migrations-file", + defaultMigrationsFile, + "path to the migrations Go source file", + ) verifyLockfile := verifyCmd.String("lockfile", defaultLockfile, "path to the lockfile to read") if len(os.Args) < 2 { @@ -30,13 +39,13 @@ func main() { switch os.Args[1] { case "lock": - lockCmd.Parse(os.Args[2:]) + lockCmd.Parse(os.Args[2:]) //nolint:errcheck // TODO: check error err := runLock(storage.BuildKVMigrationPlan(), *lockMigrationsFile, *lockLockfile) if err != nil { log.Fatal(err) } case "verify": - verifyCmd.Parse(os.Args[2:]) + verifyCmd.Parse(os.Args[2:]) //nolint:errcheck // TODO: check error err := runVerify(storage.BuildKVMigrationPlan(), *verifyMigrationsFile, *verifyLockfile) if err != nil { log.Fatal(err) @@ -49,7 +58,7 @@ func main() { func runLock(plan []storage.Migration, migrationsFile, lockfilePath string) error { generatedLockFile, err := kvmigrationlock.Generate(plan, migrationsFile) if err != nil { - return fmt.Errorf("failed to generate lockfile: %w", err) + return errors.Errorf("failed to generate lockfile: %w", err) } // Check if a committed lockfile exists. @@ -57,14 +66,14 @@ func runLock(plan []storage.Migration, migrationsFile, lockfilePath string) erro if os.IsNotExist(err) { // No lockfile yet, nothing to verify. } else if err != nil { - return fmt.Errorf("failed to read existing lockfile: %w", err) + return errors.Errorf("failed to read existing lockfile: %w", err) } else { // If a lockfile already exists, verify that no existing migration was modified or deleted. // Only appending new migrations is allowed. Rewriting history is not allowed. var committedLockfile kvmigrationlock.Lockfile err = json.Unmarshal(existingData, &committedLockfile) if err != nil { - return fmt.Errorf("failed to parse existing lockfile: %w", err) + return errors.Errorf("failed to parse existing lockfile: %w", err) } err = failIfHistoryIsModified(committedLockfile, generatedLockFile) if err != nil { @@ -74,12 +83,12 @@ func runLock(plan []storage.Migration, migrationsFile, lockfilePath string) erro data, err := json.MarshalIndent(generatedLockFile, "", " ") if err != nil { - return fmt.Errorf("failed to marshal lockfile: %w", err) + return errors.Errorf("failed to marshal lockfile: %w", err) } err = os.WriteFile(lockfilePath, data, 0644) if err != nil { - return fmt.Errorf("failed to write lockfile: %w", err) + return errors.Errorf("failed to write lockfile: %w", err) } log.Printf("Successfully wrote %s", lockfilePath) return nil @@ -88,18 +97,18 @@ func runLock(plan []storage.Migration, migrationsFile, lockfilePath string) erro func runVerify(plan []storage.Migration, migrationsFile, lockfilePath string) error { generatedLockFile, err := kvmigrationlock.Generate(plan, migrationsFile) if err != nil { - return fmt.Errorf("failed to generate lockfile for verification: %w", err) + return errors.Errorf("failed to generate lockfile for verification: %w", err) } existingData, err := os.ReadFile(lockfilePath) if err != nil { - return fmt.Errorf("failed to read lockfile. Run 'lock' first: %w", err) + return errors.Errorf("failed to read lockfile. Run 'lock' first: %w", err) } var committedLockFile kvmigrationlock.Lockfile err = json.Unmarshal(existingData, &committedLockFile) if err != nil { - return fmt.Errorf("failed to parse lockfile: %w", err) + return errors.Errorf("failed to parse lockfile: %w", err) } err = failIfHistoryIsModified(committedLockFile, generatedLockFile) @@ -107,7 +116,7 @@ func runVerify(plan []storage.Migration, migrationsFile, lockfilePath string) er return err } if len(committedLockFile.Migrations) < len(generatedLockFile.Migrations) { - return fmt.Errorf("lockfile has %d migrations but plan has %d, run 'lock' to update", + return errors.Errorf("lockfile has %d migrations but plan has %d, run 'lock' to update", len(committedLockFile.Migrations), len(generatedLockFile.Migrations)) } log.Println("OK: all existing migrations are unmodified.") @@ -115,14 +124,20 @@ func runVerify(plan []storage.Migration, migrationsFile, lockfilePath string) er } // failIfHistoryIsModified checks that no committed migration was modified or deleted. -func failIfHistoryIsModified(committedLockfile kvmigrationlock.Lockfile, generatedLockfile *kvmigrationlock.Lockfile) error { +func failIfHistoryIsModified( + committedLockfile kvmigrationlock.Lockfile, + generatedLockfile *kvmigrationlock.Lockfile, +) error { if len(committedLockfile.Migrations) > len(generatedLockfile.Migrations) { - return fmt.Errorf("plan has %d migrations but lockfile has %d: deleting migrations is not allowed", - len(generatedLockfile.Migrations), len(committedLockfile.Migrations)) + return errors.Errorf( + "plan has %d migrations but lockfile has %d: deleting migrations is not allowed", + len(generatedLockfile.Migrations), + len(committedLockfile.Migrations), + ) } for i, existing := range committedLockfile.Migrations { if existing.Hash != generatedLockfile.Migrations[i].Hash { - return fmt.Errorf("migration %d (%q) was modified", i+1, existing.Description) + return errors.Errorf("migration %d (%q) was modified", i+1, existing.Description) } } return nil diff --git a/libwallet/cmd/kv_migration_tool/main_test.go b/libwallet/cmd/kv_migration_tool/main_test.go index 49120170..cab94fc4 100644 --- a/libwallet/cmd/kv_migration_tool/main_test.go +++ b/libwallet/cmd/kv_migration_tool/main_test.go @@ -12,23 +12,47 @@ import ( var planV1 = []storage.Migration{ {Description: "Migration 1", Changes: []storage.Change{ - storage.Define("key1", storage.NoAutoBackup, storage.NotApplicable, false, &storage.StringType{}), + storage.Define( + "key1", + storage.NoAutoBackup, + storage.NotApplicable, + false, + &storage.StringType{}, + ), }}, } var planV2 = []storage.Migration{ {Description: "Migration 1", Changes: []storage.Change{ - storage.Define("key1", storage.NoAutoBackup, storage.NotApplicable, false, &storage.StringType{}), + storage.Define( + "key1", + storage.NoAutoBackup, + storage.NotApplicable, + false, + &storage.StringType{}, + ), }}, {Description: "Migration 2", Changes: []storage.Change{ - storage.Define("key2", storage.NoAutoBackup, storage.NotApplicable, false, &storage.StringType{}), + storage.Define( + "key2", + storage.NoAutoBackup, + storage.NotApplicable, + false, + &storage.StringType{}, + ), }}, } // planV1Modified has the same description as planV1 but different content, so its hash differs. var planV1Modified = []storage.Migration{ {Description: "Migration 1", Changes: []storage.Change{ - storage.Define("key1_modified", storage.NoAutoBackup, storage.NotApplicable, false, &storage.StringType{}), + storage.Define( + "key1_modified", + storage.NoAutoBackup, + storage.NotApplicable, + false, + &storage.StringType{}, + ), }}, } diff --git a/libwallet/cryptography/bitcoin_hpke/bitcoin_hpke.go b/libwallet/cryptography/bitcoin_hpke/bitcoin_hpke.go index 17b6796e..fa3e1524 100644 --- a/libwallet/cryptography/bitcoin_hpke/bitcoin_hpke.go +++ b/libwallet/cryptography/bitcoin_hpke/bitcoin_hpke.go @@ -1,34 +1,39 @@ package bitcoin_hpke import ( + "slices" + "github.com/btcsuite/btcd/btcec/v2" "golang.org/x/crypto/chacha20poly1305" - "slices" ) -// This package implements HPKE(DHKEM(secp256k1, HKDF-SHA256), HKDF-SHA256, Chacha20Poly1305) in base, single shot mode. -// Note that DHKEM(secp256k1, HKDF-SHA256) is not part of HPKE as described in RFC-9180 but of a proposed extension by -// R.S. Wahby (https://www.ietf.org/archive/id/draft-wahby-cfrg-hpke-kem-secp256k1-01.html). -// This HPKE configuration is notably used in the Payjoin protocol (https://github.com/payjoin/bitcoin-hpke). +// This package implements HPKE(DHKEM(secp256k1, HKDF-SHA256), HKDF-SHA256, Chacha20Poly1305) in +// base, single shot mode. +// Note that DHKEM(secp256k1, HKDF-SHA256) is not part of HPKE as described in RFC-9180 but of a +// proposed extension by R.S. Wahby +// (https://www.ietf.org/archive/id/draft-wahby-cfrg-hpke-kem-secp256k1-01.html). +// This HPKE configuration is notably used in the Payjoin protocol +// (https://github.com/payjoin/bitcoin-hpke). const ( hpkeIdentifier = "HPKE-v1" baseMode = 0x00 // Table 1 of RFC 9180 defaultPsk = "" // Section 5.1 of RFC 9180 - defaultPskId = "" // Section 5.1 of RFC 9180 + defaultPskId = "" //nolint:staticcheck // TODO: const defaultPskId should be defaultPskID // Section 5.1 of RFC 9180 // KEM constants (see Wahby's Internet-Draft) - kemId = 0x0016 // This is DHKEM(secp256k1, HKDF-SHA256) - privateKeyLengthInBytes = 32 // The length in bytes of a KEM shared secret - encapsulatedKeyLengthInBytes = 65 // The length in bytes of an encapsulated key - diffieHellmanSharedSecretLengthInBytes = 32 // The length in bytes of a Diffie-Hellman shared secret + kemId = 0x0016 //nolint:staticcheck // TODO: const kemId should be kemID // This is DHKEM(secp256k1, HKDF-SHA256) + privateKeyLengthInBytes = 32 // The length in bytes of a KEM shared secret + encapsulatedKeyLengthInBytes = 65 // The length in bytes of an encapsulated key + // The length in bytes of a Diffie-Hellman shared secret. + diffieHellmanSharedSecretLengthInBytes = 32 // KDF constants (see Table 3 of RFC 9180) - kdfId = 0x0001 // This is HKDF-SHA256 + kdfId = 0x0001 //nolint:staticcheck // TODO: const kdfId should be kdfID // This is HKDF-SHA256 // AEAD constants (see Table 5 of RFC 9180) - aeadId = 0x0003 // This is Chacha20Poly1305 + aeadId = 0x0003 //nolint:staticcheck // TODO: const aeadId should be aeadID // This is Chacha20Poly1305 keyLengthInBytes = 32 // The length in bytes of a key nonceLengthInBytes = 12 // The length in bytes of a nonce authenticationTagLengthInBytes = 16 // The length in bytes of an authentication tag @@ -57,7 +62,8 @@ func SingleShotEncrypt( return nil, err } - // Sealing with a nil value for the dst parameter has the effect of allocating a new slice for the result + // Sealing with a nil value for the dst parameter has the effect of allocating a new slice for + // the result ciphertext := aead.Seal(nil, baseNonce, plaintext, additionalAuthenticatedData) return &EncryptedMessage{ @@ -88,8 +94,14 @@ func (encryptedMessage EncryptedMessage) SingleShotDecrypt( return nil, err } - // Opening with a nil value for the dst parameter has the effect of allocating a new slice for the result - plaintext, err := aead.Open(nil, baseNonce, encryptedMessage.ciphertext, additionalAuthenticatedData) + // Opening with a nil value for the dst parameter has the effect of allocating a new slice for + // the result + plaintext, err := aead.Open( + nil, + baseNonce, + encryptedMessage.ciphertext, + additionalAuthenticatedData, + ) return normalize(plaintext), err } @@ -97,14 +109,19 @@ func (encryptedMessage EncryptedMessage) SingleShotDecrypt( // See Section 5.1 of RFC 9180 func keyScheduleBase(sharedSecret, info []byte) (key, baseNonce []byte, err error) { - suiteId := slices.Concat( + suiteId := slices.Concat( //nolint:staticcheck // TODO: var suiteId should be suiteID []byte("HPKE"), i2Osp(kemId, 2), i2Osp(kdfId, 2), i2Osp(aeadId, 2), ) - pskIdHash := labeledExtract([]byte(""), []byte("psk_id_hash"), []byte(defaultPskId), suiteId) + pskIdHash := labeledExtract( //nolint:staticcheck // TODO: var pskIdHash should be pskIDHash + []byte(""), + []byte("psk_id_hash"), + []byte(defaultPskId), + suiteId, + ) infoHash := labeledExtract([]byte(""), []byte("info_hash"), info, suiteId) @@ -117,7 +134,13 @@ func keyScheduleBase(sharedSecret, info []byte) (key, baseNonce []byte, err erro return nil, nil, err } - baseNonce, err = labeledExpand(secret, []byte("base_nonce"), keyScheduleContext, nonceLengthInBytes, suiteId) + baseNonce, err = labeledExpand( + secret, + []byte("base_nonce"), + keyScheduleContext, + nonceLengthInBytes, + suiteId, + ) if err != nil { return nil, nil, err } diff --git a/libwallet/cryptography/bitcoin_hpke/bitcoin_hpke_test.go b/libwallet/cryptography/bitcoin_hpke/bitcoin_hpke_test.go index fc7a895e..08e58f11 100644 --- a/libwallet/cryptography/bitcoin_hpke/bitcoin_hpke_test.go +++ b/libwallet/cryptography/bitcoin_hpke/bitcoin_hpke_test.go @@ -2,9 +2,10 @@ package bitcoin_hpke import ( "encoding/hex" + "testing" + "github.com/btcsuite/btcd/btcec/v2" "github.com/test-go/testify/assert" - "testing" ) type testVector struct { @@ -34,14 +35,24 @@ func TestHpke(t *testing.T) { return ephemeralPrivateKey, ephemeralPrivateKey.PubKey(), nil } - encryptedMessage, err := SingleShotEncrypt(plaintext, receiverPublicKey, info, additionalAuthenticatedData) + encryptedMessage, err := SingleShotEncrypt( + plaintext, + receiverPublicKey, + info, + additionalAuthenticatedData, + ) if err != nil { t.Fatal(err) } - assert.Equal(t, MustParsePublicKey(t, v.ephemeralPublicKey), encryptedMessage.GetEncapsulatedKey()) + assert.Equal( + t, + MustParsePublicKey(t, v.ephemeralPublicKey), + encryptedMessage.GetEncapsulatedKey(), + ) assert.Equal(t, MustDecode(t, v.ciphertext), encryptedMessage.GetCiphertext()) - decryptedPlaintext, err := encryptedMessage.SingleShotDecrypt(MustParsePrivateKey(t, v.receiverPrivateKey), + decryptedPlaintext, err := encryptedMessage.SingleShotDecrypt( + MustParsePrivateKey(t, v.receiverPrivateKey), info, additionalAuthenticatedData, ) @@ -62,7 +73,12 @@ func TestSerializationAndParsing(t *testing.T) { testingOnlyGenerateKeyPair = nil - encryptedMessage, err := SingleShotEncrypt(plaintext, receiverPublicKey, info, additionalAuthenticatedData) + encryptedMessage, err := SingleShotEncrypt( + plaintext, + receiverPublicKey, + info, + additionalAuthenticatedData, + ) if err != nil { t.Fatal(err) } @@ -72,10 +88,18 @@ func TestSerializationAndParsing(t *testing.T) { if err != nil { t.Fatal(err) } - assert.Equal(t, encryptedMessage.GetEncapsulatedKey(), deserializedMessage.GetEncapsulatedKey()) + assert.Equal( + t, + encryptedMessage.GetEncapsulatedKey(), + deserializedMessage.GetEncapsulatedKey(), + ) assert.Equal(t, encryptedMessage.GetCiphertext(), deserializedMessage.GetCiphertext()) - assert.Equal(t, len(serializedMessage), SerializedEncryptedMessageLengthInBytes(len(plaintext))) + assert.Equal( + t, + len(serializedMessage), + SerializedEncryptedMessageLengthInBytes(len(plaintext)), + ) } } @@ -103,366 +127,366 @@ func MustDecode(t *testing.T, s string) []byte { var testVectors = [26]testVector{ { info: "", - ephemeralPrivateKey: "312831157576e9e3411d23e5efeb3af16c38927cf6a5e655a6e010ba6f16ca21", - ephemeralPublicKey: "0413f6082ca42883fd226f196f2c60434c416187053e7480a810de825b3118027545d91cec0bf31f76099ec9eb5ff4f30226e6e88e77a01977ac95992c26604bad", - receiverPrivateKey: "09bbefb55e56548922b6d35de0b95344b8d569415f4c90f15d1441f1cd1bca4b", - receiverPublicKey: "047d70c6d509a950147a10d251c8ec62a36b1b2ebceaa3e49b2ad13c5e3ae748fc2dd9b79002a196b227a15281ca90689cad26587aabbee6ea6a2d7409548e2556", - sharedSecret: "d7275fcaab68699a8ef6854f3a6e85e1b0314a108927d04e1f4961a538d3e3b9", - key: "94ea9705ec513308aa423a5542712b132791c15696fe62036936f0c56deafd83", + ephemeralPrivateKey: "312831157576e9e3411d23e5efeb3af16c38927cf6a5e655a6e010ba6f16ca21", //nolint:lll + ephemeralPublicKey: "0413f6082ca42883fd226f196f2c60434c416187053e7480a810de825b3118027545d91cec0bf31f76099ec9eb5ff4f30226e6e88e77a01977ac95992c26604bad", //nolint:lll + receiverPrivateKey: "09bbefb55e56548922b6d35de0b95344b8d569415f4c90f15d1441f1cd1bca4b", //nolint:lll + receiverPublicKey: "047d70c6d509a950147a10d251c8ec62a36b1b2ebceaa3e49b2ad13c5e3ae748fc2dd9b79002a196b227a15281ca90689cad26587aabbee6ea6a2d7409548e2556", //nolint:lll + sharedSecret: "d7275fcaab68699a8ef6854f3a6e85e1b0314a108927d04e1f4961a538d3e3b9", //nolint:lll + key: "94ea9705ec513308aa423a5542712b132791c15696fe62036936f0c56deafd83", //nolint:lll baseNonce: "91f4df09602026724fc9ab11", - exporterSecret: "c05cb0915f0e07bfb205e737f97f7bd86c1f0db08f67efc29a55396356ebabbb", + exporterSecret: "c05cb0915f0e07bfb205e737f97f7bd86c1f0db08f67efc29a55396356ebabbb", //nolint:lll additionalAuthenticatedData: "", plaintext: "", ciphertext: "85e79a6a327c3af22e6633fcaab1b338", }, { info: "", - ephemeralPrivateKey: "178b7e7a32a3ef5e2be6a867c8e44178338b0913f515b3822e80476c75e6ec72", - ephemeralPublicKey: "042438d74563c350d67a847fd13df068b96c0d00dfe94ebccaf6be266ee61660a9c94355e1ebb89205345399f725ed23d6b8cbaa9eb815877d29b55b6061009e0f", - receiverPrivateKey: "7f0b8fe19589ce2b9f156ebab49d6d03976d0969f1b872d75d4385bdb65b9dda", - receiverPublicKey: "04007f0d25aa2452bf5e0ace0798f24fb4f1a7eea32135190544bcbe088af283189369b75448f174ab9bcad8b5246fd27f0e65e9aeebd47b62cfe6ce852ffb7c87", - sharedSecret: "7d153129057ba148425a0751579f935d59aa6bcb8ee9eedb8820c66b3bca0a66", - key: "8898f8b84ab9e07e53975ee8cdd24990a8bb6b6a275f9e7f2ea2f1285757788b", + ephemeralPrivateKey: "178b7e7a32a3ef5e2be6a867c8e44178338b0913f515b3822e80476c75e6ec72", //nolint:lll + ephemeralPublicKey: "042438d74563c350d67a847fd13df068b96c0d00dfe94ebccaf6be266ee61660a9c94355e1ebb89205345399f725ed23d6b8cbaa9eb815877d29b55b6061009e0f", //nolint:lll + receiverPrivateKey: "7f0b8fe19589ce2b9f156ebab49d6d03976d0969f1b872d75d4385bdb65b9dda", //nolint:lll + receiverPublicKey: "04007f0d25aa2452bf5e0ace0798f24fb4f1a7eea32135190544bcbe088af283189369b75448f174ab9bcad8b5246fd27f0e65e9aeebd47b62cfe6ce852ffb7c87", //nolint:lll + sharedSecret: "7d153129057ba148425a0751579f935d59aa6bcb8ee9eedb8820c66b3bca0a66", //nolint:lll + key: "8898f8b84ab9e07e53975ee8cdd24990a8bb6b6a275f9e7f2ea2f1285757788b", //nolint:lll baseNonce: "d3cf2312277bda8f8cd190dd", - exporterSecret: "578ce8f69cf247d2c77921ac5ac794af12852d11239ac39ba27826ac0e639467", + exporterSecret: "578ce8f69cf247d2c77921ac5ac794af12852d11239ac39ba27826ac0e639467", //nolint:lll additionalAuthenticatedData: "", plaintext: "cd74c634", ciphertext: "63a242ce38ad87543a63b4519dcbe1aa43f32212", }, { info: "ca934a32", - ephemeralPrivateKey: "bd02c4d5af0fc7b253f1385d9d433fcfc6cd06de006a2008604625e2a95f70dd", - ephemeralPublicKey: "0436bb51ab8ecfebd7271dd9eca689ef962ee29229499352c25a0d185e6985325a487872d692f12ddca7de4fe47a1163ba828bed5e1aa1989105dbeac31502be77", - receiverPrivateKey: "c7fa0c07592ede040d65ac865a4bf9627e1e4527bffb0bf22b1cbd49c7b9e9ac", - receiverPublicKey: "04247c309c15f4fecb7f9826e02a8b2f654f3fca6a6a8c0ce7b31a9734eb60f8b54c224e140d35ff51eb3362ad67d20a0ab4331d7acb4f579f195a99a76737a9fc", - sharedSecret: "a13c42352dae44602118d9a8d44237df7e749b3bef24e17d06213cd114829c6c", - key: "39f0a43f0787c62d16cf3b92b3c009ec9ecf50c154d263e85aab5ad1faf6678f", + ephemeralPrivateKey: "bd02c4d5af0fc7b253f1385d9d433fcfc6cd06de006a2008604625e2a95f70dd", //nolint:lll + ephemeralPublicKey: "0436bb51ab8ecfebd7271dd9eca689ef962ee29229499352c25a0d185e6985325a487872d692f12ddca7de4fe47a1163ba828bed5e1aa1989105dbeac31502be77", //nolint:lll + receiverPrivateKey: "c7fa0c07592ede040d65ac865a4bf9627e1e4527bffb0bf22b1cbd49c7b9e9ac", //nolint:lll + receiverPublicKey: "04247c309c15f4fecb7f9826e02a8b2f654f3fca6a6a8c0ce7b31a9734eb60f8b54c224e140d35ff51eb3362ad67d20a0ab4331d7acb4f579f195a99a76737a9fc", //nolint:lll + sharedSecret: "a13c42352dae44602118d9a8d44237df7e749b3bef24e17d06213cd114829c6c", //nolint:lll + key: "39f0a43f0787c62d16cf3b92b3c009ec9ecf50c154d263e85aab5ad1faf6678f", //nolint:lll baseNonce: "37a89715dab54ac09c291927", - exporterSecret: "4b4325a2682d8fe70583e9e309752de2d20dd7ca7a70c9202677fcdc15e1e461", + exporterSecret: "4b4325a2682d8fe70583e9e309752de2d20dd7ca7a70c9202677fcdc15e1e461", //nolint:lll additionalAuthenticatedData: "", plaintext: "", ciphertext: "925ab831058881de88c1ca8196e017a3", }, { info: "", - ephemeralPrivateKey: "85d410cfab141e67efd22e2b636c3f436a108502dbd3758f3d8f9d6b9e254639", - ephemeralPublicKey: "047fb5eec54f2a4320a4865f64db762d40634a924d2fd164085c056e0c69c50ce08ccb232c2f7f6724636f9e83755f7a58108faa0fd15f198a52aeaef976d8102a", - receiverPrivateKey: "60d51b81e7990e688e5c6d0887479130176a79cc111c9dd881125bb00d82914e", - receiverPublicKey: "0454140ba1ca2fbf1ccff19f52982ae5dc4dbe072449d580e99040851bdce0ed0c1da224ab3de6971068a57d2ed9f689d711fdab184736fcff0bdc8df2e9f95eca", - sharedSecret: "fd64f22b9586e022d5ef428e6de0ae7c290e298cdb517de93289b1c1b6a45469", - key: "a0b873e490b7cb6f213466d61ed45b609d14183eae5940924a0b8c66cb0af0ba", + ephemeralPrivateKey: "85d410cfab141e67efd22e2b636c3f436a108502dbd3758f3d8f9d6b9e254639", //nolint:lll + ephemeralPublicKey: "047fb5eec54f2a4320a4865f64db762d40634a924d2fd164085c056e0c69c50ce08ccb232c2f7f6724636f9e83755f7a58108faa0fd15f198a52aeaef976d8102a", //nolint:lll + receiverPrivateKey: "60d51b81e7990e688e5c6d0887479130176a79cc111c9dd881125bb00d82914e", //nolint:lll + receiverPublicKey: "0454140ba1ca2fbf1ccff19f52982ae5dc4dbe072449d580e99040851bdce0ed0c1da224ab3de6971068a57d2ed9f689d711fdab184736fcff0bdc8df2e9f95eca", //nolint:lll + sharedSecret: "fd64f22b9586e022d5ef428e6de0ae7c290e298cdb517de93289b1c1b6a45469", //nolint:lll + key: "a0b873e490b7cb6f213466d61ed45b609d14183eae5940924a0b8c66cb0af0ba", //nolint:lll baseNonce: "c3e5d91ef98bf99c16ba21e7", - exporterSecret: "c1e0606a9eddb4e12c11b1bec61d274c92476008b451b7f5490abc994d60fd37", + exporterSecret: "c1e0606a9eddb4e12c11b1bec61d274c92476008b451b7f5490abc994d60fd37", //nolint:lll additionalAuthenticatedData: "82e5a0d8", plaintext: "", ciphertext: "1cb5d8635c788983d23a1de82cbcb6af", }, { info: "d11a9ff3", - ephemeralPrivateKey: "b1505eab66964efb59dbc75d71b22bb0386ee09bd7a17b619d64215aad91e7e3", - ephemeralPublicKey: "0462fafba814d2aa53c4821c2ae4d4b8d6e58f3b5197a40427607da916b8de0da4d3fd5868e267e4a97f7d62bff66a67ddbbcb60ccfc108fac7a7101b0e0bd3cfb", - receiverPrivateKey: "b8a34a85bbbab82ea8edec50a4e199f7f4832f8eb6ae54c54e08b6fb501eb15c", - receiverPublicKey: "04089ce2f60eced83220aa9d9ae5a51e2c65d12c0faae8ac124fbc896163fe540dfc11febf2041b8c54cd4e1a095806eef97240daabd2c00bc38cd292b86a9f203", - sharedSecret: "08e927181387a2003ca3cee03c81e4168bf5ff4b6ead4d2e6ef15d74b3346fcf", - key: "36d29a808dd7d58e569fc16e00ac7c02d8078455b8e2a6560886bf4d23cabbac", + ephemeralPrivateKey: "b1505eab66964efb59dbc75d71b22bb0386ee09bd7a17b619d64215aad91e7e3", //nolint:lll + ephemeralPublicKey: "0462fafba814d2aa53c4821c2ae4d4b8d6e58f3b5197a40427607da916b8de0da4d3fd5868e267e4a97f7d62bff66a67ddbbcb60ccfc108fac7a7101b0e0bd3cfb", //nolint:lll + receiverPrivateKey: "b8a34a85bbbab82ea8edec50a4e199f7f4832f8eb6ae54c54e08b6fb501eb15c", //nolint:lll + receiverPublicKey: "04089ce2f60eced83220aa9d9ae5a51e2c65d12c0faae8ac124fbc896163fe540dfc11febf2041b8c54cd4e1a095806eef97240daabd2c00bc38cd292b86a9f203", //nolint:lll + sharedSecret: "08e927181387a2003ca3cee03c81e4168bf5ff4b6ead4d2e6ef15d74b3346fcf", //nolint:lll + key: "36d29a808dd7d58e569fc16e00ac7c02d8078455b8e2a6560886bf4d23cabbac", //nolint:lll baseNonce: "d586c0eff42c28e2a6d5a844", - exporterSecret: "7a20d7b2125e999233217391a5e9505bd554b90326ce8cc6a982320e8ff9f217", + exporterSecret: "7a20d7b2125e999233217391a5e9505bd554b90326ce8cc6a982320e8ff9f217", //nolint:lll additionalAuthenticatedData: "", plaintext: "1f986ab8", ciphertext: "91e65aecf8023ed72857da65a45e3dc5cde1692c", }, { info: "", - ephemeralPrivateKey: "71426fcc6129f14f13dcddb426d599aab92e187f542cace2afa4793dfb8e10c5", - ephemeralPublicKey: "040daee78e3f3f9475eca848407adf7126ff658d0e72b074f9ea4eed10e44cd96ace7e9d68419531a8c2e829355e0b3857344b014b7f1222d7f48e24278fea8c1b", - receiverPrivateKey: "ab279e1ac3ba41ce8942a5566676d7fb18f37e23bf177daa2af8754c0e6d86aa", - receiverPublicKey: "04af119cb52ddde925a1efbe34bafd93578829546ce26f5063badfc316a1b1cf896eb480b3884c5e1fc5f683d8d6a410b590879ef41bdda12223572edfdea42a95", - sharedSecret: "99767ee79703841e68234933cba2159b681530edeec18b7219ba6078d0df25d2", - key: "b600aac8d4b2ce21f92606afe5aef05f35fcb96e637fd20ebe959d30f4e24cc1", + ephemeralPrivateKey: "71426fcc6129f14f13dcddb426d599aab92e187f542cace2afa4793dfb8e10c5", //nolint:lll + ephemeralPublicKey: "040daee78e3f3f9475eca848407adf7126ff658d0e72b074f9ea4eed10e44cd96ace7e9d68419531a8c2e829355e0b3857344b014b7f1222d7f48e24278fea8c1b", //nolint:lll + receiverPrivateKey: "ab279e1ac3ba41ce8942a5566676d7fb18f37e23bf177daa2af8754c0e6d86aa", //nolint:lll + receiverPublicKey: "04af119cb52ddde925a1efbe34bafd93578829546ce26f5063badfc316a1b1cf896eb480b3884c5e1fc5f683d8d6a410b590879ef41bdda12223572edfdea42a95", //nolint:lll + sharedSecret: "99767ee79703841e68234933cba2159b681530edeec18b7219ba6078d0df25d2", //nolint:lll + key: "b600aac8d4b2ce21f92606afe5aef05f35fcb96e637fd20ebe959d30f4e24cc1", //nolint:lll baseNonce: "b6a47a057674130e415ceddc", - exporterSecret: "1f9b057db90c981bc0898017778898102e36a2574c7ab459d8ece4f8f61e0e4d", + exporterSecret: "1f9b057db90c981bc0898017778898102e36a2574c7ab459d8ece4f8f61e0e4d", //nolint:lll additionalAuthenticatedData: "7af8bee5", plaintext: "a0de5787", ciphertext: "81b2c3c99ab39e3ef97e06436a2256610a595968", }, { info: "b84fe730", - ephemeralPrivateKey: "49577f95464d2b2cd656ea772d07556707c445e5530a7665ecf6b105fd0a7c02", - ephemeralPublicKey: "04383f872c05cc826df22b8332d2d047028b9da0d9c5f6d60dd6842e42486e0fa56e7a6c52fd13d5852059d219a9c9d78747f77c4c15d0b39e044f5f85e72f00c5", - receiverPrivateKey: "4ab33a7ab4673cc887238cd741c908f38b40099f9947c4a8d85c7a3d0be57367", - receiverPublicKey: "04d4a7afac9bc25d91025704d961ec0ef662052ba79be6c6743c124969cdf55db964bc2928a740707d3f635d2fdd55569b2635f031c3ce90ec42a19291f0fab17d", - sharedSecret: "89ac497106280e57106808fbd32568764a54548beb1f52641a83651b14d72f8f", - key: "fc84ed1298f9b977d99977267c15e9630f183ba4a233e9e61d083f4957b8107c", + ephemeralPrivateKey: "49577f95464d2b2cd656ea772d07556707c445e5530a7665ecf6b105fd0a7c02", //nolint:lll + ephemeralPublicKey: "04383f872c05cc826df22b8332d2d047028b9da0d9c5f6d60dd6842e42486e0fa56e7a6c52fd13d5852059d219a9c9d78747f77c4c15d0b39e044f5f85e72f00c5", //nolint:lll + receiverPrivateKey: "4ab33a7ab4673cc887238cd741c908f38b40099f9947c4a8d85c7a3d0be57367", //nolint:lll + receiverPublicKey: "04d4a7afac9bc25d91025704d961ec0ef662052ba79be6c6743c124969cdf55db964bc2928a740707d3f635d2fdd55569b2635f031c3ce90ec42a19291f0fab17d", //nolint:lll + sharedSecret: "89ac497106280e57106808fbd32568764a54548beb1f52641a83651b14d72f8f", //nolint:lll + key: "fc84ed1298f9b977d99977267c15e9630f183ba4a233e9e61d083f4957b8107c", //nolint:lll baseNonce: "f9e87f894b5d6281b9847177", - exporterSecret: "fd86cf08e5c08da28636516ad0311f26a9d92fcc0ecc5dd4ecdc8a2a32dfd267", + exporterSecret: "fd86cf08e5c08da28636516ad0311f26a9d92fcc0ecc5dd4ecdc8a2a32dfd267", //nolint:lll additionalAuthenticatedData: "2adda296", plaintext: "", ciphertext: "8148da8f2a94d5c514f53f310607f6dd", }, { info: "c17c038a", - ephemeralPrivateKey: "017db271827fd7bf5b18cb6afe5775dab486e0f85c7e195186168044f2b2da14", - ephemeralPublicKey: "04e93862e43634d55d8be3545e042f6057883d27e767852c5db3170faac3c7ade9be43396a893f02d6d32be4991aaaeb17c658eff59efcd1b6ed735f38a315429d", - receiverPrivateKey: "797cfdce7a8d6ab93b89f572352301c4c981ce0ae8555662b8eca474948e8c40", - receiverPublicKey: "045a078bb8174f512299cb672c608724db666bd85356c379815944493774633fdb0ea3585718266a58a106fac5bbffff1b8bd00f1d0b4869fb50f9caab8647e335", - sharedSecret: "679757957437b0b8289cab3cfba401ba37c40ae90d398144c0b514b083825f64", - key: "82414b85201a27c53a1d7b33fcdc75ac13c8e0c53dd0baef8705284cd0be011e", + ephemeralPrivateKey: "017db271827fd7bf5b18cb6afe5775dab486e0f85c7e195186168044f2b2da14", //nolint:lll + ephemeralPublicKey: "04e93862e43634d55d8be3545e042f6057883d27e767852c5db3170faac3c7ade9be43396a893f02d6d32be4991aaaeb17c658eff59efcd1b6ed735f38a315429d", //nolint:lll + receiverPrivateKey: "797cfdce7a8d6ab93b89f572352301c4c981ce0ae8555662b8eca474948e8c40", //nolint:lll + receiverPublicKey: "045a078bb8174f512299cb672c608724db666bd85356c379815944493774633fdb0ea3585718266a58a106fac5bbffff1b8bd00f1d0b4869fb50f9caab8647e335", //nolint:lll + sharedSecret: "679757957437b0b8289cab3cfba401ba37c40ae90d398144c0b514b083825f64", //nolint:lll + key: "82414b85201a27c53a1d7b33fcdc75ac13c8e0c53dd0baef8705284cd0be011e", //nolint:lll baseNonce: "91c8ed9a89617672b7f77c1c", - exporterSecret: "b66b08dd2b0c672f24752ab1a37c662aaddb64c11f9fba79a7a2440b6eb5e2d5", + exporterSecret: "b66b08dd2b0c672f24752ab1a37c662aaddb64c11f9fba79a7a2440b6eb5e2d5", //nolint:lll additionalAuthenticatedData: "b3640228", plaintext: "5c2551b2", ciphertext: "0ee92c129848d052f91c43261cdb6451311817b7", }, { info: "633668e9d21a341f326ff4", - ephemeralPrivateKey: "8075c7f37a4abe3f757160b77199413839c181d455249b3d18929ea69ac57169", - ephemeralPublicKey: "0432d247646b5cbe9d179f39b0786c53dfa69135f89bedaf59e401b27c4af0ccc4f60ec288153b571e697f9468d7e34430b340ae8a1b7d757dd70265c5100f1ad8", - receiverPrivateKey: "2861accc5fbb1bf87426a20955f19c5a3d1082b3d99bbdc6372edb06a085a3cf", - receiverPublicKey: "04e403125b98557061c1f0391394da5d94d0e713ec895a33cda5283f814ff136f1b258c62c13df58b218649d448b3c6bc4737c30977bb296e8d4f7f84a431bfadc", - sharedSecret: "bf5e7436e7e494bb2b1fdf595313fe23756b9f2104e2b3f64fa749f0577e2a2c", - key: "76ef85c3e2ad000728ad76cbb38fc97ed8f44af45ceba9bef8b7068713953b5b", + ephemeralPrivateKey: "8075c7f37a4abe3f757160b77199413839c181d455249b3d18929ea69ac57169", //nolint:lll + ephemeralPublicKey: "0432d247646b5cbe9d179f39b0786c53dfa69135f89bedaf59e401b27c4af0ccc4f60ec288153b571e697f9468d7e34430b340ae8a1b7d757dd70265c5100f1ad8", //nolint:lll + receiverPrivateKey: "2861accc5fbb1bf87426a20955f19c5a3d1082b3d99bbdc6372edb06a085a3cf", //nolint:lll + receiverPublicKey: "04e403125b98557061c1f0391394da5d94d0e713ec895a33cda5283f814ff136f1b258c62c13df58b218649d448b3c6bc4737c30977bb296e8d4f7f84a431bfadc", //nolint:lll + sharedSecret: "bf5e7436e7e494bb2b1fdf595313fe23756b9f2104e2b3f64fa749f0577e2a2c", //nolint:lll + key: "76ef85c3e2ad000728ad76cbb38fc97ed8f44af45ceba9bef8b7068713953b5b", //nolint:lll baseNonce: "ffbe77f07e95485dc65b49a8", - exporterSecret: "9a5c236bc263759e0492525d9122eadb0823fa4ed094f312da7813074abbfe55", + exporterSecret: "9a5c236bc263759e0492525d9122eadb0823fa4ed094f312da7813074abbfe55", //nolint:lll additionalAuthenticatedData: "5f38e11af93746cf63eb39", plaintext: "1fb0fe1aac2233d983d870", ciphertext: "8776e37bb7e787e6036a32a37d071688a4da95166fec37ef51059b", }, { info: "", - ephemeralPrivateKey: "ba33ce246d4aa0d0c6cd379796e21d76c118e0b25e5826cdd4504df91d177148", - ephemeralPublicKey: "04f3a60cd312352d5ee1bef6032d1ebac6165c159bcfef327ba61b44e64bc8aba5323aceb5a368963529daf33ea637251ec2ce4230a6f3e34cae3deaeabe03bbea", - receiverPrivateKey: "447258b81582187d82617454bc17671349b481bab7396d96100b2e68386ac477", - receiverPublicKey: "04fa7f3ae9e5a5ccc1aa0fa0ce46fbda4ea4343da7f1432951b76c9cc9b2083d4b524cba2568838a581a7156c0009144c8ff4ff665a7e79df9de727ce293590b50", - sharedSecret: "6034a8e5525450b7f0bd4574584f46be7da245bee410902e5942ea287e5a5c7d", - key: "ef0a0b2d3e653e11826c35a4375b90dc79c2730a85add38fb9b32054f4b933d9", + ephemeralPrivateKey: "ba33ce246d4aa0d0c6cd379796e21d76c118e0b25e5826cdd4504df91d177148", //nolint:lll + ephemeralPublicKey: "04f3a60cd312352d5ee1bef6032d1ebac6165c159bcfef327ba61b44e64bc8aba5323aceb5a368963529daf33ea637251ec2ce4230a6f3e34cae3deaeabe03bbea", //nolint:lll + receiverPrivateKey: "447258b81582187d82617454bc17671349b481bab7396d96100b2e68386ac477", //nolint:lll + receiverPublicKey: "04fa7f3ae9e5a5ccc1aa0fa0ce46fbda4ea4343da7f1432951b76c9cc9b2083d4b524cba2568838a581a7156c0009144c8ff4ff665a7e79df9de727ce293590b50", //nolint:lll + sharedSecret: "6034a8e5525450b7f0bd4574584f46be7da245bee410902e5942ea287e5a5c7d", //nolint:lll + key: "ef0a0b2d3e653e11826c35a4375b90dc79c2730a85add38fb9b32054f4b933d9", //nolint:lll baseNonce: "e487074e37175d69e2a25c8c", - exporterSecret: "abc84eca4ad564d3650d8a6a4553cda521ffc7c890cb21e0960fe15d0b165b5e", + exporterSecret: "abc84eca4ad564d3650d8a6a4553cda521ffc7c890cb21e0960fe15d0b165b5e", //nolint:lll additionalAuthenticatedData: "", - plaintext: "a3a0c8c3de050dc6944b251e3e71c4096daec34399dc1faa892085fb6bf81f83", - ciphertext: "5cc7e28b62d5966752a77620abc6775f59f2679354e609f564fb1c80624cf730aa9682f31af7ac9bc18f153c86a91baf", + plaintext: "a3a0c8c3de050dc6944b251e3e71c4096daec34399dc1faa892085fb6bf81f83", //nolint:lll + ciphertext: "5cc7e28b62d5966752a77620abc6775f59f2679354e609f564fb1c80624cf730aa9682f31af7ac9bc18f153c86a91baf", //nolint:lll }, { info: "", - ephemeralPrivateKey: "c333d5be190b7e75f983f999e25e506595c9e59028dc21454fefdaa3b707a9e8", - ephemeralPublicKey: "043004e898487ea9176ac0441759a8d73f57ee461ca2322171dd2aea98c1ffc67d6a908d704135f596743f22c06c7eabbbdc3e1a28e72257815a74aaef9ed169a8", - receiverPrivateKey: "29ccb0f073d2565be10d4a3a038749c99fcae948996bb47d3b3b7841e82e7cfe", - receiverPublicKey: "040b054500f986a985ca0974250e5442f4aa1e97f4de2dab32abcad1be00a57f100e6e80627ddd4bb1fb97c5fc37a9be739260b1b39e272ee1d302d0632776849b", - sharedSecret: "59a7675b77f5da3be24c95e9a3286e71e211cdf1a48c10aeb52b96c0bf6fbde1", - key: "0e4cacfb661a2beb3f15b16d3277611072608dffd443dcb89472d757f2938db6", + ephemeralPrivateKey: "c333d5be190b7e75f983f999e25e506595c9e59028dc21454fefdaa3b707a9e8", //nolint:lll + ephemeralPublicKey: "043004e898487ea9176ac0441759a8d73f57ee461ca2322171dd2aea98c1ffc67d6a908d704135f596743f22c06c7eabbbdc3e1a28e72257815a74aaef9ed169a8", //nolint:lll + receiverPrivateKey: "29ccb0f073d2565be10d4a3a038749c99fcae948996bb47d3b3b7841e82e7cfe", //nolint:lll + receiverPublicKey: "040b054500f986a985ca0974250e5442f4aa1e97f4de2dab32abcad1be00a57f100e6e80627ddd4bb1fb97c5fc37a9be739260b1b39e272ee1d302d0632776849b", //nolint:lll + sharedSecret: "59a7675b77f5da3be24c95e9a3286e71e211cdf1a48c10aeb52b96c0bf6fbde1", //nolint:lll + key: "0e4cacfb661a2beb3f15b16d3277611072608dffd443dcb89472d757f2938db6", //nolint:lll baseNonce: "04256550a7570074ee8bda41", - exporterSecret: "ee12956018bc1c1f1f4e94efa551dda3c0b69c82979bd3a2e8ded03369090c5b", + exporterSecret: "ee12956018bc1c1f1f4e94efa551dda3c0b69c82979bd3a2e8ded03369090c5b", //nolint:lll additionalAuthenticatedData: "", - plaintext: "95b0aba9c30d24be0cec48cf8620e8855e729d270d913a61041243f91d544d7d", - ciphertext: "1452c7fabd337d1382ce5fd8bb499271b33377e4688f83ea27f9f5cdeab897d3eb64e286a70ee3d9bcee9f2f00aec1e3", + plaintext: "95b0aba9c30d24be0cec48cf8620e8855e729d270d913a61041243f91d544d7d", //nolint:lll + ciphertext: "1452c7fabd337d1382ce5fd8bb499271b33377e4688f83ea27f9f5cdeab897d3eb64e286a70ee3d9bcee9f2f00aec1e3", //nolint:lll }, { info: "", - ephemeralPrivateKey: "02974921b9931b3ebab65b0efa3e70877f32908335aab5a254f15b40af37f225", - ephemeralPublicKey: "04d36fb6064d588c1773efec818cd169c7841634e826a6a24646361c34cf428e6376cda2de8f2d2e2af6ab4719fec4613ae312f30acb589627834880156bbedfee", - receiverPrivateKey: "611e34c87150c2d528aa1931289fe52e3071e1cab067a0aee676ce77550c85bf", - receiverPublicKey: "042da0691f401035f0655d3f8e0c7720507163b1b5bcf1b04874140dbbac4c275c3f616c6161af6411bb40eebd97337026d3c30ec131b6dc38ba902a2e563a02eb", - sharedSecret: "d62a09bb6bf6de3b78d900228ca16d7c60f70dab567015f5cbe2333f2e0dba4a", - key: "0af33bd2e36a4aa22a0d30da334ffd5c416fc633be5951ade99942fff07ad204", + ephemeralPrivateKey: "02974921b9931b3ebab65b0efa3e70877f32908335aab5a254f15b40af37f225", //nolint:lll + ephemeralPublicKey: "04d36fb6064d588c1773efec818cd169c7841634e826a6a24646361c34cf428e6376cda2de8f2d2e2af6ab4719fec4613ae312f30acb589627834880156bbedfee", //nolint:lll + receiverPrivateKey: "611e34c87150c2d528aa1931289fe52e3071e1cab067a0aee676ce77550c85bf", //nolint:lll + receiverPublicKey: "042da0691f401035f0655d3f8e0c7720507163b1b5bcf1b04874140dbbac4c275c3f616c6161af6411bb40eebd97337026d3c30ec131b6dc38ba902a2e563a02eb", //nolint:lll + sharedSecret: "d62a09bb6bf6de3b78d900228ca16d7c60f70dab567015f5cbe2333f2e0dba4a", //nolint:lll + key: "0af33bd2e36a4aa22a0d30da334ffd5c416fc633be5951ade99942fff07ad204", //nolint:lll baseNonce: "4640d5917c273817d9dc77b2", - exporterSecret: "34a0cd1ea06c5770dc50cf6ce2a23e21467257dfcfecdbcf0936fc011886f0ea", + exporterSecret: "34a0cd1ea06c5770dc50cf6ce2a23e21467257dfcfecdbcf0936fc011886f0ea", //nolint:lll additionalAuthenticatedData: "", - plaintext: "a1d994360759bbb7369970729b26b5fc7d727ef194ddfac20dd27f986ddcb86f", - ciphertext: "2236d82030be176c17e60109edc91ca8ec4fefb0305646f9eba4b68a0763ac6f07a7c20ba4f275c341a0c002dbbe34fc", + plaintext: "a1d994360759bbb7369970729b26b5fc7d727ef194ddfac20dd27f986ddcb86f", //nolint:lll + ciphertext: "2236d82030be176c17e60109edc91ca8ec4fefb0305646f9eba4b68a0763ac6f07a7c20ba4f275c341a0c002dbbe34fc", //nolint:lll }, { info: "", - ephemeralPrivateKey: "f7901c42f65d8a9053d80aef20c14bf40f070c6730ec9e4d66fb1b241c1b6135", - ephemeralPublicKey: "0460a7217b29e90f2cb866aeccb41ddb71ee5f608ec0bd3ac5133e2f5870cc834cc322646b6f0f10f5b394e0e3079aa387451d7aa5f2906669482808829251b150", - receiverPrivateKey: "048fc556a900b966a303abbb6bd7a71e30b2285fad4efecb8d3a50f6fe770c41", - receiverPublicKey: "042b8fb675de2039d6ed3a9987cd5cd41dd525e3f46c35367047e22e3b262a3762af7d914cabe686d209b0b4097a631ec8109adaa3f66e5cb0056af8703c153c13", - sharedSecret: "a61026a6f767ad62c8f6ad248af00cb7b63acf8567e1ee03a7b77257cf2aba5a", - key: "4b523bd5a2407781c2a7a9ba111a268a88414be04792b2539cb5cc9121d46bb3", + ephemeralPrivateKey: "f7901c42f65d8a9053d80aef20c14bf40f070c6730ec9e4d66fb1b241c1b6135", //nolint:lll + ephemeralPublicKey: "0460a7217b29e90f2cb866aeccb41ddb71ee5f608ec0bd3ac5133e2f5870cc834cc322646b6f0f10f5b394e0e3079aa387451d7aa5f2906669482808829251b150", //nolint:lll + receiverPrivateKey: "048fc556a900b966a303abbb6bd7a71e30b2285fad4efecb8d3a50f6fe770c41", //nolint:lll + receiverPublicKey: "042b8fb675de2039d6ed3a9987cd5cd41dd525e3f46c35367047e22e3b262a3762af7d914cabe686d209b0b4097a631ec8109adaa3f66e5cb0056af8703c153c13", //nolint:lll + sharedSecret: "a61026a6f767ad62c8f6ad248af00cb7b63acf8567e1ee03a7b77257cf2aba5a", //nolint:lll + key: "4b523bd5a2407781c2a7a9ba111a268a88414be04792b2539cb5cc9121d46bb3", //nolint:lll baseNonce: "37611acc2b00bc9633043ff6", - exporterSecret: "e822c9174222978cc73d8e0de96d33931fd1050224cc13f3d527967361aa964f", + exporterSecret: "e822c9174222978cc73d8e0de96d33931fd1050224cc13f3d527967361aa964f", //nolint:lll additionalAuthenticatedData: "", - plaintext: "96f36d3092a53c322b90c010e18a3a811b938501a3e15157225906558bc7d2b6", - ciphertext: "7687f78f1a747e17a23b8416a03a8ab4fb4df75f093dbf6766d2435ec222fd3c189024141cf7b3893e28d6fc8f66beec", + plaintext: "96f36d3092a53c322b90c010e18a3a811b938501a3e15157225906558bc7d2b6", //nolint:lll + ciphertext: "7687f78f1a747e17a23b8416a03a8ab4fb4df75f093dbf6766d2435ec222fd3c189024141cf7b3893e28d6fc8f66beec", //nolint:lll }, { info: "", - ephemeralPrivateKey: "5eed25fd8234e6d1b14c903a7ee71eed9618a3b599cb73fb55ec0d5048f1230b", - ephemeralPublicKey: "04ff4e972a56f2d517a11953d77202c5f73bb500c2f973e04f6e17674606e7442723a57e4f349af933c4841df28cab37ac47239bea8221f68ff0855ca2f8c6cbd3", - receiverPrivateKey: "4e93f0c94d845ba5b84fa9c23df914ea07335e92f24136eab34689297c21bca4", - receiverPublicKey: "0493060302198b4a7f252cb04ba06d71b76529e063dcd4495a6d35f326e4b8d550555df458dd58c4f93f9a78d401f73291e1f5cdc4dae7e988ac732a120916f487", - sharedSecret: "1ae575561597f61ba34e75944d48351a57443c0613ad918084517eced2907d1d", - key: "a5d8f1576b845f7ea37e98c7d28d9c95913130f934d91ea38ab74426c76fc690", + ephemeralPrivateKey: "5eed25fd8234e6d1b14c903a7ee71eed9618a3b599cb73fb55ec0d5048f1230b", //nolint:lll + ephemeralPublicKey: "04ff4e972a56f2d517a11953d77202c5f73bb500c2f973e04f6e17674606e7442723a57e4f349af933c4841df28cab37ac47239bea8221f68ff0855ca2f8c6cbd3", //nolint:lll + receiverPrivateKey: "4e93f0c94d845ba5b84fa9c23df914ea07335e92f24136eab34689297c21bca4", //nolint:lll + receiverPublicKey: "0493060302198b4a7f252cb04ba06d71b76529e063dcd4495a6d35f326e4b8d550555df458dd58c4f93f9a78d401f73291e1f5cdc4dae7e988ac732a120916f487", //nolint:lll + sharedSecret: "1ae575561597f61ba34e75944d48351a57443c0613ad918084517eced2907d1d", //nolint:lll + key: "a5d8f1576b845f7ea37e98c7d28d9c95913130f934d91ea38ab74426c76fc690", //nolint:lll baseNonce: "b24b8eb9d05c066148e8815f", - exporterSecret: "9a10f685358c832fe571c969643b48f28f7de9e08f8f5581179e9065b7397e37", + exporterSecret: "9a10f685358c832fe571c969643b48f28f7de9e08f8f5581179e9065b7397e37", //nolint:lll additionalAuthenticatedData: "", - plaintext: "6cd207fe9898c1b10ab1c3e2a03ec454f3af15bbf8af99bec4bb04b4ee2f9a8b", - ciphertext: "dcc3f64049448df40c1ec4206c951de6461c3f2a3b3a1c3827c6ee0e27f2fc9e83d6685430b7a6d253305e253bf4f72b", + plaintext: "6cd207fe9898c1b10ab1c3e2a03ec454f3af15bbf8af99bec4bb04b4ee2f9a8b", //nolint:lll + ciphertext: "dcc3f64049448df40c1ec4206c951de6461c3f2a3b3a1c3827c6ee0e27f2fc9e83d6685430b7a6d253305e253bf4f72b", //nolint:lll }, { info: "", - ephemeralPrivateKey: "23bd6e181436cd959cc8c121dd537f547ed0e68e9291f65c0543c159c475197a", - ephemeralPublicKey: "04e6b43ba20f0948392377bc81a89f8649321d5ae2db6201466bc549a739a6ef17568674bfeaacfa131c942b119c59404c3dde03d0b40e7a77795d028bb104a1b6", - receiverPrivateKey: "5b12974c6b9d6dd6c3d795628dc2d8679eebfc885cc2cdb508b108f1f44e3898", - receiverPublicKey: "04c7a7f6184dc5afbb23a47a83d7a699ccaf13d7bead45a2e0aa01564f1c96f86ff44c5ee9b4b99e1c9e5e7eb56c01ca964d9a76de63fb9434c95c3ad3b6a9962b", - sharedSecret: "62908e076f95bbb6a7b1f115a7187de26753cb7804fcabbcf5e5828928ba79e5", - key: "c5b9391d706a2e727af6dd7c2d4f54b41a08e3d74225e05e494ca628de65cbf6", + ephemeralPrivateKey: "23bd6e181436cd959cc8c121dd537f547ed0e68e9291f65c0543c159c475197a", //nolint:lll + ephemeralPublicKey: "04e6b43ba20f0948392377bc81a89f8649321d5ae2db6201466bc549a739a6ef17568674bfeaacfa131c942b119c59404c3dde03d0b40e7a77795d028bb104a1b6", //nolint:lll + receiverPrivateKey: "5b12974c6b9d6dd6c3d795628dc2d8679eebfc885cc2cdb508b108f1f44e3898", //nolint:lll + receiverPublicKey: "04c7a7f6184dc5afbb23a47a83d7a699ccaf13d7bead45a2e0aa01564f1c96f86ff44c5ee9b4b99e1c9e5e7eb56c01ca964d9a76de63fb9434c95c3ad3b6a9962b", //nolint:lll + sharedSecret: "62908e076f95bbb6a7b1f115a7187de26753cb7804fcabbcf5e5828928ba79e5", //nolint:lll + key: "c5b9391d706a2e727af6dd7c2d4f54b41a08e3d74225e05e494ca628de65cbf6", //nolint:lll baseNonce: "8d9a3d99006b02b7907a1ceb", - exporterSecret: "b6d28df42d48e1e795ccd61eeae954df74fe6a1c245d632f532ef46966addba4", + exporterSecret: "b6d28df42d48e1e795ccd61eeae954df74fe6a1c245d632f532ef46966addba4", //nolint:lll additionalAuthenticatedData: "", - plaintext: "bc6a552a57d73951fd3fb31f3aff5e781ca31372937ef109496dddafba2bbbb5", - ciphertext: "7675b1c36414abd4f914b5c1c3dfc1e005e2e1e3a61c24ea51fc4044e364d79b661940431f8f89b160da453a2c3e4f35", + plaintext: "bc6a552a57d73951fd3fb31f3aff5e781ca31372937ef109496dddafba2bbbb5", //nolint:lll + ciphertext: "7675b1c36414abd4f914b5c1c3dfc1e005e2e1e3a61c24ea51fc4044e364d79b661940431f8f89b160da453a2c3e4f35", //nolint:lll }, { info: "", - ephemeralPrivateKey: "9c59b5de1fe667fe22c27d1ee7bcb074db4a20892c7a0e8351036aac496c09f7", - ephemeralPublicKey: "040d1b6dd05d380f28d66aef322540c309e0b9844ad0752bad683c2ec395d088cf0f4e1b8a248050384e0e6d1dc088cb50d8acd985f21e932a55d51cf09611e4a1", - receiverPrivateKey: "9e27565e428270b14b163fa004ca1b6fae96a3d0255433078d96150da0bd89df", - receiverPublicKey: "040066e5ae05ded9014cdf99a53292aa26d15853fc55de7b262da80b538511e2625fec47d8de4844a5e5ebd22d7d7f8bd1a4e718c1197a73ee48945d3602830f0a", - sharedSecret: "6783533c159e143c8dc67ccbcedb9ff059ea4c05fd294d817459797186f38c01", - key: "450e6b36ccb8592ff64ec1d9cdaf3f70c8e9116e5ee5fc6382890ad2f6729e22", + ephemeralPrivateKey: "9c59b5de1fe667fe22c27d1ee7bcb074db4a20892c7a0e8351036aac496c09f7", //nolint:lll + ephemeralPublicKey: "040d1b6dd05d380f28d66aef322540c309e0b9844ad0752bad683c2ec395d088cf0f4e1b8a248050384e0e6d1dc088cb50d8acd985f21e932a55d51cf09611e4a1", //nolint:lll + receiverPrivateKey: "9e27565e428270b14b163fa004ca1b6fae96a3d0255433078d96150da0bd89df", //nolint:lll + receiverPublicKey: "040066e5ae05ded9014cdf99a53292aa26d15853fc55de7b262da80b538511e2625fec47d8de4844a5e5ebd22d7d7f8bd1a4e718c1197a73ee48945d3602830f0a", //nolint:lll + sharedSecret: "6783533c159e143c8dc67ccbcedb9ff059ea4c05fd294d817459797186f38c01", //nolint:lll + key: "450e6b36ccb8592ff64ec1d9cdaf3f70c8e9116e5ee5fc6382890ad2f6729e22", //nolint:lll baseNonce: "d2834b3709b30a726b2fd57f", - exporterSecret: "c280ea68d5e389a269efc21c1b5990b8ab8fb4e0f6549b0887438a42b23acaa7", + exporterSecret: "c280ea68d5e389a269efc21c1b5990b8ab8fb4e0f6549b0887438a42b23acaa7", //nolint:lll additionalAuthenticatedData: "", - plaintext: "14a8d498bff4707215179032263af6ab6d06609096e73b83b65a2c4972e39899", - ciphertext: "a2e31d34c0108aba1959f7ca528c72040573a61878fa044f032e3e12f3e198e111c6a3329b58c071def1cefd6dac2325", + plaintext: "14a8d498bff4707215179032263af6ab6d06609096e73b83b65a2c4972e39899", //nolint:lll + ciphertext: "a2e31d34c0108aba1959f7ca528c72040573a61878fa044f032e3e12f3e198e111c6a3329b58c071def1cefd6dac2325", //nolint:lll }, { info: "", - ephemeralPrivateKey: "a90f4e8c75ab1051ff3ccc7a30296b761d2556d5d63646f55dc6c1770e1005bf", - ephemeralPublicKey: "0470efeaa07ed4fbc417b061577246baf4e996c47d9de71aa854c548a03dbc0f47aa4460151545016209ea9864f88bb2bc367da5ce3801fcb06fcc364bef0bc412", - receiverPrivateKey: "2a2938d163d40fc4352d19d95a84652be47606671e9afc22d0e879aff1a593ec", - receiverPublicKey: "04aa9812225fef2b1be3a19e1bae6fcc77678ea7f6ae8492a80e626aa2409918f860aab4af2711afbab0ee8006867c7b99899d2136d43815da452514c383ff7b57", - sharedSecret: "d8dffa3f8d08fbc3a687ae297d0a9eb530906fa5765517eb5b443a653a1e1c21", - key: "37566adca5e7e6e0eaafe75e5125abee00682ae41e2a6aec42821367f743d9be", + ephemeralPrivateKey: "a90f4e8c75ab1051ff3ccc7a30296b761d2556d5d63646f55dc6c1770e1005bf", //nolint:lll + ephemeralPublicKey: "0470efeaa07ed4fbc417b061577246baf4e996c47d9de71aa854c548a03dbc0f47aa4460151545016209ea9864f88bb2bc367da5ce3801fcb06fcc364bef0bc412", //nolint:lll + receiverPrivateKey: "2a2938d163d40fc4352d19d95a84652be47606671e9afc22d0e879aff1a593ec", //nolint:lll + receiverPublicKey: "04aa9812225fef2b1be3a19e1bae6fcc77678ea7f6ae8492a80e626aa2409918f860aab4af2711afbab0ee8006867c7b99899d2136d43815da452514c383ff7b57", //nolint:lll + sharedSecret: "d8dffa3f8d08fbc3a687ae297d0a9eb530906fa5765517eb5b443a653a1e1c21", //nolint:lll + key: "37566adca5e7e6e0eaafe75e5125abee00682ae41e2a6aec42821367f743d9be", //nolint:lll baseNonce: "6b64aaedc61523af88ae106c", - exporterSecret: "544cd9a2ffacb006684dddee294fbfa0bde06ca9856c76e211d045fbd53799d6", + exporterSecret: "544cd9a2ffacb006684dddee294fbfa0bde06ca9856c76e211d045fbd53799d6", //nolint:lll additionalAuthenticatedData: "", - plaintext: "126ce1ed2f6b3be2f89833cfe539732b8a693fa0a5c247b4f31535c8d9ecb0a2", - ciphertext: "cf79ea627d1c4e2384da96eb9171d2524d18cc37918d0a562b81224f42691b2e5054b4a75763393ccacb419c26971105", + plaintext: "126ce1ed2f6b3be2f89833cfe539732b8a693fa0a5c247b4f31535c8d9ecb0a2", //nolint:lll + ciphertext: "cf79ea627d1c4e2384da96eb9171d2524d18cc37918d0a562b81224f42691b2e5054b4a75763393ccacb419c26971105", //nolint:lll }, { info: "", - ephemeralPrivateKey: "eb362422b494ad66cd61794cb030247bb4d54ef37af6d0acf404d6c4f32c5c6c", - ephemeralPublicKey: "04d218e9b6d719763f331114b85d09ab359b23467a9d3c9fb0ff7e37b4b1076ddcb7ab04742b12713790b8bd5c409b31df6e0b0419ae6228551be58e0f906d7788", - receiverPrivateKey: "f9e68368fe8502baefb9a5f61c3ebbb0741ee2977dd3271f548eb8010ba248fd", - receiverPublicKey: "04fd5035cf394b67ad1f8fdde3e55e01315f280e5ba7a0c215a128ef07a762dea2c27df20f5cd648eb4d1b65638967a37a6712cff6b7e21108f3a337589d2bbbac", - sharedSecret: "65da2004e05fe604c1d628b34059d3bda3b068206cafb9271d77b6b2f4663f43", - key: "556c1f582704927141cb326c8247f571db3c5cc27d08cc737068986a7ab36a02", + ephemeralPrivateKey: "eb362422b494ad66cd61794cb030247bb4d54ef37af6d0acf404d6c4f32c5c6c", //nolint:lll + ephemeralPublicKey: "04d218e9b6d719763f331114b85d09ab359b23467a9d3c9fb0ff7e37b4b1076ddcb7ab04742b12713790b8bd5c409b31df6e0b0419ae6228551be58e0f906d7788", //nolint:lll + receiverPrivateKey: "f9e68368fe8502baefb9a5f61c3ebbb0741ee2977dd3271f548eb8010ba248fd", //nolint:lll + receiverPublicKey: "04fd5035cf394b67ad1f8fdde3e55e01315f280e5ba7a0c215a128ef07a762dea2c27df20f5cd648eb4d1b65638967a37a6712cff6b7e21108f3a337589d2bbbac", //nolint:lll + sharedSecret: "65da2004e05fe604c1d628b34059d3bda3b068206cafb9271d77b6b2f4663f43", //nolint:lll + key: "556c1f582704927141cb326c8247f571db3c5cc27d08cc737068986a7ab36a02", //nolint:lll baseNonce: "5314d49399dade867ac4e740", - exporterSecret: "aba0f243c8421941f068aa776c481a4eebbe7dafd4a4d248942ad9fb2708f620", + exporterSecret: "aba0f243c8421941f068aa776c481a4eebbe7dafd4a4d248942ad9fb2708f620", //nolint:lll additionalAuthenticatedData: "", - plaintext: "2a17b1a60aaa323d093ba80a1c25a96dbaf59816acf6e40018b822b773c35ae85522c0aeca81ce74540058e1823ea24488446928ae90ae2e47cdc60baa1ee7f7", - ciphertext: "9ae72630ecf483fddc287d5b12edf404c615998f94e6c734f762afcd9a906bfc4f212eb62aef84ef34ecb315f9d62417228557fc55c4cbfdfbcb42f280bc6b7681fcd111abf6aa6219c6118568032fb6", + plaintext: "2a17b1a60aaa323d093ba80a1c25a96dbaf59816acf6e40018b822b773c35ae85522c0aeca81ce74540058e1823ea24488446928ae90ae2e47cdc60baa1ee7f7", //nolint:lll + ciphertext: "9ae72630ecf483fddc287d5b12edf404c615998f94e6c734f762afcd9a906bfc4f212eb62aef84ef34ecb315f9d62417228557fc55c4cbfdfbcb42f280bc6b7681fcd111abf6aa6219c6118568032fb6", //nolint:lll }, { info: "", - ephemeralPrivateKey: "b7bd2a9de3b6f4186e28628d6e54922a5f15ab8d741756ec4bdfb38686ad061b", - ephemeralPublicKey: "042c12dbfad88c65eb434799217fe4c52296a2c1f602bbbd73e498a9c8333b52c39c8522817727c3c38f08a1708b233ba9be8b1978bb488d13b72e8c0a732b918a", - receiverPrivateKey: "1b563a60e2e3d8f8719a857e2f4cc4f001c080dfce92c340094ad82816f77d8d", - receiverPublicKey: "04e069ab19a115b01c495d0225a02b9329f666caa2b6314c9e84409ae2c4649c5f093c3ea47c069f57eca8f4a83bdaed405bd5e2ce0336893974c9f5827af4b4e7", - sharedSecret: "af7237ab04ed59f989283943c99012d58ad2230fbc25ef5be6ba70d419969643", - key: "d848bfc19d7ec25e8d1132f3342d44c93217926976198242c372560a6a5b6760", + ephemeralPrivateKey: "b7bd2a9de3b6f4186e28628d6e54922a5f15ab8d741756ec4bdfb38686ad061b", //nolint:lll + ephemeralPublicKey: "042c12dbfad88c65eb434799217fe4c52296a2c1f602bbbd73e498a9c8333b52c39c8522817727c3c38f08a1708b233ba9be8b1978bb488d13b72e8c0a732b918a", //nolint:lll + receiverPrivateKey: "1b563a60e2e3d8f8719a857e2f4cc4f001c080dfce92c340094ad82816f77d8d", //nolint:lll + receiverPublicKey: "04e069ab19a115b01c495d0225a02b9329f666caa2b6314c9e84409ae2c4649c5f093c3ea47c069f57eca8f4a83bdaed405bd5e2ce0336893974c9f5827af4b4e7", //nolint:lll + sharedSecret: "af7237ab04ed59f989283943c99012d58ad2230fbc25ef5be6ba70d419969643", //nolint:lll + key: "d848bfc19d7ec25e8d1132f3342d44c93217926976198242c372560a6a5b6760", //nolint:lll baseNonce: "51c4f15c5709ac0ec709e73a", - exporterSecret: "0cb82c797b68a6986e6d925c91196106f684c73ff2809d3b7a5548604001e6be", + exporterSecret: "0cb82c797b68a6986e6d925c91196106f684c73ff2809d3b7a5548604001e6be", //nolint:lll additionalAuthenticatedData: "", - plaintext: "df34359c09748f726acb68cc42d70a0714451b837527afbaaf080c072e23f649cbe1e07a1bb06c4deeeba49cbcf2972d9be027aafb84f1fb821127aa246fad2a", - ciphertext: "284fa6a3281f4e4123652166f329b484efd5a76942ad572612ff000e343cd0e16efdd25bfb415591c79266613ac4d6c1be68d5145edc96719152911b42b59bd290a89fc888d66a62fd70dd6a1fddc33d", + plaintext: "df34359c09748f726acb68cc42d70a0714451b837527afbaaf080c072e23f649cbe1e07a1bb06c4deeeba49cbcf2972d9be027aafb84f1fb821127aa246fad2a", //nolint:lll + ciphertext: "284fa6a3281f4e4123652166f329b484efd5a76942ad572612ff000e343cd0e16efdd25bfb415591c79266613ac4d6c1be68d5145edc96719152911b42b59bd290a89fc888d66a62fd70dd6a1fddc33d", //nolint:lll }, { info: "", - ephemeralPrivateKey: "14963adb83fe678ce0d2ce460d251ec9c040cec8179c6cf9533028beb96e0015", - ephemeralPublicKey: "049a1d756aaef5f307e71d6f43744c65dbd7652202de9c739156aa5a9143e53e5987c75257616f5798fa0264f823b8ac288eddfbeb921949ea10fb069f8debe2d1", - receiverPrivateKey: "d2f80a353ac4fe24743a50ab3318b10b46086be337dc15b7c177714b81dd64f6", - receiverPublicKey: "046e8be355741816790a67121c4eff73d01f88674632a16869ac19663d7748c2194e091e3d0b01e461b0157f8808bfd5183caf6f4802eb78eaa0038c8debc3a32a", - sharedSecret: "127b869bc4aa0a3fe23e7a9c7834092bf82719219128ecd468154df54f45a5cb", - key: "d4d29d20e0dc587d105b648813a3982ed05b879e7cee74ff6706f99327ddee9b", + ephemeralPrivateKey: "14963adb83fe678ce0d2ce460d251ec9c040cec8179c6cf9533028beb96e0015", //nolint:lll + ephemeralPublicKey: "049a1d756aaef5f307e71d6f43744c65dbd7652202de9c739156aa5a9143e53e5987c75257616f5798fa0264f823b8ac288eddfbeb921949ea10fb069f8debe2d1", //nolint:lll + receiverPrivateKey: "d2f80a353ac4fe24743a50ab3318b10b46086be337dc15b7c177714b81dd64f6", //nolint:lll + receiverPublicKey: "046e8be355741816790a67121c4eff73d01f88674632a16869ac19663d7748c2194e091e3d0b01e461b0157f8808bfd5183caf6f4802eb78eaa0038c8debc3a32a", //nolint:lll + sharedSecret: "127b869bc4aa0a3fe23e7a9c7834092bf82719219128ecd468154df54f45a5cb", //nolint:lll + key: "d4d29d20e0dc587d105b648813a3982ed05b879e7cee74ff6706f99327ddee9b", //nolint:lll baseNonce: "9156ed532ec43d119b37f73f", - exporterSecret: "c42c88b0a7306d4b0d4794f20e728945027021903b79bec5525a05fb1d040893", + exporterSecret: "c42c88b0a7306d4b0d4794f20e728945027021903b79bec5525a05fb1d040893", //nolint:lll additionalAuthenticatedData: "", - plaintext: "df2f8ff2d02b5f183d0cb2a4eb9dc065cffb8f98eefe3352efb85b771aefd3b7f0039c171faf8182f05d8249c4ac7655a19df746f17fc4ab8b07ec4a8815e296", - ciphertext: "1083dc277eedf365f6d04b39534b7492b10b66815b9b3f1f21d60281f66788020e250a05f785ce24072fa9562ccdbda7091998c550c9ec130161a961e4ef43b0b601771f27069b3f69a10fac4de00769", + plaintext: "df2f8ff2d02b5f183d0cb2a4eb9dc065cffb8f98eefe3352efb85b771aefd3b7f0039c171faf8182f05d8249c4ac7655a19df746f17fc4ab8b07ec4a8815e296", //nolint:lll + ciphertext: "1083dc277eedf365f6d04b39534b7492b10b66815b9b3f1f21d60281f66788020e250a05f785ce24072fa9562ccdbda7091998c550c9ec130161a961e4ef43b0b601771f27069b3f69a10fac4de00769", //nolint:lll }, { - info: "f2ada89c05f084d6ff6d0bb49d891bb61cafaeac557f119485308942893ef6ee3248d432b8c88b830d3188b0fbe5dccdee644c041660bd206bd8bff6536df5f9", - ephemeralPrivateKey: "1360ae696b49bfaaf8f1c9428e922fb5e83bc936087f83bac104ef95a63de3d7", - ephemeralPublicKey: "0419a3469bedcc045860c05ded5f2d51996c5604e9d08d7b7cd193b6150dc3bc11d1b53ac6626f45ce4270a561cb192b1b5111fe2a084dfd664a5fc895ca2931e5", - receiverPrivateKey: "097c03cb98c996c059c58e1da8ff685a517dc0f5b2cdf7a3e01a7aff4e05def5", - receiverPublicKey: "04e47d918b7ee6a4dca57a6589d8774be8eb1ae1c66fe3ab66a9e9de651f39cd0506d83f7bc60fd27ca51fc08b15be9bbe574d4e34d6efbadb259a622d1b470c5d", - sharedSecret: "90889a18da556475f23c91edddfa46a6e22e3ac1a7bf534943ff232db8575544", - key: "16ff002d4fe08290e5cd5c493e1a04baed40f56cc6029c6c05341891bf9e8ae8", + info: "f2ada89c05f084d6ff6d0bb49d891bb61cafaeac557f119485308942893ef6ee3248d432b8c88b830d3188b0fbe5dccdee644c041660bd206bd8bff6536df5f9", //nolint:lll + ephemeralPrivateKey: "1360ae696b49bfaaf8f1c9428e922fb5e83bc936087f83bac104ef95a63de3d7", //nolint:lll + ephemeralPublicKey: "0419a3469bedcc045860c05ded5f2d51996c5604e9d08d7b7cd193b6150dc3bc11d1b53ac6626f45ce4270a561cb192b1b5111fe2a084dfd664a5fc895ca2931e5", //nolint:lll + receiverPrivateKey: "097c03cb98c996c059c58e1da8ff685a517dc0f5b2cdf7a3e01a7aff4e05def5", //nolint:lll + receiverPublicKey: "04e47d918b7ee6a4dca57a6589d8774be8eb1ae1c66fe3ab66a9e9de651f39cd0506d83f7bc60fd27ca51fc08b15be9bbe574d4e34d6efbadb259a622d1b470c5d", //nolint:lll + sharedSecret: "90889a18da556475f23c91edddfa46a6e22e3ac1a7bf534943ff232db8575544", //nolint:lll + key: "16ff002d4fe08290e5cd5c493e1a04baed40f56cc6029c6c05341891bf9e8ae8", //nolint:lll baseNonce: "226b3b76d9939f54f4b99b9d", - exporterSecret: "4e78eb7649303f24b7a1a86f0172d40c9744dd1d972c8b1f814896c71c177c25", + exporterSecret: "4e78eb7649303f24b7a1a86f0172d40c9744dd1d972c8b1f814896c71c177c25", //nolint:lll additionalAuthenticatedData: "", - plaintext: "c9e6bc16cd4d373d9960e5a846ac7f986dc46efe59650a94cdd625a6c1a46eee", - ciphertext: "8401e7ec8f9990284aebcb2ab3d3f7e2de9731cf93682c6fc09e5164c3d9056c5aa811b427cc562c15c19fe8fa584cdd", + plaintext: "c9e6bc16cd4d373d9960e5a846ac7f986dc46efe59650a94cdd625a6c1a46eee", //nolint:lll + ciphertext: "8401e7ec8f9990284aebcb2ab3d3f7e2de9731cf93682c6fc09e5164c3d9056c5aa811b427cc562c15c19fe8fa584cdd", //nolint:lll }, { - info: "2b8a49b8e7b669541d9bd450377bc7e0e6ce78eeb4d5378d3a13bc757aa308e6dca9cddce2c814fd2f2c669998d5700485d1674138922605ae48a8c74a3ac307", - ephemeralPrivateKey: "b6b197b3819dbcb9eb2aaa8093ba23ef6930807cc31c36dc85e200bc86358405", - ephemeralPublicKey: "040128089f040e93ab26b5e53cb2c7f49d5092e35cc42e9b115fbbdfffc5cf3dbc106799c8f9a9c41b398e70fd29cc8f109fe6cf2591316bfafbc5f1e19fac63b4", - receiverPrivateKey: "f93b5bb456f0491ba13a20e1b051354922d33b301423a2c770df45a500ab3e74", - receiverPublicKey: "04ffe14faacfae346d56a9ef2a5e323878eaba13184bdc3b597856febab885f1f509d009890474ad5c188d1f88ca01ad5d6cfde055b87b120d4a5d262d2fa339ba", - sharedSecret: "0f719b11855c598a91b44a7abfbd891125dd60169b8ebc8fa84652529940b57d", - key: "00d6bb1ffba6f13a5db8710f538a7141a104fceb028f0f27a4f1be916b003800", + info: "2b8a49b8e7b669541d9bd450377bc7e0e6ce78eeb4d5378d3a13bc757aa308e6dca9cddce2c814fd2f2c669998d5700485d1674138922605ae48a8c74a3ac307", //nolint:lll + ephemeralPrivateKey: "b6b197b3819dbcb9eb2aaa8093ba23ef6930807cc31c36dc85e200bc86358405", //nolint:lll + ephemeralPublicKey: "040128089f040e93ab26b5e53cb2c7f49d5092e35cc42e9b115fbbdfffc5cf3dbc106799c8f9a9c41b398e70fd29cc8f109fe6cf2591316bfafbc5f1e19fac63b4", //nolint:lll + receiverPrivateKey: "f93b5bb456f0491ba13a20e1b051354922d33b301423a2c770df45a500ab3e74", //nolint:lll + receiverPublicKey: "04ffe14faacfae346d56a9ef2a5e323878eaba13184bdc3b597856febab885f1f509d009890474ad5c188d1f88ca01ad5d6cfde055b87b120d4a5d262d2fa339ba", //nolint:lll + sharedSecret: "0f719b11855c598a91b44a7abfbd891125dd60169b8ebc8fa84652529940b57d", //nolint:lll + key: "00d6bb1ffba6f13a5db8710f538a7141a104fceb028f0f27a4f1be916b003800", //nolint:lll baseNonce: "0b8a40e93374752d640444ed", - exporterSecret: "49a1202ad2fcb94d7e76118559012aece1af1a60c26a4af754baa30d8c307a7e", + exporterSecret: "49a1202ad2fcb94d7e76118559012aece1af1a60c26a4af754baa30d8c307a7e", //nolint:lll additionalAuthenticatedData: "", - plaintext: "8eb938552b5bdf217b6543b1a0954422dca7820cd5f2acc18a63495da3e1d4725456784a08ee6944e5b19d659d6b18f3deec04f76e6093fab32b851489c40efd", - ciphertext: "c31cd35942824f5f4efdf58c5d43ce0e69ebf233cce36829c85b1310fbc35b7af29020b778b384cc241f246471e22d943f4fc909fe2071321bc7677613f5caa6a684c4cfb400314f907868df0c455d5d", + plaintext: "8eb938552b5bdf217b6543b1a0954422dca7820cd5f2acc18a63495da3e1d4725456784a08ee6944e5b19d659d6b18f3deec04f76e6093fab32b851489c40efd", //nolint:lll + ciphertext: "c31cd35942824f5f4efdf58c5d43ce0e69ebf233cce36829c85b1310fbc35b7af29020b778b384cc241f246471e22d943f4fc909fe2071321bc7677613f5caa6a684c4cfb400314f907868df0c455d5d", //nolint:lll }, { - info: "e5c5502209a924fcdefa267c0d63a7452fd31433474de6afa24da7be1bee88bb", - ephemeralPrivateKey: "aa4701a7a6f5baf4878e66db922dab054e8448d158418c561d80e47991c7300b", - ephemeralPublicKey: "041ddad48f3e7946ecb0ebe1947a78dfda63a2a9aed7905458cec4099be111220061994d201d3c57782c270c8b991b8f59a5e55813aad3d3c94fd10ef4d7270b68", - receiverPrivateKey: "d46f48d94b91f9ec552d5c7611a9b72e0f24e82bf43f56d1b58654dfa858dc50", - receiverPublicKey: "045916081dd1bfdfc261d5dec008746e9c21cb8e873b09d28522b0a2ac414b678ad0d3914eb12891b600a08fad6fea23b8e20584a44bb1f8627c5bcb6e0aa68f52", - sharedSecret: "4b9ab4ed38a04602d6175d12ed1c804dc2e34433a1c2c250303df87afb95024a", - key: "079737d36f56ab9f005257d8e75f8a43e196ce4912dbd2720b6010ed3c31fe49", + info: "e5c5502209a924fcdefa267c0d63a7452fd31433474de6afa24da7be1bee88bb", //nolint:lll + ephemeralPrivateKey: "aa4701a7a6f5baf4878e66db922dab054e8448d158418c561d80e47991c7300b", //nolint:lll + ephemeralPublicKey: "041ddad48f3e7946ecb0ebe1947a78dfda63a2a9aed7905458cec4099be111220061994d201d3c57782c270c8b991b8f59a5e55813aad3d3c94fd10ef4d7270b68", //nolint:lll + receiverPrivateKey: "d46f48d94b91f9ec552d5c7611a9b72e0f24e82bf43f56d1b58654dfa858dc50", //nolint:lll + receiverPublicKey: "045916081dd1bfdfc261d5dec008746e9c21cb8e873b09d28522b0a2ac414b678ad0d3914eb12891b600a08fad6fea23b8e20584a44bb1f8627c5bcb6e0aa68f52", //nolint:lll + sharedSecret: "4b9ab4ed38a04602d6175d12ed1c804dc2e34433a1c2c250303df87afb95024a", //nolint:lll + key: "079737d36f56ab9f005257d8e75f8a43e196ce4912dbd2720b6010ed3c31fe49", //nolint:lll baseNonce: "5b45cf9a6fef1f0013936369", - exporterSecret: "d0a92e997d2be5bc6ccc9a68b54e185910220ec8499e410db178d6fd255f20c8", - additionalAuthenticatedData: "182235678cc891d0f0a0e0277dee0f9e5fa4b8db19af1bf303f9feed27ac68e4", - plaintext: "eafdffbd7c18f60e405c48c937d6d5cba8d2209a9901f0d3ae8b8a322299b188", - ciphertext: "de0b206205d45eafcde353e6d368bdd0b0647aa408ee85f338957e1faf283f48a17ee60793f6722d44757ef29f019e68", + exporterSecret: "d0a92e997d2be5bc6ccc9a68b54e185910220ec8499e410db178d6fd255f20c8", //nolint:lll + additionalAuthenticatedData: "182235678cc891d0f0a0e0277dee0f9e5fa4b8db19af1bf303f9feed27ac68e4", //nolint:lll + plaintext: "eafdffbd7c18f60e405c48c937d6d5cba8d2209a9901f0d3ae8b8a322299b188", //nolint:lll + ciphertext: "de0b206205d45eafcde353e6d368bdd0b0647aa408ee85f338957e1faf283f48a17ee60793f6722d44757ef29f019e68", //nolint:lll }, { - info: "4836f0950c75024165019d39cae557b58c5d61894e1d73b09e9ef4ce503b087b", - ephemeralPrivateKey: "5a93f2e625762a02863c77f1fa59acfd24bf66b29bea6ea7d88fe9ce052f4f5d", - ephemeralPublicKey: "04d6c53ee257aab8f662b478fed9c265d698d4495d93b7f322f16d95e37cbd73ad0683c4107f2c820715d2a974b9463f400fe6d05ff613367c6454a6b24c5faae2", - receiverPrivateKey: "885b6f624fa46e37fe83b64a665256ba5c5b82926eccbeb6c62fd3d9f0439a2d", - receiverPublicKey: "0446b160eb4488cfe2c855fc428ad195d629dcc7d5d4c7f302986b95aa6681df13269af8f3771c2293a249143ea08c2a6bc233fa3147e01c79fe1eca6a68cff61d", - sharedSecret: "51702c398c0a01b10a8286037e241eece16ac592cd1035d4c667c3a67bc33fae", - key: "01f5e8129a926917c640e5164d024edde32b21d531cf76b65caa513d1fa46f97", + info: "4836f0950c75024165019d39cae557b58c5d61894e1d73b09e9ef4ce503b087b", //nolint:lll + ephemeralPrivateKey: "5a93f2e625762a02863c77f1fa59acfd24bf66b29bea6ea7d88fe9ce052f4f5d", //nolint:lll + ephemeralPublicKey: "04d6c53ee257aab8f662b478fed9c265d698d4495d93b7f322f16d95e37cbd73ad0683c4107f2c820715d2a974b9463f400fe6d05ff613367c6454a6b24c5faae2", //nolint:lll + receiverPrivateKey: "885b6f624fa46e37fe83b64a665256ba5c5b82926eccbeb6c62fd3d9f0439a2d", //nolint:lll + receiverPublicKey: "0446b160eb4488cfe2c855fc428ad195d629dcc7d5d4c7f302986b95aa6681df13269af8f3771c2293a249143ea08c2a6bc233fa3147e01c79fe1eca6a68cff61d", //nolint:lll + sharedSecret: "51702c398c0a01b10a8286037e241eece16ac592cd1035d4c667c3a67bc33fae", //nolint:lll + key: "01f5e8129a926917c640e5164d024edde32b21d531cf76b65caa513d1fa46f97", //nolint:lll baseNonce: "e1a40e461e91911350ad6c96", - exporterSecret: "37eba781daca896042b78fdfc71663462f7b28eb0a4cb8923e14648369a06944", - additionalAuthenticatedData: "9cda53314ecab5654ff3e79ef5511151632e5e4ca95b41d42700385b63eccb89", - plaintext: "290826fbdb4b730cac36619764c62a223eac47f8f650f0926a4919de95854ed0", - ciphertext: "6555587a2e679108e78587f6181eb050530be6485fb7afa6afba17607e551e5b9509036129cc280c7ed42a88527555f5", + exporterSecret: "37eba781daca896042b78fdfc71663462f7b28eb0a4cb8923e14648369a06944", //nolint:lll + additionalAuthenticatedData: "9cda53314ecab5654ff3e79ef5511151632e5e4ca95b41d42700385b63eccb89", //nolint:lll + plaintext: "290826fbdb4b730cac36619764c62a223eac47f8f650f0926a4919de95854ed0", //nolint:lll + ciphertext: "6555587a2e679108e78587f6181eb050530be6485fb7afa6afba17607e551e5b9509036129cc280c7ed42a88527555f5", //nolint:lll }, { - info: "966b002be4100e097f0177266b6fc88d8b6a4648620bfa32a17c327345271f5b", - ephemeralPrivateKey: "cb9fbcf8b3bf1c3e65a7c80f403ea0d0f7842864658b6c68397d920143e6ea25", - ephemeralPublicKey: "049bfd488cf1f24c778317420bd2dc3c3531c4b77bc43e83ecf173532b2fabfc63faccce79c5786f45c7268ec3a806f84afb0d0fb2ea000c15c8a0e98b76bb283e", - receiverPrivateKey: "7288f560a74c5e78e041d671ce2a311c26d9e666351d9d8d0f6b9dc373e45c84", - receiverPublicKey: "048ca7e2da0d30a09f6e6407aba360742e9156aa41016e419f4ba50f8d8af82b925fb6b87a8836a63b022beca6fa9b6b85add7bc97c1e885eff8b6f5432df0e327", - sharedSecret: "178980764bc30f339cc5f5c068b1ce0c32f5ce4c548a5eb8a6c1ce1c597687da", - key: "84f2677122fa83684b47bf395ce4ff9478d5c92b28625d9c794f0f0834b60c60", + info: "966b002be4100e097f0177266b6fc88d8b6a4648620bfa32a17c327345271f5b", //nolint:lll + ephemeralPrivateKey: "cb9fbcf8b3bf1c3e65a7c80f403ea0d0f7842864658b6c68397d920143e6ea25", //nolint:lll + ephemeralPublicKey: "049bfd488cf1f24c778317420bd2dc3c3531c4b77bc43e83ecf173532b2fabfc63faccce79c5786f45c7268ec3a806f84afb0d0fb2ea000c15c8a0e98b76bb283e", //nolint:lll + receiverPrivateKey: "7288f560a74c5e78e041d671ce2a311c26d9e666351d9d8d0f6b9dc373e45c84", //nolint:lll + receiverPublicKey: "048ca7e2da0d30a09f6e6407aba360742e9156aa41016e419f4ba50f8d8af82b925fb6b87a8836a63b022beca6fa9b6b85add7bc97c1e885eff8b6f5432df0e327", //nolint:lll + sharedSecret: "178980764bc30f339cc5f5c068b1ce0c32f5ce4c548a5eb8a6c1ce1c597687da", //nolint:lll + key: "84f2677122fa83684b47bf395ce4ff9478d5c92b28625d9c794f0f0834b60c60", //nolint:lll baseNonce: "4c919b87b1e7c90bf0d4dd34", - exporterSecret: "6218cf9f6d79fd3c6da23e1e17149766c4735abcdf0a9a1a34475c66c776c4cc", - additionalAuthenticatedData: "e4fa78001c18cfd50d43a37961a7f64df7d7f5c705a99e05f418ce4a9ae604b9", - plaintext: "c6fb1627c6ce05331ca211d6f8c0b4cbe622fef966c31fe9f9336fad950ba206", - ciphertext: "9f5ccccb6d11284356b6baed82e5f7bda6b38e990228f010c0120998f3ee784ea145f5c89f8fc733df69a51ef760d08d", + exporterSecret: "6218cf9f6d79fd3c6da23e1e17149766c4735abcdf0a9a1a34475c66c776c4cc", //nolint:lll + additionalAuthenticatedData: "e4fa78001c18cfd50d43a37961a7f64df7d7f5c705a99e05f418ce4a9ae604b9", //nolint:lll + plaintext: "c6fb1627c6ce05331ca211d6f8c0b4cbe622fef966c31fe9f9336fad950ba206", //nolint:lll + ciphertext: "9f5ccccb6d11284356b6baed82e5f7bda6b38e990228f010c0120998f3ee784ea145f5c89f8fc733df69a51ef760d08d", //nolint:lll }, { - info: "506560ac823229c047dd28c89f986fdd056f1d62d5075972d307ed04d5e64f0c", - ephemeralPrivateKey: "bc349ab3645a843f9bd9f1ac1041b1e35afd1555b43140c4fcf4638779a548c1", - ephemeralPublicKey: "044344711d86d9054ef30ec388fce8fc0a8dfa1de5b412aef565527bb491f894a6d2203bc30d65e67a2380219897c093a4d130a5e46eb1f01993de3ab2c058735f", - receiverPrivateKey: "4f7aaac55f1bc139471be00076a6c0b2f2fd4dbea234500e705760c429d56407", - receiverPublicKey: "040a914d0592f8c38bc543343c0dafabb8ea5ab0c28bcbe3b8b1d053e0edab8c86bf9179b5579be42ce85ccfcbf8b017e17ef558bc658e1e5dd540ae2fc29bfdd9", - sharedSecret: "e9f73f9fd9cf6fdabee76a4a31354d81ed543a92a1e30c84a34b135eef085f5e", - key: "a48ea99eac818b950c2c3aa3b3c74a18b3c68ce929c8be4f8f18b72c6e405ce4", + info: "506560ac823229c047dd28c89f986fdd056f1d62d5075972d307ed04d5e64f0c", //nolint:lll + ephemeralPrivateKey: "bc349ab3645a843f9bd9f1ac1041b1e35afd1555b43140c4fcf4638779a548c1", //nolint:lll + ephemeralPublicKey: "044344711d86d9054ef30ec388fce8fc0a8dfa1de5b412aef565527bb491f894a6d2203bc30d65e67a2380219897c093a4d130a5e46eb1f01993de3ab2c058735f", //nolint:lll + receiverPrivateKey: "4f7aaac55f1bc139471be00076a6c0b2f2fd4dbea234500e705760c429d56407", //nolint:lll + receiverPublicKey: "040a914d0592f8c38bc543343c0dafabb8ea5ab0c28bcbe3b8b1d053e0edab8c86bf9179b5579be42ce85ccfcbf8b017e17ef558bc658e1e5dd540ae2fc29bfdd9", //nolint:lll + sharedSecret: "e9f73f9fd9cf6fdabee76a4a31354d81ed543a92a1e30c84a34b135eef085f5e", //nolint:lll + key: "a48ea99eac818b950c2c3aa3b3c74a18b3c68ce929c8be4f8f18b72c6e405ce4", //nolint:lll baseNonce: "1179cfb70ff9bfa396e04d9d", - exporterSecret: "b112064f612d79696b317b72996ba4e052c694e1894841a8edb01372c0b3c7bd", - additionalAuthenticatedData: "ab88c16174eaabd1c42a193931943516b3c56778bb641bedff42fbbaf151ba26", - plaintext: "ec5da2f66b9eda6d138a3a92795f12217a0c7c1cf0b349550da78b90ef53d507", - ciphertext: "f938ca03d8c1e78583481733074210aec1fa9e4da2909da2246cabc24ec23d57ed9a9ce53e1822db03339a0945f120bd", + exporterSecret: "b112064f612d79696b317b72996ba4e052c694e1894841a8edb01372c0b3c7bd", //nolint:lll + additionalAuthenticatedData: "ab88c16174eaabd1c42a193931943516b3c56778bb641bedff42fbbaf151ba26", //nolint:lll + plaintext: "ec5da2f66b9eda6d138a3a92795f12217a0c7c1cf0b349550da78b90ef53d507", //nolint:lll + ciphertext: "f938ca03d8c1e78583481733074210aec1fa9e4da2909da2246cabc24ec23d57ed9a9ce53e1822db03339a0945f120bd", //nolint:lll }, } diff --git a/libwallet/cryptography/bitcoin_hpke/encrypted_message.go b/libwallet/cryptography/bitcoin_hpke/encrypted_message.go index e2ed8397..afd71b13 100644 --- a/libwallet/cryptography/bitcoin_hpke/encrypted_message.go +++ b/libwallet/cryptography/bitcoin_hpke/encrypted_message.go @@ -1,10 +1,11 @@ package bitcoin_hpke import ( - "errors" + "slices" + "github.com/btcsuite/btcd/btcec/v2" + "github.com/go-errors/errors" "golang.org/x/crypto/chacha20poly1305" - "slices" ) // EncryptedMessage represents a message encrypted with Bitcoin Hpke @@ -19,16 +20,24 @@ func ParseEncryptedMessage(serializedEncryptedMessage []byte) (*EncryptedMessage return nil, errors.New("serialized message too short") } - encapsulatedKey, err := btcec.ParsePubKey(serializedEncryptedMessage[:encapsulatedKeyLengthInBytes]) + encapsulatedKey, err := btcec.ParsePubKey( + serializedEncryptedMessage[:encapsulatedKeyLengthInBytes], + ) if err != nil { return nil, err } - return &EncryptedMessage{encapsulatedKey, serializedEncryptedMessage[encapsulatedKeyLengthInBytes:]}, nil + return &EncryptedMessage{ + encapsulatedKey, + serializedEncryptedMessage[encapsulatedKeyLengthInBytes:], + }, nil } func (encryptedMessage EncryptedMessage) Serialize() []byte { - return slices.Concat(encryptedMessage.encapsulatedKey.SerializeUncompressed(), encryptedMessage.ciphertext) + return slices.Concat( + encryptedMessage.encapsulatedKey.SerializeUncompressed(), + encryptedMessage.ciphertext, + ) } func (encryptedMessage EncryptedMessage) GetEncapsulatedKey() *btcec.PublicKey { @@ -43,7 +52,8 @@ func (encryptedMessage EncryptedMessage) PlaintextLengthInBytes() int { return len(encryptedMessage.ciphertext) - chacha20poly1305.Overhead } -// This is a companion to ParseEncryptedMessage that allows to know the length in bytes of an encrypted message. +// This is a companion to ParseEncryptedMessage that allows to know the length in bytes of an +// encrypted message. func SerializedEncryptedMessageLengthInBytes(plaintextLengthInBytes int) int { return encapsulatedKeyLengthInBytes + plaintextLengthInBytes + authenticationTagLengthInBytes } diff --git a/libwallet/cryptography/bitcoin_hpke/i2osp.go b/libwallet/cryptography/bitcoin_hpke/i2osp.go index d635a27d..c6da6b5f 100644 --- a/libwallet/cryptography/bitcoin_hpke/i2osp.go +++ b/libwallet/cryptography/bitcoin_hpke/i2osp.go @@ -1,11 +1,13 @@ package bitcoin_hpke -import "errors" +import ( + "github.com/go-errors/errors" +) // Convert non-negative integer n to a w-length, big-endian byte string, as described in [RFC8017]. func i2Osp(n int, w int) []byte { - // In our usage of i2Osp all input values are constants that satisfy the preconditions on n and w. There is - // therefore no chance for this to panic. + // In our usage of i2Osp all input values are constants that satisfy the preconditions on n and + // w. There is therefore no chance for this to panic. if n < 0 { panic(errors.New("n must be non-negative")) } diff --git a/libwallet/cryptography/bitcoin_hpke/kem.go b/libwallet/cryptography/bitcoin_hpke/kem.go index 5866d0cf..edbd3cb8 100644 --- a/libwallet/cryptography/bitcoin_hpke/kem.go +++ b/libwallet/cryptography/bitcoin_hpke/kem.go @@ -1,10 +1,12 @@ package bitcoin_hpke import ( - "github.com/btcsuite/btcd/btcec/v2" - "github.com/muun/libwallet/encryption" "slices" "testing" + + "github.com/btcsuite/btcd/btcec/v2" + + "github.com/muun/libwallet/encryption" ) // See Section 4.1 of RFC 9180 @@ -16,7 +18,10 @@ func encapsulate( return nil, nil, err } dh := diffieHellman(ephemeralPrivateKey, receiverPublicKey) - kemContext := slices.Concat(ephemeralPublicKey.SerializeUncompressed(), receiverPublicKey.SerializeUncompressed()) + kemContext := slices.Concat( + ephemeralPublicKey.SerializeUncompressed(), + receiverPublicKey.SerializeUncompressed(), + ) sharedSecret, err = extractAndExpand(dh, kemContext) if err != nil { return nil, nil, err @@ -31,7 +36,10 @@ func decapsulate( ) (sharedSecret []byte, err error) { dh := diffieHellman(receiverPrivateKey, encapsulatedKey) - kemContext := slices.Concat(encapsulatedKey.SerializeUncompressed(), receiverPrivateKey.PubKey().SerializeUncompressed()) + kemContext := slices.Concat( + encapsulatedKey.SerializeUncompressed(), + receiverPrivateKey.PubKey().SerializeUncompressed(), + ) return extractAndExpand(dh, kemContext) } @@ -53,15 +61,25 @@ func generateKeyPair() (*btcec.PrivateKey, *btcec.PublicKey, error) { var testingOnlyGenerateKeyPair func() (*btcec.PrivateKey, *btcec.PublicKey, error) func diffieHellman(privateKey *btcec.PrivateKey, publicKey *btcec.PublicKey) []byte { - sharedSecret, _ := btcec.S256().ScalarMult(publicKey.X(), publicKey.Y(), privateKey.ToECDSA().D.Bytes()) + sharedSecret, _ := btcec.S256(). + ScalarMult(publicKey.X(), publicKey.Y(), privateKey.ToECDSA().D.Bytes()) return encryption.PaddedSerializeBigInt(diffieHellmanSharedSecretLengthInBytes, sharedSecret) } // See Section 4.1 of RFC 9180 func extractAndExpand(dh []byte, kemContext []byte) ([]byte, error) { - suiteId := slices.Concat([]byte("KEM"), i2Osp(kemId, 2)) + suiteId := slices.Concat( //nolint:staticcheck // TODO: var suiteId should be suiteID + []byte("KEM"), + i2Osp(kemId, 2), + ) eaePrk := labeledExtract([]byte(""), []byte("eae_prk"), dh, suiteId) - return labeledExpand(eaePrk, []byte("shared_secret"), kemContext, privateKeyLengthInBytes, suiteId) + return labeledExpand( + eaePrk, + []byte("shared_secret"), + kemContext, + privateKeyLengthInBytes, + suiteId, + ) } diff --git a/libwallet/cryptography/bitcoin_hpke/labeled_hkdf.go b/libwallet/cryptography/bitcoin_hpke/labeled_hkdf.go index 54c35183..35cb7383 100644 --- a/libwallet/cryptography/bitcoin_hpke/labeled_hkdf.go +++ b/libwallet/cryptography/bitcoin_hpke/labeled_hkdf.go @@ -2,25 +2,38 @@ package bitcoin_hpke import ( "crypto/sha256" - "fmt" - "golang.org/x/crypto/hkdf" "slices" + + "github.com/go-errors/errors" + "golang.org/x/crypto/hkdf" ) // See Section 4.0 of RFC 9180 -func labeledExtract(salt, label, ikm, suiteId []byte) []byte { +func labeledExtract( + salt, label, ikm, suiteId []byte, //nolint:staticcheck // TODO: func parameter suiteId should be suiteID +) []byte { labeledIkm := slices.Concat([]byte(hpkeIdentifier), suiteId, label, ikm) return hkdf.Extract(sha256.New, labeledIkm, salt) } // See Section 4.0 of RFC 9180 -func labeledExpand(pseudoRandomKey, label, info []byte, lengthInBytes int, suiteId []byte) ([]byte, error) { - labeledInfo := slices.Concat(i2Osp(lengthInBytes, 2), []byte(hpkeIdentifier), suiteId, label, info) +func labeledExpand( + pseudoRandomKey, label, info []byte, + lengthInBytes int, + suiteId []byte, //nolint:staticcheck // TODO: func parameter suiteId should be suiteID +) ([]byte, error) { + labeledInfo := slices.Concat( + i2Osp(lengthInBytes, 2), + []byte(hpkeIdentifier), + suiteId, + label, + info, + ) expandReader := hkdf.Expand(sha256.New, pseudoRandomKey, labeledInfo) result := make([]byte, lengthInBytes) n, err := expandReader.Read(result) if n != lengthInBytes { - return nil, fmt.Errorf("expand failed") + return nil, errors.Errorf("expand failed") } if err != nil { return nil, err diff --git a/libwallet/cryptography/secp256r1p.go b/libwallet/cryptography/secp256r1p.go index 0f74b2a0..0401479a 100644 --- a/libwallet/cryptography/secp256r1p.go +++ b/libwallet/cryptography/secp256r1p.go @@ -3,7 +3,8 @@ package cryptography import ( "crypto/ecdh" "crypto/rand" - "fmt" + + "github.com/go-errors/errors" ) type KeyPair struct { @@ -36,12 +37,18 @@ func GenerateSecp256r1PKeyPair() (*KeyPair, error) { privateKey, err := GenerateSecp256r1PrivateKey() if err != nil { - return nil, fmt.Errorf("failed to generate private key: %v", err) + return nil, errors.Errorf( + "failed to generate private key: %w", + err, + ) } publicKey, err := GenerateSecp256r1PublicKey(privateKey) if err != nil { - return nil, fmt.Errorf("failed to generate public key: %v", err) + return nil, errors.Errorf( + "failed to generate public key: %w", + err, + ) } return &KeyPair{PrivateKey: privateKey, PublicKey: publicKey}, nil @@ -49,7 +56,7 @@ func GenerateSecp256r1PKeyPair() (*KeyPair, error) { func ValidateSecp256r1PublicKey(publicKey []byte) error { if len(publicKey) != 65 { - return fmt.Errorf( + return errors.Errorf( "invalid public key length: %d (expected %d)", len(publicKey), 65, @@ -57,7 +64,7 @@ func ValidateSecp256r1PublicKey(publicKey []byte) error { } if publicKey[0] != 0x04 { - return fmt.Errorf( + return errors.Errorf( "invalid public key format: expected 0x04 prefix, got 0x%02X", publicKey[0], ) @@ -65,7 +72,7 @@ func ValidateSecp256r1PublicKey(publicKey []byte) error { _, err := ecdh.P256().NewPublicKey(publicKey) if err != nil { - return fmt.Errorf("invalid public key: not a valid point on curve secp256r1") + return errors.Errorf("invalid public key: not a valid point on curve secp256r1") } return nil @@ -77,17 +84,17 @@ func ECDH(privateKey, publicKey []byte) ([]byte, error) { priv, err := ecdh.P256().NewPrivateKey(privateKey) if err != nil { - return nil, fmt.Errorf("invalid private key: %w", err) + return nil, errors.Errorf("invalid private key: %w", err) } pub, err := ecdh.P256().NewPublicKey(publicKey) if err != nil { - return nil, fmt.Errorf("invalid public key: %w", err) + return nil, errors.Errorf("invalid public key: %w", err) } secret, err := priv.ECDH(pub) if err != nil { - return nil, fmt.Errorf("ecdh error: %w", err) + return nil, errors.Errorf("ecdh error: %w", err) } return secret, nil diff --git a/libwallet/data/emergency_kit/pdf.go b/libwallet/data/emergency_kit/pdf.go index 233b83b1..d7ae2536 100644 --- a/libwallet/data/emergency_kit/pdf.go +++ b/libwallet/data/emergency_kit/pdf.go @@ -2,11 +2,13 @@ package emergency_kit import ( "bytes" - "github.com/muun/libwallet/data/emergency_kit/resources" "math" "strings" + "time" "github.com/phpdave11/gofpdf" + + "github.com/muun/libwallet/data/emergency_kit/resources" ) type RenderingContext struct { @@ -34,6 +36,10 @@ type ImageAsset struct { type PdfExtensions struct { *gofpdf.Fpdf ctx RenderingContext + + // Per-step durations of the (static) setup, exposed for profiling. In milliseconds. + RegisterFontsMs int64 + RegisterImagesMs int64 } // CreateAndSetupPdf creates a new PdfExtensions wrapper around a gofpdf.Fpdf instance @@ -49,8 +55,14 @@ func CreateAndSetupPdf(ctx RenderingContext) *PdfExtensions { FontDirStr: "", }) pdfExt := &PdfExtensions{Fpdf: pdf, ctx: ctx} + + startFonts := time.Now() registerFonts(pdfExt) + pdfExt.RegisterFontsMs = time.Since(startFonts).Milliseconds() + + startImages := time.Now() registerImages(pdf, ctx.Images) + pdfExt.RegisterImagesMs = time.Since(startImages).Milliseconds() // Remove cell margins globally to prevent unwanted padding pdf.SetCellMargin(0) @@ -100,7 +112,8 @@ type TextPart struct { // - minLines: Minimum number of lines to use (0 for no minimum) // // Returns: -// - endY: Y coordinate after rendering all text (useful for calculating next element position) +// - endY: Y coordinate after rendering all text +// (useful for calculating next element position) func (p *PdfExtensions) RenderMultiStyledText( startX float64, startY float64, @@ -137,7 +150,8 @@ func (p *PdfExtensions) RenderMultiStyledText( // Check if next part starts with punctuation (don't add space before punctuation) nextPartStartsWithPunctuation := false - if partIndex < len(parts)-1 && wordIndex == len(words)-1 && len(parts[partIndex+1].Text) > 0 { + if partIndex < len(parts)-1 && wordIndex == len(words)-1 && + len(parts[partIndex+1].Text) > 0 { nextPartStartsWithPunctuation = isPunctuation(parts[partIndex+1].Text[0]) } @@ -202,7 +216,15 @@ func (p *PdfExtensions) RenderMultiStyledText( parts[word.partIdx].SetFont(p.Fpdf) parts[word.partIdx].SetColor(p.Fpdf) if letterSpacing > 0 { - p.RenderTextWithLetterSpacing(currentX, currentY, word.text, letterSpacing, "L", "T", lineHeight) + p.RenderTextWithLetterSpacing( + currentX, + currentY, + word.text, + letterSpacing, + "L", + "T", + lineHeight, + ) } else { p.SetXY(currentX, currentY) p.Cell(word.width, lineHeight, word.text) @@ -222,11 +244,15 @@ func (p *PdfExtensions) RenderMultiStyledText( return currentY + lineHeight } -// MultiCellWithLetterSpacing renders multi-line text using the current font/color with custom letter spacing. -// It mimics the behaviour of gofpdf's MultiCell but applies inter-character spacing. -// The caller must set X/Y position and font/color before calling, just like MultiCell. +// MultiCellWithLetterSpacing renders multi-line text using the current font/color with custom +// letter spacing. It mimics the behaviour of gofpdf's MultiCell but applies inter-character +// spacing. The caller must set X/Y position and font/color before calling, just like MultiCell. // After rendering, the Y cursor is advanced to the bottom of the last line. -func (p *PdfExtensions) MultiCellWithLetterSpacing(availableWidth, lineHeight float64, text string, letterSpacing float64) { +func (p *PdfExtensions) MultiCellWithLetterSpacing( + availableWidth, lineHeight float64, + text string, + letterSpacing float64, +) { startX := p.GetX() currentX := startX currentY := p.GetY() @@ -247,15 +273,27 @@ func (p *PdfExtensions) MultiCellWithLetterSpacing(availableWidth, lineHeight fl if wordWidth <= availableWidth { // 2. Word fits on a single line — render it whole. - currentX = p.RenderTextWithLetterSpacing(currentX, currentY, word, letterSpacing, "L", "T", lineHeight) + currentX = p.RenderTextWithLetterSpacing( + currentX, + currentY, + word, + letterSpacing, + "L", + "T", + lineHeight, + ) } else { - // 3. Word is longer than the available width (e.g. an encrypted key) — split character by character. + // 3. Word is longer than the available width + // (e.g. an encrypted key) — split character by character. lineStr := "" for _, char := range word { charStr := string(char) testWidth := p.GetStringWidthWithLetterSpacing(lineStr+charStr, letterSpacing) if lineStr != "" && currentX+testWidth > maxX { - p.RenderTextWithLetterSpacing(currentX, currentY, lineStr, letterSpacing, "L", "T", lineHeight) + p.RenderTextWithLetterSpacing( + currentX, currentY, lineStr, + letterSpacing, "L", "T", lineHeight, + ) currentY += lineHeight currentX = startX lineStr = charStr @@ -263,7 +301,10 @@ func (p *PdfExtensions) MultiCellWithLetterSpacing(availableWidth, lineHeight fl lineStr += charStr } } - currentX = p.RenderTextWithLetterSpacing(currentX, currentY, lineStr, letterSpacing, "L", "T", lineHeight) + currentX = p.RenderTextWithLetterSpacing( + currentX, currentY, lineStr, + letterSpacing, "L", "T", lineHeight, + ) } // Add one space after each word @@ -277,7 +318,11 @@ func (p *PdfExtensions) MultiCellWithLetterSpacing(availableWidth, lineHeight fl // LineCountWithLetterSpacing estimates the number of lines text will occupy when rendered // with the given letter spacing, based on total text width divided by available width. -func (p *PdfExtensions) LineCountWithLetterSpacing(maxWidth float64, text string, letterSpacing float64) float64 { +func (p *PdfExtensions) LineCountWithLetterSpacing( + maxWidth float64, + text string, + letterSpacing float64, +) float64 { if maxWidth <= 0 { return 1 } @@ -288,13 +333,19 @@ func (p *PdfExtensions) LineCountWithLetterSpacing(maxWidth float64, text string return math.Ceil(textWidth / maxWidth) } -// RenderTextWithLetterSpacing renders text with custom letter spacing -// letterSpacing is specified as a fraction of the font size in pixels (0.05 = 5% of font size) -// The spacing is converted from points to millimeters to match PDF coordinate system. -// Custom spacing was already being used in the html/css version so we had to replicate it. -// horizontalAlign controls horizontal positioning: "L" (left), "C" (center), "R" (right) -// verticalAlign controls vertical positioning: "T" (top), "M" (middle), "B" (bottom), "A" (baseline) -// lineHeight is optional - if not provided or <= 0, defaults to fontHeight * 1.2 +// RenderTextWithLetterSpacing renders text with custom +// letter spacing. +// letterSpacing is specified as a fraction of the font size +// in pixels (0.05 = 5% of font size). The spacing is converted +// from points to millimeters to match PDF coordinate system. +// Custom spacing was already being used in the html/css version +// so we had to replicate it. +// horizontalAlign controls horizontal positioning: +// "L" (left), "C" (center), "R" (right) +// verticalAlign controls vertical positioning: +// "T" (top), "M" (middle), "B" (bottom), "A" (baseline) +// lineHeight is optional - if not provided or <= 0, +// defaults to fontHeight * 1.2 func (p *PdfExtensions) RenderTextWithLetterSpacing( x float64, y float64, @@ -319,7 +370,17 @@ func (p *PdfExtensions) RenderTextWithLetterSpacing( charStr := string(char) charWidth := p.GetStringWidth(charStr) p.SetXY(currentX, y) - p.CellFormat(charWidth, cellHeight, charStr, "", 0, horizontalAlign+verticalAlign, false, 0, "") + p.CellFormat( + charWidth, + cellHeight, + charStr, + "", + 0, + horizontalAlign+verticalAlign, + false, + 0, + "", + ) currentX += charWidth + spacingAmount } @@ -330,7 +391,10 @@ func (p *PdfExtensions) RenderTextWithLetterSpacing( // GetStringWidthWithLetterSpacing calculates the width of text with custom letter spacing // letterSpacing is specified as a fraction of the font size in pixels (0.05 = 5% of font size) // The spacing is converted from points to millimeters to match PDF coordinate system -func (p *PdfExtensions) GetStringWidthWithLetterSpacing(text string, letterSpacing float64) float64 { +func (p *PdfExtensions) GetStringWidthWithLetterSpacing( + text string, + letterSpacing float64, +) float64 { if text == "" { return 0 } @@ -348,7 +412,8 @@ func (p *PdfExtensions) GetStringWidthWithLetterSpacing(text string, letterSpaci return totalWidth - spacingAmount } -// GetDrawablePageWidth returns the usable page width after subtracting the horizontal margins on both sides. +// GetDrawablePageWidth returns the usable page width after subtracting the horizontal margins on +// both sides. func (p *PdfExtensions) GetDrawablePageWidth() float64 { pageWidth, _ := p.GetPageSize() return pageWidth - 2*p.ctx.NonDrawableHorizontalMargins diff --git a/libwallet/data/emergency_kit/resources/fonts.go b/libwallet/data/emergency_kit/resources/fonts.go index 4a463ee9..9e9e1608 100644 --- a/libwallet/data/emergency_kit/resources/fonts.go +++ b/libwallet/data/emergency_kit/resources/fonts.go @@ -2,6 +2,7 @@ package resources import ( _ "embed" + "github.com/phpdave11/gofpdf" ) diff --git a/libwallet/data/keys/provider.go b/libwallet/data/keys/provider.go index 247d4558..1729e390 100644 --- a/libwallet/data/keys/provider.go +++ b/libwallet/data/keys/provider.go @@ -1,7 +1,8 @@ package keys import ( - "fmt" + "github.com/go-errors/errors" + "github.com/muun/libwallet" "github.com/muun/libwallet/app_provided_data" ) @@ -30,7 +31,11 @@ func (p *keyProvider) UserPrivateKey() (*libwallet.HDPrivateKey, error) { return nil, err } - userPrivKey, err := libwallet.NewHDPrivateKeyFromString(userKeyData.Serialized, userKeyData.Path, &p.network) + userPrivKey, err := libwallet.NewHDPrivateKeyFromString( + userKeyData.Serialized, + userKeyData.Path, + &p.network, + ) if err != nil { return nil, err } @@ -53,7 +58,11 @@ func (p *keyProvider) MuunPublicKey() (*libwallet.HDPublicKey, error) { return nil, err } - muunKey, err := libwallet.NewHDPublicKeyFromString(muunKeyData.Serialized, muunKeyData.Path, &p.network) + muunKey, err := libwallet.NewHDPublicKeyFromString( + muunKeyData.Serialized, + muunKeyData.Path, + &p.network, + ) if err != nil { return nil, err } @@ -70,17 +79,21 @@ func (p *keyProvider) EncryptedMuunPrivateKey() (*libwallet.EncryptedPrivateKeyI return libwallet.DecodeEncryptedPrivateKey(encodedKeyData) } -func (p *keyProvider) DecryptMuunPrivateKey(recoveryCode string, encryptedKey *libwallet.EncryptedPrivateKeyInfo, network *libwallet.Network) (*libwallet.DecryptedPrivateKey, error) { +func (p *keyProvider) DecryptMuunPrivateKey( + recoveryCode string, + encryptedKey *libwallet.EncryptedPrivateKeyInfo, + network *libwallet.Network, +) (*libwallet.DecryptedPrivateKey, error) { salt := encryptedKey.Salt decryptionKey, err := libwallet.RecoveryCodeToKey(recoveryCode, salt) if err != nil { - return nil, fmt.Errorf("failed to process recovery code: %w", err) + return nil, errors.Errorf("failed to process recovery code: %w", err) } decryptedKey, err := decryptionKey.DecryptKey(encryptedKey, network) if err != nil { - return nil, fmt.Errorf("failed to decrypt ke: %w", err) + return nil, errors.Errorf("failed to decrypt ke: %w", err) } return decryptedKey, nil diff --git a/libwallet/data/lightning/incoming_htlc_batch_repository.go b/libwallet/data/lightning/incoming_htlc_batch_repository.go new file mode 100644 index 00000000..abd31c79 --- /dev/null +++ b/libwallet/data/lightning/incoming_htlc_batch_repository.go @@ -0,0 +1,114 @@ +package lightning + +import ( + "encoding/json" + + "github.com/go-errors/errors" + + "github.com/muun/libwallet/domain/model/lightning" + "github.com/muun/libwallet/storage" +) + +// IncomingHTLCBatchRepository provides persistence operations for lightning HTLC batches. +type IncomingHTLCBatchRepository interface { + Save(htlcBatch *lightning.IncomingHTLCBatch) error + FindByID(htlcBatchID string) (*lightning.IncomingHTLCBatch, error) + FindAll() (map[string]*lightning.IncomingHTLCBatch, error) + DeleteByID(htlcBatchID string) error +} + +type KvIncomingHTLCBatchRepository struct { + kv *storage.KeyValueStorage +} + +// NewIncomingHTLCBatchRepository creates a new HTLC repository backed by the given key-value +// storage. +func NewIncomingHTLCBatchRepository(kv *storage.KeyValueStorage) IncomingHTLCBatchRepository { + return &KvIncomingHTLCBatchRepository{kv: kv} +} + +func (r *KvIncomingHTLCBatchRepository) Save(htlcBatch *lightning.IncomingHTLCBatch) error { + all, err := r.loadAll() + if err != nil { + return err + } + + if htlcBatch.UUID == "" { + return errors.Errorf("received htlcBatch should have its ID by now but it doesn't") + } + + all[htlcBatch.UUID] = htlcBatch + + return r.saveAll(all) +} + +func (r *KvIncomingHTLCBatchRepository) FindByID( + htlcBatchID string, +) (*lightning.IncomingHTLCBatch, error) { + all, err := r.loadAll() + if err != nil { + return nil, err + } + + htlcBatch, ok := all[htlcBatchID] + if !ok { + return nil, nil + } + + return htlcBatch, nil +} + +func (r *KvIncomingHTLCBatchRepository) FindAll() (map[string]*lightning.IncomingHTLCBatch, error) { + return r.loadAll() +} + +func (r *KvIncomingHTLCBatchRepository) DeleteByID(htlcBatchID string) error { + all, err := r.loadAll() + if err != nil { + return err + } + + _, ok := all[htlcBatchID] + if !ok { + return nil + } + delete(all, htlcBatchID) + + return r.saveAll(all) +} + +func (r *KvIncomingHTLCBatchRepository) loadAll() (map[string]*lightning.IncomingHTLCBatch, error) { + value, err := r.kv.Get(storage.KeyLightningIncomingHTLCBatches) + if err != nil { + return nil, errors.Errorf("loadAll error when trying to retrieve KV storage key: %w", err) + } + + if value == nil { + return make(map[string]*lightning.IncomingHTLCBatch), nil + } + + str, ok := value.(string) + if !ok { + return nil, errors.Errorf( + "loadAll unexpected type for %s: %T", + storage.KeyLightningIncomingHTLCBatches, + value, + ) + } + + var all map[string]*lightning.IncomingHTLCBatch + if err := json.Unmarshal([]byte(str), &all); err != nil { + return nil, errors.Errorf("loadAll error when unmarshalling HTLCs: %w", err) + } + + return all, nil +} + +func (r *KvIncomingHTLCBatchRepository) saveAll(all map[string]*lightning.IncomingHTLCBatch) error { + data, err := json.Marshal(all) + if err != nil { + return errors.Errorf("saveAll error when marshalling HTLCs: %w", err) + } + + return r.kv.Save(storage.KeyLightningIncomingHTLCBatches, string(data)) +} diff --git a/libwallet/data/securekv/errors.go b/libwallet/data/securekv/errors.go new file mode 100644 index 00000000..9a01fa7e --- /dev/null +++ b/libwallet/data/securekv/errors.go @@ -0,0 +1,46 @@ +package securekv + +import ( + "github.com/go-errors/errors" + + "github.com/muun/libwallet/app_provided_data" +) + +type ( + NotFoundError struct{ error } + DecryptionFailedError struct{ error } + StorageFailedError struct{ error } +) + +func newNotFoundError(err error) error { + return &NotFoundError{ + errors.Errorf("secure key-value storage: not found: %w", err), + } +} + +func newDecryptionFailedError(err error) error { + return &DecryptionFailedError{ + errors.Errorf("secure key-value storage: decryption failed: %w", err), + } +} + +func newStorageFailedError(err error) error { + return &StorageFailedError{ + errors.Errorf("secure key-value storage: storage failed: %w", err), + } +} + +// errorFromGetStatus maps a non-Ok Get status to its typed error. Caller must +// branch on Ok before invoking; Ok is treated as unexpected here. +func errorFromGetStatus(status int32) error { + switch status { + case app_provided_data.SecureKvStatusNotFound: + return newNotFoundError(errors.New("key missing")) + case app_provided_data.SecureKvStatusDecryptionFailed: + return newDecryptionFailedError(errors.New("key invalidated")) + case app_provided_data.SecureKvStatusStorageFailed: + return newStorageFailedError(errors.New("operation failed")) + default: + return newStorageFailedError(errors.Errorf("unexpected status: %d", status)) + } +} diff --git a/libwallet/data/securekv/secret.go b/libwallet/data/securekv/secret.go new file mode 100644 index 00000000..c240cfe4 --- /dev/null +++ b/libwallet/data/securekv/secret.go @@ -0,0 +1,59 @@ +package securekv + +import ( + "github.com/go-errors/errors" + + "github.com/muun/libwallet/app_provided_data" + "github.com/muun/libwallet/platform/preconditions" +) + +// Secret holds no plaintext: bytes only exist inside WithSecret's callback. +type Secret struct { + key string + fetcher app_provided_data.SecureKeyValueStorage +} + +func NewSecret(key string, fetcher app_provided_data.SecureKeyValueStorage) *Secret { + return &Secret{ + key: preconditions.CheckNotEmpty(key), + fetcher: preconditions.CheckNotNil(fetcher), + } +} + +// WithSecret fetches fresh on every call. +func (s *Secret) WithSecret(fn func([]byte) error) error { + resp, err := s.fetcher.Get(s.key) + if err != nil { + return newStorageFailedError(err) + } + if resp == nil { + return newStorageFailedError(errors.Errorf("bridge returned nil response")) + } + // Wipe runs regardless of status. + defer wipeSecret(resp.Value) + if resp.StatusCode == app_provided_data.SecureKvStatusOk { + return fn(resp.Value) + } + return errorFromGetStatus(resp.StatusCode) +} + +// wipeSecret routes through observeBytes to defeat dead-store elimination. +// Best-effort: Go has no official secure-zeroize primitive. +func wipeSecret(b []byte) { + for i := range b { + b[i] = 0 + } + _ = observeBytes(b) +} + +// observeBytes is //go:noinline so the compiler must treat the call as +// opaque, keeping prior writes to b live. +// +//go:noinline +func observeBytes(b []byte) byte { + var v byte + for _, x := range b { + v |= x + } + return v +} diff --git a/libwallet/data/securekv/storage.go b/libwallet/data/securekv/storage.go new file mode 100644 index 00000000..e920b116 --- /dev/null +++ b/libwallet/data/securekv/storage.go @@ -0,0 +1,86 @@ +package securekv + +import ( + "context" + + "github.com/go-errors/errors" + + "github.com/muun/libwallet/app_provided_data" +) + +type SecureKeyValueStorage interface { + Put(ctx context.Context, key string, value []byte) error + Get(ctx context.Context, key string) (*Secret, error) + Delete(ctx context.Context, key string) error + Wipe(ctx context.Context) error +} + +type secureKeyValueStorage struct { + bridge app_provided_data.SecureKeyValueStorage +} + +func NewSecureKeyValueStorage( + bridge app_provided_data.SecureKeyValueStorage, +) SecureKeyValueStorage { + return &secureKeyValueStorage{bridge: bridge} +} + +func (s *secureKeyValueStorage) Put(_ context.Context, key string, value []byte) error { + if key == "" { + return newStorageFailedError(errors.Errorf("key must not be empty")) + } + if value == nil { + return newStorageFailedError(errors.Errorf("value must not be nil")) + } + resp, err := s.bridge.Put(key, value) + if err != nil { + return newStorageFailedError(err) + } + if resp == nil { + return newStorageFailedError(errors.Errorf("bridge returned nil response")) + } + if resp.StatusCode != app_provided_data.SecureKvStatusOk { + return newStorageFailedError(errors.Errorf("unexpected status: %d", resp.StatusCode)) + } + return nil +} + +// Get returns a capability handle; the bridge is queried lazily by WithSecret. +// Errors from the bridge surface there, not here. +func (s *secureKeyValueStorage) Get(_ context.Context, key string) (*Secret, error) { + if key == "" { + return nil, newStorageFailedError(errors.Errorf("key must not be empty")) + } + return NewSecret(key, s.bridge), nil +} + +func (s *secureKeyValueStorage) Delete(_ context.Context, key string) error { + if key == "" { + return newStorageFailedError(errors.Errorf("key must not be empty")) + } + resp, err := s.bridge.Delete(key) + if err != nil { + return newStorageFailedError(err) + } + if resp == nil { + return newStorageFailedError(errors.Errorf("bridge returned nil response")) + } + if resp.StatusCode != app_provided_data.SecureKvStatusOk { + return newStorageFailedError(errors.Errorf("unexpected status: %d", resp.StatusCode)) + } + return nil +} + +func (s *secureKeyValueStorage) Wipe(_ context.Context) error { + resp, err := s.bridge.Wipe() + if err != nil { + return newStorageFailedError(err) + } + if resp == nil { + return newStorageFailedError(errors.Errorf("bridge returned nil response")) + } + if resp.StatusCode != app_provided_data.SecureKvStatusOk { + return newStorageFailedError(errors.Errorf("unexpected status: %d", resp.StatusCode)) + } + return nil +} diff --git a/libwallet/data/securekv/storage_test.go b/libwallet/data/securekv/storage_test.go new file mode 100644 index 00000000..a1ecd932 --- /dev/null +++ b/libwallet/data/securekv/storage_test.go @@ -0,0 +1,477 @@ +package securekv_test + +import ( + "testing" + + "github.com/go-errors/errors" + + "github.com/muun/libwallet/app_provided_data" + "github.com/muun/libwallet/data/securekv" +) + +type fakeBridge struct { + getResp *app_provided_data.SecureKvGetResponse + getErr error + getNilResp bool + lastGetReturned *app_provided_data.SecureKvGetResponse + + putStatus int32 + putErr error + putNilResp bool + deleteStatus int32 + deleteErr error + deleteNilResp bool + wipeStatus int32 + wipeErr error + wipeNilResp bool +} + +func newFakeBridge() *fakeBridge { + return &fakeBridge{} +} + +func (b *fakeBridge) Put(_ string, _ []byte) (*app_provided_data.SecureKvResponse, error) { + if b.putNilResp { + return nil, b.putErr + } + return &app_provided_data.SecureKvResponse{StatusCode: b.putStatus}, b.putErr +} + +func (b *fakeBridge) Get(_ string) (*app_provided_data.SecureKvGetResponse, error) { + if b.getErr != nil { + return nil, b.getErr + } + if b.getNilResp { + return nil, nil + } + if b.getResp == nil { + panic("fakeBridge.Get: no preset getResp, getErr, or getNilResp; tests must set one") + } + // Fresh copy each call so WithSecret's wipe does not affect later calls. + resp := &app_provided_data.SecureKvGetResponse{ + Value: append([]byte(nil), b.getResp.Value...), + StatusCode: b.getResp.StatusCode, + } + b.lastGetReturned = resp + return resp, nil +} + +func (b *fakeBridge) Delete(_ string) (*app_provided_data.SecureKvResponse, error) { + if b.deleteNilResp { + return nil, b.deleteErr + } + return &app_provided_data.SecureKvResponse{StatusCode: b.deleteStatus}, b.deleteErr +} + +func (b *fakeBridge) Wipe() (*app_provided_data.SecureKvResponse, error) { + if b.wipeNilResp { + return nil, b.wipeErr + } + return &app_provided_data.SecureKvResponse{StatusCode: b.wipeStatus}, b.wipeErr +} + +func TestSecureKeyValueStorage(t *testing.T) { + ctx := t.Context() + + t.Run("status classification via WithSecret", func(t *testing.T) { + testCases := []struct { + desc string + status int32 + check func(error) bool + }{ + { + "StatusNotFound wraps as NotFoundError", + app_provided_data.SecureKvStatusNotFound, + func(err error) bool { + var target *securekv.NotFoundError + return errors.As(err, &target) + }, + }, + { + "StatusDecryptionFailed wraps as DecryptionFailedError", + app_provided_data.SecureKvStatusDecryptionFailed, + func(err error) bool { + var target *securekv.DecryptionFailedError + return errors.As(err, &target) + }, + }, + { + "StatusStorageFailed wraps as StorageFailedError", + app_provided_data.SecureKvStatusStorageFailed, + func(err error) bool { + var target *securekv.StorageFailedError + return errors.As(err, &target) + }, + }, + } + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + bridge := newFakeBridge() + bridge.getResp = &app_provided_data.SecureKvGetResponse{StatusCode: tc.status} + storage := securekv.NewSecureKeyValueStorage(bridge) + + secret, err := storage.Get(ctx, "key") + if err != nil { + t.Fatalf("Get() error = %v, lazy Get must not fail", err) + } + err = secret.WithSecret(func(_ []byte) error { + t.Fatal("fn should not be called when status is not Ok") + return nil + }) + if err == nil { + t.Fatal("expected error from WithSecret") + } + if !tc.check(err) { + t.Fatalf("error type check failed: %v", err) + } + }) + } + }) + + t.Run("transport error wraps as StorageFailedError", func(t *testing.T) { + bridge := newFakeBridge() + bridge.getErr = errors.New("gomobile call failed") + storage := securekv.NewSecureKeyValueStorage(bridge) + + secret, err := storage.Get(ctx, "key") + if err != nil { + t.Fatalf("Get() error = %v, lazy Get must not fail", err) + } + err = secret.WithSecret(func(_ []byte) error { + t.Fatal("fn should not be called on transport error") + return nil + }) + var target *securekv.StorageFailedError + if !errors.As(err, &target) { + t.Fatalf("expected StorageFailedError, got %v", err) + } + }) + + t.Run("Get is lazy: no native call until WithSecret", func(t *testing.T) { + bridge := newFakeBridge() + // Neither getResp nor getErr set: any bridge.Get call would panic. + storage := securekv.NewSecureKeyValueStorage(bridge) + + _, err := storage.Get(ctx, "key") + if err != nil { + t.Fatalf("Get() error = %v, should not call bridge", err) + } + }) + + t.Run("Get rejects empty key", func(t *testing.T) { + bridge := newFakeBridge() + storage := securekv.NewSecureKeyValueStorage(bridge) + + _, err := storage.Get(ctx, "") + if err == nil { + t.Fatal("expected error for empty key") + } + }) + + t.Run("Put rejects empty key", func(t *testing.T) { + bridge := newFakeBridge() + storage := securekv.NewSecureKeyValueStorage(bridge) + + err := storage.Put(ctx, "", []byte("value")) + if err == nil { + t.Fatal("expected error for empty key") + } + }) + + t.Run("Delete rejects empty key", func(t *testing.T) { + bridge := newFakeBridge() + storage := securekv.NewSecureKeyValueStorage(bridge) + + err := storage.Delete(ctx, "") + if err == nil { + t.Fatal("expected error for empty key") + } + }) + + t.Run("Put rejects nil value", func(t *testing.T) { + bridge := newFakeBridge() + storage := securekv.NewSecureKeyValueStorage(bridge) + + err := storage.Put(ctx, "key", nil) + if err == nil { + t.Fatal("expected error for nil value") + } + }) + + t.Run("Put allows empty byte slice", func(t *testing.T) { + bridge := newFakeBridge() + bridge.putStatus = app_provided_data.SecureKvStatusOk + storage := securekv.NewSecureKeyValueStorage(bridge) + + err := storage.Put(ctx, "key", []byte{}) + if err != nil { + t.Fatalf("Put() error = %v, empty slice should be allowed", err) + } + }) + + t.Run("Put surfaces typed error when bridge returns non-Ok status", func(t *testing.T) { + bridge := newFakeBridge() + bridge.putStatus = app_provided_data.SecureKvStatusStorageFailed + storage := securekv.NewSecureKeyValueStorage(bridge) + + err := storage.Put(ctx, "key", []byte("value")) + var target *securekv.StorageFailedError + if !errors.As(err, &target) { + t.Fatalf("expected StorageFailedError, got %v", err) + } + }) + + t.Run("Delete returns nil when bridge reports Ok", func(t *testing.T) { + bridge := newFakeBridge() + bridge.deleteStatus = app_provided_data.SecureKvStatusOk + storage := securekv.NewSecureKeyValueStorage(bridge) + + if err := storage.Delete(ctx, "key"); err != nil { + t.Fatalf("Delete() error = %v, want nil on Ok status", err) + } + }) + + t.Run("Delete surfaces typed error when bridge returns non-Ok status", func(t *testing.T) { + bridge := newFakeBridge() + bridge.deleteStatus = app_provided_data.SecureKvStatusStorageFailed + storage := securekv.NewSecureKeyValueStorage(bridge) + + err := storage.Delete(ctx, "key") + var target *securekv.StorageFailedError + if !errors.As(err, &target) { + t.Fatalf("expected StorageFailedError, got %v", err) + } + }) + + t.Run("Wipe returns nil when bridge reports Ok", func(t *testing.T) { + bridge := newFakeBridge() + bridge.wipeStatus = app_provided_data.SecureKvStatusOk + storage := securekv.NewSecureKeyValueStorage(bridge) + + if err := storage.Wipe(ctx); err != nil { + t.Fatalf("Wipe() error = %v, want nil on Ok status", err) + } + }) + + t.Run("Wipe surfaces typed error when bridge returns non-Ok status", func(t *testing.T) { + bridge := newFakeBridge() + bridge.wipeStatus = app_provided_data.SecureKvStatusStorageFailed + storage := securekv.NewSecureKeyValueStorage(bridge) + + err := storage.Wipe(ctx) + var target *securekv.StorageFailedError + if !errors.As(err, &target) { + t.Fatalf("expected StorageFailedError, got %v", err) + } + }) + + t.Run("Unknown status surfaces as StorageFailedError", func(t *testing.T) { + bridge := newFakeBridge() + // putStatus left at zero value (SecureKvStatusUnknown) on purpose. + storage := securekv.NewSecureKeyValueStorage(bridge) + + err := storage.Put(ctx, "key", []byte("value")) + var target *securekv.StorageFailedError + if !errors.As(err, &target) { + t.Fatalf("expected StorageFailedError, got %v", err) + } + }) + + t.Run("Put surfaces StorageFailedError when bridge returns nil response", func(t *testing.T) { + bridge := newFakeBridge() + bridge.putNilResp = true + storage := securekv.NewSecureKeyValueStorage(bridge) + + err := storage.Put(ctx, "key", []byte("value")) + var target *securekv.StorageFailedError + if !errors.As(err, &target) { + t.Fatalf("expected StorageFailedError, got %v", err) + } + }) + + t.Run("Delete surfaces StorageFailedError when bridge returns nil response", + func(t *testing.T) { + bridge := newFakeBridge() + bridge.deleteNilResp = true + storage := securekv.NewSecureKeyValueStorage(bridge) + + err := storage.Delete(ctx, "key") + var target *securekv.StorageFailedError + if !errors.As(err, &target) { + t.Fatalf("expected StorageFailedError, got %v", err) + } + }) + + t.Run("Wipe surfaces StorageFailedError when bridge returns nil response", func(t *testing.T) { + bridge := newFakeBridge() + bridge.wipeNilResp = true + storage := securekv.NewSecureKeyValueStorage(bridge) + + err := storage.Wipe(ctx) + var target *securekv.StorageFailedError + if !errors.As(err, &target) { + t.Fatalf("expected StorageFailedError, got %v", err) + } + }) +} + +func TestWithSecret(t *testing.T) { + ctx := t.Context() + + t.Run("invokes fn with plaintext fetched from bridge", func(t *testing.T) { + bridge := newFakeBridge() + bridge.getResp = okResp([]byte("plaintext")) + storage := securekv.NewSecureKeyValueStorage(bridge) + + secret, _ := storage.Get(ctx, "key") + var got []byte + + err := secret.WithSecret(func(b []byte) error { + got = append([]byte(nil), b...) + return nil + }) + + if err != nil { + t.Fatalf("WithSecret() error = %v", err) + } + if string(got) != "plaintext" { + t.Fatalf("fn received %q, want %q", got, "plaintext") + } + }) + + t.Run("clears the buffer on success", func(t *testing.T) { + bridge := newFakeBridge() + bridge.getResp = okResp([]byte("plaintext")) + storage := securekv.NewSecureKeyValueStorage(bridge) + + secret, _ := storage.Get(ctx, "key") + var captured []byte + + _ = secret.WithSecret(func(b []byte) error { + captured = b + return nil + }) + + assertZeroed(t, captured) + }) + + t.Run("clears the buffer on error", func(t *testing.T) { + bridge := newFakeBridge() + bridge.getResp = okResp([]byte("plaintext")) + storage := securekv.NewSecureKeyValueStorage(bridge) + + secret, _ := storage.Get(ctx, "key") + boom := errors.New("boom") + var captured []byte + + err := secret.WithSecret(func(b []byte) error { + captured = b + return boom + }) + + if !errors.Is(err, boom) { + t.Fatalf("expected boom, got %v", err) + } + assertZeroed(t, captured) + }) + + t.Run("clears the buffer on panic", func(t *testing.T) { + bridge := newFakeBridge() + bridge.getResp = okResp([]byte("plaintext")) + storage := securekv.NewSecureKeyValueStorage(bridge) + + secret, _ := storage.Get(ctx, "key") + var captured []byte + + func() { + defer func() { _ = recover() }() + _ = secret.WithSecret(func(b []byte) error { + captured = b + panic("boom") + }) + }() + + assertZeroed(t, captured) + }) + + t.Run("clears the buffer when bridge returns non-Ok status with bytes", func(t *testing.T) { + bridge := newFakeBridge() + // Contract violation: non-Ok status carrying plaintext. WithSecret + // must still wipe the buffer defensively. + bridge.getResp = &app_provided_data.SecureKvGetResponse{ + Value: []byte("leaked-plaintext"), + StatusCode: app_provided_data.SecureKvStatusNotFound, + } + storage := securekv.NewSecureKeyValueStorage(bridge) + + secret, _ := storage.Get(ctx, "key") + err := secret.WithSecret(func(_ []byte) error { + t.Fatal("fn should not be called when status is not Ok") + return nil + }) + + var target *securekv.NotFoundError + if !errors.As(err, &target) { + t.Fatalf("expected NotFoundError, got %v", err) + } + assertZeroed(t, bridge.lastGetReturned.Value) + }) + + t.Run("WithSecret surfaces StorageFailedError when bridge returns nil response", + func(t *testing.T) { + bridge := newFakeBridge() + bridge.getNilResp = true + storage := securekv.NewSecureKeyValueStorage(bridge) + + secret, _ := storage.Get(ctx, "key") + err := secret.WithSecret(func(_ []byte) error { + t.Fatal("fn should not be called on nil response") + return nil + }) + var target *securekv.StorageFailedError + if !errors.As(err, &target) { + t.Fatalf("expected StorageFailedError, got %v", err) + } + }) + + t.Run("multiple WithSecret on the same Secret fetch fresh each time", func(t *testing.T) { + bridge := newFakeBridge() + bridge.getResp = okResp([]byte("plaintext")) + storage := securekv.NewSecureKeyValueStorage(bridge) + + secret, _ := storage.Get(ctx, "key") + + for i := range 3 { + var got []byte + err := secret.WithSecret(func(b []byte) error { + got = append([]byte(nil), b...) + return nil + }) + if err != nil { + t.Fatalf("iteration %d: WithSecret error = %v", i, err) + } + if string(got) != "plaintext" { + t.Fatalf("iteration %d: got %q, want plaintext", i, got) + } + } + }) +} + +func okResp(value []byte) *app_provided_data.SecureKvGetResponse { + return &app_provided_data.SecureKvGetResponse{ + Value: value, + StatusCode: app_provided_data.SecureKvStatusOk, + } +} + +func assertZeroed(t *testing.T, buf []byte) { + t.Helper() + if len(buf) == 0 { + t.Fatal("captured buffer is empty; nothing to verify") + } + for i, b := range buf { + if b != 0 { + t.Fatalf("byte %d not zeroed: got %x", i, b) + } + } +} diff --git a/libwallet/data/security_cards/protocol_repository.go b/libwallet/data/security_cards/protocol_repository.go new file mode 100644 index 00000000..7da7c2a4 --- /dev/null +++ b/libwallet/data/security_cards/protocol_repository.go @@ -0,0 +1,70 @@ +package security_cards + +import ( + "fmt" + "time" + + "github.com/muun/libwallet/domain/model/security_card" + "github.com/muun/libwallet/storage" +) + +// ProtocolRepository owns the client-side state of the Security Cards +// protocol. +// Backed by KeyValueStorage; callers never touch the raw KV API. +type ProtocolRepository struct { + kv *storage.KeyValueStorage +} + +func NewProtocolRepository(kv *storage.KeyValueStorage) *ProtocolRepository { + return &ProtocolRepository{kv: kv} +} + +// SavePendingPairChallenge persists the challenge atomically. The +// timestamp is captured here so callers don't need to coordinate it. +func (r *ProtocolRepository) SavePendingPairChallenge(serverPubKeyInHex string) error { + var items = make(map[string]any) + items[storage.KeyPendingPairChallengeServerPubKeyInHex] = serverPubKeyInHex + items[storage.KeyPendingPairChallengeReceivedAtUnixMillis] = time.Now().UnixMilli() + if err := r.kv.SaveBatch(items); err != nil { + return fmt.Errorf("saving pending pair challenge: %w", err) //nolint:forbidigo // TODO: use errors.Errorf from go-errors for stack traces + } + return nil +} + +// LoadPendingPairChallenge returns the persisted challenge, or nil if it +// is absent. The two backing keys are written together via SaveBatch in +// SavePendingPairChallenge, so they are either both present or both +// absent. A nil return means "no pending challenge" — callers must +// trigger a refresh before proceeding. +func (r *ProtocolRepository) LoadPendingPairChallenge() (*security_card.PendingPairChallenge, error) { //nolint:lll // TODO: line too long + keys := []string{ + storage.KeyPendingPairChallengeServerPubKeyInHex, + storage.KeyPendingPairChallengeReceivedAtUnixMillis, + } + items, err := r.kv.GetBatch(keys) + if err != nil { + return nil, fmt.Errorf("loading pending pair challenge: %w", err) //nolint:forbidigo // TODO: use errors.Errorf from go-errors for stack traces + } + + serverPubKeyHex := items[storage.KeyPendingPairChallengeServerPubKeyInHex] + receivedAtMillis := items[storage.KeyPendingPairChallengeReceivedAtUnixMillis] + + if serverPubKeyHex == nil || receivedAtMillis == nil { + return nil, nil + } + return &security_card.PendingPairChallenge{ //nolint:muun_model_constructor // TODO: use NewPendingPairChallenge instead of struct literal + ServerPubKeyInHex: serverPubKeyHex.(string), + ReceivedAtInMillis: receivedAtMillis.(int64), + }, nil +} + +// ClearPendingPairChallenge wipes the persisted challenge once it has +// been consumed (success) or the attempt failed. Errors are ignored on +// purpose: if Delete fails, the next SavePendingPairChallenge call will +// overwrite the keys anyway. +func (r *ProtocolRepository) ClearPendingPairChallenge() { + // TODO: DeleteBatch or a transaction should be used here to + // delete all items at the same time. It is not a bug now. + _ = r.kv.Delete(storage.KeyPendingPairChallengeServerPubKeyInHex) + _ = r.kv.Delete(storage.KeyPendingPairChallengeReceivedAtUnixMillis) +} diff --git a/libwallet/docker/builder.Dockerfile b/libwallet/docker/builder.Dockerfile index 69194d75..9252611b 100644 --- a/libwallet/docker/builder.Dockerfile +++ b/libwallet/docker/builder.Dockerfile @@ -1,7 +1,8 @@ FROM golang:1.24-bullseye -ENV STATICCHECK_VERSION=2025.1.1 +ENV GOLANGCI_LINT_VERSION=v2.8.0 -# install staticcheck (linter for go projects) -RUN go install "honnef.co/go/tools/cmd/staticcheck@${STATICCHECK_VERSION}" \ - && cp /go/bin/staticcheck /usr/local/bin/staticcheck \ No newline at end of file +RUN go install "github.com/golangci/golangci-lint/v2/cmd/golangci-lint@${GOLANGCI_LINT_VERSION}" \ + && cp /go/bin/golangci-lint /usr/local/bin/golangci-lint \ + && go install golang.org/x/tools/cmd/goimports@v0.40.0 \ + && cp /go/bin/goimports /usr/local/bin/goimports \ No newline at end of file diff --git a/libwallet/domain/action/challenge_keys/finish_challenge_setup_action.go b/libwallet/domain/action/challenge_keys/finish_challenge_setup_action.go index 5a8faba8..81064ae2 100644 --- a/libwallet/domain/action/challenge_keys/finish_challenge_setup_action.go +++ b/libwallet/domain/action/challenge_keys/finish_challenge_setup_action.go @@ -2,12 +2,14 @@ package challenge_keys import ( "encoding/hex" + "log/slog" + "github.com/btcsuite/btcd/btcec/v2" + "github.com/muun/libwallet/domain/action/recovery" "github.com/muun/libwallet/service" "github.com/muun/libwallet/service/model" "github.com/muun/libwallet/storage" - "log/slog" ) type FinishChallengeSetupAction struct { @@ -30,12 +32,12 @@ func NewFinishChallengeSetupAction( func (action *FinishChallengeSetupAction) Run(recoveryCodePublicKey *btcec.PublicKey) error { - challengeSetupVerifyJson := model.ChallengeSetupVerifyJson{ + challengeSetupVerifyJson := model.ChallengeSetupVerifyJson{ //nolint:staticcheck // TODO: var challengeSetupVerifyJson should be challengeSetupVerifyJSON ChallengeType: "RECOVERY_CODE", PublicKey: hex.EncodeToString(recoveryCodePublicKey.SerializeCompressed()), } - verifiableMuunKeyJson, err := action.houstonService.ChallengeSetupFinishWithVerifiableMuunKey( + verifiableMuunKeyJson, err := action.houstonService.ChallengeSetupFinishWithVerifiableMuunKey( //nolint:staticcheck // TODO: var verifiableMuunKeyJson should be verifiableMuunKeyJSON challengeSetupVerifyJson, ) if err != nil { diff --git a/libwallet/domain/action/challenge_keys/finish_challenge_setup_action_test.go b/libwallet/domain/action/challenge_keys/finish_challenge_setup_action_test.go new file mode 100644 index 00000000..66646b00 --- /dev/null +++ b/libwallet/domain/action/challenge_keys/finish_challenge_setup_action_test.go @@ -0,0 +1,99 @@ +package challenge_keys + +import ( + "encoding/hex" + "testing" + + "github.com/go-errors/errors" + + "github.com/muun/libwallet/domain/action/recovery" + "github.com/muun/libwallet/internal/testutils" + "github.com/muun/libwallet/storage" +) + +func TestFinishChallengeSetupAction(t *testing.T) { + + t.Run("calls houston and stores encrypted muun key", func(t *testing.T) { + // Setup + keys := testutils.GenerateTestKeys() + kvStorage := testutils.NewTestKeyValueStorage(t) + keyProvider := testutils.NewMockKeyProvider(keys) + vmkJSON := testutils.BuildVerifiableMuunKeyJson( + keys, + true, + ) + houston := &testutils.MockHoustonService{ + FinishWithVerifiableResult: *vmkJSON, + } + computeAction := recovery.NewComputeAndStoreEncryptedMuunKeyAction(kvStorage, keyProvider) + action := NewFinishChallengeSetupAction(houston, kvStorage, computeAction) + + // Test + err := action.Run(keys.RecoveryCodeKey.PubKey()) + if err != nil { + t.Fatalf("Run() error = %v", err) + } + + // Verify houston was called with correct challenge type and public key + if houston.CapturedChallengeSetupVerify == nil { + t.Fatal("expected Houston to be called") + } + + wantType := "RECOVERY_CODE" + if houston.CapturedChallengeSetupVerify.ChallengeType != wantType { + t.Fatalf("ChallengeType = %s, want %s", + houston.CapturedChallengeSetupVerify.ChallengeType, wantType) + } + + wantPubKey := hex.EncodeToString(keys.RecoveryCodeKey.PubKey().SerializeCompressed()) + if houston.CapturedChallengeSetupVerify.PublicKey != wantPubKey { + t.Fatalf("PublicKey = %s, want %s", + houston.CapturedChallengeSetupVerify.PublicKey, wantPubKey) + } + + // Verify encrypted muun key was stored + got, err := kvStorage.Get(storage.VerifiedEncryptedMuunKey) + if err != nil { + t.Fatalf("Get() error = %v", err) + } + if got == nil { + t.Fatal("expected verified encrypted muun key to be stored") + } + }) + + t.Run("swallows verification error", func(t *testing.T) { + // Setup + keys := testutils.GenerateTestKeys() + kvStorage := testutils.NewTestKeyValueStorage(t) + keyProvider := testutils.NewMockKeyProvider(keys) + houston := &testutils.MockHoustonService{ + FinishWithVerifiableResult: testutils.BuildInvalidVerifiableMuunKeyJson(), + } + computeAction := recovery.NewComputeAndStoreEncryptedMuunKeyAction(kvStorage, keyProvider) + action := NewFinishChallengeSetupAction(houston, kvStorage, computeAction) + + // Test — should NOT return error even though verification fails + err := action.Run(keys.RecoveryCodeKey.PubKey()) + if err != nil { + t.Fatalf("Run() error = %v, expected error to be swallowed", err) + } + }) + + t.Run("propagates houston error", func(t *testing.T) { + // Setup + keys := testutils.GenerateTestKeys() + kvStorage := testutils.NewTestKeyValueStorage(t) + keyProvider := testutils.NewMockKeyProvider(keys) + houston := &testutils.MockHoustonService{ + FinishWithVerifiableErr: errors.New("houston network error"), + } + computeAction := recovery.NewComputeAndStoreEncryptedMuunKeyAction(kvStorage, keyProvider) + action := NewFinishChallengeSetupAction(houston, kvStorage, computeAction) + + // Test + err := action.Run(keys.RecoveryCodeKey.PubKey()) + if err == nil { + t.Fatal("expected Houston error to propagate") + } + }) +} diff --git a/libwallet/domain/action/challenge_keys/start_challenge_setup_action.go b/libwallet/domain/action/challenge_keys/start_challenge_setup_action.go index bd702c9d..3784fa38 100644 --- a/libwallet/domain/action/challenge_keys/start_challenge_setup_action.go +++ b/libwallet/domain/action/challenge_keys/start_challenge_setup_action.go @@ -9,10 +9,14 @@ type StartChallengeSetupAction struct { HoustonService service.HoustonService } -func NewStartChallengeSetupAction(houstonService service.HoustonService) *StartChallengeSetupAction { +func NewStartChallengeSetupAction( + houstonService service.HoustonService, +) *StartChallengeSetupAction { return &StartChallengeSetupAction{houstonService} } -func (action *StartChallengeSetupAction) Run(challengeSetupJson model.ChallengeSetupJson) (model.SetupChallengeResponseJson, error) { +func (action *StartChallengeSetupAction) Run( + challengeSetupJson model.ChallengeSetupJson, //nolint:staticcheck // TODO: method parameter challengeSetupJson should be challengeSetupJSON +) (model.SetupChallengeResponseJson, error) { return action.HoustonService.ChallengeKeySetupStart(challengeSetupJson) } diff --git a/libwallet/domain/action/debug/zip_data_dir_action.go b/libwallet/domain/action/debug/zip_data_dir_action.go new file mode 100644 index 00000000..4a16ca0a --- /dev/null +++ b/libwallet/domain/action/debug/zip_data_dir_action.go @@ -0,0 +1,70 @@ +package debug + +import ( + "archive/zip" + "io" + "os" + "path/filepath" +) + +// ZipDataDirAction zips all regular files in the libwallet data directory +// into a single archive for debug/diagnostic purposes. +type ZipDataDirAction struct { + dataDir string +} + +func NewZipDataDirAction(dataDir string) *ZipDataDirAction { + return &ZipDataDirAction{dataDir: dataDir} +} + +// Run creates a zip archive at outputPath containing all regular files +// in the data directory. Subdirectories are skipped. +func (a *ZipDataDirAction) Run(outputPath string) error { + if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil { + return err + } + + entries, err := os.ReadDir(a.dataDir) + if err != nil { + return err + } + + outFile, err := os.Create(outputPath) + if err != nil { + return err + } + defer outFile.Close() + + zw := zip.NewWriter(outFile) + defer zw.Close() + + for _, entry := range entries { + if !entry.Type().IsRegular() { + continue + } + + name := entry.Name() + err := addFileToZip(zw, filepath.Join(a.dataDir, name), name) + if err != nil { + return err + } + } + + return nil +} + +func addFileToZip(zw *zip.Writer, filePath, entryName string) error { + f, err := os.Open(filePath) + if err != nil { + return err + } + defer f.Close() + + w, err := zw.Create(entryName) + if err != nil { + return err + } + + _, err = io.Copy(w, f) + return err +} diff --git a/libwallet/domain/action/debug/zip_data_dir_action_test.go b/libwallet/domain/action/debug/zip_data_dir_action_test.go new file mode 100644 index 00000000..fd2c63d5 --- /dev/null +++ b/libwallet/domain/action/debug/zip_data_dir_action_test.go @@ -0,0 +1,104 @@ +package debug_test + +import ( + "archive/zip" + "io" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/muun/libwallet/domain/action/debug" +) + +func TestZipDataDirAction_Run(t *testing.T) { + // Setup: create a data dir with some files and a subdirectory. + dataDir := t.TempDir() + + writeFile := func(name string, content []byte) { + t.Helper() + + require.NoError(t, os.WriteFile( + filepath.Join(dataDir, name), content, 0o644, + )) + } + writeFile("wallet.db", []byte("db-content")) + writeFile("wallet.db-wal", []byte("wal-content")) + require.NoError(t, os.Mkdir(filepath.Join(dataDir, "subdir"), 0o755)) + writeFile(filepath.Join("subdir", "nested.txt"), []byte("nested")) + + outputPath := filepath.Join(t.TempDir(), "out", "data.zip") + action := debug.NewZipDataDirAction(dataDir) + + require.NoError(t, action.Run(outputPath)) + + // Verify zip contents. + r, err := zip.OpenReader(outputPath) + require.NoError(t, err) + defer r.Close() + + files := make(map[string]string) + for _, f := range r.File { + rc, err := f.Open() + require.NoError(t, err) + buf, err := io.ReadAll(rc) + require.NoError(t, err) + rc.Close() + files[f.Name] = string(buf) + } + + require.Len(t, files, 2) + require.Equal(t, "db-content", files["wallet.db"]) + require.Equal(t, "wal-content", files["wallet.db-wal"]) +} + +func TestZipDataDirAction_EmptyDir(t *testing.T) { + dataDir := t.TempDir() + outputPath := filepath.Join(t.TempDir(), "empty.zip") + action := debug.NewZipDataDirAction(dataDir) + + require.NoError(t, action.Run(outputPath)) + + r, err := zip.OpenReader(outputPath) + require.NoError(t, err) + defer r.Close() + require.Empty(t, r.File) +} + +func TestZipDataDirAction_OverwritesExistingZip(t *testing.T) { + dataDir := t.TempDir() + require.NoError(t, os.WriteFile( + filepath.Join(dataDir, "wallet.db"), []byte("new-data"), 0o644, + )) + + outputPath := filepath.Join(t.TempDir(), "data.zip") + + // Write a stale zip first (simulates a previous run). + require.NoError(t, os.WriteFile(outputPath, []byte("stale"), 0o644)) + + action := debug.NewZipDataDirAction(dataDir) + require.NoError(t, action.Run(outputPath)) + + r, err := zip.OpenReader(outputPath) + require.NoError(t, err) + defer r.Close() + + require.Len(t, r.File, 1) + rc, err := r.File[0].Open() + require.NoError(t, err) + buf, err := io.ReadAll(rc) + require.NoError(t, err) + rc.Close() + + require.Equal(t, "wallet.db", r.File[0].Name) + require.Equal(t, "new-data", string(buf)) +} + +func TestZipDataDirAction_NonExistentDir(t *testing.T) { + action := debug.NewZipDataDirAction(filepath.Join(t.TempDir(), "does-not-exist")) + outputPath := filepath.Join(t.TempDir(), "out.zip") + + err := action.Run(outputPath) + require.Error(t, err) +} diff --git a/libwallet/domain/action/diagnostic_mode_reports/submit_diagnostic_action.go b/libwallet/domain/action/diagnostic_mode_reports/submit_diagnostic_action.go index 042e7983..213680d6 100644 --- a/libwallet/domain/action/diagnostic_mode_reports/submit_diagnostic_action.go +++ b/libwallet/domain/action/diagnostic_mode_reports/submit_diagnostic_action.go @@ -13,9 +13,12 @@ func NewSubmitDiagnosticAction(service service.HoustonService) *SubmitDiagnostic return &SubmitDiagnosticAction{service} } -func (action SubmitDiagnosticAction) Run(sessionId string, debugLog string) error { +func (action SubmitDiagnosticAction) Run( + sessionID string, + debugLog string, +) error { return action.houstonService.SubmitDiagnosticsScanData(model.DiagnosticScanDataJson{ - ScanId: sessionId, + ScanId: sessionID, Logs: debugLog, }) } diff --git a/libwallet/domain/action/emergency_kit/generate_emergency_kit_pdf_action.go b/libwallet/domain/action/emergency_kit/generate_emergency_kit_pdf_action.go index b7cb7b5b..1dca5789 100644 --- a/libwallet/domain/action/emergency_kit/generate_emergency_kit_pdf_action.go +++ b/libwallet/domain/action/emergency_kit/generate_emergency_kit_pdf_action.go @@ -2,18 +2,23 @@ package emergency_kit import ( "encoding/json" - "fmt" + "os" + "path/filepath" + "time" + + "github.com/go-errors/errors" + "github.com/muun/libwallet" "github.com/muun/libwallet/domain/model/emergency_kit/go_render" "github.com/muun/libwallet/emergencykit" - "os" - "path/filepath" ) // GeneratedEKPDF is a model including the verificationCode and version type GeneratedEKPDF struct { VerificationCode string Version int + // Profiling holds per-stage timings and allocation stats for this render. + Profiling *go_render.RenderProfiling } // GenerateEmergencyKitPDFAction action for generating emergency kit PDFs @@ -36,7 +41,7 @@ func (a *GenerateEmergencyKitPDFAction) Run( outputDir := filepath.Dir(outputPath) err := os.MkdirAll(outputDir, 0755) if err != nil { - return nil, fmt.Errorf("failed to create directory: %w", err) + return nil, errors.Errorf("failed to create directory: %w", err) } ekInput := &emergencykit.Input{ @@ -58,14 +63,19 @@ func (a *GenerateEmergencyKitPDFAction) Run( return nil, err } + startEmbed := time.Now() metadata, err := libwallet.CreateEmergencyKitMetadata(ekParams) if err != nil { - return nil, fmt.Errorf("GenerateEkHtml failed to create metadata: %w", err) + return nil, errors.Errorf("GenerateEkHtml failed to create metadata: %w", err) } metadataBytes, err := json.Marshal(&metadata) if err != nil { - return nil, fmt.Errorf("GenerateEkHtml failed to marshal %s: %w", string(metadataBytes), err) + return nil, errors.Errorf( + "GenerateEkHtml failed to marshal %s: %w", + string(metadataBytes), + err, + ) } err = os.Remove(outputPath) @@ -84,9 +94,12 @@ func (a *GenerateEmergencyKitPDFAction) Run( return nil, err } + result.Profiling.EmbedMetadataMs = time.Since(startEmbed).Milliseconds() + return &GeneratedEKPDF{ VerificationCode: result.VerificationCode, Version: result.Version, + Profiling: result.Profiling, }, nil } diff --git a/libwallet/domain/action/lightning/chain_status.go b/libwallet/domain/action/lightning/chain_status.go new file mode 100644 index 00000000..da4f1070 --- /dev/null +++ b/libwallet/domain/action/lightning/chain_status.go @@ -0,0 +1,64 @@ +package lightning + +import ( + model "github.com/muun/libwallet/domain/model/lightning" + electrum "github.com/muun/libwallet/electrum/v2" +) + +const ( + unknownTx = -1 +) + +// chainStatus is a snapshot of the bitcoin chain state. +// Saves the current chain height and accepted transaction's status. +type chainStatus struct { + currentHeight int + txStatus map[string]txStatus +} + +type txStatus struct { + confirmations int + // We can have more fields in the future (e.g. fee) +} + +// newChainStatus creates an empty chain status for the current chain height. +func newChainStatus(currentHeight int) *chainStatus { + return &chainStatus{ + currentHeight: currentHeight, + txStatus: make(map[string]txStatus), + } +} + +// addScriptHashHistory saves all transactions from a script hash history. +func (s *chainStatus) addScriptHashHistory(history []electrum.ScriptHashHistoryEntry) { + for _, entry := range history { + if _, ok := s.txStatus[entry.TxHash]; ok { + continue + } + + height := int(entry.Height) + confirmations := s.currentHeight - int(entry.Height) + 1 + if height <= 0 { + confirmations = 0 + } + s.txStatus[entry.TxHash] = txStatus{ + confirmations: confirmations, + } + } +} + +// isPresent checks if the tx is currently accepted by the chain, meaning the tx is confirmed +// or in the mempool. +func (s *chainStatus) isPresent(tx model.Transaction) bool { + _, ok := s.txStatus[tx.GetID()] + return ok +} + +// getConfirmations returns tx's current confirmation count. +// Unconfirmed txs return 0. Txs not known return unknownTx. +func (s *chainStatus) getConfirmations(tx model.Transaction) int { + if status, ok := s.txStatus[tx.GetID()]; ok { + return status.confirmations + } + return unknownTx +} diff --git a/libwallet/domain/action/lightning/complete_incoming_htlc_batch.go b/libwallet/domain/action/lightning/complete_incoming_htlc_batch.go new file mode 100644 index 00000000..6e167dae --- /dev/null +++ b/libwallet/domain/action/lightning/complete_incoming_htlc_batch.go @@ -0,0 +1,39 @@ +package lightning + +import ( + "context" + "time" + + data "github.com/muun/libwallet/data/lightning" + "github.com/muun/libwallet/platform/concurrency/memlock" +) + +// CompleteIncomingHTLCBatchAction stores the recall revocation transaction for a +// given recall, marking the HTLC as revoked. +type CompleteIncomingHTLCBatchAction interface { + Run(ctx context.Context, batchID string) error +} + +type completeIncomingHTLCAction struct { + locker *memlock.NamedLocker + batchRepository data.IncomingHTLCBatchRepository +} + +func NewCompleteIncomingHTLCAction( + locker *memlock.NamedLocker, + batchRepository data.IncomingHTLCBatchRepository, +) CompleteIncomingHTLCBatchAction { + return &completeIncomingHTLCAction{locker: locker, batchRepository: batchRepository} +} + +func (a *completeIncomingHTLCAction) Run(ctx context.Context, batchID string) error { + lock, err := a.locker.Acquire(ctx, "Enforcer:IncomingHTLCBatch:"+batchID, 200*time.Millisecond) + defer lock.Release() + if err != nil { + return err + } + + // The server has shared the recall revocations with us, this is the expected happy path final + // state and there's no longer a purpose for the client to persist the incoming HTLC batch. + return a.batchRepository.DeleteByID(batchID) +} diff --git a/libwallet/domain/action/lightning/cross_cutting_test.go b/libwallet/domain/action/lightning/cross_cutting_test.go new file mode 100644 index 00000000..08e5744b --- /dev/null +++ b/libwallet/domain/action/lightning/cross_cutting_test.go @@ -0,0 +1,93 @@ +package lightning_test + +import ( + "testing" + + "github.com/muun/libwallet/platform/test/testsuite" +) + +func TestCrossCuttingEnforcer_Integration(t *testing.T) { + testsuite.Run(t, new(CrossCuttingEnforcerTestSuite)) +} + +// CrossCuttingEnforcerTestSuite covers scenarios that involve both the recall and expiration paths. +type CrossCuttingEnforcerTestSuite struct { + EnforcerTestSuite +} + +// TestBatchDeletedWhenServerCooperates covers the happy path where the server cooperates: both +// branches' triggers are met (recall on-chain + preimages revealed), but Complete has already +// deleted the batch, so the enforcer must not act on either branch. +func (s *CrossCuttingEnforcerTestSuite) TestBatchDeletedWhenServerCooperates() { + data := s.registerBatchWith(1, 1) + recall := data.recalls[0] + enforcement := data.enforcements[0] + + s.NoError(s.revealPreimages.Run(s.Ctx, data.batch.UUID)) + + s.bitcoind.MustSendRawTransaction(recall.recallTx.ToRawHex()) + s.electrum.WaitForIndexedTx(s.Ctx, recall.recallTx.GetID()) + + s.NoError(s.completeIncomingHTLCBatch.Run(s.Ctx, data.batch.UUID)) + + s.NoError(s.publishEnforcer.Run(s.Ctx)) + + s.bitcoind.AssertTxIsNotBroadcasted(recall.recallIncomingSuccessTx.GetID()) + s.bitcoind.AssertTxIsNotBroadcasted(enforcement.enforcementTx.GetID()) + s.bitcoind.AssertTxIsNotBroadcasted(enforcement.enforcementIncomingSuccessTx.GetID()) + + batch, err := s.batchRepository.FindByID(data.batch.UUID) + s.NoError(err) + s.Nil(batch) +} + +// TestNoOpWithoutPreimage asserts the enforcer does not act on either branch when the preimage +// has not been revealed, even when both branches' triggers are met (recall on-chain). +func (s *CrossCuttingEnforcerTestSuite) TestNoOpWithoutPreimage() { + data := s.registerBatchWith(1, 1) + batch := data.batch + recall := data.recalls[0] + + s.bitcoind.MustSendRawTransaction(recall.recallTx.ToRawHex()) + s.electrum.WaitForIndexedTx(s.Ctx, recall.recallTx.GetID()) + + s.NoError(s.publishEnforcer.Run(s.Ctx)) + + s.bitcoind.AssertTxIsNotBroadcasted(recall.recallIncomingSuccessTx.GetID()) + s.assertEnforcementNoneBroadcasted(batch.UUID, 0) +} + +// TestRecallTakesPrecedenceAfterEnforcementBroadcast asserts that when a recall appears on-chain +// after the enforcer has already broadcasted the enforcement pair, the next run pivots to the +// recall branch. +func (s *CrossCuttingEnforcerTestSuite) TestRecallTakesPrecedenceAfterEnforcementBroadcast() { + data := s.registerBatchWith(1, 1) + batch := data.batch + recall := data.recalls[0] + enforcement := data.enforcements[0] + + s.NoError(s.revealPreimages.Run(s.Ctx, batch.UUID)) + + // First run: expiration branch broadcasts enforcement + EIS. + s.NoError(s.publishEnforcer.Run(s.Ctx)) + + s.assertRecallNoneBroadcasted(batch.UUID, 0) + s.assertEnforcementBothBroadcasted(batch.UUID, 0) + + // Drop enforcement (and EIS). + s.bitcoind.DropMempoolEntry(enforcement.enforcementTx.GetID()) + // Force electrum to sync new mempool + s.bitcoind.GenerateBlocks(1) + s.electrum.SyncWithBitcoind(s.Ctx, s.bitcoind) + + // Server broadcasts the recall after the enforcement has already been dropped. + s.bitcoind.MustSendRawTransaction(recall.recallTx.ToRawHex()) + s.electrum.WaitForIndexedTx(s.Ctx, recall.recallTx.GetID()) + + // Next enforcer run: recall branch takes precedence, RIS is broadcast, expiration branch is + // not re-entered. + s.NoError(s.publishEnforcer.Run(s.Ctx)) + + s.assertEnforcementNoneBroadcasted(batch.UUID, 0) + s.assertRecallBothBroadcasted(batch.UUID, 0) +} diff --git a/libwallet/domain/action/lightning/errors.go b/libwallet/domain/action/lightning/errors.go new file mode 100644 index 00000000..bd558c5b --- /dev/null +++ b/libwallet/domain/action/lightning/errors.go @@ -0,0 +1,91 @@ +package lightning + +import ( + "github.com/go-errors/errors" + + model "github.com/muun/libwallet/domain/model/lightning" +) + +// EnforcementBroadcastError is the base error for failed enforcer Tx broadcasts. +// It carries the transactions that could not be broadcast. +type EnforcementBroadcastError struct { + error + FailedTxs []model.Transaction +} + +type BroadcastRecallIncomingSuccessError struct { + *EnforcementBroadcastError +} + +func (e *BroadcastRecallIncomingSuccessError) Unwrap() error { + return e.EnforcementBroadcastError +} + +type BroadcastEnforcementIncomingSuccessError struct { + *EnforcementBroadcastError +} + +func (e *BroadcastEnforcementIncomingSuccessError) Unwrap() error { + return e.EnforcementBroadcastError +} + +type NoEnforcementBroadcastedError struct { + *EnforcementBroadcastError +} + +func (e *NoEnforcementBroadcastedError) Unwrap() error { + return e.EnforcementBroadcastError +} + +type SettledExternalSpendError struct{ error } + +func newSettledExternalSpendError(batch *model.IncomingHTLCBatch) *SettledExternalSpendError { + return &SettledExternalSpendError{ + errors.Errorf( + "batch %s: parent outputs irrevocably spent externally", + batch.UUID, + ), + } +} + +func newBroadcastRecallIncomingSuccessError( + err error, tx model.Transaction, +) error { + return &BroadcastRecallIncomingSuccessError{ + EnforcementBroadcastError: &EnforcementBroadcastError{ + error: errors.Errorf( + "broadcast recall incoming success transaction: %w", + err, + ), + FailedTxs: []model.Transaction{tx}, + }, + } +} + +func newBroadcastEnforcementIncomingSuccessError( + err error, tx model.Transaction, +) error { + return &BroadcastEnforcementIncomingSuccessError{ + EnforcementBroadcastError: &EnforcementBroadcastError{ + error: errors.Errorf( + "broadcast enforcement incoming success transaction: %w", + err, + ), + FailedTxs: []model.Transaction{tx}, + }, + } +} + +func newNoEnforcementBroadcastedError( + err error, txs []model.Transaction, +) error { + return &NoEnforcementBroadcastedError{ + EnforcementBroadcastError: &EnforcementBroadcastError{ + error: errors.Errorf( + "no enforcement transaction could be broadcasted: %w", + err, + ), + FailedTxs: txs, + }, + } +} diff --git a/libwallet/domain/action/lightning/expiration_branch_test.go b/libwallet/domain/action/lightning/expiration_branch_test.go new file mode 100644 index 00000000..bafb2ff1 --- /dev/null +++ b/libwallet/domain/action/lightning/expiration_branch_test.go @@ -0,0 +1,229 @@ +package lightning_test + +import ( + "testing" + + "github.com/go-errors/errors" + + "github.com/muun/libwallet/domain/action/lightning" + model "github.com/muun/libwallet/domain/model/lightning" + "github.com/muun/libwallet/platform/test/testsuite" +) + +func TestExpirationBranchEnforcer_Integration(t *testing.T) { + testsuite.Run(t, new(ExpirationBranchEnforcerTestSuite)) +} + +// ExpirationBranchEnforcerTestSuite covers enforcement scenarios via the expiration path. +type ExpirationBranchEnforcerTestSuite struct { + EnforcerTestSuite +} + +// TestSettlesAfterSixConfirmations covers the full expiration-branch lifecycle: +// enforcement + EIS broadcast, six confirmations, batch deleted. +func (s *ExpirationBranchEnforcerTestSuite) TestSettlesAfterSixConfirmations() { + data := s.registerBatchWith(1, 1) + batch := data.batch + enforcement := data.enforcements[0] + + s.NoError(s.revealPreimages.Run(s.Ctx, batch.UUID)) + + // First run after revealing preimages: enforcement + EIS broadcast immediately. + s.NoError(s.publishEnforcer.Run(s.Ctx)) + s.assertEnforcementBothBroadcasted(batch.UUID, 0) + + // Mine 2 blocks. Batch should still exist. + s.bitcoind.GenerateBlocks(2) + s.electrum.WaitForIndexedTx(s.Ctx, enforcement.enforcementIncomingSuccessTx.GetID()) + s.electrum.SyncWithBitcoind(s.Ctx, s.bitcoind) + + s.NoError(s.publishEnforcer.Run(s.Ctx)) + s.assertEnforcementBothBroadcasted(batch.UUID, 0) + + // Mine 4 more blocks to reach 6 total confirmations. + s.bitcoind.GenerateBlocks(4) + s.electrum.SyncWithBitcoind(s.Ctx, s.bitcoind) + + // Enforcer should detect >= 6 confirmations and remove the batch. + s.NoError(s.publishEnforcer.Run(s.Ctx)) + + batch, err := s.batchRepository.FindByID(data.batch.UUID) + s.NoError(err) + s.Nil(batch, "batch should be deleted once EIS settles") +} + +// TestRebroadcastsDroppedEnforcement covers the enforcement re-broadcast path. +func (s *ExpirationBranchEnforcerTestSuite) TestRebroadcastsDroppedEnforcement() { + data := s.registerBatchWith(1, 1) + batch := data.batch + enforcement := data.enforcements[0] + + s.NoError(s.revealPreimages.Run(s.Ctx, batch.UUID)) + + s.NoError(s.publishEnforcer.Run(s.Ctx)) + s.assertEnforcementBothBroadcasted(batch.UUID, 0) + + // Drop enforcement (and EIS). + s.bitcoind.DropMempoolEntry(enforcement.enforcementTx.GetID()) + // Force electrum to sync new mempool + s.bitcoind.GenerateBlocks(1) + s.electrum.SyncWithBitcoind(s.Ctx, s.bitcoind) + + s.assertEnforcementNoneBroadcasted(batch.UUID, 0) + + // Should detect the missing enforcement, re-broadcast it, then broadcast EIS. + s.NoError(s.publishEnforcer.Run(s.Ctx)) + s.assertEnforcementBothBroadcasted(batch.UUID, 0) +} + +// TestRebroadcastsDroppedEIS covers the EIS re-broadcast path. +func (s *ExpirationBranchEnforcerTestSuite) TestRebroadcastsDroppedEIS() { + data := s.registerBatchWith(1, 1) + enforcement := data.enforcements[0] + batch := data.batch + + s.NoError(s.revealPreimages.Run(s.Ctx, batch.UUID)) + + s.NoError(s.publishEnforcer.Run(s.Ctx)) + s.assertEnforcementBothBroadcasted(batch.UUID, 0) + + // Drop EIS. + s.bitcoind.DropMempoolEntry(enforcement.enforcementIncomingSuccessTx.GetID()) + // Force electrum to sync new mempool + s.bitcoind.GenerateBlocks(1) + s.electrum.SyncWithBitcoind(s.Ctx, s.bitcoind) + + s.bitcoind.AssertTxIsBroadcasted(enforcement.enforcementTx.GetID()) + s.bitcoind.AssertTxIsNotBroadcasted(enforcement.enforcementIncomingSuccessTx.GetID()) + + // Should detect the missing EIS and re-broadcast it. + s.NoError(s.publishEnforcer.Run(s.Ctx)) + s.assertEnforcementBothBroadcasted(batch.UUID, 0) +} + +// TestPivotsToSecondEnforcementAfterReplacement covers the enforcement replacement path. +// A higher-fee enforcement replaces the initial one via RBF, and the enforcer detects the +// replacement and broadcasts the second stage for the new enforcement. +func (s *ExpirationBranchEnforcerTestSuite) TestPivotsToSecondEnforcementAfterReplacement() { + lowFee := 0.0001 + highFee := 0.0005 + + fundingOutputs := s.createFundingOutputs(1) + recall, _ := s.buildRecall(fundingOutputs, testDefaultTxFee) + lowFeeEnforcement, _ := s.buildEnforcement(fundingOutputs, lowFee) + highFeeEnforcement, highFeeData := s.buildEnforcement(fundingOutputs, highFee) + + s.electrum.SyncWithBitcoind(s.Ctx, s.bitcoind) + + batch, err := s.registerIncomingHTLCBatch.Run( + []*model.IncomingHTLCRecall{recall}, + []*model.IncomingHTLCEnforcement{lowFeeEnforcement, highFeeEnforcement}, + ) + s.NoError(err) + + s.NoError(s.revealPreimages.Run(s.Ctx, batch.UUID)) + + // Enforcer broadcasts the first enforcement (lower fee) and its EIS. + s.NoError(s.publishEnforcer.Run(s.Ctx)) + s.assertEnforcementBothBroadcasted(batch.UUID, 0) + s.assertEnforcementNoneBroadcasted(batch.UUID, 1) + + // Higher-fee enforcement replaces the lower-fee one (and evicts its EIS). + s.bitcoind.MustSendRawTransaction(highFeeData.enforcementTx.ToRawHex()) + s.bitcoind.GenerateBlocks(1) + s.electrum.SyncWithBitcoind(s.Ctx, s.bitcoind) + + s.assertEnforcementNoneBroadcasted(batch.UUID, 0) + + // Enforcer detects the replacement and broadcasts EIS for the new enforcement. + s.NoError(s.publishEnforcer.Run(s.Ctx)) + s.assertEnforcementNoneBroadcasted(batch.UUID, 0) + s.assertEnforcementBothBroadcasted(batch.UUID, 1) +} + +// TestErrorsOnFailedEISRebroadcast covers the EIS dropped and failed re-broadcast path. +func (s *ExpirationBranchEnforcerTestSuite) TestErrorsOnFailedEISRebroadcast() { + data := s.registerBatchWith(1, 1) + batch := data.batch + enforcement := data.enforcements[0] + + s.NoError(s.revealPreimages.Run(s.Ctx, batch.UUID)) + + // enforcement + EIS broadcast + s.NoError(s.publishEnforcer.Run(s.Ctx)) + s.assertEnforcementBothBroadcasted(batch.UUID, 0) + + // Double spend EIS. + conflictHex := s.createConflictingTx( + enforcement.enforcementIncomingSuccessTx.ToRawHex(), + 0, + enforcement.firstStageOutputs, + 0.0002, + ) + s.bitcoind.MustSendRawTransaction(conflictHex) + s.bitcoind.GenerateBlocks(1) + s.electrum.SyncWithBitcoind(s.Ctx, s.bitcoind) + + // Enforcement stays on-chain, EIS rebroadcast fails (output already spent). + var eisErr *lightning.BroadcastEnforcementIncomingSuccessError + s.ErrorAs(s.publishEnforcer.Run(s.Ctx), &eisErr) + + s.assertBatchExists(batch.UUID) + s.bitcoind.AssertTxIsBroadcasted(enforcement.enforcementTx.GetID()) + s.bitcoind.AssertTxIsNotBroadcasted(enforcement.enforcementIncomingSuccessTx.GetID()) +} + +// TestErrorsWhenNoneCanBroadcast covers the error path when no enforcement can be broadcasted. +func (s *ExpirationBranchEnforcerTestSuite) TestErrorsWhenNoneCanBroadcast() { + data := s.registerBatchWith(1, 1) + batch := data.batch + enforcement := data.enforcements[0] + + s.NoError(s.revealPreimages.Run(s.Ctx, data.batch.UUID)) + + conflictHex := s.createConflictingTx( + enforcement.enforcementTx.ToRawHex(), + 0, + data.fundingOutputs, + 0.0005, + ) + s.bitcoind.MustSendRawTransaction(conflictHex) + s.bitcoind.GenerateBlocks(1) + s.electrum.SyncWithBitcoind(s.Ctx, s.bitcoind) + + var noEnfErr *lightning.NoEnforcementBroadcastedError + s.ErrorAs(s.publishEnforcer.Run(s.Ctx), &noEnfErr) + s.assertEnforcementNoneBroadcasted(batch.UUID, 0) +} + +// TestEarlyReturnOnNonElectrumBroadcastError verifies that a non-electrum error (e.g. connectivity) +// during enforcement broadcast causes an immediate return instead of trying remaining enforcements. +func (s *ExpirationBranchEnforcerTestSuite) TestEarlyReturnOnNonElectrumBroadcastError() { + data := s.registerBatchWith(1, 2) + batch := data.batch + + s.NoError(s.revealPreimages.Run(s.Ctx, batch.UUID)) + + enforcementRawHex := data.enforcements[0].enforcementTx.ToRawHex() + connectivityErr := errors.New("connection refused") + failClient := &electrumFailingClient{ + Client: s.electrum.Client, + rawTxsToBroadcast: map[string]error{enforcementRawHex: connectivityErr}, + } + failEnforcer := lightning.NewPublishEnforcerAction( + s.locker, + failClient, + s.batchRepository, + lightning.NewExternalTxCheckerAction(failClient), + ) + + err := failEnforcer.Run(s.Ctx) + s.Require().Error(err) + + // Should NOT be NoEnforcementBroadcastedError — the enforcer returned early on the + // non-electrum error instead of exhausting all enforcements. + var noEnfErr *lightning.NoEnforcementBroadcastedError + s.False(errors.As(err, &noEnfErr), + "expected direct error, not NoEnforcementBroadcastedError") + s.ErrorContains(err, "connection refused") +} diff --git a/libwallet/domain/action/lightning/external_spend_test.go b/libwallet/domain/action/lightning/external_spend_test.go new file mode 100644 index 00000000..5a6c0756 --- /dev/null +++ b/libwallet/domain/action/lightning/external_spend_test.go @@ -0,0 +1,265 @@ +package lightning_test + +import ( + "testing" + + "github.com/go-errors/errors" + + "github.com/muun/libwallet/domain/action/lightning" + "github.com/muun/libwallet/platform/test/testsuite" +) + +func TestExternalSpendEnforcer_Integration(t *testing.T) { + testsuite.Run(t, new(ExternalSpendEnforcerTestSuite)) +} + +// ExternalSpendEnforcerTestSuite cover the scenarios involved in deciding whether we should stop +// the enforcing process due to external Txs having double-spent the inputs used by our +// recall/enforcement Txs. +type ExternalSpendEnforcerTestSuite struct { + EnforcerTestSuite +} + +// TestBatchDeletedForSettledExternalTxDetected — settled competing Tx on the funding output. +func (s *ExternalSpendEnforcerTestSuite) TestBatchDeletedForSettledExternalTxDetected() { + data := s.registerBatchWith(1, 1) + batch := data.batch + recall := data.recalls[0] + enforcement := data.enforcements[0] + + s.NoError(s.revealPreimages.Run(s.Ctx, batch.UUID)) + + // A conflicting Tx double-spends the funding output, preventing both first-stage Txs. + conflictHex := s.createConflictingTx( + enforcement.enforcementTx.ToRawHex(), + 0, + data.fundingOutputs, + 0.0005, + ) + s.bitcoind.MustSendRawTransaction(conflictHex) + s.bitcoind.GenerateBlocks(6) + s.electrum.SyncWithBitcoind(s.Ctx, s.bitcoind) + + // With 6 confirmations the spend is irrevocable: batch deleted, no enforcer Txs broadcast. + var settledErr *lightning.SettledExternalSpendError + s.ErrorAs(s.publishEnforcer.Run(s.Ctx), &settledErr) + + foundBatch, err := s.batchRepository.FindByID(batch.UUID) + s.NoError(err) + s.Nil(foundBatch) + + s.bitcoind.AssertTxIsNotBroadcasted(recall.recallTx.GetID()) + s.bitcoind.AssertTxIsNotBroadcasted(recall.recallIncomingSuccessTx.GetID()) + s.bitcoind.AssertTxIsNotBroadcasted(enforcement.enforcementTx.GetID()) + s.bitcoind.AssertTxIsNotBroadcasted(enforcement.enforcementIncomingSuccessTx.GetID()) +} + +// TestBatchKeptForUnsettledExternalTxDetected — unsettled competing Tx on the funding output. +func (s *ExternalSpendEnforcerTestSuite) TestBatchKeptForUnsettledExternalTxDetected() { + data := s.registerBatchWith(1, 1) + batch := data.batch + recall := data.recalls[0] + enforcement := data.enforcements[0] + + s.NoError(s.revealPreimages.Run(s.Ctx, batch.UUID)) + + // A conflicting Tx double-spends the funding output, preventing both first-stage Txs. + conflictHex := s.createConflictingTx( + enforcement.enforcementTx.ToRawHex(), + 0, + data.fundingOutputs, + 0.0005, + ) + s.bitcoind.MustSendRawTransaction(conflictHex) + s.bitcoind.GenerateBlocks(3) + s.electrum.SyncWithBitcoind(s.Ctx, s.bitcoind) + + // Only 3 confirmations: spend could still be reversed, batch kept. + var noEnfErr *lightning.NoEnforcementBroadcastedError + s.ErrorAs(s.publishEnforcer.Run(s.Ctx), &noEnfErr) + foundBatch, err := s.batchRepository.FindByID(batch.UUID) + s.NoError(err) + s.NotNil(foundBatch) + + s.bitcoind.AssertTxIsNotBroadcasted(recall.recallTx.GetID()) + s.bitcoind.AssertTxIsNotBroadcasted(recall.recallIncomingSuccessTx.GetID()) + s.bitcoind.AssertTxIsNotBroadcasted(enforcement.enforcementTx.GetID()) + s.bitcoind.AssertTxIsNotBroadcasted(enforcement.enforcementIncomingSuccessTx.GetID()) +} + +// TestBatchDeletedForSettledRecallOutputSpend — settled competing Tx on a recall output. +func (s *ExternalSpendEnforcerTestSuite) TestBatchDeletedForSettledRecallOutputSpend() { + data := s.registerBatchWith(1, 1) + batch := data.batch + recall := data.recalls[0] + enforcement := data.enforcements[0] + + s.NoError(s.revealPreimages.Run(s.Ctx, batch.UUID)) + + // Recall Tx is on-chain; an external Tx then spends the output RIS would use. + s.bitcoind.MustSendRawTransaction(recall.recallTx.ToRawHex()) + s.bitcoind.GenerateBlocks(1) + + competingOutputs := s.createSpendTx(recall.firstStageOutputs[:1], 1, testDefaultTxFee) + s.bitcoind.MustSendRawTransaction(competingOutputs[0].txHex) + + // With 6 confirmations the spend is irrevocable: batch deleted. + s.bitcoind.GenerateBlocks(6) + s.electrum.SyncWithBitcoind(s.Ctx, s.bitcoind) + + var settledErr *lightning.SettledExternalSpendError + s.ErrorAs(s.publishEnforcer.Run(s.Ctx), &settledErr) + + s.bitcoind.AssertTxIsNotBroadcasted(recall.recallIncomingSuccessTx.GetID()) + s.bitcoind.AssertTxIsNotBroadcasted(enforcement.enforcementTx.GetID()) + s.bitcoind.AssertTxIsNotBroadcasted(enforcement.enforcementIncomingSuccessTx.GetID()) + + foundBatch, err := s.batchRepository.FindByID(batch.UUID) + s.NoError(err) + s.Nil(foundBatch) +} + +// TestBatchKeptForUnsettledRecallOutputSpend — unsettled competing Tx on a +// recall output (5 blocks). +func (s *ExternalSpendEnforcerTestSuite) TestBatchKeptForUnsettledRecallOutputSpend() { + data := s.registerBatchWith(1, 1) + batch := data.batch + recall := data.recalls[0] + + s.NoError(s.revealPreimages.Run(s.Ctx, batch.UUID)) + + // Broadcast the recall (first-stage) so its outputs exist on-chain. + s.bitcoind.MustSendRawTransaction(recall.recallTx.ToRawHex()) + s.bitcoind.GenerateBlocks(1) + + // A competing transaction spends the same first-stage output that the RIS would use. + competingOutputs := s.createSpendTx(recall.firstStageOutputs[:1], 1, testDefaultTxFee) + s.bitcoind.MustSendRawTransaction(competingOutputs[0].txHex) + + // Mine only 5 blocks — competing Tx is not yet settled. + s.bitcoind.GenerateBlocks(5) + s.electrum.SyncWithBitcoind(s.Ctx, s.bitcoind) + + // The enforcer enters the recall branch, which fails to broadcast the RIS because the output + // is already spent. The external Tx check then finds no settled transactions. + var noEnfErr *lightning.BroadcastRecallIncomingSuccessError + s.ErrorAs(s.publishEnforcer.Run(s.Ctx), &noEnfErr) + + // Batch is kept — the competing Tx might still be reversed by a reorg. + s.assertBatchExists(batch.UUID) +} + +// TestBatchDeletedForSettledEnforcementOutputSpend — settled competing Tx on an enforcement output. +func (s *ExternalSpendEnforcerTestSuite) TestBatchDeletedForSettledEnforcementOutputSpend() { + data := s.registerBatchWith(1, 1) + batch := data.batch + recall := data.recalls[0] + enforcement := data.enforcements[0] + + s.NoError(s.revealPreimages.Run(s.Ctx, batch.UUID)) + + // Enforcement Tx is on-chain; an external Tx then spends the output EIS would use. + s.bitcoind.MustSendRawTransaction(enforcement.enforcementTx.ToRawHex()) + s.bitcoind.GenerateBlocks(1) + + competingOutputs := s.createSpendTx(enforcement.firstStageOutputs[:1], 1, testDefaultTxFee) + s.bitcoind.MustSendRawTransaction(competingOutputs[0].txHex) + + // With 6 confirmations the spend is irrevocable: batch deleted. + s.bitcoind.GenerateBlocks(6) + s.electrum.SyncWithBitcoind(s.Ctx, s.bitcoind) + + var settledErr *lightning.SettledExternalSpendError + s.ErrorAs(s.publishEnforcer.Run(s.Ctx), &settledErr) + + s.bitcoind.AssertTxIsNotBroadcasted(recall.recallTx.GetID()) + s.bitcoind.AssertTxIsNotBroadcasted(recall.recallIncomingSuccessTx.GetID()) + s.bitcoind.AssertTxIsNotBroadcasted(enforcement.enforcementIncomingSuccessTx.GetID()) + + foundBatch, err := s.batchRepository.FindByID(batch.UUID) + s.NoError(err) + s.Nil(foundBatch) +} + +// TestBatchKeptForUnsettledEnforcementOutputSpend — unsettled competing Tx on an +// enforcement output (5 blocks). +func (s *ExternalSpendEnforcerTestSuite) TestBatchKeptForUnsettledEnforcementOutputSpend() { + data := s.registerBatchWith(1, 1) + batch := data.batch + enforcement := data.enforcements[0] + + s.NoError(s.revealPreimages.Run(s.Ctx, batch.UUID)) + + // Broadcast the recall (first-stage) so its outputs exist on-chain. + s.bitcoind.MustSendRawTransaction(enforcement.enforcementTx.ToRawHex()) + s.bitcoind.GenerateBlocks(1) + + // A competing transaction spends the same first-stage output that the EIS would use. + competingOutputs := s.createSpendTx(enforcement.firstStageOutputs[:1], 1, testDefaultTxFee) + s.bitcoind.MustSendRawTransaction(competingOutputs[0].txHex) + + // Mine only 5 blocks — competing Tx is not yet settled. + s.bitcoind.GenerateBlocks(5) + s.electrum.SyncWithBitcoind(s.Ctx, s.bitcoind) + + // The enforcer enters the expiration branch, which fails to broadcast the EIS because the output + // is already spent. The external Tx check then finds no settled transactions. + var noEnfErr *lightning.BroadcastEnforcementIncomingSuccessError + s.ErrorAs(s.publishEnforcer.Run(s.Ctx), &noEnfErr) + + // Batch is kept — the competing Tx might still be reversed by a reorg. + s.assertBatchExists(batch.UUID) +} + +// TestBatchKeptForSomeTxsDontProduceBroadcastError — checks that the batch is kept +// on those situations where a path produced a broadcast error (for example recall), but +// not the other (for example enforcement Txs). +func (s *ExternalSpendEnforcerTestSuite) TestBatchKeptForSomeTxsDontProduceBroadcastError() { + data := s.registerBatchWith(1, 1) + batch := data.batch + recall := data.recalls[0] + enforcement := data.enforcements[0] + + s.NoError(s.revealPreimages.Run(s.Ctx, batch.UUID)) + + // Recall Tx is on-chain; an external Tx then spends the output RIS would use. + s.bitcoind.MustSendRawTransaction(recall.recallTx.ToRawHex()) + s.bitcoind.GenerateBlocks(1) + + competingOutputs := s.createSpendTx(recall.firstStageOutputs[:1], 1, testDefaultTxFee) + s.bitcoind.MustSendRawTransaction(competingOutputs[0].txHex) + + // With 6 confirmations the spend is irrevocable but we should still keep the batch because enforcement + // broadcast will fail with a non-broadcast error. + s.bitcoind.GenerateBlocks(6) + s.electrum.SyncWithBitcoind(s.Ctx, s.bitcoind) + + // Use a dummy electrum client to be able to test scenarios that produce custom errors. In this case + // we want to fail the broadcast of an enforcement Txs with a non-electrum error, so that the batch + // is kept alive to keep retrying. + enforcementRawHex := enforcement.enforcementTx.ToRawHex() + enforcementForcedError := errors.New("dummy enforcer error") + electrumClient := &electrumFailingClient{ + Client: s.electrum.Client, + rawTxsToBroadcast: map[string]error{enforcementRawHex: enforcementForcedError}, + } + publishEnforcer := lightning.NewPublishEnforcerAction( + s.locker, + electrumClient, + s.batchRepository, + lightning.NewExternalTxCheckerAction(s.electrum.Client), + ) + + var broadcastError *lightning.BroadcastRecallIncomingSuccessError + publishEnforcerErr := publishEnforcer.Run(s.Ctx) + s.ErrorAs(publishEnforcerErr, &broadcastError) + s.ErrorIs(publishEnforcerErr, enforcementForcedError) + + s.bitcoind.AssertTxIsNotBroadcasted(recall.recallIncomingSuccessTx.GetID()) + s.bitcoind.AssertTxIsNotBroadcasted(enforcement.enforcementTx.GetID()) + s.bitcoind.AssertTxIsNotBroadcasted(enforcement.enforcementIncomingSuccessTx.GetID()) + + foundBatch, err := s.batchRepository.FindByID(batch.UUID) + s.NoError(err) + s.NotNil(foundBatch) +} diff --git a/libwallet/domain/action/lightning/external_tx_checker.go b/libwallet/domain/action/lightning/external_tx_checker.go new file mode 100644 index 00000000..56267dee --- /dev/null +++ b/libwallet/domain/action/lightning/external_tx_checker.go @@ -0,0 +1,329 @@ +package lightning + +import ( + "context" + "maps" + "slices" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/go-errors/errors" + + model "github.com/muun/libwallet/domain/model/lightning" + electrum "github.com/muun/libwallet/electrum/v2" + "github.com/muun/libwallet/platform/observability/slogctx" +) + +type ( + // ExternalTxCheckerAction returns true when it's not possible to + // broadcast all the txs to check due to their outputs having been + // irrevocably spent by external txs. + ExternalTxCheckerAction interface { + Run( + ctx context.Context, currentHeight int, + batch *model.IncomingHTLCBatch, txsToCheck []model.Transaction, + ) (bool, error) + } + + externalTxCheckerAction struct { + electrumClient electrum.Client + } + + checkerState struct { + txInputSets []map[wire.OutPoint]bool // input set per enforcer Tx + allOutpoints map[wire.OutPoint]bool // union of all txInputSets + scriptHashes []string // the scripthashes for all outpoints we care about + knownTxIDs map[string]bool // IDs for first/second stage Txs plus their parents + } +) + +func NewExternalTxCheckerAction(electrumClient electrum.Client) ExternalTxCheckerAction { + return &externalTxCheckerAction{ + electrumClient: electrumClient, + } +} + +func (c *externalTxCheckerAction) Run( + ctx context.Context, + currentHeight int, + batch *model.IncomingHTLCBatch, + txsToCheck []model.Transaction, +) (bool, error) { + // Collect Txs inputs, resolve their parent Txs, and compute scripthashes. + state, err := c.resolveOutpoints(ctx, batch, txsToCheck) + if err != nil || state == nil { + return false, err + } + + // If any Tx still has all its inputs unspent, it could still be broadcast. + // Note: this is an optimization to avoid executing more costly Electrum + // calls that fetch external TXs and verify whether they are settled. + allBlocked, err := c.noTxHasAllInputsUnspent(ctx, state) + if err != nil || !allBlocked { + return false, err + } + + // All outpoints are spent. Verify that the external spender has 6+ confirmations. + settled, err := c.hasIrrevocableExternalSpenders(ctx, currentHeight, state) + if err != nil || !settled { + return false, err + } + + slogctx.Info(ctx, + "All parent outputs irrevocably spent by external transactions, stopping enforcement.", + "batch", batch.UUID, + ) + return true, nil +} + +// resolveOutpoints builds the checkerState or returns nil when there is nothing to check. +// The checkerState will be used to determine whether we can continue the enforcement process. +func (c *externalTxCheckerAction) resolveOutpoints( + ctx context.Context, + batch *model.IncomingHTLCBatch, + txsToCheck []model.Transaction, +) (*checkerState, error) { + // Index all batch Txs so we can resolve intra-batch parent references locally instead of paying the cost + // of fetching them via Electrum. + var allBatchTxs []*model.Transaction + for _, recall := range batch.Recalls { + allBatchTxs = append(allBatchTxs, &recall.Recall, &recall.RecallIncomingSuccess) + } + for _, enforcement := range batch.Enforcements { + allBatchTxs = append( + allBatchTxs, + &enforcement.Enforcement, + &enforcement.EnforcementIncomingSuccess, + ) + } + + knownTxIDs := make(map[string]bool, len(allBatchTxs)) + enforcerTxsByID := make(map[string]*model.Transaction, len(allBatchTxs)) + for _, tx := range allBatchTxs { + knownTxIDs[tx.GetID()] = true + enforcerTxsByID[tx.GetID()] = tx + } + + // Build outpoints and per-Tx input sets only from txsToCheck. + allOutpoints := make(map[wire.OutPoint]bool) + txInputSets := make([]map[wire.OutPoint]bool, 0, len(txsToCheck)) + for i := range txsToCheck { + tx := &txsToCheck[i] + inputSet := make(map[wire.OutPoint]bool, len(tx.TxIn)) + for _, txIn := range tx.TxIn { + op := txIn.PreviousOutPoint + inputSet[op] = true + allOutpoints[op] = true + } + txInputSets = append(txInputSets, inputSet) + } + + if len(allOutpoints) == 0 { + return nil, nil + } + + // Resolve parent transactions: use Txs we already hold for parents that are part of + // the batch (e.g. Recall is the parent of RecallIncomingSuccess), fetch the rest. + // TODO(lightning): revisit whether we still need to fetch missing parents once + // we're more advanced in M2U protocol at which point we may know the parent Txs + // of enforcer/recalls (and thus we can skip fetching them). + parentTxs := make(map[string]*model.Transaction) + var missingParentIDs []string + for op := range allOutpoints { + if parentTxs[op.Hash.String()] != nil { + continue + } + if tx := enforcerTxsByID[op.Hash.String()]; tx != nil { + parentTxs[op.Hash.String()] = tx + } else { + missingParentIDs = append(missingParentIDs, op.Hash.String()) + } + } + + if len(missingParentIDs) > 0 { + parentTxResults, err := c.electrumClient.GetTransactionBatch(ctx, missingParentIDs) + if err != nil { + return nil, errors.Errorf("get parent transactions for batch %s: %w", batch.UUID, err) + } + + for txID, result := range parentTxResults { + rawHex, err := result.Unwrap() + if result.ElectrumError() != nil { + continue + } else if err != nil { + return nil, errors.Errorf("get parent transaction %s: %w", txID, err) + } + tx, err := model.NewTransaction(rawHex) + if err != nil { + return nil, errors.Errorf("deserialize parent transaction %s: %w", txID, err) + } + parentTxs[txID] = tx + knownTxIDs[txID] = true + } + } + + // Keep only outpoints whose parent Tx was resolved, and compute their scripthashes. + resolvedOutpoints := make(map[wire.OutPoint]bool) + scriptHashSet := make(map[string]bool) + + for op := range allOutpoints { + parentTx := parentTxs[op.Hash.String()] + if parentTx == nil || int(op.Index) >= len(parentTx.TxOut) { + continue + } + resolvedOutpoints[op] = true + scriptHash := electrum.GetScriptHash(parentTx.TxOut[op.Index].PkScript) + scriptHashSet[scriptHash] = true + } + + if len(resolvedOutpoints) == 0 { + return nil, nil + } + + // Filter txInputSets to only include resolved outpoints. + var resolvedInputSets []map[wire.OutPoint]bool + for _, inputSet := range txInputSets { + resolved := make(map[wire.OutPoint]bool) + for op := range inputSet { + if resolvedOutpoints[op] { + resolved[op] = true + } + } + if len(resolved) > 0 { + resolvedInputSets = append(resolvedInputSets, resolved) + } + } + + return &checkerState{ + txInputSets: resolvedInputSets, + allOutpoints: resolvedOutpoints, + scriptHashes: slices.Collect(maps.Keys(scriptHashSet)), + knownTxIDs: knownTxIDs, + }, nil +} + +// noTxHasAllInputsUnspent returns true when no Tx has all its inputs still in the UTxO set. +func (c *externalTxCheckerAction) noTxHasAllInputsUnspent( + ctx context.Context, + state *checkerState, +) (bool, error) { + unspentResults, err := c.electrumClient.ListUnspentBatch(ctx, state.scriptHashes) + if err != nil { + return false, err + } + + unspentSet := make(map[wire.OutPoint]bool) + for scriptHash, result := range unspentResults { + unspents, err := result.Unwrap() + if err != nil { + return false, errors.Errorf("list unspent for scripthash %s: %w", scriptHash, err) + } + for _, u := range unspents { + hash, err := chainhash.NewHashFromStr(u.TxHash) + if err != nil { + return false, err + } + op := wire.OutPoint{Hash: *hash, Index: uint32(u.TxPos)} + if state.allOutpoints[op] { + unspentSet[op] = true + } + } + } + + for _, inputSet := range state.txInputSets { + allUnspent := true + for op := range inputSet { + if !unspentSet[op] { + allUnspent = false + break + } + } + if allUnspent { + return false, nil + } + } + + return true, nil +} + +// hasIrrevocableExternalSpenders returns true when at least one external spender exists and all have 6+ confs. +func (c *externalTxCheckerAction) hasIrrevocableExternalSpenders( + ctx context.Context, + currentHeight int, + state *checkerState, +) (bool, error) { + historyResults, err := c.electrumClient.GetScriptHashHistoryBatch(ctx, state.scriptHashes) + if err != nil { + return false, err + } + + heightByExternalTxID := make(map[string]int) + for scriptHash, result := range historyResults { + entries, err := result.Unwrap() + if err != nil { + return false, errors.Errorf("get history for scripthash %s: %w", scriptHash, err) + } + for _, entry := range entries { + if !state.knownTxIDs[entry.TxHash] { + heightByExternalTxID[entry.TxHash] = int(entry.Height) + } + } + } + + // No external transactions found in the histories — nothing to check. + if len(heightByExternalTxID) == 0 { + return false, nil + } + + externalIDs := slices.Collect(maps.Keys(heightByExternalTxID)) + + externalTxResults, err := c.electrumClient.GetTransactionBatch(ctx, externalIDs) + if err != nil { + return false, err + } + + for txID, result := range externalTxResults { + rawHex, err := result.Unwrap() + if result.ElectrumError() != nil { + continue // Tx not found on-chain, skip + } else if err != nil { + return false, errors.Errorf("get external transaction %s: %w", txID, err) + } + tx, err := model.NewTransaction(rawHex) + if err != nil { + return false, errors.Errorf("deserialize external transaction %s: %w", txID, err) + } + + // Check if we find one of our outpoints being referenced by the inputs of the external Tx + var op *wire.OutPoint + for _, txIn := range tx.TxIn { + if state.allOutpoints[txIn.PreviousOutPoint] { + // This input references one of our outpoints + op = &txIn.PreviousOutPoint + break + } + } + + if op == nil { + // No input of the external Tx references one of our outpoints, continue with the next Tx. + continue + } + + // By now, we know that an external transaction has spent at least one of the + // outpoints referenced by one of our transactions. + // Now we check whether that external Tx has reached settlement status. + height := heightByExternalTxID[txID] + if height > 0 && currentHeight-height+1 >= settledConfirmations { + slogctx.Info(ctx, + "Detected tx with at least one of its inputs double spent by an external tx", + "local_tx", tx.TxHash().String(), + "outpoint_tx_hash", op.Hash.String(), + "outpoint_index", op.Index, + "external_tx_id", txID, + ) + return true, nil + } + } + + return false, nil +} diff --git a/libwallet/domain/action/lightning/publish_enforcer.go b/libwallet/domain/action/lightning/publish_enforcer.go new file mode 100644 index 00000000..e3ea24cc --- /dev/null +++ b/libwallet/domain/action/lightning/publish_enforcer.go @@ -0,0 +1,399 @@ +package lightning + +import ( + "context" + "time" + + "github.com/go-errors/errors" + + data "github.com/muun/libwallet/data/lightning" + model "github.com/muun/libwallet/domain/model/lightning" + electrum "github.com/muun/libwallet/electrum/v2" + "github.com/muun/libwallet/platform/concurrency/memlock" + "github.com/muun/libwallet/platform/observability/slogctx" +) + +const settledConfirmations = 6 + +// PublishEnforcerAction enforces every tracked incoming HTLC batch. +// +// It has two branches that can trigger: +// - Recall branch: a recall transaction is detected on-chain -> the enforcer broadcasts the +// corresponding recall-incoming-success second stage and waits for it to settle. +// - Expiration branch: no recall is on-chain -> the enforcer broadcasts the enforcement +// transaction and its enforcement-incoming-success second stage. +// +// In both branches the batch is considered settled once the second-stage transaction reaches +// settledConfirmations, and a settled batch is deleted. +// The recall branch takes precedence whenever a recall is on-chain: the expiration branch is only +// attempted if no recall is visible, or if the recall branch errored. +type PublishEnforcerAction interface { + Run(ctx context.Context) error +} + +type publishEnforcerAction struct { + locker *memlock.NamedLocker + electrumClient electrum.Client + batchRepository data.IncomingHTLCBatchRepository + externalTxChecker ExternalTxCheckerAction +} + +func NewPublishEnforcerAction( + locker *memlock.NamedLocker, + electrumClient electrum.Client, + batchRepository data.IncomingHTLCBatchRepository, + externalTxChecker ExternalTxCheckerAction, +) PublishEnforcerAction { + return &publishEnforcerAction{ + locker: locker, + electrumClient: electrumClient, + batchRepository: batchRepository, + externalTxChecker: externalTxChecker, + } +} + +func (a *publishEnforcerAction) Run(ctx context.Context) error { + allBatches, err := a.batchRepository.FindAll() + if err != nil { + return errors.Errorf("find all HTLCs: %w", err) + } + + currentHeight, err := a.electrumClient.GetBestBlockHeight(ctx) + if err != nil { + return errors.Errorf("get current block height: %w", err) + } + + var errs []error + for batchID := range allBatches { + if err = a.processHTLCBatch(ctx, currentHeight, batchID); err != nil { + errs = append(errs, errors.Errorf("HTLC batch %s: %w", batchID, err)) + } + } + + return errors.Join(errs...) +} + +// processHTLCBatch enforces a single HTLC batch under a lock. +// On successful settlement the batch is deleted. +// Otherwise, any batch state modification produced during the run is persisted, +// including on error paths. +func (a *publishEnforcerAction) processHTLCBatch( + ctx context.Context, + currentHeight int, + batchID string, +) error { + lock, err := a.locker.Acquire(ctx, "Enforcer:IncomingHTLCBatch:"+batchID, 200*time.Millisecond) + defer lock.Release() + if err != nil { + return err + } + + batch, err := a.batchRepository.FindByID(batchID) + + // We can only continue if we have a batch for which we've revealed its pre-images + if err != nil || batch == nil || !batch.PreimagesRevealed { + return err + } + + chainStatus, err := a.buildBatchChainStatus(ctx, currentHeight, batch) + if err != nil { + return err + } + + settled, err := a.enforceHTLCBatch(ctx, chainStatus, batch) + if settled { + deleteErr := a.batchRepository.DeleteByID(batch.UUID) + return errors.Join(err, deleteErr) + } + + // Save any state modifications produced, even if there was an error midway. + if saveErr := a.batchRepository.Save(batch); saveErr != nil { + if err != nil { + return errors.Errorf( + "save HTLC batch failed (%w) while handling previous error: %w", + saveErr, err, + ) + } + return errors.Errorf("save HTLC batch failed: %w", saveErr) + } + + return err +} + +// buildBatchChainStatus builds the current chain status for the given batch +func (a *publishEnforcerAction) buildBatchChainStatus( + ctx context.Context, + currentHeight int, + batch *model.IncomingHTLCBatch, +) (*chainStatus, error) { + histories, err := a.fetchBatchScriptHashHistories(ctx, batch) + if err != nil { + return nil, err + } + + chainStatus := newChainStatus(currentHeight) + + for _, history := range histories { + chainStatus.addScriptHashHistory(history) + } + + return chainStatus, nil +} + +// fetchBatchScriptHashHistories makes a batched request to Electrum to get all the +// missing script hash histories for the given batch. +func (a *publishEnforcerAction) fetchBatchScriptHashHistories( + ctx context.Context, + batch *model.IncomingHTLCBatch, +) (map[string][]electrum.ScriptHashHistoryEntry, error) { + outputScripts := batch.GetAllOutputScripts() + + if len(outputScripts) == 0 { + return nil, nil + } + + scriptHashes := make([]string, len(outputScripts)) + for i, script := range outputScripts { + scriptHashes[i] = electrum.GetScriptHash(script) + } + + results, err := a.electrumClient.GetScriptHashHistoryBatch(ctx, scriptHashes) + if err != nil { + return nil, errors.Errorf( + "get script hash history (batched) for transactions of batch %s failed: %w", + batch.UUID, err, + ) + } + + histories := make(map[string][]electrum.ScriptHashHistoryEntry, len(results)) + for key, result := range results { + entries, err := result.Unwrap() + if err != nil { + return nil, errors.Errorf( + "script hash history entry for %s in batch %s failed: %w", + key, batch.UUID, err, + ) + } + histories[key] = entries + } + + return histories, nil +} + +// enforceHTLCBatch drives the enforcement, running the recall and expiration branches for a single +// batch. Returns whether the batch has settled, meaning any second stage is settled or +// that enforcement of this batch is no longer possible. +func (a *publishEnforcerAction) enforceHTLCBatch( + ctx context.Context, + chainStatus *chainStatus, + batch *model.IncomingHTLCBatch, +) (bool, error) { + // Enforce recall path + settled, enforceRecallsErr := a.enforceRecall(ctx, chainStatus, batch) + if enforceRecallsErr == nil && settled { + return settled, nil + } + + // Recall path takes precedence over the expiration path. + // If a recall was found, we are already enforcing that path, so we finish. + // If there was an error, then we can't be sure, so we try to enforce anyway. + if enforceRecallsErr == nil { + for _, recall := range batch.Recalls { + if chainStatus.isPresent(recall.Recall) { + return false, nil + } + } + } + + // Enforce expiration path + settled, enforceExpirationErr := a.enforceExpiration(ctx, chainStatus, batch) + if enforceExpirationErr == nil { + // The expiration branch succeeded, so we discard any lingering recall branch errors. + // The enforcer made forward progress, and retrying the recall branch on the next run will + // surface the error again if it's still relevant. + return settled, nil + } + + enforcementFailed, noEnforcementPossibleErr := a.checkNoEnforcementPossible( + ctx, chainStatus, batch, enforceRecallsErr, enforceExpirationErr, + ) + if noEnforcementPossibleErr != nil { + return false, noEnforcementPossibleErr + } + if enforcementFailed { + // We can no longer enforce this batch. + return true, newSettledExternalSpendError(batch) + } + + return false, errors.Join(enforceRecallsErr, enforceExpirationErr) +} + +// enforceRecall drives the recall branch state machine. +func (a *publishEnforcerAction) enforceRecall( + ctx context.Context, + chainStatus *chainStatus, + batch *model.IncomingHTLCBatch, +) (bool, error) { + var broadcastedRecall *model.IncomingHTLCRecall + for _, recall := range batch.Recalls { + if chainStatus.isPresent(recall.Recall) { + broadcastedRecall = recall + } + } + + if broadcastedRecall == nil { + // No recall broadcasted + return false, nil + } + + recallConfirmations := chainStatus.getConfirmations(broadcastedRecall.Recall) + + recallIncomingSuccessPresent := chainStatus.isPresent( + broadcastedRecall.RecallIncomingSuccess, + ) + recallIncomingSuccessConfirmations := chainStatus.getConfirmations( + broadcastedRecall.RecallIncomingSuccess, + ) + + slogctx.Info(ctx, + "Recall transaction found on-chain. Executing enforcer's recall branch.", + "batch", batch.UUID, + "recall", broadcastedRecall.Recall.GetID(), + "recallConfirmations", recallConfirmations, + "recallIncomingSuccess", broadcastedRecall.RecallIncomingSuccess.GetID(), + "recallIncomingSuccessConfirmations", recallIncomingSuccessConfirmations, + ) + + if recallIncomingSuccessConfirmations >= settledConfirmations { + // Recall Incoming Success settled + return true, nil + } + + if !recallIncomingSuccessPresent { + // Recall Incoming Success needs to be broadcasted + _, err := a.electrumClient.Broadcast( + ctx, broadcastedRecall.RecallIncomingSuccess.ToRawHex(), + ) + if err != nil { + return false, newBroadcastRecallIncomingSuccessError( + err, broadcastedRecall.RecallIncomingSuccess, + ) + } + } + + return false, nil +} + +// enforceExpiration drives the expiration branch state machine. +func (a *publishEnforcerAction) enforceExpiration( + ctx context.Context, + chainStatus *chainStatus, + batch *model.IncomingHTLCBatch, +) (bool, error) { + var broadcastedEnforcement *model.IncomingHTLCEnforcement + for _, enforcement := range batch.Enforcements { + if chainStatus.isPresent(enforcement.Enforcement) { + broadcastedEnforcement = enforcement + } + } + + if broadcastedEnforcement == nil { + var err error + if broadcastedEnforcement, err = a.broadcastValidEnforcement(ctx, batch); err != nil { + return false, err + } + } + + enforcementConfirmations := chainStatus.getConfirmations(broadcastedEnforcement.Enforcement) + + enforcementIncomingSuccessPresent := chainStatus.isPresent( + broadcastedEnforcement.EnforcementIncomingSuccess, + ) + enforcementIncomingSuccessConfirmations := chainStatus.getConfirmations( + broadcastedEnforcement.EnforcementIncomingSuccess, + ) + + slogctx.Info(ctx, "Executing enforcer's expiration branch.", + "batch", batch.UUID, + "enforcement", broadcastedEnforcement.Enforcement.GetID(), + "enforcementConfirmations", enforcementConfirmations, + "enforcementIncomingSuccess", broadcastedEnforcement.EnforcementIncomingSuccess.GetID(), + "enforcementIncomingSuccessConfirmations", enforcementIncomingSuccessConfirmations, + ) + + if enforcementIncomingSuccessConfirmations >= settledConfirmations { + // Recall Incoming Success settled + return true, nil + } + + if !enforcementIncomingSuccessPresent { + // Enforcement Incoming Success needs to be broadcasted + _, err := a.electrumClient.Broadcast( + ctx, broadcastedEnforcement.EnforcementIncomingSuccess.ToRawHex(), + ) + if err != nil { + return false, newBroadcastEnforcementIncomingSuccessError( + err, broadcastedEnforcement.EnforcementIncomingSuccess, + ) + } + } + + return false, nil +} + +// broadcastValidEnforcement iterates the enforcement transactions in order and broadcasts +// them until one is accepted by the network. If all enforcements fail to broadcast, we fail. +func (a *publishEnforcerAction) broadcastValidEnforcement( + ctx context.Context, + batch *model.IncomingHTLCBatch, +) (*model.IncomingHTLCEnforcement, error) { + var broadcastErrs []error + var failedTxs []model.Transaction + for _, enforcement := range batch.Enforcements { + _, err := a.electrumClient.Broadcast(ctx, enforcement.Enforcement.ToRawHex()) + if err == nil { + return enforcement, nil + } + + if !errors.As(err, &electrum.ElectrumError{}) { + // When the error is not an electrum one it signals a deeper problem, such as a connectivity + // issue. In this scenario, don't attempt to continue with the remaining enforcements + // but rather make an early return. + return nil, err + } + + broadcastErrs = append(broadcastErrs, errors.Errorf("broadcast enforcement: %w", err)) + failedTxs = append(failedTxs, enforcement.Enforcement) + } + + return nil, newNoEnforcementBroadcastedError( + errors.Join(broadcastErrs...), failedTxs, + ) +} + +// checkNoEnforcementPossible checks if we can't possibly execute the enforcer process +// due to our Txs (RIS/Enforcement/EIS/) having had any inputs irrevocably double-spent. +func (a *publishEnforcerAction) checkNoEnforcementPossible( + ctx context.Context, + chainStatus *chainStatus, + batch *model.IncomingHTLCBatch, + errs ...error, +) (bool, error) { + var failedTxs []model.Transaction + + for _, err := range errs { + if err == nil { + continue + } + + var broadcastErr *EnforcementBroadcastError + if errors.As(err, &broadcastErr) { + failedTxs = append(failedTxs, broadcastErr.FailedTxs...) + } else { + // Got error that is not EnforcementBroadcastError, so maybe we could still enforce + return false, nil + } + } + + return a.externalTxChecker.Run(ctx, chainStatus.currentHeight, batch, failedTxs) +} diff --git a/libwallet/domain/action/lightning/recall_branch_test.go b/libwallet/domain/action/lightning/recall_branch_test.go new file mode 100644 index 00000000..80a7dd4c --- /dev/null +++ b/libwallet/domain/action/lightning/recall_branch_test.go @@ -0,0 +1,152 @@ +package lightning_test + +import ( + "testing" + + "github.com/muun/libwallet/domain/action/lightning" + "github.com/muun/libwallet/platform/test/testsuite" +) + +func TestRecallBranchEnforcer_Integration(t *testing.T) { + testsuite.Run(t, new(RecallBranchEnforcerTestSuite)) +} + +// RecallBranchEnforcerTestSuite covers enforcement scenarios via the recall path. +type RecallBranchEnforcerTestSuite struct { + EnforcerTestSuite +} + +// TestSettlesAfterSixConfirmations covers the full recall-branch lifecycle: preimage +// revealed, recall on-chain, RIS broadcast, six confirmations, batch deleted. +func (s *RecallBranchEnforcerTestSuite) TestSettlesAfterSixConfirmations() { + data := s.registerBatch() + batch := data.batch + recall := data.recalls[0] + + s.NoError(s.revealPreimages.Run(s.Ctx, data.batch.UUID)) + + s.bitcoind.MustSendRawTransaction(recall.recallTx.ToRawHex()) + s.electrum.WaitForIndexedTx(s.Ctx, recall.recallTx.GetID()) + + // First run broadcasts the RIS. + s.NoError(s.publishEnforcer.Run(s.Ctx)) + s.assertRecallBothBroadcasted(batch.UUID, 0) + + // Mine 2 blocks. Batch should still exist. + s.bitcoind.GenerateBlocks(2) + s.electrum.WaitForIndexedTx(s.Ctx, recall.recallIncomingSuccessTx.GetID()) + s.electrum.SyncWithBitcoind(s.Ctx, s.bitcoind) + + s.NoError(s.publishEnforcer.Run(s.Ctx)) + s.assertRecallBothBroadcasted(batch.UUID, 0) + + // Mine 4 more blocks to reach 6 total confirmations. + s.bitcoind.GenerateBlocks(4) + s.electrum.SyncWithBitcoind(s.Ctx, s.bitcoind) + + // Enforcer should detect >= 6 confirmations and remove the batch. + s.NoError(s.publishEnforcer.Run(s.Ctx)) + batch, err := s.batchRepository.FindByID(data.batch.UUID) + s.NoError(err, "FindByID failed") + s.Nil(batch, "batch should be deleted once RIS settles") +} + +// TestRebroadcastsDroppedRIS covers the RIS drop and re-broadcast path. +func (s *RecallBranchEnforcerTestSuite) TestRebroadcastsDroppedRIS() { + data := s.registerBatch() + batch := data.batch + recall := data.recalls[0] + + s.NoError(s.revealPreimages.Run(s.Ctx, data.batch.UUID)) + + s.bitcoind.MustSendRawTransaction(recall.recallTx.ToRawHex()) + s.electrum.WaitForIndexedTx(s.Ctx, recall.recallTx.GetID()) + + s.NoError(s.publishEnforcer.Run(s.Ctx)) + s.assertRecallBothBroadcasted(batch.UUID, 0) + + // Drop RIS + s.bitcoind.DropMempoolEntry(recall.recallIncomingSuccessTx.GetID()) + // Force electrum to sync new mempool + s.bitcoind.GenerateBlocks(1) + s.electrum.SyncWithBitcoind(s.Ctx, s.bitcoind) + + s.bitcoind.AssertTxIsBroadcasted(recall.recallTx.GetID()) + s.bitcoind.AssertTxIsNotBroadcasted(recall.recallIncomingSuccessTx.GetID()) + + // Run enforcer. Should detect the missing RIS and re-broadcast it. + s.NoError(s.publishEnforcer.Run(s.Ctx)) + s.assertRecallBothBroadcasted(batch.UUID, 0) +} + +// TestPivotsToSecondRecallAfterFirstDropped tests that when a broadcasted recall is dropped from +// the mempool, the enforcer detects it and pivots to the other recall variant. +func (s *RecallBranchEnforcerTestSuite) TestPivotsToSecondRecallAfterFirstDropped() { + data := s.registerBatchWith(2, 1) + batch := data.batch + recall0 := data.recalls[0] + recall1 := data.recalls[1] + + s.NoError(s.revealPreimages.Run(s.Ctx, batch.UUID)) + + // Broadcast recall0; enforcer finds it and broadcasts RIS0. + s.bitcoind.MustSendRawTransaction(recall0.recallTx.ToRawHex()) + s.electrum.WaitForIndexedTx(s.Ctx, recall0.recallTx.GetID()) + + s.NoError(s.publishEnforcer.Run(s.Ctx)) + s.assertRecallBothBroadcasted(batch.UUID, 0) + s.assertRecallNoneBroadcasted(batch.UUID, 1) + + // Drop recall0 (and its child RIS0) from the mempool. + s.bitcoind.DropMempoolEntry(recall0.recallTx.GetID()) + s.bitcoind.GenerateBlocks(1) + s.electrum.SyncWithBitcoind(s.Ctx, s.bitcoind) + + s.bitcoind.AssertTxIsNotBroadcasted(recall0.recallTx.GetID()) + s.bitcoind.AssertTxIsNotBroadcasted(recall0.recallIncomingSuccessTx.GetID()) + + // Broadcast recall1 so the enforcer can discover it. + s.bitcoind.MustSendRawTransaction(recall1.recallTx.ToRawHex()) + s.electrum.WaitForIndexedTx(s.Ctx, recall1.recallTx.GetID()) + + // Enforcer detects recall0 was dropped, resets its state, finds recall1, and broadcasts RIS1. + s.NoError(s.publishEnforcer.Run(s.Ctx)) + s.assertRecallNoneBroadcasted(batch.UUID, 0) + s.assertRecallBothBroadcasted(batch.UUID, 1) +} + +// TestErrorsOnFailedRISRebroadcast covers the RIS dropped and failed re-broadcast path. +func (s *RecallBranchEnforcerTestSuite) TestErrorsOnFailedRISRebroadcast() { + data := s.registerBatchWith(1, 1) + batch := data.batch + recall := data.recalls[0] + + // Broadcast recall + s.bitcoind.MustSendRawTransaction(recall.recallTx.ToRawHex()) + s.electrum.WaitForIndexedTx(s.Ctx, recall.recallTx.GetID()) + + s.NoError(s.revealPreimages.Run(s.Ctx, data.batch.UUID)) + + // RIS broadcast + s.NoError(s.publishEnforcer.Run(s.Ctx)) + s.assertRecallBothBroadcasted(batch.UUID, 0) + + // Double spend RIS. + conflictHex := s.createConflictingTx( + recall.recallIncomingSuccessTx.ToRawHex(), + 0, + recall.firstStageOutputs, + 0.0002, + ) + s.bitcoind.MustSendRawTransaction(conflictHex) + s.bitcoind.GenerateBlocks(1) + s.electrum.SyncWithBitcoind(s.Ctx, s.bitcoind) + + // Recall stays on-chain, RIS rebroadcast fails (output already spent). + var eisErr *lightning.BroadcastRecallIncomingSuccessError + s.ErrorAs(s.publishEnforcer.Run(s.Ctx), &eisErr) + + s.assertBatchExists(batch.UUID) + s.bitcoind.AssertTxIsBroadcasted(recall.recallTx.GetID()) + s.bitcoind.AssertTxIsNotBroadcasted(recall.recallIncomingSuccessTx.GetID()) +} diff --git a/libwallet/domain/action/lightning/register_incoming_htlc_batch.go b/libwallet/domain/action/lightning/register_incoming_htlc_batch.go new file mode 100644 index 00000000..6fee2358 --- /dev/null +++ b/libwallet/domain/action/lightning/register_incoming_htlc_batch.go @@ -0,0 +1,43 @@ +package lightning + +import ( + "github.com/go-errors/errors" + + data "github.com/muun/libwallet/data/lightning" + model "github.com/muun/libwallet/domain/model/lightning" +) + +// RegisterIncomingHTLCAction registers an HTLC batch in pending state with +// its corresponding recalls and enforcement transactions. +type RegisterIncomingHTLCAction interface { + Run( + recalls []*model.IncomingHTLCRecall, + enforcements []*model.IncomingHTLCEnforcement, + ) (*model.IncomingHTLCBatch, error) +} + +type registerIncomingHTLCAction struct { + batchRepository data.IncomingHTLCBatchRepository +} + +func NewRegisterIncomingHTLCAction( + batchRepository data.IncomingHTLCBatchRepository, +) RegisterIncomingHTLCAction { + return ®isterIncomingHTLCAction{batchRepository: batchRepository} +} + +func (a *registerIncomingHTLCAction) Run( + recalls []*model.IncomingHTLCRecall, + enforcements []*model.IncomingHTLCEnforcement, +) (*model.IncomingHTLCBatch, error) { + batch := model.NewIncomingHTLCBatch( + recalls, + enforcements, + ) + + if err := a.batchRepository.Save(batch); err != nil { + return nil, errors.Errorf("save HTLC: %w", err) + } + + return batch, nil +} diff --git a/libwallet/domain/action/lightning/reveal_preimages.go b/libwallet/domain/action/lightning/reveal_preimages.go new file mode 100644 index 00000000..74ba2227 --- /dev/null +++ b/libwallet/domain/action/lightning/reveal_preimages.go @@ -0,0 +1,57 @@ +package lightning + +import ( + "context" + "time" + + "github.com/go-errors/errors" + + data "github.com/muun/libwallet/data/lightning" + "github.com/muun/libwallet/platform/concurrency/memlock" +) + +// RevealPreimagesAction marks the preimage for the given paymentHash as having been revealed. +type RevealPreimagesAction interface { + Run(ctx context.Context, batchID string) error +} + +type revealPreimagesAction struct { + locker *memlock.NamedLocker + batchRepository data.IncomingHTLCBatchRepository +} + +func NewRevealPreimagesAction( + locker *memlock.NamedLocker, + batchRepository data.IncomingHTLCBatchRepository, +) RevealPreimagesAction { + return &revealPreimagesAction{locker: locker, batchRepository: batchRepository} +} + +func (a *revealPreimagesAction) Run(ctx context.Context, batchID string) error { + lock, err := a.locker.Acquire(ctx, "Enforcer:IncomingHTLCBatch:"+batchID, 200*time.Millisecond) + defer lock.Release() + if err != nil { + return err + } + + batch, err := a.batchRepository.FindByID(batchID) + if err != nil { + return errors.Errorf("find HTLC batch: %w", err) + } + if batch == nil { + return errors.Errorf("HTLC batch not found for UUID: %s", batchID) + } + + if batch.PreimagesRevealed { + // We're only keeping an idempotent behavior if the action is called twice but + // the client shouldn't invoke this action after the images have been revealed. + return nil + } + + batch.PreimagesRevealed = true + if err := a.batchRepository.Save(batch); err != nil { + return errors.Errorf("update HTLC state: %w", err) + } + + return nil +} diff --git a/libwallet/domain/action/lightning/suite_test.go b/libwallet/domain/action/lightning/suite_test.go new file mode 100644 index 00000000..1848cf38 --- /dev/null +++ b/libwallet/domain/action/lightning/suite_test.go @@ -0,0 +1,545 @@ +package lightning_test + +import ( + "bytes" + "context" + "encoding/hex" + "math" + "path" + + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/decred/dcrd/dcrec/secp256k1/v4" + + "github.com/muun/libwallet/btcsuitew/btcutilw" + data "github.com/muun/libwallet/data/lightning" + "github.com/muun/libwallet/domain/action/lightning" + model "github.com/muun/libwallet/domain/model/lightning" + electrum "github.com/muun/libwallet/electrum/v2" + "github.com/muun/libwallet/platform/concurrency/memlock" + "github.com/muun/libwallet/platform/test/testbitcoind" + "github.com/muun/libwallet/platform/test/testelectrum" + "github.com/muun/libwallet/platform/test/testsuite" + "github.com/muun/libwallet/storage" + "github.com/muun/libwallet/walletdb" +) + +const ( + // testDefaultTxFee is the fee (in BTC) used by test transactions when no specific fee is needed. + testDefaultTxFee = 0.0001 +) + +type ( + // taprootOutput holds the key material for a taproot output with two spending paths: + // key-spend (internal key) and script-spend (leaf with OP_CHECKSIG). + taprootOutput struct { + internalKey *secp256k1.PrivateKey + scriptPathKey *secp256k1.PrivateKey + leaf txscript.TapLeaf + tapTree *txscript.IndexedTapScriptTree + outputKey *secp256k1.PublicKey + pkScript []byte + } + + EnforcerTestSuite struct { + testsuite.BaseIntegrationSuite + locker *memlock.NamedLocker + bitcoind *testbitcoind.Client + electrum *testelectrum.Client + kv *storage.KeyValueStorage + batchRepository data.IncomingHTLCBatchRepository + registerIncomingHTLCBatch lightning.RegisterIncomingHTLCAction + revealPreimages lightning.RevealPreimagesAction + completeIncomingHTLCBatch lightning.CompleteIncomingHTLCBatchAction + publishEnforcer lightning.PublishEnforcerAction + } +) + +func (s *EnforcerTestSuite) SetupSuite() { + s.BaseIntegrationSuite.SetupSuite() + + s.locker = &memlock.NamedLocker{} + + s.bitcoind = testbitcoind.NewClient(s.T()) + s.electrum = testelectrum.NewClient(s.Ctx, s.T()) + + pool, err := walletdb.NewPool(path.Join(s.T().TempDir(), "test.db"), nil) + s.Require().NoError(err, "open walletdb") + s.T().Cleanup(func() { pool.Close() }) + s.kv = storage.NewKeyValueStorage( + pool.NewKeyValueRepository(), + kvTestStorageSchema(), + ) + + s.batchRepository = data.NewIncomingHTLCBatchRepository(s.kv) + + s.registerIncomingHTLCBatch = lightning.NewRegisterIncomingHTLCAction(s.batchRepository) + s.revealPreimages = lightning.NewRevealPreimagesAction(s.locker, s.batchRepository) + s.completeIncomingHTLCBatch = lightning.NewCompleteIncomingHTLCAction( + s.locker, + s.batchRepository, + ) + s.publishEnforcer = lightning.NewPublishEnforcerAction( + s.locker, + s.electrum.Client, + s.batchRepository, + lightning.NewExternalTxCheckerAction(s.electrum.Client), + ) +} + +func (s *EnforcerTestSuite) SetupTest() { + s.BaseIntegrationSuite.SetupTest() + + s.bitcoind.StartNewTest(s.T()) + s.electrum.StartNewTest(s.T()) + + // Fresh kv storage per test + s.resetKvStorage() + + s.bitcoind.EnsureMatureCoins() +} + +/// Helpers /// + +func kvTestStorageSchema() map[string]storage.Classification { + return map[string]storage.Classification{ + storage.KeyLightningIncomingHTLCBatches: {ValueType: &storage.StringType{}}, + } +} + +func (s *EnforcerTestSuite) resetKvStorage() { + for key := range kvTestStorageSchema() { + s.NoError(s.kv.Delete(key)) + } +} + +// fundingOutput represents a spendable taproot output from a confirmed parent transaction. +// Recall and enforcement transactions use these as inputs. +type fundingOutput struct { + txHex string + vout uint32 + amount float64 + tap *taprootOutput +} + +type batchTestData struct { + batch *model.IncomingHTLCBatch + recalls []recallTestData + enforcements []enforcementTestData + fundingOutputs []fundingOutput +} + +type recallTestData struct { + recallTx *model.Transaction + recallIncomingSuccessTx *model.Transaction + firstStageOutputs []fundingOutput +} + +type enforcementTestData struct { + enforcementTx *model.Transaction + enforcementIncomingSuccessTx *model.Transaction + firstStageOutputs []fundingOutput +} + +// registerBatch creates and registers the default batch: 1 recall, 1 enforcement. +func (s *EnforcerTestSuite) registerBatch() batchTestData { + s.T().Helper() + return s.registerBatchWith(1, 1) +} + +// registerBatchWith creates and registers a batch with the given number of recalls and +// enforcements. All recalls and enforcements share the same funding outputs so they are +// competing transactions. +// Synchronizes electrum with bitcoind first so the enforcer's height check agrees with the +// registration height. +func (s *EnforcerTestSuite) registerBatchWith( + numRecalls, numEnforcements int, +) batchTestData { + s.T().Helper() + + fundingOutputs := s.createFundingOutputs(1) + + recalls := make([]*model.IncomingHTLCRecall, numRecalls) + recallData := make([]recallTestData, numRecalls) + for i := range numRecalls { + recalls[i], recallData[i] = s.buildRecall(fundingOutputs, testDefaultTxFee) + } + + enforcements := make([]*model.IncomingHTLCEnforcement, numEnforcements) + enforcementData := make([]enforcementTestData, numEnforcements) + for i := range numEnforcements { + enforcements[i], enforcementData[i] = s.buildEnforcement(fundingOutputs, testDefaultTxFee) + } + + s.electrum.SyncWithBitcoind(s.Ctx, s.bitcoind) + + batch, err := s.registerIncomingHTLCBatch.Run( + recalls, + enforcements, + ) + s.NoError(err, "RegisterIncomingHTLCBatch failed") + + return batchTestData{ + batch: batch, + recalls: recallData, + enforcements: enforcementData, + fundingOutputs: fundingOutputs, + } +} + +// buildRecall creates a recall transaction pair spending from the given funding outputs. +func (s *EnforcerTestSuite) buildRecall( + fundingOutputs []fundingOutput, + fee float64, +) (*model.IncomingHTLCRecall, recallTestData) { + s.T().Helper() + + recallTxHex, recallIncomingSuccessTxHex, firstStageOutputs := s.createTxPair( + fundingOutputs, fee, + ) + recallTx, err := model.NewTransaction(recallTxHex) + s.NoError(err, "deserialize recall tx") + recallIncomingSuccessTx, err := model.NewTransaction(recallIncomingSuccessTxHex) + s.NoError(err, "deserialize recall-incoming-success tx") + + recall := model.NewIncomingHTLCRecall(*recallTx, *recallIncomingSuccessTx) + return recall, recallTestData{ + recallTx: recallTx, + recallIncomingSuccessTx: recallIncomingSuccessTx, + firstStageOutputs: firstStageOutputs, + } +} + +// buildEnforcement creates an enforcement transaction pair spending from the given funding outputs. +func (s *EnforcerTestSuite) buildEnforcement( + fundingOutputs []fundingOutput, + fee float64, +) ( + *model.IncomingHTLCEnforcement, + enforcementTestData, +) { + s.T().Helper() + + enforcementTxHex, enforcementIncomingSuccessTxHex, firstStageOutputs := s.createTxPair( + fundingOutputs, fee, + ) + enforcementTx, err := model.NewTransaction(enforcementTxHex) + s.NoError(err, "deserialize enforcement tx") + enforcementIncomingSuccessTx, err := model.NewTransaction( + enforcementIncomingSuccessTxHex, + ) + s.NoError(err, "deserialize enforcement-incoming-success tx") + + enforcement := model.NewIncomingHTLCEnforcement( + *enforcementTx, + *enforcementIncomingSuccessTx, + ) + return enforcement, enforcementTestData{ + enforcementTx: enforcementTx, + enforcementIncomingSuccessTx: enforcementIncomingSuccessTx, + firstStageOutputs: firstStageOutputs, + } +} + +func (s *EnforcerTestSuite) newTaprootOutput() *taprootOutput { + s.T().Helper() + + internalKey, err := secp256k1.GeneratePrivateKey() + s.NoError(err, "generate internal key") + + scriptPathKey, err := secp256k1.GeneratePrivateKey() + s.NoError(err, "generate script path key") + + builder := txscript.NewScriptBuilder() + builder.AddData(schnorr.SerializePubKey(scriptPathKey.PubKey())) + builder.AddOp(txscript.OP_CHECKSIG) + leafScript, err := builder.Script() + s.NoError(err, "build leaf script") + + leaf := txscript.NewBaseTapLeaf(leafScript) + tapTree := txscript.AssembleTaprootScriptTree(leaf) + rootHash := tapTree.RootNode.TapHash() + + outputKey := txscript.ComputeTaprootOutputKey(internalKey.PubKey(), rootHash[:]) + pkScript, err := txscript.PayToTaprootScript(outputKey) + s.NoError(err, "build P2TR script") + + return &taprootOutput{ + internalKey: internalKey, + scriptPathKey: scriptPathKey, + leaf: leaf, + tapTree: tapTree, + outputKey: outputKey, + pkScript: pkScript, + } +} + +// toSats converts the amount of received bitcoins to satoshis. +func toSats(btc float64) int64 { + return int64(math.Round(btc * 1e8)) +} + +// createTxPair creates a first-stage transaction spending from the given funding outputs with +// 2 taproot outputs, and a second-stage transaction that spends from the first stage's output 0 +// via key-spend. +func (s *EnforcerTestSuite) createTxPair( + fundingOutputs []fundingOutput, + fee float64, +) (string, string, []fundingOutput) { + s.T().Helper() + + firstStageOutputs := s.createSpendTx(fundingOutputs, 2, fee) + secondStageOutputs := s.createSpendTx(firstStageOutputs[:1], 1, fee) + + return firstStageOutputs[0].txHex, secondStageOutputs[0].txHex, firstStageOutputs +} + +// createFundingOutputs creates a confirmed transaction with `count` taproot outputs and returns +// them as funding outputs that recall/enforcement transactions can spend. +func (s *EnforcerTestSuite) createFundingOutputs(count int) []fundingOutput { + s.T().Helper() + + sourceAddr := s.bitcoind.GetNewAddress("funding-source") + s.bitcoind.SendToAddress(sourceAddr, 0.01) + s.bitcoind.GenerateBlocks(1) + + utxos := s.bitcoind.ListUnspentForAddress(sourceAddr) + s.NotEmpty(utxos, "No UTXOs found for address %s", sourceAddr.EncodeAddress()) + utxo := utxos[0] + + inputs := []testbitcoind.Outpoint{utxo.Outpoint} + s.bitcoind.LockUnspent(inputs) + s.T().Cleanup(func() { s.bitcoind.UnlockUnspent(inputs) }) + + totalAfterFee := utxo.Amount - 0.0001 + perOutput := totalAfterFee / float64(count) + + tapOutputs := make([]*taprootOutput, count) + txOutputs := make([]testbitcoind.CreateRawTxOutput, count) + for i := range count { + tap := s.newTaprootOutput() + tapOutputs[i] = tap + + destAddr, err := btcutilw.NewAddressTaprootKey( + schnorr.SerializePubKey(tap.outputKey), + &chaincfg.RegressionNetParams, + ) + s.NoError(err, "create taproot address for funding output %d", i) + txOutputs[i] = testbitcoind.CreateRawTxOutput{Address: destAddr, Amount: perOutput} + } + + rawTxHex := s.bitcoind.CreateRawTransaction(inputs, txOutputs) + signedTxHex := s.bitcoind.SignTransaction(rawTxHex, nil) + + s.bitcoind.MustSendRawTransaction(signedTxHex) + s.bitcoind.GenerateBlocks(1) + + result := make([]fundingOutput, count) + for i, tap := range tapOutputs { + result[i] = fundingOutput{ + txHex: signedTxHex, + vout: uint32(i), + amount: perOutput, + tap: tap, + } + } + return result +} + +// createSpendTx creates a transaction that spends all the given funding outputs via key-spend +// and produces numOutputs new taproot outputs. Returns the outputs as fundingOutputs. +func (s *EnforcerTestSuite) createSpendTx( + inputs []fundingOutput, + numOutputs int, + fee float64, +) []fundingOutput { + s.T().Helper() + + tx := wire.NewMsgTx(2) + prevFetcher := txscript.NewMultiPrevOutFetcher(nil) + var totalInput float64 + + // Add all the funding outputs that this Tx will spend from. + for _, fo := range inputs { + parentTx := s.bitcoind.DecodeTx(fo.txHex) + outpoint := wire.OutPoint{Hash: parentTx.TxHash(), Index: fo.vout} + + tx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: outpoint, + Sequence: wire.MaxTxInSequenceNum, + }) + prevFetcher.AddPrevOut(outpoint, &wire.TxOut{ + Value: toSats(fo.amount), + PkScript: fo.tap.pkScript, + }) + totalInput += fo.amount + } + + // Add all the outputs for this Tx. + totalAfterFee := totalInput - fee + perOutput := totalAfterFee / float64(numOutputs) + tapOutputs := make([]*taprootOutput, numOutputs) + for i := range numOutputs { + tap := s.newTaprootOutput() + tapOutputs[i] = tap + tx.AddTxOut(&wire.TxOut{Value: toSats(perOutput), PkScript: tap.pkScript}) + } + + // Traverse the inputs again to sign them now that we have the outputs in place. + sigHashes := txscript.NewTxSigHashes(tx, prevFetcher) + for i, fo := range inputs { + sigHash, err := txscript.CalcTaprootSignatureHash( + sigHashes, txscript.SigHashDefault, tx, i, prevFetcher, + ) + s.NoError(err, "calc sighash for input %d", i) + + rootHash := fo.tap.tapTree.RootNode.TapHash() + tweakedKey := txscript.TweakTaprootPrivKey(*fo.tap.internalKey, rootHash[:]) + sig, err := schnorr.Sign(tweakedKey, sigHash) + s.NoError(err, "sign input %d", i) + + tx.TxIn[i].Witness = wire.TxWitness{sig.Serialize()} + } + + // We will return the outputs of this Tx that can be used for new news to spend from them. + var buf bytes.Buffer + s.NoError(tx.Serialize(&buf), "serialize spend tx") + txHex := hex.EncodeToString(buf.Bytes()) + result := make([]fundingOutput, numOutputs) + for i, tap := range tapOutputs { + result[i] = fundingOutput{txHex: txHex, vout: uint32(i), amount: perOutput, tap: tap} + } + return result +} + +// createConflictingTx builds a transaction that double-spends the input at inputIndex of the +// given transaction, signing with the taproot key material from the matching funding output. +func (s *EnforcerTestSuite) createConflictingTx( + conflictingRawTx string, + conflictingInputIndex int, + parentOutputs []fundingOutput, + fee float64, +) string { + s.T().Helper() + + decoded := s.bitcoind.DecodeRawTransaction(conflictingRawTx) + input := decoded.Vin[conflictingInputIndex] + + var fo fundingOutput + for _, candidate := range parentOutputs { + if candidate.vout == input.Vout { + fo = candidate + break + } + } + s.NotNil(fo.tap, "no funding output with vout %d", input.Vout) + + parentTx := s.bitcoind.DecodeTx(fo.txHex) + + destAddr := s.bitcoind.GetNewAddress("conflicting-dest") + destScript, err := txscript.PayToAddrScript(destAddr) + s.NoError(err, "build conflicting dest script") + + conflictTx := wire.NewMsgTx(2) + conflictTx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{ + Hash: parentTx.TxHash(), + Index: fo.vout, + }, + Sequence: wire.MaxTxInSequenceNum, + }) + conflictTx.AddTxOut(&wire.TxOut{ + Value: toSats(fo.amount - fee), + PkScript: destScript, + }) + + prevOut := &wire.TxOut{ + Value: toSats(fo.amount), + PkScript: fo.tap.pkScript, + } + prevFetcher := txscript.NewCannedPrevOutputFetcher(prevOut.PkScript, prevOut.Value) + sigHashes := txscript.NewTxSigHashes(conflictTx, prevFetcher) + + sigHash, err := txscript.CalcTaprootSignatureHash( + sigHashes, txscript.SigHashDefault, conflictTx, 0, prevFetcher, + ) + s.NoError(err, "calc conflicting tx sighash") + + rootHash := fo.tap.tapTree.RootNode.TapHash() + tweakedKey := txscript.TweakTaprootPrivKey(*fo.tap.internalKey, rootHash[:]) + sig, err := schnorr.Sign(tweakedKey, sigHash) + s.NoError(err, "schnorr sign conflicting tx") + + conflictTx.TxIn[0].Witness = wire.TxWitness{sig.Serialize()} + + var buf bytes.Buffer + s.NoError(conflictTx.Serialize(&buf), "serialize conflicting tx") + return hex.EncodeToString(buf.Bytes()) +} + +func (s *EnforcerTestSuite) assertBatchExists(batchID string) *model.IncomingHTLCBatch { + s.T().Helper() + + batch, err := s.batchRepository.FindByID(batchID) + s.NoError(err) + s.NotNil(batch) + + return batch +} + +func (s *EnforcerTestSuite) assertRecallNoneBroadcasted(batchID string, recallIdx int) { + s.T().Helper() + + batch := s.assertBatchExists(batchID) + + recall := batch.Recalls[recallIdx] + s.bitcoind.AssertTxIsNotBroadcasted(recall.Recall.GetID()) + s.bitcoind.AssertTxIsNotBroadcasted(recall.RecallIncomingSuccess.GetID()) +} + +func (s *EnforcerTestSuite) assertRecallBothBroadcasted(batchID string, recallIdx int) { + s.T().Helper() + + batch := s.assertBatchExists(batchID) + recall := batch.Recalls[recallIdx] + + s.bitcoind.AssertTxIsBroadcasted(recall.Recall.GetID()) + s.bitcoind.AssertTxIsBroadcasted(recall.RecallIncomingSuccess.GetID()) +} + +func (s *EnforcerTestSuite) assertEnforcementNoneBroadcasted(batchID string, enforcementIdx int) { + s.T().Helper() + + batch := s.assertBatchExists(batchID) + enforcement := batch.Enforcements[enforcementIdx] + + s.bitcoind.AssertTxIsNotBroadcasted(enforcement.Enforcement.GetID()) + s.bitcoind.AssertTxIsNotBroadcasted(enforcement.EnforcementIncomingSuccess.GetID()) +} + +func (s *EnforcerTestSuite) assertEnforcementBothBroadcasted(batchID string, enforcementIdx int) { + s.T().Helper() + + batch := s.assertBatchExists(batchID) + enforcement := batch.Enforcements[enforcementIdx] + + s.bitcoind.AssertTxIsBroadcasted(enforcement.Enforcement.GetID()) + s.bitcoind.AssertTxIsBroadcasted(enforcement.EnforcementIncomingSuccess.GetID()) +} + +// electrumFailingClient wraps an electrum.Client, intercepting Broadcast calls. If the raw Tx +// hex matches an entry in rawTxsToBroadcast, the mapped error is returned. Otherwise the call +// is delegated to the embedded client. +type electrumFailingClient struct { + electrum.Client + rawTxsToBroadcast map[string]error +} + +func (e *electrumFailingClient) Broadcast(ctx context.Context, rawTx string) (string, error) { + if err, ok := e.rawTxsToBroadcast[rawTx]; ok { + return "", err + } + return e.Client.Broadcast(ctx, rawTx) +} diff --git a/libwallet/domain/action/nfc/pair_load_persisted_challenge_action.go b/libwallet/domain/action/nfc/pair_load_persisted_challenge_action.go new file mode 100644 index 00000000..63a588f1 --- /dev/null +++ b/libwallet/domain/action/nfc/pair_load_persisted_challenge_action.go @@ -0,0 +1,41 @@ +package nfc + +import ( + "encoding/hex" + + "github.com/go-errors/errors" + + "github.com/muun/libwallet/data/security_cards" + "github.com/muun/libwallet/domain/model/security_card" +) + +type PairLoadPersistedChallengeAction struct { + protocolRepository *security_cards.ProtocolRepository +} + +func NewPairLoadPersistedChallengeAction( + protocolRepository *security_cards.ProtocolRepository, +) *PairLoadPersistedChallengeAction { + return &PairLoadPersistedChallengeAction{protocolRepository: protocolRepository} +} + +// Run returns a FreshPairChallenge with the decoded server public key, +// or nil if no usable challenge is available in local storage. A nil +// result means either there is no persisted challenge or the persisted +// one is past its TTL — both cases tell the caller it must trigger a +// refresh before proceeding. +func (ac *PairLoadPersistedChallengeAction) Run() (*security_card.FreshPairChallenge, error) { + pending, err := ac.protocolRepository.LoadPendingPairChallenge() + if err != nil { + return nil, err + } + if pending == nil || pending.IsStale() { + return nil, nil + } + + serverPubKey, err := hex.DecodeString(pending.ServerPubKeyInHex) + if err != nil { + return nil, errors.Errorf("error decoding server public key: %w", err) + } + return security_card.NewFreshPairChallenge(serverPubKey), nil +} diff --git a/libwallet/domain/action/nfc/pair_request_challenge_action.go b/libwallet/domain/action/nfc/pair_request_challenge_action.go new file mode 100644 index 00000000..23da831e --- /dev/null +++ b/libwallet/domain/action/nfc/pair_request_challenge_action.go @@ -0,0 +1,39 @@ +package nfc + +import ( + "encoding/hex" + + "github.com/go-errors/errors" + + "github.com/muun/libwallet/data/security_cards" + "github.com/muun/libwallet/service" +) + +type PairRequestChallengeAction struct { + protocolRepository *security_cards.ProtocolRepository + houstonService service.HoustonService +} + +func NewPairRequestChallengeAction( + protocolRepository *security_cards.ProtocolRepository, + houstonService service.HoustonService, +) *PairRequestChallengeAction { + return &PairRequestChallengeAction{ + protocolRepository: protocolRepository, + houstonService: houstonService, + } +} + +func (ac *PairRequestChallengeAction) Run() error { + challengePair, err := ac.houstonService.PairRequestChallenge() + if err != nil { + return errors.Errorf("error requesting pair challenge from server: %w", err) + } + + _, err = hex.DecodeString(challengePair.ServerPubKeyInHex) + if err != nil { + return errors.Errorf("server returned malformed pair challenge pub key: %w", err) + } + + return ac.protocolRepository.SavePendingPairChallenge(challengePair.ServerPubKeyInHex) +} diff --git a/libwallet/domain/action/nfc/pair_security_card_action.go b/libwallet/domain/action/nfc/pair_security_card_action.go deleted file mode 100644 index 1604775c..00000000 --- a/libwallet/domain/action/nfc/pair_security_card_action.go +++ /dev/null @@ -1,69 +0,0 @@ -package nfc - -import ( - "crypto/rand" - "encoding/hex" - "errors" - "fmt" - "github.com/btcsuite/btcd/btcutil/base58" - "github.com/muun/libwallet/domain/nfc" - "github.com/muun/libwallet/storage" - "log/slog" -) - -type PairSecurityCardAction struct { - keyValueStorage *storage.KeyValueStorage - muunCard *nfc.MuunCard -} - -func NewPairSecurityCardAction(storage *storage.KeyValueStorage, muunCard *nfc.MuunCard) *PairSecurityCardAction { - return &PairSecurityCardAction{keyValueStorage: storage, muunCard: muunCard} -} - -func (ac *PairSecurityCardAction) Run() (*nfc.ExtendedPublicKey, error) { - xPub, _ := ac.keyValueStorage.Get(storage.KeySecurityCardXpubSerialized) - // If the user uninstall the app, xPub can be nil. In that case, we wipe nfc card in POC V3 - if xPub == nil { - // TODO: Remove this later. In POC V3 pairing/unpairing isn’t supported, so we wipe all data. - err := ac.muunCard.ResetCard() - if err != nil { - var cardError *nfc.CardError - if errors.As(err, &cardError) { - // Do not return error in first pairing - if cardError.Code != nfc.ErrSlotNotInitialized { - return nil, fmt.Errorf("error wipping card before pairing: %w", cardError) - } - } else { - return nil, fmt.Errorf("error wipping card before pairing: %w", err) - } - } - } - - seed := randomBytes(16) - var seed16Bytes [16]byte - copy(seed16Bytes[:], seed) - // FIXME: Remove this log when go to production - slog.Debug("pairing card with random seed:", slog.String("seed", hex.EncodeToString(seed))) - serializedXpub, err := ac.muunCard.GenerateKeyPair(seed16Bytes) - if err != nil { - return nil, fmt.Errorf("error pairing card: %w", err) - } - - // Persist Xpub in local storage; it will be used later for signature validation - base58Xpub := base58.Encode(serializedXpub.RawBytes) - err = ac.keyValueStorage.Save(storage.KeySecurityCardXpubSerialized, base58Xpub) - if err != nil { - return nil, fmt.Errorf("error storing Xpub: %w", err) - } - - return serializedXpub, nil -} - -func randomBytes(count int) []byte { - buf := make([]byte, count) - _, err := rand.Read(buf) - if err != nil { - panic(err) - } - return buf -} diff --git a/libwallet/domain/action/nfc/pair_security_card_action_v2.go b/libwallet/domain/action/nfc/pair_security_card_action_v2.go index fd39a7cc..a09a4c41 100644 --- a/libwallet/domain/action/nfc/pair_security_card_action_v2.go +++ b/libwallet/domain/action/nfc/pair_security_card_action_v2.go @@ -4,8 +4,9 @@ import ( "crypto/ecdh" "crypto/rand" "encoding/hex" - "errors" - "fmt" + + "github.com/go-errors/errors" + "github.com/muun/libwallet/domain/model/security_card" "github.com/muun/libwallet/domain/nfc" "github.com/muun/libwallet/service" @@ -23,23 +24,27 @@ func NewPairSecurityCardActionV2( muunCard *nfc.MuunCardV2, houstonService service.HoustonService, ) *PairSecurityCardActionV2 { - return &PairSecurityCardActionV2{keyValueStorage: storage, muunCard: muunCard, houstonService: houstonService} + return &PairSecurityCardActionV2{ + keyValueStorage: storage, + muunCard: muunCard, + houstonService: houstonService, + } } func (ac *PairSecurityCardActionV2) Run() (*security_card.SecurityCardPaired, error) { - challengePair, err := ac.houstonService.ChallengeSecurityCardPair() + challengePair, err := ac.houstonService.PairRequestChallenge() if err != nil { - return nil, fmt.Errorf("error requesting challenge to server: %w", err) + return nil, errors.Errorf("error requesting challenge to server: %w", err) } - serverPublicKey, err := hex.DecodeString(challengePair.ServerPublicKeyInHex) + serverPublicKey, err := hex.DecodeString(challengePair.ServerPubKeyInHex) if err != nil { - return nil, fmt.Errorf("error decoding server key: %w", err) + return nil, errors.Errorf("error decoding server key: %w", err) } clientPrivateKey, err := ecdh.P256().GenerateKey(rand.Reader) if err != nil { - return nil, fmt.Errorf("error generating client private key: %w", err) + return nil, errors.Errorf("error generating client private key: %w", err) } clientPublicKey := clientPrivateKey.PublicKey().Bytes() @@ -61,10 +66,10 @@ func (ac *PairSecurityCardActionV2) Run() (*security_card.SecurityCardPaired, er } } } - return nil, fmt.Errorf("error during pairing with card: %w", err) + return nil, errors.Errorf("error during pairing with card: %w", err) } - registerSecurityCardJson, err := service.MapRegisterSecurityCardJson( + registerSecurityCardJson, err := service.MapRegisterSecurityCardJson( //nolint:staticcheck // TODO: var registerSecurityCardJson should be registerSecurityCardJSON pairingResponse, clientPublicKey, ) @@ -73,7 +78,9 @@ func (ac *PairSecurityCardActionV2) Run() (*security_card.SecurityCardPaired, er return nil, err } - registerSecurityResponse, err := ac.houstonService.RegisterSecurityCard(*registerSecurityCardJson) + registerSecurityResponse, err := ac.houstonService.RegisterSecurityCard( + *registerSecurityCardJson, + ) if err != nil { var houstonError *service.HoustonResponseError if errors.As(err, &houstonError) { @@ -95,7 +102,7 @@ func (ac *PairSecurityCardActionV2) Run() (*security_card.SecurityCardPaired, er } } } - return nil, fmt.Errorf("server error registering security card: %w", err) + return nil, errors.Errorf("server error registering security card: %w", err) } return service.MapSecurityCardPaired(registerSecurityResponse), nil diff --git a/libwallet/domain/action/nfc/pair_sign_and_submit_challenge_action.go b/libwallet/domain/action/nfc/pair_sign_and_submit_challenge_action.go new file mode 100644 index 00000000..8aa0e2d8 --- /dev/null +++ b/libwallet/domain/action/nfc/pair_sign_and_submit_challenge_action.go @@ -0,0 +1,98 @@ +package nfc + +import ( + "github.com/go-errors/errors" + + "github.com/muun/libwallet/domain/model/security_card" +) + +// PairProgress identifies a non-terminal milestone reached during +// PairSignAndSubmitChallengeAction.Run. The action calls onProgress at +// each transition so the caller can react (e.g. relay it through a +// streaming RPC). +type PairProgress int + +const ( + // PairProgressRefreshingChallenge fires just before the action + // triggers the self-heal path because the persisted challenge was + // missing or stale. + PairProgressRefreshingChallenge PairProgress = iota + // PairProgressChallengeSigned fires right after the signing device + // produces its response, before the action calls Houston to register. + PairProgressChallengeSigned +) + +// PairSignAndSubmitChallengeAction orchestrates the full pair flow by +// composing four atomic actions in sequence: +// - pairLoadPersistedChallenge (cache check) +// - pairRequestChallenge (refresh on miss) +// - pairSignChallenge (NFC tap) +// - pairSubmitSolvedChallenge (Houston validation and register) +type PairSignAndSubmitChallengeAction struct { + pairLoadPersistedChallenge *PairLoadPersistedChallengeAction + pairRequestChallenge *PairRequestChallengeAction + pairSignChallenge *PairSignChallengeAction + pairSubmitSolvedChallenge *PairSubmitSolvedChallengeAction +} + +func NewPairSignAndSubmitChallengeAction( + pairLoadPersistedChallenge *PairLoadPersistedChallengeAction, + pairRequestChallenge *PairRequestChallengeAction, + pairSignChallenge *PairSignChallengeAction, + pairSubmitSolvedChallenge *PairSubmitSolvedChallengeAction, +) *PairSignAndSubmitChallengeAction { + return &PairSignAndSubmitChallengeAction{ + pairLoadPersistedChallenge: pairLoadPersistedChallenge, + pairRequestChallenge: pairRequestChallenge, + pairSignChallenge: pairSignChallenge, + pairSubmitSolvedChallenge: pairSubmitSolvedChallenge, + } +} + +// Run drives the orchestration. The optional onProgress callback is +// invoked at each non-terminal milestone (see PairProgress) and may +// return an error to abort the flow early — useful when the caller +// streams progress over a connection that can fail (e.g. a gRPC stream +// whose client disconnected), so we don't keep doing work that has no +// chance of being delivered. +func (ac *PairSignAndSubmitChallengeAction) Run( + onProgress func(PairProgress) error, +) (*security_card.SecurityCardPaired, error) { + if onProgress == nil { + onProgress = func(PairProgress) error { return nil } + } + + freshChallenge, err := ac.pairLoadPersistedChallenge.Run() + if err != nil { + return nil, err + } + + if freshChallenge == nil { + if err := onProgress(PairProgressRefreshingChallenge); err != nil { + return nil, err + } + + if err := ac.pairRequestChallenge.Run(); err != nil { + return nil, err + } + + freshChallenge, err = ac.pairLoadPersistedChallenge.Run() + if err != nil { + return nil, err + } + if freshChallenge == nil { + return nil, errors.Errorf("pair challenge missing after refresh") + } + } + + signedChallenge, err := ac.pairSignChallenge.Run(freshChallenge) + if err != nil { + return nil, err + } + + if err := onProgress(PairProgressChallengeSigned); err != nil { + return nil, err + } + + return ac.pairSubmitSolvedChallenge.Run(signedChallenge) +} diff --git a/libwallet/domain/action/nfc/pair_sign_challenge_action.go b/libwallet/domain/action/nfc/pair_sign_challenge_action.go new file mode 100644 index 00000000..9ea8d25d --- /dev/null +++ b/libwallet/domain/action/nfc/pair_sign_challenge_action.go @@ -0,0 +1,61 @@ +package nfc + +import ( + "crypto/ecdh" + "crypto/rand" + + "github.com/go-errors/errors" + + "github.com/muun/libwallet/domain/model/security_card" + "github.com/muun/libwallet/domain/nfc" +) + +type PairSignChallengeAction struct { + muunCard *nfc.MuunCardV2 +} + +func NewPairSignChallengeAction(muunCard *nfc.MuunCardV2) *PairSignChallengeAction { + return &PairSignChallengeAction{muunCard: muunCard} +} + +// Run drives the NFC tap. It returns the card's response together with the +// client public key so the downstream submit can pass both to Houston. +func (ac *PairSignChallengeAction) Run( + fresh *security_card.FreshPairChallenge, +) (*SignedPairChallenge, error) { + // TODO: remove the client keypair (and the second argument to muunCard.Pair below + // + the clientPublicKey param of MapRegisterSecurityCardJson) once the card + // firmware drops pub_client from its MAC input. Today the card still MACs over + // pub_client (retro-compat with cards in the field) and the mock mirrors that + // by re-running the same MAC check; the real Houston contract already omits it. + clientPrivateKey, err := ecdh.P256().GenerateKey(rand.Reader) + if err != nil { + return nil, errors.Errorf("error generating client private key: %w", err) + } + clientPublicKey := clientPrivateKey.PublicKey().Bytes() + + pairingResponse, err := ac.muunCard.Pair(fresh.ServerPublicKey, clientPublicKey) + if err != nil { + var cardError *nfc.CardError + if errors.As(err, &cardError) { + switch cardError.Code { + case nfc.ErrSlotOccupied: + return nil, &NoSlotsAvailableError{ + Message: "error during pairing with card", + Cause: err, + } + case nfc.ErrAppletIdNotFound: + return nil, &MuunAppletNotFoundError{ + Message: "muun applet not found", + Cause: err, + } + } + } + return nil, errors.Errorf("error during pairing with card: %w", err) + } + + return &SignedPairChallenge{ + PairingResponse: pairingResponse, + ClientPublicKey: clientPublicKey, + }, nil +} diff --git a/libwallet/domain/action/nfc/pair_submit_solved_challenge_action.go b/libwallet/domain/action/nfc/pair_submit_solved_challenge_action.go new file mode 100644 index 00000000..404c4147 --- /dev/null +++ b/libwallet/domain/action/nfc/pair_submit_solved_challenge_action.go @@ -0,0 +1,70 @@ +package nfc + +import ( + "github.com/go-errors/errors" + + "github.com/muun/libwallet/data/security_cards" + "github.com/muun/libwallet/domain/model/security_card" + "github.com/muun/libwallet/service" +) + +type PairSubmitSolvedChallengeAction struct { + protocolRepository *security_cards.ProtocolRepository + houstonService service.HoustonService +} + +func NewPairSubmitSolvedChallengeAction( + protocolRepository *security_cards.ProtocolRepository, + houstonService service.HoustonService, +) *PairSubmitSolvedChallengeAction { + return &PairSubmitSolvedChallengeAction{ + protocolRepository: protocolRepository, + houstonService: houstonService, + } +} + +// Run submits the signed pair challenge to Houston for verification and +// registration, then clears the persisted pending challenge from local +// storage (whether the submit succeeded or Houston rejected the attempt +// — either way the same challenge can't be reused). On success returns +// the paired card metadata + flags. +func (ac *PairSubmitSolvedChallengeAction) Run( + signed *SignedPairChallenge, +) (*security_card.SecurityCardPaired, error) { + defer ac.protocolRepository.ClearPendingPairChallenge() + + registerJSON, err := service.MapRegisterSecurityCardJson( + signed.PairingResponse, + signed.ClientPublicKey, + ) + if err != nil { + return nil, err + } + + registerResponse, err := ac.houstonService.RegisterSecurityCard(*registerJSON) + if err != nil { + var houstonError *service.HoustonResponseError + if errors.As(err, &houstonError) { + switch houstonError.ErrorCode { + case service.ErrInvalidMac: + return nil, &InvalidMacError{ + Message: "mac verification failed", + Cause: houstonError, + } + case service.ErrInvalidSignature: + return nil, &InvalidMacError{ + Message: "error validating signature", + Cause: houstonError, + } + case service.ErrChallengeExpired: + return nil, &ChallengeExpiredError{ + Message: "challenge has expired", + Cause: houstonError, + } + } + } + return nil, errors.Errorf("server error registering security card: %w", err) + } + + return service.MapSecurityCardPaired(registerResponse), nil +} diff --git a/libwallet/domain/action/nfc/reset_security_card_action.go b/libwallet/domain/action/nfc/reset_security_card_action.go deleted file mode 100644 index 7e72df36..00000000 --- a/libwallet/domain/action/nfc/reset_security_card_action.go +++ /dev/null @@ -1,31 +0,0 @@ -package nfc - -import ( - "fmt" - "github.com/muun/libwallet/domain/nfc" - "github.com/muun/libwallet/storage" -) - -type ResetSecurityCardAction struct { - keyValueStorage *storage.KeyValueStorage - muunCard *nfc.MuunCard -} - -func NewResetSecurityCardAction(storage *storage.KeyValueStorage, muunCard *nfc.MuunCard) *ResetSecurityCardAction { - return &ResetSecurityCardAction{keyValueStorage: storage, muunCard: muunCard} -} - -func (ac *ResetSecurityCardAction) Run() error { - - err := ac.muunCard.ResetCard() - if err != nil { - return fmt.Errorf("error unpairing card: %w", err) - } - - err = ac.keyValueStorage.Delete(storage.KeySecurityCardXpubSerialized) - if err != nil { - return fmt.Errorf("error removing Xpub: %w", err) - } - - return nil -} diff --git a/libwallet/domain/action/nfc/sign_message_security_card_action.go b/libwallet/domain/action/nfc/sign_message_security_card_action.go deleted file mode 100644 index 4e54629e..00000000 --- a/libwallet/domain/action/nfc/sign_message_security_card_action.go +++ /dev/null @@ -1,66 +0,0 @@ -package nfc - -import ( - "encoding/hex" - "fmt" - "github.com/btcsuite/btcd/btcutil/base58" - "github.com/muun/libwallet" - "github.com/muun/libwallet/domain/nfc" - "github.com/muun/libwallet/storage" - "log/slog" -) - -type SignMessageSecurityCardAction struct { - keyValueStorage *storage.KeyValueStorage - muunCard *nfc.MuunCard - network *libwallet.Network -} - -func NewSignMessageSecurityCardAction( - keyValueStorage *storage.KeyValueStorage, - muunCard *nfc.MuunCard, - network *libwallet.Network, -) *SignMessageSecurityCardAction { - return &SignMessageSecurityCardAction{keyValueStorage: keyValueStorage, muunCard: muunCard, network: network} -} - -func (ac *SignMessageSecurityCardAction) Run(messageHex string) (*nfc.SignedMessage, error) { - xPubSerialized, err := ac.keyValueStorage.Get(storage.KeySecurityCardXpubSerialized) - if err != nil { - return nil, fmt.Errorf("error loading Xpub from local: %w", err) - } - if xPubSerialized == nil { - // TODO: Remove this later. In POC V3 pairing/unpairing isn’t supported, so it does a pair in first interaction. - pairNfcCard := NewPairSecurityCardAction(ac.keyValueStorage, ac.muunCard) - xPub, err := pairNfcCard.Run() - if err != nil { - return nil, fmt.Errorf("error pairing card on first interaction: %w", err) - } - xPubSerialized = base58.Encode(xPub.RawBytes) - } - slog.Debug("WalletServer: xPubSerialized loaded", slog.Any("xPubSerialized", xPubSerialized)) - - pubKey, err := libwallet.NewHDPublicKeyFromString(xPubSerialized.(string), "m", ac.network) - if err != nil { - return nil, fmt.Errorf("error extracting public key from base58 xPub: %w", err) - } - - signedMessage, err := ac.muunCard.SignMessage(messageHex) - if err != nil { - return nil, fmt.Errorf("failed to sign message: %w", err) - } - - signedMessageHex := hex.EncodeToString(signedMessage.RawBytes) - // FIXME: Remove this log when go to production - slog.Debug("WalletServer: Signed Message", slog.Any("signature", signedMessageHex)) - - isValidated, err := ac.muunCard.VerifySignature(pubKey, []byte(messageHex), signedMessage.RawBytes) - if err != nil { - return nil, fmt.Errorf("error validating signature: %w", err) - } - if !isValidated { - return nil, fmt.Errorf("invalid signature: %s", signedMessageHex) - } - - return &nfc.SignedMessage{RawBytes: signedMessage.RawBytes}, nil -} diff --git a/libwallet/domain/action/nfc/sign_message_security_card_action_v2.go b/libwallet/domain/action/nfc/sign_message_security_card_action_v2.go index 037ed738..d931fdfb 100644 --- a/libwallet/domain/action/nfc/sign_message_security_card_action_v2.go +++ b/libwallet/domain/action/nfc/sign_message_security_card_action_v2.go @@ -2,13 +2,14 @@ package nfc import ( "encoding/hex" - "errors" - "fmt" + "log/slog" + + "github.com/go-errors/errors" + "github.com/muun/libwallet/domain/nfc" "github.com/muun/libwallet/service" "github.com/muun/libwallet/service/model" "github.com/muun/libwallet/storage" - "log/slog" ) type SignMessageSecurityCardActionV2 struct { @@ -36,7 +37,7 @@ func (ac *SignMessageSecurityCardActionV2) Run() error { value, err := ac.keyValueStorage.Get(storage.KeySecurityCardPairingSlot) if err != nil { - return fmt.Errorf("error loading security card info: %w", err) + return errors.Errorf("error loading security card info: %w", err) } if value == nil { slog.Debug("doing automatic pairing") @@ -62,22 +63,22 @@ func (ac *SignMessageSecurityCardActionV2) Run() error { } challengeResponse, err := ac.houstonService.ChallengeSecurityCardSign(request) if err != nil { - return fmt.Errorf("error requesting a challenge from houston: %w", err) + return errors.Errorf("error requesting a challenge from houston: %w", err) } challenge, err := service.MapSecurityCardSignChallengeResponse(challengeResponse) if err != nil { - return fmt.Errorf("fail to parse sign challenge response from houston: %w", err) + return errors.Errorf("fail to parse sign challenge response from houston: %w", err) } signChallengeResponse, err := ac.muunCard.SignChallenge(challenge, reasonBytes) if err != nil { - return fmt.Errorf("error signing challenge: %w", err) + return errors.Errorf("error signing challenge: %w", err) } cardPublicKeyInHex := hex.EncodeToString(signChallengeResponse.CardPublicKey) macInHex := hex.EncodeToString(signChallengeResponse.MAC) - securityCardChallengeJson := model.SolveSecurityCardChallengeJson{ + securityCardChallengeJson := model.SolveSecurityCardChallengeJson{ //nolint:staticcheck // TODO: var securityCardChallengeJson should be securityCardChallengeJSON PublicKeyInHex: cardPublicKeyInHex, MacInHex: macInHex, } @@ -99,7 +100,7 @@ func (ac *SignMessageSecurityCardActionV2) Run() error { } } } - return fmt.Errorf("error signing challenge: %w", err) + return errors.Errorf("error signing challenge: %w", err) } return nil diff --git a/libwallet/domain/action/nfc/signed_pair_challenge.go b/libwallet/domain/action/nfc/signed_pair_challenge.go new file mode 100644 index 00000000..78cda19f --- /dev/null +++ b/libwallet/domain/action/nfc/signed_pair_challenge.go @@ -0,0 +1,19 @@ +package nfc + +import "github.com/muun/libwallet/domain/nfc" + +// SignedPairChallenge bundles the output of the NFC sign step into a +// single value that the submit step consumes. It lives in the action +// package — not in domain/model/security_card alongside the other pair +// lifecycle types — because it carries *nfc.PairingResponse, and the +// model package can't depend on domain/nfc without creating an import +// cycle (domain/nfc already depends on the model package via the sign +// flow). The action package is the only layer where importing both is +// legal. +type SignedPairChallenge struct { + PairingResponse *nfc.PairingResponse + // ClientPublicKey is forwarded to the mock's MAC check today. See + // the TODO in PairSignChallengeAction.Run for the eventual removal + // once the card firmware drops the pub_client requirement. + ClientPublicKey []byte +} diff --git a/libwallet/domain/action/recovery/broadcast_recovery_tx_action.go b/libwallet/domain/action/recovery/broadcast_recovery_tx_action.go index ccd84951..e8f6ed0f 100644 --- a/libwallet/domain/action/recovery/broadcast_recovery_tx_action.go +++ b/libwallet/domain/action/recovery/broadcast_recovery_tx_action.go @@ -3,17 +3,21 @@ package recovery import ( "bytes" "encoding/hex" - "fmt" + "log/slog" + "github.com/btcsuite/btcd/wire" + "github.com/go-errors/errors" + "github.com/muun/libwallet/electrum" - "log/slog" ) type BroadcastRecoveryTxAction struct { electrumProvider *electrum.ServerProvider } -func NewBroadcastRecoveryTxAction(electrumProvider *electrum.ServerProvider) *BroadcastRecoveryTxAction { +func NewBroadcastRecoveryTxAction( + electrumProvider *electrum.ServerProvider, +) *BroadcastRecoveryTxAction { return &BroadcastRecoveryTxAction{ electrumProvider: electrumProvider, } @@ -34,7 +38,7 @@ func (s *BroadcastRecoveryTxAction) Run(tx *wire.MsgTx, log *slog.Logger) (strin err := tx.BtcEncode(txBytes, wire.ProtocolVersion, wire.WitnessEncoding) if err != nil { - return "", fmt.Errorf("error while encoding tx: %w", err) + return "", errors.Errorf("error while encoding tx: %w", err) } txHex := hex.EncodeToString(txBytes.Bytes()) diff --git a/libwallet/domain/action/recovery/build_recovery_tx_action.go b/libwallet/domain/action/recovery/build_recovery_tx_action.go index 6e70ba6d..1e44e8d6 100644 --- a/libwallet/domain/action/recovery/build_recovery_tx_action.go +++ b/libwallet/domain/action/recovery/build_recovery_tx_action.go @@ -3,14 +3,16 @@ package recovery import ( "encoding/hex" "fmt" + "math" + "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" + "github.com/muun/libwallet" "github.com/muun/libwallet/btcsuitew/txscriptw" "github.com/muun/libwallet/data/keys" "github.com/muun/libwallet/scanner" - "math" ) const dustThreshold = 546 @@ -20,14 +22,21 @@ type BuildSweepTxAction struct { network *libwallet.Network } -func NewBuildSweepTxAction(keyProvider keys.KeyProvider, network *libwallet.Network) *BuildSweepTxAction { +func NewBuildSweepTxAction( + keyProvider keys.KeyProvider, + network *libwallet.Network, +) *BuildSweepTxAction { return &BuildSweepTxAction{ keyProvider: keyProvider, network: network, } } -func (action *BuildSweepTxAction) Run(utxos []*scanner.Utxo, sweepAddress btcutil.Address, feeRateInSatsPerVByte float64) (*wire.MsgTx, error) { +func (action *BuildSweepTxAction) Run( + utxos []*scanner.Utxo, + sweepAddress btcutil.Address, + feeRateInSatsPerVByte float64, +) (*wire.MsgTx, error) { value := int64(0) tx := wire.NewMsgTx(2) @@ -64,14 +73,22 @@ func (action *BuildSweepTxAction) Run(utxos []*scanner.Utxo, sweepAddress btcuti } if tx.TxOut[0].Value < finalFee { - return nil, fmt.Errorf("fees (%d sats) exceed total funds (%d sats)", finalFee, tx.TxOut[0].Value) + return nil, fmt.Errorf( + "fees (%d sats) exceed total funds (%d sats)", + finalFee, + tx.TxOut[0].Value, + ) } // Reduce value by calculated fee. tx.TxOut[0].Value -= finalFee if tx.TxOut[0].Value < dustThreshold { - return nil, fmt.Errorf("output is sub-dust (%d sats) after deducting fees (%d sats)", tx.TxOut[0].Value, finalFee) + return nil, fmt.Errorf( + "output is sub-dust (%d sats) after deducting fees (%d sats)", + tx.TxOut[0].Value, + finalFee, + ) } return tx, nil @@ -121,7 +138,7 @@ type outpoint struct { utxo *scanner.Utxo } -func (o *outpoint) TxId() []byte { +func (o *outpoint) TxId() []byte { //nolint:staticcheck // should be TxID, but it's part of the gomobile contract with the apps raw, err := hex.DecodeString(o.utxo.TxID) if err != nil { panic(err) // we wrote this hex value ourselves, no input from anywhere else diff --git a/libwallet/domain/action/recovery/compute_and_store_encrypted_muun_key_action.go b/libwallet/domain/action/recovery/compute_and_store_encrypted_muun_key_action.go index 577e8850..6618a207 100644 --- a/libwallet/domain/action/recovery/compute_and_store_encrypted_muun_key_action.go +++ b/libwallet/domain/action/recovery/compute_and_store_encrypted_muun_key_action.go @@ -1,13 +1,15 @@ package recovery import ( - "fmt" + "log/slog" + "github.com/btcsuite/btcd/btcec/v2" + "github.com/go-errors/errors" + "github.com/muun/libwallet/data/keys" "github.com/muun/libwallet/domain/model/verifiable_muun_key" "github.com/muun/libwallet/service/model" "github.com/muun/libwallet/storage" - "log/slog" ) type ComputeAndStoreEncryptedMuunKeyAction struct { @@ -28,23 +30,23 @@ func NewComputeAndStoreEncryptedMuunKeyAction( // Verify and store the resulting encrypted muun key. This action overwrites existing keys. func (a *ComputeAndStoreEncryptedMuunKeyAction) Run( recoveryCodePublicKey *btcec.PublicKey, - verifiableMuunKeyJson *model.VerifiableMuunKeyJson, + verifiableMuunKeyJson *model.VerifiableMuunKeyJson, //nolint:staticcheck // TODO: method parameter verifiableMuunKeyJson should be verifiableMuunKeyJSON ) error { slog.Warn("ComputeAndStoreEncryptedMuunKeyAction.Run: start") userHDPrivateKey, err := a.keyProvider.UserPrivateKey() if err != nil { - return fmt.Errorf("error getting user key from KeyProvider: %w", err) + return errors.Errorf("error getting user key from KeyProvider: %w", err) } userEcPrivateKey, err := userHDPrivateKey.ECPrivateKey() if err != nil { - return fmt.Errorf("error obtaining user ec private key: %w", err) + return errors.Errorf("error obtaining user ec private key: %w", err) } muunHDPublicKey, err := a.keyProvider.MuunPublicKey() if err != nil { - return fmt.Errorf("error obtaining muun key from KeyProvider: %w", err) + return errors.Errorf("error obtaining muun key from KeyProvider: %w", err) } verifiableMuunKey, err := verifiable_muun_key.VerifiableMuunKeyFromJson(verifiableMuunKeyJson) diff --git a/libwallet/domain/action/recovery/compute_and_store_encrypted_muun_key_action_test.go b/libwallet/domain/action/recovery/compute_and_store_encrypted_muun_key_action_test.go new file mode 100644 index 00000000..fad5f470 --- /dev/null +++ b/libwallet/domain/action/recovery/compute_and_store_encrypted_muun_key_action_test.go @@ -0,0 +1,145 @@ +package recovery + +import ( + "testing" + + "github.com/go-errors/errors" + + "github.com/muun/libwallet/internal/testutils" + "github.com/muun/libwallet/storage" +) + +func TestComputeAndStoreEncryptedMuunKeyAction(t *testing.T) { + + t.Run("routes to correct storage slot based on proof", func(t *testing.T) { + testCases := []struct { + desc string + withProof bool + expectedSlot string + unexpectedSlot string + }{ + { + "verified key when proof is provided", + true, + storage.VerifiedEncryptedMuunKey, + storage.UnverifiedEncryptedMuunKey, + }, + { + "unverified key when no proof is provided", + false, + storage.UnverifiedEncryptedMuunKey, + storage.VerifiedEncryptedMuunKey, + }, + } + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + // Setup + keys := testutils.GenerateTestKeys() + kvStorage := testutils.NewTestKeyValueStorage(t) + keyProvider := testutils.NewMockKeyProvider(keys) + action := NewComputeAndStoreEncryptedMuunKeyAction(kvStorage, keyProvider) + vmkJSON := testutils.BuildVerifiableMuunKeyJson( + keys, + tc.withProof, + ) + + // Test + err := action.Run(keys.RecoveryCodeKey.PubKey(), vmkJSON) + if err != nil { + t.Fatalf("Run() error = %v", err) + } + + // Verify expected slot has data + got, err := kvStorage.Get(tc.expectedSlot) + if err != nil { + t.Fatalf("Get(%s) error = %v", tc.expectedSlot, err) + } + if got == nil { + t.Fatalf("expected key to be stored in %s", tc.expectedSlot) + } + + // Verify unexpected slot is empty + got, err = kvStorage.Get(tc.unexpectedSlot) + if err != nil { + t.Fatalf("Get(%s) error = %v", tc.unexpectedSlot, err) + } + if got != nil { + t.Fatalf("expected %s to be empty", tc.unexpectedSlot) + } + }) + } + }) + + t.Run("overwrites existing key on second run", func(t *testing.T) { + // Setup + keys := testutils.GenerateTestKeys() + kvStorage := testutils.NewTestKeyValueStorage(t) + keyProvider := testutils.NewMockKeyProvider(keys) + action := NewComputeAndStoreEncryptedMuunKeyAction(kvStorage, keyProvider) + + // First run + vmkJSON1 := testutils.BuildVerifiableMuunKeyJson( + keys, + true, + ) + err := action.Run(keys.RecoveryCodeKey.PubKey(), vmkJSON1) + if err != nil { + t.Fatalf("first Run() error = %v", err) + } + first, _ := kvStorage.Get(storage.VerifiedEncryptedMuunKey) + + // Second run (HPKE uses ephemeral keys, so ciphertext differs) + vmkJSON2 := testutils.BuildVerifiableMuunKeyJson( + keys, + true, + ) + err = action.Run(keys.RecoveryCodeKey.PubKey(), vmkJSON2) + if err != nil { + t.Fatalf("second Run() error = %v", err) + } + second, _ := kvStorage.Get(storage.VerifiedEncryptedMuunKey) + + // Verify value changed + if first.(string) == second.(string) { + t.Fatal("expected second Run to overwrite the first key with a different value") + } + }) + + t.Run("propagates user key error", func(t *testing.T) { + // Setup + keys := testutils.GenerateTestKeys() + kvStorage := testutils.NewTestKeyValueStorage(t) + keyProvider := testutils.NewMockKeyProvider(keys) + keyProvider.UserPrivateKeyErr = errors.New("keystore locked") + action := NewComputeAndStoreEncryptedMuunKeyAction(kvStorage, keyProvider) + vmkJSON := testutils.BuildVerifiableMuunKeyJson( + keys, + true, + ) + + // Test + err := action.Run(keys.RecoveryCodeKey.PubKey(), vmkJSON) + if err == nil { + t.Fatal("expected error when UserPrivateKey fails") + } + }) + + t.Run("propagates muun key error", func(t *testing.T) { + // Setup + keys := testutils.GenerateTestKeys() + kvStorage := testutils.NewTestKeyValueStorage(t) + keyProvider := testutils.NewMockKeyProvider(keys) + keyProvider.MuunPublicKeyErr = errors.New("keystore locked") + action := NewComputeAndStoreEncryptedMuunKeyAction(kvStorage, keyProvider) + vmkJSON := testutils.BuildVerifiableMuunKeyJson( + keys, + true, + ) + + // Test + err := action.Run(keys.RecoveryCodeKey.PubKey(), vmkJSON) + if err == nil { + t.Fatal("expected error when MuunPublicKey fails") + } + }) +} diff --git a/libwallet/domain/action/recovery/get_encrypted_user_key_action.go b/libwallet/domain/action/recovery/get_encrypted_user_key_action.go index d9d3f95c..a2ae473d 100644 --- a/libwallet/domain/action/recovery/get_encrypted_user_key_action.go +++ b/libwallet/domain/action/recovery/get_encrypted_user_key_action.go @@ -2,6 +2,7 @@ package recovery import ( "github.com/btcsuite/btcd/btcec/v2" + "github.com/muun/libwallet/data/keys" "github.com/muun/libwallet/domain/model/encrypted_key_v3" "github.com/muun/libwallet/storage" @@ -22,8 +23,8 @@ func NewGetEncryptedUserKeyAction( } } -// Compute the user encrypted key. If we already computed it, it is retrieved from storage so that the contents of the -// emergency kit do not change every time. +// Compute the user encrypted key. If we already computed it, it is retrieved from storage so +// that the contents of the emergency kit do not change every time. func (a *GetEncryptedUserKeyAction) Run(recoveryCodePublicKey *btcec.PublicKey) (string, error) { userExtendedPrivateKey, err := a.keyProvider.UserPrivateKey() @@ -40,7 +41,10 @@ func (a *GetEncryptedUserKeyAction) Run(recoveryCodePublicKey *btcec.PublicKey) return rawEncryptedKey.(string), nil } - encryptedKey, err := encrypted_key_v3.EncryptUserKey(userExtendedPrivateKey, recoveryCodePublicKey) + encryptedKey, err := encrypted_key_v3.EncryptUserKey( + userExtendedPrivateKey, + recoveryCodePublicKey, + ) if err != nil { return "", err } diff --git a/libwallet/domain/action/recovery/may_retrieve_encrypted_muun_key_action.go b/libwallet/domain/action/recovery/may_retrieve_encrypted_muun_key_action.go index 38e797cb..60f75018 100644 --- a/libwallet/domain/action/recovery/may_retrieve_encrypted_muun_key_action.go +++ b/libwallet/domain/action/recovery/may_retrieve_encrypted_muun_key_action.go @@ -41,11 +41,20 @@ func (a *MayRetrieveEncryptedMuunKeyAction) Run() (*EncryptedMuunKeyWithStatus, if key, ok := keys[storage.VerifiedEncryptedMuunKey]; ok && key != nil { encryptedMuunKey := key.(string) - return &EncryptedMuunKeyWithStatus{EncryptedMuunKey: &encryptedMuunKey, Status: HasVerifiedEncryptedMuunKey}, nil + return &EncryptedMuunKeyWithStatus{ + EncryptedMuunKey: &encryptedMuunKey, + Status: HasVerifiedEncryptedMuunKey, + }, nil } else if key, ok := keys[storage.UnverifiedEncryptedMuunKey]; ok && key != nil { encryptedMuunKey := key.(string) - return &EncryptedMuunKeyWithStatus{EncryptedMuunKey: &encryptedMuunKey, Status: OnlyHasUnverifiedEncryptedMuunKey}, nil + return &EncryptedMuunKeyWithStatus{ + EncryptedMuunKey: &encryptedMuunKey, + Status: OnlyHasUnverifiedEncryptedMuunKey, + }, nil } else { - return &EncryptedMuunKeyWithStatus{EncryptedMuunKey: nil, Status: HasNoEncryptedMuunKey}, nil + return &EncryptedMuunKeyWithStatus{ + EncryptedMuunKey: nil, + Status: HasNoEncryptedMuunKey, + }, nil } } diff --git a/libwallet/domain/action/recovery/populate_encrypted_muun_key_action.go b/libwallet/domain/action/recovery/populate_encrypted_muun_key_action.go index 873cccfa..7a340128 100644 --- a/libwallet/domain/action/recovery/populate_encrypted_muun_key_action.go +++ b/libwallet/domain/action/recovery/populate_encrypted_muun_key_action.go @@ -1,13 +1,15 @@ package recovery import ( - "fmt" + "log/slog" + "github.com/btcsuite/btcd/btcec/v2" + "github.com/go-errors/errors" + "github.com/muun/libwallet/data/keys" "github.com/muun/libwallet/domain/model/verifiable_muun_key" "github.com/muun/libwallet/service" "github.com/muun/libwallet/storage" - "log/slog" ) type PopulateEncryptedMuunKeyAction struct { @@ -38,17 +40,17 @@ func (a *PopulateEncryptedMuunKeyAction) Run(recoveryCodePublicKey *btcec.Public userHDPrivateKey, err := a.keyProvider.UserPrivateKey() if err != nil { - return fmt.Errorf("error getting user key from KeyProvider: %w", err) + return errors.Errorf("error getting user key from KeyProvider: %w", err) } userEcPrivateKey, err := userHDPrivateKey.ECPrivateKey() if err != nil { - return fmt.Errorf("error obtaining user ec private key: %w", err) + return errors.Errorf("error obtaining user ec private key: %w", err) } muunHDPublicKey, err := a.keyProvider.MuunPublicKey() if err != nil { - return fmt.Errorf("error obtaining muun key from KeyProvider: %w", err) + return errors.Errorf("error obtaining muun key from KeyProvider: %w", err) } currentStatus, err := a.getCurrentStatus() @@ -63,7 +65,7 @@ func (a *PopulateEncryptedMuunKeyAction) Run(recoveryCodePublicKey *btcec.Public } // we proceed, hoping to obtain a verified key - verifiableMuunKeyJson, err := a.houstonService.VerifiableMuunKey() + verifiableMuunKeyJson, err := a.houstonService.VerifiableMuunKey() //nolint:staticcheck // TODO: var verifiableMuunKeyJson should be verifiableMuunKeyJSON if err != nil { return err } diff --git a/libwallet/domain/action/recovery/populate_encrypted_muun_key_action_test.go b/libwallet/domain/action/recovery/populate_encrypted_muun_key_action_test.go new file mode 100644 index 00000000..ad687100 --- /dev/null +++ b/libwallet/domain/action/recovery/populate_encrypted_muun_key_action_test.go @@ -0,0 +1,170 @@ +package recovery + +import ( + "testing" + + "github.com/go-errors/errors" + + "github.com/muun/libwallet/internal/testutils" + "github.com/muun/libwallet/storage" +) + +func TestPopulateEncryptedMuunKeyAction(t *testing.T) { + + t.Run("stores key in correct slot when storage is empty", func(t *testing.T) { + testCases := []struct { + desc string + withProof bool + expectedSlot string + }{ + {"verified key when proof is provided", true, storage.VerifiedEncryptedMuunKey}, + {"unverified key when no proof is provided", false, storage.UnverifiedEncryptedMuunKey}, + } + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + // Setup + keys := testutils.GenerateTestKeys() + kvStorage := testutils.NewTestKeyValueStorage(t) + keyProvider := testutils.NewMockKeyProvider(keys) + vmkJSON := testutils.BuildVerifiableMuunKeyJson( + keys, + tc.withProof, + ) + houston := &testutils.MockHoustonService{ + VerifiableMuunKeyResult: *vmkJSON, + } + action := NewPopulateEncryptedMuunKeyAction(houston, kvStorage, keyProvider) + + // Test + err := action.Run(keys.RecoveryCodeKey.PubKey()) + if err != nil { + t.Fatalf("Run() error = %v", err) + } + + // Verify + got, err := kvStorage.Get(tc.expectedSlot) + if err != nil { + t.Fatalf("Get(%s) error = %v", tc.expectedSlot, err) + } + if got == nil { + t.Fatalf("expected key to be stored in %s", tc.expectedSlot) + } + }) + } + }) + + t.Run("returns early when verified key already exists", func(t *testing.T) { + // Setup + keys := testutils.GenerateTestKeys() + kvStorage := testutils.NewTestKeyValueStorage(t) + keyProvider := testutils.NewMockKeyProvider(keys) + err := kvStorage.Save(storage.VerifiedEncryptedMuunKey, "existing-verified-key") + if err != nil { + t.Fatalf("Save() error = %v", err) + } + + // Houston should NOT be called — if it is, the mock will return an error + houston := &testutils.MockHoustonService{ + VerifiableMuunKeyErr: errors.New("should not be called"), + } + action := NewPopulateEncryptedMuunKeyAction(houston, kvStorage, keyProvider) + + // Test + err = action.Run(keys.RecoveryCodeKey.PubKey()) + if err != nil { + t.Fatalf("Run() error = %v, expected early return", err) + } + + // Verify key is unchanged + got, _ := kvStorage.Get(storage.VerifiedEncryptedMuunKey) + if got.(string) != "existing-verified-key" { + t.Fatal("verified key should not have been modified") + } + }) + + t.Run("does not overwrite existing unverified key with another unverified", func(t *testing.T) { + // Setup + keys := testutils.GenerateTestKeys() + kvStorage := testutils.NewTestKeyValueStorage(t) + keyProvider := testutils.NewMockKeyProvider(keys) + err := kvStorage.Save(storage.UnverifiedEncryptedMuunKey, "existing-unverified-key") + if err != nil { + t.Fatalf("Save() error = %v", err) + } + vmkJSON := testutils.BuildVerifiableMuunKeyJson( + keys, + false, + ) + houston := &testutils.MockHoustonService{ + VerifiableMuunKeyResult: *vmkJSON, + } + action := NewPopulateEncryptedMuunKeyAction(houston, kvStorage, keyProvider) + + // Test + err = action.Run(keys.RecoveryCodeKey.PubKey()) + if err != nil { + t.Fatalf("Run() error = %v", err) + } + + // Verify key is unchanged + got, _ := kvStorage.Get(storage.UnverifiedEncryptedMuunKey) + if got.(string) != "existing-unverified-key" { + t.Fatal("unverified key should not have been overwritten") + } + }) + + t.Run("upgrades unverified to verified when proof becomes available", func(t *testing.T) { + // Setup + keys := testutils.GenerateTestKeys() + kvStorage := testutils.NewTestKeyValueStorage(t) + keyProvider := testutils.NewMockKeyProvider(keys) + err := kvStorage.Save(storage.UnverifiedEncryptedMuunKey, "existing-unverified-key") + if err != nil { + t.Fatalf("Save() error = %v", err) + } + vmkJSON := testutils.BuildVerifiableMuunKeyJson( + keys, + true, + ) + houston := &testutils.MockHoustonService{ + VerifiableMuunKeyResult: *vmkJSON, + } + action := NewPopulateEncryptedMuunKeyAction(houston, kvStorage, keyProvider) + + // Test + err = action.Run(keys.RecoveryCodeKey.PubKey()) + if err != nil { + t.Fatalf("Run() error = %v", err) + } + + // Verify verified key was stored + got, _ := kvStorage.Get(storage.VerifiedEncryptedMuunKey) + if got == nil { + t.Fatal("expected verified key to be stored after upgrade") + } + + // Verify unverified key was NOT deleted (production code writes verified but keeps + // unverified) + unverified, _ := kvStorage.Get(storage.UnverifiedEncryptedMuunKey) + if unverified == nil || unverified.(string) != "existing-unverified-key" { + t.Fatal("unverified key should remain in storage after upgrade") + } + }) + + t.Run("propagates houston error", func(t *testing.T) { + // Setup + keys := testutils.GenerateTestKeys() + kvStorage := testutils.NewTestKeyValueStorage(t) + keyProvider := testutils.NewMockKeyProvider(keys) + houston := &testutils.MockHoustonService{ + VerifiableMuunKeyErr: errors.New("houston error"), + } + action := NewPopulateEncryptedMuunKeyAction(houston, kvStorage, keyProvider) + + // Test + err := action.Run(keys.RecoveryCodeKey.PubKey()) + if err == nil { + t.Fatal("expected error from Houston to propagate") + } + }) +} diff --git a/libwallet/domain/action/recovery/scan_for_funds_action.go b/libwallet/domain/action/recovery/scan_for_funds_action.go index d1174b40..61297e65 100644 --- a/libwallet/domain/action/recovery/scan_for_funds_action.go +++ b/libwallet/domain/action/recovery/scan_for_funds_action.go @@ -1,12 +1,14 @@ package recovery import ( - "fmt" + "log/slog" + + "github.com/go-errors/errors" + "github.com/muun/libwallet" "github.com/muun/libwallet/data/keys" "github.com/muun/libwallet/electrum" "github.com/muun/libwallet/scanner" - "log/slog" ) type ScanForFundsAction struct { @@ -15,7 +17,11 @@ type ScanForFundsAction struct { network *libwallet.Network } -func NewScanForFundsAction(keyProvider keys.KeyProvider, electrumProvider *electrum.ServerProvider, network *libwallet.Network) *ScanForFundsAction { +func NewScanForFundsAction( + keyProvider keys.KeyProvider, + electrumProvider *electrum.ServerProvider, + network *libwallet.Network, +) *ScanForFundsAction { return &ScanForFundsAction{ keyProvider: keyProvider, electrumProvider: electrumProvider, @@ -32,7 +38,11 @@ func (action *ScanForFundsAction) Run(logger *slog.Logger) (<-chan *scanner.Repo const electrumPoolSize = 8 connectionPool := electrum.NewPool(electrumPoolSize, true, logger) - utxoScanner := scanner.NewScanner(connectionPool, action.electrumProvider, action.network.ToParams()) + utxoScanner := scanner.NewScanner( + connectionPool, + action.electrumProvider, + action.network.ToParams(), + ) return utxoScanner.Scan(addresses), nil } @@ -53,7 +63,7 @@ func generateAddresses(keyProvider keys.KeyProvider) (chan libwallet.MuunAddress maxIndex := keyProvider.MaxDerivedIndex() if maxIndex == 0 { - return nil, fmt.Errorf("cannot generate 0 addresses") + return nil, errors.Errorf("cannot generate 0 addresses") } addresses := addrGen.Stream(int64(maxIndex)) diff --git a/libwallet/domain/action/recovery/sign_recovery_tx_action.go b/libwallet/domain/action/recovery/sign_recovery_tx_action.go index e993f4cf..3b5a0393 100644 --- a/libwallet/domain/action/recovery/sign_recovery_tx_action.go +++ b/libwallet/domain/action/recovery/sign_recovery_tx_action.go @@ -2,8 +2,10 @@ package recovery import ( "bytes" - "fmt" + "github.com/btcsuite/btcd/wire" + "github.com/go-errors/errors" + "github.com/muun/libwallet" "github.com/muun/libwallet/data/keys" "github.com/muun/libwallet/scanner" @@ -14,14 +16,21 @@ type SignSweepTxAction struct { network *libwallet.Network } -func NewSignSweepTxAction(keyProvider keys.KeyProvider, network *libwallet.Network) *SignSweepTxAction { +func NewSignSweepTxAction( + keyProvider keys.KeyProvider, + network *libwallet.Network, +) *SignSweepTxAction { return &SignSweepTxAction{ keyProvider: keyProvider, network: network, } } -func (action *SignSweepTxAction) Run(utxos []*scanner.Utxo, tx *wire.MsgTx, recoveryCode string) (*wire.MsgTx, error) { +func (action *SignSweepTxAction) Run( + utxos []*scanner.Utxo, + tx *wire.MsgTx, + recoveryCode string, +) (*wire.MsgTx, error) { userPrivateKey, err := action.keyProvider.UserPrivateKey() if err != nil { return nil, err @@ -35,7 +44,9 @@ func (action *SignSweepTxAction) Run(utxos []*scanner.Utxo, tx *wire.MsgTx, reco return buildSignedSweepTx(utxos, tx, userPrivateKey, muunPrivateKey) } -func (action *SignSweepTxAction) fetchMuunPrivateKey(recoveryCode string) (*libwallet.HDPrivateKey, error) { +func (action *SignSweepTxAction) fetchMuunPrivateKey( + recoveryCode string, +) (*libwallet.HDPrivateKey, error) { encryptedKeyData, err := action.keyProvider.EncryptedMuunPrivateKey() if err != nil { return nil, err @@ -49,22 +60,31 @@ func (action *SignSweepTxAction) fetchMuunPrivateKey(recoveryCode string) (*libw return muunKeyData.Key, nil } -func decryptKeys(encryptedKey *libwallet.EncryptedPrivateKeyInfo, recoveryCode string, network *libwallet.Network) (*libwallet.DecryptedPrivateKey, error) { +func decryptKeys( + encryptedKey *libwallet.EncryptedPrivateKeyInfo, + recoveryCode string, + network *libwallet.Network, +) (*libwallet.DecryptedPrivateKey, error) { decryptionKey, err := libwallet.RecoveryCodeToKey(recoveryCode, encryptedKey.Salt) if err != nil { - return nil, fmt.Errorf("failed to process recovery code: %w", err) + return nil, errors.Errorf("failed to process recovery code: %w", err) } decryptedKey, err := decryptionKey.DecryptKey(encryptedKey, network) if err != nil { - return nil, fmt.Errorf("failed to decrypt key: %w", err) + return nil, errors.Errorf("failed to decrypt key: %w", err) } return decryptedKey, nil } -func buildSignedSweepTx(utxos []*scanner.Utxo, unsignedSweepTx *wire.MsgTx, userKey *libwallet.HDPrivateKey, muunKey *libwallet.HDPrivateKey) (*wire.MsgTx, error) { +func buildSignedSweepTx( + utxos []*scanner.Utxo, + unsignedSweepTx *wire.MsgTx, + userKey *libwallet.HDPrivateKey, + muunKey *libwallet.HDPrivateKey, +) (*wire.MsgTx, error) { inputList := &libwallet.InputList{} userNonces := libwallet.EmptyMusigNonces() diff --git a/libwallet/domain/action/reset/reset_action.go b/libwallet/domain/action/reset/reset_action.go new file mode 100644 index 00000000..43ccdafa --- /dev/null +++ b/libwallet/domain/action/reset/reset_action.go @@ -0,0 +1,48 @@ +package reset + +import ( + "os" + + "github.com/go-errors/errors" + + "github.com/muun/libwallet/storage" + "github.com/muun/libwallet/walletdb" +) + +type ResetDataAction interface { + Run() error +} + +type resetDataAction struct { + dbPath string + pool *walletdb.Pool + migrationPlan []storage.Migration +} + +func NewResetDataAction( + dbPath string, + pool *walletdb.Pool, + migrationPlan []storage.Migration, +) ResetDataAction { + return &resetDataAction{dbPath, pool, migrationPlan} +} + +func (a *resetDataAction) Run() error { + wipeDB := func() (string, error) { + for _, suffix := range []string{"", "-wal", "-shm"} { + p := a.dbPath + suffix + if err := os.Remove(p); err != nil && !os.IsNotExist(err) { + return "", errors.Errorf("reset: remove %s: %w", p, err) + } + } + return a.dbPath, nil + } + runMigrations := func(db *walletdb.DB) error { + _, err := storage.RunKeyValueMigrations(db, a.migrationPlan) + if err != nil { + return errors.Errorf("reset: kv migrations: %w", err) + } + return nil + } + return a.pool.ReplaceDB(wipeDB, runMigrations) +} diff --git a/libwallet/domain/action/reset/reset_action_test.go b/libwallet/domain/action/reset/reset_action_test.go new file mode 100644 index 00000000..9664ff02 --- /dev/null +++ b/libwallet/domain/action/reset/reset_action_test.go @@ -0,0 +1,89 @@ +package reset_test + +import ( + "path" + "sync" + "testing" + + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + + "github.com/muun/libwallet/domain/action/reset" + "github.com/muun/libwallet/storage" + "github.com/muun/libwallet/walletdb" +) + +func TestResetDataAction_Integration(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + suite.Run(t, new(resetDataActionSuite)) +} + +type resetDataActionSuite struct { + suite.Suite + *require.Assertions + + kvs *storage.KeyValueStorage + action reset.ResetDataAction +} + +func (s *resetDataActionSuite) SetupTest() { + s.Assertions = require.New(s.T()) + + dbPath := path.Join(s.T().TempDir(), "test.db") + var schema map[string]storage.Classification + pool, err := walletdb.NewPool(dbPath, func(db *walletdb.DB) error { + var migErr error + schema, migErr = storage.RunKeyValueMigrations(db, storage.BuildKVMigrationPlan()) + return migErr + }) + s.NoError(err) + s.T().Cleanup(func() { pool.Close() }) + + s.kvs = storage.NewKeyValueStorage(pool.NewKeyValueRepository(), schema) + s.action = reset.NewResetDataAction(dbPath, pool, storage.BuildKVMigrationPlan()) +} + +func (s *resetDataActionSuite) Test_ResetClearsAndReopensDB() { + s.NoError(s.kvs.Save(storage.KeyBiometricsOptIn, true)) + + val, err := s.kvs.Get(storage.KeyBiometricsOptIn) + s.NoError(err) + s.Equal(true, val) + + s.NoError(s.action.Run()) + + val, err = s.kvs.Get(storage.KeyBiometricsOptIn) + s.NoError(err) + s.Nil(val, "value should be cleared after reset") + + s.NoError(s.kvs.Save(storage.KeyBiometricsOptIn, false)) + val, err = s.kvs.Get(storage.KeyBiometricsOptIn) + s.NoError(err) + s.Equal(false, val) +} + +func (s *resetDataActionSuite) Test_ConcurrentGetsDuringReset() { + const goroutines = 20 + var wg sync.WaitGroup + wg.Add(goroutines) + + for range goroutines { + go func() { + defer wg.Done() + for range 50 { + _, _ = s.kvs.Get(storage.KeyBiometricsOptIn) + } + }() + } + + s.NoError(s.action.Run()) + wg.Wait() + + // Storage must still work after concurrent reset. + s.NoError(s.kvs.Save(storage.KeyBiometricsOptIn, true)) + val, err := s.kvs.Get(storage.KeyBiometricsOptIn) + s.NoError(err) + s.Equal(true, val) +} diff --git a/libwallet/domain/action/security_cards_marketplace/get_security_cards_marketplace_action.go b/libwallet/domain/action/security_cards_marketplace/get_security_cards_marketplace_action.go index 6ff13c20..77e6366f 100644 --- a/libwallet/domain/action/security_cards_marketplace/get_security_cards_marketplace_action.go +++ b/libwallet/domain/action/security_cards_marketplace/get_security_cards_marketplace_action.go @@ -1,69 +1,25 @@ package security_cards_marketplace import ( + "fmt" + "github.com/muun/libwallet/domain/model/security_cards_marketplace" "github.com/muun/libwallet/service" - "github.com/muun/libwallet/service/model" ) type GetSecurityCardsMarketplaceAction struct { + houstonService service.HoustonService } -func NewGetSecurityCardsMarketplaceAction() *GetSecurityCardsMarketplaceAction { - return &GetSecurityCardsMarketplaceAction{} +func NewGetSecurityCardsMarketplaceAction(houstonService service.HoustonService) *GetSecurityCardsMarketplaceAction { //nolint:lll // TODO: line too long + return &GetSecurityCardsMarketplaceAction{houstonService: houstonService} } -func (ac *GetSecurityCardsMarketplaceAction) Run() (*security_cards_marketplace.Marketplace, error) { - marketplaceJson := model.SecurityCardsMarketplaceJson{ - Providers: []model.SecurityCardsProviderJson{ - { - Name: "Constellations", - ColorHex: "#B19B6A", - Material: "plastic", - Price: 37500, - ShippingCost: 30000, - CurrencyCode: "ARS", - SecurityCards: []model.SecurityCardJson{ - {Image: "sc_constellations_scorpius", Stock: 10}, - {Image: "sc_constellations_gemini", Stock: 10}, - {Image: "sc_constellations_sagitarius", Stock: 10}, - {Image: "sc_constellations_virgo", Stock: 10}, - }, - }, - { - Name: "Numbers", - ColorHex: "#D9DBDD", - Material: "plastic", - Price: 30000, - ShippingCost: 15000, - CurrencyCode: "ARS", - SecurityCards: []model.SecurityCardJson{ - {Image: "sc_numbers_1", Stock: 10}, - {Image: "sc_numbers_2", Stock: 10}, - {Image: "sc_numbers_3", Stock: 10}, - {Image: "sc_numbers_4", Stock: 10}, - {Image: "sc_numbers_5", Stock: 10}, - {Image: "sc_numbers_6", Stock: 10}, - {Image: "sc_numbers_7", Stock: 10}, - {Image: "sc_numbers_8", Stock: 10}, - {Image: "sc_numbers_9", Stock: 10}, - }, - }, - { - Name: "Planets", - ColorHex: "#158E5A", - Material: "plastic", - Price: 76485, - ShippingCost: 43500, - CurrencyCode: "ARS", - SecurityCards: []model.SecurityCardJson{ - {Image: "sc_planets_earth", Stock: 10}, - {Image: "sc_planets_mars", Stock: 10}, - }, - }, - }, +func (ac *GetSecurityCardsMarketplaceAction) Run() (*security_cards_marketplace.Marketplace, error) { //nolint:lll // TODO: line too long + marketplaceJson, err := ac.houstonService.FetchSecurityCardsMarketplace() //nolint:staticcheck // TODO: var marketplaceJson should be marketplaceJSON + if err != nil { + return nil, fmt.Errorf("error fetching sc marketplace from server: %w", err) //nolint:forbidigo // TODO: use errors.Errorf from go-errors for stack traces } - marketplace, err := service.MapSecurityCardsMarketplace(marketplaceJson) - return marketplace, err + return service.MapSecurityCardsMarketplace(marketplaceJson) } diff --git a/libwallet/domain/diagnostic_mode/diagnostic_session_data.go b/libwallet/domain/diagnostic_mode/diagnostic_session_data.go index 0e001111..818d1c82 100644 --- a/libwallet/domain/diagnostic_mode/diagnostic_session_data.go +++ b/libwallet/domain/diagnostic_mode/diagnostic_session_data.go @@ -2,14 +2,16 @@ package diagnostic_mode import ( "bytes" - "fmt" + "log/slog" + "github.com/btcsuite/btcd/wire" + "github.com/go-errors/errors" + "github.com/muun/libwallet/scanner" - "log/slog" ) type DiagnosticSessionData struct { - Id string + Id string //nolint:staticcheck // TODO: struct field Id should be ID LogBuffer *bytes.Buffer Logger *slog.Logger LastScanReport *scanner.Report @@ -20,7 +22,7 @@ var diagnosticData = make(map[string]*DiagnosticSessionData) func AddDiagnosticSession(data *DiagnosticSessionData) error { if _, ok := diagnosticData[data.Id]; ok { - return fmt.Errorf("id %s already exists", data.Id) + return errors.Errorf("id %s already exists", data.Id) } diagnosticData[data.Id] = data diff --git a/libwallet/domain/model/emergency_kit/go_render/assets/fonts.go b/libwallet/domain/model/emergency_kit/go_render/assets/fonts.go index b03ca1f8..238f5502 100644 --- a/libwallet/domain/model/emergency_kit/go_render/assets/fonts.go +++ b/libwallet/domain/model/emergency_kit/go_render/assets/fonts.go @@ -2,9 +2,10 @@ package assets import ( _ "embed" - "github.com/muun/libwallet/data/emergency_kit/resources" "github.com/phpdave11/gofpdf" + + "github.com/muun/libwallet/data/emergency_kit/resources" ) const ( diff --git a/libwallet/domain/model/emergency_kit/go_render/assets/localizable.go b/libwallet/domain/model/emergency_kit/go_render/assets/localizable.go index 81f5deef..9f4e654c 100644 --- a/libwallet/domain/model/emergency_kit/go_render/assets/localizable.go +++ b/libwallet/domain/model/emergency_kit/go_render/assets/localizable.go @@ -3,8 +3,9 @@ package assets import ( _ "embed" "encoding/json" - "github.com/muun/libwallet/data/emergency_kit/resources" "time" + + "github.com/muun/libwallet/data/emergency_kit/resources" ) //go:embed localizable/en.json @@ -49,7 +50,7 @@ const ( // 7. Run tests to ensure the new language is complete: // go test ./emergencykit/go_render/assets -v type Translations struct { - Lang Language + Lang Language Header struct { Title string `json:"title"` VerificationPrefix string `json:"verification_prefix"` @@ -85,6 +86,15 @@ type Translations struct { } `json:"advanced"` } +func NewTranslations(lang Language, data []byte) (*Translations, error) { + var t Translations + if err := json.Unmarshal(data, &t); err != nil { + return nil, err + } + t.Lang = lang + return &t, nil +} + func LoadTranslations(lang Language) (*Translations, error) { var data []byte if lang == Spanish { @@ -92,15 +102,7 @@ func LoadTranslations(lang Language) (*Translations, error) { } else { data = englishJSON } - - var t Translations - err := json.Unmarshal(data, &t) - if err != nil { - return nil, err - } - t.Lang = lang - - return &t, nil + return NewTranslations(lang, data) } func (t *Translations) LocalizedDate(date time.Time) string { diff --git a/libwallet/domain/model/emergency_kit/go_render/assets/localizable_test.go b/libwallet/domain/model/emergency_kit/go_render/assets/localizable_test.go index 3c66a67d..1b25da15 100644 --- a/libwallet/domain/model/emergency_kit/go_render/assets/localizable_test.go +++ b/libwallet/domain/model/emergency_kit/go_render/assets/localizable_test.go @@ -20,15 +20,20 @@ func TestTranslationStructMatchesJSON(t *testing.T) { for _, lang := range languages { t.Run(string(lang.name), func(t *testing.T) { // Parse JSON into a generic map - var jsonMap map[string]interface{} + var jsonMap map[string]interface{} //nolint:modernize // TODO: use any instead of interface{} err := json.Unmarshal(lang.data, &jsonMap) if err != nil { t.Fatalf("Failed to unmarshal %s JSON: %v", lang.name, err) } // Validate structure matches - translations := Translations{} - validateJSONStructMatch(t, string(lang.name), "", jsonMap, reflect.TypeOf(translations)) + validateJSONStructMatch( + t, + string(lang.name), + "", + jsonMap, + reflect.TypeFor[Translations](), + ) }) } } @@ -38,7 +43,7 @@ func validateJSONStructMatch( t *testing.T, lang string, path string, - jsonMap map[string]interface{}, + jsonMap map[string]interface{}, //nolint:modernize // TODO: use any instead of interface{} structType reflect.Type, ) { if structType.Kind() != reflect.Struct { @@ -75,7 +80,7 @@ func validateJSONStructMatch( } // If it's a nested object, recurse - if nestedMap, ok := jsonValue.(map[string]interface{}); ok { + if nestedMap, ok := jsonValue.(map[string]interface{}); ok { //nolint:modernize // TODO: use any instead of interface{} validateJSONStructMatch(t, lang, fullPath, nestedMap, field.Type) } } diff --git a/libwallet/domain/model/emergency_kit/go_render/assets/measures.go b/libwallet/domain/model/emergency_kit/go_render/assets/measures.go index b82a283b..c7d37122 100644 --- a/libwallet/domain/model/emergency_kit/go_render/assets/measures.go +++ b/libwallet/domain/model/emergency_kit/go_render/assets/measures.go @@ -8,10 +8,9 @@ import ( var StandardHorizontalMargin = resources.Mm(16) // BodyParagraphLineHeight -// -// Line height are the real size each line requires considering its bottom and top space when -// they are multiline. It dependes on how big the font is, but it goes from fontSize*1.25 to fontSize*1.75 -// In the css all these values are explicit. +// Line height are the real size each line requires considering its bottom and top space when they +// are multiline. It dependes on how big the font is, but it goes from fontSize*1.25 to fontSize*1.75 +// In the css all these values are explicit. var BodyParagraphLineHeight = resources.Mm(24) var OutputDescriptorsLineHeight = resources.Mm(23) diff --git a/libwallet/domain/model/emergency_kit/go_render/components/advanced/advanced_component.go b/libwallet/domain/model/emergency_kit/go_render/components/advanced/advanced_component.go index 05a9ca9f..9c7311fa 100644 --- a/libwallet/domain/model/emergency_kit/go_render/components/advanced/advanced_component.go +++ b/libwallet/domain/model/emergency_kit/go_render/components/advanced/advanced_component.go @@ -20,6 +20,10 @@ type DescriptorLine struct { Segments []DescriptorSegment } +func NewDescriptorLine() DescriptorLine { + return DescriptorLine{} +} + type SegmentType int const ( @@ -34,6 +38,10 @@ type DescriptorSegment struct { Type SegmentType } +func NewDescriptorSegment(text string, segmentType SegmentType) DescriptorSegment { + return DescriptorSegment{Text: text, Type: segmentType} +} + func NewAdvancedComponent( pdf *emergency_kit.PdfExtensions, descriptors []string, @@ -71,11 +79,23 @@ func (r *AdvancedComponent) Height() float64 { innerWidth := r.pdf.GetDrawablePageWidth() assets.SetBodyParagraphFont(r.pdf.Fpdf) - introLines := r.pdf.LineCountWithLetterSpacing(innerWidth, r.IntroParagraph, assets.BodyLetterSpacing) + introLines := r.pdf.LineCountWithLetterSpacing( + innerWidth, + r.IntroParagraph, + assets.BodyLetterSpacing, + ) assets.SetDescriptorFont(r.pdf.Fpdf) - closing1Lines := r.pdf.LineCountWithLetterSpacing(innerWidth, r.ClosingParagraph1, assets.BodyLetterSpacing) - closing2Lines := r.pdf.LineCountWithLetterSpacing(innerWidth, r.ClosingParagraph2, assets.BodyLetterSpacing) + closing1Lines := r.pdf.LineCountWithLetterSpacing( + innerWidth, + r.ClosingParagraph1, + assets.BodyLetterSpacing, + ) + closing2Lines := r.pdf.LineCountWithLetterSpacing( + innerWidth, + r.ClosingParagraph2, + assets.BodyLetterSpacing, + ) descriptorsHeight := descriptorsPadding + float64(len(r.Descriptors))*descriptorLineHeight + @@ -116,7 +136,12 @@ func (r *AdvancedComponent) Render() { assets.SetBodyParagraphFont(r.pdf.Fpdf) assets.SetSecondaryTextColor(r.pdf.Fpdf) r.pdf.SetXY(innerStartX, r.pdf.GetY()) - r.pdf.MultiCellWithLetterSpacing(innerWidth, assets.BodyParagraphLineHeight, r.IntroParagraph, assets.BodyLetterSpacing) + r.pdf.MultiCellWithLetterSpacing( + innerWidth, + assets.BodyParagraphLineHeight, + r.IntroParagraph, + assets.BodyLetterSpacing, + ) // Render descriptors box r.renderDescriptors(innerStartX, innerWidth) @@ -126,14 +151,24 @@ func (r *AdvancedComponent) Render() { assets.SetBodyParagraphFont(r.pdf.Fpdf) assets.SetSecondaryTextColor(r.pdf.Fpdf) r.pdf.SetXY(innerStartX, r.pdf.GetY()) - r.pdf.MultiCellWithLetterSpacing(innerWidth, assets.BodyParagraphLineHeight, r.ClosingParagraph1, assets.BodyLetterSpacing) + r.pdf.MultiCellWithLetterSpacing( + innerWidth, + assets.BodyParagraphLineHeight, + r.ClosingParagraph1, + assets.BodyLetterSpacing, + ) // Render closing paragraph 2 r.pdf.SetY(r.pdf.GetY() + assets.StandardHorizontalMargin) assets.SetBodyParagraphFont(r.pdf.Fpdf) assets.SetSecondaryTextColor(r.pdf.Fpdf) r.pdf.SetXY(innerStartX, r.pdf.GetY()) - r.pdf.MultiCellWithLetterSpacing(innerWidth, assets.BodyParagraphLineHeight, r.ClosingParagraph2, assets.BodyLetterSpacing) + r.pdf.MultiCellWithLetterSpacing( + innerWidth, + assets.BodyParagraphLineHeight, + r.ClosingParagraph2, + assets.BodyLetterSpacing, + ) } // renderDescriptors is implemented in descriptors_render.go diff --git a/libwallet/domain/model/emergency_kit/go_render/components/advanced/descriptors_render.go b/libwallet/domain/model/emergency_kit/go_render/components/advanced/descriptors_render.go index 2edd2fbb..2af8bac9 100644 --- a/libwallet/domain/model/emergency_kit/go_render/components/advanced/descriptors_render.go +++ b/libwallet/domain/model/emergency_kit/go_render/components/advanced/descriptors_render.go @@ -2,6 +2,7 @@ package advanced import ( "encoding/hex" + "github.com/muun/libwallet/domain/model/emergency_kit/go_render/assets" ) @@ -12,7 +13,7 @@ var ( const fingerprintLength = 8 func parseDescriptor(descriptor string) DescriptorLine { - line := DescriptorLine{} + line := NewDescriptorLine() currentText := "" for i := 0; i < len(descriptor); i++ { @@ -73,10 +74,7 @@ func (line *DescriptorLine) addSegment(text string, segmentType SegmentType) { if text == "" { return } - line.Segments = append(line.Segments, DescriptorSegment{ - Text: text, - Type: segmentType, - }) + line.Segments = append(line.Segments, NewDescriptorSegment(text, segmentType)) } func (r *AdvancedComponent) renderDescriptors(startX float64, width float64) { @@ -124,6 +122,14 @@ func (r *AdvancedComponent) renderDescriptorLine(line DescriptorLine, lineHeight case SegmentChecksum: assets.SetDescriptorChecksumColor(r.pdf.Fpdf) } - currentX = r.pdf.RenderTextWithLetterSpacing(currentX, currentY, segment.Text, assets.BodyLetterSpacing, "L", "T", lineHeight) + currentX = r.pdf.RenderTextWithLetterSpacing( + currentX, + currentY, + segment.Text, + assets.BodyLetterSpacing, + "L", + "T", + lineHeight, + ) } } diff --git a/libwallet/domain/model/emergency_kit/go_render/components/header_component.go b/libwallet/domain/model/emergency_kit/go_render/components/header_component.go index a9cba72c..fd04c641 100644 --- a/libwallet/domain/model/emergency_kit/go_render/components/header_component.go +++ b/libwallet/domain/model/emergency_kit/go_render/components/header_component.go @@ -12,7 +12,11 @@ type HeaderComponent struct { VerificationText string } -func NewHeaderComponent(pdf *emergency_kit.PdfExtensions, verificationCode string, translations *assets.Translations) *HeaderComponent { +func NewHeaderComponent( + pdf *emergency_kit.PdfExtensions, + verificationCode string, + translations *assets.Translations, +) *HeaderComponent { return &HeaderComponent{ pdf: pdf, TitleText: translations.Header.Title, @@ -39,13 +43,33 @@ func (r *HeaderComponent) Render() { assets.SetSectionTitleFont(r.pdf.Fpdf) assets.SetTitleColor(r.pdf.Fpdf) r.pdf.SetXY(innerStartX, startY) - r.pdf.CellFormat(innerWidth, componentHeight, r.TitleText, "", 2, "LM", false, 0, "") // LM = Left, Middle + r.pdf.CellFormat( + innerWidth, + componentHeight, + r.TitleText, + "", + 2, + "LM", + false, + 0, + "", + ) // LM = Left, Middle // Render verification code on the right side assets.SetVerificationCodeFont(r.pdf.Fpdf) assets.SetSecondaryTextColor(r.pdf.Fpdf) r.pdf.SetXY(innerStartX, startY) - r.pdf.CellFormat(innerWidth, componentHeight, r.VerificationText, "", 2, "RM", false, 0, "") // RM = Right, Middle + r.pdf.CellFormat( + innerWidth, + componentHeight, + r.VerificationText, + "", + 2, + "RM", + false, + 0, + "", + ) // RM = Right, Middle // Move cursor to end of header r.pdf.SetXY(0, startY+componentHeight) diff --git a/libwallet/domain/model/emergency_kit/go_render/components/help_component.go b/libwallet/domain/model/emergency_kit/go_render/components/help_component.go index 25982610..466bfe56 100644 --- a/libwallet/domain/model/emergency_kit/go_render/components/help_component.go +++ b/libwallet/domain/model/emergency_kit/go_render/components/help_component.go @@ -19,7 +19,10 @@ type HelpComponent struct { Description string } -func NewHelpComponent(pdf *emergency_kit.PdfExtensions, translations *assets.Translations) *HelpComponent { +func NewHelpComponent( + pdf *emergency_kit.PdfExtensions, + translations *assets.Translations, +) *HelpComponent { return &HelpComponent{ pdf: pdf, Title: translations.Help.Title, @@ -29,12 +32,22 @@ func NewHelpComponent(pdf *emergency_kit.PdfExtensions, translations *assets.Tra func (r *HelpComponent) Height() float64 { pageWidth, _ := r.pdf.GetPageSize() - textWidth := pageWidth - helpIconMarginLeft - assets.HelpIconSize - helpIconMarginRight - assets.StandardHorizontalMargin + textWidth := pageWidth - + helpIconMarginLeft - + assets.HelpIconSize - + helpIconMarginRight - + assets.StandardHorizontalMargin assets.SetBodyParagraphFont(r.pdf.Fpdf) - descLines := r.pdf.LineCountWithLetterSpacing(textWidth, r.Description, assets.BodyLetterSpacing) + descLines := r.pdf.LineCountWithLetterSpacing( + textWidth, + r.Description, + assets.BodyLetterSpacing, + ) - textContentHeight := assets.SubtitleLineHeight + assets.IntraComponentSpacing + descLines*assets.BodyParagraphLineHeight + textContentHeight := assets.SubtitleLineHeight + + assets.IntraComponentSpacing + + descLines*assets.BodyParagraphLineHeight return helpSectionMarginTop + helpPaddingVertical + textContentHeight } @@ -49,7 +62,17 @@ func (r *HelpComponent) Render() { iconX := helpIconMarginLeft iconY := startY + helpPaddingVertical - r.pdf.Image(assets.HelpImageName, iconX, iconY, assets.HelpIconSize, assets.HelpIconSize, false, "", 0, "") + r.pdf.Image( + assets.HelpImageName, + iconX, + iconY, + assets.HelpIconSize, + assets.HelpIconSize, + false, + "", + 0, + "", + ) textX := helpIconMarginLeft + assets.HelpIconSize + helpIconMarginRight textY := startY + helpPaddingVertical @@ -63,7 +86,15 @@ func (r *HelpComponent) Render() { descY := textY + assets.SubtitleLineHeight + assets.IntraComponentSpacing parts := r.pdf.ParseTextWithLinks(r.Description, []string{"support@muun.com"}) - r.pdf.RenderMultiStyledText(textX, descY, textWidth, assets.BodyParagraphLineHeight, parts, assets.BodyLetterSpacing, 0) + r.pdf.RenderMultiStyledText( + textX, + descY, + textWidth, + assets.BodyParagraphLineHeight, + parts, + assets.BodyLetterSpacing, + 0, + ) r.pdf.SetY(startY + height) } diff --git a/libwallet/domain/model/emergency_kit/go_render/components/instructions_component.go b/libwallet/domain/model/emergency_kit/go_render/components/instructions_component.go index 203e3542..6e2aac35 100644 --- a/libwallet/domain/model/emergency_kit/go_render/components/instructions_component.go +++ b/libwallet/domain/model/emergency_kit/go_render/components/instructions_component.go @@ -26,27 +26,32 @@ type InstructionItem struct { Description string } -func NewInstructionsComponent(pdf *emergency_kit.PdfExtensions, translations *assets.Translations) *InstructionsComponent { +func NewInstructionItem(number, title, description string) InstructionItem { + return InstructionItem{ + Number: number, + Title: title, + Description: description, + } +} + +func NewInstructionsComponent( + pdf *emergency_kit.PdfExtensions, + translations *assets.Translations, +) *InstructionsComponent { return &InstructionsComponent{ pdf: pdf, Title: translations.Instructions.Title, Intro: translations.Instructions.Intro, Items: []InstructionItem{ - { - Number: "1", - Title: translations.Instructions.Step1Title, - Description: translations.Instructions.Step1Desc, - }, - { - Number: "2", - Title: translations.Instructions.Step2Title, - Description: translations.Instructions.Step2Desc, - }, - { - Number: "3", - Title: translations.Instructions.Step3Title, - Description: translations.Instructions.Step3Desc, - }, + NewInstructionItem( + "1", translations.Instructions.Step1Title, translations.Instructions.Step1Desc, + ), + NewInstructionItem( + "2", translations.Instructions.Step2Title, translations.Instructions.Step2Desc, + ), + NewInstructionItem( + "3", translations.Instructions.Step3Title, translations.Instructions.Step3Desc, + ), }, } } @@ -81,11 +86,20 @@ func (r *InstructionsComponent) calculateAllItemsHeight(contentWidth float64) fl return totalHeight } -func (r *InstructionsComponent) calculateItemHeight(textWidth float64, item InstructionItem) float64 { +func (r *InstructionsComponent) calculateItemHeight( + textWidth float64, + item InstructionItem, +) float64 { assets.SetBodyParagraphFont(r.pdf.Fpdf) - descriptionLines := r.pdf.LineCountWithLetterSpacing(textWidth, item.Description, assets.BodyLetterSpacing) - - return assets.SubtitleLineHeight + assets.IntraComponentSpacing + descriptionLines*assets.BodyParagraphLineHeight + descriptionLines := r.pdf.LineCountWithLetterSpacing( + textWidth, + item.Description, + assets.BodyLetterSpacing, + ) + + return assets.SubtitleLineHeight + + assets.IntraComponentSpacing + + descriptionLines*assets.BodyParagraphLineHeight } func (r *InstructionsComponent) Render() { @@ -107,7 +121,12 @@ func (r *InstructionsComponent) renderIntro() { assets.SetBodyParagraphFont(r.pdf.Fpdf) assets.SetSecondaryTextColor(r.pdf.Fpdf) r.pdf.SetXY(assets.StandardHorizontalMargin, r.pdf.GetY()) - r.pdf.MultiCellWithLetterSpacing(r.pdf.GetDrawablePageWidth(), assets.BodyParagraphLineHeight, r.Intro, assets.BodyLetterSpacing) + r.pdf.MultiCellWithLetterSpacing( + r.pdf.GetDrawablePageWidth(), + assets.BodyParagraphLineHeight, + r.Intro, + assets.BodyLetterSpacing, + ) } func (r *InstructionsComponent) renderItems() { @@ -150,16 +169,34 @@ func (r *InstructionsComponent) renderNumberCircle(itemY float64, number string) r.pdf.CellFormat(numberCircleSize, numberCircleSize, number, "", 0, "C", false, 0, "") } -func (r *InstructionsComponent) renderItemTitle(textX float64, itemY float64, textWidth float64, title string) { +func (r *InstructionsComponent) renderItemTitle( + textX float64, + itemY float64, + textWidth float64, + title string, +) { assets.SetSubtitleFont(r.pdf.Fpdf) assets.SetTitleColor(r.pdf.Fpdf) r.pdf.SetXY(textX, itemY+titleVerticalAdjust) r.pdf.CellFormat(textWidth, assets.SubtitleLineHeight, title, "", 0, "L", false, 0, "") } -func (r *InstructionsComponent) renderItemDescription(textX float64, itemY float64, textWidth float64, description string) { +func (r *InstructionsComponent) renderItemDescription( + textX float64, + itemY float64, + textWidth float64, + description string, +) { descriptionY := itemY + assets.SubtitleLineHeight + assets.IntraComponentSpacing parts := r.pdf.ParseTextWithLinks(description, []string{"github.com/muun/recovery"}) - r.pdf.RenderMultiStyledText(textX, descriptionY, textWidth, assets.BodyParagraphLineHeight, parts, assets.BodyLetterSpacing, 0) + r.pdf.RenderMultiStyledText( + textX, + descriptionY, + textWidth, + assets.BodyParagraphLineHeight, + parts, + assets.BodyLetterSpacing, + 0, + ) } diff --git a/libwallet/domain/model/emergency_kit/go_render/components/keys/key_box_component.go b/libwallet/domain/model/emergency_kit/go_render/components/keys/key_box_component.go index 331317eb..b28510c9 100644 --- a/libwallet/domain/model/emergency_kit/go_render/components/keys/key_box_component.go +++ b/libwallet/domain/model/emergency_kit/go_render/components/keys/key_box_component.go @@ -24,7 +24,11 @@ type singleKeyBox struct { KeyText string } -func newSingleKeyBox(pdf *emergency_kit.PdfExtensions, titleText string, keyText string) *singleKeyBox { +func newSingleKeyBox( + pdf *emergency_kit.PdfExtensions, + titleText string, + keyText string, +) *singleKeyBox { return &singleKeyBox{ pdf: pdf, TitleText: titleText, @@ -38,7 +42,11 @@ func (r *singleKeyBox) Height() float64 { func (r *singleKeyBox) keyTextHeight() float64 { assets.SetEncryptedKeyFont(r.pdf.Fpdf) - keyLines := r.pdf.LineCountWithLetterSpacing(r.getBoxContentWidth(), r.KeyText, assets.BodyLetterSpacing) + keyLines := r.pdf.LineCountWithLetterSpacing( + r.getBoxContentWidth(), + r.KeyText, + assets.BodyLetterSpacing, + ) return keyLines * assets.EncryptedKeysTextFontSize } @@ -64,7 +72,12 @@ func (r *singleKeyBox) Render() { // EncryptedKeysTextFontSize gives a 1.86 ratio for the KeyText 13px font. This high ratio // is intentional to improve readability of dense monospace text. - r.pdf.MultiCellWithLetterSpacing(contentWidth, assets.EncryptedKeysTextFontSize, r.KeyText, assets.BodyLetterSpacing) + r.pdf.MultiCellWithLetterSpacing( + contentWidth, + assets.EncryptedKeysTextFontSize, + r.KeyText, + assets.BodyLetterSpacing, + ) r.pdf.SetY(startY + boxHeight) } diff --git a/libwallet/domain/model/emergency_kit/go_render/components/keys/keys_component.go b/libwallet/domain/model/emergency_kit/go_render/components/keys/keys_component.go index 6b7168d1..38ff60eb 100644 --- a/libwallet/domain/model/emergency_kit/go_render/components/keys/keys_component.go +++ b/libwallet/domain/model/emergency_kit/go_render/components/keys/keys_component.go @@ -1,11 +1,12 @@ package keys import ( + "strings" + "time" + "github.com/muun/libwallet/data/emergency_kit" "github.com/muun/libwallet/data/emergency_kit/resources" "github.com/muun/libwallet/domain/model/emergency_kit/go_render/assets" - "strings" - "time" ) var ( @@ -46,7 +47,13 @@ func (r *KeysComponent) Render() { startY := r.pdf.GetY() assets.SetKeysBackgroundColor(r.pdf.Fpdf) - r.pdf.Rect(assets.StandardHorizontalMargin, startY, r.pdf.GetDrawablePageWidth(), r.Height(), "F") + r.pdf.Rect( + assets.StandardHorizontalMargin, + startY, + r.pdf.GetDrawablePageWidth(), + r.Height(), + "F", + ) r.pdf.SetY(startY) header := NewKeysHeaderComponent(r.pdf, r.Translations) @@ -67,7 +74,13 @@ func (r *KeysComponent) renderDate() { startY := r.pdf.GetY() assets.SetKeysBackgroundColor(r.pdf.Fpdf) - r.pdf.Rect(assets.StandardHorizontalMargin, startY, r.pdf.GetDrawablePageWidth(), dateBoxSize, "F") + r.pdf.Rect( + assets.StandardHorizontalMargin, + startY, + r.pdf.GetDrawablePageWidth(), + dateBoxSize, + "F", + ) prefix := r.Translations.Keys.CreatedOnPrefix + " " date := strings.ToUpper(r.Translations.LocalizedDate(time.Now())) @@ -76,7 +89,8 @@ func (r *KeysComponent) renderDate() { assets.SetDateLabelFont(r.pdf.Fpdf) prefixWidth := r.pdf.GetStringWidthWithLetterSpacing(prefix, letterSpacing) - // GetStringWidthWithLetterSpacing removes trailing spacing, but we need it between prefix and date + // GetStringWidthWithLetterSpacing removes trailing spacing, but we need it between prefix and + // date fontSizePt, _ := r.pdf.GetFontSize() fontSizeMm := resources.PtToMm(fontSizePt) letterSpacingAmount := fontSizeMm * letterSpacing @@ -94,12 +108,28 @@ func (r *KeysComponent) renderDate() { assets.SetDateLabelFont(r.pdf.Fpdf) assets.SetSecondaryTextColor(r.pdf.Fpdf) - prefixEndX := r.pdf.RenderTextWithLetterSpacing(centeredX, startY, prefix, letterSpacing, "C", "M", dateBoxSize) + prefixEndX := r.pdf.RenderTextWithLetterSpacing( + centeredX, + startY, + prefix, + letterSpacing, + "C", + "M", + dateBoxSize, + ) assets.SetDateValueFont(r.pdf.Fpdf) assets.SetTitleColor(r.pdf.Fpdf) dateStartX := prefixEndX + letterSpacingAmount - firstDateCharWidth/2 - r.pdf.RenderTextWithLetterSpacing(dateStartX, startY, date, letterSpacing, "C", "M", dateBoxSize) + r.pdf.RenderTextWithLetterSpacing( + dateStartX, + startY, + date, + letterSpacing, + "C", + "M", + dateBoxSize, + ) r.pdf.SetY(startY + dateBoxSize) } diff --git a/libwallet/domain/model/emergency_kit/go_render/components/keys/keys_header_component.go b/libwallet/domain/model/emergency_kit/go_render/components/keys/keys_header_component.go index 82c49dd4..9bc1fe0c 100644 --- a/libwallet/domain/model/emergency_kit/go_render/components/keys/keys_header_component.go +++ b/libwallet/domain/model/emergency_kit/go_render/components/keys/keys_header_component.go @@ -19,7 +19,10 @@ type KeysHeaderComponent struct { Translations *assets.Translations } -func NewKeysHeaderComponent(pdf *emergency_kit.PdfExtensions, translations *assets.Translations) *KeysHeaderComponent { +func NewKeysHeaderComponent( + pdf *emergency_kit.PdfExtensions, + translations *assets.Translations, +) *KeysHeaderComponent { return &KeysHeaderComponent{ pdf: pdf, Translations: translations, @@ -31,13 +34,18 @@ func (r *KeysHeaderComponent) Height() float64 { textWidth := r.pdf.GetDrawablePageWidth() - iconTrailing assets.SetKeysHeaderSubtitlesFont(r.pdf.Fpdf) - fullText := r.Translations.Keys.EncryptedBackupDesc1 + " " + r.Translations.Keys.EncryptedBackupDesc2 - subtitleLines := r.pdf.LineCountWithLetterSpacing(textWidth, fullText, assets.BodyLetterSpacing) + fullText := r.Translations.Keys.EncryptedBackupDesc1 + + " " + r.Translations.Keys.EncryptedBackupDesc2 + subtitleLines := r.pdf.LineCountWithLetterSpacing( + textWidth, fullText, assets.BodyLetterSpacing, + ) if subtitleLines < 2 { subtitleLines = 2 } - totalTextHeightWithSpaces := assets.KeysSectionTitleLineHeight + titleToSubtitleSpace + subtitleLines*assets.BodyParagraphLineHeight + totalTextHeightWithSpaces := assets.KeysSectionTitleLineHeight + + titleToSubtitleSpace + + subtitleLines*assets.BodyParagraphLineHeight return assets.StandardHorizontalMargin + totalTextHeightWithSpaces + subtitleToBottomSpace } @@ -47,7 +55,17 @@ func (r *KeysHeaderComponent) Render() { iconX := assets.StandardHorizontalMargin + iconMarginX iconY := startY + assets.StandardHorizontalMargin + iconMarginTop - r.pdf.Image(assets.PadlockImageName, iconX, iconY, assets.PadlockIconSize, assets.PadlockIconSize, false, "", 0, "") + r.pdf.Image( + assets.PadlockImageName, + iconX, + iconY, + assets.PadlockIconSize, + assets.PadlockIconSize, + false, + "", + 0, + "", + ) titleX := iconX + assets.PadlockIconSize relativeToComponentYTitleY := startY + titleY @@ -55,17 +73,43 @@ func (r *KeysHeaderComponent) Render() { assets.SetKeysSectionTitleFont(r.pdf.Fpdf) assets.SetTitleColor(r.pdf.Fpdf) r.pdf.SetXY(titleX, relativeToComponentYTitleY) - r.pdf.CellFormat(0, assets.KeysSectionTitleLineHeight, r.Translations.Keys.EncryptedBackupTitle, "", 2, "L", false, 0, "") + r.pdf.CellFormat( + 0, + assets.KeysSectionTitleLineHeight, + r.Translations.Keys.EncryptedBackupTitle, + "", + 2, + "L", + false, + 0, + "", + ) subtitleY := r.pdf.GetY() + titleToSubtitleSpace textWidth := r.pdf.GetDrawablePageWidth() - (titleX - assets.StandardHorizontalMargin) parts := []emergency_kit.TextPart{ - {Text: r.Translations.Keys.EncryptedBackupDesc1, SetFont: assets.SetKeysHeaderSubtitlesFont, SetColor: assets.SetSecondaryTextColor}, - {Text: r.Translations.Keys.EncryptedBackupDesc2, SetFont: assets.SetKeysHeaderSubtitleBoldFont, SetColor: assets.SetTitleColor}, + { + Text: r.Translations.Keys.EncryptedBackupDesc1, + SetFont: assets.SetKeysHeaderSubtitlesFont, + SetColor: assets.SetSecondaryTextColor, + }, + { + Text: r.Translations.Keys.EncryptedBackupDesc2, + SetFont: assets.SetKeysHeaderSubtitleBoldFont, + SetColor: assets.SetTitleColor, + }, } - _ = r.pdf.RenderMultiStyledText(titleX, subtitleY, textWidth, assets.BodyParagraphLineHeight, parts, assets.BodyLetterSpacing, 2) + _ = r.pdf.RenderMultiStyledText( + titleX, + subtitleY, + textWidth, + assets.BodyParagraphLineHeight, + parts, + assets.BodyLetterSpacing, + 2, + ) r.pdf.SetY(startY + r.Height()) } diff --git a/libwallet/domain/model/emergency_kit/go_render/render.go b/libwallet/domain/model/emergency_kit/go_render/render.go index 2de196d6..7fce8890 100644 --- a/libwallet/domain/model/emergency_kit/go_render/render.go +++ b/libwallet/domain/model/emergency_kit/go_render/render.go @@ -1,7 +1,11 @@ package go_render import ( - "fmt" + "runtime" + "time" + + "github.com/go-errors/errors" + "github.com/muun/libwallet/data/emergency_kit" "github.com/muun/libwallet/data/emergency_kit/resources" "github.com/muun/libwallet/domain/model/emergency_kit/go_render/assets" @@ -11,12 +15,55 @@ import ( "github.com/muun/libwallet/emergencykit" ) -// GeneratedEKPDF is a model including the path in which Libwallet left the generated pdf, the verificationCode and -// the version +// GeneratedEKPDF is a model including the path in which Libwallet left the generated pdf, the +// verificationCode and the version type GeneratedEKPDF struct { Path string VerificationCode string Version int + Profiling *RenderProfiling // per-stage timings and allocation stats for this render +} + +// RenderProfiling captures per-stage durations and allocation totals for a single Render call. +// Alloc stats are a process-wide delta from runtime.MemStats, so they are an approximation: other +// goroutines allocating concurrently during the render pollute the number. +type RenderProfiling struct { + LoadTranslationsMs int64 + RegisterFontsMs int64 + RegisterImagesMs int64 + ComponentsRenderingMs int64 + CreateAndSaveOnDiskMs int64 + TotalHeapAllocatedBytes int64 + TotalObjectsAllocated int64 + EmbedMetadataMs int64 +} + +func NewRenderProfiling( + loadTranslationsMs int64, + registerFontsMs int64, + registerImagesMs int64, + componentsRenderingMs int64, + createAndSaveOnDiskMs int64, + totalHeapAllocatedBytes int64, + totalObjectsAllocated int64, +) *RenderProfiling { + return &RenderProfiling{ + LoadTranslationsMs: loadTranslationsMs, + RegisterFontsMs: registerFontsMs, + RegisterImagesMs: registerImagesMs, + ComponentsRenderingMs: componentsRenderingMs, + CreateAndSaveOnDiskMs: createAndSaveOnDiskMs, + TotalHeapAllocatedBytes: totalHeapAllocatedBytes, + TotalObjectsAllocated: totalObjectsAllocated, + } +} + +func NewGeneratedEKPDF(path, verificationCode string, version int) *GeneratedEKPDF { + return &GeneratedEKPDF{ + Path: path, + VerificationCode: verificationCode, + Version: version, + } } func Render( @@ -24,14 +71,17 @@ func Render( expectedFilePath string, lang string, ) (*GeneratedEKPDF, error) { + var memBefore runtime.MemStats + runtime.ReadMemStats(&memBefore) + verificationCode := emergencykit.GenerateDeterministicCode(ekInput) + startLocalizables := time.Now() translations, err := loadTranslations(lang) if err != nil { - return nil, fmt.Errorf("failed to load translations: %w", err) + return nil, errors.Errorf("failed to load translations: %w", err) } - - fmt.Println("Creating PDF with custom page size...") + loadTranslationsMs := time.Since(startLocalizables).Milliseconds() ctx := emergency_kit.RenderingContext{ NonDrawableHorizontalMargins: assets.StandardHorizontalMargin, @@ -47,7 +97,10 @@ func Render( }, } pdfExt := emergency_kit.CreateAndSetupPdf(ctx) + registerFontsMs := pdfExt.RegisterFontsMs + registerImagesMs := pdfExt.RegisterImagesMs + startDraw := time.Now() pdfExt.AddPage() pdfExt.SetXY(0, 0) @@ -81,19 +134,31 @@ func Render( descriptors := emergencykit.GetDescriptors(descriptorsData) advanced.NewAdvancedComponent(pdfExt, descriptors, translations).Render() + componentsRenderingMs := time.Since(startDraw).Milliseconds() // Save PDF to file + startOutput := time.Now() err = pdfExt.OutputFileAndClose(expectedFilePath) if err != nil { - return nil, fmt.Errorf("failed to save PDF: %w", err) - } - - generatedEKit := &GeneratedEKPDF{ - Path: expectedFilePath, - VerificationCode: verificationCode, - Version: ekInput.Version, + return nil, errors.Errorf("failed to save PDF: %w", err) } - return generatedEKit, nil + createAndSaveOnDiskMs := time.Since(startOutput).Milliseconds() + + var memAfter runtime.MemStats + runtime.ReadMemStats(&memAfter) + + result := NewGeneratedEKPDF(expectedFilePath, verificationCode, ekInput.Version) + result.Profiling = NewRenderProfiling( + loadTranslationsMs, + registerFontsMs, + registerImagesMs, + componentsRenderingMs, + createAndSaveOnDiskMs, + // TotalAlloc/Mallocs only grow, so the delta is never negative. + int64(memAfter.TotalAlloc-memBefore.TotalAlloc), + int64(memAfter.Mallocs-memBefore.Mallocs), + ) + return result, nil } func loadTranslations(lang string) (*assets.Translations, error) { diff --git a/libwallet/domain/model/encrypted_key_v3/domain_separation.go b/libwallet/domain/model/encrypted_key_v3/domain_separation.go index 8cf31464..6a9ef7fd 100644 --- a/libwallet/domain/model/encrypted_key_v3/domain_separation.go +++ b/libwallet/domain/model/encrypted_key_v3/domain_separation.go @@ -4,7 +4,9 @@ const ( userFirstHalfToRecoveryCode = "muun.com/cosigning-key/1/1/recovery-code" userSecondHalfToRecoveryCode = "muun.com/cosigning-key/1/2/recovery-code" muunFirstHalfToRecoveryCode = "muun.com/cosigning-key/2/1/recovery-code" - muunSecondHalfToRecoveryCode = "muun.com/cosigning-key/2/2/recovery-code" + // MuunSecondHalfToRecoveryCode is the HPKE info string Houston uses when encrypting + // the second half of the muun key to the recovery code. + MuunSecondHalfToRecoveryCode = "muun.com/cosigning-key/2/2/recovery-code" MuunFirstHalfToClient = "muun.com/cosigning-key/2/1/client" ) diff --git a/libwallet/domain/model/encrypted_key_v3/encrypted_key.go b/libwallet/domain/model/encrypted_key_v3/encrypted_key.go index 9c3b3419..77d0d6f2 100644 --- a/libwallet/domain/model/encrypted_key_v3/encrypted_key.go +++ b/libwallet/domain/model/encrypted_key_v3/encrypted_key.go @@ -3,12 +3,13 @@ package encrypted_key_v3 import ( "bytes" "encoding/base64" - "errors" - "fmt" + "slices" + "github.com/btcsuite/btcd/btcec/v2" + "github.com/go-errors/errors" + "github.com/muun/libwallet" "github.com/muun/libwallet/cryptography/bitcoin_hpke" - "slices" ) const ( @@ -36,29 +37,28 @@ func FinishMuunKeyEncryption( ) if err != nil { - return "", fmt.Errorf("encrypt extended key: failed for first encrypted message: %w", err) - } - - k := encryptedKey{ - version, - muun, - firstEncryptedMessage, - secondHalfKeyEncryptedToRecoveryCode, + return "", errors.Errorf( + "encrypt extended key: failed for first encrypted message: %w", err, + ) } + k := newEncryptedKey(version, muun, firstEncryptedMessage, secondHalfKeyEncryptedToRecoveryCode) return k.serialize(), nil } -func EncryptUserKey(userPrivateKey *libwallet.HDPrivateKey, recoveryCodePublicKey *btcec.PublicKey) (string, error) { +func EncryptUserKey( + userPrivateKey *libwallet.HDPrivateKey, + recoveryCodePublicKey *btcec.PublicKey, +) (string, error) { privateKey, err := userPrivateKey.ECPrivateKey() if err != nil { - return "", fmt.Errorf("encrypt extended key: failed to extract private key %w", err) + return "", errors.Errorf("encrypt extended key: failed to extract private key %w", err) } a, err := btcec.NewPrivateKey() if err != nil { - return "", fmt.Errorf("encrypt extended key: failed to create new private key %w", err) + return "", errors.Errorf("encrypt extended key: failed to create new private key %w", err) } b := new(btcec.ModNScalar).Set(&a.Key).Negate().Add(&privateKey.Key).Bytes() @@ -69,7 +69,9 @@ func EncryptUserKey(userPrivateKey *libwallet.HDPrivateKey, recoveryCodePublicKe []byte(""), ) if err != nil { - return "", fmt.Errorf("encrypt extended key: failed for first encrypted message: %w", err) + return "", errors.Errorf( + "encrypt extended key: failed for first encrypted message: %w", err, + ) } secondEncryptedMessage, err := bitcoin_hpke.SingleShotEncrypt( @@ -79,20 +81,20 @@ func EncryptUserKey(userPrivateKey *libwallet.HDPrivateKey, recoveryCodePublicKe []byte(""), ) if err != nil { - return "", fmt.Errorf("encrypt extended key: failed for second encrypted message: %w", err) - } - - ek := encryptedKey{ - version, - user, - firstEncryptedMessage, - secondEncryptedMessage, + return "", errors.Errorf( + "encrypt extended key: failed for second encrypted message: %w", err, + ) } + ek := newEncryptedKey(version, user, firstEncryptedMessage, secondEncryptedMessage) return ek.serialize(), nil } -func DecryptExtendedKey(recoveryCodePrivateKey *btcec.PrivateKey, encryptedExtendedKey string, network *libwallet.Network) (*libwallet.HDPrivateKey, error) { +func DecryptExtendedKey( + recoveryCodePrivateKey *btcec.PrivateKey, + encryptedExtendedKey string, + network *libwallet.Network, +) (*libwallet.HDPrivateKey, error) { key, err := deserializeEncryptedKeyV3(encryptedExtendedKey) if err != nil { @@ -122,7 +124,7 @@ func DecryptExtendedKey(recoveryCodePrivateKey *btcec.PrivateKey, encryptedExten if key.bearer == user { infoForSecondHalf = userSecondHalfToRecoveryCode } else { - infoForSecondHalf = muunSecondHalfToRecoveryCode + infoForSecondHalf = MuunSecondHalfToRecoveryCode } secondMessage, err := key.secondEncryptedMessage.SingleShotDecrypt( recoveryCodePrivateKey, @@ -146,6 +148,20 @@ type encryptedKey struct { secondEncryptedMessage *bitcoin_hpke.EncryptedMessage } +func newEncryptedKey( + version uint8, + bearer keyBearer, + first *bitcoin_hpke.EncryptedMessage, + second *bitcoin_hpke.EncryptedMessage, +) *encryptedKey { + return &encryptedKey{ + version: version, + bearer: bearer, + firstEncryptedMessage: first, + secondEncryptedMessage: second, + } +} + func (key *encryptedKey) serialize() string { keyBytes := slices.Concat( []byte{key.version}, @@ -161,22 +177,25 @@ func deserializeEncryptedKeyV3(serializedKey string) (*encryptedKey, error) { serializedKeyBytes, err := base64.StdEncoding.DecodeString(serializedKey) if err != nil { - return nil, fmt.Errorf("decrypting key: failed to decode from base64 %w", err) + return nil, errors.Errorf("decrypting key: failed to decode from base64 %w", err) } reader := bytes.NewReader(serializedKeyBytes) versionByte, err := reader.ReadByte() if err != nil { - return nil, fmt.Errorf("decrypting key: failed to read version %w", err) + return nil, errors.Errorf("decrypting key: failed to read version %w", err) } if versionByte != 3 { - return nil, fmt.Errorf("decrypting key: expected a v3 key, version byte indicates v%d", versionByte) + return nil, errors.Errorf( + "decrypting key: expected a v3 key, version byte indicates v%d", + versionByte, + ) } bearerByte, err := reader.ReadByte() if err != nil { - return nil, fmt.Errorf("decrypting key: failed to read bearer %w", err) + return nil, errors.Errorf("decrypting key: failed to read bearer %w", err) } bearer, err := validateBearerByte(bearerByte) @@ -184,38 +203,39 @@ func deserializeEncryptedKeyV3(serializedKey string) (*encryptedKey, error) { return nil, err } - firstEncryptedMessageLenInBytes := bitcoin_hpke.SerializedEncryptedMessageLengthInBytes(privateKeyLenBytes + chainCodeLenBytes) + firstEncryptedMessageLenInBytes := bitcoin_hpke.SerializedEncryptedMessageLengthInBytes( + privateKeyLenBytes + chainCodeLenBytes, + ) firstEncryptedMessageBytes := make([]byte, firstEncryptedMessageLenInBytes) n, err := reader.Read(firstEncryptedMessageBytes[:]) if err != nil || n != firstEncryptedMessageLenInBytes { - return nil, fmt.Errorf("decrypting key: failed to read firstEncryptedMessage %w", err) + return nil, errors.Errorf("decrypting key: failed to read firstEncryptedMessage %w", err) } firstEncryptedMessage, err := bitcoin_hpke.ParseEncryptedMessage(firstEncryptedMessageBytes[:]) if err != nil { - return nil, fmt.Errorf("decrypting key: failed to parse firstEncryptedMessage %w", err) + return nil, errors.Errorf("decrypting key: failed to parse firstEncryptedMessage %w", err) } - secondEncryptedMessageLenInBytes := bitcoin_hpke.SerializedEncryptedMessageLengthInBytes(privateKeyLenBytes) + secondEncryptedMessageLenInBytes := bitcoin_hpke.SerializedEncryptedMessageLengthInBytes( + privateKeyLenBytes, + ) secondEncryptedMessageBytes := make([]byte, secondEncryptedMessageLenInBytes) n, err = reader.Read(secondEncryptedMessageBytes[:]) if err != nil || n != secondEncryptedMessageLenInBytes { - return nil, fmt.Errorf("decrypting key: failed to read secondEncryptedMessage %w", err) + return nil, errors.Errorf("decrypting key: failed to read secondEncryptedMessage %w", err) } - secondEncryptedMessage, err := bitcoin_hpke.ParseEncryptedMessage(secondEncryptedMessageBytes[:]) + secondEncryptedMessage, err := bitcoin_hpke.ParseEncryptedMessage( + secondEncryptedMessageBytes[:], + ) if err != nil { - return nil, fmt.Errorf("decrypting key: failed to parse secondEncryptedMessage %w", err) + return nil, errors.Errorf("decrypting key: failed to parse secondEncryptedMessage %w", err) } if reader.Len() > 0 { return nil, errors.New("decrypting key: key is longer than expected") } - return &encryptedKey{ - versionByte, - bearer, - firstEncryptedMessage, - secondEncryptedMessage, - }, nil + return newEncryptedKey(versionByte, bearer, firstEncryptedMessage, secondEncryptedMessage), nil } func validateBearerByte(bearerByte uint8) (keyBearer, error) { @@ -224,6 +244,6 @@ func validateBearerByte(bearerByte uint8) (keyBearer, error) { case user, muun: return bearer, nil default: - return bearer, fmt.Errorf("invalid value for key bearer byte: %d", bearerByte) + return bearer, errors.Errorf("invalid value for key bearer byte: %d", bearerByte) } } diff --git a/libwallet/domain/model/encrypted_key_v3/encrypted_key_test.go b/libwallet/domain/model/encrypted_key_v3/encrypted_key_test.go index 70cfdd32..e83a9884 100644 --- a/libwallet/domain/model/encrypted_key_v3/encrypted_key_test.go +++ b/libwallet/domain/model/encrypted_key_v3/encrypted_key_test.go @@ -4,11 +4,13 @@ import ( "bytes" "crypto/rand" "encoding/base64" + "testing" + "github.com/btcsuite/btcd/btcec/v2" + "github.com/muun/libwallet" "github.com/muun/libwallet/cryptography/bitcoin_hpke" "github.com/muun/libwallet/recoverycode" - "testing" ) func TestFinishMuunKeyEncryption(t *testing.T) { @@ -37,11 +39,16 @@ func TestFinishMuunKeyEncryption(t *testing.T) { t.Fatal(err) } - secondHalfKeyBytes := new(btcec.ModNScalar).Set(&firstHalfKey.Key).Negate().Add(&muunECPrivateKey.Key).Bytes() + secondHalfKeyBytes := new( + btcec.ModNScalar, + ).Set(&firstHalfKey.Key). + Negate(). + Add(&muunECPrivateKey.Key). + Bytes() secondHalfKeyEncryptedToRecoveryCode, err := bitcoin_hpke.SingleShotEncrypt( secondHalfKeyBytes[:], recoveryCodePublicKey, - []byte(muunSecondHalfToRecoveryCode), + []byte(MuunSecondHalfToRecoveryCode), []byte(""), ) if err != nil { @@ -49,12 +56,21 @@ func TestFinishMuunKeyEncryption(t *testing.T) { } // Now test FinishMuunKeyEncryption - encryptedMuunKey, err := FinishMuunKeyEncryption(recoveryCodePublicKey, firstHalfKey, muunKey.ChainCode(), secondHalfKeyEncryptedToRecoveryCode) + encryptedMuunKey, err := FinishMuunKeyEncryption( + recoveryCodePublicKey, + firstHalfKey, + muunKey.ChainCode(), + secondHalfKeyEncryptedToRecoveryCode, + ) if err != nil { t.Fatal(err) } - decryptedMuunKey, err := DecryptExtendedKey(recoveryCodePrivateKey, encryptedMuunKey, libwallet.Mainnet()) + decryptedMuunKey, err := DecryptExtendedKey( + recoveryCodePrivateKey, + encryptedMuunKey, + libwallet.Mainnet(), + ) if err != nil { t.Fatal(err) } @@ -87,7 +103,11 @@ func TestEncryptUserKey(t *testing.T) { t.Fatal(err) } - decryptedUserKey, err := DecryptExtendedKey(recoveryCodePrivateKey, encryptedUserKey, libwallet.Mainnet()) + decryptedUserKey, err := DecryptExtendedKey( + recoveryCodePrivateKey, + encryptedUserKey, + libwallet.Mainnet(), + ) if err != nil { t.Fatal(err) } diff --git a/libwallet/domain/model/lightning/incoming_htlc_batch.go b/libwallet/domain/model/lightning/incoming_htlc_batch.go new file mode 100644 index 00000000..295807ed --- /dev/null +++ b/libwallet/domain/model/lightning/incoming_htlc_batch.go @@ -0,0 +1,59 @@ +package lightning + +import ( + "github.com/google/uuid" +) + +// IncomingHTLCBatch holds the state of an incoming HTLC batch tracked by the M2U. +type IncomingHTLCBatch struct { + UUID string + Recalls []*IncomingHTLCRecall + Enforcements []*IncomingHTLCEnforcement + PreimagesRevealed bool +} + +func NewIncomingHTLCBatch( + recalls []*IncomingHTLCRecall, + enforcements []*IncomingHTLCEnforcement, +) *IncomingHTLCBatch { + if len(recalls) == 0 { + panic("expected at least one recall") + } + if len(enforcements) == 0 { + panic("expected at least one enforcement") + } + + return &IncomingHTLCBatch{ + UUID: uuid.New().String(), + Recalls: recalls, + Enforcements: enforcements, + } +} + +// GetAllOutputScripts gets all distinct output scripts from the transactions of the batch. +func (b *IncomingHTLCBatch) GetAllOutputScripts() [][]byte { + seenScripts := make(map[string]bool) + var outputScripts [][]byte + + for _, recall := range b.Recalls { + script := recall.RecallIncomingSuccessOutputScript() + scriptString := string(script) + + if !seenScripts[scriptString] { + seenScripts[scriptString] = true + outputScripts = append(outputScripts, script) + } + } + + for _, enforcement := range b.Enforcements { + script := enforcement.EnforcementIncomingSuccessOutputScript() + scriptString := string(script) + + if !seenScripts[scriptString] { + seenScripts[scriptString] = true + outputScripts = append(outputScripts, script) + } + } + + return outputScripts +} diff --git a/libwallet/domain/model/lightning/incoming_htlc_enforcement.go b/libwallet/domain/model/lightning/incoming_htlc_enforcement.go new file mode 100644 index 00000000..29c0450e --- /dev/null +++ b/libwallet/domain/model/lightning/incoming_htlc_enforcement.go @@ -0,0 +1,40 @@ +package lightning + +import ( + "fmt" +) + +// IncomingHTLCEnforcement represents the enforcement version the client may +// broadcast, alongside its second-stage transaction. +type IncomingHTLCEnforcement struct { + Enforcement Transaction + EnforcementIncomingSuccess Transaction +} + +func NewIncomingHTLCEnforcement( + enforcementTx, enforcementIncomingSuccess Transaction, +) *IncomingHTLCEnforcement { + if len(enforcementIncomingSuccess.TxIn) == 0 { + panic("enforcement-incoming-success tx has no inputs") + } + + spentIndex := enforcementIncomingSuccess.TxIn[0].PreviousOutPoint.Index + if int(spentIndex) >= len(enforcementTx.TxOut) { + panic(fmt.Sprintf( + "enforcement tx has %d outputs but its second stage spends index %d", + len(enforcementTx.TxOut), spentIndex, + )) + } + + return &IncomingHTLCEnforcement{ + Enforcement: enforcementTx, + EnforcementIncomingSuccess: enforcementIncomingSuccess, + } +} + +// EnforcementIncomingSuccessOutputScript returns the output script from the +// enforcement transaction that the enforcement-incoming-success transaction spends. +func (e *IncomingHTLCEnforcement) EnforcementIncomingSuccessOutputScript() []byte { + spentIndex := e.EnforcementIncomingSuccess.TxIn[0].PreviousOutPoint.Index + return e.Enforcement.TxOut[spentIndex].PkScript +} diff --git a/libwallet/domain/model/lightning/incoming_htlc_recall.go b/libwallet/domain/model/lightning/incoming_htlc_recall.go new file mode 100644 index 00000000..5ba8c367 --- /dev/null +++ b/libwallet/domain/model/lightning/incoming_htlc_recall.go @@ -0,0 +1,40 @@ +package lightning + +import ( + "fmt" +) + +// IncomingHTLCRecall represents the recall transaction that the server may +// broadcast and its associated second-stage (recall-incoming-success) transaction. +type IncomingHTLCRecall struct { + Recall Transaction + RecallIncomingSuccess Transaction +} + +func NewIncomingHTLCRecall( + recallTx, recallIncomingSuccess Transaction, +) *IncomingHTLCRecall { + if len(recallIncomingSuccess.TxIn) == 0 { + panic("recall-incoming-success tx has no inputs") + } + + spentIndex := recallIncomingSuccess.TxIn[0].PreviousOutPoint.Index + if int(spentIndex) >= len(recallTx.TxOut) { + panic(fmt.Sprintf( + "recall tx has %d outputs but its second stage spends index %d", + len(recallTx.TxOut), spentIndex, + )) + } + + return &IncomingHTLCRecall{ + Recall: recallTx, + RecallIncomingSuccess: recallIncomingSuccess, + } +} + +// RecallIncomingSuccessOutputScript returns the output script from the recall +// transaction that the recall-incoming-success transaction spends. +func (h *IncomingHTLCRecall) RecallIncomingSuccessOutputScript() []byte { + spentIndex := h.RecallIncomingSuccess.TxIn[0].PreviousOutPoint.Index + return h.Recall.TxOut[spentIndex].PkScript +} diff --git a/libwallet/domain/model/lightning/transaction.go b/libwallet/domain/model/lightning/transaction.go new file mode 100644 index 00000000..1ba1238f --- /dev/null +++ b/libwallet/domain/model/lightning/transaction.go @@ -0,0 +1,41 @@ +package lightning + +import ( + "bytes" + "encoding/hex" + + "github.com/btcsuite/btcd/wire" + "github.com/go-errors/errors" +) + +// Transaction is a wrapper over wire.MsgTx with useful methods for M2U operations +type Transaction struct { + wire.MsgTx +} + +func NewTransaction(rawHex string) (*Transaction, error) { + rawBytes, err := hex.DecodeString(rawHex) + if err != nil { + return nil, errors.Errorf("decode hex: %w", err) + } + + var tx Transaction + if err := tx.Deserialize(bytes.NewReader(rawBytes)); err != nil { + return nil, errors.Errorf("deserialize tx: %w", err) + } + + return &tx, nil +} + +// GetID returns the transaction hash +func (t *Transaction) GetID() string { + return t.TxID() +} + +func (t *Transaction) ToRawHex() string { + var buf bytes.Buffer + if err := t.Serialize(&buf); err != nil { + return "" + } + return hex.EncodeToString(buf.Bytes()) +} diff --git a/libwallet/domain/model/security_card/fresh_pair_challenge.go b/libwallet/domain/model/security_card/fresh_pair_challenge.go new file mode 100644 index 00000000..0e04094c --- /dev/null +++ b/libwallet/domain/model/security_card/fresh_pair_challenge.go @@ -0,0 +1,11 @@ +package security_card + +// FreshPairChallenge is a usable pair challenge: it was found in local +// storage and has not yet expired. +type FreshPairChallenge struct { + ServerPublicKey []byte +} + +func NewFreshPairChallenge(serverPublicKey []byte) *FreshPairChallenge { + return &FreshPairChallenge{ServerPublicKey: serverPublicKey} +} diff --git a/libwallet/domain/model/security_card/pending_pair_challenge.go b/libwallet/domain/model/security_card/pending_pair_challenge.go new file mode 100644 index 00000000..88d75f3a --- /dev/null +++ b/libwallet/domain/model/security_card/pending_pair_challenge.go @@ -0,0 +1,19 @@ +package security_card + +import "time" + +// pendingPairChallengeTTLSeconds matches Houston's challenge TTL. +const pendingPairChallengeTTLSeconds = 90 + +// PendingPairChallenge bridges PairRequestChallenge and +// PairSignAndSubmitChallenge: the request RPC persists it, the submit +// RPC reads it back to drive the NFC tap. +type PendingPairChallenge struct { + ServerPubKeyInHex string + ReceivedAtInMillis int64 +} + +func (c *PendingPairChallenge) IsStale() bool { + age := time.Since(time.UnixMilli(c.ReceivedAtInMillis)).Seconds() + return age > pendingPairChallengeTTLSeconds +} diff --git a/libwallet/domain/model/security_card/security_card_metadata.go b/libwallet/domain/model/security_card/security_card_metadata.go index c2ec0fec..109c1a0f 100644 --- a/libwallet/domain/model/security_card/security_card_metadata.go +++ b/libwallet/domain/model/security_card/security_card_metadata.go @@ -8,3 +8,21 @@ type SecurityCardMetadata struct { UsageCount uint16 LanguageCodeInHex string } + +func NewSecurityCardMetadata( + globalPublicKeyInHex string, + cardVendorInHex string, + cardModelInHex string, + firmwareVersion uint16, + usageCount uint16, + languageCodeInHex string, +) *SecurityCardMetadata { + return &SecurityCardMetadata{ + GlobalPublicKeyInHex: globalPublicKeyInHex, + CardVendorInHex: cardVendorInHex, + CardModelInHex: cardModelInHex, + FirmwareVersion: firmwareVersion, + UsageCount: usageCount, + LanguageCodeInHex: languageCodeInHex, + } +} diff --git a/libwallet/domain/model/security_card/security_card_paired.go b/libwallet/domain/model/security_card/security_card_paired.go index 40701121..46ccf24c 100644 --- a/libwallet/domain/model/security_card/security_card_paired.go +++ b/libwallet/domain/model/security_card/security_card_paired.go @@ -5,3 +5,15 @@ type SecurityCardPaired struct { IsKnownProvider bool IsCardAlreadyUsed bool } + +func NewSecurityCardPaired( + metadata *SecurityCardMetadata, + isKnownProvider bool, + isCardAlreadyUsed bool, +) *SecurityCardPaired { + return &SecurityCardPaired{ + Metadata: metadata, + IsKnownProvider: isKnownProvider, + IsCardAlreadyUsed: isCardAlreadyUsed, + } +} diff --git a/libwallet/domain/model/security_card/security_card_sign_challenge.go b/libwallet/domain/model/security_card/security_card_sign_challenge.go index 0753ed69..33a959e9 100644 --- a/libwallet/domain/model/security_card/security_card_sign_challenge.go +++ b/libwallet/domain/model/security_card/security_card_sign_challenge.go @@ -7,3 +7,17 @@ type SecurityCardSignChallenge struct { CardUsageCount uint16 PairingSlot uint16 } + +func NewSecurityCardSignChallenge( + serverPublicKey []byte, + mac []byte, + cardUsageCount uint16, + pairingSlot uint16, +) *SecurityCardSignChallenge { + return &SecurityCardSignChallenge{ + ServerPublicKey: serverPublicKey, + Mac: mac, + CardUsageCount: cardUsageCount, + PairingSlot: pairingSlot, + } +} diff --git a/libwallet/domain/model/security_card/security_card_sign_challenge_v3.go b/libwallet/domain/model/security_card/security_card_sign_challenge_v3.go new file mode 100644 index 00000000..17372baf --- /dev/null +++ b/libwallet/domain/model/security_card/security_card_sign_challenge_v3.go @@ -0,0 +1,13 @@ +package security_card + +// SecurityCardSignChallengeV3 carries everything a card needs to +// verify and sign a challenge: the server's ephemeral key, the +// monotonic counter, the pairing index, the human-readable reason +// (bound into the MAC), and the server-computed MAC. +type SecurityCardSignChallengeV3 struct { + ServerPublicKey []byte // C (65 bytes) - server's ephemeral public key + Counter uint16 // count_card, strictly increasing for anti-replay + Index uint16 // pairing index on the card (V3 is always 0x0000) + Reason []byte // human-readable action description, bound into the MAC + MAC []byte // HMAC over tag || C || counter || index || reason +} diff --git a/libwallet/domain/model/security_cards_marketplace/SecurityCardsMarketplace.go b/libwallet/domain/model/security_cards_marketplace/SecurityCardsMarketplace.go index 6d53cf5d..4eccfe11 100644 --- a/libwallet/domain/model/security_cards_marketplace/SecurityCardsMarketplace.go +++ b/libwallet/domain/model/security_cards_marketplace/SecurityCardsMarketplace.go @@ -2,19 +2,142 @@ package security_cards_marketplace type Marketplace struct { Providers []SecurityCardsProvider + Specs []SecurityCardSpec +} + +func NewMarketplace( + providers []SecurityCardsProvider, + specs []SecurityCardSpec, +) *Marketplace { + return &Marketplace{Providers: providers, Specs: specs} } type SecurityCardsProvider struct { - Name string - SecurityCards []SecurityCard - CurrencyCode string - ColorHex string - Material string - Price float64 - ShippingCost float64 + Id string //nolint:staticcheck // TODO: struct field Id should be ID + Name string + Description string + SiteUrl string //nolint:staticcheck // TODO: struct field SiteUrl should be SiteURL + LightTheme ProviderTheme + DarkTheme ProviderTheme + SecurityCards []SecurityCard + EstimatedShippingPrices []ShippingPrice +} + +func NewSecurityCardsProvider( + id string, + name string, + description string, + siteUrl string, //nolint:staticcheck // TODO: func parameter siteUrl should be siteURL + lightTheme ProviderTheme, + darkTheme ProviderTheme, + securityCards []SecurityCard, + estimatedShippingPrices []ShippingPrice, +) SecurityCardsProvider { + return SecurityCardsProvider{ + Id: id, + Name: name, + Description: description, + SiteUrl: siteUrl, + LightTheme: lightTheme, + DarkTheme: darkTheme, + SecurityCards: securityCards, + EstimatedShippingPrices: estimatedShippingPrices, + } +} + +type ProviderTheme struct { + PrimaryColor string + SurfaceColor string +} + +func NewProviderTheme(primaryColor, surfaceColor string) ProviderTheme { + return ProviderTheme{ + PrimaryColor: primaryColor, + SurfaceColor: surfaceColor, + } } type SecurityCard struct { - Image string - Stock int32 + Id string //nolint:staticcheck // TODO: struct field Id should be ID + AssetUrl string //nolint:staticcheck // TODO: struct field AssetUrl should be AssetURL + Tag string + SpecId string //nolint:staticcheck // TODO: struct field SpecId should be SpecID + CardCost Price +} + +func NewSecurityCard( + id string, + assetUrl string, //nolint:staticcheck // TODO: func parameter assetUrl should be assetURL + tag string, + specId string, //nolint:staticcheck // TODO: func parameter specId should be specID + cardCost Price, +) SecurityCard { + return SecurityCard{ + Id: id, + AssetUrl: assetUrl, + Tag: tag, + SpecId: specId, + CardCost: cardCost, + } +} + +type Price struct { + CurrencyCode string + Amount string +} + +func NewPrice(currencyCode, amount string) Price { + return Price{CurrencyCode: currencyCode, Amount: amount} +} + +type ShippingPrice struct { + Price Price + Countries []Country +} + +func NewShippingPrice(price Price, countries []Country) ShippingPrice { + return ShippingPrice{Price: price, Countries: countries} +} + +type Country struct { + Code string + Name string + Flag string +} + +func NewCountry(code, name, flag string) Country { + return Country{Code: code, Name: name, Flag: flag} +} + +type SecurityCardSpec struct { + SpecId string //nolint:staticcheck // TODO: struct field SpecId should be SpecID + Items map[string][]SpecItem +} + +func NewSecurityCardSpec( + specId string, //nolint:staticcheck // TODO: func parameter specId should be specID + items map[string][]SpecItem, +) SecurityCardSpec { + return SecurityCardSpec{SpecId: specId, Items: items} +} + +type SpecItem struct { + IconUrl string //nolint:staticcheck // TODO: struct field IconUrl should be IconURL + Label string + Value string + AdditionalData string +} + +func NewSpecItem( + iconUrl string, //nolint:staticcheck // TODO: func parameter iconUrl should be iconURL + label string, + value string, + additionalData string, +) SpecItem { + return SpecItem{ + IconUrl: iconUrl, + Label: label, + Value: value, + AdditionalData: additionalData, + } } diff --git a/libwallet/domain/model/verifiable_muun_key/verifiable_muun_key.go b/libwallet/domain/model/verifiable_muun_key/verifiable_muun_key.go index 817c6e2b..277310dd 100644 --- a/libwallet/domain/model/verifiable_muun_key/verifiable_muun_key.go +++ b/libwallet/domain/model/verifiable_muun_key/verifiable_muun_key.go @@ -3,17 +3,18 @@ package verifiable_muun_key import ( "encoding/base64" "encoding/hex" - "fmt" - "github.com/muun/libwallet/librs" "log/slog" "math/big" "testing" "github.com/btcsuite/btcd/btcec/v2" + "github.com/go-errors/errors" + "github.com/muun/libwallet" "github.com/muun/libwallet/cryptography/bitcoin_hpke" "github.com/muun/libwallet/domain/model/encrypted_key_v3" "github.com/muun/libwallet/encryption" + "github.com/muun/libwallet/librs" "github.com/muun/libwallet/service/model" ) @@ -23,43 +24,77 @@ type VerifiableMuunKey struct { Proof *string } -func VerifiableMuunKeyFromJson(verifiableMuunKeyJson *model.VerifiableMuunKeyJson) (*VerifiableMuunKey, error) { +func NewVerifiableMuunKey( + firstHalf *bitcoin_hpke.EncryptedMessage, + secondHalf *bitcoin_hpke.EncryptedMessage, + proof *string, +) *VerifiableMuunKey { + return &VerifiableMuunKey{ + FirstHalfKeyEncryptedToClient: firstHalf, + SecondHalfKeyEncryptedToRecoveryCode: secondHalf, + Proof: proof, + } +} + +func VerifiableMuunKeyFromJson( //nolint:staticcheck // TODO: func VerifiableMuunKeyFromJson should be VerifiableMuunKeyFromJSON + verifiableMuunKeyJson *model.VerifiableMuunKeyJson, //nolint:staticcheck // TODO: func parameter verifiableMuunKeyJson should be verifiableMuunKeyJSON +) (*VerifiableMuunKey, error) { - firstHalfKeyEncryptedToClientBytes, err := hex.DecodeString(verifiableMuunKeyJson.FirstHalfKeyEncryptedToClient) + firstHalfKeyEncryptedToClientBytes, err := hex.DecodeString( + verifiableMuunKeyJson.FirstHalfKeyEncryptedToClient, + ) if err != nil { return nil, err } - firstHalfKeyEncryptedToClient, err := bitcoin_hpke.ParseEncryptedMessage(firstHalfKeyEncryptedToClientBytes) + firstHalfKeyEncryptedToClient, err := bitcoin_hpke.ParseEncryptedMessage( + firstHalfKeyEncryptedToClientBytes, + ) if err != nil { return nil, err } - secondHalfKeyEncryptedToRecoveryCodeBytes, err := hex.DecodeString(verifiableMuunKeyJson.SecondHalfKeyEncryptedToRecoveryCode) + secondHalfKeyEncryptedToRecoveryCodeBytes, err := hex.DecodeString( + verifiableMuunKeyJson.SecondHalfKeyEncryptedToRecoveryCode, + ) if err != nil { return nil, err } - secondHalfKeyEncryptedToRecoveryCode, err := bitcoin_hpke.ParseEncryptedMessage(secondHalfKeyEncryptedToRecoveryCodeBytes) + secondHalfKeyEncryptedToRecoveryCode, err := bitcoin_hpke.ParseEncryptedMessage( + secondHalfKeyEncryptedToRecoveryCodeBytes, + ) if err != nil { return nil, err } - return &VerifiableMuunKey{ - FirstHalfKeyEncryptedToClient: firstHalfKeyEncryptedToClient, - SecondHalfKeyEncryptedToRecoveryCode: secondHalfKeyEncryptedToRecoveryCode, - Proof: verifiableMuunKeyJson.Proof, - }, nil + return NewVerifiableMuunKey( + firstHalfKeyEncryptedToClient, + secondHalfKeyEncryptedToRecoveryCode, + verifiableMuunKeyJson.Proof, + ), nil } type EncryptedMuunKeyWithVerificationFlag struct { - // The base64 encoded encrypted muun key that can be decrypted with the recovery code private key. + // The base64 encoded encrypted muun key that can be decrypted with the recovery code private + // key. EncryptedMuunKey string - // A boolean value indicating if the encryption was proven to be correct with a zero-knowledge proof. + // A boolean value indicating if the encryption was proven to be correct with a zero-knowledge + // proof. Verified bool } +func NewEncryptedMuunKeyWithVerificationFlag( + encryptedMuunKey string, + verified bool, +) *EncryptedMuunKeyWithVerificationFlag { + return &EncryptedMuunKeyWithVerificationFlag{ + EncryptedMuunKey: encryptedMuunKey, + Verified: verified, + } +} + // Verify returning an EncryptedMuunKeyWithVerificationFlag. func (vk *VerifiableMuunKey) Verify( muunPublicKey *libwallet.HDPublicKey, @@ -81,7 +116,7 @@ func (vk *VerifiableMuunKey) Verify( return nil, err } if len(firstHalfKeyBytes) != 32 { - return nil, fmt.Errorf("firstHalfKeyBytes should be 32 bytes") + return nil, errors.Errorf("firstHalfKeyBytes should be 32 bytes") } firstHalfKey, firstHalfPubKey := btcec.PrivKeyFromBytes(firstHalfKeyBytes) @@ -114,7 +149,7 @@ func (vk *VerifiableMuunKey) Verify( return nil, err } - return &EncryptedMuunKeyWithVerificationFlag{EncryptedMuunKey: encryptedMuunKey, Verified: verified}, nil + return NewEncryptedMuunKeyWithVerificationFlag(encryptedMuunKey, verified), nil } func verifyZeroKnowledgeProof( @@ -164,8 +199,9 @@ func verifyZeroKnowledgeProof( // Compute the subtraction A - B func subtractPublicKeys(A, B *btcec.PublicKey) *btcec.PublicKey { - // Recall that -B is given by (B.X, -B.Y). Note also that since B is on the curve, B.Y cannot be zero and therefore - // P-B.Y is already reduced modulo P. Thus there is no need to reduce modulo P in the line below. + // Recall that -B is given by (B.X, -B.Y). Note also that since B is on the curve, B.Y cannot be + // zero and therefore P-B.Y is already reduced modulo P. Thus there is no need to reduce modulo + // P in the line below. rX, rY := btcec.S256().Add(A.X(), A.Y(), B.X(), new(big.Int).Sub(btcec.S256().P, B.Y())) var X, Y btcec.FieldVal X.SetByteSlice(encryption.PaddedSerializeBigInt(32, rX)) diff --git a/libwallet/domain/model/verifiable_muun_key/verifiable_muun_key_test.go b/libwallet/domain/model/verifiable_muun_key/verifiable_muun_key_test.go index 3b3b6cea..7188d99d 100644 --- a/libwallet/domain/model/verifiable_muun_key/verifiable_muun_key_test.go +++ b/libwallet/domain/model/verifiable_muun_key/verifiable_muun_key_test.go @@ -2,18 +2,23 @@ package verifiable_muun_key import ( "encoding/hex" - "github.com/btcsuite/btcd/btcec/v2" "testing" + + "github.com/btcsuite/btcd/btcec/v2" ) func TestSubtractPublicKeys(t *testing.T) { - aSerialization, err := hex.DecodeString("028e949335c8d8bc841167860949c990ac10c87004f74c4513c39603dddf687dbb") + aSerialization, err := hex.DecodeString( + "028e949335c8d8bc841167860949c990ac10c87004f74c4513c39603dddf687dbb", + ) if err != nil { t.Fatal(err) } - bSerialization, err := hex.DecodeString("0316e7c706d5bfd42194360e7109d0717c18bdba36c24442af99555dc981d1a66b") + bSerialization, err := hex.DecodeString( + "0316e7c706d5bfd42194360e7109d0717c18bdba36c24442af99555dc981d1a66b", + ) if err != nil { t.Fatal(err) } diff --git a/libwallet/domain/nfc/apdu.go b/libwallet/domain/nfc/apdu.go index bed535d7..bea937d6 100644 --- a/libwallet/domain/nfc/apdu.go +++ b/libwallet/domain/nfc/apdu.go @@ -2,10 +2,28 @@ package nfc import ( "encoding/hex" + + "github.com/go-errors/errors" +) + +// ISO 7816-4 length limits for the two APDU wire formats. +const ( + // MaxShortApduDataSize is the maximum data field the short form can + // carry: Lc is a single byte. + MaxShortApduDataSize = 255 + + // MaxExtendedApduSupportedLength caps the Lc/Le fields of an extended + // APDU at 32767. This is our card's limit (JCOP stores them as signed + // 16-bit shorts and rejects larger values with 0x6700), not an ISO + // 7816-4 one. + MaxExtendedApduSupportedLength = 0x7FFF ) // apdu message structure as per documentation: // https://www.cardlogix.com/glossary/apdu-application-protocol-data-unit-smart-card/ +// +// Two wire formats are supported via serializeShort and serializeExtended; +// the caller picks based on the target applet's ISO 7816-4 capabilities. type apdu struct { cls byte // Class of instruction ins byte // Instruction code @@ -20,7 +38,9 @@ func newAPDU(cls byte, ins byte, p1 byte, p2 byte, data []byte) *apdu { // newSelectAPDU builds the ISO select apdu to pick the applet. [00 a4 04 00 (appletId)]. // This is required to get started. -func newSelectAPDU(appletId string) (*apdu, error) { +func newSelectAPDU( + appletId string, //nolint:staticcheck // TODO: func parameter appletId should be appletID +) (*apdu, error) { initByteCode, err := hex.DecodeString(appletId) if err != nil { return nil, err @@ -29,6 +49,50 @@ func newSelectAPDU(appletId string) (*apdu, error) { return newAPDU(cla, insSelect, 4, 0, initByteCode), nil } -func (a *apdu) serialize() []byte { +// serializeShort produces an ISO 7816-4 short-form APDU on the wire: +// +// CLA INS P1 P2 Lc Data +// +// Lc is a single byte, so data must be at most 255 bytes. Data larger than +// that is a caller bug (short-only applets can't send it); panic instead of +// silently truncating. +func (a *apdu) serializeShort() []byte { + if len(a.data) > MaxShortApduDataSize { + panic(errors.Errorf( + "short APDU data %d exceeds %d bytes", + len(a.data), MaxShortApduDataSize, + )) + } return append([]byte{a.cls, a.ins, a.p1, a.p2, byte(len(a.data))}, a.data...) } + +// serializeExtended produces an ISO 7816-4 extended-length APDU on the wire. +// Two shapes depending on whether data is present: +// +// No data (case 2E): CLA INS P1 P2 | 0x00 LeHi LeLo +// With data (case 4E): CLA INS P1 P2 | 0x00 LcHi LcLo | Data | LeHi LeLo +// +// Le is fixed to MaxExtendedApduSupportedLength (0x7FFF), the largest response the +// card accepts (Java Card caps Ne at a signed short, 0..32767). Requesting +// 0xFFFF would make the JCRE reject the APDU with 0x6700. The card still +// returns only what it has. Command data above the same 32767 cap panics. +func (a *apdu) serializeExtended() []byte { + if len(a.data) > MaxExtendedApduSupportedLength { + panic(errors.Errorf( + "extended APDU data %d exceeds %d bytes", + len(a.data), MaxExtendedApduSupportedLength, + )) + } + header := []byte{a.cls, a.ins, a.p1, a.p2} + leHi, leLo := byte(MaxExtendedApduSupportedLength>>8), byte(MaxExtendedApduSupportedLength&0xFF) + if len(a.data) == 0 { + // Case 2E: extended Le (3 bytes including the 0x00 marker). + return append(header, 0x00, leHi, leLo) + } + // Case 4E: 0x00 marker + 2-byte Lc, then data, then 2-byte Le. + lc := []byte{0x00, byte(len(a.data) >> 8), byte(len(a.data))} + out := append(header, lc...) + out = append(out, a.data...) + out = append(out, leHi, leLo) + return out +} diff --git a/libwallet/domain/nfc/apdu_test.go b/libwallet/domain/nfc/apdu_test.go new file mode 100644 index 00000000..e41609c8 --- /dev/null +++ b/libwallet/domain/nfc/apdu_test.go @@ -0,0 +1,107 @@ +package nfc + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestSerializeShort_NoData(t *testing.T) { + a := newAPDU(0x80, 0x70, 0x00, 0x00, []byte{}) + require.Equal(t, []byte{0x80, 0x70, 0x00, 0x00, 0x00}, a.serializeShort()) +} + +func TestSerializeShort_WithData(t *testing.T) { + a := newAPDU(0x80, 0x10, 0x00, 0x00, []byte{0xAA, 0xBB, 0xCC}) + require.Equal( + t, + []byte{0x80, 0x10, 0x00, 0x00, 0x03, 0xAA, 0xBB, 0xCC}, + a.serializeShort(), + ) +} + +func TestSerializeShort_MaxData(t *testing.T) { + data := make([]byte, MaxShortApduDataSize) + for i := range data { + data[i] = byte(i) + } + a := newAPDU(0x80, 0x10, 0x00, 0x00, data) + + got := a.serializeShort() + require.Len(t, got, 5+MaxShortApduDataSize) + require.Equal(t, byte(0xFF), got[4]) // Lc = 255 + require.Equal(t, data, got[5:]) +} + +func TestSerializeShort_OverflowPanics(t *testing.T) { + data := make([]byte, MaxShortApduDataSize+1) + a := newAPDU(0x80, 0x10, 0x00, 0x00, data) + require.PanicsWithError( + t, + "short APDU data 256 exceeds 255 bytes", + func() { a.serializeShort() }, + ) +} + +func TestSerializeExtended_NoData(t *testing.T) { + // Case 2E: CLA INS P1 P2 | 0x00 LeHi LeLo (7 bytes) + a := newAPDU(0x80, 0x70, 0x00, 0x00, []byte{}) + require.Equal( + t, + []byte{0x80, 0x70, 0x00, 0x00, 0x00, 0x7F, 0xFF}, + a.serializeExtended(), + ) +} + +func TestSerializeExtended_WithData(t *testing.T) { + // Case 4E: CLA INS P1 P2 | 0x00 LcHi LcLo | Data | LeHi LeLo + data := []byte{0xAA, 0xBB, 0xCC} + a := newAPDU(0x80, 0x10, 0x00, 0x00, data) + require.Equal( + t, + []byte{ + 0x80, 0x10, 0x00, 0x00, + 0x00, 0x00, 0x03, // extended Lc = 3 + 0xAA, 0xBB, 0xCC, // data + 0x7F, 0xFF, // extended Le = 32767 + }, + a.serializeExtended(), + ) +} + +func TestSerializeExtended_LcSpanning256(t *testing.T) { + // Verify LcHi/LcLo encoding across the short-APDU boundary. + data := make([]byte, 300) + for i := range data { + data[i] = byte(i % 256) + } + a := newAPDU(0x80, 0x10, 0x00, 0x00, data) + + got := a.serializeExtended() + require.Len(t, got, 4+3+300+2) + require.Equal(t, byte(0x00), got[4]) // extended marker + require.Equal(t, byte(0x01), got[5]) // LcHi = 300 >> 8 = 1 + require.Equal(t, byte(0x2C), got[6]) // LcLo = 300 & 0xFF = 44 + require.Equal(t, data, got[7:7+300]) // data + require.Equal(t, []byte{0x7F, 0xFF}, got[7+300:]) // Le +} + +func TestSerializeExtended_MaxData(t *testing.T) { + data := make([]byte, MaxExtendedApduSupportedLength) + a := newAPDU(0x80, 0x10, 0x00, 0x00, data) + + got := a.serializeExtended() + require.Len(t, got, 4+3+MaxExtendedApduSupportedLength+2) + require.Equal(t, byte(0x7F), got[5]) // LcHi + require.Equal(t, byte(0xFF), got[6]) // LcLo +} + +func TestSerializeExtended_OverflowPanics(t *testing.T) { + data := make([]byte, MaxExtendedApduSupportedLength+1) + a := newAPDU(0x80, 0x10, 0x00, 0x00, data) + require.PanicsWithError( + t, + "extended APDU data 32768 exceeds 32767 bytes", + func() { a.serializeExtended() }, + ) +} diff --git a/libwallet/domain/nfc/card_errors.go b/libwallet/domain/nfc/card_errors.go new file mode 100644 index 00000000..fc6e7deb --- /dev/null +++ b/libwallet/domain/nfc/card_errors.go @@ -0,0 +1,33 @@ +package nfc + +import "fmt" + +// CardErrorCode represents our internal domain error codes +type CardErrorCode uint16 + +const ( + ErrInternal CardErrorCode = 1 + ErrSlotOccupied CardErrorCode = 2 + ErrSlotNotInitialized CardErrorCode = 3 + ErrAppletIdNotFound CardErrorCode = 4 //nolint:staticcheck // TODO: const ErrAppletIdNotFound should be ErrAppletIDNotFound + // ErrTransport marks a failure in the underlying NFC bridge (card moved away, link dropped, etc) + ErrTransport CardErrorCode = 5 +) + +type CardError struct { + Message string + Code CardErrorCode +} + +// Error implements the error interface. +func (e *CardError) Error() string { + return fmt.Sprintf("status %d: %s", e.Code, e.Message) +} + +// newCardError creates a new CardError instance. +func newCardError(code CardErrorCode, message string) *CardError { + return &CardError{ + Message: message, + Code: code, + } +} diff --git a/libwallet/domain/nfc/javacard.go b/libwallet/domain/nfc/javacard.go index d1174d44..ff796f69 100644 --- a/libwallet/domain/nfc/javacard.go +++ b/libwallet/domain/nfc/javacard.go @@ -2,9 +2,11 @@ package nfc import ( "encoding/hex" - "fmt" - "github.com/muun/libwallet/app_provided_data" "log/slog" + + "github.com/go-errors/errors" + + "github.com/muun/libwallet/app_provided_data" ) const nullByte = 0x00 @@ -14,6 +16,9 @@ const nullByte = 0x00 // 0xFF for applet-specific or Javacard proprietary commands. const cla = 0x00 +// claEdge is our custom Instruction Class for applet-specific commands (0x80-0xFF range). +const claEdge = 0x80 + // InsSelect is the Instruction code sent to the card selecting the applet to operate with. // Most of the instructions are described in the ISO7816 interface document. // https://docs.oracle.com/en/java/javacard/3.2/jcapi/api_classic/javacard/framework/ISO7816.html @@ -48,16 +53,18 @@ func newJavaCard(nfcBridge app_provided_data.NfcBridge) *JavaCard { } // selectApplet sends the ISO select apdu command with the specified AppletId to this JavaCard -func (c *JavaCard) selectApplet(appletId string) error { +func (c *JavaCard) selectApplet( + appletId string, //nolint:staticcheck // TODO: method parameter appletId should be appletID +) error { selectAPDU, err := newSelectAPDU(appletId) if err != nil { - return fmt.Errorf("couldn't build select apdu command: %w", err) + return errors.Errorf("couldn't build select apdu command: %w", err) } - initialResponse, err := c.transmit(selectAPDU.serialize()) + initialResponse, err := c.transmit(selectAPDU.serializeShort()) if err != nil { - return fmt.Errorf("couldn't transmit select apdu command: %w", err) + return errors.Errorf("couldn't transmit select apdu command: %w", err) } if initialResponse.StatusCode != responseOk { @@ -65,7 +72,7 @@ func (c *JavaCard) selectApplet(appletId string) error { "unknown response to select apdu command", slog.Any("response", hex.EncodeToString(initialResponse.Response)), ) - return fmt.Errorf("ISO app select failed, status code %x", initialResponse.StatusCode) + return errors.Errorf("ISO app select failed, status code %x", initialResponse.StatusCode) } return nil diff --git a/libwallet/domain/nfc/mock_javacard.go b/libwallet/domain/nfc/mock_javacard.go index 631a9ee1..4d848a8e 100644 --- a/libwallet/domain/nfc/mock_javacard.go +++ b/libwallet/domain/nfc/mock_javacard.go @@ -1,10 +1,12 @@ package nfc import ( + "crypto/rand" "encoding/hex" "fmt" - "github.com/muun/libwallet/app_provided_data" "strings" + + "github.com/muun/libwallet/app_provided_data" ) type JavaCardApplet interface { @@ -66,3 +68,27 @@ func (m *MockJavaCard) handleSelectApplet(apdu []byte) ( // Return some internal OS stuff + "muun.com" in hex (e.g. 6D75756E2E636F6D). return newSuccessResponse([]byte("D1010855046D75756E2E636F6D")), nil } + +func newSuccessResponse(responseBytes []byte) *app_provided_data.NfcBridgeResponse { + return &app_provided_data.NfcBridgeResponse{ + Response: responseBytes, + StatusCode: responseOk, + } +} + +func newErrorResponse(statusCode int32) *app_provided_data.NfcBridgeResponse { + return &app_provided_data.NfcBridgeResponse{ + Response: nil, + StatusCode: statusCode, + } +} + +func randomBytes(count int) []byte { + buf := make([]byte, count) + _, err := rand.Read(buf) + if err != nil { + panic("couldn't read random bytes") + } + + return buf +} diff --git a/libwallet/domain/nfc/mock_muuncard.go b/libwallet/domain/nfc/mock_muuncard.go deleted file mode 100644 index cfc36c68..00000000 --- a/libwallet/domain/nfc/mock_muuncard.go +++ /dev/null @@ -1,289 +0,0 @@ -package nfc - -import ( - "crypto/aes" - "crypto/cipher" - "crypto/hmac" - "crypto/rand" - "crypto/sha1" - "encoding/hex" - "errors" - "fmt" - "github.com/btcsuite/btcd/btcec/v2/ecdsa" - "github.com/btcsuite/btcd/btcutil/base58" - "github.com/muun/libwallet" - "github.com/muun/libwallet/app_provided_data" - "log/slog" -) - -type MockMuunCard struct { - network *libwallet.Network - secureChannel *muunCardSecureChannel - privateKeySlots []*libwallet.HDPrivateKey -} - -// Enforce we implement the interface -var _ JavaCardApplet = (*MockMuunCard)(nil) - -func NewMockMuunCard(network *libwallet.Network) *MockMuunCard { - return &MockMuunCard{ - network: network, - privateKeySlots: make([]*libwallet.HDPrivateKey, 1), - } -} - -func (s *muunCardSecureChannel) processSecureCommand(apdu []byte) ([]byte, error) { - - dataSize := int(apdu[iso7816OffsetLc]) - if dataSize < 20 { - // TODO return error response SW_WRONG_LENGTH - return nil, fmt.Errorf("incorrect data length %v", dataSize) - } - - data := apdu[iso7816OffsetCData:] - if len(data) != dataSize { - return nil, fmt.Errorf("invalid apdu: expected %v data bytes, got %v", dataSize, len(data)) - } - - receivedMac := data[len(data)-hmacSha1SizeInBytes:] - ciphertext := data[:len(data)-hmacSha1SizeInBytes] - - // Decrypt the payload with AES-CBC and no padding using a zero IV - zeroIV := make([]byte, aes.BlockSize) // Zero IV - key := s.derivedKey[:16] // Use first 16 bytes for AES - decryptedPlaintext, err := aesDecrypt(key, zeroIV, ciphertext) - if err != nil { - return nil, err - } - - // Recompute the MAC using the derived key and encrypted data - computedMac := computeHmacSha1(s.derivedKey, ciphertext) // Use full derived key for HMAC - - // Verify the MAC by comparing computed MAC with received MAC - if !hmac.Equal(receivedMac, computedMac) { - // TODO return error response SW_MAC_MISMATCH - return nil, fmt.Errorf( - "MAC verification failed, wanted %s, got %s", - hex.EncodeToString(computedMac), - hex.EncodeToString(receivedMac), - ) - } - - return decryptedPlaintext, nil -} - -func (c *MockMuunCard) getAppletId() string { - return muuncardAppletId -} - -func (c *MockMuunCard) processCommand(apdu []byte) (*app_provided_data.NfcBridgeResponse, error) { - ins := apdu[iso7816OffsetIns] - - switch ins { - case insMuuncardSetup: - return c.handleSetupCard(apdu) - case insMuuncardReset: - return c.handleResetCard(apdu) - case insMuuncardInitSecureChannel: - return c.handleInitSecureChannel(apdu) - case insMuuncardSignMessage: - return c.handleSignMessage(apdu) - } - - // TODO return ISO7816.SW_INS_NOT_SUPPORTED - return &app_provided_data.NfcBridgeResponse{ - Response: nil, - StatusCode: responseOk, - }, nil -} - -func (c *MockMuunCard) handleSetupCard(apdu []byte) (*app_provided_data.NfcBridgeResponse, error) { - - if c.secureChannel == nil { - // TODO return SW_CONDITIONS_NOT_SATISFIED error response - } - - // First thing, process secure command (if error, there's no sense in further processing) - seed, err := c.secureChannel.processSecureCommand(apdu) - if err != nil { - return nil, err - } - - // If MAC is verified, respond with the computed MAC (appended to response below). - // Note: that is not what current muuncard impl does, it returns deriveKey as mac. So we - // replicate that behavior. Will fix soon. - derivedKey := c.secureChannel.derivedKey - - // We expect exactly 16 bytes - if len(seed) != 16 { - // TODO return SW_WRONG_LENGTH error response - } - - slot := int(apdu[iso7816OffsetP1]) - if slot >= len(c.privateKeySlots) { - // TODO return SW_INVALID_SLOT error response - } - - if c.privateKeySlots[slot] != nil { - return newErrorResponse(swMuuncardSlotOccupied), nil - } - - // Generate random keypair - generatedExtendedPrivateKey, err := libwallet.NewHDPrivateKey(seed, c.network) - if err != nil { - return nil, fmt.Errorf("failed to generate keypair: %w", err) - } - - c.privateKeySlots[slot] = generatedExtendedPrivateKey - generatedExtendedPublicKey := base58.Decode(generatedExtendedPrivateKey.PublicKey().String()) - - // Reset secure channel so the client is forced to reinitialize it - c.closeSecureChannel() - - return newSuccessResponse(append(generatedExtendedPublicKey, derivedKey...)), nil -} - -func (c *MockMuunCard) handleResetCard(apdu []byte) (*app_provided_data.NfcBridgeResponse, error) { - - if c.secureChannel == nil { - // TODO return SW_CONDITIONS_NOT_SATISFIED error response - } - - slot := int(apdu[iso7816OffsetP1]) - if slot >= len(c.privateKeySlots) { - // TODO return SW_INVALID_SLOT error response - } - - if c.privateKeySlots[slot] == nil { - return newErrorResponse(swMuuncardKeyNotInitialized), nil - } - - c.privateKeySlots[slot] = nil - - return newSuccessResponse([]byte{}), nil -} - -func (c *MockMuunCard) handleInitSecureChannel(apdu []byte) ( - *app_provided_data.NfcBridgeResponse, - error, -) { - dataLength := int(apdu[iso7816OffsetLc]) - pubKeyBytes := apdu[iso7816OffsetCData:] - if len(pubKeyBytes) != dataLength || dataLength != 65 { - return nil, errors.New("extended uncompressed pub key must have 65 bytes") - } - - randomHDPriKey, _ := libwallet.NewHDPrivateKey(randomBytes(16), c.network) - cardPrivateKey, err := randomHDPriKey.ECPrivateKey() - if err != nil { - return nil, err - } - - secureChannel, err := newSecureChannel(cardPrivateKey, pubKeyBytes) - if err != nil { - return nil, err - } - - c.secureChannel = secureChannel - - // Return 65 bytes uncompressed card ephemeral public key - return newSuccessResponse(randomHDPriKey.PublicKey().SerializeUncompressed()), nil -} - -func (c *MockMuunCard) handleSignMessage(apdu []byte) (*app_provided_data.NfcBridgeResponse, error) { - - if c.secureChannel == nil { - // TODO return SW_CONDITIONS_NOT_SATISFIED error response - } - - slot := int(apdu[iso7816OffsetP1]) - - // Note this is inconsistent with what generateKeyPair/resetCard do (they return SW_INVALID_SLOT - // if slot >= maxSlots), but it's what current muuncard impl does. - if slot >= len(c.privateKeySlots) || c.privateKeySlots[slot] == nil { - return newErrorResponse(swMuuncardKeyNotInitialized), nil - } - - // This is also inconsistent with generateKeyPair do (first it processSecureCommand, then - // validations), but it's what current muuncard impl does. - messageHash, err := c.secureChannel.processSecureCommand(apdu) - if err != nil { - return nil, err - } - - // We expect exactly 32 bytes - if len(messageHash) != 32 { - // TODO return error response SW_WRONG_LENGTH - } - - // Sign the hash with private key of the given slot - - signingKey, err := c.privateKeySlots[slot].ECPrivateKey() - if err != nil { - return nil, err - } - - finalMessageHash := sha1.Sum(messageHash[:]) - sig := ecdsa.Sign(signingKey, finalMessageHash[:]) - - // If MAC is verified, respond with the computed MAC (appended to response below). - // Note: that is not what current muuncard impl does, it returns deriveKey as mac. So we - // replicate that behavior. Will fix soon. - derivedKey := c.secureChannel.derivedKey - - return newSuccessResponse(append(sig.Serialize(), derivedKey...)), nil -} - -func (c *MockMuunCard) closeSecureChannel() { - if c.secureChannel == nil { - // TODO return SW_CONDITIONS_NOT_SATISFIED error response - } - c.secureChannel = nil -} - -func newSuccessResponse(responseBytes []byte) *app_provided_data.NfcBridgeResponse { - - slog.Debug("response apdu", "apdu", hex.EncodeToString(append(responseBytes, 0x90, 0x00))) - - return &app_provided_data.NfcBridgeResponse{ - Response: responseBytes, - StatusCode: responseOk, - } -} - -func newErrorResponse(statusCode int32) *app_provided_data.NfcBridgeResponse { - - slog.Debug("error response apdu", "statusCode", statusCode) - - return &app_provided_data.NfcBridgeResponse{ - Response: nil, - StatusCode: statusCode, - } -} - -func randomBytes(count int) []byte { - buf := make([]byte, count) - _, err := rand.Read(buf) - if err != nil { - panic("couldn't read random bytes") - } - - return buf -} - -// aesDecrypt decrypts a ciphertext using the specified key and a IV. -// Note: this impl uses AES-128-CBC with no padding. Hence, key and IV must be 16 bytes (128 bits) -// and ciphertext length must be a multiple of 16. -func aesDecrypt(key []byte, iv []byte, ciphertext []byte) ([]byte, error) { - block, err := aes.NewCipher(key) - if err != nil { - return nil, fmt.Errorf("failed to create AES cipher: %w", err) - } - - plaintext := make([]byte, len(ciphertext)) - - mode := cipher.NewCBCDecrypter(block, iv) - mode.CryptBlocks(plaintext, ciphertext) - - return plaintext, nil -} diff --git a/libwallet/domain/nfc/mock_muuncard_test.go b/libwallet/domain/nfc/mock_muuncard_test.go deleted file mode 100644 index 8d326e36..00000000 --- a/libwallet/domain/nfc/mock_muuncard_test.go +++ /dev/null @@ -1,10 +0,0 @@ -package nfc - -import "testing" - -func TestMuunCard(t *testing.T) { - - // TODO test the internals of Mock MuunCards (e.g specific error handling) - // - Mismatch MACs - // - Send -} diff --git a/libwallet/domain/nfc/mock_muuncard_v2.go b/libwallet/domain/nfc/mock_muuncard_v2.go index 7e82b357..13978ace 100644 --- a/libwallet/domain/nfc/mock_muuncard_v2.go +++ b/libwallet/domain/nfc/mock_muuncard_v2.go @@ -8,9 +8,10 @@ import ( "encoding/asn1" "encoding/hex" "fmt" + "math/big" + "github.com/muun/libwallet/app_provided_data" "github.com/muun/libwallet/cryptography" - "math/big" ) type MockMuunCardV2 struct { @@ -522,7 +523,9 @@ func buildChallengeMacInput(challengeC []byte, counter, index uint16, payload [] macInput = append(macInput, challengeC...) // C (65 bytes) macInput = append(macInput, byte(counter>>8), byte(counter&0xFF)) // counter (2 bytes) macInput = append(macInput, byte(index>>8), byte(index&0xFF)) // index (2 bytes) - macInput = append(macInput, payload...) // payload (reason or reason_hash) + macInput = append( + macInput, + payload...) // payload (reason or reason_hash) return macInput } @@ -549,7 +552,8 @@ func constantTimeCompare(a, b []byte) bool { return result == 0 } -// generateChallengeResponse generates unified challenge response for both single and streaming modes +// generateChallengeResponse generates unified challenge response for both single and streaming +// modes func (c *MockMuunCardV2) generateChallengeResponse(slot uint16, challengeC []byte) ( *app_provided_data.NfcBridgeResponse, error, @@ -562,7 +566,7 @@ func (c *MockMuunCardV2) generateChallengeResponse(slot uint16, challengeC []byt } ephemeralPublic, err := cryptography.GenerateSecp256r1PublicKey(ephemeralPrivate) if err != nil { - return nil, fmt.Errorf("failed to generate ephemeral public key: %v", err) + return nil, fmt.Errorf("failed to generate ephemeral public key: %w", err) } // Perform ECDH: shared_point = p * C diff --git a/libwallet/domain/nfc/mock_muuncard_v2_test.go b/libwallet/domain/nfc/mock_muuncard_v2_test.go index eed2e809..cc3980ae 100644 --- a/libwallet/domain/nfc/mock_muuncard_v2_test.go +++ b/libwallet/domain/nfc/mock_muuncard_v2_test.go @@ -1,8 +1,9 @@ package nfc import ( - "github.com/muun/libwallet/cryptography" "testing" + + "github.com/muun/libwallet/cryptography" ) // TODO another reason why "reason" (yes pun intended) should be part of SignChallenge @@ -113,36 +114,6 @@ func TestMockCardSignChallengeSingle_ErrorScenarios(t *testing.T) { }, expectedStatusCode: swInsNotSupported, }, - { - name: "MockCardSignChallengeIncorrectHasMoreChunks_2", - challengeMapper: func(challengeData *SignChallengeData) *SignChallengeData { - - // hasMoreChunks is false but reason is greater than 1 chunk - challengeData.hasMoreChunks = byte(0) - // more than max apdu size, definitely should be split - challengeData.reason = make([]byte, 256) - return challengeData - }, - // For reasons that doesn't fit in 1 chunk, we should use streaming apdus (and split in - // chunks). Apdus have a 1-byte length field which allows max 255 bytes data. When - // larger data array is passed, length gets truncated or encoded incorrectly, causing - // WrongLength error. - expectedStatusCode: swMuuncardV2WrongLength, - }, - { - name: "MockCardSignChallengeHasMoreChunks", - challengeMapper: func(challengeData *SignChallengeData) *SignChallengeData { - - challengeData.hasMoreChunks = byte(1) - // more than apdu size, definitely should be split - challengeData.reason = make([]byte, 256) - return challengeData - }, - // For reasons that doesn't fit in 1 chunk, we should use streaming apdus (and split in - // chunks). Apdus have a 1-byte length field which allows max 255 bytes data. When - // larger data array is passed, length gets truncated or encoded incorrectly, causing - // WrongLength error. - expectedStatusCode: swMuuncardV2WrongLength}, } for _, tt := range tests { @@ -187,7 +158,7 @@ func TestMockCardSignChallengeSingle_ErrorScenarios(t *testing.T) { t.Fatalf("invalid input for test. Either apduData or challengeMapper must be defined") } - response, err := card.rawCard.transmit(buildSignChallengeAPDU(apduData).serialize()) + response, err := card.rawCard.transmit(buildSignChallengeAPDU(apduData).serializeShort()) if err != nil { t.Errorf("transmit failed: %v", err) @@ -253,7 +224,7 @@ func TestMockCardSignChallengeSingle_Success(t *testing.T) { challenge.Mac, // mac (32 bytes) ) - response, err := card.rawCard.transmit(buildSignChallengeAPDU(apduData).serialize()) + response, err := card.rawCard.transmit(buildSignChallengeAPDU(apduData).serializeShort()) if err != nil { t.Errorf("transmit failed: %v", err) diff --git a/libwallet/domain/nfc/muuncard.go b/libwallet/domain/nfc/muuncard.go deleted file mode 100644 index 96dc2ea7..00000000 --- a/libwallet/domain/nfc/muuncard.go +++ /dev/null @@ -1,311 +0,0 @@ -package nfc - -import ( - "crypto/sha1" - "crypto/sha256" - "encoding/hex" - "fmt" - "github.com/btcsuite/btcd/btcec/v2" - "github.com/btcsuite/btcd/btcec/v2/ecdsa" - "github.com/btcsuite/btcd/btcutil/base58" - "github.com/muun/libwallet" - "github.com/muun/libwallet/app_provided_data" - "log/slog" -) - -// Implementation to interact with our reference security card firmware. - -const muuncardAppletId = "A00000015100133700" - -// claEdge is our Cla custom Instruction Class. You use 0x80-0xFF for applet-specific or Javacard -// proprietary commands. -const claEdge = 0x80 - -// Muuncard specific apdu bytes. -const insMuuncardSetup = 0x10 -const insMuuncardReset = 0x30 -const insMuuncardInitSecureChannel = 0x40 -const insMuuncardSignMessage = 0x80 - -// Muuncard specific status words. -const swMuuncardInvalidSlot = 0x6B01 -const swMuuncardSlotOccupied = 0x6B02 -const swMuuncardKeyNotInitialized = 0x6B03 -const swMuuncardKeyBuilderError = 0x6B04 -const swMuuncardMacMismatch = 0x6B05 -const swMuuncardHmacErrorBufferOverflow = 0x6884 -const swMuuncardSecureChannelNotInitialized = 0x6985 - -type MuunCard struct { - rawCard *JavaCard -} - -func NewCard(nfcBridge app_provided_data.NfcBridge) *MuunCard { - return &MuunCard{rawCard: newJavaCard(nfcBridge)} -} - -// CardErrorCode represents our internal domain error codes -type CardErrorCode uint16 - -const ( - ErrInternal CardErrorCode = 1 - ErrSlotOccupied CardErrorCode = 2 - ErrSlotNotInitialized CardErrorCode = 3 - ErrAppletIdNotFound CardErrorCode = 4 -) - -var cardStatusToError = map[uint16]*CardError{ - swMuuncardSecureChannelNotInitialized: {Message: "secure channel is not initialized", Code: ErrInternal}, - swMuuncardSlotOccupied: {Message: "slot already initialized", Code: ErrSlotOccupied}, - swMuuncardInvalidSlot: {Message: "invalid slot (out of range)", Code: ErrInternal}, - swMuuncardKeyBuilderError: {Message: "key builder error (internal)", Code: ErrInternal}, - swMuuncardMacMismatch: {Message: "secure channel MAC doesn't match", Code: ErrInternal}, - swMuuncardKeyNotInitialized: { - Message: "private key not initialized in given slot", - Code: ErrSlotNotInitialized, - }, - swMuuncardHmacErrorBufferOverflow: { - Message: "HMAC buffer overflow (block_size + message_length > data.length)", - Code: ErrInternal, - }, -} - -func mapStatusToCardError(code uint16) error { - if cardError, ok := cardStatusToError[code]; ok { - return cardError - } - return newCardError(ErrInternal, fmt.Sprintf("unknown error code: 0x%x", code)) -} - -type CardError struct { - Message string - Code CardErrorCode -} - -// CardError implements the error interface for CustomError. -func (e *CardError) Error() string { - return fmt.Sprintf("status %d: %s", e.Code, e.Message) -} - -// newCardError creates a new CardError instance. -func newCardError(code CardErrorCode, message string) *CardError { - return &CardError{ - Message: message, - Code: code, - } -} - -type ExtendedPublicKey struct { - RawBytes []byte -} - -// GenerateKeyPair sets up a fresh private key in the smart card. This command expects a 16-byte -// array to be used as part of the seed for the private key generation process. -func (c *MuunCard) GenerateKeyPair(seedBytes [16]byte) (*ExtendedPublicKey, error) { - var cla byte = claEdge - var ins byte = insMuuncardSetup - var p2 byte = nullByte - var slot byte = 0 - - msg := newAPDU(cla, ins, slot, p2, seedBytes[:]) - serializedAPDU := msg.serialize() - - slog.Debug("Setting private key in card slot", slog.Any("slot", slot)) - - resp, err := c.transmit(serializedAPDU) - if err != nil { - return nil, fmt.Errorf("error transmitting card setup apdu: %w", err) - } - - if resp.StatusCode != responseOk { - errorMsg := fmt.Sprintf("unknown status code %x, expecting ResponseOk", resp.StatusCode) - slog.Warn(errorMsg, slog.Any("status code", resp.StatusCode)) - return nil, newCardError(ErrInternal, errorMsg) - } - - slog.Debug("card private key has been set up", slog.Any("slot", slot)) - - if len(resp.Response) != 82 { - errorMsg := fmt.Sprintf( - "invalid response length: %v, wanted 82. %s", - len(resp.Response), - hex.EncodeToString(resp.Response), - ) - return nil, newCardError(ErrInternal, errorMsg) - } - - xpub := resp.Response - encodedXpub := base58.Encode(xpub) - slog.Debug("xpub", slog.String("encodedXpub", encodedXpub)) - - return &ExtendedPublicKey{RawBytes: xpub}, nil -} - -// ResetCard resets an existing seed, effectively wiping out the related keys. -func (c *MuunCard) ResetCard() error { - var cla byte = claEdge - - var ins byte = insMuuncardReset - var p2 byte = nullByte - var slot byte = 0 - - msg := newAPDU(cla, ins, slot, p2, []byte{}) - serializedAPDU := msg.serialize() - - slog.Debug("Resetting slot", slog.Any("slot", slot)) - - resp, err := c.transmit(serializedAPDU) - if err != nil { - return fmt.Errorf("error transmitting reset card apdu: %w", err) - } - - if resp.StatusCode == responseOk { - slog.Debug("card private key has been reset") - - } else if resp.StatusCode == swMuuncardKeyNotInitialized { - return newCardError(swMuuncardKeyNotInitialized, "this slot was empty") - } - - return nil -} - -type SignedMessage struct { - RawBytes []byte -} - -// SignMessage signs a message hash (double SHA256) with the private key from the smart card. -// Requires a private key to be generated beforehand. -func (c *MuunCard) SignMessage(message string) (*SignedMessage, error) { - var cla byte = claEdge - - var ins byte = insMuuncardSignMessage - var p2 byte = nullByte - var slot byte = 0 - - msgHash := sha256.Sum256([]byte(message)) - msgHash = sha256.Sum256(msgHash[:]) - - msg := newAPDU(cla, ins, slot, p2, msgHash[:]) - serializedAPDU := msg.serialize() - - resp, err := c.transmit(serializedAPDU) - if err != nil { - return nil, fmt.Errorf("error transmitting sign message apdu: %w", err) - } - - if resp.StatusCode != responseOk { - return nil, fmt.Errorf("couldn't sign message: %x", resp.StatusCode) - } - - return &SignedMessage{RawBytes: resp.Response}, nil -} - -// VerifySignature verifies a signature from a muuncard. -func (c *MuunCard) VerifySignature( - hdPubKey *libwallet.HDPublicKey, - message []byte, - signedMessage []byte, -) (bool, error) { - - // TODO: Check whether the verification will only take place in Houston. - ecPublicKey, err := hdPubKey.ECPubKey() - if err != nil { - return false, fmt.Errorf("error converting to EcPubKey: %w", err) - } - - // Parse DER signature with ecdsa - sig, err := ecdsa.ParseDERSignature(signedMessage) - if err != nil { - return false, fmt.Errorf("error parsing DER signature with ecdsa: %w", err) - } - - // Double SHA256 + SHA1 - messageHash := sha256.Sum256(message) - messageHash = sha256.Sum256(messageHash[:]) - finalMessageHash := sha1.Sum(messageHash[:]) - - // Verify the signature using the SHA-1 digest of the double SHA-256 hash - return sig.Verify(finalMessageHash[:], ecPublicKey), nil -} - -func (c *MuunCard) transmit(apdu []byte) (*CardResponse, error) { - - err := c.rawCard.selectApplet(muuncardAppletId) - if err != nil { - return nil, fmt.Errorf("error selecting Muuuncard Applet: %w", err) - } - - secureChannel, err := c.initSecureChannel() - if err != nil { - return nil, fmt.Errorf("error while initiating the secure channel transport layer: %w", err) - } - - encryptedAPDU, err := secureChannel.encryptMessage(apdu) - if err != nil { - return nil, fmt.Errorf("error while encrypting message for secure channel: %w", err) - } - - resp, err := c.rawCard.transmit(encryptedAPDU) - if err != nil { - return nil, fmt.Errorf("error transmitting encryptedAPDU: %w", err) - } - - if resp.StatusCode != responseOk { - return nil, mapStatusToCardError(resp.StatusCode) - } - - if len(resp.Response) == 0 { - return resp, nil - } - - verifiedData, err := secureChannel.verifyResponseMAC(resp.Response) - if err != nil { - return nil, fmt.Errorf("MAC verification failed: %v", err) - } - resp.Response = verifiedData - - return resp, nil -} - -func (c *MuunCard) initSecureChannel() (*muunCardSecureChannel, error) { - var cla byte = claEdge - - var ins byte = insMuuncardInitSecureChannel - var p1 byte = nullByte - var p2 byte = nullByte - - priv, err := btcec.NewPrivateKey() - if err != nil { - return nil, fmt.Errorf("error generating keypair for secure channel: %w", err) - } - - pub := priv.PubKey() - serializedPubkey := pub.SerializeUncompressed() - - apdu := newAPDU(cla, ins, p1, p2, serializedPubkey) - - resp, err := c.rawCard.transmit(apdu.serialize()) - if err != nil { - return nil, fmt.Errorf("error transmitting init secure channel apdu: %w", err) - } - - if resp.StatusCode != responseOk { - return nil, mapStatusToCardError(resp.StatusCode) - } - - // This validation ended up being ugly as physical card firmware is currently returning more - // data than required (incorrectly). We're keeping this for now to be compat with that. Will - // change it in next firmware version. - if !(len(resp.Response) == 65 || len(resp.Response) == 117) { - errorMsg := fmt.Sprintf( - "invalid response length: %v, wanted 65. %s", - len(resp.Response), - hex.EncodeToString(resp.Response), - ) - return nil, newCardError(ErrInternal, errorMsg) - } - - cardPubKeyBytes := resp.Response[:65] - - return newSecureChannel(priv, cardPubKeyBytes) -} diff --git a/libwallet/domain/nfc/muuncard_secure_channel.go b/libwallet/domain/nfc/muuncard_secure_channel.go deleted file mode 100644 index 6adc49df..00000000 --- a/libwallet/domain/nfc/muuncard_secure_channel.go +++ /dev/null @@ -1,130 +0,0 @@ -package nfc - -import ( - "crypto/aes" - "crypto/cipher" - "crypto/hmac" - "crypto/sha1" - "encoding/hex" - "fmt" - "github.com/btcsuite/btcd/btcec/v2" -) - -type muunCardSecureChannel struct { - sharedSecret []byte - derivedKey []byte -} - -func newSecureChannel( - devicePrivateKey *btcec.PrivateKey, - cardPubKeyBytes []byte, -) (*muunCardSecureChannel, error) { - - sharedSecret, err := computeSharedSecret(devicePrivateKey, cardPubKeyBytes) - if err != nil { - return nil, fmt.Errorf("error generating shared secret: %w", err) - } - - derivedKey := computeDerivedKey(sharedSecret) - - return &muunCardSecureChannel{ - sharedSecret: sharedSecret, - derivedKey: derivedKey, - }, nil -} - -func (s *muunCardSecureChannel) encryptMessage(apdu []byte) ([]byte, error) { - cla := claEdge - - ins := apdu[iso7816OffsetIns] - p1 := apdu[iso7816OffsetP1] - p2 := apdu[iso7816OffsetP2] - plainData := apdu[iso7816OffsetCData:] - - // Encrypt the payload with AES-CBC and no padding using a zero IV - zeroIV := make([]byte, aes.BlockSize) // Zero IV - key := s.derivedKey[:16] // Use first 16 bytes for AES - cipherText, err := aesEncrypt(key, zeroIV, plainData) - if err != nil { - return nil, err - } - - // Generate MAC for encrypted data - mac := computeHmacSha1(s.derivedKey, cipherText) // Use full derived key for HMAC - - // Build encrypted apdu - encrypted := append(cipherText, mac...) - msg := newAPDU(byte(cla), ins, p1, p2, encrypted) - - return msg.serialize(), nil -} - -func (s *muunCardSecureChannel) verifyResponseMAC(response []byte) ([]byte, error) { - if len(response) < hmacSha1SizeInBytes { // SHA1-HMAC is 20 bytes - hexResponse := hex.EncodeToString(response) - return nil, fmt.Errorf("response too short to contain MAC: %s", hexResponse) - } - - dataLen := len(response) - hmacSha1SizeInBytes - data := response[:dataLen] - responseMAC := response[dataLen:] - - // Generate MAC for verification - // Note: we're currently sending the derived key (which is an hmac of the shared secret). - // TODO: this is probably wrong and we'll fix it but its what the current muuncard impl does - expectedMAC := computeDerivedKey(s.sharedSecret) - - if !hmac.Equal(responseMAC, expectedMAC) { - return nil, fmt.Errorf( - "response MAC verification failed, wanted %s, got %s", - hex.EncodeToString(expectedMAC), - hex.EncodeToString(responseMAC), - ) - } - - return data, nil -} - -func computeSharedSecret( - devicePrivateKey *btcec.PrivateKey, - cardPublicKeyBytes []byte, -) ([]byte, error) { - - cardPublicKey, err := btcec.ParsePubKey(cardPublicKeyBytes) - if err != nil { - return nil, fmt.Errorf("invalid card public key: %w", err) - } - - return btcec.GenerateSharedSecret(devicePrivateKey, cardPublicKey), nil -} - -func computeDerivedKey(sharedSecret []byte) []byte { - // Computing derived key is just applying HMAC-SHA1 to the salt using the sharedSecret as key - salt := []byte("deriv_key") // 9 bytes - return computeHmacSha1(sharedSecret, salt) -} - -// computeHmacSha1 computes HMAC-SHA160 for the input message using the provided key. -// Note: Sha1 is a 160-bit (20-byte) hash, meaning it outputs 20 bytes. -func computeHmacSha1(key []byte, message []byte) []byte { - h := hmac.New(sha1.New, key) - h.Write(message) - return h.Sum(nil) // 20-byte HMAC-SHA1 -} - -// aesEncrypt encrypts a plaintext using the specified key and a IV. -// Note: this impl uses AES-128-CBC with no padding. Hence, key and IV must be 16 bytes (128 bits) -// and plaintext length must be a multiple of 16. -func aesEncrypt(key []byte, iv []byte, plaintext []byte) ([]byte, error) { - block, err := aes.NewCipher(key) - if err != nil { - return nil, err - } - - ciphertext := make([]byte, len(plaintext)) - - mode := cipher.NewCBCEncrypter(block, iv) - mode.CryptBlocks(ciphertext, plaintext) - - return ciphertext, nil -} diff --git a/libwallet/domain/nfc/muuncard_test.go b/libwallet/domain/nfc/muuncard_test.go deleted file mode 100644 index 729ecb5e..00000000 --- a/libwallet/domain/nfc/muuncard_test.go +++ /dev/null @@ -1,147 +0,0 @@ -package nfc - -import ( - "encoding/hex" - "errors" - "github.com/btcsuite/btcd/btcutil/base58" - "github.com/muun/libwallet" - "testing" -) - -func TestMuunCardSetupAndResetCard_Integration(t *testing.T) { - - mockJavaCard := NewMockJavaCard(&MockMuunCard{ - network: libwallet.Mainnet(), - privateKeySlots: make([]*libwallet.HDPrivateKey, 1), - }) - - muuncard := NewCard(mockJavaCard) - - // 1. Set up Card (e.g. generate xpub) - - seedBytes := []byte("0123456789ABCDEF") - seed16Bytes := [16]byte{} - copy(seed16Bytes[:], seedBytes) - - xpub, err := muuncard.GenerateKeyPair(seed16Bytes) - if err != nil { - t.Fatal(err) - } - - generatedXpub := base58.Encode(xpub.RawBytes) - - expectedXpubInBase58 := "xpub661MyMwAqRbcGh9azVFw7ed84cvzSBBSN3rGaTCwJnb9ZFtyj3RMkphXKv4bTLJmrayqR6Vt4PFKw7UUktXXdUqTpDj2dT6T7zWSbySR7jc" - if generatedXpub != expectedXpubInBase58 { - t.Fatalf("wanted %v, but got %v", expectedXpubInBase58, generatedXpub) - } - - // 2. Reset Card - - err = muuncard.ResetCard() - - if err != nil { - t.Fatal(err) - } - - // 3. Reset Card again (should fail) - - err = muuncard.ResetCard() - - if err == nil { - t.Fatal("expected error: ErrSlotNotInitialized") - } - - var cardError *CardError - if errors.As(err, &cardError) { - if cardError.Code != ErrSlotNotInitialized { - t.Fatalf("expected error: ErrSlotNotInitialized, got: %s", err) - } - } - - // 4. Set up card again, different seed generates different xpub - - seedBytes = []byte("0000000000000000") - seed16Bytes = [16]byte{} - copy(seed16Bytes[:], seedBytes) - xpub, err = muuncard.GenerateKeyPair(seed16Bytes) - - if err != nil { - t.Fatal(err) - } - - generatedXpub = base58.Encode(xpub.RawBytes) - - expectedXpubInBase58 = "xpub661MyMwAqRbcFSR1jrK1urk2s2yKY4avX58e5PsG9bZds6Yj5n5Ya1TVBnyDMqWUANnoQPcCCTohfzbzpNEyX1ZAPzw76iproJpWJJYS6sT" - if generatedXpub != expectedXpubInBase58 { - t.Fatalf("wanted %v, but got %v", expectedXpubInBase58, generatedXpub) - } - - // 5. Set up card again without reset (should fail) - - xpub, err = muuncard.GenerateKeyPair(seed16Bytes) - - if err == nil { - t.Fatal("expected error: ErrSlotOccupied") - } - - if errors.As(err, &cardError) { - if cardError.Code != ErrSlotOccupied { - t.Fatalf("expected error: ErrSlotOccupied, got: %s", err) - } - } - - if xpub != nil { - t.Fatal("xpub should be nil on error") - } -} - -func TestMuunCardSignMessage_Integration(t *testing.T) { - - mockJavaCard := NewMockJavaCard(&MockMuunCard{ - network: libwallet.Mainnet(), - privateKeySlots: make([]*libwallet.HDPrivateKey, 1), - }) - - muuncard := NewCard(mockJavaCard) - - // 1. Set up Card (e.g. generate xpub) - - seedBytes := []byte("1111111111111111") - seed16Bytes := [16]byte{} - copy(seed16Bytes[:], seedBytes) - - xpub, err := muuncard.GenerateKeyPair(seed16Bytes) - if err != nil { - t.Fatal(err) - } - - generatedXpub := base58.Encode(xpub.RawBytes) - - expectedXpubInBase58 := "xpub661MyMwAqRbcGeKCHGwgz2ViScN6rkHwoRqnRDMqDEGWCXkjUAY5Hv2TCud3MtQTVhvzYenmLhAkLXnwDo3x2cHKCicWU3qH4XRRvh63zQd" - if generatedXpub != expectedXpubInBase58 { - t.Fatalf("wanted %v, but got %v", expectedXpubInBase58, generatedXpub) - } - - // 2. Sign Message - - message := "testing libwallet muuncard impl" - signature, err := muuncard.SignMessage(message) - if err != nil { - t.Fatal(err) - } - - // 3. Verify Signature - pubKey, err := libwallet.NewHDPublicKeyFromString(generatedXpub, "m", libwallet.Mainnet()) - if err != nil { - t.Fatal(err) - } - - verified, err := muuncard.VerifySignature(pubKey, []byte(message), signature.RawBytes) - if err != nil { - t.Fatal(err) - } - if !verified { - signatureHex := hex.EncodeToString(signature.RawBytes) - t.Fatalf("invalid signature: %s", signatureHex) - } -} diff --git a/libwallet/domain/nfc/muuncard_v2.go b/libwallet/domain/nfc/muuncard_v2.go index 205a4ae9..bb12cdb4 100644 --- a/libwallet/domain/nfc/muuncard_v2.go +++ b/libwallet/domain/nfc/muuncard_v2.go @@ -1,8 +1,10 @@ package nfc import ( - "errors" "fmt" + + "github.com/go-errors/errors" + "github.com/muun/libwallet/app_provided_data" "github.com/muun/libwallet/cryptography" "github.com/muun/libwallet/domain/model/security_card" @@ -10,7 +12,7 @@ import ( // Implementation to interact with our reference security card firmware v2. -const MuuncardV2AppletId = "A00000015100133900" +const MuuncardV2AppletId = "A00000015100133900" //nolint:staticcheck // TODO: const MuuncardV2AppletId should be MuuncardV2AppletID // Muuncard V2 specific APDU bytes. const insMuuncardV2Setup = 0x10 @@ -30,14 +32,15 @@ const swMuuncardV2InvalidCounter = 0x6B18 const swMuuncardV2SlotNotPaired = 0x6B19 const ( - Secp256R1PointSize = 65 - PairingSlotSize = 2 - MetadataSize = 75 - MacSize = 32 - TotalPairInputSize = Secp256R1PointSize * 2 // C + pub_client = 130 bytes - PairResponseSize = Secp256R1PointSize + PairingSlotSize + MetadataSize + MacSize // 174 bytes - SignChallengeResponseSize = Secp256R1PointSize + MacSize // 97 bytes - MaxApduSize = 255 + PairingSlotSize = 2 + MetadataSize = 75 + // C + pub_client = 130 bytes + TotalPairInputSize = Secp256R1PointSize * 2 + // 174 bytes + PairResponseSize = Secp256R1PointSize + + PairingSlotSize + + MetadataSize + + MacSize ) type MuunCardV2 struct { @@ -51,11 +54,13 @@ type AppletVersion struct { } type PairingResponse struct { - CardPublicKey []byte // 65 bytes - Card's ephemeral public key - PairingSlot []byte // 2 bytes - Random pairing identifier - Metadata *CardMetadata // 75 bytes - Metadata including global_pub_card - MAC []byte // 32 bytes - HMAC-SHA256 authentication - GlobalSignature []byte // 70-72 bytes - DER-Encoded ECDSA signature with global private key + CardPublicKey []byte // 65 bytes - Card's ephemeral public key + PairingSlot []byte // 2 bytes - Random pairing identifier + Metadata *CardMetadata // 75 bytes - Metadata including global_pub_card + MAC []byte // 32 bytes - HMAC-SHA256 authentication + // 70-72 bytes - DER-Encoded ECDSA signature + // with global private key + GlobalSignature []byte } type CardMetadata struct { @@ -77,14 +82,26 @@ func NewCardV2(nfcBridge app_provided_data.NfcBridge) *MuunCardV2 { } var cardV2StatusToError = map[uint16]*CardError{ - swMuuncardV2WrongLength: {Message: "card rejected input: wrong length", Code: ErrInternal}, - swMuuncardV2InvalidPubKey: {Message: "card rejected public key: invalid format", Code: ErrInternal}, - swMuuncardV2ResponseTooLarge: {Message: "response too large, exceeds APDU limit of 255 bytes", Code: ErrInternal}, - swMuuncardV2CryptoError: {Message: "cryptographic error during pairing", Code: ErrInternal}, - swMuuncardV2NoSlotsAvailable: {Message: "no pairing slots available on card", Code: ErrSlotOccupied}, - swMuuncardV2InvalidMac: {Message: "invalid MAC", Code: ErrInternal}, - swMuuncardV2InvalidCounter: {Message: "invalid counter", Code: ErrInternal}, - swMuuncardV2SlotNotPaired: {Message: "slot not paired", Code: ErrSlotNotInitialized}, + swMuuncardV2WrongLength: {Message: "card rejected input: wrong length", Code: ErrInternal}, + swMuuncardV2InvalidPubKey: { + Message: "card rejected public key: invalid format", + Code: ErrInternal, + }, + swMuuncardV2ResponseTooLarge: { + Message: "response too large, exceeds APDU limit of 255 bytes", + Code: ErrInternal, + }, + swMuuncardV2CryptoError: { + Message: "cryptographic error during pairing", + Code: ErrInternal, + }, + swMuuncardV2NoSlotsAvailable: { + Message: "no pairing slots available on card", + Code: ErrSlotOccupied, + }, + swMuuncardV2InvalidMac: {Message: "invalid MAC", Code: ErrInternal}, + swMuuncardV2InvalidCounter: {Message: "invalid counter", Code: ErrInternal}, + swMuuncardV2SlotNotPaired: {Message: "slot not paired", Code: ErrSlotNotInitialized}, } func (c *MuunCardV2) GetVersion() (*AppletVersion, error) { @@ -97,13 +114,16 @@ func (c *MuunCardV2) GetVersion() (*AppletVersion, error) { []byte{}, ) - response, err := c.rawCard.transmit(apdu.serialize()) + response, err := c.rawCard.transmit(apdu.serializeShort()) if err != nil { - return nil, fmt.Errorf("failed to transmit insMuuncardV2GetVersion: %v", err) + return nil, errors.Errorf( + "failed to transmit insMuuncardV2GetVersion: %w", + err, + ) } if response.StatusCode != responseOk { - return nil, fmt.Errorf("failed with status: %04X", response.StatusCode) + return nil, errors.Errorf("failed with status: %04X", response.StatusCode) } if len(response.Response) < 8 { @@ -131,18 +151,21 @@ func (c *MuunCardV2) GetMetadata() (*CardMetadata, error) { []byte{}, ) - response, err := c.rawCard.transmit(apdu.serialize()) + response, err := c.rawCard.transmit(apdu.serializeShort()) if err != nil { - return nil, fmt.Errorf("failed to transmit insMuuncardV2GetMetadata: %v", err) + return nil, errors.Errorf( + "failed to transmit insMuuncardV2GetMetadata: %w", + err, + ) } if response.StatusCode != responseOk { - return nil, fmt.Errorf("failed with status: %04X", response.StatusCode) + return nil, errors.Errorf("failed with status: %04X", response.StatusCode) } metadata, err := parseMetadata(response.Response) if err != nil { - return nil, fmt.Errorf("failed to parse metadata: %v", response.Response) + return nil, errors.Errorf("failed to parse metadata: %v", response.Response) } return metadata, nil @@ -152,13 +175,13 @@ func (c *MuunCardV2) Pair(serverRandomPublicKey, clientPublicKey []byte) (*Pairi // Validate server random public key format (C) err := cryptography.ValidateSecp256r1PublicKey(serverRandomPublicKey) if err != nil { - return nil, fmt.Errorf("invalid server random public key: %w", err) + return nil, errors.Errorf("invalid server random public key: %w", err) } // Validate client public key format (pub_client) err = cryptography.ValidateSecp256r1PublicKey(clientPublicKey) if err != nil { - return nil, fmt.Errorf("invalid client public key: %w", err) + return nil, errors.Errorf("invalid client public key: %w", err) } // Send C || pub_client to card (130 bytes total) @@ -174,9 +197,9 @@ func (c *MuunCardV2) Pair(serverRandomPublicKey, clientPublicKey []byte) (*Pairi input, ) - response, err := c.transmit(apdu.serialize()) + response, err := c.transmit(apdu.serializeShort()) if err != nil { - return nil, fmt.Errorf("failed to transmit insMuuncardV2Setup: %w", err) + return nil, errors.Errorf("failed to transmit insMuuncardV2Setup: %w", err) } return parsePairingResponse(response.Response) @@ -190,7 +213,7 @@ func (c *MuunCardV2) SignChallenge( // Calculate maximum reason size for single chunk // Format: C(65) + count(2) + index(2) + has_more_chunks(1) + reason + mac(32) = 102 + reason // Max APDU = 255, so max single reason = 255 - 102 = 153 bytes - maxSingleReasonSize := MaxApduSize - 65 - 2 - 2 - 1 - 32 // 153 bytes + maxSingleReasonSize := MaxShortApduDataSize - 65 - 2 - 2 - 1 - 32 // 153 bytes if len(reason) <= maxSingleReasonSize { return c.signChallengeSingle(challenge, reason) @@ -214,9 +237,9 @@ func (c *MuunCardV2) signChallengeSingle( ) apdu := buildSignChallengeAPDU(data) - response, err := c.transmit(apdu.serialize()) + response, err := c.transmit(apdu.serializeShort()) if err != nil { - return nil, fmt.Errorf("failed to transmit Sign Challenge: %w", err) + return nil, errors.Errorf("failed to transmit Sign Challenge: %w", err) } return parseSignChallengeResponse(response) @@ -231,7 +254,7 @@ func (c *MuunCardV2) transmit(apdu []byte) (*CardResponse, error) { resp, err := c.rawCard.transmit(apdu) if err != nil { - return nil, fmt.Errorf("error transmitting APDU: %w", err) + return nil, errors.Errorf("error transmitting APDU: %w", err) } if resp.StatusCode != responseOk { diff --git a/libwallet/domain/nfc/muuncard_v2_test.go b/libwallet/domain/nfc/muuncard_v2_test.go index 6822a041..66a6abb5 100644 --- a/libwallet/domain/nfc/muuncard_v2_test.go +++ b/libwallet/domain/nfc/muuncard_v2_test.go @@ -2,10 +2,11 @@ package nfc import ( "crypto/sha256" - "github.com/muun/libwallet/cryptography" - "github.com/muun/libwallet/domain/model/security_card" "strings" "testing" + + "github.com/muun/libwallet/cryptography" + "github.com/muun/libwallet/domain/model/security_card" ) type SignChallenge = security_card.SecurityCardSignChallenge @@ -277,7 +278,11 @@ func TestSignChallenge_ErrorScenarios(t *testing.T) { } if !strings.Contains(err.Error(), tt.expectedError) { - t.Errorf("expected error containing '%s' but got '%s'", tt.expectedError, err.Error()) + t.Errorf( + "expected error containing '%s' but got '%s'", + tt.expectedError, + err.Error(), + ) } }) } @@ -338,7 +343,8 @@ func TestSignChallenge_Success(t *testing.T) { } // Sign challenge mac response validation is tested in a full integration test with a mock - // client that performs the full protocol. See nfc_integration_test.go. + // client that performs the full protocol. + // See nfc_integration_test.go. } func TestSignChallenge_SecretValidForRetries(t *testing.T) { diff --git a/libwallet/domain/nfc/muuncard_v3.go b/libwallet/domain/nfc/muuncard_v3.go new file mode 100644 index 00000000..f8035ea1 --- /dev/null +++ b/libwallet/domain/nfc/muuncard_v3.go @@ -0,0 +1,316 @@ +package nfc + +import ( + "context" + "fmt" + + "github.com/go-errors/errors" + + "github.com/muun/libwallet/app_provided_data" + "github.com/muun/libwallet/cryptography" + "github.com/muun/libwallet/domain/model/security_card" +) + +// Implementation to interact with our reference security card firmware v3. + +// MuuncardV3AppletID identifies the V3 applet inside the card, used by +// the JavaCard SELECT command to route subsequent APDUs to it. +const MuuncardV3AppletID = "A00000015100133B00" + +// Muuncard V3 specific APDU instruction bytes. +const ( + insMuuncardV3Pair = 0x10 + insMuuncardV3SignChallenge = 0x20 + insMuuncardV3GetVersion = 0x70 + insMuuncardV3GetMetadata = 0x80 +) + +// Muuncard V3 specific status words. +const ( + swMuuncardV3WrongLength = 0x6700 + swMuuncardV3InvalidPubKey = 0x6B12 + swMuuncardV3CryptoError = 0x6B14 + swMuuncardV3InvalidMac = 0x6B17 + swMuuncardV3InvalidCounter = 0x6B18 + swMuuncardV3SlotNotPaired = 0x6B19 +) + +// V3 wire format sizes. +const ( + Secp256R1PointSize = 65 + MacSize = 32 + PairIndexSize = 2 + CounterSize = 2 + MaxProviderSigSize = 72 // DER-encoded P-256 ECDSA signature upper bound + + // PairResponseSizeV3 is P || index || MAC = 65 + 2 + 32 = 99. + PairResponseSizeV3 = Secp256R1PointSize + PairIndexSize + MacSize + + // SignChallengeResponseSize is P || MAC = 65 + 32 = 97. + SignChallengeResponseSize = Secp256R1PointSize + MacSize + + // MetadataMinSizeV3 is the metadata length when no provider + // certificate has been provisioned (sig_len = 0). Full layout + // is variable, 141..213 bytes: + // attestation_pub(65) + vendor(2) + model(2) + firmware(2) + + // capabilities(2) + op_counter(2) + provider_pub(65) + + // sig_len(1) + provider_sig(0..72) + MetadataMinSizeV3 = Secp256R1PointSize + 2 + 2 + 2 + 2 + 2 + Secp256R1PointSize + 1 +) + +// MuunCardV3 is the long-lived client wired with the NFC bridge. It does +// not talk to the card until Connect() is called. Instantiate once per +// app and call Connect() for each NFC session. +type MuunCardV3 struct { + rawCard *JavaCard +} + +// MuunCardV3Session represents an open NFC session with the V3 applet +// already selected. Obtain one via MuunCardV3.Connect(). Calls on the +// session do not re-select the applet, so the cost of multiple APDUs +// in the same session is one SELECT plus one transmit per APDU. +// +// A session is bound to the underlying NFC session lifecycle. If the +// card is moved away the next call returns a transport error and the +// caller must obtain a fresh session via Connect() again. +type MuunCardV3Session struct { + rawCard *JavaCard +} + +// AppletVersionV3 mirrors the firmware GET_VERSION response (12 bytes: +// 6-byte vendor + 1-byte major + 1-byte minor + 4-byte commit hash). +type AppletVersionV3 struct { + Vendor string + Major byte + Minor byte + GitCommit []byte +} + +// PairingResponseV3 bundles what the server needs to verify a pairing: +// the card's ephemeral public key, the slot index, the MAC, and the +// post-pair metadata. +// +// With extended APDUs the card returns everything in one response: +// P || index || MAC || metadata (240..312 bytes). Metadata is always +// populated after a successful Pair(); the pairing MAC binds it, so +// the caller cannot make progress without it. +type PairingResponseV3 struct { + CardPublicKey []byte // P (65 bytes) - card's ephemeral public key + Index uint16 // 2 bytes + MAC []byte // 32 bytes - HMAC over tag || C || P || index || metadata + Metadata CardMetadataV3 // post-pair metadata; always populated after Pair() +} + +// ChallengeResponseV3 is what the card returns after a successful +// SIGN_CHALLENGE: a fresh ephemeral public key plus a MAC. +type ChallengeResponseV3 struct { + CardPublicKey []byte // P (65 bytes) - fresh per challenge + MAC []byte // 32 bytes - HMAC over tag || C || P +} + +// CardMetadataV3 contains the card's identity, status and certificate. +// layout: attestation_pub(65) || vendor(2) || model(2) || firmware(2) || +// capabilities(2) || op_counter(2) || provider_pub(65) || sig_len(1) || +// provider_sig(0..72). +// The total size is variable: 141 bytes when no certificate is provisioned, +// up to 213 bytes once one is. +// +// RawBytes holds the exact wire bytes the card emitted. The pairing MAC +// is computed over those bytes server-side, so callers verifying the MAC +// must use RawBytes verbatim instead of re-serializing the struct fields +// (any byte reshuffle or padding change breaks the MAC). +type CardMetadataV3 struct { + AttestationPub [65]byte // Card's permanent identity pubkey, set at factory + CardVendor [2]byte // Vendor id, set at vault install + CardModel [2]byte // Model id, set at vault install + FirmwareVersion [2]byte // Firmware version (e.g. 0x03 0x00 for 3.0) + Capabilities [2]byte // Bitmap of card capabilities (e.g. has screen); opaque to firmware + OperationCount uint16 // Total crypto operations performed by the card + ProviderPub [65]byte // Card provider's pubkey + ProviderSigLen byte // Length of ProviderSig in bytes + ProviderSig []byte // Provider's ECDSA signature over AttestationPub + + RawBytes []byte // exact wire bytes; load-bearing input for the pairing MAC +} + +// NewCardV3 builds a MuunCardV3 wired to the given NFC bridge. The +// returned client is long-lived and does not talk to the card until +// Connect is called. +func NewCardV3(nfcBridge app_provided_data.NfcBridge) *MuunCardV3 { + return &MuunCardV3{rawCard: newJavaCard(nfcBridge)} +} + +// Connect opens an NFC session by selecting the V3 applet on the card. +// Returns a *MuunCardV3Session to send subsequent APDUs without +// re-selecting. Call once per NFC session (typically once per logical +// operation like Pair or SignChallenge). +func (c *MuunCardV3) Connect(_ context.Context) (*MuunCardV3Session, error) { + if err := c.rawCard.selectApplet(MuuncardV3AppletID); err != nil { + return nil, newCardError(ErrAppletIdNotFound, "error selecting muuncard v3 applet") + } + return &MuunCardV3Session{rawCard: c.rawCard}, nil +} + +var cardV3StatusToError = map[uint16]*CardError{ + swMuuncardV3WrongLength: { + Message: "card rejected input: wrong length", + Code: ErrInternal, + }, + swMuuncardV3InvalidPubKey: { + Message: "card rejected public key: invalid format", + Code: ErrInternal, + }, + swMuuncardV3CryptoError: { + Message: "cryptographic error during pairing or challenge", + Code: ErrInternal, + }, + swMuuncardV3InvalidMac: { + Message: "invalid MAC", + Code: ErrInternal, + }, + swMuuncardV3InvalidCounter: { + Message: "invalid counter", + Code: ErrInternal, + }, + swMuuncardV3SlotNotPaired: { + Message: "slot is not paired", + Code: ErrSlotNotInitialized, + }, +} + +// GetVersion reads the firmware version banner from the card: vendor +// string, major and minor numbers, and the git commit hash of the +// firmware build. +func (s *MuunCardV3Session) GetVersion(_ context.Context) (*AppletVersionV3, error) { + apdu := newAPDU(claEdge, insMuuncardV3GetVersion, nullByte, nullByte, []byte{}) + + response, err := s.transmit(apdu.serializeExtended()) + if err != nil { + return nil, errors.Errorf("transmit get version: %w", err) + } + + // Firmware always writes 12 bytes: 6 vendor + major + minor + 4 commit. + if len(response.Response) < 12 { + return nil, errors.Errorf( + "get_version response too short: %d bytes (expected 12)", + len(response.Response), + ) + } + + return &AppletVersionV3{ + Vendor: string(response.Response[:6]), // "MuunV3" + Major: response.Response[6], + Minor: response.Response[7], + GitCommit: response.Response[8:12], + }, nil +} + +// GetMetadata reads the card's identity, status and certificate. The +// returned CardMetadataV3 preserves the exact wire bytes in RawBytes so +// the caller can verify the pairing MAC server-side without re-serializing. +func (s *MuunCardV3Session) GetMetadata(_ context.Context) (*CardMetadataV3, error) { + apdu := newAPDU(claEdge, insMuuncardV3GetMetadata, nullByte, nullByte, []byte{}) + + response, err := s.transmit(apdu.serializeExtended()) + if err != nil { + return nil, errors.Errorf("transmit get metadata: %w", err) + } + + metadata, err := ParseMetadataV3(response.Response) + if err != nil { + return nil, errors.Errorf("parse v3 metadata: %w", err) + } + + return metadata, nil +} + +// Pair runs the V3 pairing flow. The card derives secret_card from +// two Diffie-Hellman exchanges and returns its ephemeral public key, +// a MAC, and the metadata block — all in one extended-APDU response. +// +// Re-running Pair() bumps op_counter on the card, which can make a new +// card look already used. +func (s *MuunCardV3Session) Pair( + _ context.Context, + serverRandomPublicKey []byte, +) (*PairingResponseV3, error) { + if err := cryptography.ValidateSecp256r1PublicKey(serverRandomPublicKey); err != nil { + return nil, errors.Errorf("invalid server random public key: %w", err) + } + + pairAPDU := newAPDU( + claEdge, + insMuuncardV3Pair, + nullByte, + nullByte, + serverRandomPublicKey, + ) + + pairResponse, err := s.transmit(pairAPDU.serializeExtended()) + if err != nil { + return nil, errors.Errorf("transmit pair: %w", err) + } + + return parsePairingResponseV3(pairResponse.Response) +} + +// SignChallenge runs the V3 challenge-response flow. The challenge +// fields (C, counter, index, reason, MAC) are produced by the server; +// the client just relays them to the card and returns the card's +// response. +// +// Extended APDUs let the whole payload — including a reason of any +// realistic size — travel in one command, so there is no dispatcher +// between single-chunk and streaming forms. +func (s *MuunCardV3Session) SignChallenge( + _ context.Context, + challenge *security_card.SecurityCardSignChallengeV3, +) (*ChallengeResponseV3, error) { + if err := cryptography.ValidateSecp256r1PublicKey(challenge.ServerPublicKey); err != nil { + return nil, errors.Errorf("invalid server public key: %w", err) + } + if len(challenge.MAC) != MacSize { + return nil, errors.Errorf("mac must be %d bytes, got %d", MacSize, len(challenge.MAC)) + } + + data := buildSignChallengeDataV3( + challenge.ServerPublicKey, + challenge.Counter, + challenge.Index, + challenge.Reason, + challenge.MAC, + ) + + apdu := newAPDU(claEdge, insMuuncardV3SignChallenge, nullByte, nullByte, data) + + response, err := s.transmit(apdu.serializeExtended()) + if err != nil { + return nil, errors.Errorf("transmit sign challenge: %w", err) + } + + return parseSignChallengeResponseV3(response.Response) +} + +// transmit sends a single APDU over the already selected applet and +// fails on either a transport level error or a non-ok status code from +// the card. +// +// Transport errors are returned as *CardError with code ErrTransport so +// callers can discriminate them from protocol errors via errors.As. +func (s *MuunCardV3Session) transmit(apdu []byte) (*CardResponse, error) { + resp, err := s.rawCard.transmit(apdu) + if err != nil { + return nil, newCardError(ErrTransport, fmt.Sprintf("nfc connection error: %s", err)) + } + if resp.StatusCode != responseOk { + return nil, mapStatusToCardV3Error(resp.StatusCode) + } + return resp, nil +} + +func mapStatusToCardV3Error(code uint16) error { + if cardError, ok := cardV3StatusToError[code]; ok { + return cardError + } + return newCardError(ErrInternal, fmt.Sprintf("unknown v3 status: 0x%04X", code)) +} diff --git a/libwallet/domain/nfc/protocol_common.go b/libwallet/domain/nfc/protocol_common.go index b442a6bb..1567742c 100644 --- a/libwallet/domain/nfc/protocol_common.go +++ b/libwallet/domain/nfc/protocol_common.go @@ -3,9 +3,11 @@ package nfc import ( "bytes" "crypto/sha256" - "fmt" - "github.com/muun/libwallet/cryptography" "io" + + "github.com/go-errors/errors" + + "github.com/muun/libwallet/cryptography" ) // parseMetadata parses raw card metadata bytes into a structured CardMetadata. @@ -27,7 +29,7 @@ import ( func parseMetadata(data []byte) (*CardMetadata, error) { if len(data) < MetadataSize { - return nil, fmt.Errorf( + return nil, errors.Errorf( "invalid metadata length: %d (expected %d)", len(data), MetadataSize, ) } @@ -38,44 +40,44 @@ func parseMetadata(data []byte) (*CardMetadata, error) { // Read global public key (65 bytes) _, err := io.ReadFull(reader, metadata.GlobalPubCard[:]) if err != nil { - return nil, fmt.Errorf("failed to read global public key") + return nil, errors.Errorf("failed to read global public key") } err = cryptography.ValidateSecp256r1PublicKey(metadata.GlobalPubCard[:]) if err != nil { - return nil, fmt.Errorf("invalid card global public key: %w", err) + return nil, errors.Errorf("invalid card global public key: %w", err) } // Read card vendor (2 bytes) _, err = io.ReadFull(reader, metadata.CardVendor[:]) if err != nil { - return nil, fmt.Errorf("failed to read card vendor") + return nil, errors.Errorf("failed to read card vendor") } // Read card model (2 bytes) _, err = io.ReadFull(reader, metadata.CardModel[:]) if err != nil { - return nil, fmt.Errorf("failed to read card model") + return nil, errors.Errorf("failed to read card model") } // Read firmware version (2 bytes) _, err = io.ReadFull(reader, metadata.FirmwareVersion[:]) if err != nil { - return nil, fmt.Errorf("failed to read firmware version") + return nil, errors.Errorf("failed to read firmware version") } // Read usage count (2 bytes, big-endian) usageBytes := make([]byte, 2) _, err = io.ReadFull(reader, usageBytes) if err != nil { - return nil, fmt.Errorf("failed to read usage count") + return nil, errors.Errorf("failed to read usage count") } metadata.UsageCount = uint16(usageBytes[0])<<8 | uint16(usageBytes[1]) // Read language code (2 bytes) _, err = io.ReadFull(reader, metadata.LanguageCode[:]) if err != nil { - return nil, fmt.Errorf("failed to read language code") + return nil, errors.Errorf("failed to read language code") } return metadata, nil @@ -107,7 +109,7 @@ func parsePairingResponse(data []byte) (*PairingResponse, error) { // TODO: this doesn't take into account signature size if len(data) < PairResponseSize { - return nil, fmt.Errorf("response too short: %d bytes", len(data)) + return nil, errors.Errorf("response too short: %d bytes", len(data)) } reader := bytes.NewReader(data) @@ -117,26 +119,26 @@ func parsePairingResponse(data []byte) (*PairingResponse, error) { pairingResp.CardPublicKey = make([]byte, Secp256R1PointSize) _, err := io.ReadFull(reader, pairingResp.CardPublicKey) if err != nil { - return nil, fmt.Errorf("failed to read card public key: %w", err) + return nil, errors.Errorf("failed to read card public key: %w", err) } // Read pairing slot pairingResp.PairingSlot = make([]byte, PairingSlotSize) _, err = io.ReadFull(reader, pairingResp.PairingSlot) if err != nil { - return nil, fmt.Errorf("failed to read pairing slot: %w", err) + return nil, errors.Errorf("failed to read pairing slot: %w", err) } // Read metadata metadataBytes := make([]byte, MetadataSize) _, err = io.ReadFull(reader, metadataBytes) if err != nil { - return nil, fmt.Errorf("failed to read metadata: %w", err) + return nil, errors.Errorf("failed to read metadata: %w", err) } metadata, err := parseMetadata(metadataBytes) if err != nil { - return nil, fmt.Errorf("failed to parse metadata: %v", metadataBytes) + return nil, errors.Errorf("failed to parse metadata: %v", metadataBytes) } pairingResp.Metadata = metadata @@ -144,24 +146,24 @@ func parsePairingResponse(data []byte) (*PairingResponse, error) { pairingResp.MAC = make([]byte, MacSize) _, err = io.ReadFull(reader, pairingResp.MAC) if err != nil { - return nil, fmt.Errorf("failed to read MAC: %w", err) + return nil, errors.Errorf("failed to read MAC: %w", err) } // Read remaining bytes as global signature remainingBytes, err := io.ReadAll(reader) if err != nil { - return nil, fmt.Errorf("failed to read global signature: %w", err) + return nil, errors.Errorf("failed to read global signature: %w", err) } pairingResp.GlobalSignature = remainingBytes globalSignatureLength := len(pairingResp.GlobalSignature) if globalSignatureLength > 72 { - return nil, fmt.Errorf("invalid global signature length: %v", globalSignatureLength) + return nil, errors.Errorf("invalid global signature length: %v", globalSignatureLength) } err = cryptography.ValidateSecp256r1PublicKey(pairingResp.CardPublicKey) if err != nil { - return nil, fmt.Errorf("invalid card public key: %w", err) + return nil, errors.Errorf("invalid card public key: %w", err) } return pairingResp, nil @@ -185,11 +187,11 @@ func VerifyMetadata(metadata *CardMetadata) error { err := cryptography.ValidateSecp256r1PublicKey(metadata.GlobalPubCard[:]) if err != nil { - return fmt.Errorf("expected valid GlobalPubCard, got 0x%02x", metadata.GlobalPubCard[0]) + return errors.Errorf("expected valid GlobalPubCard, got 0x%02x", metadata.GlobalPubCard[0]) } if metadata.FirmwareVersion[0] != 0x02 || metadata.FirmwareVersion[1] != 0x00 { - return fmt.Errorf( + return errors.Errorf( "expected firmware version 2.0, got %d.%d", metadata.FirmwareVersion[0], metadata.FirmwareVersion[1], @@ -221,14 +223,14 @@ func VerifyMetadata(metadata *CardMetadata) error { func parseSignChallengeResponse(response *CardResponse) (*ChallengeResponse, error) { if len(response.Response) != SignChallengeResponseSize { - return nil, fmt.Errorf( + return nil, errors.Errorf( "invalid sign challenge response length: %d", len(response.Response), ) } err := cryptography.ValidateSecp256r1PublicKey(response.Response[:Secp256R1PointSize]) if err != nil { - return nil, fmt.Errorf("invalid card public key: %w", err) + return nil, errors.Errorf("invalid card public key: %w", err) } return &ChallengeResponse{ @@ -351,7 +353,7 @@ func ComputeHMACSHA256(key, data []byte) []byte { innerPad := make([]byte, blockSize) outerPad := make([]byte, blockSize) - for i := 0; i < blockSize; i++ { + for i := 0; i < blockSize; i++ { //nolint:modernize // TODO: use range over int innerPad[i] = paddedKey[i] ^ 0x36 outerPad[i] = paddedKey[i] ^ 0x5c } diff --git a/libwallet/domain/nfc/protocol_common_test.go b/libwallet/domain/nfc/protocol_common_test.go index 31d75ae8..b328260b 100644 --- a/libwallet/domain/nfc/protocol_common_test.go +++ b/libwallet/domain/nfc/protocol_common_test.go @@ -2,9 +2,10 @@ package nfc import ( "fmt" - "github.com/muun/libwallet/cryptography" "strings" "testing" + + "github.com/muun/libwallet/cryptography" ) func TestParseMetadata_ErrorScenarios(t *testing.T) { @@ -318,9 +319,9 @@ func TestParsePairingResponse_ValidSignatureLengths(t *testing.T) { } func TestParsePairingResponse_LargerThanMinimumSize(t *testing.T) { - // Test that data larger than minimum still works (the function reads the remaining bytes as signature) - // Since parsePairingResponse reads "remaining bytes" as signature, extra data becomes part of signature - // This should fail if signature becomes > 72 bytes + // Test that data larger than minimum still works (the function reads the remaining bytes as + // signature) Since parsePairingResponse reads "remaining bytes" as signature, extra data + // becomes part of signature This should fail if signature becomes > 72 bytes baseData := createValidPairingResponseData(t) // This has 70-byte signature // Add 2 extra bytes to make signature 72 bytes (still valid) @@ -338,8 +339,11 @@ func TestParsePairingResponse_LargerThanMinimumSize(t *testing.T) { // Should still parse correctly if result.PairingSlot[0] != 0x12 || result.PairingSlot[1] != 0x34 { - t.Fatalf("PairingSlot not parsed correctly with extra data: got [%02x, %02x], want [0x12, 0x34]", - result.PairingSlot[0], result.PairingSlot[1]) + t.Fatalf( + "PairingSlot not parsed correctly with extra data: got [%02x, %02x], want [0x12, 0x34]", + result.PairingSlot[0], + result.PairingSlot[1], + ) } // Signature should now be 72 bytes @@ -471,7 +475,7 @@ func TestParseSignChallengeResponse_Success(t *testing.T) { // TODO: mac verification will tested in another test // Verify MAC content matches - for i := 0; i < MacSize; i++ { + for i := 0; i < MacSize; i++ { //nolint:modernize // TODO: use range over int if result.MAC[i] != testMAC[i] { t.Fatalf("MAC byte %d not parsed correctly: got 0x%02x, want 0x%02x", i, result.MAC[i], testMAC[i]) @@ -479,7 +483,7 @@ func TestParseSignChallengeResponse_Success(t *testing.T) { } // Verify the card public key matches what we put in - for i := 0; i < Secp256R1PointSize; i++ { + for i := 0; i < Secp256R1PointSize; i++ { //nolint:modernize // TODO: use range over int if result.CardPublicKey[i] != cardKeyPair.PublicKey[i] { t.Fatalf("CardPublicKey byte %d not parsed correctly: got 0x%02x, want 0x%02x", i, result.CardPublicKey[i], cardKeyPair.PublicKey[i]) diff --git a/libwallet/domain/nfc/protocol_common_v3.go b/libwallet/domain/nfc/protocol_common_v3.go new file mode 100644 index 00000000..d9141bf1 --- /dev/null +++ b/libwallet/domain/nfc/protocol_common_v3.go @@ -0,0 +1,221 @@ +package nfc + +import ( + "bytes" + "io" + + "github.com/go-errors/errors" + + "github.com/muun/libwallet/cryptography" +) + +// ParseMetadataV3 decodes the GET_METADATA wire format for V3 cards. +// Layout: +// +// attestation_pub (65) || vendor (2) || model (2) || firmware (2) || +// capabilities (2) || op_counter (2) || provider_pub (65) || +// sig_len (1) || provider_sig (0..72) +// +// The provider section is always present; before STORE_CERTIFICATE the +// card writes 65 zero bytes for provider_pub and sig_len = 0. After +// provisioning, sig_len announces the trailing signature length. +// +// The exact wire bytes that produced the result are copied into +// metadata.RawBytes. Callers verifying the pairing MAC server-side must +// reuse those bytes verbatim; re-serializing the struct fields can +// change byte order or padding and break the MAC. +func ParseMetadataV3(data []byte) (*CardMetadataV3, error) { + if len(data) < MetadataMinSizeV3 { + return nil, errors.Errorf( + "invalid metadata length: %d (expected at least %d)", + len(data), MetadataMinSizeV3, + ) + } + + reader := bytes.NewReader(data) + metadata := &CardMetadataV3{} + + // Read attestation public key (65 bytes) + if _, err := io.ReadFull(reader, metadata.AttestationPub[:]); err != nil { + return nil, errors.Errorf("read attestation pub: %w", err) + } + if err := cryptography.ValidateSecp256r1PublicKey(metadata.AttestationPub[:]); err != nil { + return nil, errors.Errorf("invalid attestation pub: %w", err) + } + + // Read card vendor (2 bytes) + if _, err := io.ReadFull(reader, metadata.CardVendor[:]); err != nil { + return nil, errors.Errorf("read card vendor: %w", err) + } + + // Read card model (2 bytes) + if _, err := io.ReadFull(reader, metadata.CardModel[:]); err != nil { + return nil, errors.Errorf("read card model: %w", err) + } + + // Read firmware version (2 bytes) + if _, err := io.ReadFull(reader, metadata.FirmwareVersion[:]); err != nil { + return nil, errors.Errorf("read firmware version: %w", err) + } + + // Read card capabilities (2 bytes) + if _, err := io.ReadFull(reader, metadata.Capabilities[:]); err != nil { + return nil, errors.Errorf("read capabilities: %w", err) + } + + // Read operation count (2 bytes, big-endian) + operationBytes := make([]byte, 2) + if _, err := io.ReadFull(reader, operationBytes); err != nil { + return nil, errors.Errorf("read operation count: %w", err) + } + metadata.OperationCount = uint16(operationBytes[0])<<8 | uint16(operationBytes[1]) + + if _, err := io.ReadFull(reader, metadata.ProviderPub[:]); err != nil { + return nil, errors.Errorf("read provider pub: %w", err) + } + + // Read provider signature length (1 byte) + sigLenByte := make([]byte, 1) + if _, err := io.ReadFull(reader, sigLenByte); err != nil { + return nil, errors.Errorf("read provider sig length: %w", err) + } + metadata.ProviderSigLen = sigLenByte[0] + + if metadata.ProviderSigLen > MaxProviderSigSize { + return nil, errors.Errorf( + "provider sig length %d exceeds max %d", + metadata.ProviderSigLen, MaxProviderSigSize, + ) + } + + if metadata.ProviderSigLen > 0 { + // Read provider signature (variable length 0..72 bytes) + metadata.ProviderSig = make([]byte, metadata.ProviderSigLen) + if _, err := io.ReadFull(reader, metadata.ProviderSig); err != nil { + return nil, errors.Errorf("read provider sig: %w", err) + } + } + + // Snapshot the exact wire bytes the card emitted into RawBytes for + // MAC verification. + totalMetadataLen := MetadataMinSizeV3 + int(metadata.ProviderSigLen) + metadata.RawBytes = make([]byte, totalMetadataLen) + copy(metadata.RawBytes, data[:totalMetadataLen]) + + return metadata, nil +} + +// parsePairingResponseV3 decodes the PAIR response wire format: +// +// P (65) || index (2) || metadata (141..213) || MAC (32) = 240..312 bytes. +// +// The firmware places the MAC LAST ("MAC is last so a parser can always +// find it at [len-32..len]"), with the variable-length metadata sitting +// between the fixed P||index header and the trailing MAC. Metadata is +// self-delimiting via its sig_len byte. +func parsePairingResponseV3(data []byte) (*PairingResponseV3, error) { + // fixedHeader is P (65) + index (2); metadata and the trailing MAC + // follow it. + fixedHeader := Secp256R1PointSize + PairIndexSize + minLen := fixedHeader + MetadataMinSizeV3 + MacSize + if len(data) < minLen { + return nil, errors.Errorf( + "invalid v3 pair response length: %d (expected at least %d)", + len(data), minLen, + ) + } + + response := &PairingResponseV3{ + CardPublicKey: make([]byte, Secp256R1PointSize), + MAC: make([]byte, MacSize), + } + + copy(response.CardPublicKey, data[:Secp256R1PointSize]) + response.Index = uint16(data[Secp256R1PointSize])<<8 | uint16(data[Secp256R1PointSize+1]) + + if err := cryptography.ValidateSecp256r1PublicKey(response.CardPublicKey); err != nil { + return nil, errors.Errorf("invalid card public key in v3 pair response: %w", err) + } + + // Metadata is self-delimited (its sig_len byte gives its exact length), + // so parse it right after the fixed header; the MAC is the 32 bytes + // immediately after the metadata. + metadata, err := ParseMetadataV3(data[fixedHeader:]) + if err != nil { + return nil, errors.Errorf("parse metadata in v3 pair response: %w", err) + } + response.Metadata = *metadata + + // Under MAC-last framing the MAC is positioned by the metadata length, + // so the response must be exactly header + metadata + MAC. Trailing + // bytes would shift the MAC and be read as a corrupted one, so reject + // them instead of silently mangling verification. + macStart := fixedHeader + len(metadata.RawBytes) + if len(data) != macStart+MacSize { + return nil, errors.Errorf( + "invalid v3 pair response length: %d (expected %d for a %d-byte metadata)", + len(data), macStart+MacSize, len(metadata.RawBytes), + ) + } + copy(response.MAC, data[macStart:macStart+MacSize]) + + return response, nil +} + +// buildSignChallengeDataV3 constructs the data payload for a sign challenge command. +// It assembles the challenge parameters into a binary format expected by the card. +// The data format is: C || counter || index || has_more || reason || mac. +// +// Parameters: +// - serverC: Server's ephemeral public key (65 bytes) +// - counter: Usage counter for replay protection (2 bytes, big-endian) +// - index: Pairing slot index (2 bytes, big-endian) +// - reason: Variable-length operation reason/context data +// - mac: HMAC-SHA256 authentication code (32 bytes) +func buildSignChallengeDataV3( + serverC []byte, + counter uint16, + index uint16, + reason []byte, + mac []byte, +) []byte { + // hasMoreSingleChunk is the has_more byte for a single-shot (non + // streaming) challenge. The firmware reads it right after the + // index to pick the single vs streaming path, and expects it even when + // the whole reason fits in one command. + const hasMoreSingleChunk = 0x00 + + capacity := Secp256R1PointSize + CounterSize + PairIndexSize + 1 + len(reason) + MacSize + data := make([]byte, 0, capacity) + data = append(data, serverC...) // C (65 bytes) + data = append(data, IntTo2Bytes(counter)...) // counter (2 bytes) + data = append(data, IntTo2Bytes(index)...) // index (2 bytes) + data = append(data, hasMoreSingleChunk) // has_more = 0 (1 byte) + data = append(data, reason...) // reason (variable) + data = append(data, mac...) // mac (32 bytes) + return data +} + +// parseSignChallengeResponseV3 decodes the SIGN_CHALLENGE response wire +// format: P (65) || MAC (32) = 97 bytes. +func parseSignChallengeResponseV3(data []byte) (*ChallengeResponseV3, error) { + if len(data) != SignChallengeResponseSize { + return nil, errors.Errorf( + "invalid v3 sign challenge response length: %d (expected %d)", + len(data), SignChallengeResponseSize, + ) + } + + result := &ChallengeResponseV3{ + CardPublicKey: make([]byte, Secp256R1PointSize), + MAC: make([]byte, MacSize), + } + copy(result.CardPublicKey, data[:Secp256R1PointSize]) + copy(result.MAC, data[Secp256R1PointSize:]) + + if err := cryptography.ValidateSecp256r1PublicKey(result.CardPublicKey); err != nil { + return nil, errors.Errorf("invalid card public key in v3 challenge response: %w", err) + } + + return result, nil +} diff --git a/libwallet/domain/nfc/protocol_common_v3_test.go b/libwallet/domain/nfc/protocol_common_v3_test.go new file mode 100644 index 00000000..611e5390 --- /dev/null +++ b/libwallet/domain/nfc/protocol_common_v3_test.go @@ -0,0 +1,546 @@ +package nfc + +import ( + "bytes" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/muun/libwallet/cryptography" +) + +func TestParseMetadataV3_ErrorScenarios(t *testing.T) { + + tests := []struct { + name string + data []byte + expectError string + }{ + { + name: "empty data", + data: []byte{}, + expectError: "invalid metadata length: 0 (expected at least 141)", + }, + { + name: "data too short - 140 bytes (one byte less than required)", + data: make([]byte, MetadataMinSizeV3-1), + expectError: "invalid metadata length: 140 (expected at least 141)", + }, + { + name: "invalid attestation pub - wrong prefix", + data: func() []byte { + data := make([]byte, MetadataMinSizeV3) + data[0] = 0x03 // Invalid prefix for uncompressed point (should be 0x04) + return data + }(), + expectError: "invalid attestation pub:", + }, + { + name: "invalid attestation pub - all zeros", + data: func() []byte { + data := make([]byte, MetadataMinSizeV3) + data[0] = 0x04 // Valid prefix for uncompressed point but all the rest are zeros + return data + }(), + expectError: "invalid attestation pub:", + }, + { + name: "all zeros", + data: make([]byte, MetadataMinSizeV3), // All zeros + expectError: "invalid attestation pub:", + }, + { + name: "provider sig length too large - 73 bytes", + data: func() []byte { + // 141 + 72 + 1 so the reader has bytes available, but sig_len exceeds MaxProviderSigSize. + data := make([]byte, MetadataMinSizeV3+MaxProviderSigSize+1) + pair, err := cryptography.GenerateSecp256r1PKeyPair() + require.NoError(t, err) + copy(data[0:65], pair.PublicKey) + data[MetadataMinSizeV3-1] = 73 // sig_len is the last byte of the fixed prefix + return data + }(), + expectError: "provider sig length 73 exceeds max 72", + }, + { + name: "sig_len greater than remaining bytes", + data: func() []byte { + // Declares sig_len = 10 but no signature bytes follow. + data := make([]byte, MetadataMinSizeV3) + pair, err := cryptography.GenerateSecp256r1PKeyPair() + require.NoError(t, err) + copy(data[0:65], pair.PublicKey) + data[MetadataMinSizeV3-1] = 10 + return data + }(), + expectError: "read provider sig:", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := ParseMetadataV3(tt.data) + require.Error(t, err) + require.Nil(t, result) + assert.ErrorContains(t, err, tt.expectError) + }) + } +} + +func TestParseMetadataV3_Success_NoCert(t *testing.T) { + + // Create valid metadata with exactly MetadataMinSizeV3 bytes (no certificate) + validData := generateValidMetadataV3(t) + + result, err := ParseMetadataV3(validData) + require.NoError(t, err) + + verifyTestMetadataV3(t, result) + + // Before STORE_CERTIFICATE: provider_pub is zeros, sig_len = 0, sig nil + require.Zero(t, result.ProviderSigLen) + require.Nil(t, result.ProviderSig) + + // RawBytes should snapshot the 141 bytes consumed + require.Len(t, result.RawBytes, MetadataMinSizeV3) +} + +func TestParseMetadataV3_Success_WithCert(t *testing.T) { + + // Create valid metadata with a 70-byte signature (typical DER length) + sigLen := byte(70) + validData := generateValidMetadataV3WithCert(t, sigLen) + + result, err := ParseMetadataV3(validData) + require.NoError(t, err) + + verifyTestMetadataV3(t, result) + + require.Equal(t, sigLen, result.ProviderSigLen) + require.Len(t, result.ProviderSig, int(sigLen)) + + // ProviderPub should not be all zeros after STORE_CERTIFICATE + require.NoError( + t, + cryptography.ValidateSecp256r1PublicKey(result.ProviderPub[:]), + "ProviderPub should be a valid point after STORE_CERTIFICATE", + ) + + // RawBytes snapshots 141 + sig_len bytes + expectedRawLen := MetadataMinSizeV3 + int(sigLen) + require.Len(t, result.RawBytes, expectedRawLen) + + // Verify ProviderSig was copied byte-for-byte from the input. + expectedSig := validData[MetadataMinSizeV3 : MetadataMinSizeV3+int(sigLen)] + require.Equal(t, expectedSig, result.ProviderSig) + + // Verify RawBytes matches the input prefix exactly. The pairing MAC + // is computed over RawBytes, so any drift here would break it. + require.Equal(t, validData[:expectedRawLen], result.RawBytes) +} + +func TestParseMetadataV3_ValidSignatureLengths(t *testing.T) { + // Test all valid signature lengths (0 = no cert, then 1..MaxProviderSigSize) + for sigLen := 0; sigLen <= MaxProviderSigSize; sigLen++ { + t.Run(fmt.Sprintf("signature_length_%d", sigLen), func(t *testing.T) { + var validData []byte + if sigLen == 0 { + validData = generateValidMetadataV3(t) + } else { + validData = generateValidMetadataV3WithCert(t, byte(sigLen)) + } + + result, err := ParseMetadataV3(validData) + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, sigLen, int(result.ProviderSigLen)) + }) + } +} + +func TestParseMetadataV3_MoreThanMetadataSize(t *testing.T) { + + // Test that more than the consumed length still works (trailing bytes ignored, + // and not included in RawBytes since the MAC only binds the metadata). + validData := generateValidMetadataV3(t) + + // 10 extra random bytes + validData = append(validData, randomBytes(10)...) + + result, err := ParseMetadataV3(validData) + require.NoError(t, err) + + verifyTestMetadataV3(t, result) + + // Trailing bytes must NOT bleed into RawBytes + require.Len(t, result.RawBytes, MetadataMinSizeV3) +} + +func TestParsePairingResponseV3_ErrorScenarios(t *testing.T) { + + minSizeValid := PairResponseSizeV3 + MetadataMinSizeV3 // 99 + 141 = 240 + + tests := []struct { + name string + data func() []byte + expectError string + }{ + { + name: "empty data", + data: func() []byte { return []byte{} }, + expectError: "invalid v3 pair response length: 0 (expected at least 240)", + }, + { + name: "only pair core, no metadata", + data: func() []byte { return make([]byte, PairResponseSizeV3) }, + expectError: "invalid v3 pair response length: 99 (expected at least 240)", + }, + { + name: "data one byte short of minimum", + data: func() []byte { return make([]byte, minSizeValid-1) }, + expectError: "invalid v3 pair response length: 239 (expected at least 240)", + }, + { + name: "invalid card public key - all zeros", + data: func() []byte { + return make([]byte, minSizeValid) + }, + expectError: "invalid card public key in v3 pair response:", + }, + { + name: "invalid card public key - wrong prefix", + data: func() []byte { + data := make([]byte, minSizeValid) + data[0] = 0x03 // Invalid prefix for uncompressed point + return data + }, + expectError: "invalid card public key in v3 pair response:", + }, + { + name: "valid pair core but invalid trailing metadata", + data: func() []byte { + cardKeyPair, err := cryptography.GenerateSecp256r1PKeyPair() + require.NoError(t, err) + data := make([]byte, minSizeValid) + copy(data, cardKeyPair.PublicKey) + // Metadata bytes stay zero-filled → attestation pub is + // all zeros, which parseMetadataV3 rejects. + return data + }, + expectError: "parse metadata in v3 pair response:", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := parsePairingResponseV3(tt.data()) + require.Error(t, err) + require.Nil(t, result) + assert.ErrorContains(t, err, tt.expectError) + }) + } +} + +func TestParsePairingResponseV3_Success(t *testing.T) { + + validData := createValidPairingResponseDataV3(t) + + result, err := parsePairingResponseV3(validData) + require.NoError(t, err) + require.NotNil(t, result) + + // Verify structure sizes + require.Len(t, result.CardPublicKey, Secp256R1PointSize) + require.Len(t, result.MAC, MacSize) + + // Verify index content + require.Equal(t, uint16(0x1234), result.Index) + + // Verify card public key is valid + require.NoError(t, cryptography.ValidateSecp256r1PublicKey(result.CardPublicKey)) + + // Metadata now comes bundled in the pair response. + verifyTestMetadataV3(t, &result.Metadata) +} + +func TestParsePairingResponseV3_Success_WithCert(t *testing.T) { + // Ensures the parser handles the variable-length metadata tail + // correctly when a provider certificate is present. + sigLen := byte(70) + metadata := generateValidMetadataV3WithCert(t, sigLen) + validData := createPairingResponseDataV3(t, metadata) + + result, err := parsePairingResponseV3(validData) + require.NoError(t, err) + require.NotNil(t, result) + + verifyTestMetadataV3(t, &result.Metadata) + require.Equal(t, sigLen, result.Metadata.ProviderSigLen) + require.Len(t, result.Metadata.ProviderSig, int(sigLen)) + + // RawBytes must capture the full metadata block: base + sig. + require.Len(t, result.Metadata.RawBytes, MetadataMinSizeV3+int(sigLen)) + require.Equal(t, metadata, result.Metadata.RawBytes) +} + +func TestParsePairingResponseV3_TrailingBytes(t *testing.T) { + // Under MAC-last framing (P || index || metadata || MAC) the MAC is + // positioned by the metadata length, so the response must be exactly + // sized. Trailing bytes would shift what gets read as the MAC and + // corrupt verification, so the parser must reject them. The card never + // pads, so this only guards against malformed input. + validData := createValidPairingResponseDataV3(t) + dataWithTrailing := append(validData, randomBytes(10)...) + + _, err := parsePairingResponseV3(dataWithTrailing) + require.Error(t, err) +} + +func TestParseSignChallengeResponseV3_ErrorScenarios(t *testing.T) { + + tests := []struct { + name string + data []byte + expectError string + }{ + { + name: "response too short - empty", + data: []byte{}, + expectError: "invalid v3 sign challenge response length: 0", + }, + { + name: "response too short - 96 bytes (one less than required)", + data: make([]byte, SignChallengeResponseSize-1), + expectError: "invalid v3 sign challenge response length: 96", + }, + { + name: "response too long - 98 bytes", + data: make([]byte, SignChallengeResponseSize+1), + expectError: "invalid v3 sign challenge response length: 98", + }, + { + name: "invalid card public key - all zeros", + data: make([]byte, SignChallengeResponseSize), + expectError: "invalid card public key in v3 challenge response:", + }, + { + name: "invalid card public key - wrong prefix", + data: func() []byte { + data := make([]byte, SignChallengeResponseSize) + data[0] = 0x03 // Invalid prefix for uncompressed point (should be 0x04) + return data + }(), + expectError: "invalid card public key in v3 challenge response:", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := parseSignChallengeResponseV3(tt.data) + require.Error(t, err) + require.Nil(t, result) + assert.ErrorContains(t, err, tt.expectError) + }) + } +} + +func TestParseSignChallengeResponseV3_Success(t *testing.T) { + + // Create valid sign challenge response + cardKeyPair, err := cryptography.GenerateSecp256r1PKeyPair() + require.NoError(t, err) + + // Create response data: card public key (65 bytes) + MAC (32 bytes) + responseData := make([]byte, SignChallengeResponseSize) + copy(responseData[0:65], cardKeyPair.PublicKey) + + // Fill MAC with test data + testMAC := randomBytes(MacSize) + copy(responseData[65:], testMAC) + + result, err := parseSignChallengeResponseV3(responseData) + require.NoError(t, err) + require.NotNil(t, result) + + // Verify structure sizes + require.Len(t, result.CardPublicKey, Secp256R1PointSize) + require.Len(t, result.MAC, MacSize) + + // Verify card public key is valid + require.NoError(t, cryptography.ValidateSecp256r1PublicKey(result.CardPublicKey)) + + // Please note: MAC verification will be tested in another test. + require.Equal(t, testMAC, result.MAC) + require.Equal(t, cardKeyPair.PublicKey, result.CardPublicKey) +} + +func TestBuildSignChallengeDataV3_Layout(t *testing.T) { + // Distinct, non-overlapping byte patterns so any mis-offset shows up. + serverC := bytes.Repeat([]byte{0xAA}, Secp256R1PointSize) // 65 × 0xAA + reason := []byte{0xCC, 0xDD, 0xEE} + mac := bytes.Repeat([]byte{0xBB}, MacSize) // 32 × 0xBB + + data := buildSignChallengeDataV3( + serverC, + 0x0102, // counter + 0x0304, // index + reason, + mac, + ) + + // Wire format: C(65) || counter(2) || index(2) || has_more(1) || reason || MAC(32) + expected := []byte{} + expected = append(expected, serverC...) + expected = append(expected, 0x01, 0x02) // counter big-endian + expected = append(expected, 0x03, 0x04) // index big-endian + expected = append(expected, 0x00) // has_more = 0 + expected = append(expected, reason...) + expected = append(expected, mac...) + + require.Equal(t, expected, data) + require.Len(t, data, Secp256R1PointSize+CounterSize+PairIndexSize+1+len(reason)+MacSize) +} + +func TestBuildSignChallengeDataV3_EmptyReason(t *testing.T) { + // With an empty reason, the has_more byte sits directly between the + // index and the MAC. + serverC := bytes.Repeat([]byte{0xAA}, Secp256R1PointSize) + mac := bytes.Repeat([]byte{0xBB}, MacSize) + + data := buildSignChallengeDataV3(serverC, 0x0102, 0x0304, nil, mac) + + require.Len(t, data, Secp256R1PointSize+CounterSize+PairIndexSize+1+MacSize) + // Byte right after index (offset 65+2+2=69) is has_more = 0; the MAC + // follows it. + require.Equal(t, byte(0x00), data[Secp256R1PointSize+CounterSize+PairIndexSize]) + require.Equal(t, byte(0xBB), data[Secp256R1PointSize+CounterSize+PairIndexSize+1]) +} + +func TestBuildSignChallengeDataV3_LargeReason(t *testing.T) { + // Extended APDUs remove the old 153-byte cap. A 500-byte reason must + // be embedded verbatim, without truncation or wrapping. + serverC := bytes.Repeat([]byte{0xAA}, Secp256R1PointSize) + reason := bytes.Repeat([]byte{0xCC}, 500) + mac := bytes.Repeat([]byte{0xBB}, MacSize) + + data := buildSignChallengeDataV3(serverC, 0, 0, reason, mac) + + require.Len(t, data, Secp256R1PointSize+CounterSize+PairIndexSize+1+500+MacSize) + // Reason should appear verbatim between the has_more byte (offset 70) + // and the MAC. + reasonStart := Secp256R1PointSize + CounterSize + PairIndexSize + 1 + require.Equal(t, reason, data[reasonStart:reasonStart+500]) +} + +// TODO add tests for derivePairingSecretV3 and the V3 MAC builders when the +// mock exposes them in the next PR + +//========================== +// Private, helper functions +//========================== + +func generateValidMetadataV3(t *testing.T) []byte { + t.Helper() + // No-cert layout: 141 bytes, with provider_pub left as zeros and sig_len = 0. + validData := make([]byte, MetadataMinSizeV3) + + // Attestation pub (65 bytes) + pair, err := cryptography.GenerateSecp256r1PKeyPair() + require.NoError(t, err) + copy(validData[0:65], pair.PublicKey) + + // Card Vendor (2 bytes) + validData[65] = 0x12 + validData[66] = 0x34 + + // Card Model (2 bytes) + validData[67] = 0x56 + validData[68] = 0x78 + + // Firmware Version (2 bytes) + validData[69] = 0x03 + validData[70] = 0x00 + + // Capabilities (2 bytes) + validData[71] = 0xAB + validData[72] = 0xCD + + // Operation Count (2 bytes, big-endian) - e.g., 0x0304 = 772 + validData[73] = 0x03 + validData[74] = 0x04 + + // Provider Pub (65 bytes) and sig_len (1 byte) are left as zeros (no cert). + + return validData +} + +// providerPubOffsetV3 is where provider_pub starts inside the V3 +// metadata layout: attestation_pub(65) + vendor(2) + model(2) + +// firmware(2) + capabilities(2) + op_counter(2) = 75. +const providerPubOffsetV3 = Secp256R1PointSize + 2 + 2 + 2 + 2 + 2 + +// sigLenOffsetV3 is where the 1-byte sig_len field sits. +const sigLenOffsetV3 = providerPubOffsetV3 + Secp256R1PointSize + +func generateValidMetadataV3WithCert(t *testing.T, sigLen byte) []byte { + t.Helper() + base := generateValidMetadataV3(t) + + // Provider Pub (65 bytes) - replace zeros with a valid point + providerKeyPair, err := cryptography.GenerateSecp256r1PKeyPair() + require.NoError(t, err) + copy(base[providerPubOffsetV3:sigLenOffsetV3], providerKeyPair.PublicKey) + + // sig_len (1 byte) + base[sigLenOffsetV3] = sigLen + + // signature (variable length, 0..72 bytes) at the end + if sigLen > 0 { + base = append(base, randomBytes(int(sigLen))...) + } + return base +} + +func verifyTestMetadataV3(t *testing.T, result *CardMetadataV3) { + t.Helper() + require.NotNil(t, result) + + require.NoError( + t, + cryptography.ValidateSecp256r1PublicKey(result.AttestationPub[:]), + "expected valid AttestationPub, first byte was 0x%02x", result.AttestationPub[0], + ) + + require.Equal(t, [2]byte{0x12, 0x34}, result.CardVendor) + require.Equal(t, [2]byte{0x56, 0x78}, result.CardModel) + require.Equal(t, [2]byte{0x03, 0x00}, result.FirmwareVersion) + require.Equal(t, [2]byte{0xAB, 0xCD}, result.Capabilities) + require.Equal(t, uint16(0x0304), result.OperationCount) +} + +// createValidPairingResponseDataV3 builds a full V3 pairing response as it +// arrives on the wire once the firmware speaks extended APDUs: +// P(65) || index(2) || metadata(141) || MAC(32) = 240 bytes. +func createValidPairingResponseDataV3(t *testing.T) []byte { + t.Helper() + return createPairingResponseDataV3(t, generateValidMetadataV3(t)) +} + +// createPairingResponseDataV3 builds a pair response with the given +// metadata block, laid out as P || index || metadata || MAC (MAC last). +// Callers pick between the no-cert metadata (141 bytes) and the with-cert +// metadata (141 + sigLen bytes). +func createPairingResponseDataV3(t *testing.T, metadata []byte) []byte { + t.Helper() + cardKeyPair, err := cryptography.GenerateSecp256r1PKeyPair() + require.NoError(t, err) + + data := make([]byte, 0, Secp256R1PointSize+PairIndexSize+len(metadata)+MacSize) + data = append(data, cardKeyPair.PublicKey...) // P (65 bytes) + data = append(data, 0x12, 0x34) // index big-endian + data = append(data, metadata...) // metadata (variable) + data = append(data, randomBytes(MacSize)...) // MAC (32 bytes, last) + return data +} diff --git a/libwallet/electrum/client.go b/libwallet/electrum/client.go index 4a5e6e5d..58204113 100644 --- a/libwallet/electrum/client.go +++ b/libwallet/electrum/client.go @@ -12,6 +12,8 @@ import ( "sort" "strings" "time" + + "github.com/go-errors/errors" ) const defaultLoggerTag = "Electrum/?" @@ -35,7 +37,7 @@ type Client struct { nextRequestID int conn net.Conn log *slog.Logger - requireTls bool + requireTls bool //nolint:staticcheck // TODO: struct field requireTls should be requireTLS } // Request models the structure of all Electrum protocol requests. @@ -48,7 +50,7 @@ type Request struct { // ErrorResponse models the structure of a generic error response. type ErrorResponse struct { ID int `json:"id"` - Error interface{} `json:"error"` // type varies among Electrum implementations. + Error interface{} `json:"error"` //nolint:modernize // TODO: use any instead of interface{} // type varies among Electrum implementations. } // ServerVersionResponse models the structure of a `server.version` response. @@ -66,7 +68,7 @@ type ServerFeaturesResponse struct { // ServerPeersResponse models the structure (or lack thereof) of a `server.peers.subscribe` response type ServerPeersResponse struct { ID int `json:"id"` - Result []interface{} `json:"result"` + Result []interface{} `json:"result"` //nolint:modernize // TODO: use any instead of interface{} } // ListUnspentResponse models a `blockchain.scripthash.listunspent` response. @@ -118,10 +120,13 @@ type ServerFeatures struct { } // Param is a convenience type that models an item in the `Params` array of an Request. -type Param = interface{} +type Param = interface{} //nolint:modernize // TODO: use any instead of interface{} // NewClient creates an initialized Client instance. -func NewClient(requireTls bool, logger *slog.Logger) *Client { +func NewClient( + requireTls bool, //nolint:staticcheck // TODO: func parameter requireTls should be requireTLS + logger *slog.Logger, +) *Client { return &Client{ log: logger, requireTls: requireTls, @@ -130,7 +135,7 @@ func NewClient(requireTls bool, logger *slog.Logger) *Client { // Connect establishes a TLS connection to an Electrum server. func (c *Client) Connect(server string) error { - c.Disconnect() + c.Disconnect() //nolint:errcheck // TODO: check error c.log = c.log.With(slog.String("source", "Electrum/"+server)) c.Server = server @@ -139,7 +144,7 @@ func (c *Client) Connect(server string) error { err := c.establishConnection() if err != nil { - c.Disconnect() + c.Disconnect() //nolint:errcheck // TODO: check error c.log.Error("Connect failed", "error", err) return err } @@ -147,7 +152,7 @@ func (c *Client) Connect(server string) error { // Before calling it a day send a test request (trust me), and as we do identify the server: err = c.identifyServer() if err != nil { - c.Disconnect() + c.Disconnect() //nolint:errcheck // TODO: check error c.log.Error("Identifying server failed", "error", err) return err } @@ -226,15 +231,17 @@ func (c *Client) ServerFeatures() (*ServerFeatures, error) { func (c *Client) ServerPeers() ([]string, error) { res, err := c.rawServerPeers() if err != nil { - return nil, err // note that, besides I/O errors, some servers close the socket on this request + // Note that, besides I/O errors, some servers close the + // socket on this request. + return nil, err } var peers []string for _, entry := range res { // Get ready for some hot casting action. Not for the faint of heart. - addr := entry.([]interface{})[1].(string) - port := entry.([]interface{})[2].([]interface{})[1].(string)[1:] + addr := entry.([]interface{})[1].(string) //nolint:modernize // TODO: use any instead of interface{} + port := entry.([]interface{})[2].([]interface{})[1].(string)[1:] //nolint:modernize // TODO: use any instead of interface{} peers = append(peers, addr+":"+port) } @@ -247,7 +254,7 @@ func (c *Client) ServerPeers() ([]string, error) { // [ "", "", ["", "s", "t"] ] // // Ports can be in any order, or absent if the protocol is not supported -func (c *Client) rawServerPeers() ([]interface{}, error) { +func (c *Client) rawServerPeers() ([]interface{}, error) { //nolint:modernize // TODO: use any instead of interface{} request := Request{ Method: "server.peers.subscribe", Params: []Param{}, @@ -282,7 +289,8 @@ func (c *Client) Broadcast(rawTx string) (string, error) { return response.Result, nil } -// GetHeaders calls the `blockchain.headers.subscribe` endpoint and responds with the current block height and hash. +// GetHeaders calls the `blockchain.headers.subscribe` endpoint and responds with the current block +// height and hash. func (c *Client) GetHeaders() (*GetHeadersResult, error) { request := Request{ Method: "blockchain.headers.subscribe", @@ -354,7 +362,7 @@ func (c *Client) ListUnspentBatch(indexHashes []string) ([][]UnspentRef, error) err := c.callBatch(method, requests, &responses, timeout) if err != nil { - return nil, fmt.Errorf("ListUnspentBatch failed: %w", err) + return nil, errors.Errorf("ListUnspentBatch failed: %w", err) } // Don't forget to sort responses: @@ -385,7 +393,12 @@ func (c *Client) establishConnection() error { Timeout: connectionTimeout, } - tlsConn, err := tls.DialWithDialer(dialer, "tcp", c.Server, config) + tlsConn, err := tls.DialWithDialer( //nolint:noctx // TODO: use (*tls.Dialer).DialContext + dialer, + "tcp", + c.Server, + config, + ) if err == nil { c.conn = tlsConn return nil @@ -394,7 +407,11 @@ func (c *Client) establishConnection() error { return err } - conn, err := net.DialTimeout("tcp", c.Server, connectionTimeout) + conn, err := net.DialTimeout( //nolint:noctx // TODO: use (*net.Dialer).DialContext + "tcp", + c.Server, + connectionTimeout, + ) if err != nil { return err } @@ -425,7 +442,11 @@ func (c *Client) IsConnected() bool { } // call executes a request with JSON marshalling, and loads the response into a pointer. -func (c *Client) call(request *Request, response interface{}, timeout time.Duration) error { +func (c *Client) call( + request *Request, + response interface{}, //nolint:modernize // TODO: use any instead of interface{} + timeout time.Duration, +) error { // Assign a fresh request ID: request.ID = c.incRequestID() @@ -470,7 +491,10 @@ func (c *Client) call(request *Request, response interface{}, timeout time.Durat // call executes a batch request with JSON marshalling, and loads the response into a pointer. // Response may not match request order, so callers MUST sort them by ID. func (c *Client) callBatch( - method string, requests []*Request, response interface{}, timeout time.Duration, + method string, + requests []*Request, + response interface{}, //nolint:modernize // TODO: use any instead of interface{} + timeout time.Duration, ) error { // Assign fresh request IDs: for _, request := range requests { @@ -496,7 +520,13 @@ func (c *Client) callBatch( err = json.Unmarshal(responseBytes, &maybeErrorResponses) if err != nil { - c.log.Error("Unmarshal of potential error failed", "response", string(responseBytes), "error", err) + c.log.Error( + "Unmarshal of potential error failed", + "response", + string(responseBytes), + "error", + err, + ) return err } @@ -504,7 +534,7 @@ func (c *Client) callBatch( for _, maybeErrorResponse := range maybeErrorResponses { if maybeErrorResponse.Error != nil { c.log.Error("Electrum error", "error", maybeErrorResponse.Error) - return fmt.Errorf("%v", maybeErrorResponse.Error) + return errors.Errorf("%v", maybeErrorResponse.Error) } } @@ -524,7 +554,7 @@ func (c *Client) callRaw(method string, request []byte, timeout time.Duration) ( c.log.Debug(fmt.Sprintf("Sending %s body: %s", method, string(request))) if !c.IsConnected() { - err := fmt.Errorf("send failed %s: not connected", method) + err := errors.Errorf("send failed %s: not connected", method) c.log.Error("Send failed: not connected", "method", method) return nil, err } @@ -552,7 +582,15 @@ func (c *Client) callRaw(method string, request []byte, timeout time.Duration) ( if err != nil { duration := time.Since(start) - c.log.Error("Send failed", "method", method, "duration", duration.Milliseconds(), "error", err) + c.log.Error( + "Send failed", + "method", + method, + "duration", + duration.Milliseconds(), + "error", + err, + ) return nil, err } @@ -561,7 +599,15 @@ func (c *Client) callRaw(method string, request []byte, timeout time.Duration) ( response, err := reader.ReadBytes(messageDelim) duration := time.Since(start) if err != nil { - c.log.Error("Receive failed", "method", method, "duration", duration.Milliseconds(), "error", err) + c.log.Error( + "Receive failed", + "method", + method, + "duration", + duration.Milliseconds(), + "error", + err, + ) return nil, err } diff --git a/libwallet/electrum/pool.go b/libwallet/electrum/pool.go index 126851fa..27838efb 100644 --- a/libwallet/electrum/pool.go +++ b/libwallet/electrum/pool.go @@ -9,11 +9,15 @@ type Pool struct { } // NewPool creates an initialized Pool with a `size` number of clients. -func NewPool(size int, requireTls bool, logger *slog.Logger) *Pool { +func NewPool( + size int, + requireTls bool, //nolint:staticcheck // TODO: func parameter requireTls should be requireTLS + logger *slog.Logger, +) *Pool { nextClient := make(chan *Client, size) logger = logger.With(slog.String("source", defaultLoggerTag)) - for i := 0; i < size; i++ { + for i := 0; i < size; i++ { //nolint:modernize // TODO: use range over int nextClient <- NewClient(requireTls, logger) } diff --git a/libwallet/electrum/v2/client.go b/libwallet/electrum/v2/client.go new file mode 100644 index 00000000..6a4c5066 --- /dev/null +++ b/libwallet/electrum/v2/client.go @@ -0,0 +1,312 @@ +package v2 + +import ( + "context" + + "github.com/go-errors/errors" + + "github.com/muun/libwallet/platform/observability/slogctx" +) + +const ( + clientName = "muun" + clientVersion = "1.4" // Electrum version we support. +) + +type ConnectionType int + +const ( + RequireTLS ConnectionType = iota + RequireTCP + PreferTLS +) + +// Client is a TLS client that implements a subset of the Electrum protocol. +// +// It includes a minimal implementation of a JSON-RPC client, since the one provided by the +// standard library doesn't support features such as batching. +// +// It is not thread-safe for concurrent calls. Every Client should have a single owner. +type Client interface { + GetServerAddress() string + GetServerImplementation() string + GetProtocolVersion() string + + Disconnect(ctx context.Context) + IsConnected() bool + + ServerFeatures(ctx context.Context) (ServerFeatures, error) + ServerPeers(ctx context.Context) ([]string, error) + + Broadcast(ctx context.Context, rawTx string) (string, error) + BroadcastBatch(ctx context.Context, rawTxs []string) (map[string]Result[string], error) + + GetBestBlockHeight(ctx context.Context) (int, error) + GetHeaders(ctx context.Context) (GetHeadersResult, error) + + GetScriptHashHistory(ctx context.Context, scriptHash string) ([]ScriptHashHistoryEntry, error) + GetScriptHashHistoryBatch( + ctx context.Context, + scriptHashes []string, + ) (map[string]Result[[]ScriptHashHistoryEntry], error) + + GetTransaction(ctx context.Context, txID string) (string, error) + GetTransactionBatch(ctx context.Context, txIDs []string) (map[string]Result[string], error) + + ListUnspent(ctx context.Context, indexHash string) ([]UnspentRef, error) + ListUnspentBatch( + ctx context.Context, + indexHashes []string, + ) (map[string]Result[[]UnspentRef], error) +} + +type client struct { + serverAddress string + serverImplementation string + protocolVersion string + rpcClient *rpcClient +} + +// NewClient creates an initialized Client instance and connects to the Electrum server. +func NewClient(ctx context.Context, serverAddress string, connType ConnectionType) (Client, error) { + // Connect to Electrum server + rpcClient, err := newRPCClient(ctx, serverAddress, connType) + if err != nil { + slogctx.Error(ctx, "Failed to establish connection to Electrum server", + "server_address", serverAddress, + "connection_type", connType, + "error", err, + ) + return nil, err + } + + client := &client{ + serverAddress: serverAddress, + rpcClient: rpcClient, + } + + // Upon connection, we MUST identify server + if err := client.identifyServer(ctx); err != nil { + client.Disconnect(ctx) + slogctx.Error(ctx, "Failed to identify Electrum server", + "server_address", serverAddress, + "connection_type", connType, + "error", err, + ) + return nil, errors.Errorf("identify server: %w", err) + } + + slogctx.Info(ctx, "Successfully connected to Electrum server", + "server_address", client.serverAddress, + "server_implementation", client.serverImplementation, + "protocol_version", client.protocolVersion, + ) + + return client, nil +} + +// identifyServer calls the `server.version` method and saves the returned +// [server implementation, protocol version] tuple. +// `server.version` MUST be called upon connection, and cannot be called again. +func (c *client) identifyServer(ctx context.Context) error { + serverVersion, err := call[[]string](c.rpcClient, ctx, + "server.version", + clientName, clientVersion, + ) + if err != nil { + return err + } + + c.serverImplementation = serverVersion[0] + c.protocolVersion = serverVersion[1] + + return nil +} + +// GetServerAddress returns the server address the client connects to. +func (c *client) GetServerAddress() string { + return c.serverAddress +} + +// GetServerImplementation returns the advertised server implementation returned in the first +// `server.version` method call. +func (c *client) GetServerImplementation() string { + return c.serverImplementation +} + +// GetProtocolVersion returns the advertised protocol implementation returned in the first +// `server.version` method call. +func (c *client) GetProtocolVersion() string { + return c.protocolVersion +} + +// Disconnect cuts the connection to the Electrum server, if connected. +func (c *client) Disconnect(ctx context.Context) { + if c == nil || c.rpcClient == nil { + return + } + + slogctx.Info(ctx, "Disconnecting from Electrum server", + "server_address", c.serverAddress, + "server_implementation", c.serverImplementation, + "protocol_version", c.protocolVersion, + ) + + if err := c.rpcClient.disconnect(); err != nil { + slogctx.Error(ctx, "Found errors while disconnecting", "error", err) + } + + c.rpcClient = nil +} + +// IsConnected returns whether this client is still connected to the Electrum server. +func (c *client) IsConnected() bool { + return c.rpcClient != nil +} + +// ------------------ Electrum method calls ------------------ // + +// ServerFeatures calls the `server.features` method and returns the relevant part of the result. +func (c *client) ServerFeatures(ctx context.Context) (ServerFeatures, error) { + return call[ServerFeatures](c.rpcClient, ctx, "server.features") +} + +// ServerPeers calls the `server.peers.subscribe` method and returns a list of server addresses. +func (c *client) ServerPeers(ctx context.Context) ([]string, error) { + result, err := call[[]any](c.rpcClient, ctx, "server.peers.subscribe") + if err != nil { + return nil, err + } + + var peers []string + for _, entry := range result { + if peer := c.parseServerPeersEntry(entry); peer != "" { + peers = append(peers, peer) + } + } + + return peers, nil +} + +// parseServerPeersEntry parses a single entry in the result payload of `server.peers.subscribe`. +// Returns empty if payload couldn't been parsed. +func (c *client) parseServerPeersEntry(serverPeersEntry any) string { + // `server.peers.subscribe` method returns this monstrosity: + // [ "", "", ["", "s", "t"] ] + // Ports can be in any order, or absent if the protocol is not supported + + array, ok := serverPeersEntry.([]any) + if !ok || len(array) < 3 { + return "" + } + + address, ok := array[1].(string) + if !ok { + return "" + } + + portArray, ok := array[2].([]any) + if !ok { + return "" + } + + var sslPort, tcpPort string + for _, port := range portArray { + strPort, ok := port.(string) + if ok && len(strPort) > 0 { + if strPort[0] == 's' { + sslPort = strPort[1:] + } else if strPort[0] == 't' { + tcpPort = strPort[1:] + } + } + } + + // prioritize TLS connections + if sslPort != "" { + return "tls://" + address + ":" + sslPort + } + + // but accept TCP connections if there's nothing else + if tcpPort != "" { + return "tcp://" + address + ":" + tcpPort + } + + return "" +} + +// Broadcast calls the `blockchain.transaction.broadcast` endpoint and returns the transaction hash. +func (c *client) Broadcast(ctx context.Context, rawTx string) (string, error) { + return call[string](c.rpcClient, ctx, "blockchain.transaction.broadcast", rawTx) +} + +// BroadcastBatch is `Broadcast` with batching. +func (c *client) BroadcastBatch( + ctx context.Context, rawTxs []string, +) (map[string]Result[string], error) { + return callBatch1Param[string](c.rpcClient, ctx, "blockchain.transaction.broadcast", rawTxs) +} + +// GetBestBlockHeight calls the `blockchain.headers.subscribe` endpoint and responds with the best +// block height. +func (c *client) GetBestBlockHeight(ctx context.Context) (int, error) { + headers, err := c.GetHeaders(ctx) + if err != nil { + return 0, err + } + return int(headers.Height), nil +} + +// GetHeaders calls the `blockchain.headers.subscribe` endpoint and responds +// with the current block height and hash. +func (c *client) GetHeaders(ctx context.Context) (GetHeadersResult, error) { + return call[GetHeadersResult](c.rpcClient, ctx, "blockchain.headers.subscribe") +} + +// GetScriptHashHistory calls `blockchain.scripthash.get_history` and returns the transaction +// history for the given script hash. +func (c *client) GetScriptHashHistory( + ctx context.Context, scriptHash string, +) ([]ScriptHashHistoryEntry, error) { + return call[[]ScriptHashHistoryEntry](c.rpcClient, ctx, + "blockchain.scripthash.get_history", + scriptHash, + ) +} + +// GetScriptHashHistoryBatch is `GetScriptHashHistory` with batching. +func (c *client) GetScriptHashHistoryBatch( + ctx context.Context, scriptHashes []string, +) (map[string]Result[[]ScriptHashHistoryEntry], error) { + return callBatch1Param[[]ScriptHashHistoryEntry](c.rpcClient, ctx, + "blockchain.scripthash.get_history", + scriptHashes, + ) +} + +// GetTransaction calls the `blockchain.transaction.get` endpoint and returns the transaction hex. +func (c *client) GetTransaction(ctx context.Context, txID string) (string, error) { + return call[string](c.rpcClient, ctx, "blockchain.transaction.get", txID) +} + +// GetTransactionBatch is `GetTransaction` with batching. +func (c *client) GetTransactionBatch( + ctx context.Context, txIDs []string, +) (map[string]Result[string], error) { + return callBatch1Param[string](c.rpcClient, ctx, "blockchain.transaction.get", txIDs) +} + +// ListUnspent calls `blockchain.scripthash.listunspent` and returns the UTXO results. +func (c *client) ListUnspent(ctx context.Context, indexHash string) ([]UnspentRef, error) { + return call[[]UnspentRef](c.rpcClient, ctx, "blockchain.scripthash.listunspent", indexHash) +} + +// ListUnspentBatch is `ListUnspent` with batching. +func (c *client) ListUnspentBatch( + ctx context.Context, indexHashes []string, +) (map[string]Result[[]UnspentRef], error) { + return callBatch1Param[[]UnspentRef](c.rpcClient, ctx, + "blockchain.scripthash.listunspent", + indexHashes, + ) +} diff --git a/libwallet/electrum/v2/electrum_integration_test.go b/libwallet/electrum/v2/electrum_integration_test.go new file mode 100644 index 00000000..eb8511b0 --- /dev/null +++ b/libwallet/electrum/v2/electrum_integration_test.go @@ -0,0 +1,566 @@ +package v2_test + +import ( + "bytes" + "encoding/hex" + "fmt" + "testing" + "time" + + "github.com/btcsuite/btcd/btcjson" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" + "github.com/go-errors/errors" + + electrum "github.com/muun/libwallet/electrum/v2" + "github.com/muun/libwallet/platform/test/testbitcoind" + "github.com/muun/libwallet/platform/test/testsuite" +) + +// dummyScriptHash is a valid-format scripthash with no history on chain. +const dummyScriptHash = "0000000000000000000000000000000000000000000000000000000000000000" + +func TestElectrum_electrs_0_11_1_Integration(t *testing.T) { + testsuite.Run(t, newElectrumTestSuite("electrs/0.11.1", "localhost:60002")) +} + +func TestElectrum_fulcrum_2_0_0_Integration(t *testing.T) { + testsuite.Run(t, newElectrumTestSuite("Fulcrum 2.0", "localhost:60003")) +} + +func TestElectrum_electrumx_1_10_0_Integration(t *testing.T) { + testsuite.Run(t, newElectrumTestSuite("ElectrumX 1.10.0", "localhost:60004")) +} + +// ElectrumTestSuite is intended to be run against all the electrum implementations defined in +// the `electrum-test` group/directory to verify compatibility with the most common implementations +// in the wild. The implementation versions are chosen to be the lowest version of each popular +// electrum project with representative use in production. +// If any version is updated, then this test file should be updated accordingly. +type ElectrumTestSuite struct { + testsuite.BaseIntegrationSuite + serverImpl string + serverAddr string + bitcoind *testbitcoind.Client + electrum electrum.Client +} + +func newElectrumTestSuite(serverImpl, serverAddr string) *ElectrumTestSuite { + return &ElectrumTestSuite{serverImpl: serverImpl, serverAddr: serverAddr} +} + +func (s *ElectrumTestSuite) SetupSuite() { + s.BaseIntegrationSuite.SetupSuite() + + s.bitcoind = testbitcoind.NewClient(s.T()) +} + +func (s *ElectrumTestSuite) SetupTest() { + s.BaseIntegrationSuite.SetupTest() + + s.electrum = s.newElectrumClient() + s.bitcoind.StartNewTest(s.T()) + + s.bitcoind.EnsureMatureCoins() + + // Let's make sure electrum is caught up to bitcoind before we start + s.waitForElectrumToCatchUp() +} + +func (s *ElectrumTestSuite) TestServerFeatures() { + features, err := s.electrum.ServerFeatures(s.Ctx) + s.NoError(err) + s.Equal( + "0f9188f13cb7b2c71f2a335e3a4fc328bf5beb436012afca590b1a11466e2206", + features.GenesisHash, + ) + s.Equal("sha256", features.HashFunction) + s.Equal(0, features.Pruning) + s.Equal(s.serverImpl, features.ServerVersion) +} + +func (s *ElectrumTestSuite) TestServerPeers() { + // In regtest we don't have peers. + peers, err := s.electrum.ServerPeers(s.Ctx) + s.NoError(err) + s.Nil(peers) +} + +func (s *ElectrumTestSuite) TestDisconnect() { + client := s.newElectrumClient() + s.True(client.IsConnected()) + + client.Disconnect(s.Ctx) + s.False(client.IsConnected()) + + _, err := client.GetHeaders(s.Ctx) + s.Error(err) +} + +func (s *ElectrumTestSuite) TestBroadcast() { + destAddr, destScriptHash := s.newAddressWithScriptHash() + signedHex, expectedTxID := s.buildSignedTx(destAddr, btcutil.Amount(10_000)) + + txHash, err := s.electrum.Broadcast(s.Ctx, signedHex) + s.NoError(err) + s.Equal(expectedTxID, txHash) + + s.bitcoind.GenerateBlocks(1) + s.waitForElectrumToCatchUp() + + history, err := s.electrum.GetScriptHashHistory(s.Ctx, destScriptHash) + s.NoError(err) + s.Len(history, 1) + s.Equal(txHash, history[0].TxHash) + s.Greater(history[0].Height, int32(0), "transaction should be confirmed") +} + +func (s *ElectrumTestSuite) TestBroadcastBatch() { + addr1, hash1 := s.newAddressWithScriptHash() + addr2, hash2 := s.newAddressWithScriptHash() + hex1, txID1 := s.buildSignedTx(addr1, btcutil.Amount(10_000)) + hex2, txID2 := s.buildSignedTx(addr2, btcutil.Amount(10_000)) + + broadcastResults, err := s.electrum.BroadcastBatch(s.Ctx, []string{hex1, hex2}) + s.NoError(err) + s.Len(broadcastResults, 2) + s.NoError(broadcastResults[hex1].Err) + s.Equal(txID1, broadcastResults[hex1].Value) + s.NoError(broadcastResults[hex2].Err) + s.Equal(txID2, broadcastResults[hex2].Value) + + s.bitcoind.GenerateBlocks(1) + s.waitForElectrumToCatchUp() + + historyResults, err := s.electrum.GetScriptHashHistoryBatch(s.Ctx, []string{hash1, hash2}) + s.NoError(err) + s.Len(historyResults, 2) + + s.NoError(historyResults[hash1].Err) + s.Len(historyResults[hash1].Value, 1) + s.Equal(txID1, historyResults[hash1].Value[0].TxHash) + s.Greater(historyResults[hash1].Value[0].Height, int32(0)) + + s.NoError(historyResults[hash2].Err) + s.Len(historyResults[hash2].Value, 1) + s.Equal(txID2, historyResults[hash2].Value[0].TxHash) + s.Greater(historyResults[hash2].Value[0].Height, int32(0)) +} + +// TestBroadcastBatchConflicting broadcasts multiple transactions that all spend +// the same UTXO and verifies that exactly one result is Ok and the rest carry Electrum errors. +func (s *ElectrumTestSuite) TestBroadcastBatchConflicting() { + const n = 3 + txs := s.buildConflictingTxs(n) + + results, err := s.electrum.BroadcastBatch(s.Ctx, txs) + s.NoError(err) + s.Len(results, n) + + okCount := 0 + for _, hexTx := range txs { + r := results[hexTx] + if r.IsOk() { + okCount++ + continue + } + s.NotNil(r.ElectrumError(), "expected Electrum error on conflict") + } + s.Equal(1, okCount, "exactly one of the conflicting txs should succeed") +} + +func (s *ElectrumTestSuite) TestGetTransaction() { + coinbaseTxID := s.getTxIDFromHash(s.bitcoind.GenerateBlocks(1)[0]) + s.waitForElectrumToCatchUp() + + txHex, err := s.electrum.GetTransaction(s.Ctx, coinbaseTxID) + s.NoError(err) + s.NotEmpty(txHex) + + tx := s.bitcoind.DecodeTx(txHex) + s.Equal(coinbaseTxID, tx.TxID()) +} + +func (s *ElectrumTestSuite) TestGetTransactionBatch() { + coinbaseTxID1 := s.getTxIDFromHash(s.bitcoind.GenerateBlocks(1)[0]) + coinbaseTxID2 := s.getTxIDFromHash(s.bitcoind.GenerateBlocks(1)[0]) + s.waitForElectrumToCatchUp() + + // dummyScriptHash is a valid 32-byte hex format that doubles as an unindexed txid + results, err := s.electrum.GetTransactionBatch( + s.Ctx, []string{coinbaseTxID1, coinbaseTxID2, dummyScriptHash}, + ) + s.NoError(err) + s.Len(results, 3) + + s.True(results[coinbaseTxID1].IsOk()) + tx0 := s.bitcoind.DecodeTx(results[coinbaseTxID1].Value) + s.Equal(coinbaseTxID1, tx0.TxID()) + + s.True(results[coinbaseTxID2].IsOk()) + tx1 := s.bitcoind.DecodeTx(results[coinbaseTxID2].Value) + s.Equal(coinbaseTxID2, tx1.TxID()) + + s.True(results[dummyScriptHash].IsErr()) + electrumErr := results[dummyScriptHash].ElectrumError() + s.NotNil(electrumErr, "expected an Electrum error for unknown txid") + s.NotEmpty(electrumErr.Message) +} + +func (s *ElectrumTestSuite) TestListUnspent() { + // Empty for unused address + unspent, err := s.electrum.ListUnspent(s.Ctx, dummyScriptHash) + s.NoError(err) + s.Empty(unspent) + + // Fund an address and verify the UTXO appears + addr, scriptHash := s.newAddressWithScriptHash() + s.bitcoind.GenerateToAddress(1, addr) + s.waitForElectrumToCatchUp() + + height := s.bitcoind.GetBlockCount() + + unspent, err = s.electrum.ListUnspent(s.Ctx, scriptHash) + s.NoError(err) + s.Len(unspent, 1) + + s.NotEmpty(unspent[0].TxHash) + s.Equal(0, unspent[0].TxPos) + s.Greater(unspent[0].Value, int64(0)) + s.Equal(int(height), unspent[0].Height) +} + +func (s *ElectrumTestSuite) TestListUnspentBatch() { + addr, fundedHash := s.newAddressWithScriptHash() + coinbaseTxID := s.getTxIDFromHash(s.bitcoind.GenerateToAddress(1, addr)[0]) + s.waitForElectrumToCatchUp() + + height := s.bitcoind.GetBlockCount() + + results, err := s.electrum.ListUnspentBatch(s.Ctx, []string{fundedHash, dummyScriptHash}) + s.NoError(err) + s.Len(results, 2) + + // Funded address should have one UTXO matching the coinbase + fundedResult := results[fundedHash] + s.NoError(fundedResult.Err) + s.Len(fundedResult.Value, 1) + s.Equal(coinbaseTxID, fundedResult.Value[0].TxHash) + s.Greater(fundedResult.Value[0].Value, int64(0)) + s.Equal(int(height), fundedResult.Value[0].Height) + + // Unused address should be empty + dummyResult := results[dummyScriptHash] + s.NoError(dummyResult.Err) + s.Empty(dummyResult.Value) + + // Empty batch input should fail + _, err = s.electrum.ListUnspentBatch(s.Ctx, nil) + s.Error(err) +} + +func (s *ElectrumTestSuite) TestGetHeaders() { + bitcoindHeight := s.bitcoind.GetBlockCount() + + result, err := s.electrum.GetHeaders(s.Ctx) + s.NoError(err) + s.Equal(bitcoindHeight, result.Height) + s.Len(result.HeaderHex, 160, "80-byte block header = 160 hex chars") +} + +func (s *ElectrumTestSuite) TestGetHeadersTracksNewBlocks() { + initial, err := s.electrum.GetHeaders(s.Ctx) + s.NoError(err) + + // Single block + s.bitcoind.GenerateBlocks(1) + s.waitForElectrumToCatchUp() + + afterOne, err := s.electrum.GetHeaders(s.Ctx) + s.NoError(err) + s.Equal(initial.Height+1, afterOne.Height) + s.NotEqual(initial.HeaderHex, afterOne.HeaderHex) + + // Multiple blocks in quick succession + const blocksToMine = 3 + for range blocksToMine { + s.bitcoind.GenerateBlocks(1) + } + s.waitForElectrumToCatchUp() + + afterMany, err := s.electrum.GetHeaders(s.Ctx) + s.NoError(err) + s.Equal(afterOne.Height+int32(blocksToMine), afterMany.Height) +} + +func (s *ElectrumTestSuite) TestGetScriptHashHistory() { + addr, scriptHash := s.newAddressWithScriptHash() + + // Empty history for unused address + history, err := s.electrum.GetScriptHashHistory(s.Ctx, scriptHash) + s.NoError(err) + s.Empty(history) + + // Fund and check confirmed history appears + coinbaseTxID := s.getTxIDFromHash(s.bitcoind.GenerateToAddress(1, addr)[0]) + s.waitForElectrumToCatchUp() + + height := s.bitcoind.GetBlockCount() + + history, err = s.electrum.GetScriptHashHistory(s.Ctx, scriptHash) + s.NoError(err) + s.Len(history, 1) + s.Equal(coinbaseTxID, history[0].TxHash) + s.Equal(height, history[0].Height) +} + +func (s *ElectrumTestSuite) TestGetScriptHashHistoryMempool() { + addr, scriptHash := s.newAddressWithScriptHash() + + // Send a tx to the address WITHOUT mining → mempool entry + mempoolTxID := s.bitcoind.SendToAddress(addr, 0.0001) + + // Wait until electrs indexes the mempool tx + err := s.waitFor(func() (bool, error) { + history, err := s.electrum.GetScriptHashHistory(s.Ctx, scriptHash) + if err != nil { + return false, err + } + for _, entry := range history { + if entry.TxHash == mempoolTxID { + return true, nil + } + } + return false, nil + }) + s.NoError(err, "Electrum did not index mempool tx %s", mempoolTxID) + + history, err := s.electrum.GetScriptHashHistory(s.Ctx, scriptHash) + s.NoError(err) + s.Len(history, 1) + s.Equal(mempoolTxID, history[0].TxHash) + s.LessOrEqual(history[0].Height, int32(0), "mempool tx should have non-positive height") + + // Mine a block: the entry should transition to confirmed + s.bitcoind.GenerateBlocks(1) + s.waitForElectrumToCatchUp() + + history, err = s.electrum.GetScriptHashHistory(s.Ctx, scriptHash) + s.NoError(err) + s.Len(history, 1) + s.Equal(mempoolTxID, history[0].TxHash) + s.Greater(history[0].Height, int32(0), "tx should be confirmed after mining") +} + +func (s *ElectrumTestSuite) TestMixedWorkload() { + t := s.T() + + // Round 1: check height + h1, err := s.electrum.GetHeaders(s.Ctx) + s.NoError(err) + t.Logf("Round 1: height %d", h1.Height) + + // Round 2: mine + ListUnspent + check height + s.bitcoind.GenerateBlocks(1) + s.waitForElectrumToCatchUp() + + unspent, err := s.electrum.ListUnspent(s.Ctx, dummyScriptHash) + s.NoError(err) + s.Empty(unspent) + + h2, err := s.electrum.GetHeaders(s.Ctx) + s.NoError(err) + s.Equal(h1.Height+1, h2.Height) + t.Logf("Round 2: height %d", h2.Height) + + // Round 3: mine + GetTransaction + check height + coinbase := s.getTxIDFromHash(s.bitcoind.GenerateBlocks(1)[0]) + s.waitForElectrumToCatchUp() + + txHex, err := s.electrum.GetTransaction(s.Ctx, coinbase) + s.NoError(err) + tx := s.bitcoind.DecodeTx(txHex) + s.Equal(coinbase, tx.TxID()) + + h3, err := s.electrum.GetHeaders(s.Ctx) + s.NoError(err) + s.Equal(h2.Height+1, h3.Height) + t.Logf("Round 3: height %d", h3.Height) + + // Round 4: mine multiple blocks + batch call + check height + for range 2 { + s.bitcoind.GenerateBlocks(1) + } + s.waitForElectrumToCatchUp() + + results, err := s.electrum.ListUnspentBatch(s.Ctx, []string{dummyScriptHash}) + s.NoError(err) + s.Len(results, 1) + + h4, err := s.electrum.GetHeaders(s.Ctx) + s.NoError(err) + s.Equal(h3.Height+2, h4.Height) + t.Logf("Round 4: height %d", h4.Height) +} + +// --- Helpers --- + +func (s *ElectrumTestSuite) newElectrumClient() electrum.Client { + s.T().Helper() + + client, err := electrum.NewClient(s.Ctx, s.serverAddr, electrum.RequireTCP) + s.NoError(err) + s.T().Cleanup(func() { client.Disconnect(s.Ctx) }) + + return client +} + +// newAddressWithScriptHash generates a new bitcoind address and returns both the +// address (for funding via bitcoind RPC) and its Electrum scripthash. +func (s *ElectrumTestSuite) newAddressWithScriptHash() (btcutil.Address, string) { + s.T().Helper() + + addr := s.bitcoind.GetNewAddress("test") + + decoded, err := btcutil.DecodeAddress(addr.EncodeAddress(), &chaincfg.RegressionNetParams) + s.NoError(err, "Failed to decode address") + + pkScript, err := txscript.PayToAddrScript(decoded) + s.NoError(err, "Failed to create pkScript") + + return decoded, electrum.GetScriptHash(pkScript) +} + +func (s *ElectrumTestSuite) getTxIDFromHash(hash *chainhash.Hash) string { + s.T().Helper() + + block, err := s.bitcoind.GetBlock(hash) + s.NoError(err) + + return block.Transactions[0].TxHash().String() +} + +// buildSignedTx creates a funded and signed transaction paying amount to dest. +func (s *ElectrumTestSuite) buildSignedTx( + dest btcutil.Address, amount btcutil.Amount, +) (string, string) { + s.T().Helper() + + rawTx, err := s.bitcoind.Client.CreateRawTransaction( + nil, + map[btcutil.Address]btcutil.Amount{dest: amount}, + nil, + ) + s.NoError(err) + + lockUnspents := true + fundedTx, err := s.bitcoind.FundRawTransaction( + rawTx, + btcjson.FundRawTransactionOpts{LockUnspents: &lockUnspents}, + nil, + ) + s.NoError(err) + + outpoints := make([]testbitcoind.Outpoint, 0, len(fundedTx.Transaction.TxIn)) + for _, in := range fundedTx.Transaction.TxIn { + outpoints = append(outpoints, testbitcoind.Outpoint{ + TxID: in.PreviousOutPoint.Hash.String(), + Vout: in.PreviousOutPoint.Index, + }) + } + s.T().Cleanup(func() { s.bitcoind.UnlockUnspent(outpoints) }) + + signedTx, complete, err := s.bitcoind.SignRawTransactionWithWallet(fundedTx.Transaction) + s.NoError(err) + s.True(complete, "transaction should be fully signed") + + var buf bytes.Buffer + err = signedTx.Serialize(&buf) + s.NoError(err) + + return hex.EncodeToString(buf.Bytes()), signedTx.TxID() +} + +// buildConflictingTxs creates n signed transactions that all spend the same +// confirmed UTXO to different fresh addresses. +func (s *ElectrumTestSuite) buildConflictingTxs(n int) []string { + s.T().Helper() + + utxos, err := s.bitcoind.ListUnspentMin(1) + s.NoError(err) + s.NotEmpty(utxos, "wallet must have at least one spendable UTXO") + picked := utxos[0] + + pickedOutpoint := []testbitcoind.Outpoint{{TxID: picked.TxID, Vout: picked.Vout}} + s.bitcoind.LockUnspent(pickedOutpoint) + s.T().Cleanup(func() { s.bitcoind.UnlockUnspent(pickedOutpoint) }) + + inputAmount, err := btcutil.NewAmount(picked.Amount) + s.NoError(err) + outputAmount := inputAmount - btcutil.Amount(1_000) // 1000 sat fee + + inputs := []btcjson.TransactionInput{{Txid: picked.TxID, Vout: picked.Vout}} + + txs := make([]string, n) + for i := range n { + dest := s.bitcoind.GetNewAddress(fmt.Sprintf("conflict-%d", i)) + + rawTx, err := s.bitcoind.Client.CreateRawTransaction( + inputs, + map[btcutil.Address]btcutil.Amount{dest: outputAmount}, + nil, + ) + s.NoError(err) + + signedTx, complete, err := s.bitcoind.SignRawTransactionWithWallet(rawTx) + s.NoError(err) + s.True(complete, "conflicting tx %d should be fully signed", i) + + var buf bytes.Buffer + s.NoError(signedTx.Serialize(&buf)) + txs[i] = hex.EncodeToString(buf.Bytes()) + } + + return txs +} + +func (s *ElectrumTestSuite) waitForElectrumToCatchUp() { + s.T().Helper() + + bitcoindHeight := s.bitcoind.GetBlockCount() + + _, err := s.electrum.GetHeaders(s.Ctx) + s.NoError(err, "Poll client GetHeaders failed") + + err = s.waitFor(func() (bool, error) { + result, err := s.electrum.GetHeaders(s.Ctx) + return result.Height >= bitcoindHeight, err + }) + s.NoError(err, "Electrum did not catch up to bitcoind height %d", bitcoindHeight) +} + +func (s *ElectrumTestSuite) waitFor(f func() (bool, error)) error { + s.T().Helper() + + const pollInterval = 100 * time.Millisecond + const maxWait = 30 * time.Second + deadline := time.Now().Add(maxWait) + + var err error + var ok bool + for time.Now().Before(deadline) { + ok, err = f() + if err == nil && ok { + return nil + } + time.Sleep(pollInterval) + } + + if err != nil { + return errors.Errorf("timed out after %f seconds with error: %w", maxWait.Seconds(), err) + } + + return errors.Errorf("timed out after %f seconds", maxWait.Seconds()) +} diff --git a/libwallet/electrum/v2/errors.go b/libwallet/electrum/v2/errors.go new file mode 100644 index 00000000..b73277cc --- /dev/null +++ b/libwallet/electrum/v2/errors.go @@ -0,0 +1,27 @@ +package v2 + +import ( + "fmt" +) + +// ElectrumError is a parsed JSON-RPC 2.0 error representing an electrum error. +// Most (but not necessary all) electrum implementations return an error with this structure. +// Errors that don't conform to this structure are given the Code 0, and the serialized error +// is stored in Message. +type ElectrumError struct { + Code int `json:"code"` + Message string `json:"message"` + Data any `json:"data"` +} + +func (e ElectrumError) Error() string { + return fmt.Sprintf("%d: %s", e.Code, e.Message) +} + +func newUnstructuredElectrumError(err string) error { + return ElectrumError{ + Code: 0, + Message: err, + Data: nil, + } +} diff --git a/libwallet/electrum/v2/result.go b/libwallet/electrum/v2/result.go new file mode 100644 index 00000000..86d2f381 --- /dev/null +++ b/libwallet/electrum/v2/result.go @@ -0,0 +1,44 @@ +package v2 + +import "github.com/go-errors/errors" + +// Result represents a single response from an Electrum call, which can contain the expected result +// or an error. +type Result[T any] struct { + Value T + Err error +} + +func okResult[T any](v T) Result[T] { + return Result[T]{Value: v} +} + +func errResult[T any](e error) Result[T] { + return Result[T]{Err: e} +} + +func (r Result[T]) Unwrap() (T, error) { + return r.Value, r.Err +} + +func (r Result[T]) IsOk() bool { + return r.Err == nil +} + +func (r Result[T]) IsErr() bool { + return r.Err != nil +} + +// ElectrumError returns the Electrum protocol error returned by the server, if any +func (r Result[T]) ElectrumError() *ElectrumError { + if r.IsOk() { + return nil + } + + var electrumError ElectrumError + if errors.As(r.Err, &electrumError) { + return &electrumError + } + + return nil +} diff --git a/libwallet/electrum/v2/rpc.go b/libwallet/electrum/v2/rpc.go new file mode 100644 index 00000000..e0339215 --- /dev/null +++ b/libwallet/electrum/v2/rpc.go @@ -0,0 +1,407 @@ +package v2 + +import ( + "bufio" + "bytes" + "cmp" + "context" + "crypto/tls" + "encoding/json" + "math" + "net" + "slices" + "time" + + "github.com/go-errors/errors" + + "github.com/muun/libwallet/platform/observability/slogctx" +) + +const ( + connectionTimeout = time.Second * 30 + singleCallTimeout = time.Second * 30 + batchCallTimeout = time.Second * 60 + + messageDelimiter = byte('\n') +) + +type rpcClient struct { + conn net.Conn + reader *bufio.Reader + nextRequestID int +} + +func newRPCClient( + ctx context.Context, address string, connType ConnectionType, +) (*rpcClient, error) { + conn, err := establishConnection(ctx, address, connType) + if err != nil { + return nil, err + } + + return &rpcClient{ + conn: conn, + reader: bufio.NewReader(conn), + nextRequestID: 0, + }, nil +} + +// establishConnection connects to the RPC server and returns the connection. +func establishConnection( + ctx context.Context, address string, connType ConnectionType, +) (net.Conn, error) { + var conn net.Conn + var err error + + tcpDialer := &net.Dialer{ + Timeout: connectionTimeout, + } + + if connType == RequireTLS || connType == PreferTLS { + tlsDialer := tls.Dialer{ + NetDialer: tcpDialer, + Config: &tls.Config{ + // TODO: check if insecure is necessary + InsecureSkipVerify: true, + }, + } + + conn, err = tlsDialer.DialContext(ctx, "tcp", address) + if err == nil { + return conn, nil + } + } + + if connType == RequireTCP || connType == PreferTLS { + conn, err = tcpDialer.DialContext(ctx, "tcp", address) + if err == nil { + return conn, nil + } + } + + return nil, err +} + +func (c *rpcClient) disconnect() error { + if c == nil || c.conn == nil { + return nil + } + + err := c.conn.Close() + c.conn = nil + c.reader = nil + return err +} + +// call sends a single request, deserializes the result into T, and returns it. +func call[T any](c *rpcClient, ctx context.Context, method string, params ...any) (T, error) { + results, err := callBatch[T](c, ctx, []request{newRequest(method, params...)}) + if err != nil { + var value T + return value, err + } + return results[0].Unwrap() +} + +// callBatch1Param is like `callBatch` with better ergonomics for requests with a single param. +func callBatch1Param[T any, P comparable]( + c *rpcClient, ctx context.Context, method string, params []P, +) (map[P]Result[T], error) { + requests := make([]request, len(params)) + for i, param := range params { + requests[i] = newRequest(method, param) + } + + results, err := callBatch[T](c, ctx, requests) + if err != nil { + return nil, err + } + + mappedResults := make(map[P]Result[T], len(results)) + for i := range params { + mappedResults[params[i]] = results[i] + } + + return mappedResults, nil +} + +// callBatch sends multiple requests as a JSON-RPC batch and deserializes each result into Result[T]. +func callBatch[T any](c *rpcClient, ctx context.Context, requests []request) ([]Result[T], error) { + // Make the call, obtain the serialized result + rawResults, err := c.callRaw(ctx, requests) + if err != nil { + return nil, errors.Errorf("call raw: %w", err) + } + + // Deserialize results + results := make([]Result[T], len(rawResults)) + for i, rawResult := range rawResults { + if rawResult.IsOk() { + var value T + if err = json.Unmarshal(rawResult.Value, &value); err != nil { + return nil, errors.Errorf("result unmarshal failed for type %T: %w", value, err) + } + results[i] = okResult(value) + } else { + results[i] = errResult[T](rawResult.Err) + } + } + + return results, nil +} + +// callRaw sends requests, collects responses, validates them, and returns the raw result payloads. +func (c *rpcClient) callRaw( + ctx context.Context, requests []request, +) ([]Result[json.RawMessage], error) { + if c == nil || c.conn == nil { + return nil, errors.Errorf("client is disconnected") + } + if len(requests) == 0 { + return nil, errors.Errorf("expected at least one request") + } + + // Assign fresh req IDs + for i := range requests { + requests[i].ID = c.incRequestID() + } + + slogctx.Info(ctx, "Sending requests", "requests", requests) + + // Set request timeout + var timeout time.Duration + if len(requests) == 1 { + timeout = singleCallTimeout + } else { + timeout = batchCallTimeout + } + deadline := time.Now().Add(timeout) + + var cancel context.CancelFunc + ctx, cancel = context.WithDeadline(ctx, deadline) + defer cancel() + + if err := c.conn.SetDeadline(deadline); err != nil { + return nil, errors.Errorf("SetDeadline failed: %w", err) + } + + // Send requests + if err := c.sendRequest(requests); err != nil { + slogctx.Error(ctx, "Failed to send request", + "requests", requests, + "error", err, + ) + return nil, errors.Errorf("send request: %w", err) + } + + // Receive responses + responses, err := c.receiveResponse(ctx, requests) + if err != nil { + slogctx.Error(ctx, "Failed to read responses", + "requests", requests, + "error", err, + ) + return nil, errors.Errorf("receive response: %w", err) + } + + slogctx.Info(ctx, "Received responses", "responses", responses) + + // Validate response length + if len(responses) != len(requests) { + return nil, errors.Errorf( + "response count %d doesn't match request count %d", + len(responses), len(requests), + ) + } + + // Check all responses have valid IDs + var noIDErrors []error + for _, response := range responses { + if response.id != 0 { + continue + } + + if response.isError() { + noIDErrors = append(noIDErrors, errors.Errorf("response with unknown ID: %v", response)) + } + } + if len(noIDErrors) > 0 { + return nil, errors.Join(noIDErrors...) + } + + // Sort the responses by ID + slices.SortFunc(responses, func(a response, b response) int { + return cmp.Compare(a.id, b.id) + }) + + // Validate IDs match and build results array + results := make([]Result[json.RawMessage], len(responses)) + for i, resp := range responses { + // Validate ID + if requests[i].ID != responses[i].id { + return nil, errors.Errorf( + "request and response ID doesn't match: request id %d, response id %d", + requests[i].ID, responses[i].id, + ) + } + + if resp.isError() { + results[i] = errResult[json.RawMessage](c.parseElectrumError(resp.error)) + } else { + results[i] = okResult(resp.result) + } + } + + return results, nil +} + +// sendRequest serializes the request(s) as JSON and writes them to the connection. +// A single request is sent as a JSON object; multiple requests as a JSON array (batch). +func (c *rpcClient) sendRequest(requests []request) error { + // Serialize requests + var rawRequest []byte + if len(requests) == 1 { + rawReq, err := json.Marshal(requests[0]) + if err != nil { + return errors.Errorf("single request marshalling failed: %w", err) + } + rawRequest = rawReq + } else { + rawReq, err := json.Marshal(requests) + if err != nil { + return errors.Errorf("batch requests marshalling failed: %w", err) + } + rawRequest = rawReq + } + + rawRequest = append(rawRequest, messageDelimiter) + + writeStart := time.Now() + + _, err := c.conn.Write(rawRequest) + if err != nil { + duration := time.Since(writeStart) + return errors.Errorf( + "write request failed after %d millis: %w", + duration.Milliseconds(), err, + ) + } + + return nil +} + +// receiveResponse reads from the connection until a non-stale response arrives. +// Stale responses (from previous timed-out calls) and notification messages are discarded. +func (c *rpcClient) receiveResponse(ctx context.Context, requests []request) ([]response, error) { + // IDs are created on a strictly ascending order, so the first one is the minimum + minReqID := requests[0].ID + + readStart := time.Now() + + for { + rawMessage, err := c.reader.ReadBytes(messageDelimiter) + if err != nil { + duration := time.Since(readStart) + return nil, errors.Errorf( + "read response failed after %d millis: %w", + duration.Milliseconds(), err, + ) + } + + parsedMessages, err := c.parseIncomingMessage(rawMessage) + if err != nil { + return nil, err + } + + responses := make([]response, 0, len(parsedMessages)) + for _, parsedMessage := range parsedMessages { + if parsedMessage.isNotification() { + c.processNotification(ctx, parsedMessage) + } else { + resp := parsedMessage.toResponse() + if resp.id == 0 { + slogctx.Warn(ctx, "Failed to parse response ID", "response", resp) + } + responses = append(responses, resp) + } + } + if len(responses) == 0 { + // Message was all notifications -> Wait for next message + continue + } + + // Skip stale responses from previous timed-out calls. + // Responses with IDs lower than the min ID sent in request (excluding 0 for unknown) + // are stale. + minRespID := math.MaxInt + for _, resp := range responses { + if resp.id != 0 && resp.id < minRespID { + minRespID = resp.id + } + } + if minRespID < minReqID { + slogctx.Warn(ctx, "Skipping stale response with lower ID than requested", + "request", requests, + "staleResponse", responses, + ) + continue + } + + return responses, nil + } +} + +// parseIncomingMessage parses a single JSON-RPC line into one or more messages. +// A line starting with '[' is treated as a batch message array; otherwise as a single message. +func (c *rpcClient) parseIncomingMessage(rawMessage []byte) ([]message, error) { + rawMessage = bytes.TrimLeft(rawMessage, " \t\r\n") + + if rawMessage[0] == '[' { + var batch []message + if err := json.Unmarshal(rawMessage, &batch); err != nil { + return nil, errors.Errorf("batch messages unmarshalling failed: %w", err) + } + return batch, nil + } + + var single message + if err := json.Unmarshal(rawMessage, &single); err != nil { + return nil, errors.Errorf("single message unmarshalling failed: %w", err) + } + return []message{single}, nil +} + +// parseElectrumError parses an Electrum protocol error returned by the server. +func (c *rpcClient) parseElectrumError(rawError json.RawMessage) error { + var electrumError ElectrumError + if err := json.Unmarshal(rawError, &electrumError); err != nil { + return newUnstructuredElectrumError(string(rawError)) + } + return electrumError +} + +// processNotification parses and process a notification. +// Currently, we are just discarding the parsed notification. +// It is left as future work to figure out what we want to do with them. +// Errors are logged and ignored. +func (c *rpcClient) processNotification(ctx context.Context, msg message) { + noti, err := msg.toNotification() + if err != nil { + slogctx.Error(ctx, "Failed to parse notification", + "message", msg, + "error", err, + ) + // ignore error + return + } + + // TODO(lightning): Decide what to do with notifications + slogctx.Info(ctx, "Discarding notification", "notification", noti) +} + +// incRequestID generates monotonically increasing integer IDs for the requests +func (c *rpcClient) incRequestID() int { + // ID 0 is reserved for responses with unknown ID + c.nextRequestID++ + return c.nextRequestID +} diff --git a/libwallet/electrum/v2/rpc_test.go b/libwallet/electrum/v2/rpc_test.go new file mode 100644 index 00000000..aa0e0fc9 --- /dev/null +++ b/libwallet/electrum/v2/rpc_test.go @@ -0,0 +1,193 @@ +package v2 + +import ( + "bufio" + "encoding/json" + "net" + "testing" +) + +// newTestClient creates a rpcClient wired to the client side of a net.Pipe. +// Returns the rpcClient and the server-side conn. +func newTestClient(t *testing.T) (*rpcClient, net.Conn) { + t.Helper() + serverConn, clientConn := net.Pipe() + + client := &rpcClient{ + conn: clientConn, + reader: bufio.NewReader(clientConn), + } + + t.Cleanup(func() { + _ = serverConn.Close() + _ = client.disconnect() + }) + + return client, serverConn +} + +// TestIsNotification verifies the classification of JSON-RPC messages into +// responses vs notifications, including edge cases. +func TestIsNotification(t *testing.T) { + tests := []struct { + name string + msg string + want bool + }{ + // Responses (have an id) -> false + { + name: "response with numeric id", + msg: `{"id":1,"result":{"height":100}}`, + want: false, + }, + { + name: "response with id 0", + msg: `{"id":0,"result":"ok"}`, + want: false, + }, + { + name: "response with string id", + msg: `{"id":"abc","result":"ok"}`, + want: false, + }, + // Error responses -> false (even with null/missing id) + { + name: "error response with id", + msg: `{"id":1,"error":"bad request"}`, + want: false, + }, + { + name: "error response with null id", + msg: `{"id":null,"error":"parse error"}`, + want: false, + }, + { + name: "error response without id field", + msg: `{"error":"parse error"}`, + want: false, + }, + // Notifications (no id, no error) → true + { + name: "notification with null id", + msg: `{"id":null,"method":"blockchain.headers.subscribe","params":[{"height":100}]}`, + want: true, + }, + { + name: "notification without id field", + msg: `{"method":"blockchain.headers.subscribe","params":[{"height":100}]}`, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var msg message + if err := json.Unmarshal([]byte(tt.msg), &msg); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + got := msg.isNotification() + if got != tt.want { + t.Errorf("isNotification() = %v, want %v", got, tt.want) + } + }) + } +} + +// TestUnparseableID verifies that non-numeric IDs fall back to the sentinel value 0. +func TestUnparseableID(t *testing.T) { + rawMessage := `{"id":"not-a-number","result":"ok"}` + var msg message + if err := json.Unmarshal([]byte(rawMessage), &msg); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + + resp := msg.toResponse() + if resp.id != 0 { + t.Errorf("id = %d, want 0 (sentinel for unparseable)", resp.id) + } +} + +// TestCallRawSkipsStaleResponses verifies that responses from previous timed-out calls (lower IDs) +// are discarded. +func TestCallRawSkipsStaleResponses(t *testing.T) { + client, serverConn := newTestClient(t) + client.nextRequestID = 4 + + go func() { + defer func() { _ = serverConn.Close() }() + + buf := make([]byte, 4096) + if _, err := serverConn.Read(buf); err != nil { + t.Errorf("failed to read response: %v", err) + return + } + + // Send a stale response (id=3) followed by the current one (id=5) + messages := []string{ + `{"id":3,"result":"stale"}`, + `{"id":5,"result":"current"}`, + } + for _, msg := range messages { + if _, err := serverConn.Write([]byte(msg + "\n")); err != nil { + t.Errorf("failed to write response: %v", err) + return + } + } + }() + + ctx := t.Context() + result, err := call[string](client, ctx, "test.method") + if err != nil { + t.Fatalf("call failed: %v", err) + } + + if result != "current" { + t.Errorf("result = %q, want %q", result, "current") + } +} + +// TestCallRawSkipsStaleResponsesInBatch is the batch variant of stale response +// skipping, a stale single response followed by a valid batch response. +func TestCallRawSkipsStaleResponsesInBatch(t *testing.T) { + client, serverConn := newTestClient(t) + client.nextRequestID = 4 + + go func() { + defer func() { _ = serverConn.Close() }() + + buf := make([]byte, 4096) + if _, err := serverConn.Read(buf); err != nil { + t.Errorf("failed to read response: %v", err) + return + } + + // Stale response first, then a batch response with 2 valid results + messages := []string{ + `{"id":1,"result":"stale"}`, + `[{"id":5,"result":"first"},{"id":6,"result":"second"}]`, + } + for _, msg := range messages { + if _, err := serverConn.Write([]byte(msg + "\n")); err != nil { + t.Errorf("failed to write response: %v", err) + return + } + } + }() + + ctx := t.Context() + reqs := []request{ + newRequest("test.method", "param1"), + newRequest("test.method", "param2"), + } + results, err := callBatch[string](client, ctx, reqs) + if err != nil { + t.Fatalf("callBatch failed: %v", err) + } + + if results[0].Value != "first" { + t.Errorf("results[0] = %q, want `first`", results[0]) + } + if results[1].Value != "second" { + t.Errorf("results[1] = %q, want `second`", results[1]) + } +} diff --git a/libwallet/electrum/v2/rpc_types.go b/libwallet/electrum/v2/rpc_types.go new file mode 100644 index 00000000..0d8b1e4c --- /dev/null +++ b/libwallet/electrum/v2/rpc_types.go @@ -0,0 +1,110 @@ +package v2 + +import ( + "bytes" + "encoding/json" + "fmt" + + "github.com/go-errors/errors" +) + +var nullMessage = []byte("null") // Cannot create a slice as constant, so it is var + +// request models the structure of all Electrum protocol requests. +type request struct { + ID int `json:"id"` + Method string `json:"method"` + Params []any `json:"params"` +} + +func newRequest(method string, params ...any) request { + if params == nil { + params = []any{} + } + return request{ + Method: method, + Params: params, + } +} + +// response is a parsed JSON-RPC response. The id matches the request that produced it. +// An id of 0 means the ID could not be parsed (see toResponse). +type response struct { + id int + result json.RawMessage // present only on success + error json.RawMessage // present only on failure +} + +func (r response) isError() bool { + // In JSON-RPC 1.0, Error can be "null" instead of missing + return r.error != nil && !bytes.Equal(r.error, nullMessage) +} + +func (r response) String() string { + return fmt.Sprintf("{id: %d result: %s error: %s}", r.id, r.result, r.error) +} + +// notification is a parsed JSON-RPC notification (server push with no request ID). +type notification struct { + method string + params []json.RawMessage +} + +func (n notification) String() string { + return fmt.Sprintf("{method:%s params:%s}", n.method, n.params) +} + +// message is the raw JSON-RPC message received from the server before +// classification. Both responses and notifications arrive as the same JSON structure; +// isNotification() distinguishes them by checking whether an `id` field is present. +type message struct { + // Missing on notifications + ID json.RawMessage `json:"id"` + + // Present only on notifications + Method json.RawMessage `json:"method"` + Params json.RawMessage `json:"params"` + + // Present only on success responses + Result json.RawMessage `json:"result"` + + // Present only on failure responses + Error json.RawMessage `json:"error"` +} + +func (m message) isNotification() bool { + // In JSON-RPC 1.0, ID and Error can be "null" instead of missing + isError := m.Error != nil && !bytes.Equal(m.Error, nullMessage) + return !isError && (m.ID == nil || bytes.Equal(m.ID, nullMessage)) +} + +func (m message) isResponse() bool { + return !m.isNotification() +} + +// toNotification parses the message into a JSON-RPC notification +func (m message) toNotification() (*notification, error) { + var noti notification + + if err := json.Unmarshal(m.Method, ¬i.method); err != nil { + return nil, errors.Errorf("notification method unmarshalling failed: %w", err) + } + + if err := json.Unmarshal(m.Params, ¬i.params); err != nil { + return nil, errors.Errorf("notification params unmarshalling failed: %w", err) + } + + return ¬i, nil +} + +// toResponse parses a responseOrNotification into a response. If the ID cannot be parsed +// (e.g. string IDs from non-standard servers), it is set to 0 as a sentinel for "unknown". +// Valid request IDs start at 1, so 0 is never ambiguous with a real response. +func (m message) toResponse() response { + var id int + if err := json.Unmarshal(m.ID, &id); err != nil { + id = 0 + } + + return response{id: id, result: m.Result, error: m.Error} +} diff --git a/libwallet/electrum/v2/scripthash.go b/libwallet/electrum/v2/scripthash.go new file mode 100644 index 00000000..80211c69 --- /dev/null +++ b/libwallet/electrum/v2/scripthash.go @@ -0,0 +1,14 @@ +package v2 + +import ( + "crypto/sha256" + "encoding/hex" + "slices" +) + +// GetScriptHash returns the script hash parameter to use with Electrum, given a Bitcoin script. +func GetScriptHash(script []byte) string { + indexHash := sha256.Sum256(script) + slices.Reverse(indexHash[:]) + return hex.EncodeToString(indexHash[:]) +} diff --git a/libwallet/electrum/v2/servers.go b/libwallet/electrum/v2/servers.go new file mode 100644 index 00000000..fedb1497 --- /dev/null +++ b/libwallet/electrum/v2/servers.go @@ -0,0 +1,105 @@ +package v2 + +import "sync/atomic" + +// ServerProvider manages a rotating server list, from which callers can pull server addresses. +type ServerProvider struct { + nextIndex int32 + servers []string +} + +// NewServerProvider returns an initialized ServerProvider. +func NewServerProvider(servers []string) *ServerProvider { + return &ServerProvider{ + nextIndex: -1, + servers: servers, + } +} + +// NextServer returns an address from the rotating list. It's thread-safe. +func (p *ServerProvider) NextServer() string { + index := int(atomic.AddInt32(&p.nextIndex, 1)) + return p.servers[index%len(p.servers)] +} + +// PublicServers list. +// +// This list was taken from Electrum repositories, keeping TLS servers and excluding onion URIs. +// It was then sorted into sections using the `cmd/survey` program, to prioritize the more reliable +// servers with batch support. +// +// See https://github.com/spesmilo/electrum/blob/master/electrum/servers.json +// See https://github.com/kyuupichan/electrumx/blob/master/electrumx/lib/coins.py +// See `cmd/survey/main.go` +var PublicServers = []string{ + // Fast servers with batching + "electrum.coinext.com.br:50002", // impl: ElectrumX 1.14.0, batching: true, ttc: 0.15, speed: 113, from: + "fulcrum.sethforprivacy.com:50002", // impl: Fulcrum 1.9.0, batching: true, ttc: 0.55, speed: 96, from: + "mainnet.foundationdevices.com:50002", // impl: Fulcrum 1.8.2, batching: true, ttc: 0.54, speed: 88, from: + "btc.lastingcoin.net:50002", // impl: Fulcrum 1.7.0, batching: true, ttc: 0.74, speed: 73, from: + "vmd71287.contaboserver.net:50002", // impl: Fulcrum 1.9.0, batching: true, ttc: 0.60, speed: 70, from: + "de.poiuty.com:50002", // impl: Fulcrum 1.8.2, batching: true, ttc: 0.87, speed: 70, from: + "electrum.jochen-hoenicke.de:50006", // impl: Fulcrum 1.8.2, batching: true, ttc: 0.83, speed: 69, from: + "btc.cr.ypto.tech:50002", // impl: Fulcrum 1.9.0, batching: true, ttc: 0.81, speed: 65, from: + "e.keff.org:50002", // impl: Fulcrum 1.8.2, batching: true, ttc: 0.82, speed: 65, from: + "vmd104014.contaboserver.net:50002", // impl: Fulcrum 1.9.0, batching: true, ttc: 0.58, speed: 64, from: + "e2.keff.org:50002", // impl: Fulcrum 1.8.2, batching: true, ttc: 0.83, speed: 64, from: + "fulcrum.grey.pw:51002", // impl: Fulcrum 1.9.0, batching: true, ttc: 0.81, speed: 63, from: + "fortress.qtornado.com:443", // impl: ElectrumX 1.16.0, batching: true, ttc: 0.84, speed: 62, from: + "f.keff.org:50002", // impl: Fulcrum 1.8.2, batching: true, ttc: 0.89, speed: 62, from: + "2ex.digitaleveryware.com:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 0.71, speed: 61, from: + "electrum.petrkr.net:50002", // impl: Fulcrum 1.9.0, batching: true, ttc: 0.84, speed: 58, from: + "electrum.stippy.com:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 0.80, speed: 57, from: + "electrum0.snel.it:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 0.80, speed: 56, from: + "ru.poiuty.com:50002", // impl: Fulcrum 1.8.2, batching: true, ttc: 0.99, speed: 56, from: + "electrum.privateservers.network:50002", // impl: ElectrumX 1.15.0, batching: true, ttc: 0.85, speed: 49, from: + "btc.electroncash.dk:60002", // impl: Fulcrum 1.9.0, batching: true, ttc: 0.92, speed: 48, from: + "bitcoin.aranguren.org:50002", // impl: Fulcrum 1.8.2, batching: true, ttc: 1.19, speed: 48, from: + "electrum.bitcoinserver.nl:50514", // impl: Fulcrum 1.8.1, batching: true, ttc: 0.85, speed: 44, from: + "btc.prompt.cash:61002", // impl: Fulcrum 1.8.1, batching: true, ttc: 1.22, speed: 44, from: + "fulc.bot.nu:50002", // impl: Fulcrum 1.7.0, batching: true, ttc: 1.04, speed: 35, from: + "bolt.schulzemic.net:50002", // impl: Fulcrum 1.8.2, batching: true, ttc: 0.96, speed: 33, from: + "node1.btccuracao.com:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 0.90, speed: 25, from: + + // Other servers + "xtrum.com:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 0.91, speed: 19, from: fulcrum.sethforprivacy.com:50002 + "electrum.bitaroo.net:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 1.04, speed: 19, from: + "btce.iiiiiii.biz:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 1.07, speed: 19, from: electrum.coinext.com.br:50002 + "electrum.emzy.de:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 1.17, speed: 19, from: + "alviss.coinjoined.com:50002", // impl: ElectrumX 1.15.0, batching: true, ttc: 1.15, speed: 17, from: + "2AZZARITA.hopto.org:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 0.81, speed: 16, from: electrum.coinext.com.br:50002 + "vmd104012.contaboserver.net:50002", // impl: Fulcrum 1.9.0, batching: true, ttc: 1.25, speed: 16, from: + "electrum.bitcoinlizard.net:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 4.99, speed: 14, from: + "btc.ocf.sh:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 1.19, speed: 12, from: + "bitcoins.sk:56002", // impl: ElectrumX 1.14.0, batching: true, ttc: 0.80, speed: 11, from: + "electrum-btc.leblancnet.us:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 1.04, speed: 11, from: + "helicarrier.bauerj.eu:50002", // impl: ElectrumX 1.10.0, batching: true, ttc: 0.96, speed: 10, from: + "electrum.neocrypto.io:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 0.69, speed: 7, from: + "caleb.vegas:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 0.71, speed: 7, from: + "smmalis37.ddns.net:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 0.77, speed: 7, from: + "2azzarita.hopto.org:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 0.78, speed: 7, from: fulcrum.sethforprivacy.com:50002 + "electrum.kendigisland.xyz:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 0.79, speed: 7, from: + "electrum.hsmiths.com:50002", // impl: ElectrumX 1.10.0, batching: true, ttc: 0.90, speed: 7, from: + "vmd63185.contaboserver.net:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 0.92, speed: 7, from: + "blkhub.net:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 0.94, speed: 7, from: + "electrum.mmitech.info:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 0.95, speed: 7, from: + "elx.bitske.com:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 1.17, speed: 7, from: + "bitcoin.lu.ke:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 1.21, speed: 7, from: + "ex05.axalgo.com:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 1.21, speed: 7, from: + "walle.dedyn.io:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 1.23, speed: 7, from: + "eai.coincited.net:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 0.34, speed: 6, from: + "2electrumx.hopto.me:56022", // impl: ElectrumX 1.16.0, batching: true, ttc: 0.93, speed: 6, from: + "hodlers.beer:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 1.03, speed: 6, from: + "kareoke.qoppa.org:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 1.13, speed: 6, from: + "ASSUREDLY.not.fyi:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 0.85, speed: 4, from: + "electrumx.alexridevski.net:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 0.87, speed: 4, from: + "assuredly.not.fyi:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 0.90, speed: 4, from: + "ragtor.duckdns.org:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 1.07, speed: 4, from: + "surely.not.fyi:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 1.20, speed: 4, from: + "btc.electrum.bitbitnet.net:50002", // impl: ElectrumX 1.15.0, batching: true, ttc: 0.71, speed: 3, from: + "gods-of-rock.screaminglemur.net:50002", // impl: ElectrumX 1.15.0, batching: true, ttc: 0.92, speed: 3, from: + "SURELY.not.fyi:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 1.35, speed: 3, from: + "horsey.cryptocowboys.net:50002", // impl: ElectrumX 1.15.0, batching: true, ttc: 0.61, speed: 2, from: + "electrumx-btc.cryptonermal.net:50002", // impl: ElectrumX 1.15.0, batching: true, ttc: 0.83, speed: 1, from: + "electrum.coineuskal.com:50002", // impl: ElectrumX 1.15.0, batching: true, ttc: 1.73, speed: 0, from: electrum.coinext.com.br:50002 +} diff --git a/libwallet/electrum/v2/types.go b/libwallet/electrum/v2/types.go new file mode 100644 index 00000000..d9d65322 --- /dev/null +++ b/libwallet/electrum/v2/types.go @@ -0,0 +1,33 @@ +package v2 + +// ServerFeatures models the structure of a `server.features` result. +type ServerFeatures struct { + GenesisHash string `json:"genesis_hash"` + HashFunction string `json:"hash_function"` + ServerVersion string `json:"server_version"` + ProtocolMin string `json:"protocol_min"` + ProtocolMax string `json:"protocol_max"` + Pruning int `json:"pruning"` +} + +// GetHeadersResult models the structure of a `blockchain.headers.subscribe` result. +type GetHeadersResult struct { + Height int32 `json:"height"` + HeaderHex string `json:"hex"` +} + +// ScriptHashHistoryEntry models the structure of an item in a `blockchain.scripthash.get_history` +// result array. +type ScriptHashHistoryEntry struct { + TxHash string `json:"tx_hash"` + Height int32 `json:"height"` +} + +// UnspentRef models the structure of an item of the array of a `blockchain.scripthash.listunspent` +// result. +type UnspentRef struct { + TxHash string `json:"tx_hash"` + TxPos int `json:"tx_pos"` + Value int64 `json:"value"` + Height int `json:"height"` +} diff --git a/libwallet/emergency_kit.go b/libwallet/emergency_kit.go index ecfd0478..4122d011 100755 --- a/libwallet/emergency_kit.go +++ b/libwallet/emergency_kit.go @@ -2,7 +2,8 @@ package libwallet import ( "encoding/json" - "fmt" + + "github.com/go-errors/errors" "github.com/muun/libwallet/emergencykit" ) @@ -15,7 +16,8 @@ const ( EKVersionDescriptors = 2 // EKVersionMusig add the musig descriptors EKVersionMusig = 3 - // This is public because this is being consumed by the new architecture inside emergency_kit packages. + // This is public because this is being consumed by the new architecture inside + // emergency_kit packages. // TODO: execute the non-trivial refactor to migrate this inside emergency kit package EkVersionCurrent = EKVersionMusig ) @@ -48,7 +50,8 @@ func GenerateEmergencyKitHTML(ekParams *EKInput, language string) (*EKOutput, er FirstFingerprint: ekParams.FirstFingerprint, SecondEncryptedKey: ekParams.SecondEncryptedKey, SecondFingerprint: ekParams.SecondFingerprint, - // This is public because this is being consumed by the new architecture inside emergency_kit packages. + // This is public because this is being consumed by the new architecture inside + // emergency_kit packages. // TODO: execute the non-trivial refactor to migrate this inside emergency kit package Version: EkVersionCurrent, } @@ -56,18 +59,22 @@ func GenerateEmergencyKitHTML(ekParams *EKInput, language string) (*EKOutput, er // Create the HTML and the verification code: htmlWithCode, err := emergencykit.GenerateHTML(moduleInput, language) if err != nil { - return nil, fmt.Errorf("GenerateEkHtml failed to render: %w", err) + return nil, errors.Errorf("GenerateEkHtml failed to render: %w", err) } // Create and serialize the metadata: metadata, err := CreateEmergencyKitMetadata(ekParams) if err != nil { - return nil, fmt.Errorf("GenerateEkHtml failed to create metadata: %w", err) + return nil, errors.Errorf("GenerateEkHtml failed to create metadata: %w", err) } metadataBytes, err := json.Marshal(&metadata) if err != nil { - return nil, fmt.Errorf("GenerateEkHtml failed to marshal %s: %w", string(metadataBytes), err) + return nil, errors.Errorf( + "GenerateEkHtml failed to marshal %s: %w", + string(metadataBytes), + err, + ) } output := &EKOutput{ @@ -83,7 +90,8 @@ func GenerateEmergencyKitHTML(ekParams *EKInput, language string) (*EKOutput, er // AddEmergencyKitMetadata produces a copy of the PDF file at `srcFile` with embedded metadata, // writing it into `dstFile`. The provided metadata must be the same opaque string produced by // `GenerateEmergencyKitHTML`. -// This is public because this is being consumed by the new architecture inside emergency_kit packages. +// This is public because this is being consumed by the new architecture inside +// emergency_kit packages. // TODO: execute the non-trivial refactor to migrate this inside emergency kit package func AddEmergencyKitMetadata(metadataText string, srcFile string, dstFile string) error { // Initialize the MetadataWriter: @@ -97,35 +105,35 @@ func AddEmergencyKitMetadata(metadataText string, srcFile string, dstFile string err := json.Unmarshal([]byte(metadataText), &metadata) if err != nil { - return fmt.Errorf("AddEkMetadata failed to unmarshal: %w", err) + return errors.Errorf("AddEkMetadata failed to unmarshal: %w", err) } err = metadataWriter.WriteMetadata(&metadata) if err != nil { - return fmt.Errorf("AddEkMetadata failed to write metadata: %w", err) + return errors.Errorf("AddEkMetadata failed to write metadata: %w", err) } return nil } -// This is public because this is being consumed by the new architecture inside emergency_kit packages. +// This is public because this is being consumed by the new architecture inside +// emergency_kit packages. // TODO: execute the non-trivial refactor to migrate this inside emergency kit package func CreateEmergencyKitMetadata(ekParams *EKInput) (*emergencykit.Metadata, error) { - // NOTE: - // This method would be more naturally placed in the `emergencykit` module, but given the current - // project structure (heavily determined by `gomobile` and the need for top-level bindings) and - // the use of `decodeEncryptedPrivateKey` this isn't possible. Instead, we peek through the layer - // boundary to craft the object here. + // NOTE: this method would be more naturally placed in the `emergencykit` module, but given + // the current project structure (heavily determined by `gomobile` and the need for + // top-level bindings) and the use of `decodeEncryptedPrivateKey` this isn't possible. + // Instead, we peek through the layer boundary to craft the object here. // Decode both keys, to extract their inner properties: firstKey, err := DecodeEncryptedPrivateKey(ekParams.FirstEncryptedKey) if err != nil { - return nil, fmt.Errorf("createEkMetadata failed to decode first key: %w", err) + return nil, errors.Errorf("createEkMetadata failed to decode first key: %w", err) } secondKey, err := DecodeEncryptedPrivateKey(ekParams.SecondEncryptedKey) if err != nil { - return nil, fmt.Errorf("createEkMetadata failed to decode second key: %w", err) + return nil, errors.Errorf("createEkMetadata failed to decode second key: %w", err) } // Obtain the list of checksumed output descriptors: diff --git a/libwallet/emergency_kit_test.go b/libwallet/emergency_kit_test.go index 3423426c..8d90797b 100755 --- a/libwallet/emergency_kit_test.go +++ b/libwallet/emergency_kit_test.go @@ -6,8 +6,8 @@ import ( func TestGenerateEmergencyKitHTML(t *testing.T) { _, err := GenerateEmergencyKitHTML(&EKInput{ - FirstEncryptedKey: "5zZPk5V7oJcXtQyFgdxrP6D5A4Xck2XMC2FG7rrxeDu89K4YuuMoAdZ2MeAGqMU28aR4Lsa5HRxB5mDXmajmYgLaZi6CivXeBRSzazJb8T4VizArrDA8NDH8TipEsHnwCyCd6eiNQYbedyRPw4B", - SecondEncryptedKey: "4RLVcRNPSdCcV5pdd6FsNuUzhGwp3h7piXhpDkHbF31PrHmNqsyMd9vRveXsBVsWPLXHvMkvhzk68yGw4Wwcxfz55yPeN5Jogqpmn7BQc7P1SNymwtgbatLiJfwqFLm1iqoLPobCmK6wH7MY9N7", + FirstEncryptedKey: "5zZPk5V7oJcXtQyFgdxrP6D5A4Xck2XMC2FG7rrxeDu89K4YuuMoAdZ2MeAGqMU28aR4Lsa5HRxB5mDXmajmYgLaZi6CivXeBRSzazJb8T4VizArrDA8NDH8TipEsHnwCyCd6eiNQYbedyRPw4B", //nolint:lll + SecondEncryptedKey: "4RLVcRNPSdCcV5pdd6FsNuUzhGwp3h7piXhpDkHbF31PrHmNqsyMd9vRveXsBVsWPLXHvMkvhzk68yGw4Wwcxfz55yPeN5Jogqpmn7BQc7P1SNymwtgbatLiJfwqFLm1iqoLPobCmK6wH7MY9N7", //nolint:lll }, "es") if err != nil { t.Fatal(err) diff --git a/libwallet/emergencykit/content.go b/libwallet/emergencykit/content.go index c61bb2c4..9df725b4 100644 --- a/libwallet/emergencykit/content.go +++ b/libwallet/emergencykit/content.go @@ -1,7 +1,7 @@ package emergencykit type pageData struct { - Css string + Css string //nolint:staticcheck // TODO: struct field Css should be CSS Content string } diff --git a/libwallet/emergencykit/descriptors.go b/libwallet/emergencykit/descriptors.go index dc5cc7d8..0b22d8ad 100644 --- a/libwallet/emergencykit/descriptors.go +++ b/libwallet/emergencykit/descriptors.go @@ -46,7 +46,8 @@ func GetDescriptorsHTML(data *DescriptorsData) string { html := descriptor - // Replace script type expressions (parenthesis in match prevent replacing the "sh" in "wsh") + // Replace script type expressions (parenthesis in match prevent replacing the "sh" in + // "wsh") html = strings.ReplaceAll(html, "wsh(", renderScriptType("wsh")+"(") html = strings.ReplaceAll(html, "sh(", renderScriptType("sh")+"(") html = strings.ReplaceAll(html, "multi(", renderScriptType("multi")+"(") @@ -54,8 +55,16 @@ func GetDescriptorsHTML(data *DescriptorsData) string { html = strings.ReplaceAll(html, "musig(", renderScriptType("musig")+"(") // Replace fingerprint expressions: - html = strings.ReplaceAll(html, data.FirstFingerprint, renderFingerprint(data.FirstFingerprint)) - html = strings.ReplaceAll(html, data.SecondFingerprint, renderFingerprint(data.SecondFingerprint)) + html = strings.ReplaceAll( + html, + data.FirstFingerprint, + renderFingerprint(data.FirstFingerprint), + ) + html = strings.ReplaceAll( + html, + data.SecondFingerprint, + renderFingerprint(data.SecondFingerprint), + ) // Add checksum and wrap everything: html += renderChecksum(checksum) @@ -98,20 +107,21 @@ func splitChecksum(descriptor string) (string, string) { } // ------------------------------------------------------------------------------------------------- -// WARNING: -// Below this point, you may find only fear and confusion. +// WARNING: Below this point, you may find only fear and confusion. // I translated the code for computing checksums from the original C++ in the bitcoind source, // making a few adjustments for language differences. It's a specialized algorithm for the domain of // output descriptors, and it uses the same primitives as the bech32 encoding. -var inputCharset = "0123456789()[],'/*abcdefgh@:$%{}IJKLMNOPQRSTUVWXYZ&+-.;<=>?!^_|~ijklmnopqrstuvwxyzABCDEFGH`#\"\\ " +var inputCharset = "0123456789()[],'/*abcdefgh@:$%{}" + //nolint:lll + "IJKLMNOPQRSTUVWXYZ&+-.;<=>?!^_|~" + + "ijklmnopqrstuvwxyzABCDEFGH`#\"\\ " var checksumCharset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" func calculateChecksum(desc string) string { var c uint64 = 1 - var cls int = 0 - var clscount int = 0 + var cls int = 0 //nolint:staticcheck // TODO: should omit type int from declaration; it will be inferred from the right-hand side + var clscount int = 0 //nolint:staticcheck // TODO: should omit type int from declaration; it will be inferred from the right-hand side for _, ch := range desc { pos := strings.IndexRune(inputCharset, ch) @@ -135,14 +145,14 @@ func calculateChecksum(desc string) string { c = polyMod(c, cls) } - for i := 0; i < 8; i++ { + for i := 0; i < 8; i++ { //nolint:modernize // TODO: use range over int c = polyMod(c, 0) } c ^= 1 ret := make([]byte, 8) - for i := 0; i < 8; i++ { + for i := 0; i < 8; i++ { //nolint:modernize // TODO: use range over int ret[i] = checksumCharset[(c>>(5*(7-i)))&31] } diff --git a/libwallet/emergencykit/descriptors_test.go b/libwallet/emergencykit/descriptors_test.go index a69ab45f..47d1c716 100644 --- a/libwallet/emergencykit/descriptors_test.go +++ b/libwallet/emergencykit/descriptors_test.go @@ -3,32 +3,102 @@ package emergencykit import "testing" func TestChecksum(t *testing.T) { - // These descriptors are in https://github.com/bitcoin/bitcoin/blob/master/doc/descriptors.md and - // their expected checksums obtained via the `getdescriptorinfo` RPC endpoint. Note that, to - // reproduce these results, you need a mainnet Bitcoin node (HD key parsing fails otherwise). + // These descriptors are in https://github.com/bitcoin/bitcoin/blob/master/doc/descriptors.md + // and their expected checksums obtained via the `getdescriptorinfo` RPC endpoint. + // Note that, to reproduce these results, you need a mainnet Bitcoin node + // (HD key parsing fails otherwise). - testChecksum(t, "gn28ywm7", "pk(0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798)") - testChecksum(t, "8fhd9pwu", "pkh(02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5)") - testChecksum(t, "8zl0zxma", "wpkh(02f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9)") - testChecksum(t, "qkrrc7je", "sh(wpkh(03fff97bd5755eeea420453a14355235d382f6472f8568a18b2f057a1460297556))") - testChecksum(t, "lq9sf04s", "combo(0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798)") - testChecksum(t, "2wtr0ej5", "sh(wsh(pkh(02e493dbf1c10d80f3581e4904930b1404cc6c13900ee0758474fa94abe8c4cd13)))") - testChecksum(t, "hzhjw406", "multi(1,022f8bde4d1a07209355b4a7250a5c5128e88b84bddc619ab7cba8d569b240efe4,025cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc)") - testChecksum(t, "y9zthqta", "sh(multi(2,022f01e5e15cca351daff3843fb70f3c2f0a1bdd05e5af888a67784ef3e10a2a01,03acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe))") - testChecksum(t, "en3tu306", "wsh(multi(2,03a0434d9e47f3c86235477c7b1ae6ae5d3442d49b1943c2b752a68e2a47e247c7,03774ae7f858a9411e5ef4246b70c65aac5649980be5c17891bbec17895da008cb,03d01115d548e7561b15c38f004d734633687cf4419620095bc5b0f47070afe85a))") - testChecksum(t, "ks05yr6p", "sh(wsh(multi(1,03f28773c2d975288bc7d1d205c3748651b075fbc6610e58cddeeddf8f19405aa8,03499fdf9e895e719cfd64e67f07d38e3226aa7b63678949e6e49b241a60e823e4,02d7924d4f7d43ea965a465ae3095ff41131e5946f3c85f79e44adbcf8e27e080e)))") - testChecksum(t, "qwx6n9lh", "sh(sortedmulti(2,03acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe,022f01e5e15cca351daff3843fb70f3c2f0a1bdd05e5af888a67784ef3e10a2a01))") - testChecksum(t, "axav5m0j", "pk(xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8)") - testChecksum(t, "kczqajcv", "pkh(xpub68Gmy5EdvgibQVfPdqkBBCHxA5htiqg55crXYuXoQRKfDBFA1WEjWgP6LHhwBZeNK1VTsfTFUHCdrfp1bgwQ9xv5ski8PX9rL2dZXvgGDnw/1/2)") - testChecksum(t, "ml40v0wf", "pkh([d34db33f/44'/0'/0']xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL/1/*)") - testChecksum(t, "t2zpj2eu", "wsh(multi(1,xpub661MyMwAqRbcFW31YEwpkMuc5THy2PSt5bDMsktWQcFF8syAmRUapSCGu8ED9W6oDMSgv6Zz8idoc4a6mr8BDzTJY47LJhkJ8UB7WEGuduB/1/0/*,xpub69H7F5d8KSRgmmdJg2KhpAK8SR3DjMwAdkxj3ZuxV27CprR9LgpeyGmXUbC6wb7ERfvrnKZjXoUmmDznezpbZb7ap6r1D3tgFxHmwMkQTPH/0/0/*))") - testChecksum(t, "v66cvalc", "wsh(sortedmulti(1,xpub661MyMwAqRbcFW31YEwpkMuc5THy2PSt5bDMsktWQcFF8syAmRUapSCGu8ED9W6oDMSgv6Zz8idoc4a6mr8BDzTJY47LJhkJ8UB7WEGuduB/1/0/*,xpub69H7F5d8KSRgmmdJg2KhpAK8SR3DjMwAdkxj3ZuxV27CprR9LgpeyGmXUbC6wb7ERfvrnKZjXoUmmDznezpbZb7ap6r1D3tgFxHmwMkQTPH/0/0/*))") + testChecksum( + t, + "gn28ywm7", + "pk(0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798)", + ) + testChecksum( + t, + "8fhd9pwu", + "pkh(02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5)", + ) + testChecksum( + t, + "8zl0zxma", + "wpkh(02f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9)", + ) + testChecksum( + t, + "qkrrc7je", + "sh(wpkh(03fff97bd5755eeea420453a14355235d382f6472f8568a18b2f057a1460297556))", + ) + testChecksum( + t, + "lq9sf04s", + "combo(0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798)", + ) + testChecksum( + t, + "2wtr0ej5", + "sh(wsh(pkh(02e493dbf1c10d80f3581e4904930b1404cc6c13900ee0758474fa94abe8c4cd13)))", + ) + testChecksum( + t, + "hzhjw406", + "multi(1,022f8bde4d1a07209355b4a7250a5c5128e88b84bddc619ab7cba8d569b240efe4,025cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc)", //nolint:lll + ) + testChecksum( + t, + "y9zthqta", + "sh(multi(2,022f01e5e15cca351daff3843fb70f3c2f0a1bdd05e5af888a67784ef3e10a2a01,03acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe))", //nolint:lll + ) + testChecksum( + t, + "en3tu306", + "wsh(multi(2,03a0434d9e47f3c86235477c7b1ae6ae5d3442d49b1943c2b752a68e2a47e247c7,03774ae7f858a9411e5ef4246b70c65aac5649980be5c17891bbec17895da008cb,03d01115d548e7561b15c38f004d734633687cf4419620095bc5b0f47070afe85a))", //nolint:lll + ) + testChecksum( + t, + "ks05yr6p", + "sh(wsh(multi(1,03f28773c2d975288bc7d1d205c3748651b075fbc6610e58cddeeddf8f19405aa8,03499fdf9e895e719cfd64e67f07d38e3226aa7b63678949e6e49b241a60e823e4,02d7924d4f7d43ea965a465ae3095ff41131e5946f3c85f79e44adbcf8e27e080e)))", //nolint:lll + ) + testChecksum( + t, + "qwx6n9lh", + "sh(sortedmulti(2,03acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe,022f01e5e15cca351daff3843fb70f3c2f0a1bdd05e5af888a67784ef3e10a2a01))", //nolint:lll + ) + testChecksum( + t, + "axav5m0j", + "pk(xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8)", //nolint:lll + ) + testChecksum( + t, + "kczqajcv", + "pkh(xpub68Gmy5EdvgibQVfPdqkBBCHxA5htiqg55crXYuXoQRKfDBFA1WEjWgP6LHhwBZeNK1VTsfTFUHCdrfp1bgwQ9xv5ski8PX9rL2dZXvgGDnw/1/2)", //nolint:lll + ) + testChecksum( + t, + "ml40v0wf", + "pkh([d34db33f/44'/0'/0']xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL/1/*)", //nolint:lll + ) + testChecksum( + t, + "t2zpj2eu", + "wsh(multi(1,xpub661MyMwAqRbcFW31YEwpkMuc5THy2PSt5bDMsktWQcFF8syAmRUapSCGu8ED9W6oDMSgv6Zz8idoc4a6mr8BDzTJY47LJhkJ8UB7WEGuduB/1/0/*,xpub69H7F5d8KSRgmmdJg2KhpAK8SR3DjMwAdkxj3ZuxV27CprR9LgpeyGmXUbC6wb7ERfvrnKZjXoUmmDznezpbZb7ap6r1D3tgFxHmwMkQTPH/0/0/*))", //nolint:lll + ) + testChecksum( + t, + "v66cvalc", + "wsh(sortedmulti(1,xpub661MyMwAqRbcFW31YEwpkMuc5THy2PSt5bDMsktWQcFF8syAmRUapSCGu8ED9W6oDMSgv6Zz8idoc4a6mr8BDzTJY47LJhkJ8UB7WEGuduB/1/0/*,xpub69H7F5d8KSRgmmdJg2KhpAK8SR3DjMwAdkxj3ZuxV27CprR9LgpeyGmXUbC6wb7ERfvrnKZjXoUmmDznezpbZb7ap6r1D3tgFxHmwMkQTPH/0/0/*))", //nolint:lll + ) } func testChecksum(t *testing.T, expectedChecksum string, descriptor string) { actualChecksum := calculateChecksum(descriptor) if actualChecksum != expectedChecksum { - t.Errorf("Descriptor %s checksum was %s expecting %s", descriptor, actualChecksum, expectedChecksum) + t.Errorf( + "Descriptor %s checksum was %s expecting %s", + descriptor, + actualChecksum, + expectedChecksum, + ) } } diff --git a/libwallet/emergencykit/emergencykit.go b/libwallet/emergencykit/emergencykit.go index 1d962bba..067685ff 100644 --- a/libwallet/emergencykit/emergencykit.go +++ b/libwallet/emergencykit/emergencykit.go @@ -3,11 +3,13 @@ package emergencykit import ( "bytes" "crypto/sha256" - "fmt" - "github.com/muun/libwallet/data/emergency_kit/resources" "strconv" "text/template" "time" + + "github.com/go-errors/errors" + + "github.com/muun/libwallet/data/emergency_kit/resources" ) // Input struct to fill the PDF @@ -25,7 +27,8 @@ type Output struct { VerificationCode string } -// GenerateHTML returns the translated emergency kit html as a string along with the verification code. +// GenerateHTML returns the translated emergency kit html as a string along with the verification +// code. func GenerateHTML(params *Input, lang string) (*Output, error) { verificationCode := GenerateDeterministicCode(params) @@ -55,7 +58,7 @@ func GenerateHTML(params *Input, lang string) (*Output, error) { IconPadlock: iconPadlock, }) if err != nil { - return nil, fmt.Errorf("failed to render EmergencyKitContent template: %w", err) + return nil, errors.Errorf("failed to render EmergencyKitContent template: %w", err) } // Render complete HTML page: @@ -64,7 +67,7 @@ func GenerateHTML(params *Input, lang string) (*Output, error) { Content: content, }) if err != nil { - return nil, fmt.Errorf("failed to render EmergencyKitPage template: %w", err) + return nil, errors.Errorf("failed to render EmergencyKitPage template: %w", err) } return &Output{ @@ -73,14 +76,15 @@ func GenerateHTML(params *Input, lang string) (*Output, error) { }, nil } - func GenerateDeterministicCode(params *Input) string { // NOTE: - // This function creates a stable verification code given the inputs to render the Emergency Kit. For now, the - // implementation relies exclusively on the SecondEncryptedKey, which is the Muun key. This is obviously not ideal, - // since we're both dropping part of the input and introducing the assumption that the Muun key will always be - // rendered second -- but it compensates for a problem with one of our clients that causes the user key serialization - // to be recreated each time the kit is rendered (making this deterministic approach useless). + // This function creates a stable verification code given the inputs to render the + // Emergency Kit. For now, the implementation relies exclusively on the SecondEncryptedKey, + // which is the Muun key. This is obviously not ideal, since we're both dropping part of the + // input and introducing the assumption that the Muun key will always be rendered second -- + // but it compensates for a problem with one of our clients that causes the user key + // serialization to be recreated each time the kit is rendered (making this deterministic + // approach useless). // Create a deterministic serialization of the input: inputMaterial := params.SecondEncryptedKey + strconv.Itoa(params.Version) @@ -91,13 +95,16 @@ func GenerateDeterministicCode(params *Input) string { // Extract a verification code from the hash (doesn't matter if we discard bytes): var code string for _, b := range inputHash[:6] { - code += strconv.Itoa(int(b) % 10) + code += strconv.Itoa(int(b) % 10) //nolint:modernize // TODO: use strings.Builder } return code } -func render(name, language string, data interface{}) (string, error) { +func render( + name, language string, + data interface{}, //nolint:modernize // TODO: use any instead of interface{} +) (string, error) { tmpl, err := template.New(name).Parse(getContent(name, language)) if err != nil { return "", err diff --git a/libwallet/emergencykit/metadata.go b/libwallet/emergencykit/metadata.go index a314ca97..c8cdae1b 100644 --- a/libwallet/emergencykit/metadata.go +++ b/libwallet/emergencykit/metadata.go @@ -2,10 +2,10 @@ package emergencykit import ( "encoding/json" - "fmt" "os" "path/filepath" + "github.com/go-errors/errors" "github.com/pdfcpu/pdfcpu/pkg/api" "github.com/pdfcpu/pdfcpu/pkg/pdfcpu" ) @@ -57,7 +57,7 @@ var pdfConfig = &pdfcpu.Configuration{ func (mr *MetadataReader) HasMetadata() (bool, error) { fs, err := api.ListAttachmentsFile(mr.SrcFile, pdfConfig) if err != nil { - return false, fmt.Errorf("HasMetadata failed to list attachments: %w", err) + return false, errors.Errorf("HasMetadata failed to list attachments: %w", err) } return len(fs) == 1 && fs[0] == metadataName, nil @@ -66,44 +66,52 @@ func (mr *MetadataReader) HasMetadata() (bool, error) { // ReadMetadata returns the deserialized metadata file embedded in the SrcFile PDF. func (mr *MetadataReader) ReadMetadata() (*Metadata, error) { // NOTE: - // Due to library constraints, this makes use of a temporary directory in the default system temp - // location, which for the Recovery Tool will always be accessible. If we eventually want to read - // this metadata in mobile clients, we'll need the caller to provide a directory. + // Due to library constraints, this makes use of a temporary directory in the default + // system temp location, which for the Recovery Tool will always be accessible. If we + // eventually want to read this metadata in mobile clients, we'll need the caller to + // provide a directory. // Before we begin, verify that the metadata file is embedded: hasMetadata, err := mr.HasMetadata() if err != nil { - return nil, fmt.Errorf("ReadMetadata failed to check for existence: %w", err) + return nil, errors.Errorf("ReadMetadata failed to check for existence: %w", err) } if !hasMetadata { - return nil, fmt.Errorf("ReadMetadata didn't find %s (or found more) in this PDF", metadataName) + return nil, errors.Errorf( + "ReadMetadata didn't find %s (or found more) in this PDF", + metadataName, + ) } // Create the temporary directory, with a deferred call to clean up: tmpDir, err := os.MkdirTemp("", "ek-metadata-*") if err != nil { - return nil, fmt.Errorf("ReadMetadata failed to create a temporary directory") + return nil, errors.Errorf("ReadMetadata failed to create a temporary directory") } - defer os.RemoveAll(tmpDir) + defer os.RemoveAll(tmpDir) //nolint:errcheck // TODO: check error // Extract the embedded attachment from the PDF into that directory: err = api.ExtractAttachmentsFile(mr.SrcFile, tmpDir, []string{metadataName}, pdfConfig) if err != nil { - return nil, fmt.Errorf("ReadMetadata failed to extract attachment: %w", err) + return nil, errors.Errorf("ReadMetadata failed to extract attachment: %w", err) } // Read the contents of the file: metadataBytes, err := os.ReadFile(filepath.Join(tmpDir, metadataName)) if err != nil { - return nil, fmt.Errorf("ReadMetadata failed to read the extracted file: %w", err) + return nil, errors.Errorf("ReadMetadata failed to read the extracted file: %w", err) } // Deserialize the metadata: var metadata Metadata err = json.Unmarshal(metadataBytes, &metadata) if err != nil { - return nil, fmt.Errorf("ReadMetadata failed to unmarshal %s: %w", string(metadataBytes), err) + return nil, errors.Errorf( + "ReadMetadata failed to unmarshal %s: %w", + string(metadataBytes), + err, + ) } // Done we are! @@ -113,9 +121,9 @@ func (mr *MetadataReader) ReadMetadata() (*Metadata, error) { // WriteMetadata creates a copy of SrcFile with attached JSON metadata into DstFile. func (mw *MetadataWriter) WriteMetadata(metadata *Metadata) error { // NOTE: - // Due to library constraints, this makes use of a temporary file placed in the same directory as - // `SrcFile`, which is assumed to be writable. This is a much safer bet than attempting to pick a - // location for temporary files ourselves. + // Due to library constraints, this makes use of a temporary file placed in the same + // directory as `SrcFile`, which is assumed to be writable. This is a much safer bet + // than attempting to pick a location for temporary files ourselves. // Decide the location of the temporary file: srcDir := filepath.Dir(mw.SrcFile) @@ -124,21 +132,21 @@ func (mw *MetadataWriter) WriteMetadata(metadata *Metadata) error { // Serialize the metadata: metadataBytes, err := json.Marshal(metadata) if err != nil { - return fmt.Errorf("WriteMetadata failed to marshal: %w", err) + return errors.Errorf("WriteMetadata failed to marshal: %w", err) } // Write to the temporary file, with a deferred call to clean up: err = os.WriteFile(tmpFile, metadataBytes, os.FileMode(0600)) if err != nil { - return fmt.Errorf("WriteMetadata failed to write a temporary file: %w", err) + return errors.Errorf("WriteMetadata failed to write a temporary file: %w", err) } - defer os.Remove(tmpFile) + defer os.Remove(tmpFile) //nolint:errcheck // TODO: check error // Add the attachment, returning potential errors: err = api.AddAttachmentsFile(mw.SrcFile, mw.DstFile, []string{tmpFile}, false, pdfConfig) if err != nil { - return fmt.Errorf("WriteMetadata failed to add attachment file %s: %w", tmpFile, err) + return errors.Errorf("WriteMetadata failed to add attachment file %s: %w", tmpFile, err) } return nil diff --git a/libwallet/emergencykit/metadata_test.go b/libwallet/emergencykit/metadata_test.go index 24c072ca..c0bea0ea 100644 --- a/libwallet/emergencykit/metadata_test.go +++ b/libwallet/emergencykit/metadata_test.go @@ -14,9 +14,9 @@ var someMetadata = Metadata{ BirthdayBlock: 12345, EncryptedKeys: []*MetadataKey{ - &MetadataKey{ + { DhPubKey: "0338c52ecbb886ab45de31120c76888da73437e3d6e81510f56d3746399f0fef52", - EncryptedPrivKey: "d0a801c1923663295892e9a9a0bfc770abcb00c20e7cef28e2d743c96b441e677c875e8d6495afb8362aba886ae9ee346c62e82758f5b5ba9a70f61957529255", + EncryptedPrivKey: "d0a801c1923663295892e9a9a0bfc770abcb00c20e7cef28e2d743c96b441e677c875e8d6495afb8362aba886ae9ee346c62e82758f5b5ba9a70f61957529255", //nolint:lll Salt: "d579c14c61365bc0", }, }, diff --git a/libwallet/encrypt.go b/libwallet/encrypt.go index acf1035b..6a9745db 100644 --- a/libwallet/encrypt.go +++ b/libwallet/encrypt.go @@ -3,9 +3,10 @@ package libwallet import ( "crypto/aes" "crypto/rand" - "fmt" "github.com/btcsuite/btcd/btcec/v2" + "github.com/go-errors/errors" + "github.com/muun/libwallet/aescbc" "github.com/muun/libwallet/encryption" ) @@ -13,7 +14,7 @@ import ( const serializedPublicKeyLength = btcec.PubKeyBytesLenCompressed type Encrypter interface { - // Encrypt the payload and return a string with the necesary information for decryption + // Encrypt the payload and return a string with the necessary information for decryption Encrypt(payload []byte) (string, error) } @@ -30,7 +31,10 @@ var _ Decrypter = (*encryption.HdPrivKeyDecrypter)(nil) // encryptWithPubKey encrypts a message using a pubKey // It uses ECDHE/AES/CBC leaving padding up to the caller. -func encryptWithPubKey(pubKey *btcec.PublicKey, plaintext []byte) (*btcec.PublicKey, []byte, error) { +func encryptWithPubKey( + pubKey *btcec.PublicKey, + plaintext []byte, +) (*btcec.PublicKey, []byte, error) { // Use deprecated ECDH for compat pubEph, sharedSecret, err := encryption.GenerateSharedEncryptionSecret(pubKey) if err != nil { @@ -41,7 +45,7 @@ func encryptWithPubKey(pubKey *btcec.PublicKey, plaintext []byte) (*btcec.Public ciphertext, err := aescbc.EncryptNoPadding(sharedSecret, iv, plaintext) if err != nil { - return nil, nil, fmt.Errorf("encryptWithPubKey: encrypt failed: %w", err) + return nil, nil, errors.Errorf("encryptWithPubKey: encrypt failed: %w", err) } return pubEph, ciphertext, nil @@ -49,7 +53,11 @@ func encryptWithPubKey(pubKey *btcec.PublicKey, plaintext []byte) (*btcec.Public // decryptWithPrivKey decrypts a message encrypted to a pubKey using the corresponding privKey // It uses ECDHE/AES/CBC leaving padding up to the caller. -func decryptWithPrivKey(privKey *btcec.PrivateKey, rawPubEph []byte, ciphertext []byte) ([]byte, error) { +func decryptWithPrivKey( + privKey *btcec.PrivateKey, + rawPubEph []byte, + ciphertext []byte, +) ([]byte, error) { // Use deprecated ECDH for compat sharedSecret, err := encryption.RecoverSharedEncryptionSecret(privKey, rawPubEph) if err != nil { @@ -60,7 +68,7 @@ func decryptWithPrivKey(privKey *btcec.PrivateKey, rawPubEph []byte, ciphertext plaintext, err := aescbc.DecryptNoPadding(sharedSecret, iv, ciphertext) if err != nil { - return nil, fmt.Errorf("decryptWithPrivKey: failed to decrypt: %w", err) + return nil, errors.Errorf("decryptWithPrivKey: failed to decrypt: %w", err) } return plaintext, nil @@ -86,7 +94,12 @@ type DecryptOperation struct { func NewDecryptOperation(key *HDPrivateKey, payload string) *DecryptOperation { return &DecryptOperation{key.Decrypter(), payload} } -func NewDecryptOperationFrom(sender *PublicKey, key *HDPrivateKey, payload string) *DecryptOperation { + +func NewDecryptOperationFrom( + sender *PublicKey, + key *HDPrivateKey, + payload string, +) *DecryptOperation { return &DecryptOperation{key.DecrypterFrom(sender), payload} } diff --git a/libwallet/encrypt_test.go b/libwallet/encrypt_test.go index 6bff5bd2..7c250812 100644 --- a/libwallet/encrypt_test.go +++ b/libwallet/encrypt_test.go @@ -74,7 +74,7 @@ func TestPublicKeyEncryption(t *testing.T) { func TestEncDecOps(t *testing.T) { const ( - privHex = "xprv9s21ZrQH143K36uECEJcmTnxSXfHjT9jdb7FpMoUJpENDxeRgpscDF3g2w4ySH6G9uVsGKK7e6WgGp7Vc9VVnwC2oWdrr7a3taWiKW8jKnD" + privHex = "xprv9s21ZrQH143K36uECEJcmTnxSXfHjT9jdb7FpMoUJpENDxeRgpscDF3g2w4ySH6G9uVsGKK7e6WgGp7Vc9VVnwC2oWdrr7a3taWiKW8jKnD" //nolint:lll path = "m" pathLen = 1 ) @@ -95,9 +95,9 @@ func TestEncDecOps(t *testing.T) { func TestEncryptedMetadataImplicitHardenedDerivationBug(t *testing.T) { const ( - encryptedMetadata = "57t61UGHbFPyQdauas7E8nYoZU5hVB1f5YveFYS5mTbXeJuYDXikVunjiL3wFi3upuQ3pgHLUrsQpjWfWUPEH7Fq3AHTUmsA24nSLV6cJwwZRoM2gZofQ86qmsr2TBvdzpifppj8JjXahaVYwnBFUzDs1L3zr1XabCJ9fFigetkmWt5vzq5uzWdSv6dK3W5H7T3aWqkYU9is4AsMUuQFCjMgRBTU1UsMPvctLMNCAhe7Frjs6vCYf1eo9XQM44UYyEoLFdNjyDfmsXaCWR3ZbB11wLcUqr8K1UDX2cZ2hz1o91S82fXmBusMnprvteri8TiPxGJ5AEwABUMj725resLwmc5AxBUBc7PamVbC4pqKVjHDGVWTDurHb3MjLqq4kPEM7bf4P5S7cny9Ans63mQnkTWvooxYYJvsQJ7PLFdb1kpYcb4V1QNFtvEfHHjE9x8DckkiANhkqBxqVR6wCzmyEU8gSFgjG3JVtZDgNZhUTBtd2CZQLrXum4YhaEV1VmTVECCk3AZAzBvrPpjwz5zgasMHLdRSZLuGWC8XppjS8xHHjYbLdkQsxZCwZZiuxiLV9zcohEb2uchMpQmXgsgjuHGzDwcjr8e8PttvGQuvay62SmBgwsyYMWiW9B3PLny1c2URsGPAN4Uwg5ycXw83CXZ6oNubhFCjRzxw4ddXqUCqBskShG7AXETsQAkXUifD7GpcXfEEyxMgar5NTx9xQ2qebcVTbGaeWa6vvXTrhoE8UusxwA5C2Kq1M4F6E4w1tmj8YPi6LiLQtyiVpJVy3xQ2D3weNTt3JTArKHiWhURuPwpuuhdhJmaCapDhewPp83TJ8RjATaKx4ahJhQAjn1ZyXVZoi87UdwgLqWD5wB14ADdZBfN81uhArrFeq1QJ6WRebSeUNwk8G38j3cUSSLwizxmt9JKTrEXkP63QroQo4yM4ibqSo8DZ6b81i6BikjYCwtsWrxnLaPUs6xEi2Qmn2B64HULCHmqHAfjUxZ9F2TutHpMbA83kWjeSL59ZGbEUSzkj8CMir34HiHk1184fPAXww2YkmvaSjxa5QUPcNa1CQySoN88Arm8E4t72MTSCipBjYzgF7yzF4V8GwroAwSkuQ92T8PTgaSEvVxpoLNyiBPyxMtvXpJfiEw2kUEG9EL63MmEGf52NGH5ZDwVNicYE3Wfi1dRHGTcAapw5PNxMtekteo2NbaqUeDN8z5DRXRbpbVvo7ArLZCyt7FVeypRyA7bLCqGvDt3jWuN3ovvpdjRmsJLzgE4xg3oNCtMYhvAyDXAfPNNwAY6QAT9xwxztUB6vGWfVs1YJKBLrn866HU4TTzz" - encodedXpriv = "xprv9s21ZrQH143K2B5wwtaARqgJa9XJMFnxK3AmZT8EsYZj3MTNBvuPBGR8eDsmDvCN75Znebqf2eEJz6mJHHQzkmNs7t2FAAQCeb4hFd8HDG1" - expectedPlaintextBase64 = "W8lXYVt0oLHqeraGOIzqI+oqaMvbqTR0K2kKLAyv8/3iydwkP7dyTKBJU63YLC85jCFlcxPGfg9Rp0WlW/snvU0E9278WizintlaUF5Z55i9TbOQmsd6m4Pr0m1qMX9/fz0pruL7ryjnWCRDk/0Nr6nlF8SfatvG6Pl+pZ7GbocJJ5t0nYVjI11NtD2VWePfFofViSr/NMT56UUWb9D8BT9W9l6Zt3r6qiEnDCrMCjV4OSwFGWtzDSwQj9Zehr2YZMImi1VayZnkj1UsOFR4Nr69NwKGaDgytLIHWucw4EMCHR2xWF2whM7F9SLOp4iM42l4S5Mh5K8CdOeJg0rDg1/2G16JkLXzSS4yVFfUgR1nxr890CvOH5mEnatyY2ImwEOFTGaHmQeJAcLm8o4W5I3R9ePovkeYHb6yKP7sZdKBb7Z2nJU+VrUsiQzO2hJ3z2yzoqALbFCx/tRSdYh403M33n/SH4+9gpkzYx+eYBmCtykNlphzkh9SLzaV3OdpeXjckDRgQaocdAL4ZGdsjRF8qWd52c+H7iVgAps6ZooEY4axSAN0ATfem+UL8QJDzLJP1/PVz9pWwD1Hmw1IaqYn/z6ZkdF4SuiZioZmlXbhGf24qgfmh+yiRq+ITrn5u0hqwreFR0QC7JDU59SK5XmzeUyPAdK264WNwkendAxM58PdK9onVfFa3qKl3FMwU4y4LyUIt+lmPugXJJTbqHAvkT1ZebuBmxsoQ+oDYTYqqwcUdTNA+NH89s4HTQogD9tCzjF7Fmpr5gG0+G1J6Rldr0nKeP9OKCJkWmvuBgX0W/7Yn90vCgRQuoHAZHTolJLvgJLEQbF0Cp53JtmVhIg0UmSwHvjqfeYSEQe3bvOJ66GWyhRWaOvWtDhbjdZNiMPK4M8XArGRQyLBoDpTdi9aZcOoOc6LqfC+mUJ8EBrrlyYvARAuiZvP6/d/KUC3oMiS98ayhAt5EU6qMHhwy4/qHPu/nS274/OnGkbvblR+nHhW+dzpxHLdgVlLjyTinJCIa/2CUj8XzpT8oMjfhrAEDFndAK5jLNfCVbU=" + encryptedMetadata = "57t61UGHbFPyQdauas7E8nYoZU5hVB1f5YveFYS5mTbXeJuYDXikVunjiL3wFi3upuQ3pgHLUrsQpjWfWUPEH7Fq3AHTUmsA24nSLV6cJwwZRoM2gZofQ86qmsr2TBvdzpifppj8JjXahaVYwnBFUzDs1L3zr1XabCJ9fFigetkmWt5vzq5uzWdSv6dK3W5H7T3aWqkYU9is4AsMUuQFCjMgRBTU1UsMPvctLMNCAhe7Frjs6vCYf1eo9XQM44UYyEoLFdNjyDfmsXaCWR3ZbB11wLcUqr8K1UDX2cZ2hz1o91S82fXmBusMnprvteri8TiPxGJ5AEwABUMj725resLwmc5AxBUBc7PamVbC4pqKVjHDGVWTDurHb3MjLqq4kPEM7bf4P5S7cny9Ans63mQnkTWvooxYYJvsQJ7PLFdb1kpYcb4V1QNFtvEfHHjE9x8DckkiANhkqBxqVR6wCzmyEU8gSFgjG3JVtZDgNZhUTBtd2CZQLrXum4YhaEV1VmTVECCk3AZAzBvrPpjwz5zgasMHLdRSZLuGWC8XppjS8xHHjYbLdkQsxZCwZZiuxiLV9zcohEb2uchMpQmXgsgjuHGzDwcjr8e8PttvGQuvay62SmBgwsyYMWiW9B3PLny1c2URsGPAN4Uwg5ycXw83CXZ6oNubhFCjRzxw4ddXqUCqBskShG7AXETsQAkXUifD7GpcXfEEyxMgar5NTx9xQ2qebcVTbGaeWa6vvXTrhoE8UusxwA5C2Kq1M4F6E4w1tmj8YPi6LiLQtyiVpJVy3xQ2D3weNTt3JTArKHiWhURuPwpuuhdhJmaCapDhewPp83TJ8RjATaKx4ahJhQAjn1ZyXVZoi87UdwgLqWD5wB14ADdZBfN81uhArrFeq1QJ6WRebSeUNwk8G38j3cUSSLwizxmt9JKTrEXkP63QroQo4yM4ibqSo8DZ6b81i6BikjYCwtsWrxnLaPUs6xEi2Qmn2B64HULCHmqHAfjUxZ9F2TutHpMbA83kWjeSL59ZGbEUSzkj8CMir34HiHk1184fPAXww2YkmvaSjxa5QUPcNa1CQySoN88Arm8E4t72MTSCipBjYzgF7yzF4V8GwroAwSkuQ92T8PTgaSEvVxpoLNyiBPyxMtvXpJfiEw2kUEG9EL63MmEGf52NGH5ZDwVNicYE3Wfi1dRHGTcAapw5PNxMtekteo2NbaqUeDN8z5DRXRbpbVvo7ArLZCyt7FVeypRyA7bLCqGvDt3jWuN3ovvpdjRmsJLzgE4xg3oNCtMYhvAyDXAfPNNwAY6QAT9xwxztUB6vGWfVs1YJKBLrn866HU4TTzz" //nolint:lll + encodedXpriv = "xprv9s21ZrQH143K2B5wwtaARqgJa9XJMFnxK3AmZT8EsYZj3MTNBvuPBGR8eDsmDvCN75Znebqf2eEJz6mJHHQzkmNs7t2FAAQCeb4hFd8HDG1" //nolint:lll + expectedPlaintextBase64 = "W8lXYVt0oLHqeraGOIzqI+oqaMvbqTR0K2kKLAyv8/3iydwkP7dyTKBJU63YLC85jCFlcxPGfg9Rp0WlW/snvU0E9278WizintlaUF5Z55i9TbOQmsd6m4Pr0m1qMX9/fz0pruL7ryjnWCRDk/0Nr6nlF8SfatvG6Pl+pZ7GbocJJ5t0nYVjI11NtD2VWePfFofViSr/NMT56UUWb9D8BT9W9l6Zt3r6qiEnDCrMCjV4OSwFGWtzDSwQj9Zehr2YZMImi1VayZnkj1UsOFR4Nr69NwKGaDgytLIHWucw4EMCHR2xWF2whM7F9SLOp4iM42l4S5Mh5K8CdOeJg0rDg1/2G16JkLXzSS4yVFfUgR1nxr890CvOH5mEnatyY2ImwEOFTGaHmQeJAcLm8o4W5I3R9ePovkeYHb6yKP7sZdKBb7Z2nJU+VrUsiQzO2hJ3z2yzoqALbFCx/tRSdYh403M33n/SH4+9gpkzYx+eYBmCtykNlphzkh9SLzaV3OdpeXjckDRgQaocdAL4ZGdsjRF8qWd52c+H7iVgAps6ZooEY4axSAN0ATfem+UL8QJDzLJP1/PVz9pWwD1Hmw1IaqYn/z6ZkdF4SuiZioZmlXbhGf24qgfmh+yiRq+ITrn5u0hqwreFR0QC7JDU59SK5XmzeUyPAdK264WNwkendAxM58PdK9onVfFa3qKl3FMwU4y4LyUIt+lmPugXJJTbqHAvkT1ZebuBmxsoQ+oDYTYqqwcUdTNA+NH89s4HTQogD9tCzjF7Fmpr5gG0+G1J6Rldr0nKeP9OKCJkWmvuBgX0W/7Yn90vCgRQuoHAZHTolJLvgJLEQbF0Cp53JtmVhIg0UmSwHvjqfeYSEQe3bvOJ66GWyhRWaOvWtDhbjdZNiMPK4M8XArGRQyLBoDpTdi9aZcOoOc6LqfC+mUJ8EBrrlyYvARAuiZvP6/d/KUC3oMiS98ayhAt5EU6qMHhwy4/qHPu/nS274/OnGkbvblR+nHhW+dzpxHLdgVlLjyTinJCIa/2CUj8XzpT8oMjfhrAEDFndAK5jLNfCVbU=" //nolint:lll ) expectedPlaintext, err := base64.StdEncoding.DecodeString(expectedPlaintextBase64) if err != nil { diff --git a/libwallet/encryption/cypher.go b/libwallet/encryption/cypher.go index fd9fbaeb..35deb769 100644 --- a/libwallet/encryption/cypher.go +++ b/libwallet/encryption/cypher.go @@ -7,13 +7,13 @@ import ( "crypto/rand" "crypto/sha256" "encoding/binary" - "errors" - "fmt" "log/slog" "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcec/v2/ecdsa" "github.com/btcsuite/btcd/btcutil/base58" + "github.com/go-errors/errors" + "github.com/muun/libwallet/hdpath" ) @@ -32,7 +32,8 @@ const maxDerivationPathLen = 1000 // maxSignatureLen is a safety limit to avoid giant allocations const maxSignatureLen = 200 -// minNonceLen is the safe minimum we'll set for the nonce. This is the default for golang, but it's not exposed. +// minNonceLen is the safe minimum we'll set for the nonce. This is the default for golang, but it's +// not exposed. const minNonceLen = 12 type HdPubKeyEncrypter struct { @@ -42,7 +43,8 @@ type HdPubKeyEncrypter struct { } func (e *HdPubKeyEncrypter) Encrypt(payload []byte) (string, error) { - // Uses AES128-GCM with associated data. ECDHE is used for key exchange and ECDSA for authentication. + // Uses AES128-GCM with associated data. ECDHE is used for key exchange + // and ECDSA for authentication. // The goal is to be able to send an arbitrary message to a 3rd party or our future selves via // an intermediary which has knowledge of public keys for all parties involved. // @@ -55,13 +57,14 @@ func (e *HdPubKeyEncrypter) Encrypt(payload []byte) (string, error) { // * The derivation path for his pub key // * The ephemeral key used for ECDH // * The version code of this scheme - // 5. HMAC the encrypted payload and the metadata so the receiver can check it hasn't been tampered + // 5. HMAC the encrypted payload and the metadata so the receiver can + // check it hasn't been tampered // 6. Add the nonce to the payload so the receiver can actually decrypt the message. // The nonce can't be covered by the HMAC since it's used to generate it. // 7. Profit! // - // The implementation actually use an AES128-GCM with is an AEAD, so the encryption and HMAC all happen - // at the same time. + // The implementation actually use an AES128-GCM with is an AEAD, so the encryption and HMAC all + // happen at the same time. signingKey := e.SenderKey encryptionKey := e.ReceiverKey @@ -73,40 +76,40 @@ func (e *HdPubKeyEncrypter) Encrypt(payload []byte) (string, error) { hash := sha256.Sum256(signaturePayload) senderSignature, err := ecdsa.SignCompact(signingKey, hash[:], false) if err != nil { - return "", fmt.Errorf("Encrypt: failed to sign payload: %w", err) + return "", errors.Errorf("Encrypt: failed to sign payload: %w", err) } // plaintext is "senderSignature || payload" plaintext := bytes.NewBuffer(make([]byte, 0, 2+len(payload)+2+len(senderSignature))) err = addVariableBytes(plaintext, senderSignature) if err != nil { - return "", fmt.Errorf("Encrypt: failed to add senderSignature: %w", err) + return "", errors.Errorf("Encrypt: failed to add senderSignature: %w", err) } err = addVariableBytes(plaintext, payload) if err != nil { - return "", fmt.Errorf("Encrypt: failed to add payload: %w", err) + return "", errors.Errorf("Encrypt: failed to add payload: %w", err) } pubEph, sharedSecret, err := GenerateSharedEncryptionSecretForAES(encryptionKey) if err != nil { - return "", fmt.Errorf("Encrypt: failed to generate shared encryption key: %w", err) + return "", errors.Errorf("Encrypt: failed to generate shared encryption key: %w", err) } blockCipher, err := aes.NewCipher(sharedSecret) if err != nil { - return "", fmt.Errorf("Encrypt: new aes failed: %w", err) + return "", errors.Errorf("Encrypt: new aes failed: %w", err) } gcm, err := cipher.NewGCM(blockCipher) if err != nil { - return "", fmt.Errorf("Encrypt: new gcm failed: %w", err) + return "", errors.Errorf("Encrypt: new gcm failed: %w", err) } nonce := make([]byte, gcm.NonceSize()) _, err = rand.Read(nonce) if err != nil { - return "", fmt.Errorf("Encrypt: failed to generate nonce: %w", err) + return "", errors.Errorf("Encrypt: failed to generate nonce: %w", err) } // additionalData is "version || pubEph || ReceiverKeyPath || nonceLen" @@ -117,13 +120,13 @@ func (e *HdPubKeyEncrypter) Encrypt(payload []byte) (string, error) { err = addVariableBytes(result, []byte(e.ReceiverKeyPath)) if err != nil { - return "", fmt.Errorf("Encrypt: failed to add receiver path: %w", err) + return "", errors.Errorf("Encrypt: failed to add receiver path: %w", err) } nonceLen := uint16(len(nonce)) err = binary.Write(result, binary.BigEndian, &nonceLen) if err != nil { - return "", fmt.Errorf("Encrypt: failed to add nonce len: %w", err) + return "", errors.Errorf("Encrypt: failed to add nonce len: %w", err) } ciphertext := gcm.Seal(nil, nonce, plaintext.Bytes(), result.Bytes()) @@ -154,7 +157,8 @@ type HdPrivKeyDecrypter struct { // SenderKey optionally holds the pub key used by sender // If the sender is the same as the receiver, set this to nil and set FromSelf to true. - // If the sender is unknown, set this to nil. If so, the authenticity of the message won't be validated. + // If the sender is unknown, set this to nil. If so, the authenticity of + // the message won't be validated. SenderKey *btcec.PublicKey // FromSelf is true if this message is from yourself @@ -162,30 +166,33 @@ type HdPrivKeyDecrypter struct { } func (d *HdPrivKeyDecrypter) Decrypt(payload string) ([]byte, error) { - // Uses AES128-GCM with associated data. ECDHE is used for key exchange and ECDSA for authentication. + // Uses AES128-GCM with associated data. ECDHE is used for key exchange and ECDSA for + // authentication. // See Encrypt further up for an in depth dive into the scheme used slog.Info("Libwallet: Decrypting payload " + payload) parsed, err := parseEncodedPayload(payload) if err != nil { - return nil, fmt.Errorf("Decrypt: failed to parse payload: %w", err) + return nil, errors.Errorf("Decrypt: failed to parse payload: %w", err) } encryptionKey, verificationKey, err := d.computeKeys(parsed, false) if err != nil { - return nil, fmt.Errorf("Decrypt: failed to compute keys: %w", err) + return nil, errors.Errorf("Decrypt: failed to compute keys: %w", err) } data, err := parsed.decryptAndVerify(encryptionKey, verificationKey) if err != nil { - // Save the error why the first attempt failed so we can return it we - // shouldn't attempt again. + // Save the error why the first attempt failed so we can return it we shouldn't attempt + // again. originalError := err - shouldTryDerivationWithBug, err := d.isPotentiallyAffectedByImplicitHardenedDerivationBug(parsed) + shouldTryDerivationWithBug, err := d.isPotentiallyAffectedByImplicitHardenedDerivationBug( + parsed, + ) if err != nil { - return nil, fmt.Errorf( + return nil, errors.Errorf( "Decrypt: failed to check if affected by derivation bug: %w"+ " -- originally failed due to %w", err, originalError, ) @@ -197,12 +204,17 @@ func (d *HdPrivKeyDecrypter) Decrypt(payload string) ([]byte, error) { encryptionKey, verificationKey, err = d.computeKeys(parsed, true) if err != nil { - return nil, fmt.Errorf("Decrypt: failed to compute keys with derivation bug: %w", err) + return nil, errors.Errorf( + "Decrypt: failed to compute keys with derivation bug: %w", err, + ) } data, err = parsed.decryptAndVerify(encryptionKey, verificationKey) if err != nil { - return nil, fmt.Errorf("Decrypt: failed to decrypt payload with derivation bug: %w", err) + return nil, errors.Errorf( + "Decrypt: failed to decrypt payload with derivation bug: %w", + err, + ) } return data, nil @@ -211,7 +223,10 @@ func (d *HdPrivKeyDecrypter) Decrypt(payload string) ([]byte, error) { return data, nil } -func (d *HdPrivKeyDecrypter) computeKeys(parsed decodedPayload, useHardenedDerivationWithBug bool) (*btcec.PrivateKey, *btcec.PublicKey, error) { +func (d *HdPrivKeyDecrypter) computeKeys( + parsed decodedPayload, + useHardenedDerivationWithBug bool, +) (*btcec.PrivateKey, *btcec.PublicKey, error) { var err error var verificationKey *btcec.PublicKey @@ -222,7 +237,11 @@ func (d *HdPrivKeyDecrypter) computeKeys(parsed decodedPayload, useHardenedDeriv receiverKey, err = d.KeyProvider.WithPath(parsed.receiverPath) } if err != nil { - return nil, nil, fmt.Errorf("computeKeys: failed to derive receiver key to path %v: %w", parsed.receiverPath, err) + return nil, nil, errors.Errorf( + "computeKeys: failed to derive receiver key to path %v: %w", + parsed.receiverPath, + err, + ) } if d.FromSelf { @@ -235,19 +254,28 @@ func (d *HdPrivKeyDecrypter) computeKeys(parsed decodedPayload, useHardenedDeriv return receiverKey, verificationKey, nil } -func (d *HdPrivKeyDecrypter) isPotentiallyAffectedByImplicitHardenedDerivationBug(parsed decodedPayload) (bool, error) { +func (d *HdPrivKeyDecrypter) isPotentiallyAffectedByImplicitHardenedDerivationBug( + parsed decodedPayload, +) (bool, error) { if parsed.version != PKEncryptionVersionV1 { return false, nil } receiverKeyPath, err := hdpath.Parse(d.KeyProvider.Path()) if err != nil { - return false, fmt.Errorf("isPotentiallyAffectedByImplicitHardenedDerivationBug: error parsing ReceiverKey path %w", err) + return false, errors.Errorf( + "isPotentiallyAffectedByImplicitHardenedDerivationBug: "+ + "error parsing ReceiverKey path %w", + err, + ) } pathToDeriveTo, err := hdpath.Parse(parsed.receiverPath) if err != nil { - return false, fmt.Errorf("isPotentiallyAffectedByImplicitHardenedDerivationBug: error parsing receiverPath %w", err) + return false, errors.Errorf( + "isPotentiallyAffectedByImplicitHardenedDerivationBug: error parsing receiverPath %w", + err, + ) } for _, index := range pathToDeriveTo.IndexesFrom(receiverKeyPath) { @@ -273,10 +301,13 @@ func parseEncodedPayload(payload string) (decodedPayload, error) { reader := bytes.NewReader(decoded) version, err := reader.ReadByte() if err != nil { - return decodedPayload{}, fmt.Errorf("parseEncodedPayload: failed to read version byte: %w", err) + return decodedPayload{}, errors.Errorf( + "parseEncodedPayload: failed to read version byte: %w", + err, + ) } if version != PKEncryptionVersionV1 && version != PKEncryptionVersionV2 { - return decodedPayload{}, fmt.Errorf("parseEncodedPayload: found key version %v", version) + return decodedPayload{}, errors.Errorf("parseEncodedPayload: found key version %v", version) } rawPubEph := make([]byte, serializedPublicKeyLength) @@ -287,7 +318,10 @@ func parseEncodedPayload(payload string) (decodedPayload, error) { receiverPath, err := extractVariableString(reader, maxDerivationPathLen) if err != nil { - return decodedPayload{}, fmt.Errorf("parseEncodedPayload: failed to extract receiver path: %w", err) + return decodedPayload{}, errors.Errorf( + "parseEncodedPayload: failed to extract receiver path: %w", + err, + ) } // additionalDataSize is Whatever I've read so far plus two bytes for the nonce len @@ -303,7 +337,10 @@ func parseEncodedPayload(payload string) (decodedPayload, error) { ciphertext := make([]byte, reader.Len()) _, err = reader.Read(ciphertext) if err != nil { - return decodedPayload{}, fmt.Errorf("parseEncodedPayload: failed to read ciphertext: %w", err) + return decodedPayload{}, errors.Errorf( + "parseEncodedPayload: failed to read ciphertext: %w", + err, + ) } additionalData := decoded[:additionalDataSize] @@ -318,37 +355,40 @@ func parseEncodedPayload(payload string) (decodedPayload, error) { }, nil } -func (p decodedPayload) decryptAndVerify(encryptionKey *btcec.PrivateKey, verificationKey *btcec.PublicKey) ([]byte, error) { +func (p decodedPayload) decryptAndVerify( + encryptionKey *btcec.PrivateKey, + verificationKey *btcec.PublicKey, +) ([]byte, error) { sharedSecret, err := RecoverSharedEncryptionSecretForAES(encryptionKey, p.rawPubEph) if err != nil { - return nil, fmt.Errorf("decryptAndVerify: failed to recover shared secret: %w", err) + return nil, errors.Errorf("decryptAndVerify: failed to recover shared secret: %w", err) } blockCipher, err := aes.NewCipher(sharedSecret) if err != nil { - return nil, fmt.Errorf("decryptAndVerify: new aes failed: %w", err) + return nil, errors.Errorf("decryptAndVerify: new aes failed: %w", err) } gcm, err := cipher.NewGCMWithNonceSize(blockCipher, len(p.nonce)) if err != nil { - return nil, fmt.Errorf("decryptAndVerify: new gcm failed: %w", err) + return nil, errors.Errorf("decryptAndVerify: new gcm failed: %w", err) } plaintext, err := gcm.Open(nil, p.nonce, p.ciphertext, p.additionalData) if err != nil { - return nil, fmt.Errorf("decryptAndVerify: AEAD failed: %w", err) + return nil, errors.Errorf("decryptAndVerify: AEAD failed: %w", err) } plaintextReader := bytes.NewReader(plaintext) sig, err := extractVariableBytes(plaintextReader, maxSignatureLen) if err != nil { - return nil, fmt.Errorf("decryptAndVerify: failed to read sig: %w", err) + return nil, errors.Errorf("decryptAndVerify: failed to read sig: %w", err) } data, err := extractVariableBytes(plaintextReader, plaintextReader.Len()) if err != nil { - return nil, fmt.Errorf("decryptAndVerify: failed to extract user data: %w", err) + return nil, errors.Errorf("decryptAndVerify: failed to extract user data: %w", err) } signatureData := make([]byte, 0, len(sig)+serializedPublicKeyLength) @@ -357,7 +397,7 @@ func (p decodedPayload) decryptAndVerify(encryptionKey *btcec.PrivateKey, verifi hash := sha256.Sum256(signatureData) signatureKey, _, err := ecdsa.RecoverCompact(sig, hash[:]) if err != nil { - return nil, fmt.Errorf("decryptAndVerify: failed to verify signature: %w", err) + return nil, errors.Errorf("decryptAndVerify: failed to verify signature: %w", err) } if verificationKey != nil && !signatureKey.IsEqual(verificationKey) { return nil, errors.New("decryptAndVerify: signing key mismatch") diff --git a/libwallet/encryption/cypher_test.go b/libwallet/encryption/cypher_test.go index 3508ef42..21189c0a 100644 --- a/libwallet/encryption/cypher_test.go +++ b/libwallet/encryption/cypher_test.go @@ -15,6 +15,7 @@ import ( "github.com/btcsuite/btcd/btcutil/base58" "github.com/btcsuite/btcd/btcutil/hdkeychain" "github.com/btcsuite/btcd/chaincfg" + "github.com/muun/libwallet/hdpath" ) @@ -89,7 +90,9 @@ func (k keyProvider) WithPathUsingHardenedBug(path string) (*btcec.PrivateKey, e modifier = hdkeychain.HardenedKeyStart } //lint:ignore SA1019 using deprecated method for backwards compat with the bug - key, err = key.DeriveNonStandard(index.Index | modifier) + key, err = key.DeriveNonStandard( //nolint:staticcheck // TODO: key.DeriveNonStandard is deprecated: This is a non-standard derivation that is affected by issue #172. 1-of-256 hardened derivations will be wrong. See note in the Derive method and IsAffectedByIssue172. + index.Index | modifier, + ) if err != nil { return nil, err } @@ -104,9 +107,9 @@ func (k keyProvider) Path() string { func TestPublicKeyEncryptionV1(t *testing.T) { const ( - priv = "xprv9s21ZrQH143K2DAjx7FiAo2GQAQ5g7GrPYkTB2RaCd2Ei5ZH7f9cbREHiZTCc1FPn9HKuviUHk8sf5cW3dhYjz6W6XPjXNHu5mLpT5oRH1j" - ciphertext = "AMWm2L3YjA7myBTQQgiZi9F5g1NzaaupkPq1y7csUkf7WLXwnPYjkmy5KjVkyTKjaSXPwjx2zmX9Augzwwh89AsWYTv7KfJTXTj3Lx2mNZgmxJ7eezaJyRHv4koQaEmRykSoVE4esjWK779Sac28kCstkqDMPDYeNud5H4ApetF4BvhvPJyMaVn4RHYSAGzBzMcBV7WxYoRveKHqU9LbAfhCndPtRSVZyTVXY8iE3EvQJFeZVyYdovPK67aHsXWRdi8QCinMQSG21TMmhs7GQAh6iB26X2ABcVFJRGeEKE2coAsfuAHzcAMZ3CdzGgVAm7rrQw13W3XpxwwjWVatH9Jm9H4TrnnnLxRCsBoSKDvA1hmH8a2UG9iMxkhsBVMPzNRMy4Bg4MHk8WyRo3bwCLSVJUFFEciQ3mUneHprezzbVZio" - plaintextHex = "ca4dabb05a47d3ab306c1fad895d97b06dc30564191e610f9b254b1a1d0a536b6eca2b83d0d17d67aaad2a958fe6a6557ad5b26f44e12e7662f47a4e4fd6f482b68a83cd140ad4ded43b90a2c2cf349af84d828b1f961901616b4c4cb01f761bd277ad0d3d90506065aef76b930a962fcb90f2f009898c0d55cd07b5e01c355a9067937185fa9237d03e5ed4243e1bf0f8a959c72a83cbb1729b679cbd660052dd2dd3096b0f19e9275ac459b94d02a95642" + priv = "xprv9s21ZrQH143K2DAjx7FiAo2GQAQ5g7GrPYkTB2RaCd2Ei5ZH7f9cbREHiZTCc1FPn9HKuviUHk8sf5cW3dhYjz6W6XPjXNHu5mLpT5oRH1j" //nolint:lll + ciphertext = "AMWm2L3YjA7myBTQQgiZi9F5g1NzaaupkPq1y7csUkf7WLXwnPYjkmy5KjVkyTKjaSXPwjx2zmX9Augzwwh89AsWYTv7KfJTXTj3Lx2mNZgmxJ7eezaJyRHv4koQaEmRykSoVE4esjWK779Sac28kCstkqDMPDYeNud5H4ApetF4BvhvPJyMaVn4RHYSAGzBzMcBV7WxYoRveKHqU9LbAfhCndPtRSVZyTVXY8iE3EvQJFeZVyYdovPK67aHsXWRdi8QCinMQSG21TMmhs7GQAh6iB26X2ABcVFJRGeEKE2coAsfuAHzcAMZ3CdzGgVAm7rrQw13W3XpxwwjWVatH9Jm9H4TrnnnLxRCsBoSKDvA1hmH8a2UG9iMxkhsBVMPzNRMy4Bg4MHk8WyRo3bwCLSVJUFFEciQ3mUneHprezzbVZio" //nolint:lll + plaintextHex = "ca4dabb05a47d3ab306c1fad895d97b06dc30564191e610f9b254b1a1d0a536b6eca2b83d0d17d67aaad2a958fe6a6557ad5b26f44e12e7662f47a4e4fd6f482b68a83cd140ad4ded43b90a2c2cf349af84d828b1f961901616b4c4cb01f761bd277ad0d3d90506065aef76b930a962fcb90f2f009898c0d55cd07b5e01c355a9067937185fa9237d03e5ed4243e1bf0f8a959c72a83cbb1729b679cbd660052dd2dd3096b0f19e9275ac459b94d02a95642" //nolint:lll ) privKey, _ := hdkeychain.NewKeyFromString(priv) @@ -136,7 +139,7 @@ func TestPublicKeyEncryptionV1(t *testing.T) { func TestPublicKeyDecryptV1(t *testing.T) { const ( - privHex = "xprv9s21ZrQH143K36uECEJcmTnxSXfHjT9jdb7FpMoUJpENDxeRgpscDF3g2w4ySH6G9uVsGKK7e6WgGp7Vc9VVnwC2oWdrr7a3taWiKW8jKnD" + privHex = "xprv9s21ZrQH143K36uECEJcmTnxSXfHjT9jdb7FpMoUJpENDxeRgpscDF3g2w4ySH6G9uVsGKK7e6WgGp7Vc9VVnwC2oWdrr7a3taWiKW8jKnD" //nolint:lll path = "m" pathLen = 1 ) @@ -219,7 +222,10 @@ func TestPublicKeyDecryptV1(t *testing.T) { nonce := data[len(data)-12:] encryptionKey, _ := hdkeychain.NewKeyFromString(privHex) ecEncryptionKey, _ := encryptionKey.ECPrivKey() - secret, _ := RecoverSharedEncryptionSecretForAES(ecEncryptionKey, data[1:serializedPublicKeyLength+1]) + secret, _ := RecoverSharedEncryptionSecretForAES( + ecEncryptionKey, + data[1:serializedPublicKeyLength+1], + ) block, _ := aes.NewCipher(secret) gcm, _ := cipher.NewGCM(block) diff --git a/libwallet/encryption/encodings.go b/libwallet/encryption/encodings.go index 6f959f18..3d132c51 100755 --- a/libwallet/encryption/encodings.go +++ b/libwallet/encryption/encodings.go @@ -3,11 +3,11 @@ package encryption import ( "bytes" "encoding/binary" - "errors" - "fmt" "io" "math" "math/big" + + "github.com/go-errors/errors" ) func PaddedSerializeBigInt(size uint, x *big.Int) []byte { @@ -23,13 +23,13 @@ func PaddedSerializeBigInt(size uint, x *big.Int) []byte { func addVariableBytes(writer io.Writer, data []byte) error { if len(data) > math.MaxUint16 { - return fmt.Errorf("data length can't exceeed %v", math.MaxUint16) + return errors.Errorf("data length can't exceeed %v", math.MaxUint16) } dataLen := uint16(len(data)) err := binary.Write(writer, binary.BigEndian, &dataLen) if err != nil { - return fmt.Errorf("failed to write var bytes len: %w", err) + return errors.Errorf("failed to write var bytes len: %w", err) } n, err := writer.Write(data) diff --git a/libwallet/encryption/encodings_test.go b/libwallet/encryption/encodings_test.go index 3b459dd4..08d40f8a 100755 --- a/libwallet/encryption/encodings_test.go +++ b/libwallet/encryption/encodings_test.go @@ -32,13 +32,19 @@ func Test_paddedSerializeBigInt(t *testing.T) { }{ { name: "31 bytes key", - args: args{size: 32, x: hexToBigInt("0e815b7892396a2e28e09c0d50082931eedd7fec16ef2e06724fe48f877ea6")}, + args: args{ + size: 32, + x: hexToBigInt("0e815b7892396a2e28e09c0d50082931eedd7fec16ef2e06724fe48f877ea6"), + }, want: hexToBytes("000e815b7892396a2e28e09c0d50082931eedd7fec16ef2e06724fe48f877ea6"), }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := PaddedSerializeBigInt(tt.args.size, tt.args.x); !reflect.DeepEqual(got, tt.want) { + if got := PaddedSerializeBigInt(tt.args.size, tt.args.x); !reflect.DeepEqual( + got, + tt.want, + ) { t.Errorf("PaddedSerializeBigInt() = %v, want %v", got, tt.want) } }) diff --git a/libwallet/encryption/secret.go b/libwallet/encryption/secret.go index 06b528d9..1d6f9c9a 100644 --- a/libwallet/encryption/secret.go +++ b/libwallet/encryption/secret.go @@ -2,9 +2,10 @@ package encryption import ( "crypto/sha256" - "fmt" "github.com/btcsuite/btcd/btcec/v2" + "github.com/go-errors/errors" + "github.com/muun/libwallet/aescbc" ) @@ -13,7 +14,10 @@ import ( func GenerateSharedEncryptionSecret(pubKey *btcec.PublicKey) (*btcec.PublicKey, []byte, error) { privEph, err := btcec.NewPrivateKey() if err != nil { - return nil, nil, fmt.Errorf("GenerateSharedEncryptionSecretForAES: failed to generate key: %w", err) + return nil, nil, errors.Errorf( + "GenerateSharedEncryptionSecretForAES: failed to generate key: %w", + err, + ) } sharedSecret, _ := btcec.S256().ScalarMult(pubKey.X(), pubKey.Y(), privEph.ToECDSA().D.Bytes()) @@ -21,12 +25,16 @@ func GenerateSharedEncryptionSecret(pubKey *btcec.PublicKey) (*btcec.PublicKey, return privEph.PubKey(), PaddedSerializeBigInt(aescbc.KeySize, sharedSecret), nil } -// RecoverSharedEncryptionSecret performs an ECDH to recover the encryption secret meant for privKey from rawPubEph -// Deprecated: this function is unsafe and RecoverSharedEncryptionSecretForAES should be used +// RecoverSharedEncryptionSecret performs an ECDH to recover the encryption secret meant for privKey +// from rawPubEph Deprecated: this function is unsafe and RecoverSharedEncryptionSecretForAES should +// be used func RecoverSharedEncryptionSecret(privKey *btcec.PrivateKey, rawPubEph []byte) ([]byte, error) { pubEph, err := btcec.ParsePubKey(rawPubEph) if err != nil { - return nil, fmt.Errorf("RecoverSharedEncryptionSecretForAES: failed to parse pub eph: %w", err) + return nil, errors.Errorf( + "RecoverSharedEncryptionSecretForAES: failed to parse pub eph: %w", + err, + ) } sharedSecret, _ := btcec.S256().ScalarMult(pubEph.X(), pubEph.Y(), privKey.ToECDSA().D.Bytes()) @@ -34,7 +42,9 @@ func RecoverSharedEncryptionSecret(privKey *btcec.PrivateKey, rawPubEph []byte) } // GenerateSharedEncryptionSecret performs a ECDH with pubKey and produces a secret usable with AES -func GenerateSharedEncryptionSecretForAES(pubKey *btcec.PublicKey) (*btcec.PublicKey, []byte, error) { +func GenerateSharedEncryptionSecretForAES( + pubKey *btcec.PublicKey, +) (*btcec.PublicKey, []byte, error) { privEph, sharedSecret, err := GenerateSharedEncryptionSecret(pubKey) if err != nil { return nil, nil, err @@ -44,7 +54,10 @@ func GenerateSharedEncryptionSecretForAES(pubKey *btcec.PublicKey) (*btcec.Publi return privEph, hash[:], nil } -func RecoverSharedEncryptionSecretForAES(privKey *btcec.PrivateKey, rawPubEph []byte) ([]byte, error) { +func RecoverSharedEncryptionSecretForAES( + privKey *btcec.PrivateKey, + rawPubEph []byte, +) ([]byte, error) { sharedSecret, err := RecoverSharedEncryptionSecret(privKey, rawPubEph) if err != nil { return nil, err diff --git a/libwallet/errors.go b/libwallet/errors.go index 6b9c3e46..0dd58bb9 100644 --- a/libwallet/errors.go +++ b/libwallet/errors.go @@ -13,7 +13,7 @@ func ErrorCode(err error) int64 { type coder interface { Code() int64 } - switch e := err.(type) { + switch e := err.(type) { //nolint:errorlint // TODO: use errors.As case coder: return e.Code() default: diff --git a/libwallet/errors/api_errors.go b/libwallet/errors/api_errors.go index 5ad32861..2efc4c52 100644 --- a/libwallet/errors/api_errors.go +++ b/libwallet/errors/api_errors.go @@ -14,16 +14,19 @@ type ErrorCode struct { } var ErrorCodes = struct { - ErrKeyEmpty ErrorCode - ErrValueEmpty ErrorCode - ErrItemsEmpty ErrorCode - ErrSignInternalError ErrorCode - ErrSignMacValidation ErrorCode - ErrChallengeExpired ErrorCode - ErrPairInternalError ErrorCode - ErrNoSlotsAvailable ErrorCode - ErrAppletNotFound ErrorCode - ErrUnknown ErrorCode + ErrKeyEmpty ErrorCode + ErrValueEmpty ErrorCode + ErrItemsEmpty ErrorCode + ErrSignInternalError ErrorCode + ErrSignMacValidation ErrorCode + ErrChallengeExpired ErrorCode + ErrPairInternalError ErrorCode + ErrNoSlotsAvailable ErrorCode + ErrAppletNotFound ErrorCode + ErrSecureKvNotFound ErrorCode + ErrSecureKvDecryptionFailed ErrorCode + ErrSecureKvStorageFailed ErrorCode + ErrUnknown ErrorCode }{ // key-value storage errors: ErrKeyEmpty: ErrorCode{Code: 14_001, Message: "Key can not be empty", Type: CLIENT}, @@ -32,11 +35,40 @@ var ErrorCodes = struct { // security cards errors: ErrSignInternalError: ErrorCode{Code: 14_100, Message: "Sign internal error", Type: LIBWALLET}, - ErrSignMacValidation: ErrorCode{Code: 14_101, Message: "Mac validation failure", Type: LIBWALLET}, + ErrSignMacValidation: ErrorCode{ + Code: 14_101, + Message: "Mac validation failure", + Type: LIBWALLET, + }, ErrChallengeExpired: ErrorCode{Code: 14_102, Message: "Challenge expired", Type: LIBWALLET}, ErrPairInternalError: ErrorCode{Code: 14_103, Message: "Pair internal error", Type: LIBWALLET}, - ErrNoSlotsAvailable: ErrorCode{Code: 14_104, Message: "No slots available for pairing a card", Type: LIBWALLET}, - ErrAppletNotFound: ErrorCode{Code: 14_105, Message: "Muun applet id not found", Type: LIBWALLET}, + ErrNoSlotsAvailable: ErrorCode{ + Code: 14_104, + Message: "No slots available for pairing a card", + Type: LIBWALLET, + }, + ErrAppletNotFound: ErrorCode{ + Code: 14_105, + Message: "Muun applet id not found", + Type: LIBWALLET, + }, + + // secure key-value storage errors: + ErrSecureKvNotFound: ErrorCode{ + Code: 14_200, + Message: "Secure key-value storage: key not found", + Type: LIBWALLET, + }, + ErrSecureKvDecryptionFailed: ErrorCode{ + Code: 14_201, + Message: "Secure key-value storage: decryption failed", + Type: LIBWALLET, + }, + ErrSecureKvStorageFailed: ErrorCode{ + Code: 14_202, + Message: "Secure key-value storage: storage failed", + Type: LIBWALLET, + }, ErrUnknown: ErrorCode{Code: 14_999, Message: "Unknown error", Type: LIBWALLET}, } diff --git a/libwallet/errors/errors.go b/libwallet/errors/errors.go index c24b0759..a1a42567 100644 --- a/libwallet/errors/errors.go +++ b/libwallet/errors/errors.go @@ -1,8 +1,7 @@ package errors import ( - "errors" - "fmt" + "github.com/go-errors/errors" ) type Error struct { @@ -22,7 +21,11 @@ func New(code int64, msg string) error { return &Error{errors.New(msg), code} } -func Errorf(code int64, format string, a ...interface{}) error { - err := fmt.Errorf(format, a...) +func Errorf( + code int64, + format string, + a ...interface{}, //nolint:modernize // TODO: use any instead of interface{} +) error { + err := errors.Errorf(format, a...) return &Error{err, code} } diff --git a/libwallet/features_test.go b/libwallet/features_test.go index a7a5ee0d..6d927ea4 100644 --- a/libwallet/features_test.go +++ b/libwallet/features_test.go @@ -205,7 +205,13 @@ func Test_DetermineUserActivatedFeatureStatus(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := DetermineUserActivatedFeatureStatus(tt.args.feature, tt.args.height, tt.args.kitVersion, tt.args.backendFeatures, tt.args.network); got != tt.want { + got := DetermineUserActivatedFeatureStatus( + tt.args.feature, tt.args.height, + tt.args.kitVersion, + tt.args.backendFeatures, + tt.args.network, + ) + if got != tt.want { t.Errorf("DetermineUserActivatedFeatureStatus() = %v, want %v", got, tt.want) } }) @@ -226,16 +232,30 @@ func Test_DetermineBackendActivatedFeatureStatus(t *testing.T) { var status = DetermineBackendActivatedFeatureStatus(BackendFeatureEffectiveFeesCalculation) if !status { - t.Errorf("DetermineBackendActivatedFeatureStatus(BackendFeatureEffectiveFeesCalculation) = %v, want %v", status, true) + t.Errorf( + "DetermineBackendActivatedFeatureStatus"+ + "(BackendFeatureEffectiveFeesCalculation) = %v, want %v", + status, + true, + ) } status = DetermineBackendActivatedFeatureStatus(BackendFeatureHighFeesHomeBanner) if status { - t.Errorf("DetermineBackendActivatedFeatureStatus(BackendFeatureHighFeesHomeBanner) = %v, want %v", status, false) + t.Errorf( + "DetermineBackendActivatedFeatureStatus"+ + "(BackendFeatureHighFeesHomeBanner) = %v, want %v", + status, + false, + ) } status = DetermineBackendActivatedFeatureStatus("UnknownFlag") if status { - t.Errorf("DetermineBackendActivatedFeatureStatus(\"UnknownFlag\") = %v, want %v", status, false) + t.Errorf( + "DetermineBackendActivatedFeatureStatus(\"UnknownFlag\") = %v, want %v", + status, + false, + ) } } diff --git a/libwallet/fees/fees.go b/libwallet/fees/fees.go index 2fa2b653..530807b4 100644 --- a/libwallet/fees/fees.go +++ b/libwallet/fees/fees.go @@ -4,20 +4,30 @@ import "github.com/btcsuite/btcd/btcutil" const dustThreshold = 546 -// BestRouteFees represents a possible route for a lightning payment. In particular, it encodes the fee -// policy of such route (e.g how the route charges fees) and how a big a payment it can handle/route (e.g what is -// the maximum amount that is routable/payable via this route). +// BestRouteFees represents a possible route for a lightning payment. In particular, it encodes the +// fee policy of such route (e.g how the route charges fees) and how a big a payment it can +// handle/route (e.g what is the maximum amount that is routable/payable via this route). type BestRouteFees struct { - MaxCapacity btcutil.Amount // maximum amount that is routable/payable via this route - FeeProportionalMillionth uint64 // fee proportion of the routed amount, divided by a million - FeeBase btcutil.Amount // fixed fee component. For a specific route: TotalFee=(FeeProportionalMillionth*amount)/1000000 + FeeBase + // maximum amount that is routable/payable via this route + MaxCapacity btcutil.Amount + // fee proportion of the routed amount, divided by a million + FeeProportionalMillionth uint64 + // fixed fee component. For a specific route: + // TotalFee=(FeeProportionalMillionth*amount)/1000000 + FeeBase + FeeBase btcutil.Amount } // FundingOutputPolicies represents the conditions that decide how the funding output is created. type FundingOutputPolicies struct { - MaximumDebt btcutil.Amount // maximum amount of debt that we're ok with lending this user, according swap provider risk tolerance - PotentialCollect btcutil.Amount // amount of debt we can effectively collect for a specific swap. - MaxAmountFor0Conf btcutil.Amount // maximum amount allowed for a 0-conf swap. Greater amounts will require 1-conf (higher fees, worse UX). Depends on swap provider risk tolerance. + // maximum amount of debt that we're ok with lending this + // user, according swap provider risk tolerance + MaximumDebt btcutil.Amount + // amount of debt we can effectively collect for a specific swap. + PotentialCollect btcutil.Amount + // maximum amount allowed for a 0-conf swap. Greater + // amounts will require 1-conf (higher fees, worse UX). + // Depends on swap provider risk tolerance. + MaxAmountFor0Conf btcutil.Amount } type DebtType string @@ -37,7 +47,9 @@ type SwapFees struct { ConfirmationsNeeded uint } -func (p *FundingOutputPolicies) FundingConfirmations(paymentAmount, lightningFee btcutil.Amount) uint { +func (p *FundingOutputPolicies) FundingConfirmations( + paymentAmount, lightningFee btcutil.Amount, +) uint { totalAmount := paymentAmount + lightningFee if totalAmount <= p.MaxAmountFor0Conf { return 0 @@ -57,7 +69,9 @@ func (p *FundingOutputPolicies) DebtType(paymentAmount, lightningFee btcutil.Amo return DebtTypeNone } -func (p *FundingOutputPolicies) DebtAmount(paymentAmount, lightningFee btcutil.Amount) btcutil.Amount { +func (p *FundingOutputPolicies) DebtAmount( + paymentAmount, lightningFee btcutil.Amount, +) btcutil.Amount { switch p.DebtType(paymentAmount, lightningFee) { case DebtTypeLend: return paymentAmount + lightningFee @@ -70,7 +84,9 @@ func (p *FundingOutputPolicies) DebtAmount(paymentAmount, lightningFee btcutil.A } } -func (p *FundingOutputPolicies) MinFundingAmount(paymentAmount, lightningFee btcutil.Amount) btcutil.Amount { +func (p *FundingOutputPolicies) MinFundingAmount( + paymentAmount, lightningFee btcutil.Amount, +) btcutil.Amount { inputAmount := paymentAmount + lightningFee if p.DebtType(paymentAmount, lightningFee) == DebtTypeCollect { inputAmount += p.DebtAmount(paymentAmount, lightningFee) @@ -78,7 +94,9 @@ func (p *FundingOutputPolicies) MinFundingAmount(paymentAmount, lightningFee btc return inputAmount } -func (p *FundingOutputPolicies) FundingOutputAmount(paymentAmount, lightningFee btcutil.Amount) btcutil.Amount { +func (p *FundingOutputPolicies) FundingOutputAmount( + paymentAmount, lightningFee btcutil.Amount, +) btcutil.Amount { minAmount := p.MinFundingAmount(paymentAmount, lightningFee) if minAmount < dustThreshold { return dustThreshold @@ -86,13 +104,20 @@ func (p *FundingOutputPolicies) FundingOutputAmount(paymentAmount, lightningFee return minAmount } -func (p *FundingOutputPolicies) FundingOutputPadding(paymentAmount, lightningFee btcutil.Amount) btcutil.Amount { +func (p *FundingOutputPolicies) FundingOutputPadding( + paymentAmount, lightningFee btcutil.Amount, +) btcutil.Amount { minAmount := p.MinFundingAmount(paymentAmount, lightningFee) outputAmount := p.FundingOutputAmount(paymentAmount, lightningFee) return outputAmount - minAmount } -func ComputeSwapFees(amount btcutil.Amount, bestRouteFees []BestRouteFees, policies *FundingOutputPolicies, takeFeeFromAmount bool) *SwapFees { +func ComputeSwapFees( + amount btcutil.Amount, + bestRouteFees []BestRouteFees, + policies *FundingOutputPolicies, + takeFeeFromAmount bool, +) *SwapFees { if takeFeeFromAmount { // Handle edge cases for TFFA swaps. We don't allow lend for TFFA. This impacts sub-dust // swaps because we don't allow debt for output padding. Except, the very special case of diff --git a/libwallet/go.mod b/libwallet/go.mod index 53ab501d..f1643784 100644 --- a/libwallet/go.mod +++ b/libwallet/go.mod @@ -1,6 +1,6 @@ module github.com/muun/libwallet -go 1.22 +go 1.24 require ( github.com/btcsuite/btcd v0.24.2-beta.rc1.0.20240403021926-ae5533602c46 @@ -10,6 +10,7 @@ require ( github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 github.com/fiatjaf/go-lnurl v1.13.1 + github.com/go-errors/errors v1.5.1 github.com/google/uuid v1.6.0 github.com/grpc-ecosystem/go-grpc-middleware v1.3.0 github.com/jinzhu/gorm v1.9.16 @@ -23,6 +24,7 @@ require ( github.com/stretchr/testify v1.9.0 github.com/test-go/testify v1.1.4 golang.org/x/crypto v0.25.0 + golang.org/x/sync v0.7.0 google.golang.org/grpc v1.64.0 google.golang.org/protobuf v1.36.3 gopkg.in/gormigrate.v1 v1.6.0 @@ -59,7 +61,6 @@ require ( github.com/docker/go-units v0.5.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/fergusstrange/embedded-postgres v1.25.0 // indirect - github.com/go-errors/errors v1.0.1 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang-jwt/jwt/v4 v4.4.2 // indirect github.com/golang-migrate/migrate/v4 v4.17.0 // indirect @@ -163,7 +164,6 @@ require ( golang.org/x/mobile v0.0.0-20220414153400-ce6a79cf6a13 // indirect golang.org/x/mod v0.19.0 // indirect golang.org/x/net v0.27.0 // indirect - golang.org/x/sync v0.7.0 // indirect golang.org/x/sys v0.22.0 // indirect golang.org/x/term v0.22.0 // indirect golang.org/x/text v0.16.0 // indirect @@ -185,6 +185,10 @@ require ( sigs.k8s.io/yaml v1.2.0 // indirect ) +// gomobile is invoked as a CLI tool from tools/libwallet-android.sh and is not +// imported by any Go source, so `go mod tidy` would otherwise drop it. +tool golang.org/x/mobile/cmd/gomobile + // Fork that includes the -cache flag for quicker builds replace golang.org/x/mobile => github.com/muun/mobile v0.0.0-20240709203120-049ae58602a0 diff --git a/libwallet/go.sum b/libwallet/go.sum index 361b5ddf..e3d57dd4 100644 --- a/libwallet/go.sum +++ b/libwallet/go.sum @@ -205,8 +205,8 @@ github.com/fsnotify/fsnotify v1.5.4/go.mod h1:OVB6XrOHzAwXMpEM7uPOzcehqUV2UqJxmV github.com/getsentry/raven-go v0.2.0 h1:no+xWJRb5ZI7eE8TWgIq1jLulQiIoLG0IfYxv5JYMGs= github.com/getsentry/raven-go v0.2.0/go.mod h1:KungGk8q33+aIAZUIVWZDr2OfAEBsO49PX4NzFV5kcQ= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= -github.com/go-errors/errors v1.0.1 h1:LUHzmkK3GUKUrL/1gfBUxAHzcev3apQlezX/+O7ma6w= -github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q= +github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk= +github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= diff --git a/libwallet/hdpath/hdpath.go b/libwallet/hdpath/hdpath.go index c1630015..f2a1e76c 100644 --- a/libwallet/hdpath/hdpath.go +++ b/libwallet/hdpath/hdpath.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/btcsuite/btcd/btcutil/hdkeychain" + "github.com/go-errors/errors" ) type Path string @@ -17,7 +18,7 @@ var re = regexp.MustCompile(`^(m?|\/|(([a-z]+:)?\d+'?))(\/([a-z]+:)?\d+'?)*$`) func Parse(s string) (Path, error) { if !re.MatchString(s) { - return "", fmt.Errorf("path is not valid: `%s`", s) + return "", errors.Errorf("path is not valid: `%s`", s) } return Path(s), nil } @@ -89,7 +90,7 @@ func (p Path) Indexes() []PathIndex { path = strings.TrimPrefix(path, "m") path = strings.TrimPrefix(path, "/") - for _, chunk := range strings.Split(path, "/") { + for _, chunk := range strings.Split(path, "/") { //nolint:modernize // TODO: use strings.SplitSeq hardened := false indexText := chunk if strings.HasSuffix(indexText, HardenedSymbol) { @@ -135,7 +136,8 @@ func (p Path) HasPrefix(prefix Path) bool { } for i, prefixPathIndex := range prefixPathIndexes { pathIndex := indexes[i] - if pathIndex.Index != prefixPathIndex.Index || pathIndex.Hardened != prefixPathIndex.Hardened { + if pathIndex.Index != prefixPathIndex.Index || + pathIndex.Hardened != prefixPathIndex.Hardened { return false } } diff --git a/libwallet/hdpath/hdpath_test.go b/libwallet/hdpath/hdpath_test.go index 96e66fa6..15bb3805 100644 --- a/libwallet/hdpath/hdpath_test.go +++ b/libwallet/hdpath/hdpath_test.go @@ -8,19 +8,19 @@ import ( var ( rootPath = make([]PathIndex, 0) shortPath = []PathIndex{ - PathIndex{Index: 0, Hardened: true}, + {Index: 0, Hardened: true}, } longPath = []PathIndex{ - PathIndex{Index: 44, Hardened: true}, - PathIndex{Index: 1, Hardened: true}, - PathIndex{Index: 2, Hardened: false}, + {Index: 44, Hardened: true}, + {Index: 1, Hardened: true}, + {Index: 2, Hardened: false}, } shortMuunPath = []PathIndex{ - PathIndex{Index: 1, Hardened: true, Name: "schema"}, + {Index: 1, Hardened: true, Name: "schema"}, } longMuunPath = []PathIndex{ - PathIndex{Index: 1, Hardened: true, Name: "schema"}, - PathIndex{Index: 1, Hardened: true, Name: "recovery"}, + {Index: 1, Hardened: true, Name: "schema"}, + {Index: 1, Hardened: true, Name: "recovery"}, } ) @@ -93,14 +93,46 @@ func TestPrefixRecognition(t *testing.T) { }{ {name: "empty prefix on empty path", args: args{path: "", prefix: ""}, want: true}, {name: "empty prefix on non-empty path", args: args{path: "m", prefix: ""}, want: true}, - {name: "long prefix on empty path", args: args{path: "", prefix: "m/schema:1'/recovery:1'"}, want: false}, - {name: "long prefix on short path", args: args{path: "m/schema:1'", prefix: "m/schema:1'/recovery:1'"}, want: false}, - {name: "same prefix and path", args: args{path: "m/0'/1'/4", prefix: "m/0'/1'/4"}, want: true}, - {name: "mismatched prefix at start", args: args{path: "m/44'/1'/2", prefix: "m/45'/1'/2"}, want: false}, - {name: "mismatched prefix at end", args: args{path: "m/44'/1'/2", prefix: "m/44'/1'/5"}, want: false}, - {name: "comments in path and prefix", args: args{path: "m/schema:1'/recovery:1'", prefix: "m/schema:1'"}, want: true}, - {name: "comments in path, not in prefix", args: args{path: "m/schema:1'/recovery:1'", prefix: "m/1'"}, want: true}, - {name: "comments in prefix, not in path", args: args{path: "m/1'/1'", prefix: "m/schema:1'"}, want: true}, + { + name: "long prefix on empty path", + args: args{path: "", prefix: "m/schema:1'/recovery:1'"}, + want: false, + }, + { + name: "long prefix on short path", + args: args{path: "m/schema:1'", prefix: "m/schema:1'/recovery:1'"}, + want: false, + }, + { + name: "same prefix and path", + args: args{path: "m/0'/1'/4", prefix: "m/0'/1'/4"}, + want: true, + }, + { + name: "mismatched prefix at start", + args: args{path: "m/44'/1'/2", prefix: "m/45'/1'/2"}, + want: false, + }, + { + name: "mismatched prefix at end", + args: args{path: "m/44'/1'/2", prefix: "m/44'/1'/5"}, + want: false, + }, + { + name: "comments in path and prefix", + args: args{path: "m/schema:1'/recovery:1'", prefix: "m/schema:1'"}, + want: true, + }, + { + name: "comments in path, not in prefix", + args: args{path: "m/schema:1'/recovery:1'", prefix: "m/1'"}, + want: true, + }, + { + name: "comments in prefix, not in path", + args: args{path: "m/1'/1'", prefix: "m/schema:1'"}, + want: true, + }, } for _, tt := range tests { @@ -115,7 +147,12 @@ func TestPrefixRecognition(t *testing.T) { } if path.HasPrefix(prefix) != tt.want { - t.Errorf("path.HasPrefix() = %v, with path=%v, prefix=%v", !tt.want, tt.args.path, tt.args.prefix) + t.Errorf( + "path.HasPrefix() = %v, with path=%v, prefix=%v", + !tt.want, + tt.args.path, + tt.args.prefix, + ) } }) } diff --git a/libwallet/hdprivatekey.go b/libwallet/hdprivatekey.go index fdd691ba..9e5adc0f 100644 --- a/libwallet/hdprivatekey.go +++ b/libwallet/hdprivatekey.go @@ -2,16 +2,15 @@ package libwallet import ( "crypto/sha256" - "errors" - "fmt" "strings" "github.com/btcsuite/btcd/btcec/v2" - "github.com/muun/libwallet/encryption" - "github.com/muun/libwallet/hdpath" - "github.com/btcsuite/btcd/btcec/v2/ecdsa" "github.com/btcsuite/btcd/btcutil/hdkeychain" + "github.com/go-errors/errors" + + "github.com/muun/libwallet/encryption" + "github.com/muun/libwallet/hdpath" ) // HDPrivateKey is an HD capable priv key @@ -32,9 +31,12 @@ func NewHDPrivateKey(seed []byte, network *Network) (*HDPrivateKey, error) { return &HDPrivateKey{key: *key, Network: network, Path: "m"}, nil } -// NewMasterHDPrivateKeyFromBytes builds an HD priv key from the compress priv and chain code for a given network -// The data is assumed to correspond to a master key. -func NewMasterHDPrivateKeyFromBytes(rawKey, chainCode []byte, network *Network) (*HDPrivateKey, error) { +// NewMasterHDPrivateKeyFromBytes builds an HD priv key from the compress priv and chain code for a +// given network The data is assumed to correspond to a master key. +func NewMasterHDPrivateKeyFromBytes( + rawKey, chainCode []byte, + network *Network, +) (*HDPrivateKey, error) { parentFP := []byte{0, 0, 0, 0} key := hdkeychain.NewExtendedKey(network.network.HDPrivateKeyID[:], @@ -43,11 +45,16 @@ func NewMasterHDPrivateKeyFromBytes(rawKey, chainCode []byte, network *Network) return &HDPrivateKey{key: *key, Network: network, Path: "m"}, nil } -// NewBasePathHDPrivateKeyFromBytes builds an HD priv key from the compress priv and chain code for a given network. -// The data is assumed to correspond to a key derived at our usual base path "m/schema:1'/recovery:1'". -func NewBasePathHDPrivateKeyFromBytes(rawKey, chainCode []byte, network *Network) (*HDPrivateKey, error) { +// NewBasePathHDPrivateKeyFromBytes builds an HD priv key from the compress +// priv and chain code for a given network. +// The data is assumed to correspond to a key derived at our usual base path +// "m/schema:1'/recovery:1'". +func NewBasePathHDPrivateKeyFromBytes( + rawKey, chainCode []byte, + network *Network, +) (*HDPrivateKey, error) { - basePath := "m/schema:1'/recovery:1'" + basePath := "m/schema:1'/recovery:1'" //nolint:goconst // TODO: extract "m/schema:1'/recovery:1'" to a const var depth uint8 = 2 var childNum uint32 = hdkeychain.HardenedKeyStart + 1 isPrivate := true @@ -102,10 +109,10 @@ func (p *HDPrivateKey) ChainCode() []byte { // index should be uint32 but for java compat we use int64 func (p *HDPrivateKey) DerivedAt(index int64, hardened bool) (*HDPrivateKey, error) { if index&hdkeychain.HardenedKeyStart != 0 { - return nil, fmt.Errorf("index should not be hardened (index %v)", index) + return nil, errors.Errorf("index should not be hardened (index %v)", index) } if index < 0 || index > int64(hdkeychain.HardenedKeyStart) { - return nil, fmt.Errorf("index is out of bounds (index %v)", index) + return nil, errors.Errorf("index is out of bounds (index %v)", index) } var modifier uint32 @@ -131,16 +138,18 @@ func (p *HDPrivateKey) DeriveTo(path string) (*HDPrivateKey, error) { firstPath, err := hdpath.Parse(p.Path) if err != nil { - return nil, fmt.Errorf("couldn't parse derivation path %v: %w", p.Path, err) + return nil, errors.Errorf("couldn't parse derivation path %v: %w", p.Path, err) } secondPath, err := hdpath.Parse(path) if err != nil { - return nil, fmt.Errorf("couldn't parse derivation path %v: %w", path, err) + return nil, errors.Errorf("couldn't parse derivation path %v: %w", path, err) } if !secondPath.HasPrefix(firstPath) { - return nil, fmt.Errorf("derivation path %v is not prefix of the keys path %v", path, p.Path) + return nil, errors.Errorf( + "derivation path %v is not prefix of the keys path %v", path, p.Path, + ) } indexes := secondPath.IndexesFrom(firstPath) @@ -148,7 +157,12 @@ func (p *HDPrivateKey) DeriveTo(path string) (*HDPrivateKey, error) { for depth, index := range indexes { derivedKey, err = derivedKey.DerivedAt(int64(index.Index), index.Hardened) if err != nil { - return nil, fmt.Errorf("failed to derive key at path %v on depth %v: %w", path, depth, err) + return nil, errors.Errorf( + "failed to derive key at path %v on depth %v: %w", + path, + depth, + err, + ) } } // The generated path has no names in it, so replace it @@ -165,17 +179,19 @@ func (p *HDPrivateKey) DeriveTo(path string) (*HDPrivateKey, error) { func (p *HDPrivateKey) deriveToPathWithHardenedBug(path string) (*HDPrivateKey, error) { if !strings.HasPrefix(path, p.Path) { - return nil, fmt.Errorf("derivation path %v is not prefix of the keys path %v", path, p.Path) + return nil, errors.Errorf( + "derivation path %v is not prefix of the keys path %v", path, p.Path, + ) } firstPath, err := hdpath.Parse(p.Path) if err != nil { - return nil, fmt.Errorf("couldn't parse derivation path %v: %w", p.Path, err) + return nil, errors.Errorf("couldn't parse derivation path %v: %w", p.Path, err) } secondPath, err := hdpath.Parse(path) if err != nil { - return nil, fmt.Errorf("couldn't parse derivation path %v: %w", path, err) + return nil, errors.Errorf("couldn't parse derivation path %v: %w", path, err) } derivedKey := &p.key @@ -192,7 +208,12 @@ func (p *HDPrivateKey) deriveToPathWithHardenedBug(path string) (*HDPrivateKey, derivedKeyPath = derivedKeyPath.Child(index) derivedKey, err = derivedKey.DeriveNonStandard(index) if err != nil { - return nil, fmt.Errorf("failed to derive key at path %v on depth %v: %w", path, depth, err) + return nil, errors.Errorf( + "failed to derive key at path %v on depth %v: %w", + path, + depth, + err, + ) } } diff --git a/libwallet/hdprivatekey_test.go b/libwallet/hdprivatekey_test.go index 264d7f2c..fec2f1bc 100644 --- a/libwallet/hdprivatekey_test.go +++ b/libwallet/hdprivatekey_test.go @@ -12,53 +12,53 @@ import ( const ( // m - vector1PrivKey = "xprv9s21ZrQH143K3QTDL4LXw2F7HEK3wJUD2nW2nRk4stbPy6cq3jPPqjiChkVvvNKmPGJxWUtg6LnF5kejMRNNU3TGtRBeJgk33yuGBxrMPHi" - vector1PubKey = "xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8" + vector1PrivKey = "xprv9s21ZrQH143K3QTDL4LXw2F7HEK3wJUD2nW2nRk4stbPy6cq3jPPqjiChkVvvNKmPGJxWUtg6LnF5kejMRNNU3TGtRBeJgk33yuGBxrMPHi" //nolint:lll + vector1PubKey = "xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8" //nolint:lll vector1FirstPath = "m/0'/1" - vector1FirstPriv = "xprv9wTYmMFdV23N2TdNG573QoEsfRrWKQgWeibmLntzniatZvR9BmLnvSxqu53Kw1UmYPxLgboyZQaXwTCg8MSY3H2EU4pWcQDnRnrVA1xe8fs" - vector1FirstPub = "xpub6ASuArnXKPbfEwhqN6e3mwBcDTgzisQN1wXN9BJcM47sSikHjJf3UFHKkNAWbWMiGj7Wf5uMash7SyYq527Hqck2AxYysAA7xmALppuCkwQ" + vector1FirstPriv = "xprv9wTYmMFdV23N2TdNG573QoEsfRrWKQgWeibmLntzniatZvR9BmLnvSxqu53Kw1UmYPxLgboyZQaXwTCg8MSY3H2EU4pWcQDnRnrVA1xe8fs" //nolint:lll + vector1FirstPub = "xpub6ASuArnXKPbfEwhqN6e3mwBcDTgzisQN1wXN9BJcM47sSikHjJf3UFHKkNAWbWMiGj7Wf5uMash7SyYq527Hqck2AxYysAA7xmALppuCkwQ" //nolint:lll vector1SecondPath = "m/0'/1/2'/2" - vector1SecondPriv = "xprvA2JDeKCSNNZky6uBCviVfJSKyQ1mDYahRjijr5idH2WwLsEd4Hsb2Tyh8RfQMuPh7f7RtyzTtdrbdqqsunu5Mm3wDvUAKRHSC34sJ7in334" - vector1SecondPub = "xpub6FHa3pjLCk84BayeJxFW2SP4XRrFd1JYnxeLeU8EqN3vDfZmbqBqaGJAyiLjTAwm6ZLRQUMv1ZACTj37sR62cfN7fe5JnJ7dh8zL4fiyLHV" + vector1SecondPriv = "xprvA2JDeKCSNNZky6uBCviVfJSKyQ1mDYahRjijr5idH2WwLsEd4Hsb2Tyh8RfQMuPh7f7RtyzTtdrbdqqsunu5Mm3wDvUAKRHSC34sJ7in334" //nolint:lll + vector1SecondPub = "xpub6FHa3pjLCk84BayeJxFW2SP4XRrFd1JYnxeLeU8EqN3vDfZmbqBqaGJAyiLjTAwm6ZLRQUMv1ZACTj37sR62cfN7fe5JnJ7dh8zL4fiyLHV" //nolint:lll - vector2PrivKey = "xprv9s21ZrQH143K31xYSDQpPDxsXRTUcvj2iNHm5NUtrGiGG5e2DtALGdso3pGz6ssrdK4PFmM8NSpSBHNqPqm55Qn3LqFtT2emdEXVYsCzC2U" - vector2PubKey = "xpub661MyMwAqRbcFW31YEwpkMuc5THy2PSt5bDMsktWQcFF8syAmRUapSCGu8ED9W6oDMSgv6Zz8idoc4a6mr8BDzTJY47LJhkJ8UB7WEGuduB" + vector2PrivKey = "xprv9s21ZrQH143K31xYSDQpPDxsXRTUcvj2iNHm5NUtrGiGG5e2DtALGdso3pGz6ssrdK4PFmM8NSpSBHNqPqm55Qn3LqFtT2emdEXVYsCzC2U" //nolint:lll + vector2PubKey = "xpub661MyMwAqRbcFW31YEwpkMuc5THy2PSt5bDMsktWQcFF8syAmRUapSCGu8ED9W6oDMSgv6Zz8idoc4a6mr8BDzTJY47LJhkJ8UB7WEGuduB" //nolint:lll vector2FirstPath = "m/0" - vector2FirstPriv = "xprv9vHkqa6EV4sPZHYqZznhT2NPtPCjKuDKGY38FBWLvgaDx45zo9WQRUT3dKYnjwih2yJD9mkrocEZXo1ex8G81dwSM1fwqWpWkeS3v86pgKt" - vector2FirstPub = "xpub69H7F5d8KSRgmmdJg2KhpAK8SR3DjMwAdkxj3ZuxV27CprR9LgpeyGmXUbC6wb7ERfvrnKZjXoUmmDznezpbZb7ap6r1D3tgFxHmwMkQTPH" + vector2FirstPriv = "xprv9vHkqa6EV4sPZHYqZznhT2NPtPCjKuDKGY38FBWLvgaDx45zo9WQRUT3dKYnjwih2yJD9mkrocEZXo1ex8G81dwSM1fwqWpWkeS3v86pgKt" //nolint:lll + vector2FirstPub = "xpub69H7F5d8KSRgmmdJg2KhpAK8SR3DjMwAdkxj3ZuxV27CprR9LgpeyGmXUbC6wb7ERfvrnKZjXoUmmDznezpbZb7ap6r1D3tgFxHmwMkQTPH" //nolint:lll vector2SecondPath = "m/0/2147483647'/1" - vector2SecondPriv = "xprv9zFnWC6h2cLgpmSA46vutJzBcfJ8yaJGg8cX1e5StJh45BBciYTRXSd25UEPVuesF9yog62tGAQtHjXajPPdbRCHuWS6T8XA2ECKADdw4Ef" - vector2SecondPub = "xpub6DF8uhdarytz3FWdA8TvFSvvAh8dP3283MY7p2V4SeE2wyWmG5mg5EwVvmdMVCQcoNJxGoWaU9DCWh89LojfZ537wTfunKau47EL2dhHKon" + vector2SecondPriv = "xprv9zFnWC6h2cLgpmSA46vutJzBcfJ8yaJGg8cX1e5StJh45BBciYTRXSd25UEPVuesF9yog62tGAQtHjXajPPdbRCHuWS6T8XA2ECKADdw4Ef" //nolint:lll + vector2SecondPub = "xpub6DF8uhdarytz3FWdA8TvFSvvAh8dP3283MY7p2V4SeE2wyWmG5mg5EwVvmdMVCQcoNJxGoWaU9DCWh89LojfZ537wTfunKau47EL2dhHKon" //nolint:lll vector2ThirdPath = "m/0/2147483647'/1/2147483646'" - vector2ThirdPriv = "xprvA1RpRA33e1JQ7ifknakTFpgNXPmW2YvmhqLQYMmrj4xJXXWYpDPS3xz7iAxn8L39njGVyuoseXzU6rcxFLJ8HFsTjSyQbLYnMpCqE2VbFWc" - vector2ThirdPub = "xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL" + vector2ThirdPriv = "xprvA1RpRA33e1JQ7ifknakTFpgNXPmW2YvmhqLQYMmrj4xJXXWYpDPS3xz7iAxn8L39njGVyuoseXzU6rcxFLJ8HFsTjSyQbLYnMpCqE2VbFWc" //nolint:lll + vector2ThirdPub = "xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL" //nolint:lll - vector3PrivKey = "xprv9s21ZrQH143K25QhxbucbDDuQ4naNntJRi4KUfWT7xo4EKsHt2QJDu7KXp1A3u7Bi1j8ph3EGsZ9Xvz9dGuVrtHHs7pXeTzjuxBrCmmhgC6" - vector3PubKey = "xpub661MyMwAqRbcEZVB4dScxMAdx6d4nFc9nvyvH3v4gJL378CSRZiYmhRoP7mBy6gSPSCYk6SzXPTf3ND1cZAceL7SfJ1Z3GC8vBgp2epUt13" + vector3PrivKey = "xprv9s21ZrQH143K25QhxbucbDDuQ4naNntJRi4KUfWT7xo4EKsHt2QJDu7KXp1A3u7Bi1j8ph3EGsZ9Xvz9dGuVrtHHs7pXeTzjuxBrCmmhgC6" //nolint:lll + vector3PubKey = "xpub661MyMwAqRbcEZVB4dScxMAdx6d4nFc9nvyvH3v4gJL378CSRZiYmhRoP7mBy6gSPSCYk6SzXPTf3ND1cZAceL7SfJ1Z3GC8vBgp2epUt13" //nolint:lll vector3FirstPath = "m/0'" - vector3FirstPriv = "xprv9uPDJpEQgRQfDcW7BkF7eTya6RPxXeJCqCJGHuCJ4GiRVLzkTXBAJMu2qaMWPrS7AANYqdq6vcBcBUdJCVVFceUvJFjaPdGZ2y9WACViL4L" - vector3FirstPub = "xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y" + vector3FirstPriv = "xprv9uPDJpEQgRQfDcW7BkF7eTya6RPxXeJCqCJGHuCJ4GiRVLzkTXBAJMu2qaMWPrS7AANYqdq6vcBcBUdJCVVFceUvJFjaPdGZ2y9WACViL4L" //nolint:lll + vector3FirstPub = "xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y" //nolint:lll - symmetricPrivKey = "xprv9s21ZrQH143K31xYSDQpPDxsXRTUcvj2iNHm5NUtrGiGG5e2DtALGdso3pGz6ssrdK4PFmM8NSpSBHNqPqm55Qn3LqFtT2emdEXVYsCzC2U" - symmetricPubKey = "xpub661MyMwAqRbcFW31YEwpkMuc5THy2PSt5bDMsktWQcFF8syAmRUapSCGu8ED9W6oDMSgv6Zz8idoc4a6mr8BDzTJY47LJhkJ8UB7WEGuduB" + symmetricPrivKey = "xprv9s21ZrQH143K31xYSDQpPDxsXRTUcvj2iNHm5NUtrGiGG5e2DtALGdso3pGz6ssrdK4PFmM8NSpSBHNqPqm55Qn3LqFtT2emdEXVYsCzC2U" //nolint:lll + symmetricPubKey = "xpub661MyMwAqRbcFW31YEwpkMuc5THy2PSt5bDMsktWQcFF8syAmRUapSCGu8ED9W6oDMSgv6Zz8idoc4a6mr8BDzTJY47LJhkJ8UB7WEGuduB" //nolint:lll symmetricFirstPath = "m/0" - symmetricFirstPriv = "xprv9vHkqa6EV4sPZHYqZznhT2NPtPCjKuDKGY38FBWLvgaDx45zo9WQRUT3dKYnjwih2yJD9mkrocEZXo1ex8G81dwSM1fwqWpWkeS3v86pgKt" - symmetricFirstPub = "xpub69H7F5d8KSRgmmdJg2KhpAK8SR3DjMwAdkxj3ZuxV27CprR9LgpeyGmXUbC6wb7ERfvrnKZjXoUmmDznezpbZb7ap6r1D3tgFxHmwMkQTPH" + symmetricFirstPriv = "xprv9vHkqa6EV4sPZHYqZznhT2NPtPCjKuDKGY38FBWLvgaDx45zo9WQRUT3dKYnjwih2yJD9mkrocEZXo1ex8G81dwSM1fwqWpWkeS3v86pgKt" //nolint:lll + symmetricFirstPub = "xpub69H7F5d8KSRgmmdJg2KhpAK8SR3DjMwAdkxj3ZuxV27CprR9LgpeyGmXUbC6wb7ERfvrnKZjXoUmmDznezpbZb7ap6r1D3tgFxHmwMkQTPH" //nolint:lll symmetricSecondPath = "m/0/2147483647'/1" - symmetricSecondPriv = "xprv9zFnWC6h2cLgpmSA46vutJzBcfJ8yaJGg8cX1e5StJh45BBciYTRXSd25UEPVuesF9yog62tGAQtHjXajPPdbRCHuWS6T8XA2ECKADdw4Ef" - symmetricSecondPub = "xpub6DF8uhdarytz3FWdA8TvFSvvAh8dP3283MY7p2V4SeE2wyWmG5mg5EwVvmdMVCQcoNJxGoWaU9DCWh89LojfZ537wTfunKau47EL2dhHKon" + symmetricSecondPriv = "xprv9zFnWC6h2cLgpmSA46vutJzBcfJ8yaJGg8cX1e5StJh45BBciYTRXSd25UEPVuesF9yog62tGAQtHjXajPPdbRCHuWS6T8XA2ECKADdw4Ef" //nolint:lll + symmetricSecondPub = "xpub6DF8uhdarytz3FWdA8TvFSvvAh8dP3283MY7p2V4SeE2wyWmG5mg5EwVvmdMVCQcoNJxGoWaU9DCWh89LojfZ537wTfunKau47EL2dhHKon" //nolint:lll symmetricThirdPath = "m/0/2147483647'/1/2147483646'" - symmetricThirdPriv = "xprvA1RpRA33e1JQ7ifknakTFpgNXPmW2YvmhqLQYMmrj4xJXXWYpDPS3xz7iAxn8L39njGVyuoseXzU6rcxFLJ8HFsTjSyQbLYnMpCqE2VbFWc" - symmetricThirdPub = "xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL" + symmetricThirdPriv = "xprvA1RpRA33e1JQ7ifknakTFpgNXPmW2YvmhqLQYMmrj4xJXXWYpDPS3xz7iAxn8L39njGVyuoseXzU6rcxFLJ8HFsTjSyQbLYnMpCqE2VbFWc" //nolint:lll + symmetricThirdPub = "xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL" //nolint:lll ) var ( @@ -87,7 +87,11 @@ func TestNewHDPrivateKeySerialization(t *testing.T) { badPubKey, err := NewHDPublicKeyFromString(vector1FirstPriv, "m", Regtest()) if badPubKey != nil || err == nil { - t.Errorf("expected failure when parsing priv key as pub key, got %v, %v", badPubKey, err) + t.Errorf( + "expected failure when parsing priv key as pub key, got %v, %v", + badPubKey, + err, + ) } }) @@ -123,7 +127,10 @@ func TestNewHDPrivateKeySerialization(t *testing.T) { t.Run("Child key serialization", func(t *testing.T) { root, err := NewHDPrivateKeyFromString( - "tprv8ZgxMBicQKsPdGCzsJ31BsQnFL1TSQ82dfsZYTtsWJ1T8g7xTfnV19gf8nYPqzkzk6yLL9kzDYshmUrYyXt7uXsGbk9eN7juRxg9sjaxSjn", "m", Regtest()) + "tprv8ZgxMBicQKsPdGCzsJ31BsQnFL1TSQ82dfsZYTtsWJ1T8g7xTfnV19gf8nYPqzkzk6yLL9kzDYshmUrYyXt7uXsGbk9eN7juRxg9sjaxSjn", //nolint:lll + "m", + Regtest(), + ) if err != nil { t.Fatalf("failed to parse root key") } @@ -131,7 +138,7 @@ func TestNewHDPrivateKeySerialization(t *testing.T) { key1, _ := root.DerivedAt(1, true) key2, _ := key1.DerivedAt(1, true) - const encodedKey = "tprv8e8vMhwEcLr1ZfZETKTQSpxJ6KfZuczALe8KrRCDLpSbXPwp7PY1ZVHtqUkFsYZETPRcfjVSCv8DiYP9RyAZrFhnLE8aYdaSaZEWyT5c8Ji" + const encodedKey = "tprv8e8vMhwEcLr1ZfZETKTQSpxJ6KfZuczALe8KrRCDLpSbXPwp7PY1ZVHtqUkFsYZETPRcfjVSCv8DiYP9RyAZrFhnLE8aYdaSaZEWyT5c8Ji" //nolint:lll if key2.String() != encodedKey { t.Fatalf("derived key doesn't match serialized") } diff --git a/libwallet/hdpublickey.go b/libwallet/hdpublickey.go index 54886409..1f959b89 100644 --- a/libwallet/hdpublickey.go +++ b/libwallet/hdpublickey.go @@ -1,11 +1,11 @@ package libwallet import ( - "errors" - "fmt" "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/btcutil/hdkeychain" + "github.com/go-errors/errors" + "github.com/muun/libwallet/hdpath" ) @@ -42,10 +42,10 @@ func (p *HDPublicKey) String() string { func (p *HDPublicKey) DerivedAt(index int64) (*HDPublicKey, error) { if index&hdkeychain.HardenedKeyStart != 0 { - return nil, fmt.Errorf("can't derive a hardened pub key (index %v)", index) + return nil, errors.Errorf("can't derive a hardened pub key (index %v)", index) } if index < 0 || index > int64(hdkeychain.HardenedKeyStart) { - return nil, fmt.Errorf("index is out of bounds (index %v)", index) + return nil, errors.Errorf("index is out of bounds (index %v)", index) } child, err := p.key.Derive(uint32(index)) @@ -66,28 +66,35 @@ func (p *HDPublicKey) DeriveTo(path string) (*HDPublicKey, error) { firstPath, err := hdpath.Parse(p.Path) if err != nil { - return nil, fmt.Errorf("couldn't parse derivation path %v: %w", p.Path, err) + return nil, errors.Errorf("couldn't parse derivation path %v: %w", p.Path, err) } secondPath, err := hdpath.Parse(path) if err != nil { - return nil, fmt.Errorf("couldn't parse derivation path %v: %w", path, err) + return nil, errors.Errorf("couldn't parse derivation path %v: %w", path, err) } if !secondPath.HasPrefix(firstPath) { - return nil, fmt.Errorf("derivation path %v is not prefix of the keys path %v", path, p.Path) + return nil, errors.Errorf( + "derivation path %v is not prefix of the keys path %v", path, p.Path, + ) } indexes := secondPath.IndexesFrom(firstPath) derivedKey := p for depth, index := range indexes { if index.Hardened { - return nil, fmt.Errorf("can't derive a hardened pub key (path %v)", path) + return nil, errors.Errorf("can't derive a hardened pub key (path %v)", path) } derivedKey, err = derivedKey.DerivedAt(int64(index.Index)) if err != nil { - return nil, fmt.Errorf("failed to derive key at path %v on depth %v: %w", path, depth, err) + return nil, errors.Errorf( + "failed to derive key at path %v on depth %v: %w", + path, + depth, + err, + ) } } // The generated path has no names in it, so replace it diff --git a/libwallet/hdpublickey_test.go b/libwallet/hdpublickey_test.go index 2986115a..553f930f 100644 --- a/libwallet/hdpublickey_test.go +++ b/libwallet/hdpublickey_test.go @@ -34,7 +34,7 @@ func TestHDPublicKey_DerivedAt(t *testing.T) { func TestHDPublicKey_Fingerprint(t *testing.T) { pubKey, _ := NewHDPublicKeyFromString( - "xpub661MyMwAqRbcF3YgLe8xTTTrDHf5bmEQuj5XfQP3bvwHqBpYvt99tcMSXXzroWJoQM4eMDNZNzNYZEJfTqxq5S82J644buASmW4Y7VnwUeJ", + "xpub661MyMwAqRbcF3YgLe8xTTTrDHf5bmEQuj5XfQP3bvwHqBpYvt99tcMSXXzroWJoQM4eMDNZNzNYZEJfTqxq5S82J644buASmW4Y7VnwUeJ", //nolint:lll "m/schema:1'/recovery:1'", Mainnet(), ) diff --git a/libwallet/incoming_swap.go b/libwallet/incoming_swap.go index 1e63102e..88a87e07 100644 --- a/libwallet/incoming_swap.go +++ b/libwallet/incoming_swap.go @@ -3,16 +3,16 @@ package libwallet import ( "bytes" "crypto/sha256" - "errors" - "fmt" "strings" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" + "github.com/go-errors/errors" lndinput "github.com/lightningnetwork/lnd/input" "github.com/lightningnetwork/lnd/lnwire" + "github.com/muun/libwallet/btcsuitew/txscriptw" "github.com/muun/libwallet/hdpath" "github.com/muun/libwallet/sphinx" @@ -50,13 +50,15 @@ type IncomingSwapFulfillmentResult struct { } func (s *IncomingSwap) getInvoice() (*walletdb.Invoice, error) { - db, err := openDB() - if err != nil { + var invoice *walletdb.Invoice + if err := Pool.WithDB(func(db *walletdb.DB) error { + var err error + invoice, err = db.FindByPaymentHash(s.PaymentHash) + return err + }); err != nil { return nil, err } - defer db.Close() - - return db.FindByPaymentHash(s.PaymentHash) + return invoice, nil } // VerifyFulfillable checks that an incoming swap is fulfillable. @@ -64,34 +66,42 @@ func (s *IncomingSwap) VerifyFulfillable(userKey *HDPrivateKey, net *Network) er paymentHash := s.PaymentHash if len(paymentHash) != 32 { - return fmt.Errorf("VerifyFulfillable: received invalid hash len %v", len(paymentHash)) + return errors.Errorf("VerifyFulfillable: received invalid hash len %v", len(paymentHash)) } // Lookup invoice data matching this HTLC using the payment hash invoice, err := s.getInvoice() if err != nil { - return fmt.Errorf("VerifyFulfillable: could not find invoice data for payment hash: %w", err) + return errors.Errorf( + "VerifyFulfillable: could not find invoice data for payment hash: %w", + err, + ) } parentPath, err := hdpath.Parse(invoice.KeyPath) if err != nil { - return fmt.Errorf("VerifyFulfillable: invoice key path is not valid: %v", invoice.KeyPath) + return errors.Errorf( + "VerifyFulfillable: invoice key path is not valid: %v", invoice.KeyPath, + ) } identityKeyPath := parentPath.Child(identityKeyChildIndex) nodeHDKey, err := userKey.DeriveTo(identityKeyPath.String()) if err != nil { - return fmt.Errorf("VerifyFulfillable: failed to derive key: %w", err) + return errors.Errorf("VerifyFulfillable: failed to derive key: %w", err) } nodeKey, err := nodeHDKey.key.ECPrivKey() if err != nil { - return fmt.Errorf("VerifyFulfillable: failed to get priv key: %w", err) + return errors.Errorf("VerifyFulfillable: failed to get priv key: %w", err) } // implementation is allowed to send a few extra sats if invoice.AmountSat != 0 && invoice.AmountSat > s.PaymentAmountSat { - return fmt.Errorf("VerifyFulfillable: payment amount (%v) does not match invoice amount (%v)", - s.PaymentAmountSat, invoice.AmountSat) + return errors.Errorf( + "VerifyFulfillable: payment amount (%v) does not match invoice amount (%v)", + s.PaymentAmountSat, + invoice.AmountSat, + ) } if len(s.SphinxPacket) == 0 { @@ -108,7 +118,7 @@ func (s *IncomingSwap) VerifyFulfillable(userKey *HDPrivateKey, net *Network) er net.network, ) if err != nil { - return fmt.Errorf("VerifyFulfillable: invalid sphinx: %w", err) + return errors.Errorf("VerifyFulfillable: invalid sphinx: %w", err) } return nil @@ -122,7 +132,7 @@ func (s *IncomingSwap) Fulfill( net *Network) (*IncomingSwapFulfillmentResult, error) { if s.Htlc == nil { - return nil, fmt.Errorf("Fulfill: missing swap htlc data") + return nil, errors.Errorf("Fulfill: missing swap htlc data") } // TODO: add debug logs (e.g debug logging capabilities in libwallet) @@ -136,19 +146,25 @@ func (s *IncomingSwap) Fulfill( tx := wire.MsgTx{} err = tx.DeserializeNoWitness(bytes.NewReader(data.FulfillmentTx)) if err != nil { - return nil, fmt.Errorf("Fulfill: could not deserialize fulfillment tx: %w", err) + return nil, errors.Errorf("Fulfill: could not deserialize fulfillment tx: %w", err) } if len(tx.TxIn) != 1 { - return nil, fmt.Errorf("Fulfill: expected fulfillment tx to have exactly 1 input, found %d", len(tx.TxIn)) + return nil, errors.Errorf( + "Fulfill: expected fulfillment tx to have exactly 1 input, found %d", + len(tx.TxIn), + ) } if len(tx.TxOut) != 1 { - return nil, fmt.Errorf("Fulfill: expected fulfillment tx to have exactly 1 output, found %d", len(tx.TxOut)) + return nil, errors.Errorf( + "Fulfill: expected fulfillment tx to have exactly 1 output, found %d", + len(tx.TxOut), + ) } // Lookup invoice data matching this HTLC using the payment hash invoice, err := s.getInvoice() if err != nil { - return nil, fmt.Errorf("Fulfill: could not find invoice data for payment hash: %w", err) + return nil, errors.Errorf("Fulfill: could not find invoice data for payment hash: %w", err) } // Sign the htlc input (there is only one, at index 0) @@ -172,7 +188,7 @@ func (s *IncomingSwap) Fulfill( var buf bytes.Buffer err = tx.Serialize(&buf) if err != nil { - return nil, fmt.Errorf("Fulfill: could not serialize fulfillment tx: %w", err) + return nil, errors.Errorf("Fulfill: could not serialize fulfillment tx: %w", err) } return &IncomingSwapFulfillmentResult{ FulfillmentTx: buf.Bytes(), @@ -184,15 +200,16 @@ func (s *IncomingSwap) Fulfill( func (s *IncomingSwap) FulfillFullDebt() (*IncomingSwapFulfillmentResult, error) { // Lookup invoice data matching this HTLC using the payment hash - db, err := openDB() - if err != nil { - return nil, err - } - defer db.Close() - - secrets, err := db.FindByPaymentHash(s.PaymentHash) - if err != nil { - return nil, fmt.Errorf("FulfillFullDebt: could not find invoice data for payment hash: %w", err) + var secrets *walletdb.Invoice + if err := Pool.WithDB(func(db *walletdb.DB) error { + var err error + secrets, err = db.FindByPaymentHash(s.PaymentHash) + return err + }); err != nil { + return nil, errors.Errorf( + "FulfillFullDebt: could not find invoice data for payment hash: %w", + err, + ) } return &IncomingSwapFulfillmentResult{ @@ -216,37 +233,48 @@ type coinIncomingSwap struct { } // NOTE: this method only works on segwit v0 txs -func (c *coinIncomingSwap) SignInput(index int, tx *wire.MsgTx, userKey *HDPrivateKey, muunKey *HDPublicKey) error { +func (c *coinIncomingSwap) SignInput( + index int, + tx *wire.MsgTx, + userKey *HDPrivateKey, + muunKey *HDPublicKey, +) error { // Deserialize the HTLC transaction htlcTx := wire.MsgTx{} err := htlcTx.Deserialize(bytes.NewReader(c.HtlcTx)) if err != nil { - return fmt.Errorf("could not deserialize htlc tx: %w", err) + return errors.Errorf("could not deserialize htlc tx: %w", err) } - // Lookup invoice data matching this HTLC using the payment hash - db, err := openDB() - if err != nil { + // Lookup invoice data matching this HTLC using the payment hash. We keep the lookup result + // separate from WithDB's own error so that a genuine "DB unavailable" failure surfaces + // immediately, instead of being masked as a missing invoice and routed into the recovery + // path below (which would sign with fallback-constructed secrets). + var secrets *walletdb.Invoice + var lookupErr error + if err := Pool.WithDB(func(db *walletdb.DB) error { + secrets, lookupErr = db.FindByPaymentHash(c.PaymentHash256) + return nil + }); err != nil { return err } - defer db.Close() - - secrets, err := db.FindByPaymentHash(c.PaymentHash256) - if err != nil { + if secrets == nil { - // Note: there's an edge case where fulfillment txs can be dropped and clients may forgot the invoice secrets - // (e.g if they logout) thus being unable to spend incoming swap inputs. Here we try to collaborate with Houston - // (in the cases where preimage data was previously revealed) to allow clients to spend these inputs that - // otherwise would be un-spendable. + // Note: there's an edge case where fulfillment txs can be dropped and clients may forgot + // the invoice secrets (e.g if they logout) thus being unable to spend incoming swap inputs. + // Here we try to collaborate with Houston (in the cases where preimage data was previously + // revealed) to allow clients to spend these inputs that otherwise would be un-spendable. if len(c.Preimage) > 0 { - // There's actually several derivation paths involved in incoming swaps, all of which are needed by the apps - // to sign an HTLC input. However, apps register only one of these fully-derived paths. Lucky for us, apps - // internally derive all these paths out of a single base path. We can simply drop the last level from the - // path we have to obtain the common root. See [ GenerateInvoiceSecrets ] in invoices.go for impl details. + // There's actually several derivation paths involved in incoming swaps, all of which + // are needed by the apps to sign an HTLC input. However, apps register only one of + // these fully-derived paths. Lucky for us, apps internally derive all these paths out + // of a single base path. We can simply drop the last level from the path we have to + // obtain the common root. + // See [ GenerateInvoiceSecrets ] in invoices.go for impl details. - // Backend provides the htlcOutputKeypath, but what we store in the Invoice secrets db table is the - // invoice's base key derivation path. + // Backend provides the htlcOutputKeypath, but what we store in the Invoice secrets db + // table is the invoice's base key derivation path. index := strings.LastIndex(c.HtlcOutputKeyPath, "/") invoiceBaseKeyPath := c.HtlcOutputKeyPath[:index] @@ -255,13 +283,13 @@ func (c *coinIncomingSwap) SignInput(index int, tx *wire.MsgTx, userKey *HDPriva KeyPath: invoiceBaseKeyPath, } } else { - return fmt.Errorf("could not find invoice data for payment hash: %w", err) + return errors.Errorf("could not find invoice data for payment hash: %w", lookupErr) } } parentPath, err := hdpath.Parse(secrets.KeyPath) if err != nil { - return fmt.Errorf("invalid invoice key path: %w", err) + return errors.Errorf("invalid invoice key path: %w", err) } // Recreate the HTLC script to verify it matches the transaction. For this @@ -282,7 +310,7 @@ func (c *coinIncomingSwap) SignInput(index int, tx *wire.MsgTx, userKey *HDPriva htlcScript, err := c.createHtlcScript(userPublicKey, muunPublicKey) if err != nil { - return fmt.Errorf("could not create htlc script: %w", err) + return errors.Errorf("could not create htlc script: %w", err) } // Try to find the script we just built inside the HTLC output scripts @@ -307,10 +335,10 @@ func (c *coinIncomingSwap) SignInput(index int, tx *wire.MsgTx, userKey *HDPriva txInput := tx.TxIn[index] if txInput.PreviousOutPoint.Hash != htlcTx.TxHash() { - return fmt.Errorf("expected fulfillment tx input to point to htlc tx") + return errors.Errorf("expected fulfillment tx input to point to htlc tx") } if txInput.PreviousOutPoint.Index != uint32(htlcOutputIndex) { - return fmt.Errorf("expected fulfillment tx input to point to correct htlc output") + return errors.Errorf("expected fulfillment tx input to point to correct htlc output") } sigHashes := lndinput.NewTxSigHashesV0Only(tx) @@ -332,20 +360,21 @@ func (c *coinIncomingSwap) SignInput(index int, tx *wire.MsgTx, userKey *HDPriva muunSigKey, ) if err != nil { - return fmt.Errorf("could not verify Muun signature for htlc: %w", err) + return errors.Errorf("could not verify Muun signature for htlc: %w", err) } var outputAmount, expectedAmount lnwire.MilliSatoshi if c.VerifyOutputAmount { outputAmount = lnwire.MilliSatoshi(tx.TxOut[0].Value * 1000) - // This incoming swap might be collecting debt, which would be deducted from the outputAmount - // so we add it back up so the amount will match with the sphinx + // This incoming swap might be collecting debt, which would be deducted from the + // outputAmount so we add it back up so the amount will match with the sphinx expectedAmount = outputAmount + lnwire.NewMSatFromSatoshis(c.Collect) } - // Now check the information we have against the sphinx created by the payer - // Note: we avoid this validation if we're collaboratively signing with Houston (since we don't know payment secret) + // Now check the information we have against the sphinx created by the payer. + // Note: we avoid this validation if we're collaboratively signing with Houston + // (since we don't know payment secret) if len(c.Sphinx) > 0 && len(secrets.PaymentSecret) > 0 { err = sphinx.Validate( c.Sphinx, @@ -357,7 +386,7 @@ func (c *coinIncomingSwap) SignInput(index int, tx *wire.MsgTx, userKey *HDPriva c.Network, ) if err != nil { - return fmt.Errorf("could not verify sphinx blob: %w", err) + return errors.Errorf("could not verify sphinx blob: %w", err) } } @@ -370,7 +399,7 @@ func (c *coinIncomingSwap) SignInput(index int, tx *wire.MsgTx, userKey *HDPriva btcutil.Amount(htlcOutputAmount), ) if err != nil { - return fmt.Errorf("could not sign fulfillment tx: %w", err) + return errors.Errorf("could not sign fulfillment tx: %w", err) } txInput.Witness = wire.TxWitness{ @@ -383,25 +412,33 @@ func (c *coinIncomingSwap) SignInput(index int, tx *wire.MsgTx, userKey *HDPriva return nil } -func (c *coinIncomingSwap) FullySignInput(index int, tx *wire.MsgTx, userKey, muunKey *HDPrivateKey) error { +func (c *coinIncomingSwap) FullySignInput( + index int, + tx *wire.MsgTx, + userKey, muunKey *HDPrivateKey, +) error { // Lookup invoice data matching this HTLC using the payment hash - db, err := openDB() - if err != nil { + var secrets *walletdb.Invoice + if err := Pool.WithDB(func(db *walletdb.DB) error { + var err error + secrets, err = db.FindByPaymentHash(c.PaymentHash256) return err - } - defer db.Close() - - secrets, err := db.FindByPaymentHash(c.PaymentHash256) - if err != nil { - return fmt.Errorf("could not find invoice data for payment hash: %w", err) + }); err != nil { + return errors.Errorf("could not find invoice data for payment hash: %w", err) } derivedMuunKey, err := muunKey.DeriveTo(secrets.KeyPath) if err != nil { - return fmt.Errorf("failed to derive muun key: %w", err) + return errors.Errorf("failed to derive muun key: %w", err) } - muunSignature, err := c.signature(index, tx, userKey.PublicKey(), derivedMuunKey.PublicKey(), derivedMuunKey) + muunSignature, err := c.signature( + index, + tx, + userKey.PublicKey(), + derivedMuunKey.PublicKey(), + derivedMuunKey, + ) if err != nil { return err } @@ -409,7 +446,9 @@ func (c *coinIncomingSwap) FullySignInput(index int, tx *wire.MsgTx, userKey, mu return c.SignInput(index, tx, userKey, muunKey.PublicKey()) } -func (c *coinIncomingSwap) createHtlcScript(userPublicKey, muunPublicKey *HDPublicKey) ([]byte, error) { +func (c *coinIncomingSwap) createHtlcScript( + userPublicKey, muunPublicKey *HDPublicKey, +) ([]byte, error) { return createHtlcScript( userPublicKey.Raw(), muunPublicKey.Raw(), @@ -419,18 +458,23 @@ func (c *coinIncomingSwap) createHtlcScript(userPublicKey, muunPublicKey *HDPubl ) } -func (c *coinIncomingSwap) signature(index int, tx *wire.MsgTx, userKey *HDPublicKey, muunKey *HDPublicKey, - signingKey *HDPrivateKey) ([]byte, error) { +func (c *coinIncomingSwap) signature( + index int, + tx *wire.MsgTx, + userKey *HDPublicKey, + muunKey *HDPublicKey, + signingKey *HDPrivateKey, +) ([]byte, error) { htlcTx := wire.MsgTx{} err := htlcTx.Deserialize(bytes.NewReader(c.HtlcTx)) if err != nil { - return nil, fmt.Errorf("could not deserialize htlc tx: %w", err) + return nil, errors.Errorf("could not deserialize htlc tx: %w", err) } htlcScript, err := c.createHtlcScript(userKey, muunKey) if err != nil { - return nil, fmt.Errorf("could not create htlc script: %w", err) + return nil, errors.Errorf("could not create htlc script: %w", err) } htlcOutputIndex, err := c.findHtlcOutputIndex(&htlcTx, htlcScript) @@ -448,7 +492,7 @@ func (c *coinIncomingSwap) signature(index int, tx *wire.MsgTx, userKey *HDPubli btcutil.Amount(prevOutAmount), ) if err != nil { - return nil, fmt.Errorf("could not sign fulfillment tx: %w", err) + return nil, errors.Errorf("could not sign fulfillment tx: %w", err) } return sig, nil } @@ -457,12 +501,12 @@ func (c *coinIncomingSwap) findHtlcOutputIndex(htlcTx *wire.MsgTx, htlcScript [] witnessHash := sha256.Sum256(htlcScript) address, err := btcutil.NewAddressWitnessScriptHash(witnessHash[:], c.Network) if err != nil { - return 0, fmt.Errorf("could not create htlc address: %w", err) + return 0, errors.Errorf("could not create htlc address: %w", err) } pkScript, err := txscriptw.PayToAddrScript(address) if err != nil { - return 0, fmt.Errorf("could not create pk script: %w", err) + return 0, errors.Errorf("could not create pk script: %w", err) } // Try to find the script we just built inside the HTLC output scripts @@ -475,7 +519,11 @@ func (c *coinIncomingSwap) findHtlcOutputIndex(htlcTx *wire.MsgTx, htlcScript [] return 0, errors.New("could not find valid htlc output in htlc tx") } -func createHtlcScript(userPublicKey, muunPublicKey, swapServerPublicKey []byte, expiry int64, paymentHash []byte) ([]byte, error) { +func createHtlcScript( + userPublicKey, muunPublicKey, swapServerPublicKey []byte, + expiry int64, + paymentHash []byte, +) ([]byte, error) { sb := txscript.NewScriptBuilder() sb.AddData(muunPublicKey) sb.AddOp(txscript.OP_CHECKSIG) diff --git a/libwallet/incoming_swap_test.go b/libwallet/incoming_swap_test.go index 7fa3124d..f27f5960 100644 --- a/libwallet/incoming_swap_test.go +++ b/libwallet/incoming_swap_test.go @@ -18,7 +18,9 @@ import ( "github.com/lightningnetwork/lnd/record" "github.com/lightningnetwork/lnd/tlv" "github.com/lightningnetwork/lnd/zpay32" + "github.com/muun/libwallet/hdpath" + "github.com/muun/libwallet/walletdb" ) func TestFulfillHtlc(t *testing.T) { @@ -141,8 +143,14 @@ func TestFulfillHtlc(t *testing.T) { } swap := &IncomingSwap{ - SphinxPacket: createSphinxPacket(nodePublicKey, paymentHash, invoice.paymentSecret, amt, lockTime), - PaymentHash: paymentHash, + SphinxPacket: createSphinxPacket( + nodePublicKey, + paymentHash, + invoice.paymentSecret, + amt, + lockTime, + ), + PaymentHash: paymentHash, Htlc: &IncomingSwapHtlc{ HtlcTx: serializeTx(htlcTx), ExpirationHeight: lockTime, @@ -166,7 +174,7 @@ func TestFulfillHtlc(t *testing.T) { signedTx := wire.NewMsgTx(2) signedTx.Deserialize(bytes.NewReader(result.FulfillmentTx)) - verifyInput(t, signedTx, hex.EncodeToString(swap.Htlc.HtlcTx), 0, 0) + mustVerifyInput(t, signedTx, hex.EncodeToString(swap.Htlc.HtlcTx), 0, 0) } func TestFulfillHtlcWithCollect(t *testing.T) { @@ -290,8 +298,14 @@ func TestFulfillHtlcWithCollect(t *testing.T) { } swap := &IncomingSwap{ - SphinxPacket: createSphinxPacket(nodePublicKey, paymentHash, invoiceSecrets.paymentSecret, amt, lockTime), - PaymentHash: paymentHash, + SphinxPacket: createSphinxPacket( + nodePublicKey, + paymentHash, + invoiceSecrets.paymentSecret, + amt, + lockTime, + ), + PaymentHash: paymentHash, Htlc: &IncomingSwapHtlc{ HtlcTx: serializeTx(htlcTx), ExpirationHeight: lockTime, @@ -324,7 +338,7 @@ func TestFulfillHtlcWithCollect(t *testing.T) { signedTx := wire.NewMsgTx(2) signedTx.Deserialize(bytes.NewReader(result.FulfillmentTx)) - verifyInput(t, signedTx, hex.EncodeToString(swap.Htlc.HtlcTx), 0, 0) + mustVerifyInput(t, signedTx, hex.EncodeToString(swap.Htlc.HtlcTx), 0, 0) } func TestVerifyFulfillable(t *testing.T) { @@ -353,7 +367,7 @@ func TestVerifyFulfillable(t *testing.T) { builder.Network(network) builder.UserKey(userKey) builder.AddRouteHints(&RouteHints{ - Pubkey: "03c48d1ff96fa32e2776f71bba02102ffc2a1b91e2136586418607d32e762869fd", + Pubkey: "03c48d1ff96fa32e2776f71bba02102ffc2a1b91e2136586418607d32e762869fd", //nolint:lll FeeBaseMsat: 1000, FeeProportionalMillionths: 1000, CltvExpiryDelta: 8, @@ -413,16 +427,21 @@ func TestVerifyFulfillable(t *testing.T) { } }) - t.Run("non existant invoice", func(t *testing.T) { - swap := &IncomingSwap{ - PaymentHash: randomBytes(32), - // ignore the rest of the parameters - } - - if err := swap.VerifyFulfillable(userKey, network); err == nil { - t.Fatal("expected failure to fulfill non existant invoice") - } - }) + t.Run( + "non existant invoice", //nolint:misspell // TODO: existant -> existent + func(t *testing.T) { + swap := &IncomingSwap{ + PaymentHash: randomBytes(32), + // ignore the rest of the parameters + } + + if err := swap.VerifyFulfillable(userKey, network); err == nil { + t.Fatal( + "expected failure to fulfill non existant invoice", //nolint:misspell // TODO: existant -> existent + ) + } + }, + ) t.Run("invalid payment secret", func(t *testing.T) { invoice := createInvoice(0) @@ -563,17 +582,27 @@ func TestFulfillFailureWithoutPaymentSecret(t *testing.T) { network := Regtest() swap := &IncomingSwap{ - SphinxPacket: d("0002dc29e8562cbd4961bbe76ebc847641fba878b5dda04a31d17c5c4648c4e8f614380397b83978e2f12161c7a010d494f16ca5dc96a06369a19ccfadf9ee3ec0ecdcac9479b25459d01670c629175e8cc1110f328ec6d0e21ca81c5a7f3b71023b10ca287985695fc4c757ea25c9d49bd6b4e43bb85abe043fbcb2ef473bfd1830dbdad7c3e6de26d3a703bd307cba5a33ba56d8398e22c87034b6794ecd4c2d4157a90520b78171b1860c69c302b25f7a10edf9ac3ad87d10cf7cbe8525ac4b3ebc6544787b1b010e61ab73ee86ae8752f44687753af3b31678a7fe1e85c57c6e1de33878f43ccbba1478fbd8c055a5e2c55cadcae05537f6478ba13391343c7f1063138ba9c38803ac8fd6b9eb5b5114559df1746593df1d4d9a6883f835758dc583bb9dea72ad3079df653e73efa915c629ba8056d945cf63dc316ffd118aa7e8d20430de12ac9beaf9f472b68bdf278dccd6a84f2b6c37c25ddb3abc3583094613a07f277ed80840a33ae34d62e3dd17d21e2faf82221375914444460e38ebe5ef67d9fac02de507d7964a2191b0de43c0c7115840e863f1ca03e0a5b05dedb90826b79b1b1ce5aa7666c37bae08bbe8032a82ed1d9c15df4195e408be16844dc2b5e5868a38bd560e87d629d1c6ec11e3dbb112dc1d2692ad4b7c28b5904bf49c1efcb87562f48ec5e7177f2034dadd2c08c4a02d718ffa16585738489d89f01d350123e621e4bd8927879bd3c4cccf1fe44f7b4daf4466a60b7197dbb14c5ffd23e477343fa79a8d8818804280757b1f98439749927de21545d1a9434c59c1d0e093ab3c1936b4db3b4c67dd9cae55cf2ee55066490a602a74cf88382d35db442b7e57b869fd43360ca0c9ef03bc89784e340450fcae81fb2080c97f9852124900a71bf68921e5a6e690a5ee73c266df2344106aec8de601f8a14254c97ee96dd3f858df1cb727ee51bc8ebeb6dea5253841bd2a13aeba1bc3846c9cc45d7124f9f9aa61a6c3a7b15424c5dfadfb7644392bf0843f643d97b2e08c1a3d6ebfcb7aafcd78cd2d904645cf043e1a42b60390647f24d6663fc74dc77d06bb691d12b09bb4afc3b55427f5bac76748b73b6debb17ca6bb890f2005f39e714aa0e7a584e57a41a78f1d3f4981ce4e22a49caa389360eabc9f623b923c864eb74a2a860a061d6ecbe6f4c55596907ba342836c7607117f405e098af1f73b8ae2542a59d30c58fca8ee37c6482bd87069b142e692f54a04fd6d3a5e22595eb2de31c830cea4395b085b7c8725971df657c5af5501fa8cc9cefda4f1ae8862b6229ed74b045e17587f68ab55c9176c256c69564274502d0ec6e5e3be8ea93e14428d328963ca4671ee2f629ae8f2c2ff8f2b2145f218d8a3707715bdfa5b2bb5211b9cd8775e33ce5546f618bc998b5953c5d2a2f7932873fd248be3a504ce7f7d4b731bfb4fea363e5e281ff3c314b997d8c89d90c8bf15d983da26e75bf52e98b92d108e6f4aee0b25561d0ce8f22a8400b2085e713d909c20b2c84d5ba36dbe94f324690ab207070bfb7247510e78263989dc04669ea273ca44d2de31aa8a950bc120fcec0c627ad78b59f635ddd657d97d56fcc9ebef32b3ee1051e003c0b617a1196d6c387f014fd47e7f1c64b27d43cadfaf25a7849a77392a63470665e5e3bb0c28b66b9de938c805fab01de62cd63b0d200f97156236fcd412f1eadc125371bd09726e65da8ee8e77e7fa0070bb4f6090a2afd7a33e3d37aff7a5dac62830a7f79aa28f6bce305fc6eb96dd53cd2448b618bdadfc79dcee815d6dd6935d9cece06f810df6cbd529b01361d97f3c50d749739d9598edd53c9bd984a5348a5345c25c13fc7c6d48b7412f4ab6de74e6b7fd4945f710562c312a2903680c387a7364920e435db7777fe66b60a49adb656cdd12f"), - PaymentHash: d("31b35302d3e842a363f8992e423910bfb655b9cd6325b67f5c469fa8f2c4e55b"), + SphinxPacket: d( + "0002dc29e8562cbd4961bbe76ebc847641fba878b5dda04a31d17c5c4648c4e8f614380397b83978e2f12161c7a010d494f16ca5dc96a06369a19ccfadf9ee3ec0ecdcac9479b25459d01670c629175e8cc1110f328ec6d0e21ca81c5a7f3b71023b10ca287985695fc4c757ea25c9d49bd6b4e43bb85abe043fbcb2ef473bfd1830dbdad7c3e6de26d3a703bd307cba5a33ba56d8398e22c87034b6794ecd4c2d4157a90520b78171b1860c69c302b25f7a10edf9ac3ad87d10cf7cbe8525ac4b3ebc6544787b1b010e61ab73ee86ae8752f44687753af3b31678a7fe1e85c57c6e1de33878f43ccbba1478fbd8c055a5e2c55cadcae05537f6478ba13391343c7f1063138ba9c38803ac8fd6b9eb5b5114559df1746593df1d4d9a6883f835758dc583bb9dea72ad3079df653e73efa915c629ba8056d945cf63dc316ffd118aa7e8d20430de12ac9beaf9f472b68bdf278dccd6a84f2b6c37c25ddb3abc3583094613a07f277ed80840a33ae34d62e3dd17d21e2faf82221375914444460e38ebe5ef67d9fac02de507d7964a2191b0de43c0c7115840e863f1ca03e0a5b05dedb90826b79b1b1ce5aa7666c37bae08bbe8032a82ed1d9c15df4195e408be16844dc2b5e5868a38bd560e87d629d1c6ec11e3dbb112dc1d2692ad4b7c28b5904bf49c1efcb87562f48ec5e7177f2034dadd2c08c4a02d718ffa16585738489d89f01d350123e621e4bd8927879bd3c4cccf1fe44f7b4daf4466a60b7197dbb14c5ffd23e477343fa79a8d8818804280757b1f98439749927de21545d1a9434c59c1d0e093ab3c1936b4db3b4c67dd9cae55cf2ee55066490a602a74cf88382d35db442b7e57b869fd43360ca0c9ef03bc89784e340450fcae81fb2080c97f9852124900a71bf68921e5a6e690a5ee73c266df2344106aec8de601f8a14254c97ee96dd3f858df1cb727ee51bc8ebeb6dea5253841bd2a13aeba1bc3846c9cc45d7124f9f9aa61a6c3a7b15424c5dfadfb7644392bf0843f643d97b2e08c1a3d6ebfcb7aafcd78cd2d904645cf043e1a42b60390647f24d6663fc74dc77d06bb691d12b09bb4afc3b55427f5bac76748b73b6debb17ca6bb890f2005f39e714aa0e7a584e57a41a78f1d3f4981ce4e22a49caa389360eabc9f623b923c864eb74a2a860a061d6ecbe6f4c55596907ba342836c7607117f405e098af1f73b8ae2542a59d30c58fca8ee37c6482bd87069b142e692f54a04fd6d3a5e22595eb2de31c830cea4395b085b7c8725971df657c5af5501fa8cc9cefda4f1ae8862b6229ed74b045e17587f68ab55c9176c256c69564274502d0ec6e5e3be8ea93e14428d328963ca4671ee2f629ae8f2c2ff8f2b2145f218d8a3707715bdfa5b2bb5211b9cd8775e33ce5546f618bc998b5953c5d2a2f7932873fd248be3a504ce7f7d4b731bfb4fea363e5e281ff3c314b997d8c89d90c8bf15d983da26e75bf52e98b92d108e6f4aee0b25561d0ce8f22a8400b2085e713d909c20b2c84d5ba36dbe94f324690ab207070bfb7247510e78263989dc04669ea273ca44d2de31aa8a950bc120fcec0c627ad78b59f635ddd657d97d56fcc9ebef32b3ee1051e003c0b617a1196d6c387f014fd47e7f1c64b27d43cadfaf25a7849a77392a63470665e5e3bb0c28b66b9de938c805fab01de62cd63b0d200f97156236fcd412f1eadc125371bd09726e65da8ee8e77e7fa0070bb4f6090a2afd7a33e3d37aff7a5dac62830a7f79aa28f6bce305fc6eb96dd53cd2448b618bdadfc79dcee815d6dd6935d9cece06f810df6cbd529b01361d97f3c50d749739d9598edd53c9bd984a5348a5345c25c13fc7c6d48b7412f4ab6de74e6b7fd4945f710562c312a2903680c387a7364920e435db7777fe66b60a49adb656cdd12f", //nolint:lll + ), + PaymentHash: d("31b35302d3e842a363f8992e423910bfb655b9cd6325b67f5c469fa8f2c4e55b"), Htlc: &IncomingSwapHtlc{ - HtlcTx: d("02000000000101896c8b88d8219cc7dae111558626c952da6fc2a542f7db970e8af745c4678bdb0000000000feffffff02d006032a01000000160014b710e26258f27a99807e2a09bf39b5d3588c561b089d0000000000002200208fb1ed3841bee4385ba4efe1a8aff0943b3b1eeadada45e4784f54e2efa1f30a0247304402205e6a82391804b8bc483f6d9d44bdcd7afb477f66c4c794872735447f1dd883480220626fc746386f8afed04a43776d661bab1d610cdebcb5d03c7d594b0edd3612ed0121037d4c78fdce4b13788efb012a68834da3a75f6ac153f55edf22fadc09e6d4f67700000000"), - ExpirationHeight: 401, - SwapServerPublicKey: d("028b7c740b590012eaffef072675baaa95aee39508fd049ed1cd698ee26ce33f02"), + HtlcTx: d( + "02000000000101896c8b88d8219cc7dae111558626c952da6fc2a542f7db970e8af745c4678bdb0000000000feffffff02d006032a01000000160014b710e26258f27a99807e2a09bf39b5d3588c561b089d0000000000002200208fb1ed3841bee4385ba4efe1a8aff0943b3b1eeadada45e4784f54e2efa1f30a0247304402205e6a82391804b8bc483f6d9d44bdcd7afb477f66c4c794872735447f1dd883480220626fc746386f8afed04a43776d661bab1d610cdebcb5d03c7d594b0edd3612ed0121037d4c78fdce4b13788efb012a68834da3a75f6ac153f55edf22fadc09e6d4f67700000000", //nolint:lll + ), + ExpirationHeight: 401, + SwapServerPublicKey: d( + "028b7c740b590012eaffef072675baaa95aee39508fd049ed1cd698ee26ce33f02", + ), }, } data := &IncomingSwapFulfillmentData{ - FulfillmentTx: d("0100000001a2b209d88daaa2b9fedc8217904b75934d280f889cd64db243c530dbd72a9b670100000000ffffffff0110270000000000002200209c58b43eff77533a3a056046ee4cb5044bb0eeb74635ebb8cc03048b3720716b00000000"), - MuunSignature: d("30450221008c40c9ef1613cfa500c52531b9fd0b7212f562e425dcdc4358cc3a6de25e11940220717ab86c13cb645dd2e694c3b4e5fd0e81e84f00ed8380570ab33a19fed0547201"), + FulfillmentTx: d( + "0100000001a2b209d88daaa2b9fedc8217904b75934d280f889cd64db243c530dbd72a9b670100000000ffffffff0110270000000000002200209c58b43eff77533a3a056046ee4cb5044bb0eeb74635ebb8cc03048b3720716b00000000", //nolint:lll + ), + MuunSignature: d( + "30450221008c40c9ef1613cfa500c52531b9fd0b7212f562e425dcdc4358cc3a6de25e11940220717ab86c13cb645dd2e694c3b4e5fd0e81e84f00ed8380570ab33a19fed0547201", //nolint:lll + ), OutputVersion: 4, OutputPath: "m/schema:1\\'/recovery:1\\'/change:0/3", MerkleTree: d(""), @@ -581,8 +610,16 @@ func TestFulfillFailureWithoutPaymentSecret(t *testing.T) { ConfirmationTarget: 0, } - userKey, _ := NewHDPrivateKeyFromString("tprv8eNitriyeyGgaAe7teh17j8mvqN3MuzkFy5TzdfS4KUATjgdP29jN7w9A8iQ5PDUZMqsb2aiJjEgjuPGCRjoDbJsCZ5iFGpb4uJCXkksjXM", "m/schema:1'/recovery:1'", network) - muunKey, _ := NewHDPublicKeyFromString("tpubDBYMnFoxYLdMBZThTk4uARTe4kGPeEYWdKcaEzaUxt1cesetnxtTqmAxVkzDRou51emWytommyLWcF91SdF5KecA6Ja8oHK1FF7d5U2hMxX", "m/schema:1'/recovery:1'", network) + userKey, _ := NewHDPrivateKeyFromString( + "tprv8eNitriyeyGgaAe7teh17j8mvqN3MuzkFy5TzdfS4KUATjgdP29jN7w9A8iQ5PDUZMqsb2aiJjEgjuPGCRjoDbJsCZ5iFGpb4uJCXkksjXM", //nolint:lll + "m/schema:1'/recovery:1'", + network, + ) + muunKey, _ := NewHDPublicKeyFromString( + "tpubDBYMnFoxYLdMBZThTk4uARTe4kGPeEYWdKcaEzaUxt1cesetnxtTqmAxVkzDRou51emWytommyLWcF91SdF5KecA6Ja8oHK1FF7d5U2hMxX", //nolint:lll + "m/schema:1'/recovery:1'", + network, + ) invoice := &InvoiceSecrets{ preimage: d("52441108d7144b82ed13a18b7572fa78fa6f6a3f85fdbf4752dcce985430e43c"), @@ -603,12 +640,16 @@ func TestFulfillFailureWithoutPaymentSecret(t *testing.T) { } } -// TestFulfillWithIncorrectPaymentSecret tests that payment secret sphinx validation works and its enforced. The way we -// do this is by having every other piece of data be correct and consistent and only the payment secret being invalid. -// When trying to fulfill(), the function should fail with an error signalling a payment secret mismatch. Technically, -// we should be modifying the sphinx packet to contain an incorrect payment secret instead of checking against an -// invalid payment secret provided by the app/invoice. But our current approach is WAY easier and saved us a lot of -// time :). +// TestFulfillWithIncorrectPaymentSecret tests that payment secret sphinx +// validation works and its enforced. +// The way we do this is by having every other piece of data be correct and +// consistent and only the payment secret being invalid. +// When trying to fulfill(), the function should fail with an error signalling +// a payment secret mismatch. +// Technically, we should be modifying the sphinx packet to contain an incorrect +// payment secret instead of checking against an invalid payment secret provided +// by the app/invoice. +// But our current approach is WAY easier and saved us a lot of time :). func TestFulfillWithIncorrectPaymentSecret(t *testing.T) { setup() @@ -623,24 +664,34 @@ func TestFulfillWithIncorrectPaymentSecret(t *testing.T) { return res } - // Note: for this test, we want to avoid depending on the code that we use to generate sphinx packets in other tests - // (e.g createSphinxPacket). So, the following values are taken from a data dump of an incoming payment attempt in - // a local (e.g regtest) env in device emulator. + // Note: for this test, we want to avoid depending on the code that we use to generate sphinx + // packets in other tests (e.g createSphinxPacket). So, the following values are taken from a + // data dump of an incoming payment attempt in a local (e.g regtest) env in device emulator. network := Regtest() swap := &IncomingSwap{ - SphinxPacket: d("000315be7c15b19fb4a5c4f8654fa2527f766b0a4db6c9ebc32fc47aed1a07127fc3506162074aeee6d9c60c763b9f867ebe083c383b8467617dcc4fa961be959909200a56d8628591eb253bffeb5eab444b4e483c627a618228b8a2cf4f336bc4035e42453133d17e447227534af81d814d5fc0b444fe23bfea587fb28abbad185998f7fc58e753ff7f8017b3cd525a65c15956ea47792208717564da215253287ca36bf1f2b632a8f32c89ce6aa2347a4f668ce3d0c87240a830483976638c289e6973be4910a24c2a3c16f0694bedc2f7bbaa1cdc132d749bab063c2854be6ac05e105d7db09ebf080b7dd2684c6223a84cc82853cee6f55bb4e910eb0e70a178c4974df7d19c819856ab13899ce69f23f1cf764608482a7ae6ec241cfae0244219c9d65543992f30e2550c16834a8f5eb0c432fc05132dd972b0d092570bcd2c421695f9b0b31465c84ea8b7bc87a5ba8b8d9ae69766e049231021e5dd3335ad60379f08ba51a1b4b2643e16bf7492e4a14f40f0505dbfcb7185a1ff2ab31be46a3554495db3d9a050f14d8faa3ff8a822cb09ca6e48077c65a1f3da1e1ab6efd5d871bbfd02ab4f274046e0c0666831b62ca42d754c06aad1e7bd807a9cb2a6a8963dfa8027f68411f1e52f89f2f32a24a1db0ae46f695c7a0d0217d68e03e902ed0382f99e30b514c890dd370b193bd6da19623473dd9772a5a1119dbe2d525831b112bb7aefa3ed3e71f5f9bf10d163d5d5d93fac9b8513a9844a1230bfcd1e3021d562e30bcd5e2c46ab479a0b401d66ec72a6280fccd150e9a8d786a3e6896bb17d31f9cfe1148a9787901d1d6fa84b061342505039a965c3f8fa88a0faaec728f32f21c83c5acf5b7276d511455909588612af064914b67bef705846e271a8f898b13815a127aa63aa5b66f5610a227b6601be526417e92b88732fa52997951125b225e6a2e1e1cafc6ac18a93f3fa810e6cfd594ff57b0061304d777ebd8a40ca8eac19877e3df7419a8355122d403dedbef8b110c61b3b2821fc1788609cd0f20bc8be162303cd936640179665b89747e39c847cff19f32a002abb63557292ccef8a4847bb5c02394f947a2d0bd4df00b82203a482954e5814c2ac2ce7839f1eeb5428bfd992bfff4af2dde537505891c3c1d345a6e750de22d3b6e50023bf5172f415d5618f67097c20bb016fdeb87cdea66ea88adeb8b5d8b014d86364b35c915740d96bfe8432fcc9f21ecc33d29402d84e405106c65e572d68ac313a34a4ff117b4eac946736287c528bd490ad60f5fff9dc18ef8c3de80e18207ccbdc7a5abc6f73b79ec098ea8f41323032dc6ed62584a2c4dd2e4c4b6e76b88dc26d33279da47aa91e1eebb44dcdb5ead73c5bf6f17584142dca009ff29bcc46e291c59fd24d3b3b8a0e2157df61ffea5265c589de2c62f33a66ee1408e2b7ae2c17168a4183eb3bf392f70437c2c65c37d0d22e861c4caab58c89705c1ee2a9ad45e338347f524071875dc48c3a23f67b6e670b228caf945d5b8d7a389b3766ac01e40b79791fd667f342604cab6374bf4bd9d795a4f181d7f48192e947da557fc5280f224da63e8fff64e862984183c4465d5beaad464fcd24122c38a24a83b31ab31a618efb2a56af109ba7c5c1b1911620165ac01088b1559fa02d49017c635cd8e3a26e0d9699c7ff4048389521add8fdb72a439959364db2e612964ab9483a812c25dd35d2360d0a8855205ee72ecc4542d42a1b25b5d12fdc94c77ba1a7479d23854838c79f1a7b83774907f8be0912744a256c1934c9734de55f65446d7a197753b2f4d37374a3637e27a134872f7e70cfc8b0a1c205e00c2c93c6867f2f70f335a6bffac8b80461ada6f6e0e0bba69be6fb8dbf5141318b43e7dbdd3e58776621eb3a07df4d5a47584bc503ca68e1"), - PaymentHash: d("c7165cd3692877f5a85c51d834730dddffa1493117273926a20310e18b44523d"), + SphinxPacket: d( + "000315be7c15b19fb4a5c4f8654fa2527f766b0a4db6c9ebc32fc47aed1a07127fc3506162074aeee6d9c60c763b9f867ebe083c383b8467617dcc4fa961be959909200a56d8628591eb253bffeb5eab444b4e483c627a618228b8a2cf4f336bc4035e42453133d17e447227534af81d814d5fc0b444fe23bfea587fb28abbad185998f7fc58e753ff7f8017b3cd525a65c15956ea47792208717564da215253287ca36bf1f2b632a8f32c89ce6aa2347a4f668ce3d0c87240a830483976638c289e6973be4910a24c2a3c16f0694bedc2f7bbaa1cdc132d749bab063c2854be6ac05e105d7db09ebf080b7dd2684c6223a84cc82853cee6f55bb4e910eb0e70a178c4974df7d19c819856ab13899ce69f23f1cf764608482a7ae6ec241cfae0244219c9d65543992f30e2550c16834a8f5eb0c432fc05132dd972b0d092570bcd2c421695f9b0b31465c84ea8b7bc87a5ba8b8d9ae69766e049231021e5dd3335ad60379f08ba51a1b4b2643e16bf7492e4a14f40f0505dbfcb7185a1ff2ab31be46a3554495db3d9a050f14d8faa3ff8a822cb09ca6e48077c65a1f3da1e1ab6efd5d871bbfd02ab4f274046e0c0666831b62ca42d754c06aad1e7bd807a9cb2a6a8963dfa8027f68411f1e52f89f2f32a24a1db0ae46f695c7a0d0217d68e03e902ed0382f99e30b514c890dd370b193bd6da19623473dd9772a5a1119dbe2d525831b112bb7aefa3ed3e71f5f9bf10d163d5d5d93fac9b8513a9844a1230bfcd1e3021d562e30bcd5e2c46ab479a0b401d66ec72a6280fccd150e9a8d786a3e6896bb17d31f9cfe1148a9787901d1d6fa84b061342505039a965c3f8fa88a0faaec728f32f21c83c5acf5b7276d511455909588612af064914b67bef705846e271a8f898b13815a127aa63aa5b66f5610a227b6601be526417e92b88732fa52997951125b225e6a2e1e1cafc6ac18a93f3fa810e6cfd594ff57b0061304d777ebd8a40ca8eac19877e3df7419a8355122d403dedbef8b110c61b3b2821fc1788609cd0f20bc8be162303cd936640179665b89747e39c847cff19f32a002abb63557292ccef8a4847bb5c02394f947a2d0bd4df00b82203a482954e5814c2ac2ce7839f1eeb5428bfd992bfff4af2dde537505891c3c1d345a6e750de22d3b6e50023bf5172f415d5618f67097c20bb016fdeb87cdea66ea88adeb8b5d8b014d86364b35c915740d96bfe8432fcc9f21ecc33d29402d84e405106c65e572d68ac313a34a4ff117b4eac946736287c528bd490ad60f5fff9dc18ef8c3de80e18207ccbdc7a5abc6f73b79ec098ea8f41323032dc6ed62584a2c4dd2e4c4b6e76b88dc26d33279da47aa91e1eebb44dcdb5ead73c5bf6f17584142dca009ff29bcc46e291c59fd24d3b3b8a0e2157df61ffea5265c589de2c62f33a66ee1408e2b7ae2c17168a4183eb3bf392f70437c2c65c37d0d22e861c4caab58c89705c1ee2a9ad45e338347f524071875dc48c3a23f67b6e670b228caf945d5b8d7a389b3766ac01e40b79791fd667f342604cab6374bf4bd9d795a4f181d7f48192e947da557fc5280f224da63e8fff64e862984183c4465d5beaad464fcd24122c38a24a83b31ab31a618efb2a56af109ba7c5c1b1911620165ac01088b1559fa02d49017c635cd8e3a26e0d9699c7ff4048389521add8fdb72a439959364db2e612964ab9483a812c25dd35d2360d0a8855205ee72ecc4542d42a1b25b5d12fdc94c77ba1a7479d23854838c79f1a7b83774907f8be0912744a256c1934c9734de55f65446d7a197753b2f4d37374a3637e27a134872f7e70cfc8b0a1c205e00c2c93c6867f2f70f335a6bffac8b80461ada6f6e0e0bba69be6fb8dbf5141318b43e7dbdd3e58776621eb3a07df4d5a47584bc503ca68e1", //nolint:lll + ), + PaymentHash: d("c7165cd3692877f5a85c51d834730dddffa1493117273926a20310e18b44523d"), Htlc: &IncomingSwapHtlc{ - HtlcTx: d("0200000000010151ca4ece06e7ec3e0458dfd8bba0c58ad53c8e296fdcbb08de15f8b17a2419e10000000000ffffffff0834ca010000000000220020a0b8c170b680c76e4655d80e4e206206b4fb365674fd752590862445499dae2040420f00000000001600148b194ab0e7019f8eddfba7f14b00045a82bd8bf040420f0000000000160014d64044cd7e17e204a2ebc5737fa9862e1ab6b71e40420f000000000016001434353a4568fe6edaa2c83647fe0c591ef61421f840420f0000000000160014bce7c77a9b648e69f0292331ece4436150ce593340420f000000000016001439fbdcce9849d9a913a40911c318322e4bfda65340420f0000000000160014193a6bd1cd36d510a245c9959c507bfdcde45c04e015a829010000001600143e5eb692c5d6703672fac7b86bae54ead1b074c50247304402207a7ac24c0e43123b7f6d4d27fcb21fdc3c7ed2cb1c601e03136e4f988ad0468d0220440e596e241728073df049ed0dda504f55b435ba4535c2e5f0844c636f3da65501210313d8559514b06cca0da351b0d05222acfc92ff3f90cc7ddef32a648a80790a0b00000000"), - ExpirationHeight: 504, - SwapServerPublicKey: d("028b7c740b590012eaffef072675baaa95aee39508fd049ed1cd698ee26ce33f02"), + HtlcTx: d( + "0200000000010151ca4ece06e7ec3e0458dfd8bba0c58ad53c8e296fdcbb08de15f8b17a2419e10000000000ffffffff0834ca010000000000220020a0b8c170b680c76e4655d80e4e206206b4fb365674fd752590862445499dae2040420f00000000001600148b194ab0e7019f8eddfba7f14b00045a82bd8bf040420f0000000000160014d64044cd7e17e204a2ebc5737fa9862e1ab6b71e40420f000000000016001434353a4568fe6edaa2c83647fe0c591ef61421f840420f0000000000160014bce7c77a9b648e69f0292331ece4436150ce593340420f000000000016001439fbdcce9849d9a913a40911c318322e4bfda65340420f0000000000160014193a6bd1cd36d510a245c9959c507bfdcde45c04e015a829010000001600143e5eb692c5d6703672fac7b86bae54ead1b074c50247304402207a7ac24c0e43123b7f6d4d27fcb21fdc3c7ed2cb1c601e03136e4f988ad0468d0220440e596e241728073df049ed0dda504f55b435ba4535c2e5f0844c636f3da65501210313d8559514b06cca0da351b0d05222acfc92ff3f90cc7ddef32a648a80790a0b00000000", //nolint:lll + ), + ExpirationHeight: 504, + SwapServerPublicKey: d( + "028b7c740b590012eaffef072675baaa95aee39508fd049ed1cd698ee26ce33f02", + ), }, } data := &IncomingSwapFulfillmentData{ - FulfillmentTx: d("01000000013754eeb4d1e71094e2470024163f073ce0e6c4f1b16ded51d1792255f4c8e3ed0000000000ffffffff01a086010000000000225120005914f986cb6749440e0e77367bac6c6e53d814449a2fd7443474aab61606f300000000"), - MuunSignature: d("304402206a1cfc3d01a8ca050967e5dddff87984ebafe390a61dc5225044c2ce22b02fae022071887ef3f13bd1b4cca33ffb1a217de5fb92b3bb74df398f8508fec0b37dfa9f01"), + FulfillmentTx: d( + "01000000013754eeb4d1e71094e2470024163f073ce0e6c4f1b16ded51d1792255f4c8e3ed0000000000ffffffff01a086010000000000225120005914f986cb6749440e0e77367bac6c6e53d814449a2fd7443474aab61606f300000000", //nolint:lll + ), + MuunSignature: d( + "304402206a1cfc3d01a8ca050967e5dddff87984ebafe390a61dc5225044c2ce22b02fae022071887ef3f13bd1b4cca33ffb1a217de5fb92b3bb74df398f8508fec0b37dfa9f01", //nolint:lll + ), OutputVersion: 5, OutputPath: "m/schema:1\\'/recovery:1\\'/change:0/5", MerkleTree: d(""), @@ -648,18 +699,28 @@ func TestFulfillWithIncorrectPaymentSecret(t *testing.T) { ConfirmationTarget: 0, } - userKey, _ := NewHDPrivateKeyFromString("tprv8fAB8ynEKVR4LdJUc6ryH3u2tqJF8wZJH2rzRprZ6YhqdFHi5HnP1fYRuoHfos9RQZ1bkxsyP8oHENfiezvAp4dFj83rYbBGkQHSwbhqiDW", "m/schema:1'/recovery:1'", network) - muunKey, _ := NewHDPublicKeyFromString("tpubDBZaivUL3Hv8r25JDupShPuWVkGcwM7NgbMBwkhQLfWu18iBbyQCbRdyg1wRMjoWdZN7Afg3F25zs4c8E6Q4VJrGqAw51DJeqacTFABV9u8", "m/schema:1'/recovery:1'", network) + userKey, _ := NewHDPrivateKeyFromString( + "tprv8fAB8ynEKVR4LdJUc6ryH3u2tqJF8wZJH2rzRprZ6YhqdFHi5HnP1fYRuoHfos9RQZ1bkxsyP8oHENfiezvAp4dFj83rYbBGkQHSwbhqiDW", //nolint:lll + "m/schema:1'/recovery:1'", + network, + ) + muunKey, _ := NewHDPublicKeyFromString( + "tpubDBZaivUL3Hv8r25JDupShPuWVkGcwM7NgbMBwkhQLfWu18iBbyQCbRdyg1wRMjoWdZN7Afg3F25zs4c8E6Q4VJrGqAw51DJeqacTFABV9u8", //nolint:lll + "m/schema:1'/recovery:1'", + network, + ) invoice := &InvoiceSecrets{ - preimage: d("e28dd8e23e3f427190104373c71f46db31efa665612f670610afe378a1713100"), - paymentSecret: d("e28dd8e23e3f427190104373c71f46db31efa665612f670610afe378a1713100"), // INVALID - keyPath: "m/schema:1'/recovery:1'/invoices:4/2036904351/908182055", - PaymentHash: d("c7165cd3692877f5a85c51d834730dddffa1493117273926a20310e18b44523d"), - IdentityKey: nil, - UserHtlcKey: nil, - MuunHtlcKey: nil, - ShortChanId: parseInt64("15120913803481186240"), + preimage: d("e28dd8e23e3f427190104373c71f46db31efa665612f670610afe378a1713100"), + paymentSecret: d( + "e28dd8e23e3f427190104373c71f46db31efa665612f670610afe378a1713100", + ), // INVALID + keyPath: "m/schema:1'/recovery:1'/invoices:4/2036904351/908182055", + PaymentHash: d("c7165cd3692877f5a85c51d834730dddffa1493117273926a20310e18b44523d"), + IdentityKey: nil, + UserHtlcKey: nil, + MuunHtlcKey: nil, + ShortChanId: parseInt64("15120913803481186240"), } PersistInvoiceSecrets(&InvoiceSecretsList{secrets: []*InvoiceSecrets{invoice}}) @@ -686,24 +747,34 @@ func TestFulfillWithHardwiredData(t *testing.T) { return res } - // Note: for this test, we want to avoid depending on the code that we use to generate sphinx packets in other tests - // (e.g createSphinxPacket). So, the following values are taken from a data dump of an incoming payment attempt in - // a local (e.g regtest) env in device emulator. + // Note: for this test, we want to avoid depending on the code that we use to generate sphinx + // packets in other tests (e.g createSphinxPacket). So, the following values are taken from a + // data dump of an incoming payment attempt in a local (e.g regtest) env in device emulator. network := Regtest() swap := &IncomingSwap{ - SphinxPacket: d("000315be7c15b19fb4a5c4f8654fa2527f766b0a4db6c9ebc32fc47aed1a07127fc3506162074aeee6d9c60c763b9f867ebe083c383b8467617dcc4fa961be959909200a56d8628591eb253bffeb5eab444b4e483c627a618228b8a2cf4f336bc4035e42453133d17e447227534af81d814d5fc0b444fe23bfea587fb28abbad185998f7fc58e753ff7f8017b3cd525a65c15956ea47792208717564da215253287ca36bf1f2b632a8f32c89ce6aa2347a4f668ce3d0c87240a830483976638c289e6973be4910a24c2a3c16f0694bedc2f7bbaa1cdc132d749bab063c2854be6ac05e105d7db09ebf080b7dd2684c6223a84cc82853cee6f55bb4e910eb0e70a178c4974df7d19c819856ab13899ce69f23f1cf764608482a7ae6ec241cfae0244219c9d65543992f30e2550c16834a8f5eb0c432fc05132dd972b0d092570bcd2c421695f9b0b31465c84ea8b7bc87a5ba8b8d9ae69766e049231021e5dd3335ad60379f08ba51a1b4b2643e16bf7492e4a14f40f0505dbfcb7185a1ff2ab31be46a3554495db3d9a050f14d8faa3ff8a822cb09ca6e48077c65a1f3da1e1ab6efd5d871bbfd02ab4f274046e0c0666831b62ca42d754c06aad1e7bd807a9cb2a6a8963dfa8027f68411f1e52f89f2f32a24a1db0ae46f695c7a0d0217d68e03e902ed0382f99e30b514c890dd370b193bd6da19623473dd9772a5a1119dbe2d525831b112bb7aefa3ed3e71f5f9bf10d163d5d5d93fac9b8513a9844a1230bfcd1e3021d562e30bcd5e2c46ab479a0b401d66ec72a6280fccd150e9a8d786a3e6896bb17d31f9cfe1148a9787901d1d6fa84b061342505039a965c3f8fa88a0faaec728f32f21c83c5acf5b7276d511455909588612af064914b67bef705846e271a8f898b13815a127aa63aa5b66f5610a227b6601be526417e92b88732fa52997951125b225e6a2e1e1cafc6ac18a93f3fa810e6cfd594ff57b0061304d777ebd8a40ca8eac19877e3df7419a8355122d403dedbef8b110c61b3b2821fc1788609cd0f20bc8be162303cd936640179665b89747e39c847cff19f32a002abb63557292ccef8a4847bb5c02394f947a2d0bd4df00b82203a482954e5814c2ac2ce7839f1eeb5428bfd992bfff4af2dde537505891c3c1d345a6e750de22d3b6e50023bf5172f415d5618f67097c20bb016fdeb87cdea66ea88adeb8b5d8b014d86364b35c915740d96bfe8432fcc9f21ecc33d29402d84e405106c65e572d68ac313a34a4ff117b4eac946736287c528bd490ad60f5fff9dc18ef8c3de80e18207ccbdc7a5abc6f73b79ec098ea8f41323032dc6ed62584a2c4dd2e4c4b6e76b88dc26d33279da47aa91e1eebb44dcdb5ead73c5bf6f17584142dca009ff29bcc46e291c59fd24d3b3b8a0e2157df61ffea5265c589de2c62f33a66ee1408e2b7ae2c17168a4183eb3bf392f70437c2c65c37d0d22e861c4caab58c89705c1ee2a9ad45e338347f524071875dc48c3a23f67b6e670b228caf945d5b8d7a389b3766ac01e40b79791fd667f342604cab6374bf4bd9d795a4f181d7f48192e947da557fc5280f224da63e8fff64e862984183c4465d5beaad464fcd24122c38a24a83b31ab31a618efb2a56af109ba7c5c1b1911620165ac01088b1559fa02d49017c635cd8e3a26e0d9699c7ff4048389521add8fdb72a439959364db2e612964ab9483a812c25dd35d2360d0a8855205ee72ecc4542d42a1b25b5d12fdc94c77ba1a7479d23854838c79f1a7b83774907f8be0912744a256c1934c9734de55f65446d7a197753b2f4d37374a3637e27a134872f7e70cfc8b0a1c205e00c2c93c6867f2f70f335a6bffac8b80461ada6f6e0e0bba69be6fb8dbf5141318b43e7dbdd3e58776621eb3a07df4d5a47584bc503ca68e1"), - PaymentHash: d("c7165cd3692877f5a85c51d834730dddffa1493117273926a20310e18b44523d"), + SphinxPacket: d( + "000315be7c15b19fb4a5c4f8654fa2527f766b0a4db6c9ebc32fc47aed1a07127fc3506162074aeee6d9c60c763b9f867ebe083c383b8467617dcc4fa961be959909200a56d8628591eb253bffeb5eab444b4e483c627a618228b8a2cf4f336bc4035e42453133d17e447227534af81d814d5fc0b444fe23bfea587fb28abbad185998f7fc58e753ff7f8017b3cd525a65c15956ea47792208717564da215253287ca36bf1f2b632a8f32c89ce6aa2347a4f668ce3d0c87240a830483976638c289e6973be4910a24c2a3c16f0694bedc2f7bbaa1cdc132d749bab063c2854be6ac05e105d7db09ebf080b7dd2684c6223a84cc82853cee6f55bb4e910eb0e70a178c4974df7d19c819856ab13899ce69f23f1cf764608482a7ae6ec241cfae0244219c9d65543992f30e2550c16834a8f5eb0c432fc05132dd972b0d092570bcd2c421695f9b0b31465c84ea8b7bc87a5ba8b8d9ae69766e049231021e5dd3335ad60379f08ba51a1b4b2643e16bf7492e4a14f40f0505dbfcb7185a1ff2ab31be46a3554495db3d9a050f14d8faa3ff8a822cb09ca6e48077c65a1f3da1e1ab6efd5d871bbfd02ab4f274046e0c0666831b62ca42d754c06aad1e7bd807a9cb2a6a8963dfa8027f68411f1e52f89f2f32a24a1db0ae46f695c7a0d0217d68e03e902ed0382f99e30b514c890dd370b193bd6da19623473dd9772a5a1119dbe2d525831b112bb7aefa3ed3e71f5f9bf10d163d5d5d93fac9b8513a9844a1230bfcd1e3021d562e30bcd5e2c46ab479a0b401d66ec72a6280fccd150e9a8d786a3e6896bb17d31f9cfe1148a9787901d1d6fa84b061342505039a965c3f8fa88a0faaec728f32f21c83c5acf5b7276d511455909588612af064914b67bef705846e271a8f898b13815a127aa63aa5b66f5610a227b6601be526417e92b88732fa52997951125b225e6a2e1e1cafc6ac18a93f3fa810e6cfd594ff57b0061304d777ebd8a40ca8eac19877e3df7419a8355122d403dedbef8b110c61b3b2821fc1788609cd0f20bc8be162303cd936640179665b89747e39c847cff19f32a002abb63557292ccef8a4847bb5c02394f947a2d0bd4df00b82203a482954e5814c2ac2ce7839f1eeb5428bfd992bfff4af2dde537505891c3c1d345a6e750de22d3b6e50023bf5172f415d5618f67097c20bb016fdeb87cdea66ea88adeb8b5d8b014d86364b35c915740d96bfe8432fcc9f21ecc33d29402d84e405106c65e572d68ac313a34a4ff117b4eac946736287c528bd490ad60f5fff9dc18ef8c3de80e18207ccbdc7a5abc6f73b79ec098ea8f41323032dc6ed62584a2c4dd2e4c4b6e76b88dc26d33279da47aa91e1eebb44dcdb5ead73c5bf6f17584142dca009ff29bcc46e291c59fd24d3b3b8a0e2157df61ffea5265c589de2c62f33a66ee1408e2b7ae2c17168a4183eb3bf392f70437c2c65c37d0d22e861c4caab58c89705c1ee2a9ad45e338347f524071875dc48c3a23f67b6e670b228caf945d5b8d7a389b3766ac01e40b79791fd667f342604cab6374bf4bd9d795a4f181d7f48192e947da557fc5280f224da63e8fff64e862984183c4465d5beaad464fcd24122c38a24a83b31ab31a618efb2a56af109ba7c5c1b1911620165ac01088b1559fa02d49017c635cd8e3a26e0d9699c7ff4048389521add8fdb72a439959364db2e612964ab9483a812c25dd35d2360d0a8855205ee72ecc4542d42a1b25b5d12fdc94c77ba1a7479d23854838c79f1a7b83774907f8be0912744a256c1934c9734de55f65446d7a197753b2f4d37374a3637e27a134872f7e70cfc8b0a1c205e00c2c93c6867f2f70f335a6bffac8b80461ada6f6e0e0bba69be6fb8dbf5141318b43e7dbdd3e58776621eb3a07df4d5a47584bc503ca68e1", //nolint:lll + ), + PaymentHash: d("c7165cd3692877f5a85c51d834730dddffa1493117273926a20310e18b44523d"), Htlc: &IncomingSwapHtlc{ - HtlcTx: d("0200000000010151ca4ece06e7ec3e0458dfd8bba0c58ad53c8e296fdcbb08de15f8b17a2419e10000000000ffffffff0834ca010000000000220020a0b8c170b680c76e4655d80e4e206206b4fb365674fd752590862445499dae2040420f00000000001600148b194ab0e7019f8eddfba7f14b00045a82bd8bf040420f0000000000160014d64044cd7e17e204a2ebc5737fa9862e1ab6b71e40420f000000000016001434353a4568fe6edaa2c83647fe0c591ef61421f840420f0000000000160014bce7c77a9b648e69f0292331ece4436150ce593340420f000000000016001439fbdcce9849d9a913a40911c318322e4bfda65340420f0000000000160014193a6bd1cd36d510a245c9959c507bfdcde45c04e015a829010000001600143e5eb692c5d6703672fac7b86bae54ead1b074c50247304402207a7ac24c0e43123b7f6d4d27fcb21fdc3c7ed2cb1c601e03136e4f988ad0468d0220440e596e241728073df049ed0dda504f55b435ba4535c2e5f0844c636f3da65501210313d8559514b06cca0da351b0d05222acfc92ff3f90cc7ddef32a648a80790a0b00000000"), - ExpirationHeight: 504, - SwapServerPublicKey: d("028b7c740b590012eaffef072675baaa95aee39508fd049ed1cd698ee26ce33f02"), + HtlcTx: d( + "0200000000010151ca4ece06e7ec3e0458dfd8bba0c58ad53c8e296fdcbb08de15f8b17a2419e10000000000ffffffff0834ca010000000000220020a0b8c170b680c76e4655d80e4e206206b4fb365674fd752590862445499dae2040420f00000000001600148b194ab0e7019f8eddfba7f14b00045a82bd8bf040420f0000000000160014d64044cd7e17e204a2ebc5737fa9862e1ab6b71e40420f000000000016001434353a4568fe6edaa2c83647fe0c591ef61421f840420f0000000000160014bce7c77a9b648e69f0292331ece4436150ce593340420f000000000016001439fbdcce9849d9a913a40911c318322e4bfda65340420f0000000000160014193a6bd1cd36d510a245c9959c507bfdcde45c04e015a829010000001600143e5eb692c5d6703672fac7b86bae54ead1b074c50247304402207a7ac24c0e43123b7f6d4d27fcb21fdc3c7ed2cb1c601e03136e4f988ad0468d0220440e596e241728073df049ed0dda504f55b435ba4535c2e5f0844c636f3da65501210313d8559514b06cca0da351b0d05222acfc92ff3f90cc7ddef32a648a80790a0b00000000", //nolint:lll + ), + ExpirationHeight: 504, + SwapServerPublicKey: d( + "028b7c740b590012eaffef072675baaa95aee39508fd049ed1cd698ee26ce33f02", + ), }, } htlcTxIndex := 0 // Required for verifying input script at end of test data := &IncomingSwapFulfillmentData{ - FulfillmentTx: d("01000000013754eeb4d1e71094e2470024163f073ce0e6c4f1b16ded51d1792255f4c8e3ed0000000000ffffffff01a086010000000000225120005914f986cb6749440e0e77367bac6c6e53d814449a2fd7443474aab61606f300000000"), - MuunSignature: d("304402206a1cfc3d01a8ca050967e5dddff87984ebafe390a61dc5225044c2ce22b02fae022071887ef3f13bd1b4cca33ffb1a217de5fb92b3bb74df398f8508fec0b37dfa9f01"), + FulfillmentTx: d( + "01000000013754eeb4d1e71094e2470024163f073ce0e6c4f1b16ded51d1792255f4c8e3ed0000000000ffffffff01a086010000000000225120005914f986cb6749440e0e77367bac6c6e53d814449a2fd7443474aab61606f300000000", //nolint:lll + ), + MuunSignature: d( + "304402206a1cfc3d01a8ca050967e5dddff87984ebafe390a61dc5225044c2ce22b02fae022071887ef3f13bd1b4cca33ffb1a217de5fb92b3bb74df398f8508fec0b37dfa9f01", //nolint:lll + ), OutputVersion: 5, OutputPath: "m/schema:1\\'/recovery:1\\'/change:0/5", MerkleTree: d(""), @@ -711,8 +782,16 @@ func TestFulfillWithHardwiredData(t *testing.T) { ConfirmationTarget: 0, } - userKey, _ := NewHDPrivateKeyFromString("tprv8fAB8ynEKVR4LdJUc6ryH3u2tqJF8wZJH2rzRprZ6YhqdFHi5HnP1fYRuoHfos9RQZ1bkxsyP8oHENfiezvAp4dFj83rYbBGkQHSwbhqiDW", "m/schema:1'/recovery:1'", network) - muunKey, _ := NewHDPublicKeyFromString("tpubDBZaivUL3Hv8r25JDupShPuWVkGcwM7NgbMBwkhQLfWu18iBbyQCbRdyg1wRMjoWdZN7Afg3F25zs4c8E6Q4VJrGqAw51DJeqacTFABV9u8", "m/schema:1'/recovery:1'", network) + userKey, _ := NewHDPrivateKeyFromString( + "tprv8fAB8ynEKVR4LdJUc6ryH3u2tqJF8wZJH2rzRprZ6YhqdFHi5HnP1fYRuoHfos9RQZ1bkxsyP8oHENfiezvAp4dFj83rYbBGkQHSwbhqiDW", //nolint:lll + "m/schema:1'/recovery:1'", + network, + ) + muunKey, _ := NewHDPublicKeyFromString( + "tpubDBZaivUL3Hv8r25JDupShPuWVkGcwM7NgbMBwkhQLfWu18iBbyQCbRdyg1wRMjoWdZN7Afg3F25zs4c8E6Q4VJrGqAw51DJeqacTFABV9u8", //nolint:lll + "m/schema:1'/recovery:1'", + network, + ) invoice := &InvoiceSecrets{ preimage: d("e28dd8e23e3f427190104373c71f46db31efa665612f670610afe378a1713100"), @@ -738,7 +817,7 @@ func TestFulfillWithHardwiredData(t *testing.T) { signedTx := wire.NewMsgTx(2) signedTx.Deserialize(bytes.NewReader(result.FulfillmentTx)) - verifyInput(t, signedTx, hex.EncodeToString(swap.Htlc.HtlcTx), htlcTxIndex, 0) + mustVerifyInput(t, signedTx, hex.EncodeToString(swap.Htlc.HtlcTx), htlcTxIndex, 0) } func TestFulfillFullDebt(t *testing.T) { @@ -779,7 +858,11 @@ func TestFulfillFullDebt(t *testing.T) { } } -func createSphinxPacket(nodePublicKey *btcec.PublicKey, paymentHash, paymentSecret []byte, amt, lockTime int64) []byte { +func createSphinxPacket( + nodePublicKey *btcec.PublicKey, + paymentHash, paymentSecret []byte, + amt, lockTime int64, +) []byte { var paymentPath sphinx.PaymentPath paymentPath[0].NodePub = *nodePublicKey @@ -869,7 +952,11 @@ func createMppSphinxPacket( return buf.Bytes() } -func newAddressAt(userKey, muunKey *HDPrivateKey, keyPath string, network *Network) btcutil.Address { +func newAddressAt( + userKey, muunKey *HDPrivateKey, + keyPath string, + network *Network, +) btcutil.Address { userPublicKey, err := userKey.PublicKey().DeriveTo(keyPath) if err != nil { panic(err) @@ -898,32 +985,30 @@ func serializeTx(tx *wire.MsgTx) []byte { return buf.Bytes() } -func getInvoiceSecrets(invoice string, userKey *HDPrivateKey) (paymentHash []byte, paymentSecret []byte, identityKey *btcec.PublicKey) { - db, err := openDB() - if err != nil { - panic(err) - } - defer db.Close() - +func getInvoiceSecrets( + invoice string, + userKey *HDPrivateKey, +) (paymentHash []byte, paymentSecret []byte, identityKey *btcec.PublicKey) { payReq, err := zpay32.Decode(invoice, network.network) if err != nil { panic(err) } - dbInvoice, err := db.FindByPaymentHash(payReq.PaymentHash[:]) - if err != nil { - panic(err) - } - - paymentHash = payReq.PaymentHash[:] - paymentSecret = dbInvoice.PaymentSecret - keyPath := hdpath.MustParse(dbInvoice.KeyPath).Child(identityKeyChildIndex) - key, err := userKey.DeriveTo(keyPath.String()) - if err != nil { - panic(err) - } - identityKey, err = key.key.ECPubKey() - if err != nil { + if err := Pool.WithDB(func(db *walletdb.DB) error { + dbInvoice, err := db.FindByPaymentHash(payReq.PaymentHash[:]) + if err != nil { + return err + } + paymentHash = payReq.PaymentHash[:] + paymentSecret = dbInvoice.PaymentSecret + keyPath := hdpath.MustParse(dbInvoice.KeyPath).Child(identityKeyChildIndex) + key, err := userKey.DeriveTo(keyPath.String()) + if err != nil { + return err + } + identityKey, err = key.key.ECPubKey() + return err + }); err != nil { panic(err) } return diff --git a/libwallet/init.go b/libwallet/init.go index 54b75d49..5c628a1c 100644 --- a/libwallet/init.go +++ b/libwallet/init.go @@ -2,9 +2,11 @@ package libwallet import ( "github.com/muun/libwallet/app_provided_data" + "github.com/muun/libwallet/walletdb" ) var Cfg *app_provided_data.Config +var Pool *walletdb.Pool // Init configures the libwallet func Init(c *app_provided_data.Config) { diff --git a/libwallet/init_test.go b/libwallet/init_test.go index 045f737b..38f83e82 100644 --- a/libwallet/init_test.go +++ b/libwallet/init_test.go @@ -2,8 +2,10 @@ package libwallet import ( "os" + "path" "github.com/muun/libwallet/app_provided_data" + "github.com/muun/libwallet/walletdb" ) func setup() { @@ -15,4 +17,13 @@ func setup() { Init(&app_provided_data.Config{ DataDir: dir, }) + + if Pool != nil { + Pool.Close() + } + pool, err := walletdb.NewPool(path.Join(dir, "wallet.db"), nil) + if err != nil { + panic(err) + } + Pool = pool } diff --git a/libwallet/internal/kvmigrationlock/kv_migration_lock.go b/libwallet/internal/kvmigrationlock/kv_migration_lock.go index f06bcb7d..0e2400b2 100644 --- a/libwallet/internal/kvmigrationlock/kv_migration_lock.go +++ b/libwallet/internal/kvmigrationlock/kv_migration_lock.go @@ -10,6 +10,8 @@ import ( "go/token" "sort" + "github.com/go-errors/errors" + "github.com/muun/libwallet/storage" ) @@ -35,7 +37,7 @@ func Generate(plan []storage.Migration, migrationsFilePath string) (*Lockfile, e // inside an AddCustomChange literal does not affect its hash and invalidate the lockfile. fileNode, err := parser.ParseFile(fset, migrationsFilePath, nil, 0) if err != nil { - return nil, fmt.Errorf("could not parse %s: %w", migrationsFilePath, err) + return nil, errors.Errorf("could not parse %s: %w", migrationsFilePath, err) } lockfile := &Lockfile{ @@ -49,13 +51,14 @@ func Generate(plan []storage.Migration, migrationsFilePath string) (*Lockfile, e for _, change := range migration.Changes { h, err := hashChange(change, fset, fileNode) if err != nil { - return nil, fmt.Errorf("migration '%s': %w", migration.Description, err) + return nil, errors.Errorf("migration '%s': %w", migration.Description, err) } lock.ChangeHashes = append(lock.ChangeHashes, h) } // Hash the migration as description + change hashes in order. - // Order is intentionally preserved: swapping two changes within a migration must be detected. + // Order is intentionally preserved: swapping two changes within a + // migration must be detected. hw := sha256.New() hw.Write([]byte(migration.Description)) for _, ch := range lock.ChangeHashes { @@ -78,12 +81,22 @@ func hashChange(change storage.Change, fset *token.FileSet, fileNode *ast.File) } // stableChangeString produces a deterministic string representation of a Change for hashing. -// json.Marshal is not used because ValueType is an interface and would serialize to {} for all types. -func stableChangeString(change storage.Change, fset *token.FileSet, fileNode *ast.File) (string, error) { +// json.Marshal is not used because ValueType is an interface and would serialize to {} for all +// types. +func stableChangeString( + change storage.Change, + fset *token.FileSet, + fileNode *ast.File, +) (string, error) { switch c := change.(type) { case storage.KeyDefinition: - return fmt.Sprintf("KeyDefinition{Key:%s, BackupType:%d, BackupSecurity:%d, SecurityCritical:%v, ValueType:%T}", - c.Key, c.BackupType, c.BackupSecurity, c.SecurityCritical, c.ValueType), nil + return fmt.Sprintf( + "KeyDefinition{Key:%s, BackupType:%d, "+ + "BackupSecurity:%d, SecurityCritical:%v, "+ + "ValueType:%T}", + c.Key, c.BackupType, c.BackupSecurity, + c.SecurityCritical, c.ValueType, + ), nil case storage.TypeMigration: return fmt.Sprintf("TypeMigration{Key:%s, NewType:%T}", @@ -105,12 +118,12 @@ func stableChangeString(change storage.Change, fset *token.FileSet, fileNode *as return fmt.Sprintf("CustomChange{ID:%s, Step:%s}", c.ID, src), nil default: - return "", fmt.Errorf("unknown change type %T", change) + return "", errors.Errorf("unknown change type %T", change) } } -// findCustomChangeSource finds and formats the function literal passed to AddCustomChange("id", ...) -// by matching the ID string argument in the AST. +// findCustomChangeSource finds and formats the function literal passed to AddCustomChange("id", +// ...) by matching the ID string argument in the AST. func findCustomChangeSource(id string, fset *token.FileSet, fileNode *ast.File) (string, error) { var source string var found bool @@ -137,7 +150,10 @@ func findCustomChangeSource(id string, fset *token.FileSet, fileNode *ast.File) } funcLit, ok := call.Args[1].(*ast.FuncLit) if !ok { - findErr = fmt.Errorf("second argument of AddCustomChange(%q, ...) is not a function literal", id) + findErr = errors.Errorf( + "second argument of AddCustomChange(%q, ...) is not a function literal", + id, + ) return false } var buf bytes.Buffer @@ -145,7 +161,7 @@ func findCustomChangeSource(id string, fset *token.FileSet, fileNode *ast.File) // format.Node produces the canonical representation of the code, // normalizing whitespace and empty lines so they don't affect the hash. if err := format.Node(&buf, fset, funcLit); err != nil { - findErr = fmt.Errorf("failed to format function literal for id %q: %w", id, err) + findErr = errors.Errorf("failed to format function literal for id %q: %w", id, err) return false } source = buf.String() @@ -157,7 +173,7 @@ func findCustomChangeSource(id string, fset *token.FileSet, fileNode *ast.File) return "", findErr } if !found { - return "", fmt.Errorf("AddCustomChange(%q, ...) not found in migrations file", id) + return "", errors.Errorf("AddCustomChange(%q, ...) not found in migrations file", id) } return source, nil } diff --git a/libwallet/internal/testutils/keys.go b/libwallet/internal/testutils/keys.go new file mode 100644 index 00000000..9c7d1738 --- /dev/null +++ b/libwallet/internal/testutils/keys.go @@ -0,0 +1,53 @@ +package testutils + +import ( + "crypto/rand" + + "github.com/btcsuite/btcd/btcec/v2" + + "github.com/muun/libwallet" + "github.com/muun/libwallet/recoverycode" +) + +// TestKeys holds all cryptographic keys needed for testing recovery and challenge key actions. +type TestKeys struct { + UserKey *libwallet.HDPrivateKey + MuunKey *libwallet.HDPrivateKey + RecoveryCodeKey *btcec.PrivateKey + RecoveryCode string +} + +// GenerateTestKeys creates a fresh set of test keys using Regtest network. +func GenerateTestKeys() *TestKeys { + userKey, err := libwallet.NewHDPrivateKey(randomBytes(32), libwallet.Regtest()) + if err != nil { + panic("failed to generate user key: " + err.Error()) + } + + muunKey, err := libwallet.NewHDPrivateKey(randomBytes(32), libwallet.Regtest()) + if err != nil { + panic("failed to generate muun key: " + err.Error()) + } + + rc := recoverycode.Generate() + rcKey, err := recoverycode.ConvertToKey(rc, "") + if err != nil { + panic("failed to convert recovery code to key: " + err.Error()) + } + + return &TestKeys{ + UserKey: userKey, + MuunKey: muunKey, + RecoveryCodeKey: rcKey, + RecoveryCode: rc, + } +} + +func randomBytes(count int) []byte { + buf := make([]byte, count) + _, err := rand.Read(buf) + if err != nil { + panic("couldn't read random bytes") + } + return buf +} diff --git a/libwallet/internal/testutils/mock_houston_service.go b/libwallet/internal/testutils/mock_houston_service.go new file mode 100644 index 00000000..912c682b --- /dev/null +++ b/libwallet/internal/testutils/mock_houston_service.go @@ -0,0 +1,106 @@ +package testutils + +import ( + "github.com/muun/libwallet/service" + "github.com/muun/libwallet/service/model" +) + +// Compile-time check. +var _ service.HoustonService = (*MockHoustonService)(nil) + +// MockHoustonService is a configurable test double for service.HoustonService. +// Set the fields you need for your test; all unconfigured methods panic. +type MockHoustonService struct { + VerifiableMuunKeyResult model.VerifiableMuunKeyJson + VerifiableMuunKeyErr error + FinishWithVerifiableResult model.VerifiableMuunKeyJson + FinishWithVerifiableErr error + + // Captured requests for assertions + CapturedChallengeSetupVerify *model.ChallengeSetupVerifyJson +} + +func (m *MockHoustonService) VerifiableMuunKey() (model.VerifiableMuunKeyJson, error) { + return m.VerifiableMuunKeyResult, m.VerifiableMuunKeyErr +} + +func (m *MockHoustonService) ChallengeSetupFinishWithVerifiableMuunKey( + req model.ChallengeSetupVerifyJson, +) (model.VerifiableMuunKeyJson, error) { + m.CapturedChallengeSetupVerify = &req + return m.FinishWithVerifiableResult, m.FinishWithVerifiableErr +} + +// Methods below are not used in the actions under test — they panic if called. + +func (m *MockHoustonService) HealthCheck() error { + panic("MockHoustonService: unexpected call to HealthCheck") +} + +func (m *MockHoustonService) ChallengeKeySetupStart( + model.ChallengeSetupJson, +) (model.SetupChallengeResponseJson, error) { + panic("MockHoustonService: unexpected call to ChallengeKeySetupStart") +} + +func (m *MockHoustonService) ChallengeKeySetupFinish(model.ChallengeSetupVerifyJson) error { + panic("MockHoustonService: unexpected call to ChallengeKeySetupFinish") +} + +func (m *MockHoustonService) CreateFirstSession( + model.CreateFirstSessionJson, +) (model.CreateFirstSessionOkJson, error) { + panic("MockHoustonService: unexpected call to CreateFirstSession") +} + +func (m *MockHoustonService) FetchFeeWindow() (model.FeeWindowJson, error) { + panic("MockHoustonService: unexpected call to FetchFeeWindow") +} + +func (m *MockHoustonService) SubmitDiagnosticsScanData(model.DiagnosticScanDataJson) error { + panic("MockHoustonService: unexpected call to SubmitDiagnosticsScanData") +} + +func (m *MockHoustonService) PairRequestChallenge() (model.PairRequestChallengeResponseJSON, error) { + panic("MockHoustonService: unexpected call to PairRequestChallenge") +} + +func (m *MockHoustonService) PairSubmitSignedChallenge( + model.PairSubmitSignedChallengeJSON, +) (model.PairSubmitSignedChallengeResponseJSON, error) { + panic("MockHoustonService: unexpected call to PairSubmitSignedChallenge") +} + +func (m *MockHoustonService) SignRequestChallenge( + model.SignRequestChallengeJSON, +) (model.SignRequestChallengeResponseJSON, error) { + panic("MockHoustonService: unexpected call to SignRequestChallenge") +} + +func (m *MockHoustonService) SignSubmitSignedChallenge( + model.SignSubmitSignedChallengeJSON, +) error { + panic("MockHoustonService: unexpected call to SignSubmitSignedChallenge") +} + +func (m *MockHoustonService) RegisterSecurityCard( + model.RegisterSecurityCardJson, +) (model.RegisterSecurityCardOkJson, error) { + panic("MockHoustonService: unexpected call to RegisterSecurityCard") +} + +func (m *MockHoustonService) ChallengeSecurityCardSign( + model.ChallengeSecurityCardSignJson, +) (model.ChallengeSecurityCardSignResponseJson, error) { + panic("MockHoustonService: unexpected call to ChallengeSecurityCardSign") +} + +func (m *MockHoustonService) SolveSecurityCardChallenge( + model.SolveSecurityCardChallengeJson, +) error { + panic("MockHoustonService: unexpected call to SolveSecurityCardChallenge") +} + +func (m *MockHoustonService) FetchSecurityCardsMarketplace() (model.SecurityCardsMarketplaceJson, error) { + panic("MockHoustonService: unexpected call to FetchSecurityCardsMarketplace") +} diff --git a/libwallet/internal/testutils/mock_key_provider.go b/libwallet/internal/testutils/mock_key_provider.go new file mode 100644 index 00000000..e6a1289e --- /dev/null +++ b/libwallet/internal/testutils/mock_key_provider.go @@ -0,0 +1,54 @@ +package testutils + +import ( + "github.com/muun/libwallet" + "github.com/muun/libwallet/data/keys" +) + +// Compile-time check that MockKeyProvider implements keys.KeyProvider. +var _ keys.KeyProvider = (*MockKeyProvider)(nil) + +// MockKeyProvider is a test implementation of keys.KeyProvider. +// Set error fields to simulate failures. +type MockKeyProvider struct { + userPrivateKey *libwallet.HDPrivateKey + muunKey *libwallet.HDPrivateKey + UserPrivateKeyErr error + MuunPublicKeyErr error +} + +// NewMockKeyProvider creates a MockKeyProvider from test keys. +func NewMockKeyProvider(testKeys *TestKeys) *MockKeyProvider { + return &MockKeyProvider{ + userPrivateKey: testKeys.UserKey, + muunKey: testKeys.MuunKey, + } +} + +func (m *MockKeyProvider) UserPrivateKey() (*libwallet.HDPrivateKey, error) { + if m.UserPrivateKeyErr != nil { + return nil, m.UserPrivateKeyErr + } + return m.userPrivateKey, nil +} + +func (m *MockKeyProvider) MuunPublicKey() (*libwallet.HDPublicKey, error) { + if m.MuunPublicKeyErr != nil { + return nil, m.MuunPublicKeyErr + } + return m.muunKey.PublicKey(), nil +} + +// Methods below are not used in the actions under test — they panic if called. + +func (m *MockKeyProvider) UserPublicKey() (*libwallet.HDPublicKey, error) { + panic("unimplemented") +} + +func (m *MockKeyProvider) EncryptedMuunPrivateKey() (*libwallet.EncryptedPrivateKeyInfo, error) { + panic("unimplemented") +} + +func (m *MockKeyProvider) MaxDerivedIndex() int { + panic("unimplemented") +} diff --git a/libwallet/internal/testutils/storage.go b/libwallet/internal/testutils/storage.go new file mode 100644 index 00000000..f69e44d6 --- /dev/null +++ b/libwallet/internal/testutils/storage.go @@ -0,0 +1,29 @@ +package testutils + +import ( + "path" + "testing" + + "github.com/muun/libwallet/storage" + "github.com/muun/libwallet/walletdb" +) + +// NewTestKeyValueStorage creates a KeyValueStorage backed by a temp file with the production +// schema. +func NewTestKeyValueStorage(t *testing.T) *storage.KeyValueStorage { + t.Helper() + + dbPath := path.Join(t.TempDir(), "test.db") + var schema map[string]storage.Classification + pool, err := walletdb.NewPool(dbPath, func(db *walletdb.DB) error { + var migErr error + schema, migErr = storage.RunKeyValueMigrations(db, storage.BuildKVMigrationPlan()) + return migErr + }) + if err != nil { + t.Fatalf("failed to open walletdb: %v", err) + } + t.Cleanup(func() { pool.Close() }) + + return storage.NewKeyValueStorage(pool.NewKeyValueRepository(), schema) +} diff --git a/libwallet/internal/testutils/verifiable_muun_key.go b/libwallet/internal/testutils/verifiable_muun_key.go new file mode 100644 index 00000000..9f9c1638 --- /dev/null +++ b/libwallet/internal/testutils/verifiable_muun_key.go @@ -0,0 +1,88 @@ +package testutils + +import ( + "encoding/hex" + + "github.com/btcsuite/btcd/btcec/v2" + + "github.com/muun/libwallet/cryptography/bitcoin_hpke" + "github.com/muun/libwallet/domain/model/encrypted_key_v3" + "github.com/muun/libwallet/service/model" +) + +// BuildVerifiableMuunKeyJson creates a valid VerifiableMuunKeyJson for testing. +// The muun key is split into two halves: firstHalf encrypted to the user's public key, +// secondHalf encrypted to the recovery code's public key. If withProof is true, includes +// "mock_proof" which bypasses ZK verification in test mode. +func BuildVerifiableMuunKeyJson( //nolint:staticcheck // TODO: func BuildVerifiableMuunKeyJson should be BuildVerifiableMuunKeyJSON + testKeys *TestKeys, + withProof bool, +) *model.VerifiableMuunKeyJson { + // Split the muun private key: muunPrivKey = firstHalf + secondHalf + firstHalfKey, err := btcec.NewPrivateKey() + if err != nil { + panic("failed to generate first half key: " + err.Error()) + } + + muunECPrivateKey, err := testKeys.MuunKey.ECPrivateKey() + if err != nil { + panic("failed to get muun EC private key: " + err.Error()) + } + + secondHalfKeyBytes := new(btcec.ModNScalar). + Set(&firstHalfKey.Key). + Negate(). + Add(&muunECPrivateKey.Key). + Bytes() + + // Encrypt first half to user's public key (this is what Verify() will decrypt) + userECPubKey, err := testKeys.UserKey.PublicKey().ECPubKey() + if err != nil { + panic("failed to get user EC public key: " + err.Error()) + } + + firstHalfEncToClient, err := bitcoin_hpke.SingleShotEncrypt( + firstHalfKey.Serialize(), + userECPubKey, + []byte(encrypted_key_v3.MuunFirstHalfToClient), + []byte(""), + ) + if err != nil { + panic("failed to encrypt first half to client: " + err.Error()) + } + + // Encrypt second half to recovery code's public key + rcPubKey := testKeys.RecoveryCodeKey.PubKey() + + secondHalfEncToRC, err := bitcoin_hpke.SingleShotEncrypt( + secondHalfKeyBytes[:], + rcPubKey, + []byte(encrypted_key_v3.MuunSecondHalfToRecoveryCode), + []byte(""), + ) + if err != nil { + panic("failed to encrypt second half to recovery code: " + err.Error()) + } + + var proof *string + if withProof { + p := "mock_proof" + proof = &p + } + + return &model.VerifiableMuunKeyJson{ + FirstHalfKeyEncryptedToClient: hex.EncodeToString(firstHalfEncToClient.Serialize()), + SecondHalfKeyEncryptedToRecoveryCode: hex.EncodeToString(secondHalfEncToRC.Serialize()), + Proof: proof, + } +} + +// BuildInvalidVerifiableMuunKeyJson returns a VerifiableMuunKeyJson with invalid hex data +// that will cause parsing to fail inside ComputeAndStoreEncryptedMuunKeyAction. +func BuildInvalidVerifiableMuunKeyJson() model.VerifiableMuunKeyJson { //nolint:staticcheck // TODO: func BuildInvalidVerifiableMuunKeyJson should be BuildInvalidVerifiableMuunKeyJSON + return model.VerifiableMuunKeyJson{ + FirstHalfKeyEncryptedToClient: "not-valid-hex", + SecondHalfKeyEncryptedToRecoveryCode: "not-valid-hex", + Proof: nil, + } +} diff --git a/libwallet/invoice.go b/libwallet/invoice.go index 983800aa..b6d17e7d 100644 --- a/libwallet/invoice.go +++ b/libwallet/invoice.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/lightningnetwork/lnd/zpay32" + "github.com/muun/libwallet/errors" ) diff --git a/libwallet/invoice_test.go b/libwallet/invoice_test.go index 75a45b52..e3c49d05 100644 --- a/libwallet/invoice_test.go +++ b/libwallet/invoice_test.go @@ -9,17 +9,17 @@ import ( func TestParseInvoice(t *testing.T) { const ( - invoice = "lnbcrt1pwtpd4xpp55meuklpslk5jtxytyh7u2q490c2xhm68dm3a94486zntsg7ad4vsdqqcqzys763w70h39ze44ngzhdt2mag84wlkefqkphuy7ssg4la5gt9vcpmqts00fnapf8frs928mc5ujfutzyu8apkezhrfvydx82l40w0fckqqmerzjc" - invoiceWithAmount = "lnbcrt10u1pwtpd4jpp5lh0p9amq02xel0gduna95ta5ve9q5dwyk8tglvpa258yzzvcgynsdqqcqzysrukfteknjzcqpu8kfnm76dhdtnkmyr3j42xrl89axhqxmpgusyqhn28u2uaave3nr8sk3mg5nug6t8hcnj2aw8t2l5wtksh6w0yyntgqjrrgqk" - invoiceWithDescription = "lnbcrt1pwtpdh7pp5celcayxvuw9pm9f8420n2dyd3css8ahzlr4nl69uczhf2sf99ydqdqswe5hvcfqwpjhymmwcqzysx7gwcf9a559rxrah9yp0u7dnk4vuvq2ywy6dyqtwzna9c92q058qppmv9p094vq9g6nv46d3sc7jd8faglzjj2h0w7j06wcu2h3e27cqc5zm4d" - invoiceWithFallbackAdrr = "lnbcrt1pwtpduxpp57xglq4thtrerzzxt8wzg4wresfclewh8pk8xghahwq8kgek3qslqdqqcqzysfppqhv0a0uhrt2crdehgfge8e8e6texw3q4hpmge888yuu6076utcrhgc97wu7vydmudyagkz25ahuyp4fqrc9e945ff248cpa3krn7vvgcqq6spyuqltd245sjvwh23gz220cegadspkn3lx0" + invoice = "lnbcrt1pwtpd4xpp55meuklpslk5jtxytyh7u2q490c2xhm68dm3a94486zntsg7ad4vsdqqcqzys763w70h39ze44ngzhdt2mag84wlkefqkphuy7ssg4la5gt9vcpmqts00fnapf8frs928mc5ujfutzyu8apkezhrfvydx82l40w0fckqqmerzjc" //nolint:lll + invoiceWithAmount = "lnbcrt10u1pwtpd4jpp5lh0p9amq02xel0gduna95ta5ve9q5dwyk8tglvpa258yzzvcgynsdqqcqzysrukfteknjzcqpu8kfnm76dhdtnkmyr3j42xrl89axhqxmpgusyqhn28u2uaave3nr8sk3mg5nug6t8hcnj2aw8t2l5wtksh6w0yyntgqjrrgqk" //nolint:lll + invoiceWithDescription = "lnbcrt1pwtpdh7pp5celcayxvuw9pm9f8420n2dyd3css8ahzlr4nl69uczhf2sf99ydqdqswe5hvcfqwpjhymmwcqzysx7gwcf9a559rxrah9yp0u7dnk4vuvq2ywy6dyqtwzna9c92q058qppmv9p094vq9g6nv46d3sc7jd8faglzjj2h0w7j06wcu2h3e27cqc5zm4d" //nolint:lll + invoiceWithFallbackAdrr = "lnbcrt1pwtpduxpp57xglq4thtrerzzxt8wzg4wresfclewh8pk8xghahwq8kgek3qslqdqqcqzysfppqhv0a0uhrt2crdehgfge8e8e6texw3q4hpmge888yuu6076utcrhgc97wu7vydmudyagkz25ahuyp4fqrc9e945ff248cpa3krn7vvgcqq6spyuqltd245sjvwh23gz220cegadspkn3lx0" //nolint:lll - invoiceHashHex = "a6f3cb7c30fda925988b25fdc502a57e146bef476ee3d2d6a7d0a6b823dd6d59" - invoiceWithAmountHashHex = "fdde12f7607a8d9fbd0de4fa5a2fb4664a0a35c4b1d68fb03d550e4109984127" - invoiceWithDescriptionHashHex = "c67f8e90cce38a1d9527aa9f35348d8e2103f6e2f8eb3fe8bcc0ae954125291a" - invoiceWithFallbackAddrHashHex = "f191f0557758f23108cb3b848ab8798271fcbae70d8e645fb7700f6466d1043e" + invoiceHashHex = "a6f3cb7c30fda925988b25fdc502a57e146bef476ee3d2d6a7d0a6b823dd6d59" //nolint:lll + invoiceWithAmountHashHex = "fdde12f7607a8d9fbd0de4fa5a2fb4664a0a35c4b1d68fb03d550e4109984127" //nolint:lll + invoiceWithDescriptionHashHex = "c67f8e90cce38a1d9527aa9f35348d8e2103f6e2f8eb3fe8bcc0ae954125291a" //nolint:lll + invoiceWithFallbackAddrHashHex = "f191f0557758f23108cb3b848ab8798271fcbae70d8e645fb7700f6466d1043e" //nolint:lll - invoiceDestinationHex = "028cfad4e092191a41f081bedfbe5a6e8f441603c78bf9001b8fb62ac0858f20edasd" + invoiceDestinationHex = "028cfad4e092191a41f081bedfbe5a6e8f441603c78bf9001b8fb62ac0858f20edasd" //nolint:lll ) invoiceDestination, _ := hex.DecodeString(invoiceDestinationHex) @@ -149,7 +149,7 @@ func TestParseInvoice(t *testing.T) { { name: "invoice with invalid fallback address", args: args{ - invoice: "lnbcrt1pwtpduxpp57xglq4thtrerzzxt8wzg4wresfclewh8pk8xghahwq8kgek3qslqdqqcqzysfppqhv0a0uhrt2crdehgfge8e8e6texw3q4hpmge888yuu6076utcrhgc97wu7vydmudyagkz25ahuyp4fqrc9e945ff248cpa3krn7vvgcqq6spyuqltd245sjvwh23gz220cegadspkn3lx0", + invoice: "lnbcrt1pwtpduxpp57xglq4thtrerzzxt8wzg4wresfclewh8pk8xghahwq8kgek3qslqdqqcqzysfppqhv0a0uhrt2crdehgfge8e8e6texw3q4hpmge888yuu6076utcrhgc97wu7vydmudyagkz25ahuyp4fqrc9e945ff248cpa3krn7vvgcqq6spyuqltd245sjvwh23gz220cegadspkn3lx0", //nolint:lll network: Mainnet(), }, wantErr: true, diff --git a/libwallet/invoices.go b/libwallet/invoices.go index e8c53213..f8005ac2 100644 --- a/libwallet/invoices.go +++ b/libwallet/invoices.go @@ -6,10 +6,7 @@ import ( "encoding/binary" "encoding/hex" "encoding/json" - "errors" - "fmt" "math/rand" - "path" "time" "github.com/btcsuite/btcd/btcec/v2" @@ -17,6 +14,7 @@ import ( "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" + "github.com/go-errors/errors" "github.com/lightningnetwork/lnd/keychain" "github.com/lightningnetwork/lnd/lnwire" "github.com/lightningnetwork/lnd/netann" @@ -45,7 +43,7 @@ type InvoiceSecrets struct { IdentityKey *HDPublicKey UserHtlcKey *HDPublicKey MuunHtlcKey *HDPublicKey - ShortChanId int64 + ShortChanId int64 //nolint:staticcheck // TODO: struct field ShortChanId should be ShortChanID } // RouteHints is a struct returned by the remote server containing the data @@ -87,21 +85,18 @@ func (l *InvoiceSecretsList) Get(i int) *InvoiceSecrets { return l.secrets[i] } -// GenerateInvoiceSecrets returns a slice of new secrets to register with -// the remote server. Once registered, those invoices should be stored with -// the PersistInvoiceSecrets method. +// GenerateInvoiceSecrets returns a slice of new secrets to register with the remote server. Once +// registered, those invoices should be stored with the PersistInvoiceSecrets method. func GenerateInvoiceSecrets(userKey, muunKey *HDPublicKey) (*InvoiceSecretsList, error) { var secrets []*InvoiceSecrets - db, err := openDB() - if err != nil { - return nil, err - } - defer db.Close() - - unused, err := db.CountUnusedInvoices() - if err != nil { + var unused int + if err := Pool.WithDB(func(db *walletdb.DB) error { + var err error + unused, err = db.CountUnusedInvoices() + return err + }); err != nil { return nil, err } @@ -111,7 +106,7 @@ func GenerateInvoiceSecrets(userKey, muunKey *HDPublicKey) (*InvoiceSecretsList, num := MaxUnusedSecrets - unused - for i := 0; i < num; i++ { + for i := 0; i < num; i++ { //nolint:modernize // TODO: use range over int preimage := randomBytes(32) paymentSecret := randomBytes(32) paymentHashArray := sha256.Sum256(preimage) @@ -141,7 +136,9 @@ func GenerateInvoiceSecrets(userKey, muunKey *HDPublicKey) (*InvoiceSecretsList, return nil, err } - shortChanId := binary.LittleEndian.Uint64(randomBytes(8)) | (1 << 63) + shortChanId := binary.LittleEndian.Uint64( //nolint:staticcheck // TODO: var shortChanId should be shortChanID + randomBytes(8), + ) | (1 << 63) secrets = append(secrets, &InvoiceSecrets{ preimage: preimage, @@ -164,23 +161,22 @@ func GenerateInvoiceSecrets(userKey, muunKey *HDPublicKey) (*InvoiceSecretsList, // in the device local database. These secrets can be used to craft new // Lightning invoices. func PersistInvoiceSecrets(list *InvoiceSecretsList) error { - db, err := openDB() - if err != nil { - return err - } - defer db.Close() - - for _, s := range list.secrets { - db.CreateInvoice(&walletdb.Invoice{ - Preimage: s.preimage, - PaymentHash: s.PaymentHash, - PaymentSecret: s.paymentSecret, - KeyPath: s.keyPath, - ShortChanId: uint64(s.ShortChanId), - State: walletdb.InvoiceStateRegistered, - }) - } - return nil + return Pool.WithDB(func(db *walletdb.DB) error { + for _, s := range list.secrets { + err := db.CreateInvoice(&walletdb.Invoice{ + Preimage: s.preimage, + PaymentHash: s.PaymentHash, + PaymentSecret: s.paymentSecret, + KeyPath: s.keyPath, + ShortChanId: uint64(s.ShortChanId), + State: walletdb.InvoiceStateRegistered, + }) + if err != nil { + return err + } + } + return nil + }) } type InvoiceBuilder struct { @@ -229,14 +225,12 @@ func (i *InvoiceBuilder) AddRouteHints(routeHints *RouteHints) *InvoiceBuilder { func (i *InvoiceBuilder) Build() (string, error) { // obtain first unused secret from db - db, err := openDB() - if err != nil { - return "", err - } - defer db.Close() - - dbInvoice, err := db.FindFirstUnusedInvoice() - if err != nil { + var dbInvoice *walletdb.Invoice + if err := Pool.WithDB(func(db *walletdb.DB) error { + var err error + dbInvoice, err = db.FindFirstUnusedInvoice() + return err + }); err != nil { return "", err } if dbInvoice == nil { @@ -251,7 +245,7 @@ func (i *InvoiceBuilder) Build() (string, error) { nodeID, err := parsePubKey(hint.Pubkey) if err != nil { - return "", fmt.Errorf("can't parse route hint pubkey: %w", err) + return "", errors.Errorf("can't parse route hint pubkey: %w", err) } iopts = append(iopts, zpay32.RouteHint([]zpay32.HopHint{ @@ -265,7 +259,8 @@ func (i *InvoiceBuilder) Build() (string, error) { })) } - // We require Payment Secret in our invoices. We also require TLV Onion since payment secret depends on it. + // We require Payment Secret in our invoices. We also require TLV Onion since payment secret + // depends on it. // // Having the flag as optional was causing some strict services to block // zero amount invoices from Muun. If the secret is optional, the last hop @@ -281,8 +276,12 @@ func (i *InvoiceBuilder) Build() (string, error) { // [2] ACINQ/phoenix@03e709c features := lnwire.EmptyFeatureVector() - features.RawFeatureVector.Set(lnwire.TLVOnionPayloadRequired) - features.RawFeatureVector.Set(lnwire.PaymentAddrRequired) + features.RawFeatureVector.Set( //nolint:staticcheck // TODO: could remove embedded field "RawFeatureVector" from selector + lnwire.TLVOnionPayloadRequired, + ) + features.RawFeatureVector.Set( //nolint:staticcheck // TODO: could remove embedded field "RawFeatureVector" from selector + lnwire.PaymentAddrRequired, + ) iopts = append(iopts, zpay32.Features(features)) iopts = append(iopts, zpay32.CLTVExpiry(72)) // ~1/2 day @@ -322,7 +321,7 @@ func (i *InvoiceBuilder) Build() (string, error) { } identityKey, err := identityHDKey.key.ECPrivKey() if err != nil { - return "", fmt.Errorf("can't obtain identity privkey: %w", err) + return "", errors.Errorf("can't obtain identity privkey: %w", err) } // sign the invoice with the identity pubkey @@ -344,10 +343,9 @@ func (i *InvoiceBuilder) Build() (string, error) { now := time.Now() - // This is rounding down. Invoices with amount accept any amount larger - // but none smaller. So if we have non-integer sats amount, rounding down - // might accept a few msats less. But, rounding up would always fail the - // payment. + // This is rounding down. Invoices with amount accept any amount larger but none smaller. So if + // we have non-integer sats amount, rounding down might accept a few msats less. But, rounding + // up would always fail the payment. if invoice.MilliSat != nil { dbInvoice.AmountSat = int64(invoice.MilliSat.ToSatoshis()) } else { @@ -368,22 +366,23 @@ func (i *InvoiceBuilder) Build() (string, error) { var buf bytes.Buffer err := json.NewEncoder(&buf).Encode(metadata) if err != nil { - return "", fmt.Errorf("failed to encode metadata json: %w", err) + return "", errors.Errorf("failed to encode metadata json: %w", err) } // encryption key is derived at 3/x/y with x and y random indexes key, err := deriveMetadataEncryptionKey(i.userKey) if err != nil { - return "", fmt.Errorf("failed to derive encryption key: %w", err) + return "", errors.Errorf("failed to derive encryption key: %w", err) } encryptedMetadata, err := key.Encrypter().Encrypt(buf.Bytes()) if err != nil { - return "", fmt.Errorf("failed to encrypt metadata: %w", err) + return "", errors.Errorf("failed to encrypt metadata: %w", err) } dbInvoice.Metadata = encryptedMetadata } - err = db.SaveInvoice(dbInvoice) - if err != nil { + if err := Pool.WithDB(func(db *walletdb.DB) error { + return db.SaveInvoice(dbInvoice) + }); err != nil { return "", err } @@ -403,19 +402,18 @@ func deriveMetadataEncryptionKey(key *HDPrivateKey) (*HDPrivateKey, error) { } func GetInvoiceMetadata(paymentHash []byte) (string, error) { - db, err := openDB() - if err != nil { - return "", err - } - invoice, err := db.FindByPaymentHash(paymentHash) - if err != nil { + var metadata string + if err := Pool.WithDB(func(db *walletdb.DB) error { + invoice, err := db.FindByPaymentHash(paymentHash) + if err != nil { + return err + } + metadata = invoice.Metadata + return nil + }); err != nil { return "", err } - return invoice.Metadata, nil -} - -func openDB() (*walletdb.DB, error) { - return walletdb.Open(path.Join(Cfg.DataDir, "wallet.db")) + return metadata, nil } func parsePubKey(s string) (*btcec.PublicKey, error) { @@ -426,8 +424,23 @@ func parsePubKey(s string) (*btcec.PublicKey, error) { return btcec.ParsePubKey(bytes) } -func verifyTxWitnessSignature(tx *wire.MsgTx, sigHashes *txscript.TxSigHashes, outputIndex int, amount int64, script []byte, sig []byte, signKey *btcec.PublicKey) error { - sigHash, err := txscript.CalcWitnessSigHash(script, sigHashes, txscript.SigHashAll, tx, outputIndex, amount) +func verifyTxWitnessSignature( + tx *wire.MsgTx, + sigHashes *txscript.TxSigHashes, + outputIndex int, + amount int64, + script []byte, + sig []byte, + signKey *btcec.PublicKey, +) error { + sigHash, err := txscript.CalcWitnessSigHash( + script, + sigHashes, + txscript.SigHashAll, + tx, + outputIndex, + amount, + ) if err != nil { return err } diff --git a/libwallet/invoices_test.go b/libwallet/invoices_test.go index a980bbd2..8cd6b2bc 100644 --- a/libwallet/invoices_test.go +++ b/libwallet/invoices_test.go @@ -9,6 +9,8 @@ import ( "github.com/btcsuite/btcd/btcutil" "github.com/lightningnetwork/lnd/lnwire" "github.com/lightningnetwork/lnd/zpay32" + + "github.com/muun/libwallet/walletdb" ) func TestInvoiceSecrets(t *testing.T) { @@ -22,7 +24,7 @@ func TestInvoiceSecrets(t *testing.T) { muunKey.Path = "m/schema:1'/recovery:1'" routeHints := &RouteHints{ - Pubkey: "03c48d1ff96fa32e2776f71bba02102ffc2a1b91e2136586418607d32e762869fd", + Pubkey: "03c48d1ff96fa32e2776f71bba02102ffc2a1b91e2136586418607d32e762869fd", //nolint:lll FeeBaseMsat: 1000, FeeProportionalMillionths: 1000, CltvExpiryDelta: 8, @@ -109,10 +111,16 @@ func TestInvoiceSecrets(t *testing.T) { t.Fatalf("expected fee base to be 1000 msat, got %v instead", hopHints[0].FeeBaseMSat) } if hopHints[0].FeeProportionalMillionths != 1000 { - t.Fatalf("expected fee proportional millionths to be 1000, got %v instead", hopHints[0].FeeProportionalMillionths) + t.Fatalf( + "expected fee proportional millionths to be 1000, got %v instead", + hopHints[0].FeeProportionalMillionths, + ) } if hopHints[0].CLTVExpiryDelta != 8 { - t.Fatalf("expected CLTV expiry delta to be 8, got %v instead", hopHints[0].CLTVExpiryDelta) + t.Fatalf( + "expected CLTV expiry delta to be 8, got %v instead", + hopHints[0].CLTVExpiryDelta, + ) } metadata, err := GetInvoiceMetadata(payreq.PaymentHash[:]) if err != nil { @@ -180,13 +188,12 @@ func TestInvoiceSecrets(t *testing.T) { t.Fatal(err) } - db, err := openDB() - if err != nil { - t.Fatal(err) - } - defer db.Close() - invoiceMetadata, err := db.FindByPaymentHash(payreq3.PaymentHash[:]) - if err != nil { + var invoiceMetadata *walletdb.Invoice + if err := Pool.WithDB(func(db *walletdb.DB) error { + var err error + invoiceMetadata, err = db.FindByPaymentHash(payreq3.PaymentHash[:]) + return err + }); err != nil { t.Fatal(err) } @@ -203,7 +210,7 @@ func TestInvoiceSecrets(t *testing.T) { UserKey(userKey). AddRouteHints(routeHints). AddRouteHints(&RouteHints{ - Pubkey: "03c48d1ff96fa32e2776f71bba02102ffc2a1b91e2136586418607d32e762869ff", + Pubkey: "03c48d1ff96fa32e2776f71bba02102ffc2a1b91e2136586418607d32e762869ff", //nolint:lll FeeBaseMsat: 123, FeeProportionalMillionths: 1, CltvExpiryDelta: 23, @@ -238,7 +245,7 @@ func TestInvoiceSecrets(t *testing.T) { // Second hint expectedFeeBase = 123 expectedProportional = 1 - expectedPubKey = "03c48d1ff96fa32e2776f71bba02102ffc2a1b91e2136586418607d32e762869ff" + expectedPubKey = "03c48d1ff96fa32e2776f71bba02102ffc2a1b91e2136586418607d32e762869ff" //nolint:lll } else if hint.CLTVExpiryDelta == uint16(routeHints.CltvExpiryDelta) { // First hint expectedFeeBase = uint32(routeHints.FeeBaseMsat) @@ -252,7 +259,12 @@ func TestInvoiceSecrets(t *testing.T) { t.Fatal("invalid short channel id in hophints") } if hint.FeeProportionalMillionths != expectedProportional { - t.Fatalf("Route hint %v proportional fee %v != %v", i, hint.FeeProportionalMillionths, expectedProportional) + t.Fatalf( + "Route hint %v proportional fee %v != %v", + i, + hint.FeeProportionalMillionths, + expectedProportional, + ) } if hint.FeeBaseMSat != expectedFeeBase { t.Fatalf("Route hint %v base fee %v != %v", i, hint.FeeBaseMSat, expectedFeeBase) diff --git a/libwallet/keycrypt/keycrypt.go b/libwallet/keycrypt/keycrypt.go index 1b4579c6..aa0a3d4c 100755 --- a/libwallet/keycrypt/keycrypt.go +++ b/libwallet/keycrypt/keycrypt.go @@ -5,16 +5,15 @@ import ( "crypto/rand" "encoding/binary" "encoding/hex" - "errors" - "fmt" "strconv" "strings" "unicode/utf16" "github.com/btcsuite/btcd/btcutil/hdkeychain" + "github.com/go-errors/errors" + "golang.org/x/crypto/scrypt" "github.com/muun/libwallet/aescbc" - "golang.org/x/crypto/scrypt" ) const ( @@ -45,14 +44,14 @@ func Encrypt(key *hdkeychain.ExtendedKey, keyPath, passphrase string) (string, e scryptOutputLength, ) if err != nil { - return "", fmt.Errorf("failed to compute scrypt key: %w", err) + return "", errors.Errorf("failed to compute scrypt key: %w", err) } privateKeyBytes := []byte(key.String()) encrypted, err := aescbc.EncryptPkcs7(inputSecret, iv, privateKeyBytes) if err != nil { - return "", fmt.Errorf("failed to encrypt: %w", err) + return "", errors.Errorf("failed to encrypt: %w", err) } derivationPathBytes := []byte(keyPath) @@ -83,41 +82,41 @@ func Decrypt(value, passphrase string) (*hdkeychain.ExtendedKey, string, error) version := elements[0] iterations, err := strconv.Atoi(elements[1]) if err != nil { - return nil, "", fmt.Errorf("invalid iterations: %w", err) + return nil, "", errors.Errorf("invalid iterations: %w", err) } parallelizationFactor, err := strconv.Atoi(elements[2]) if err != nil { - return nil, "", fmt.Errorf("invalid p: %w", err) + return nil, "", errors.Errorf("invalid p: %w", err) } blockSize, err := strconv.Atoi(elements[3]) if err != nil { - return nil, "", fmt.Errorf("invalid blocksize: %w", err) + return nil, "", errors.Errorf("invalid blocksize: %w", err) } salt, err := hex.DecodeString(elements[4]) if err != nil { - return nil, "", fmt.Errorf("invalid salt: %w", err) + return nil, "", errors.Errorf("invalid salt: %w", err) } iv, err := hex.DecodeString(elements[5]) if err != nil { - return nil, "", fmt.Errorf("invalid iv: %w", err) + return nil, "", errors.Errorf("invalid iv: %w", err) } payload, err := hex.DecodeString(elements[6]) if err != nil { - return nil, "", fmt.Errorf("invalid payload: %w", err) + return nil, "", errors.Errorf("invalid payload: %w", err) } pathBytes, err := hex.DecodeString(elements[7]) if err != nil { - return nil, "", fmt.Errorf("invalid path: %w", err) + return nil, "", errors.Errorf("invalid path: %w", err) } if version != "v1" { - return nil, "", fmt.Errorf("invalid version %s", version) + return nil, "", errors.Errorf("invalid version %s", version) } inputSecret, err := scrypt.Key( @@ -129,17 +128,17 @@ func Decrypt(value, passphrase string) (*hdkeychain.ExtendedKey, string, error) scryptOutputLength, ) if err != nil { - return nil, "", fmt.Errorf("failed to compute scrypt key: %w", err) + return nil, "", errors.Errorf("failed to compute scrypt key: %w", err) } decryptedBytes, err := aescbc.DecryptPkcs7(inputSecret, iv, payload) if err != nil { - return nil, "", fmt.Errorf("failed to decrypt: %w", err) + return nil, "", errors.Errorf("failed to decrypt: %w", err) } key, err := hdkeychain.NewKeyFromString(string(decryptedBytes[:])) if err != nil { - return nil, "", fmt.Errorf("could not decode private key: %w", err) + return nil, "", errors.Errorf("could not decode private key: %w", err) } if !key.IsPrivate() { return nil, "", errors.New("expected extended key to be private, not public") @@ -168,7 +167,7 @@ func encodeUTF16(s string) []byte { var buf bytes.Buffer for _, r := range utf16.Encode([]rune(s)) { - binary.Write(&buf, binary.BigEndian, r) + binary.Write(&buf, binary.BigEndian, r) //nolint:errcheck // TODO: check error } return buf.Bytes() } diff --git a/libwallet/keycrypt/keycrypt_test.go b/libwallet/keycrypt/keycrypt_test.go index b2f91f3e..c42357d9 100755 --- a/libwallet/keycrypt/keycrypt_test.go +++ b/libwallet/keycrypt/keycrypt_test.go @@ -5,9 +5,8 @@ import ( "os" "testing" - "github.com/btcsuite/btcd/chaincfg" - "github.com/btcsuite/btcd/btcutil/hdkeychain" + "github.com/btcsuite/btcd/chaincfg" ) var ( diff --git a/libwallet/keycrypter.go b/libwallet/keycrypter.go index ccaeca2c..da1449b2 100644 --- a/libwallet/keycrypter.go +++ b/libwallet/keycrypter.go @@ -1,7 +1,7 @@ package libwallet import ( - "fmt" + "github.com/go-errors/errors" "github.com/muun/libwallet/keycrypt" ) @@ -17,7 +17,7 @@ type DecryptedKey struct { func KeyEncrypt(privKey *HDPrivateKey, passphrase string) (string, error) { ciphertext, err := keycrypt.Encrypt(&privKey.key, privKey.Path, passphrase) if err != nil { - return "", fmt.Errorf("KeyEncrypt: failed to encrypt: %w", err) + return "", errors.Errorf("KeyEncrypt: failed to encrypt: %w", err) } return ciphertext, nil } @@ -26,7 +26,7 @@ func KeyEncrypt(privKey *HDPrivateKey, passphrase string) (string, error) { func KeyDecrypt(value, passphrase string, network *Network) (*DecryptedKey, error) { key, path, err := keycrypt.Decrypt(value, passphrase) if err != nil { - return nil, fmt.Errorf("KeyDecrypt: failed to decrypt: %w", err) + return nil, errors.Errorf("KeyDecrypt: failed to decrypt: %w", err) } privateKey := &HDPrivateKey{key: *key, Network: network, Path: path} diff --git a/libwallet/librs/librs.go b/libwallet/librs/librs.go index c13d0c61..4db696d1 100644 --- a/libwallet/librs/librs.go +++ b/libwallet/librs/librs.go @@ -30,9 +30,11 @@ type CCharArray struct { array C.CharArray } -func make_char_array(value []byte) CCharArray { +func make_char_array( //nolint:staticcheck // TODO: should not use underscores in Go names; func make_char_array should be makeCharArray + value []byte, +) CCharArray { ptr := C.malloc(C.size_t(len(value))) - for i := 0; i < len(value); i++ { + for i := 0; i < len(value); i++ { //nolint:modernize // TODO: use range over int *(*byte)(unsafe.Add(ptr, i)) = value[i] } return CCharArray{ @@ -43,11 +45,15 @@ func make_char_array(value []byte) CCharArray { } } -func free_char_array(array CCharArray) { +func free_char_array( //nolint:staticcheck // TODO: should not use underscores in Go names; func free_char_array should be freeCharArray + array CCharArray, +) { C.free(unsafe.Pointer(array.array.data)) } -func extract_value(array CCharArray) []byte { +func extract_value( //nolint:staticcheck // TODO: should not use underscores in Go names; func extract_value should be extractValue + array CCharArray, +) []byte { res := make([]byte, int(array.array.len)) for i := 0; i < int(array.array.len); i++ { res[i] = *(*byte)(unsafe.Add(unsafe.Pointer(array.array.data), i)) @@ -57,35 +63,47 @@ func extract_value(array CCharArray) []byte { func Plonky2ServerKeyVerify( proof []byte, - recovery_code_public_key []byte, - hpke_ephemeral_public_key []byte, + recovery_code_public_key []byte, //nolint:staticcheck // TODO: should not use underscores in Go names; func parameter recovery_code_public_key should be recoveryCodePublicKey + hpke_ephemeral_public_key []byte, //nolint:staticcheck // TODO: should not use underscores in Go names; func parameter hpke_ephemeral_public_key should be hpkeEphemeralPublicKey ciphertext []byte, - plaintext_public_key []byte, + plaintext_public_key []byte, //nolint:staticcheck // TODO: should not use underscores in Go names; func parameter plaintext_public_key should be plaintextPublicKey ) []byte { - proof_char_array := make_char_array(proof) + proof_char_array := make_char_array( //nolint:staticcheck // TODO: should not use underscores in Go names; var proof_char_array should be proofCharArray + proof, + ) defer free_char_array(proof_char_array) - recovery_code_public_key_char_array := make_char_array(recovery_code_public_key) + recovery_code_public_key_char_array := make_char_array( //nolint:staticcheck // TODO: should not use underscores in Go names; var recovery_code_public_key_char_array should be recoveryCodePublicKeyCharArray + recovery_code_public_key, + ) defer free_char_array(recovery_code_public_key_char_array) - hpke_ephemeral_public_key_char_array := make_char_array(hpke_ephemeral_public_key) + hpke_ephemeral_public_key_char_array := make_char_array( //nolint:staticcheck // TODO: should not use underscores in Go names; var hpke_ephemeral_public_key_char_array should be hpkeEphemeralPublicKeyCharArray + hpke_ephemeral_public_key, + ) defer free_char_array(hpke_ephemeral_public_key_char_array) - ciphertext_char_array := make_char_array(ciphertext) + ciphertext_char_array := make_char_array( //nolint:staticcheck // TODO: should not use underscores in Go names; var ciphertext_char_array should be ciphertextCharArray + ciphertext, + ) defer free_char_array(ciphertext_char_array) - plaintext_public_key_char_array := make_char_array(plaintext_public_key) + plaintext_public_key_char_array := make_char_array( //nolint:staticcheck // TODO: should not use underscores in Go names; var plaintext_public_key_char_array should be plaintextPublicKeyCharArray + plaintext_public_key, + ) defer free_char_array(plaintext_public_key_char_array) - result_char_array := CCharArray{array: C.plonky2_server_key_verify( - proof_char_array.array, - recovery_code_public_key_char_array.array, - hpke_ephemeral_public_key_char_array.array, - ciphertext_char_array.array, - plaintext_public_key_char_array.array, - )} + result_char_array := CCharArray{ //nolint:staticcheck // TODO: should not use underscores in Go names; var result_char_array should be resultCharArray + array: C.plonky2_server_key_verify( + proof_char_array.array, + recovery_code_public_key_char_array.array, + hpke_ephemeral_public_key_char_array.array, + ciphertext_char_array.array, + plaintext_public_key_char_array.array, + ), + } defer free_char_array(result_char_array) return extract_value(result_char_array) -} \ No newline at end of file +} diff --git a/libwallet/librs/librs_test.go b/libwallet/librs/librs_test.go index 1c00df38..440e74bb 100644 --- a/libwallet/librs/librs_test.go +++ b/libwallet/librs/librs_test.go @@ -1,11 +1,10 @@ package librs import ( + _ "embed" "encoding/hex" "strings" "testing" - - _ "embed" ) //go:embed test_proof.bin @@ -18,10 +17,10 @@ func decode(s string) []byte { func TestVerifierOk(t *testing.T) { - HPKE_EPHEMERAL_PUBLIC_KEY := "0471b55503fb340ec6c202d6cdce7d49c365b78ae2fa3bab06ae87553610006553441e4f7ad3c3c834b0e0538ac241e2adc61c85a10ec7341eb1129edb0caccd0a" - RECOVERY_CODE_PUBLIC_KEY := "04dc5489ca59d23d4deebc778850651da1f3da1c505db198df8e5cf9fe322964c7c5ab62cac0b255be7d75606e04bc8015e70c39d6e0d6faaf435eb92c29043ded" - CIPHERTEXT := "23d170accd4b2849fbfa0e8e49f753eefb274c0449ab8ab46e9f35a4e2265f054d7cbab020157c34c5ba61e0e7695608" - PLAINTEXT_PUBLIC_KEY := "0468a18701d75331dddbef334c070931cf3561288e78346666fdcc01fb28aac0f17823d00b35cd06eb0508067a345027ab03a716ea825220059a168c6a6d5090db" + HPKE_EPHEMERAL_PUBLIC_KEY := "0471b55503fb340ec6c202d6cdce7d49c365b78ae2fa3bab06ae87553610006553441e4f7ad3c3c834b0e0538ac241e2adc61c85a10ec7341eb1129edb0caccd0a" //nolint:staticcheck // TODO: should not use ALL_CAPS in Go names; use CamelCase instead + RECOVERY_CODE_PUBLIC_KEY := "04dc5489ca59d23d4deebc778850651da1f3da1c505db198df8e5cf9fe322964c7c5ab62cac0b255be7d75606e04bc8015e70c39d6e0d6faaf435eb92c29043ded" //nolint:staticcheck // TODO: should not use ALL_CAPS in Go names; use CamelCase instead + CIPHERTEXT := "23d170accd4b2849fbfa0e8e49f753eefb274c0449ab8ab46e9f35a4e2265f054d7cbab020157c34c5ba61e0e7695608" //nolint:lll + PLAINTEXT_PUBLIC_KEY := "0468a18701d75331dddbef334c070931cf3561288e78346666fdcc01fb28aac0f17823d00b35cd06eb0508067a345027ab03a716ea825220059a168c6a6d5090db" //nolint:staticcheck // TODO: should not use ALL_CAPS in Go names; use CamelCase instead res := string(Plonky2ServerKeyVerify( proof, @@ -36,10 +35,10 @@ func TestVerifierOk(t *testing.T) { } func TestVerifierPanic(t *testing.T) { - HPKE_EPHEMERAL_PUBLIC_KEY := "0471b55503fb340ec6c202d6cdce7d49c365b78ae2fa3bab06ae87553610006553441e4f7ad3c3c834b0e0538ac241e2adc61c85a10ec7341eb1129edb0caccd0a" - RECOVERY_CODE_PUBLIC_KEY := "04dc5489ca59d23d4deebc778850651da1f3da1c505db198df8e5cf9fe322964c7c5ab62cac0b255be7d75606e04bc8015e70c39d6e0d6faaf435eb92c29043ded" - CIPHERTEXT := "23d170accd4b2849fbfa0e8e49f753eefb274c0449ab8ab46e9f35a4e2265f054d7cbab020157c34c5ba61e0e7695608" - PLAINTEXT_PUBLIC_KEY := "0468a18701d75331dddbef334c070931cf3561288e78346666fdcc01fb28aac0f17823d00b35cd06eb0508067a345027ab03a716ea825220059a168c6a6d5090db" + HPKE_EPHEMERAL_PUBLIC_KEY := "0471b55503fb340ec6c202d6cdce7d49c365b78ae2fa3bab06ae87553610006553441e4f7ad3c3c834b0e0538ac241e2adc61c85a10ec7341eb1129edb0caccd0a" //nolint:staticcheck // TODO: should not use ALL_CAPS in Go names; use CamelCase instead + RECOVERY_CODE_PUBLIC_KEY := "04dc5489ca59d23d4deebc778850651da1f3da1c505db198df8e5cf9fe322964c7c5ab62cac0b255be7d75606e04bc8015e70c39d6e0d6faaf435eb92c29043ded" //nolint:staticcheck // TODO: should not use ALL_CAPS in Go names; use CamelCase instead + CIPHERTEXT := "23d170accd4b2849fbfa0e8e49f753eefb274c0449ab8ab46e9f35a4e2265f054d7cbab020157c34c5ba61e0e7695608" //nolint:lll + PLAINTEXT_PUBLIC_KEY := "0468a18701d75331dddbef334c070931cf3561288e78346666fdcc01fb28aac0f17823d00b35cd06eb0508067a345027ab03a716ea825220059a168c6a6d5090db" //nolint:staticcheck // TODO: should not use ALL_CAPS in Go names; use CamelCase instead modifiedProof := append([]byte{}, proof...) modifiedProof[100] = 7 @@ -57,11 +56,11 @@ func TestVerifierPanic(t *testing.T) { } func TestVerifierError(t *testing.T) { - HPKE_EPHEMERAL_PUBLIC_KEY := "0471b55503fb340ec6c202d6cdce7d49c365b78ae2fa3bab06ae87553610006553441e4f7ad3c3c834b0e0538ac241e2adc61c85a10ec7341eb1129edb0caccd0a" + HPKE_EPHEMERAL_PUBLIC_KEY := "0471b55503fb340ec6c202d6cdce7d49c365b78ae2fa3bab06ae87553610006553441e4f7ad3c3c834b0e0538ac241e2adc61c85a10ec7341eb1129edb0caccd0a" //nolint:staticcheck // TODO: should not use ALL_CAPS in Go names; use CamelCase instead // changed the first byte in RECOVERY_CODE_PUBLIC_KEY to 0x03 so that the encoding is not valid - RECOVERY_CODE_PUBLIC_KEY := "03dc5489ca59d23d4deebc778850651da1f3da1c505db198df8e5cf9fe322964c7c5ab62cac0b255be7d75606e04bc8015e70c39d6e0d6faaf435eb92c29043ded" - CIPHERTEXT := "23d170accd4b2849fbfa0e8e49f753eefb274c0449ab8ab46e9f35a4e2265f054d7cbab020157c34c5ba61e0e7695608" - PLAINTEXT_PUBLIC_KEY := "0468a18701d75331dddbef334c070931cf3561288e78346666fdcc01fb28aac0f17823d00b35cd06eb0508067a345027ab03a716ea825220059a168c6a6d5090db" + RECOVERY_CODE_PUBLIC_KEY := "03dc5489ca59d23d4deebc778850651da1f3da1c505db198df8e5cf9fe322964c7c5ab62cac0b255be7d75606e04bc8015e70c39d6e0d6faaf435eb92c29043ded" //nolint:staticcheck // TODO: should not use ALL_CAPS in Go names; use CamelCase instead + CIPHERTEXT := "23d170accd4b2849fbfa0e8e49f753eefb274c0449ab8ab46e9f35a4e2265f054d7cbab020157c34c5ba61e0e7695608" //nolint:lll + PLAINTEXT_PUBLIC_KEY := "0468a18701d75331dddbef334c070931cf3561288e78346666fdcc01fb28aac0f17823d00b35cd06eb0508067a345027ab03a716ea825220059a168c6a6d5090db" //nolint:staticcheck // TODO: should not use ALL_CAPS in Go names; use CamelCase instead res := string(Plonky2ServerKeyVerify( proof, decode(RECOVERY_CODE_PUBLIC_KEY), diff --git a/libwallet/libwallet_init/init.go b/libwallet/libwallet_init/init.go index 467a4eac..051f581f 100644 --- a/libwallet/libwallet_init/init.go +++ b/libwallet/libwallet_init/init.go @@ -1,35 +1,42 @@ package libwallet_init import ( - "errors" "fmt" "log/slog" "net" "path" "runtime/debug" - "github.com/grpc-ecosystem/go-grpc-middleware" + "github.com/go-errors/errors" + grpc_middleware "github.com/grpc-ecosystem/go-grpc-middleware" + "google.golang.org/grpc" + + "github.com/muun/libwallet" + "github.com/muun/libwallet/app_provided_data" "github.com/muun/libwallet/data/keys" + "github.com/muun/libwallet/data/securekv" + "github.com/muun/libwallet/data/security_cards" "github.com/muun/libwallet/domain/action/challenge_keys" + debugAction "github.com/muun/libwallet/domain/action/debug" "github.com/muun/libwallet/domain/action/diagnostic_mode_reports" "github.com/muun/libwallet/domain/action/emergency_kit" nfcActions "github.com/muun/libwallet/domain/action/nfc" "github.com/muun/libwallet/domain/action/recovery" + "github.com/muun/libwallet/domain/action/reset" "github.com/muun/libwallet/domain/action/security_cards_marketplace" "github.com/muun/libwallet/domain/nfc" "github.com/muun/libwallet/electrum" "github.com/muun/libwallet/storage" + "github.com/muun/libwallet/walletdb" - "github.com/muun/libwallet" - "github.com/muun/libwallet/app_provided_data" "github.com/muun/libwallet/log" "github.com/muun/libwallet/presentation" "github.com/muun/libwallet/presentation/api" "github.com/muun/libwallet/service" - "google.golang.org/grpc" ) var server *grpc.Server +var pool *walletdb.Pool var cfg *app_provided_data.Config var keyValueStorage *storage.KeyValueStorage var network *libwallet.Network @@ -44,13 +51,19 @@ var scanForFundsAction *recovery.ScanForFundsAction var submitDiagnosticAction *diagnostic_mode_reports.SubmitDiagnosticAction var buildSweepTxAction *recovery.BuildSweepTxAction var signSweepTxAction *recovery.SignSweepTxAction -var pairSecurityCardAction *nfcActions.PairSecurityCardAction -var resetSecurityCardAction *nfcActions.ResetSecurityCardAction -var signMessageSecurityCardAction *nfcActions.SignMessageSecurityCardAction var pairSecurityCardActionV2 *nfcActions.PairSecurityCardActionV2 var signMessageSecurityCardActionV2 *nfcActions.SignMessageSecurityCardActionV2 +var pairRequestChallengeAction *nfcActions.PairRequestChallengeAction +var pairLoadPersistedChallengeAction *nfcActions.PairLoadPersistedChallengeAction +var pairSignChallengeAction *nfcActions.PairSignChallengeAction +var pairSubmitSolvedChallengeAction *nfcActions.PairSubmitSolvedChallengeAction +var pairSignAndSubmitChallengeAction *nfcActions.PairSignAndSubmitChallengeAction +var securityCardsProtocolRepository *security_cards.ProtocolRepository var securityCardsMarketplaceAction *security_cards_marketplace.GetSecurityCardsMarketplaceAction var generateEmergencyKitPDFAction *emergency_kit.GenerateEmergencyKitPDFAction +var secureKeyValueStorage securekv.SecureKeyValueStorage +var zipDataDirAction *debugAction.ZipDataDirAction +var resetDataAction reset.ResetDataAction // Init configures libwallet func Init(c *app_provided_data.Config) { @@ -70,12 +83,22 @@ func Init(c *app_provided_data.Config) { } dbPath := path.Join(cfg.DataDir, "wallet.db") - storageSchema, err := storage.RunKeyValueMigrations(dbPath, storage.BuildKVMigrationPlan()) + var storageSchema map[string]storage.Classification + var err error + pool, err = walletdb.NewPool(dbPath, func(db *walletdb.DB) error { + var migErr error + storageSchema, migErr = storage.RunKeyValueMigrations(db, storage.BuildKVMigrationPlan()) + return migErr + }) if err != nil { - slog.Error("failed to run key-value migrations", "error", err) - panic(fmt.Sprintf("failed to run key-value migrations: %v", err)) + slog.Error("failed to initialize database", "error", err) + panic(fmt.Sprintf("failed to initialize database: %v", err)) } - keyValueStorage = storage.NewKeyValueStorage(dbPath, storageSchema) + libwallet.Pool = pool + keyValueStorage = storage.NewKeyValueStorage( + pool.NewKeyValueRepository(), + storageSchema, + ) mockHoustonService = service.NewMockHoustonService(keyValueStorage) @@ -96,7 +119,6 @@ func Init(c *app_provided_data.Config) { //mockMuunCardV2, _ := nfc.NewMockMuunCardV2() //cfg.NfcBridge = nfc.NewMockJavaCard(mockMuunCardV2) - muuncard := nfc.NewCard(cfg.NfcBridge) muuncardV2 := nfc.NewCardV2(cfg.NfcBridge) // Actions computeAndStoreEncryptedMuunKeyAction = recovery.NewComputeAndStoreEncryptedMuunKeyAction( @@ -119,22 +141,45 @@ func Init(c *app_provided_data.Config) { submitDiagnosticAction = diagnostic_mode_reports.NewSubmitDiagnosticAction(houstonService) buildSweepTxAction = recovery.NewBuildSweepTxAction(keyProvider, network) signSweepTxAction = recovery.NewSignSweepTxAction(keyProvider, network) - pairSecurityCardAction = nfcActions.NewPairSecurityCardAction(keyValueStorage, muuncard) - resetSecurityCardAction = nfcActions.NewResetSecurityCardAction(keyValueStorage, muuncard) - signMessageSecurityCardAction = nfcActions.NewSignMessageSecurityCardAction( + pairSecurityCardActionV2 = nfcActions.NewPairSecurityCardActionV2( keyValueStorage, - muuncard, - network, + muuncardV2, + mockHoustonService, ) - pairSecurityCardActionV2 = nfcActions.NewPairSecurityCardActionV2(keyValueStorage, muuncardV2, mockHoustonService) signMessageSecurityCardActionV2 = nfcActions.NewSignMessageSecurityCardActionV2( muuncardV2, mockHoustonService, keyValueStorage, pairSecurityCardActionV2, ) - securityCardsMarketplaceAction = security_cards_marketplace.NewGetSecurityCardsMarketplaceAction() + securityCardsProtocolRepository = security_cards.NewProtocolRepository(keyValueStorage) + pairRequestChallengeAction = nfcActions.NewPairRequestChallengeAction( + securityCardsProtocolRepository, + mockHoustonService, + ) + pairLoadPersistedChallengeAction = nfcActions.NewPairLoadPersistedChallengeAction( + securityCardsProtocolRepository, + ) + pairSignChallengeAction = nfcActions.NewPairSignChallengeAction(muuncardV2) + pairSubmitSolvedChallengeAction = nfcActions.NewPairSubmitSolvedChallengeAction( + securityCardsProtocolRepository, + mockHoustonService, + ) + pairSignAndSubmitChallengeAction = nfcActions.NewPairSignAndSubmitChallengeAction( + pairLoadPersistedChallengeAction, + pairRequestChallengeAction, + pairSignChallengeAction, + pairSubmitSolvedChallengeAction, + ) + securityCardsMarketplaceAction = security_cards_marketplace. + NewGetSecurityCardsMarketplaceAction(mockHoustonService) generateEmergencyKitPDFAction = emergency_kit.NewGenerateEmergencyKitPDFAction() + + if cfg.SecureKeyValueStorage != nil { + secureKeyValueStorage = securekv.NewSecureKeyValueStorage(cfg.SecureKeyValueStorage) + } + zipDataDirAction = debugAction.NewZipDataDirAction(cfg.DataDir) + resetDataAction = reset.NewResetDataAction(dbPath, pool, storage.BuildKVMigrationPlan()) } func StartServer() error { @@ -149,9 +194,17 @@ func StartServer() error { grpc.UnaryInterceptor( grpc_middleware.ChainUnaryServer( // Order is important. - presentation.LoggingInterceptor(), // First interceptor - presentation.RecoverUnknownErrorInterceptor(), - presentation.RecoverPanicInterceptor(), // Last interceptor + presentation.LoggingUnaryInterceptor(), // First interceptor + presentation.RecoverUnknownErrorUnaryInterceptor(), + presentation.RecoverPanicUnaryInterceptor(), // Last interceptor + ), + ), + grpc.StreamInterceptor( + grpc_middleware.ChainStreamServer( + // Order is important. + presentation.LoggingStreamInterceptor(), // First interceptor + presentation.RecoverUnknownErrorStreamInterceptor(), + presentation.RecoverPanicStreamInterceptor(), // Last interceptor ), ), } @@ -163,6 +216,7 @@ func StartServer() error { network, houstonService, keyValueStorage, + resetDataAction, startChallengeSetupAction, finishChallengeSetupAction, populateEncryptedMuunKeyAction, @@ -170,16 +224,17 @@ func StartServer() error { submitDiagnosticAction, buildSweepTxAction, signSweepTxAction, - pairSecurityCardAction, - resetSecurityCardAction, - signMessageSecurityCardAction, pairSecurityCardActionV2, signMessageSecurityCardActionV2, + pairRequestChallengeAction, + pairSignAndSubmitChallengeAction, securityCardsMarketplaceAction, generateEmergencyKitPDFAction, + zipDataDirAction, + secureKeyValueStorage, )) - listener, err := net.Listen("unix", cfg.SocketPath) + listener, err := net.Listen("unix", cfg.SocketPath) //nolint:noctx // TODO: use (*net.ListenConfig).Listen if err != nil { slog.Error("socket creation failure", "error", err) return err @@ -200,4 +255,9 @@ func StopServer() { return } server.Stop() + if pool != nil { + pool.Close() + pool = nil + libwallet.Pool = nil + } } diff --git a/libwallet/linters/checksum_test.go b/libwallet/linters/checksum_test.go new file mode 100644 index 00000000..be15caaf --- /dev/null +++ b/libwallet/linters/checksum_test.go @@ -0,0 +1,86 @@ +package linters_test + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/muun/libwallet/linters" +) + +// TestSourceChecksum verifies that .checksum matches the current linter sources. +// A mismatch means the linter source changed and the custom golangci-lint binary must be rebuilt. +func TestSourceChecksum(t *testing.T) { + repoRoot := gitRoot(t) + + got, err := linters.ComputeSourceChecksum(repoRoot) + if err != nil { + t.Fatal("failed to compute checksum:", err) + } + + checksumFile := filepath.Join( + repoRoot, "libwallet", "linters", + "muun_model_constructor", ".checksum", + ) + stored, err := os.ReadFile(checksumFile) + if err != nil { + t.Fatalf("cannot read .checksum: %v", err) + } + want := strings.TrimSpace(string(stored)) + + if got != want { + t.Fatalf( + "linter source checksum mismatch (got %s, want %s).\n"+ + "The linter source changed. Rebuild the custom binary and update the checksum:\n"+ + " muun local build-golangci\n"+ + " # then update .checksum with the new checksum: %s", + got, want, got, + ) + } +} + +// TestCmdChecksumInSync verifies that cmd/checksum (standalone, no linters import) +// produces the same result as linters.ComputeSourceChecksum. The two must stay in +// sync — cmd/checksum intentionally avoids importing the linters package to prevent +// Go build cache poisoning (see cmd/checksum/main.go). +func TestCmdChecksumInSync(t *testing.T) { + repoRoot := gitRoot(t) + + fromLib, err := linters.ComputeSourceChecksum(repoRoot) + if err != nil { + t.Fatal("ComputeSourceChecksum:", err) + } + + cmd := exec.CommandContext( + context.Background(), + "go", "run", "./cmd/checksum", + ) + cmd.Dir = filepath.Join(repoRoot, "libwallet", "linters") + out, err := cmd.Output() + if err != nil { + t.Fatal("go run ./cmd/checksum:", err) + } + fromCmd := strings.TrimSpace(string(out)) + + if fromLib != fromCmd { + t.Fatalf( + "cmd/checksum disagrees with linters.ComputeSourceChecksum:\n"+ + " lib: %s\n cmd: %s\n"+ + "Keep the two implementations in sync.", + fromLib, fromCmd, + ) + } +} + +func gitRoot(t *testing.T) string { + t.Helper() + cmd := exec.CommandContext(context.Background(), "git", "rev-parse", "--show-toplevel") + out, err := cmd.Output() + if err != nil { + t.Fatal("cannot find git root:", err) + } + return strings.TrimSpace(string(out)) +} diff --git a/libwallet/linters/cmd/checksum/main.go b/libwallet/linters/cmd/checksum/main.go new file mode 100644 index 00000000..6fd2fbfb --- /dev/null +++ b/libwallet/linters/cmd/checksum/main.go @@ -0,0 +1,86 @@ +// Command checksum prints the SHA-256 of the linter source inputs. Used by +// the toolkit, pre-commit hook, and TestSourceChecksum to detect when the +// custom golangci-lint binary needs rebuilding. +// +// This command must NOT import the linters package. Doing so would compile the +// linters package (and its //go:embed .checksum) every time the toolkit +// computes the checksum, poisoning Go's build cache with whatever .checksum +// value exists at that moment — before the toolkit has a chance to update it. +package main + +import ( + "context" + "crypto/sha256" + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" +) + +func main() { + repoRoot := gitRoot() + checksum, err := computeSourceChecksum(repoRoot) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + fmt.Println(checksum) +} + +// computeSourceChecksum mirrors linters.ComputeSourceChecksum exactly — same +// files, same order, same hash. Keep both in sync. +func computeSourceChecksum(repoRoot string) (string, error) { + lintersDir := filepath.Join(repoRoot, "libwallet", "linters") + + var paths []string + err := filepath.Walk( + lintersDir, + func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + if info.Name() == "testdata" || info.Name() == "cmd" { + return filepath.SkipDir + } + return nil + } + isSrc := strings.HasSuffix(path, ".go") && + !strings.HasSuffix(path, "_test.go") + isMod := info.Name() == "go.mod" || info.Name() == "go.sum" + if isSrc || isMod { + paths = append(paths, path) + } + return nil + }) + if err != nil { + return "", err + } + paths = append(paths, + filepath.Join(repoRoot, ".custom-gcl.yml"), + ) + sort.Strings(paths) + + h := sha256.New() + for _, p := range paths { + data, err := os.ReadFile(p) + if err != nil { + return "", err + } + h.Write(data) + } + return fmt.Sprintf("%x", h.Sum(nil)), nil +} + +func gitRoot() string { + cmd := exec.CommandContext(context.Background(), + "git", "rev-parse", "--show-toplevel") + out, err := cmd.Output() + if err != nil { + fmt.Fprintln(os.Stderr, "cannot find git root:", err) + os.Exit(1) + } + return strings.TrimSpace(string(out)) +} diff --git a/libwallet/linters/go.mod b/libwallet/linters/go.mod new file mode 100644 index 00000000..fae2f1a3 --- /dev/null +++ b/libwallet/linters/go.mod @@ -0,0 +1,15 @@ +module github.com/muun/libwallet/linters + +go 1.23.0 + +toolchain go1.24.10 + +require ( + github.com/golangci/plugin-module-register v0.1.2 + golang.org/x/tools v0.32.0 +) + +require ( + golang.org/x/mod v0.24.0 // indirect + golang.org/x/sync v0.13.0 // indirect +) diff --git a/libwallet/linters/go.sum b/libwallet/linters/go.sum new file mode 100644 index 00000000..e2328570 --- /dev/null +++ b/libwallet/linters/go.sum @@ -0,0 +1,10 @@ +github.com/golangci/plugin-module-register v0.1.2 h1:e5WM6PO6NIAEcij3B053CohVp3HIYbzSuP53UAYgOpg= +github.com/golangci/plugin-module-register v0.1.2/go.mod h1:1+QGTsKBvAIvPvoY/os+G5eoqxWn70HYDm2uvUyGuVw= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= +golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= +golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU= +golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s= diff --git a/libwallet/linters/muun_errorf_wrap/analyzer.go b/libwallet/linters/muun_errorf_wrap/analyzer.go new file mode 100644 index 00000000..45c11952 --- /dev/null +++ b/libwallet/linters/muun_errorf_wrap/analyzer.go @@ -0,0 +1,170 @@ +package muun_errorf_wrap + +import ( + "go/ast" + "go/constant" + "go/types" + "strings" + + "github.com/golangci/plugin-module-register/register" + "golang.org/x/tools/go/analysis" + "golang.org/x/tools/go/analysis/passes/inspect" + "golang.org/x/tools/go/ast/inspector" + + "github.com/muun/libwallet/linters" +) + +func init() { + register.Plugin("muun_errorf_wrap", newPlugin) +} + +func newPlugin(any) (register.LinterPlugin, error) { + return &plugin{}, nil +} + +type plugin struct{} + +func (p *plugin) BuildAnalyzers() ([]*analysis.Analyzer, error) { + return []*analysis.Analyzer{Analyzer}, nil +} + +func (p *plugin) GetLoadMode() string { + return register.LoadModeTypesInfo +} + +var Analyzer = &analysis.Analyzer{ + Name: "muun_errorf_wrap", + Doc: "Checks that errors.Errorf from go-errors uses %w for error arguments.", + Requires: []*analysis.Analyzer{inspect.Analyzer}, + Run: run, +} + +const goErrorsPkg = "github.com/go-errors/errors" + +var errorIface = types.Universe.Lookup("error").Type().Underlying().(*types.Interface) + +func run(pass *analysis.Pass) (any, error) { + linters.ReportStalenessOnce(pass) + + insp := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector) + + nodeFilter := []ast.Node{(*ast.CallExpr)(nil)} + + insp.Preorder(nodeFilter, func(n ast.Node) { + call := n.(*ast.CallExpr) + checkErrorfCall(pass, call) + }) + + return nil, nil +} + +func checkErrorfCall(pass *analysis.Pass, call *ast.CallExpr) { + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok || sel.Sel.Name != "Errorf" { + return + } + + obj := pass.TypesInfo.ObjectOf(sel.Sel) + if obj == nil || obj.Pkg() == nil { + return + } + + if obj.Pkg().Path() != goErrorsPkg { + return + } + + if len(call.Args) < 2 { + return + } + + formatArg := call.Args[0] + variadicArgs := call.Args[1:] + + formatVal := pass.TypesInfo.Types[formatArg].Value + if formatVal == nil || formatVal.Kind() != constant.String { + return + } + format := constant.StringVal(formatVal) + + verbs := parseFormatVerbs(format) + + for _, v := range verbs { + if v.argIndex >= len(variadicArgs) { + break + } + + argType := pass.TypesInfo.TypeOf(variadicArgs[v.argIndex]) + if argType == nil { + continue + } + + if !types.Implements(argType, errorIface) { + continue + } + + // Only %v and %s render the error's message; other verbs (%T, %p, ...) + // are legitimate non-wrapping ways to format an error. + if v.verb == 'v' || v.verb == 's' { + pass.Reportf(call.Pos(), + "errors.Errorf call has %%%c for error argument; use %%w to wrap it", + v.verb) + } + } +} + +type formatVerb struct { + argIndex int + verb byte +} + +func parseFormatVerbs(format string) []formatVerb { + var verbs []formatVerb + argIndex := 0 + i := 0 + for i < len(format) { + if format[i] != '%' { + i++ + continue + } + i++ + if i >= len(format) { + break + } + if format[i] == '%' { + i++ + continue + } + // skip flags: #, 0, -, +, ' ' + for i < len(format) && strings.ContainsRune("#0-+ ", rune(format[i])) { + i++ + } + // skip width (* consumes an argument) + if i < len(format) && format[i] == '*' { + argIndex++ + i++ + } else { + for i < len(format) && format[i] >= '0' && format[i] <= '9' { + i++ + } + } + // skip precision + if i < len(format) && format[i] == '.' { + i++ + if i < len(format) && format[i] == '*' { + argIndex++ + i++ + } else { + for i < len(format) && format[i] >= '0' && format[i] <= '9' { + i++ + } + } + } + if i >= len(format) { + break + } + verbs = append(verbs, formatVerb{argIndex: argIndex, verb: format[i]}) + argIndex++ + i++ + } + return verbs +} diff --git a/libwallet/linters/muun_errorf_wrap/analyzer_test.go b/libwallet/linters/muun_errorf_wrap/analyzer_test.go new file mode 100644 index 00000000..53efec92 --- /dev/null +++ b/libwallet/linters/muun_errorf_wrap/analyzer_test.go @@ -0,0 +1,12 @@ +package muun_errorf_wrap + +import ( + "testing" + + "golang.org/x/tools/go/analysis/analysistest" +) + +func TestAnalyzer(t *testing.T) { + testdata := analysistest.TestData() + analysistest.Run(t, testdata, Analyzer, "errorfwrap") +} diff --git a/libwallet/linters/muun_errorf_wrap/testdata/src/errorfwrap/errorfwrap.go b/libwallet/linters/muun_errorf_wrap/testdata/src/errorfwrap/errorfwrap.go new file mode 100644 index 00000000..6d822866 --- /dev/null +++ b/libwallet/linters/muun_errorf_wrap/testdata/src/errorfwrap/errorfwrap.go @@ -0,0 +1,59 @@ +package errorfwrap + +import ( + "other" + + "github.com/go-errors/errors" +) + +func badV(err error) { + _ = errors.Errorf("failed: %v", err) // want `errors\.Errorf call has %v for error argument; use %w to wrap it` +} + +func badS(err error) { + _ = errors.Errorf("failed: %s", err) // want `errors\.Errorf call has %s for error argument; use %w to wrap it` +} + +func badMixed(name string, err error) { + _ = errors.Errorf("user %s: %v", name, err) // want `errors\.Errorf call has %v for error argument; use %w to wrap it` +} + +func badConcrete(err *concreteErr) { + _ = errors.Errorf("failed: %v", err) // want `errors\.Errorf call has %v for error argument; use %w to wrap it` +} + +func goodW(err error) { + _ = errors.Errorf("failed: %w", err) +} + +func goodMixed(name string, err error) { + _ = errors.Errorf("user %s: %w", name, err) +} + +func goodNonError(s string) { + _ = errors.Errorf("value: %v", s) +} + +func goodNoArgs() { + _ = errors.Errorf("static message") +} + +func goodSingleNonError(n int) { + _ = errors.Errorf("count: %d", n) +} + +func goodPercent(err error) { + _ = errors.Errorf("100%% failed: %w", err) +} + +func goodOtherPackage(err error) { + _ = other.Errorf("failed: %v", err) +} + +func goodType(err error) { + _ = errors.Errorf("unexpected error type %T", err) +} + +type concreteErr struct{} + +func (e *concreteErr) Error() string { return "" } diff --git a/libwallet/linters/muun_errorf_wrap/testdata/src/github.com/go-errors/errors/errors.go b/libwallet/linters/muun_errorf_wrap/testdata/src/github.com/go-errors/errors/errors.go new file mode 100644 index 00000000..d3cbe4b0 --- /dev/null +++ b/libwallet/linters/muun_errorf_wrap/testdata/src/github.com/go-errors/errors/errors.go @@ -0,0 +1,7 @@ +package errors + +type Error struct{ s string } + +func (e *Error) Error() string { return e.s } + +func Errorf(format string, a ...interface{}) *Error { return nil } diff --git a/libwallet/linters/muun_errorf_wrap/testdata/src/other/other.go b/libwallet/linters/muun_errorf_wrap/testdata/src/other/other.go new file mode 100644 index 00000000..f520a124 --- /dev/null +++ b/libwallet/linters/muun_errorf_wrap/testdata/src/other/other.go @@ -0,0 +1,3 @@ +package other + +func Errorf(format string, a ...interface{}) error { return nil } diff --git a/libwallet/linters/muun_model_constructor/.checksum b/libwallet/linters/muun_model_constructor/.checksum new file mode 100644 index 00000000..b1e88fa3 --- /dev/null +++ b/libwallet/linters/muun_model_constructor/.checksum @@ -0,0 +1 @@ +61bb7dbf92dab13087951ca40ee7df3ed50bf305df8d217b8a6932377f6aa524 diff --git a/libwallet/linters/muun_model_constructor/analyzer.go b/libwallet/linters/muun_model_constructor/analyzer.go new file mode 100644 index 00000000..b009eaad --- /dev/null +++ b/libwallet/linters/muun_model_constructor/analyzer.go @@ -0,0 +1,286 @@ +package muun_model_constructor + +import ( + "go/ast" + "go/token" + "go/types" + "strings" + "unicode" + + "github.com/golangci/plugin-module-register/register" + "golang.org/x/tools/go/analysis" + "golang.org/x/tools/go/analysis/passes/inspect" + "golang.org/x/tools/go/ast/inspector" + + "github.com/muun/libwallet/linters" +) + +func init() { + register.Plugin("muun_model_constructor", newPlugin) +} + +func newPlugin(any) (register.LinterPlugin, error) { + return &plugin{}, nil +} + +type plugin struct{} + +func (p *plugin) BuildAnalyzers() ([]*analysis.Analyzer, error) { + return []*analysis.Analyzer{Analyzer}, nil +} + +func (p *plugin) GetLoadMode() string { + return register.LoadModeTypesInfo +} + +var Analyzer = newAnalyzer("/domain/model/") + +// constructorPanicsFact is exported for model types whose canonical constructor contains a panic. +type constructorPanicsFact struct{} + +func (*constructorPanicsFact) AFact() {} +func (*constructorPanicsFact) String() string { return "constructor panics" } + +func newAnalyzer(packagePattern string) *analysis.Analyzer { + a := &analysis.Analyzer{ + Name: "muun_model_constructor", + Doc: "Checks that structs from model packages use constructors.", + Requires: []*analysis.Analyzer{inspect.Analyzer}, + FactTypes: []analysis.Fact{(*constructorPanicsFact)(nil)}, + } + a.Run = func(pass *analysis.Pass) (any, error) { + return run(pass, packagePattern) + } + return a +} + +func run(pass *analysis.Pass, packagePattern string) (any, error) { + linters.ReportStalenessOnce(pass) + + if strings.Contains(pass.Pkg.Path(), packagePattern) { + exportConstructorFacts(pass) + } + + insp := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector) + + nodeFilter := []ast.Node{ + (*ast.CompositeLit)(nil), + (*ast.GenDecl)(nil), + (*ast.CallExpr)(nil), + (*ast.FuncDecl)(nil), + } + + insp.WithStack(nodeFilter, func(n ast.Node, push bool, stack []ast.Node) bool { + if !push { + return true + } + switch node := n.(type) { + case *ast.CompositeLit: + checkCompositeLit(pass, node, stack, packagePattern) + case *ast.GenDecl: + checkVarDecl(pass, node, stack, packagePattern) + } + return true + }) + + return nil, nil +} + +// exportConstructorFacts marks model types whose canonical constructor panics, so that +// cross-package json.Unmarshal checks can detect when UnmarshalJSON is required. +func exportConstructorFacts(pass *analysis.Pass) { + for _, name := range pass.Pkg.Scope().Names() { + obj, ok := pass.Pkg.Scope().Lookup(name).(*types.TypeName) + if !ok { + continue + } + if _, ok := obj.Type().(*types.Named); !ok { + continue + } + if _, ok := obj.Type().Underlying().(*types.Struct); !ok { + continue + } + ctorName := canonicalConstructorName(obj.Name()) + if funcDeclPanics(pass, ctorName) { + pass.ExportObjectFact(obj, &constructorPanicsFact{}) + } + } +} + +func funcDeclPanics(pass *analysis.Pass, funcName string) bool { + for _, file := range pass.Files { + for _, decl := range file.Decls { + fd, ok := decl.(*ast.FuncDecl) + if !ok || fd.Name.Name != funcName || fd.Recv != nil { + continue + } + return containsPanic(pass, fd.Body) + } + } + return false +} + +// containsPanic returns true if the AST contains a direct panic() call or a call to any +// function in the preconditions package (which unconditionally panic on failure). +func containsPanic(pass *analysis.Pass, node ast.Node) bool { + if node == nil { + return false + } + found := false + ast.Inspect(node, func(n ast.Node) bool { + if found { + return false + } + call, ok := n.(*ast.CallExpr) + if !ok { + return true + } + if ident, ok := call.Fun.(*ast.Ident); ok && ident.Name == "panic" { + found = true + } + if sel, ok := call.Fun.(*ast.SelectorExpr); ok { + obj := pass.TypesInfo.ObjectOf(sel.Sel) + if obj != nil && obj.Pkg() != nil && + obj.Pkg().Name() == "preconditions" { + found = true + } + } + return !found + }) + return found +} + +func checkCompositeLit( + pass *analysis.Pass, + cl *ast.CompositeLit, + stack []ast.Node, + packagePattern string, +) { + typ := pass.TypesInfo.TypeOf(cl) + if typ == nil { + return + } + + named := namedType(typ) + if named == nil { + return + } + + obj, pkgName := modelStructObj(named, packagePattern) + if obj == nil { + return + } + + structName := obj.Name() + if isConstructor(enclosingFuncName(stack), structName) { + return + } + + pass.Reportf(cl.Pos(), "use %s instead of struct literal for %s.%s", + constructorHint(obj), pkgName, structName) +} + +func checkVarDecl( + pass *analysis.Pass, + gd *ast.GenDecl, + stack []ast.Node, + packagePattern string, +) { + if gd.Tok != token.VAR { + return + } + + for _, spec := range gd.Specs { + vs, ok := spec.(*ast.ValueSpec) + if !ok || vs.Type == nil { + continue + } + + if vs.Values != nil { + continue + } + + typ := pass.TypesInfo.TypeOf(vs.Type) + if typ == nil { + continue + } + + if _, isPtr := typ.(*types.Pointer); isPtr { + continue + } + + named := namedType(typ) + if named == nil { + continue + } + + obj, pkgName := modelStructObj(named, packagePattern) + if obj == nil { + continue + } + + structName := obj.Name() + if isConstructor(enclosingFuncName(stack), structName) { + continue + } + + pass.Reportf(vs.Pos(), + "use %s instead of zero-value var for %s.%s", + constructorHint(obj), pkgName, structName) + } +} + +func modelStructObj(named *types.Named, packagePattern string) (*types.TypeName, string) { + if _, ok := named.Underlying().(*types.Struct); !ok { + return nil, "" + } + obj := named.Obj() + if obj.Pkg() == nil { + return nil, "" + } + if !strings.Contains(obj.Pkg().Path(), packagePattern) { + return nil, "" + } + if obj.Parent() != obj.Pkg().Scope() { + return nil, "" + } + return obj, obj.Pkg().Name() +} + +func canonicalConstructorName(structName string) string { + if ast.IsExported(structName) { + return "New" + structName + } + runes := []rune(structName) + runes[0] = unicode.ToUpper(runes[0]) + return "new" + string(runes) +} + +func constructorHint(obj *types.TypeName) string { + name := canonicalConstructorName(obj.Name()) + if obj.Pkg().Scope().Lookup(name) != nil { + return name + } + return "a constructor like " + name +} + +func isConstructor(funcName, structName string) bool { + return funcName == canonicalConstructorName(structName) +} + +func enclosingFuncName(stack []ast.Node) string { + for i := len(stack) - 1; i >= 0; i-- { + if fd, ok := stack[i].(*ast.FuncDecl); ok { + return fd.Name.Name + } + } + return "" +} + +func namedType(typ types.Type) *types.Named { + if ptr, ok := typ.(*types.Pointer); ok { + typ = ptr.Elem() + } + named, _ := typ.(*types.Named) + return named +} diff --git a/libwallet/linters/muun_model_constructor/analyzer_test.go b/libwallet/linters/muun_model_constructor/analyzer_test.go new file mode 100644 index 00000000..5e06741a --- /dev/null +++ b/libwallet/linters/muun_model_constructor/analyzer_test.go @@ -0,0 +1,16 @@ +package muun_model_constructor + +import ( + "testing" + + "golang.org/x/tools/go/analysis/analysistest" +) + +// TestAnalyzer runs the linter against the testdata packages using analysistest. Each testdata +// source file contains "// want" comments that declare the diagnostics the linter must produce. +// See https://pkg.go.dev/golang.org/x/tools/go/analysis/analysistest for the framework. +func TestAnalyzer(t *testing.T) { + testdata := analysistest.TestData() + analyzer := newAnalyzer("model") + analysistest.Run(t, testdata, analyzer, "model", "consumer") +} diff --git a/libwallet/linters/muun_model_constructor/testdata/src/consumer/consumer.go b/libwallet/linters/muun_model_constructor/testdata/src/consumer/consumer.go new file mode 100644 index 00000000..2454408c --- /dev/null +++ b/libwallet/linters/muun_model_constructor/testdata/src/consumer/consumer.go @@ -0,0 +1,84 @@ +package consumer + +import ( + "bytes" + "encoding/json" + "model" + "unrelated" +) + +func badPointer() *model.User { + return &model.User{ // want `use NewUser instead of struct literal for model\.User` + Name: "alice", + Email: "alice@example.com", + } +} + +func badValue() model.User { + return model.User{ // want `use NewUser instead of struct literal for model\.User` + Name: "bob", + Email: "bob@example.com", + } +} + +func badNoConstructor() *model.Config { + return &model.Config{ // want `use a constructor like NewConfig instead of struct literal for model\.Config` + Key: "k", + Value: "v", + } +} + +func goodConstructor() *model.User { + return model.NewUser("alice", "alice@example.com") +} + +func goodUnrelated() *unrelated.Other { + return &unrelated.Other{X: 1} +} + +func badZeroValueVar() model.User { + var u model.User // want `use NewUser instead of zero-value var for model\.User` + return u +} + +func goodZeroValueVarUnrelated() unrelated.Other { + var o unrelated.Other + return o +} + +func goodVarWithInit() model.User { + var u model.User = *model.NewUser("alice", "alice@example.com") + return u +} + +func goodPointerVar() *model.User { + var u *model.User + return u +} + +// json.Unmarshal on type whose constructor doesn't panic — allowed. +func goodJSONUnmarshalNoPanic(data []byte) (*model.User, error) { + var u model.User // want `use NewUser instead of zero-value var for model\.User` + err := json.Unmarshal(data, &u) + return &u, err +} + +// json.Unmarshal on type with Unmarshaler — allowed. +func goodJSONUnmarshalWithUnmarshaler(data []byte) (*model.ValidatedModel, error) { + var v model.ValidatedModel // want `use NewValidatedModel instead of zero-value var for model\.ValidatedModel` + err := json.Unmarshal(data, &v) + return &v, err +} + +func goodJSONUnmarshalUnrelated(data []byte) (*unrelated.Other, error) { + var o unrelated.Other + err := json.Unmarshal(data, &o) + return &o, err +} + +// json.NewDecoder().Decode() on type with Unmarshaler — allowed. +func goodJSONDecoderDecodeWithUnmarshaler(data []byte) (*model.ValidatedModel, error) { + var v model.ValidatedModel // want `use NewValidatedModel instead of zero-value var for model\.ValidatedModel` + err := json.NewDecoder(bytes.NewReader(data)).Decode(&v) + return &v, err +} diff --git a/libwallet/linters/muun_model_constructor/testdata/src/model/model.go b/libwallet/linters/muun_model_constructor/testdata/src/model/model.go new file mode 100644 index 00000000..adc0ac67 --- /dev/null +++ b/libwallet/linters/muun_model_constructor/testdata/src/model/model.go @@ -0,0 +1,109 @@ +package model + +import ( + "encoding/json" + "preconditions" +) + +type Address struct { + Street string + City string +} + +func NewAddress(street, city string) *Address { + return &Address{ + Street: street, + City: city, + } +} + +type User struct { + Name string + Email string + Address Address +} + +func NewUser(name, email string) *User { + return &User{ + Name: name, + Email: email, + Address: Address{ // want `use NewAddress instead of struct literal for model\.Address` + Street: "default", + City: "default", + }, + } +} + +type Config struct { + Key string + Value string +} + +func helperFunc() *Address { + return &Address{ // want `use NewAddress instead of struct literal for model\.Address` + Street: "x", + City: "y", + } +} + +type internalItem struct { + Value string +} + +func newInternalItem(value string) *internalItem { + return &internalItem{Value: value} +} + +func badInternalItem() *internalItem { + return &internalItem{ // want `use newInternalItem instead of struct literal for model\.internalItem` + Value: "x", + } +} + +func badZeroValueVar() Address { + var a Address // want `use NewAddress instead of zero-value var for model\.Address` + return a +} + +// ValidatedModel has a panicking constructor and a proper UnmarshalJSON. +type ValidatedModel struct { // want ValidatedModel:"constructor panics" + Value string +} + +func NewValidatedModel(value string) *ValidatedModel { + if value == "" { + panic("value required") + } + return &ValidatedModel{Value: value} +} + +func (m *ValidatedModel) UnmarshalJSON(data []byte) error { + var raw struct{ Value string } + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + *m = *NewValidatedModel(raw.Value) + return nil +} + +// UnprotectedModel has a panicking constructor but no UnmarshalJSON. +type UnprotectedModel struct { // want UnprotectedModel:"constructor panics" + Score int +} + +func NewUnprotectedModel(score int) *UnprotectedModel { + if score < 0 { + panic("score must be non-negative") + } + return &UnprotectedModel{Score: score} +} + +// PreconditionModel validates via preconditions.CheckState, not direct panic. +type PreconditionModel struct { // want PreconditionModel:"constructor panics" + Name string +} + +func NewPreconditionModel(name string) *PreconditionModel { + preconditions.CheckState(name != "") + return &PreconditionModel{Name: name} +} diff --git a/libwallet/linters/muun_model_constructor/testdata/src/preconditions/preconditions.go b/libwallet/linters/muun_model_constructor/testdata/src/preconditions/preconditions.go new file mode 100644 index 00000000..86c39eb0 --- /dev/null +++ b/libwallet/linters/muun_model_constructor/testdata/src/preconditions/preconditions.go @@ -0,0 +1,7 @@ +package preconditions + +func CheckState(expr bool) { + if !expr { + panic("precondition failed") + } +} diff --git a/libwallet/linters/muun_model_constructor/testdata/src/unrelated/unrelated.go b/libwallet/linters/muun_model_constructor/testdata/src/unrelated/unrelated.go new file mode 100644 index 00000000..1ef2328c --- /dev/null +++ b/libwallet/linters/muun_model_constructor/testdata/src/unrelated/unrelated.go @@ -0,0 +1,7 @@ +// Package unrelated provides a type outside of any model package. Struct literals for these types +// should never be flagged by the linter. +package unrelated + +type Other struct { + X int +} diff --git a/libwallet/linters/staleness.go b/libwallet/linters/staleness.go new file mode 100644 index 00000000..a1359407 --- /dev/null +++ b/libwallet/linters/staleness.go @@ -0,0 +1,127 @@ +package linters + +import ( + "context" + "crypto/sha256" + _ "embed" + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "sync" + + "golang.org/x/tools/go/analysis" +) + +//go:embed muun_model_constructor/.checksum +var checksum string + +var ( + stalenessOnce sync.Once + stalenessError string + reportedStale bool + reportedMu sync.Mutex +) + +// ReportStalenessOnce emits the staleness diagnostic at most once across all +// custom analyzers, so a stale binary produces a single message instead of +// one per analyzer. +func ReportStalenessOnce(pass *analysis.Pass) { + stalenessOnce.Do(func() { + stalenessError = VerifyStaleness() + }) + if stalenessError == "" || len(pass.Files) == 0 { + return + } + reportedMu.Lock() + shouldReport := !reportedStale + reportedStale = true + reportedMu.Unlock() + if shouldReport { + pass.Reportf(pass.Files[0].Package, "%s", stalenessError) + } +} + +// VerifyStaleness checks whether the embedded checksum matches the current +// linter sources. Returns an error message if stale, empty string if current +// or if the check cannot run (e.g. not inside a git repo). +func VerifyStaleness() string { + repoRoot, err := findRepoRoot() + if err != nil { + log.Printf("muun_model_constructor: skipping staleness check: %v", err) + return "" + } + current, err := ComputeSourceChecksum(repoRoot) + if err != nil { + log.Printf("muun_model_constructor: skipping staleness check: %v", err) + return "" + } + if current != strings.TrimSpace(checksum) { + return "muun-golangci-lint binary is stale — " + + "run: muun local build-golangci" + } + return "" +} + +func findRepoRoot() (string, error) { + cmd := exec.CommandContext( + context.Background(), + "git", "rev-parse", "--show-toplevel", + ) + out, err := cmd.Output() + if err != nil { + return "", err + } + return strings.TrimSpace(string(out)), nil +} + +// ComputeSourceChecksum returns the SHA-256 hex digest of the linter source +// inputs. Used by VerifyStaleness, cmd/checksum, and TestSourceChecksum. +func ComputeSourceChecksum(repoRoot string) (string, error) { + lintersDir := filepath.Join( + repoRoot, "libwallet", "linters", + ) + + var paths []string + err := filepath.Walk( + lintersDir, + // we will filter out non-go files, test data files, cmd files + func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + if info.Name() == "testdata" || info.Name() == "cmd" { + return filepath.SkipDir + } + return nil + } + isSrc := strings.HasSuffix(path, ".go") && + !strings.HasSuffix(path, "_test.go") + isMod := info.Name() == "go.mod" || info.Name() == "go.sum" + if isSrc || isMod { + paths = append(paths, path) + } + return nil + }) + if err != nil { + return "", err + } + paths = append(paths, + filepath.Join(repoRoot, ".custom-gcl.yml"), + ) + sort.Strings(paths) + + h := sha256.New() + for _, p := range paths { + data, err := os.ReadFile(p) + if err != nil { + return "", err + } + h.Write(data) + } + return fmt.Sprintf("%x", h.Sum(nil)), nil +} diff --git a/libwallet/lnurl.go b/libwallet/lnurl.go index eefbf9b2..9bfe97ba 100644 --- a/libwallet/lnurl.go +++ b/libwallet/lnurl.go @@ -1,9 +1,11 @@ package libwallet import ( + "reflect" + "github.com/lightningnetwork/lnd/lnwire" + "github.com/muun/libwallet/lnurl" - "reflect" ) type LNURLEvent struct { diff --git a/libwallet/lnurl/lnurl.go b/libwallet/lnurl/lnurl.go index a5453afa..48b30447 100644 --- a/libwallet/lnurl/lnurl.go +++ b/libwallet/lnurl/lnurl.go @@ -114,11 +114,16 @@ func Validate(qr string) bool { // Withdraw will parse an LNURL withdraw QR and begin a withdraw process. // Caller must wait for the actual payment after this function has notified success. -func Withdraw(qr string, createInvoiceFunc CreateInvoiceFunction, allowUnsafe bool, notify func(e *Event)) { +func Withdraw( + qr string, + createInvoiceFunc CreateInvoiceFunction, + allowUnsafe bool, + notify func(e *Event), +) { notifier := notifier{notify: notify} // decode the qr - qrUrl, err := decode(qr) + qrUrl, err := decode(qr) //nolint:staticcheck // TODO: var qrUrl should be qrURL if err != nil { notifier.Error(ErrDecode, err) return @@ -143,15 +148,17 @@ func Withdraw(qr string, createInvoiceFunc CreateInvoiceFunction, allowUnsafe bo notifier.Status(StatusContacting) // start withdraw with service - resp, err := httpClient.Get(qrUrl.String()) + resp, err := httpClient.Get( //nolint:noctx // TODO: use (*http.Client).Do with http.NewRequestWithContext + qrUrl.String(), + ) if err != nil { notifier.Error(ErrUnreachable, err) return } - defer resp.Body.Close() + defer resp.Body.Close() //nolint:errcheck // TODO: check error if code, reason := validateHttpResponse(resp); code != ErrNone { - notifier.Errorf(code, reason) + notifier.Errorf(code, "%s", reason) return } @@ -163,7 +170,7 @@ func Withdraw(qr string, createInvoiceFunc CreateInvoiceFunction, allowUnsafe bo return } if code, reason := wr.Validate(); code != ErrNone { - notifier.Errorf(code, reason) + notifier.Errorf(code, "%s", reason) return } @@ -177,7 +184,8 @@ func Withdraw(qr string, createInvoiceFunc CreateInvoiceFunction, allowUnsafe bo return } // We don't check for "callbackURL.Host == qrUrl.Host" since for withdraw it does not add - // any security. Note: for other lnurl action it will definitely be a requirement. + // any security. + // Note: for other lnurl action it will definitely be a requirement. // generate invoice amount := lnwire.MilliSatoshi(int64(wr.MaxWithdrawable)) @@ -202,10 +210,10 @@ func Withdraw(qr string, createInvoiceFunc CreateInvoiceFunction, allowUnsafe bo notifier.Errorf(ErrUnreachable, "failed to get response from callback URL: %v", err) return } - defer fresp.Body.Close() + defer fresp.Body.Close() //nolint:errcheck // TODO: check error if code, reason := validateHttpResponse(fresp); code != ErrNone { - notifier.Errorf(code, reason) + notifier.Errorf(code, "%s", reason) return } @@ -218,14 +226,16 @@ func Withdraw(qr string, createInvoiceFunc CreateInvoiceFunction, allowUnsafe bo } if code, reason := fr.Validate(); code != ErrNone { - notifier.Errorf(code, reason) + notifier.Errorf(code, "%s", reason) return } notifier.Status(StatusReceiving) } -func validateHttpResponse(resp *http.Response) (int, string) { +func validateHttpResponse( //nolint:staticcheck // TODO: func validateHttpResponse should be validateHTTPResponse + resp *http.Response, +) (int, string) { if resp.StatusCode >= 400 { // try to obtain response body @@ -239,12 +249,19 @@ func validateHttpResponse(resp *http.Response) (int, string) { } } - return code, fmt.Sprintf("unexpected status code in response: %v, body: %s", resp.StatusCode, string(bytesBody)) + return code, fmt.Sprintf( + "unexpected status code in response: %v, body: %s", + resp.StatusCode, + string(bytesBody), + ) } } if resp.StatusCode >= 300 { - return ErrInvalidResponse, fmt.Sprintf("unexpected status code in response: %v", resp.StatusCode) + return ErrInvalidResponse, fmt.Sprintf( + "unexpected status code in response: %v", + resp.StatusCode, + ) } return ErrNone, "" @@ -377,7 +394,11 @@ func (n *notifier) Error(status int, err error) { n.notify(&Event{Code: status, Message: err.Error(), Metadata: n.metadata}) } -func (n *notifier) Errorf(status int, format string, a ...interface{}) { +func (n *notifier) Errorf( + status int, + format string, + a ...interface{}, //nolint:modernize // TODO: use any instead of interface{} +) { msg := fmt.Sprintf(format, a...) n.notify(&Event{Code: status, Message: msg, Metadata: n.metadata}) } diff --git a/libwallet/lnurl/lnurl_test.go b/libwallet/lnurl/lnurl_test.go index 0563fe25..663366b7 100644 --- a/libwallet/lnurl/lnurl_test.go +++ b/libwallet/lnurl/lnurl_test.go @@ -16,22 +16,24 @@ import ( type FakeWithdrawClient struct { WithdrawClient WithdrawClient - ServerUrl string + ServerURL string } func (fwc *FakeWithdrawClient) Get(urlString string) (resp *http.Response, err error) { - URL := parseUrl(urlString) + URL := parseURL(urlString) if URL.Host == "test.com" { - host := parseUrl(fwc.ServerUrl).Host + host := parseURL(fwc.ServerURL).Host return fwc.WithdrawClient.Get(strings.Replace(urlString, "test.com", host, 1)) } else { return fwc.WithdrawClient.Get(urlString) } } -func parseUrl(urlString string) *url.URL { +func parseURL( + urlString string, +) *url.URL { URL, err := url.Parse(urlString) if err != nil { @@ -54,11 +56,14 @@ func TestWithdraw(t *testing.T) { Tag: "withdrawRequest", }) }) - mux.HandleFunc("/withdraw/complete", func(w http.ResponseWriter, r *http.Request) { - json.NewEncoder(w).Encode(&Response{ - Status: StatusOK, - }) - }) + mux.HandleFunc( + "/withdraw/complete", + func(w http.ResponseWriter, r *http.Request) { //nolint:revive // TODO: use or remove r + json.NewEncoder(w).Encode(&Response{ + Status: StatusOK, + }) + }, + ) server := httptest.NewServer(mux) t.Cleanup(func() { server.Close() }) @@ -100,11 +105,14 @@ func TestWithdrawWithCompatibilityTag(t *testing.T) { Tag: "withdraw", }) }) - mux.HandleFunc("/withdraw/complete", func(w http.ResponseWriter, r *http.Request) { - json.NewEncoder(w).Encode(&Response{ - Status: StatusOK, - }) - }) + mux.HandleFunc( + "/withdraw/complete", + func(w http.ResponseWriter, r *http.Request) { //nolint:revive // TODO: use or remove r + json.NewEncoder(w).Encode(&Response{ + Status: StatusOK, + }) + }, + ) server := httptest.NewServer(mux) t.Cleanup(func() { server.Close() }) @@ -137,21 +145,28 @@ func TestWithdrawWithCompatibilityTag(t *testing.T) { //goland:noinspection GoUnhandledErrorResult,HttpUrlsUsage func TestWithdrawWithDifferentDomainHosts(t *testing.T) { mux := http.NewServeMux() - mux.HandleFunc("/withdraw/", func(w http.ResponseWriter, r *http.Request) { - json.NewEncoder(w).Encode(&WithdrawResponse{ - K1: "foobar", - Callback: "http://test.com/withdraw/complete", // Callback url has a different host than localhost - MaxWithdrawable: 1_000_000, - DefaultDescription: "Withdraw from Lapp", - Tag: "withdraw", - }) - }) - - mux.HandleFunc("/withdraw/complete", func(w http.ResponseWriter, r *http.Request) { - json.NewEncoder(w).Encode(&Response{ - Status: StatusOK, - }) - }) + mux.HandleFunc( + "/withdraw/", + func(w http.ResponseWriter, r *http.Request) { //nolint:revive // TODO: use or remove r + json.NewEncoder(w).Encode(&WithdrawResponse{ + K1: "foobar", + // Callback url has a different host than localhost + Callback: "http://test.com/withdraw/complete", + MaxWithdrawable: 1_000_000, + DefaultDescription: "Withdraw from Lapp", + Tag: "withdraw", + }) + }, + ) + + mux.HandleFunc( + "/withdraw/complete", + func(w http.ResponseWriter, r *http.Request) { //nolint:revive // TODO: use or remove r + json.NewEncoder(w).Encode(&Response{ + Status: StatusOK, + }) + }, + ) server := httptest.NewServer(mux) t.Cleanup(func() { server.Close() }) @@ -170,7 +185,8 @@ func TestWithdrawWithDifferentDomainHosts(t *testing.T) { return "12345", nil } - // We "inject" FakeWithdrawClient to simulate test.com responds correctly (by redirecting to localhost) + // We "inject" FakeWithdrawClient to simulate test.com responds correctly (by redirecting to + // localhost) originalClient := withdrawClient withdrawClient = &FakeWithdrawClient{originalClient, server.URL} t.Cleanup(func() { @@ -192,7 +208,7 @@ func TestWithdrawWithDifferentDomainHosts(t *testing.T) { func TestDecodeError(t *testing.T) { qr := "lightning:abcde" - createInvoiceFunc := func(amt lnwire.MilliSatoshi, desc string, host string) (string, error) { + createInvoiceFunc := func(amt lnwire.MilliSatoshi, desc string, host string) (string, error) { //nolint:revive // TODO: use or remove amt panic("should not reach here") } @@ -209,17 +225,20 @@ func TestDecodeError(t *testing.T) { //goland:noinspection GoUnhandledErrorResult func TestWrongTagError(t *testing.T) { mux := http.NewServeMux() - mux.HandleFunc("/channelRequest", func(w http.ResponseWriter, r *http.Request) { - json.NewEncoder(w).Encode(&WithdrawResponse{ - Tag: "channelRequest", - }) - }) + mux.HandleFunc( + "/channelRequest", + func(w http.ResponseWriter, r *http.Request) { //nolint:revive // TODO: use or remove r + json.NewEncoder(w).Encode(&WithdrawResponse{ + Tag: "channelRequest", + }) + }, + ) server := httptest.NewServer(mux) t.Cleanup(func() { server.Close() }) qr, _ := encode(fmt.Sprintf("%s/channelRequest", server.URL)) - createInvoiceFunc := func(amt lnwire.MilliSatoshi, desc string, host string) (string, error) { + createInvoiceFunc := func(amt lnwire.MilliSatoshi, desc string, host string) (string, error) { //nolint:revive // TODO: use or remove amt panic("should not reach here") } @@ -242,9 +261,9 @@ func TestUnreachableError(t *testing.T) { }() // LNURL QR pointing to a non-responding domain - qr := "LIGHTNING:LNURL1DP68GURN8GHJ7ARGD9EJUER0D4SKJM3WV3HK2UEWDEHHGTN90P5HXAPWV4UXZMTSD3JJUCM0D5LHXETRWFJHG0F3XGENGDGQ8EH52" + qr := "LIGHTNING:LNURL1DP68GURN8GHJ7ARGD9EJUER0D4SKJM3WV3HK2UEWDEHHGTN90P5HXAPWV4UXZMTSD3JJUCM0D5LHXETRWFJHG0F3XGENGDGQ8EH52" //nolint:lll - createInvoiceFunc := func(amt lnwire.MilliSatoshi, desc string, host string) (string, error) { + createInvoiceFunc := func(amt lnwire.MilliSatoshi, desc string, host string) (string, error) { //nolint:revive // TODO: use or remove amt panic("should not reach here") } @@ -271,12 +290,15 @@ func TestServiceError(t *testing.T) { Tag: "withdrawRequest", }) }) - mux.HandleFunc("/withdraw/complete", func(w http.ResponseWriter, r *http.Request) { - json.NewEncoder(w).Encode(&Response{ - Status: StatusError, - Reason: "something something", - }) - }) + mux.HandleFunc( + "/withdraw/complete", + func(w http.ResponseWriter, r *http.Request) { //nolint:revive // TODO: use or remove r + json.NewEncoder(w).Encode(&Response{ + Status: StatusError, + Reason: "something something", + }) + }, + ) server := httptest.NewServer(mux) t.Cleanup(func() { server.Close() }) @@ -308,15 +330,18 @@ func TestServiceError(t *testing.T) { //goland:noinspection GoUnhandledErrorResult func TestInvalidResponseError(t *testing.T) { mux := http.NewServeMux() - mux.HandleFunc("/withdraw/", func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte("foobar")) - }) + mux.HandleFunc( + "/withdraw/", + func(w http.ResponseWriter, r *http.Request) { //nolint:revive // TODO: use or remove r + w.Write([]byte("foobar")) + }, + ) server := httptest.NewServer(mux) t.Cleanup(func() { server.Close() }) qr, _ := encode(fmt.Sprintf("%s/withdraw", server.URL)) - createInvoiceFunc := func(amt lnwire.MilliSatoshi, desc string, host string) (string, error) { + createInvoiceFunc := func(amt lnwire.MilliSatoshi, desc string, host string) (string, error) { //nolint:revive // TODO: use or remove amt panic("should not reach here") } @@ -333,7 +358,7 @@ func TestInvalidResponseError(t *testing.T) { func TestUnsafeURLError(t *testing.T) { qr, _ := encode("http://localhost/withdraw") - createInvoiceFunc := func(amt lnwire.MilliSatoshi, desc string, host string) (string, error) { + createInvoiceFunc := func(amt lnwire.MilliSatoshi, desc string, host string) (string, error) { //nolint:revive // TODO: use or remove amt panic("should not reach here") } @@ -346,9 +371,9 @@ func TestUnsafeURLError(t *testing.T) { func TestWrongTagInQR(t *testing.T) { // LNURL QR with a `login` tag value in its query params - qr := "lightning:lnurl1dp68gurn8ghj7mrww4exctt5dahkccn00qhxget8wfjk2um0veax2un09e3k7mf0w5lhgct884kx7emfdcnxkvfa8qexxc35vymnxcf5xumkxvfsv4snxwph8qunzv3hxesnyv3jvv6nyv3e8yuxzvnpv4skvepnxg6rwv34xqck2c3sxcerzdpnv56r2dss2vt96" + qr := "lightning:lnurl1dp68gurn8ghj7mrww4exctt5dahkccn00qhxget8wfjk2um0veax2un09e3k7mf0w5lhgct884kx7emfdcnxkvfa8qexxc35vymnxcf5xumkxvfsv4snxwph8qunzv3hxesnyv3jvv6nyv3e8yuxzvnpv4skvepnxg6rwv34xqck2c3sxcerzdpnv56r2dss2vt96" //nolint:lll - createInvoiceFunc := func(amt lnwire.MilliSatoshi, desc string, host string) (string, error) { + createInvoiceFunc := func(amt lnwire.MilliSatoshi, desc string, host string) (string, error) { //nolint:revive // TODO: use or remove amt panic("should not reach here") } @@ -365,7 +390,7 @@ func TestWrongTagInQR(t *testing.T) { func TestOnionLinkNotSupported(t *testing.T) { qr := "LNURL1DP68GUP69UHKVMM0VFSHYTN0DE5K7MSHXU8YD" - createInvoiceFunc := func(amt lnwire.MilliSatoshi, desc string, host string) (string, error) { + createInvoiceFunc := func(amt lnwire.MilliSatoshi, desc string, host string) (string, error) { //nolint:revive // TODO: use or remove amt panic("should not reach here") } @@ -382,23 +407,29 @@ func TestOnionLinkNotSupported(t *testing.T) { //goland:noinspection GoUnhandledErrorResult func TestExpiredCheck(t *testing.T) { mux := http.NewServeMux() - mux.HandleFunc("/withdraw/", func(w http.ResponseWriter, r *http.Request) { - json.NewEncoder(w).Encode(&Response{ - Status: "ERROR", - Reason: "something something Expired blabla", - }) - }) - mux.HandleFunc("/withdraw/complete", func(w http.ResponseWriter, r *http.Request) { - json.NewEncoder(w).Encode(&Response{ - Status: StatusOK, - }) - }) + mux.HandleFunc( + "/withdraw/", + func(w http.ResponseWriter, r *http.Request) { //nolint:revive // TODO: use or remove r + json.NewEncoder(w).Encode(&Response{ + Status: "ERROR", + Reason: "something something Expired blabla", + }) + }, + ) + mux.HandleFunc( + "/withdraw/complete", + func(w http.ResponseWriter, r *http.Request) { //nolint:revive // TODO: use or remove r + json.NewEncoder(w).Encode(&Response{ + Status: StatusOK, + }) + }, + ) server := httptest.NewServer(mux) t.Cleanup(func() { server.Close() }) qr, _ := encode(fmt.Sprintf("%s/withdraw", server.URL)) - createInvoiceFunc := func(amt lnwire.MilliSatoshi, desc string, host string) (string, error) { + createInvoiceFunc := func(amt lnwire.MilliSatoshi, desc string, host string) (string, error) { //nolint:revive // TODO: use or remove amt panic("should not reach here") } @@ -430,7 +461,7 @@ func TestNoAvailableBalance(t *testing.T) { qr, _ := encode(fmt.Sprintf("%s/withdraw", server.URL)) - createInvoiceFunc := func(amt lnwire.MilliSatoshi, desc string, host string) (string, error) { + createInvoiceFunc := func(amt lnwire.MilliSatoshi, desc string, host string) (string, error) { //nolint:revive // TODO: use or remove amt panic("should not reach here") } @@ -456,18 +487,21 @@ func TestNoRouteCheck(t *testing.T) { Tag: "withdrawRequest", }) }) - mux.HandleFunc("/withdraw/complete", func(w http.ResponseWriter, r *http.Request) { - json.NewEncoder(w).Encode(&Response{ - Status: StatusError, - Reason: "Unable to pay LN Invoice: FAILURE_REASON_NO_ROUTE", - }) - }) + mux.HandleFunc( + "/withdraw/complete", + func(w http.ResponseWriter, r *http.Request) { //nolint:revive // TODO: use or remove r + json.NewEncoder(w).Encode(&Response{ + Status: StatusError, + Reason: "Unable to pay LN Invoice: FAILURE_REASON_NO_ROUTE", + }) + }, + ) server := httptest.NewServer(mux) t.Cleanup(func() { server.Close() }) qr, _ := encode(fmt.Sprintf("%s/withdraw", server.URL)) - createInvoiceFunc := func(amt lnwire.MilliSatoshi, desc string, host string) (string, error) { + createInvoiceFunc := func(amt lnwire.MilliSatoshi, desc string, host string) (string, error) { //nolint:revive // TODO: use or remove amt return "12345", nil } @@ -548,11 +582,14 @@ func TestStringlyTypedNumberFields(t *testing.T) { Tag: "withdrawRequest", }) }) - mux.HandleFunc("/withdraw/complete", func(w http.ResponseWriter, r *http.Request) { - json.NewEncoder(w).Encode(&Response{ - Status: StatusOK, - }) - }) + mux.HandleFunc( + "/withdraw/complete", + func(w http.ResponseWriter, r *http.Request) { //nolint:revive // TODO: use or remove r + json.NewEncoder(w).Encode(&Response{ + Status: StatusOK, + }) + }, + ) server := httptest.NewServer(mux) t.Cleanup(func() { server.Close() }) @@ -585,16 +622,19 @@ func TestStringlyTypedNumberFields(t *testing.T) { //goland:noinspection GoUnhandledErrorResult func TestErrorContainsResponseBody(t *testing.T) { mux := http.NewServeMux() - mux.HandleFunc("/withdraw/", func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(400) - w.Write([]byte("this is a custom error response")) - }) + mux.HandleFunc( + "/withdraw/", + func(w http.ResponseWriter, r *http.Request) { //nolint:revive // TODO: use or remove r + w.WriteHeader(400) + w.Write([]byte("this is a custom error response")) + }, + ) server := httptest.NewServer(mux) t.Cleanup(func() { server.Close() }) qr, _ := encode(fmt.Sprintf("%s/withdraw", server.URL)) - createInvoiceFunc := func(amt lnwire.MilliSatoshi, desc string, host string) (string, error) { + createInvoiceFunc := func(amt lnwire.MilliSatoshi, desc string, host string) (string, error) { //nolint:revive // TODO: use or remove amt panic("should not reach here") } @@ -625,16 +665,19 @@ func TestErrorContainsResponseBodyForFinishRequest(t *testing.T) { Tag: "withdrawRequest", }) }) - mux.HandleFunc("/withdraw/complete", func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(400) - w.Write([]byte("this is a custom error response")) - }) + mux.HandleFunc( + "/withdraw/complete", + func(w http.ResponseWriter, r *http.Request) { //nolint:revive // TODO: use or remove r + w.WriteHeader(400) + w.Write([]byte("this is a custom error response")) + }, + ) server := httptest.NewServer(mux) t.Cleanup(func() { server.Close() }) qr, _ := encode(fmt.Sprintf("%s/withdraw", server.URL)) - createInvoiceFunc := func(amt lnwire.MilliSatoshi, desc string, host string) (string, error) { + createInvoiceFunc := func(amt lnwire.MilliSatoshi, desc string, host string) (string, error) { //nolint:revive // TODO: use or remove amt return "12345", nil } @@ -656,16 +699,19 @@ func TestErrorContainsResponseBodyForFinishRequest(t *testing.T) { //goland:noinspection GoUnhandledErrorResult func TestForbidden(t *testing.T) { mux := http.NewServeMux() - mux.HandleFunc("/withdraw/", func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(403) - w.Write([]byte("Forbidden")) - }) + mux.HandleFunc( + "/withdraw/", + func(w http.ResponseWriter, r *http.Request) { //nolint:revive // TODO: use or remove r + w.WriteHeader(403) + w.Write([]byte("Forbidden")) + }, + ) server := httptest.NewServer(mux) t.Cleanup(func() { server.Close() }) qr, _ := encode(fmt.Sprintf("%s/withdraw", server.URL)) - createInvoiceFunc := func(amt lnwire.MilliSatoshi, desc string, host string) (string, error) { + createInvoiceFunc := func(amt lnwire.MilliSatoshi, desc string, host string) (string, error) { //nolint:revive // TODO: use or remove amt panic("should not reach here") } @@ -684,10 +730,13 @@ func TestForbidden(t *testing.T) { //goland:noinspection GoUnhandledErrorResult func TestZebedee403MapsToCountryNotSupported(t *testing.T) { mux := http.NewServeMux() - mux.HandleFunc("/withdraw/", func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(403) - w.Write([]byte("Forbidden")) - }) + mux.HandleFunc( + "/withdraw/", + func(w http.ResponseWriter, r *http.Request) { //nolint:revive // TODO: use or remove r + w.WriteHeader(403) + w.Write([]byte("Forbidden")) + }, + ) server := httptest.NewServer(mux) t.Cleanup(func() { server.Close() }) @@ -699,7 +748,7 @@ func TestZebedee403MapsToCountryNotSupported(t *testing.T) { qr, _ := encode(fmt.Sprintf("%s/withdraw", server.URL)) - createInvoiceFunc := func(amt lnwire.MilliSatoshi, desc string, host string) (string, error) { + createInvoiceFunc := func(amt lnwire.MilliSatoshi, desc string, host string) (string, error) { //nolint:revive // TODO: use or remove amt panic("should not reach here") } @@ -821,42 +870,58 @@ func TestValidate(t *testing.T) { }{ { name: "plain", - args: args{"LNURL1DP68GUP69UHKCMMRV9KXSMMNWSARWVPCXQHKCMN4WFKZ7AMFW35XGUNPWULHXETRWFJHG0F3XGENGDGK59DKV"}, + args: args{ + "LNURL1DP68GUP69UHKCMMRV9KXSMMNWSARWVPCXQHKCMN4WFKZ7AMFW35XGUNPWULHXETRWFJHG0F3XGENGDGK59DKV", //nolint:lll + }, want: true, }, { name: "lightning scheme", - args: args{"lightning:LNURL1DP68GUP69UHKCMMRV9KXSMMNWSARWVPCXQHKCMN4WFKZ7AMFW35XGUNPWULHXETRWFJHG0F3XGENGDGK59DKV"}, + args: args{ + "lightning:LNURL1DP68GUP69UHKCMMRV9KXSMMNWSARWVPCXQHKCMN4WFKZ7AMFW35XGUNPWULHXETRWFJHG0F3XGENGDGK59DKV", //nolint:lll + }, want: true, }, { name: "HTTP fallback scheme", - args: args{"https://example.com/?lightning=LNURL1DP68GUP69UHKCMMRV9KXSMMNWSARWVPCXQHKCMN4WFKZ7AMFW35XGUNPWULHXETRWFJHG0F3XGENGDGK59DKV"}, + args: args{ + "https://example.com/?lightning=LNURL1DP68GUP69UHKCMMRV9KXSMMNWSARWVPCXQHKCMN4WFKZ7AMFW35XGUNPWULHXETRWFJHG0F3XGENGDGK59DKV", //nolint:lll + }, want: true, }, { name: "muun scheme", - args: args{"muun:LNURL1DP68GUP69UHKCMMRV9KXSMMNWSARWVPCXQHKCMN4WFKZ7AMFW35XGUNPWULHXETRWFJHG0F3XGENGDGK59DKV"}, + args: args{ + "muun:LNURL1DP68GUP69UHKCMMRV9KXSMMNWSARWVPCXQHKCMN4WFKZ7AMFW35XGUNPWULHXETRWFJHG0F3XGENGDGK59DKV", //nolint:lll + }, want: true, }, { name: "muun scheme with double slashes", - args: args{"muun://LNURL1DP68GUP69UHKCMMRV9KXSMMNWSARWVPCXQHKCMN4WFKZ7AMFW35XGUNPWULHXETRWFJHG0F3XGENGDGK59DKV"}, + args: args{ + "muun://LNURL1DP68GUP69UHKCMMRV9KXSMMNWSARWVPCXQHKCMN4WFKZ7AMFW35XGUNPWULHXETRWFJHG0F3XGENGDGK59DKV", //nolint:lll + }, want: true, }, { name: "lightning scheme with double slashes", - args: args{"lightning://LNURL1DP68GUP69UHKCMMRV9KXSMMNWSARWVPCXQHKCMN4WFKZ7AMFW35XGUNPWULHXETRWFJHG0F3XGENGDGK59DKV"}, + args: args{ + "lightning://LNURL1DP68GUP69UHKCMMRV9KXSMMNWSARWVPCXQHKCMN4WFKZ7AMFW35XGUNPWULHXETRWFJHG0F3XGENGDGK59DKV", //nolint:lll + }, want: true, }, { name: "muun + lightning schemes", - args: args{"muun:lightning:LNURL1DP68GUP69UHKCMMRV9KXSMMNWSARWVPCXQHKCMN4WFKZ7AMFW35XGUNPWULHXETRWFJHG0F3XGENGDGK59DKV"}, + args: args{ + "muun:lightning:LNURL1DP68GUP69UHKCMMRV9KXSMMNWSARWVPCXQHKCMN4WFKZ7AMFW35XGUNPWULHXETRWFJHG0F3XGENGDGK59DKV", //nolint:lll + }, want: true, }, { name: "muun + lightning schemes with double slashes", - args: args{"muun://lightning:LNURL1DP68GUP69UHKCMMRV9KXSMMNWSARWVPCXQHKCMN4WFKZ7AMFW35XGUNPWULHXETRWFJHG0F3XGENGDGK59DKV"}, + args: args{ + "muun://lightning:LNURL1DP68GUP69UHKCMMRV9KXSMMNWSARWVPCXQHKCMN4WFKZ7AMFW35XGUNPWULHXETRWFJHG0F3XGENGDGK59DKV", //nolint:lll + }, want: true, }, } diff --git a/libwallet/lnurl_test.go b/libwallet/lnurl_test.go index 833541ed..9291dc63 100644 --- a/libwallet/lnurl_test.go +++ b/libwallet/lnurl_test.go @@ -3,11 +3,13 @@ package libwallet import ( "encoding/json" "fmt" - goLnurl "github.com/fiatjaf/go-lnurl" - "github.com/muun/libwallet/lnurl" "net/http" "net/http/httptest" "testing" + + goLnurl "github.com/fiatjaf/go-lnurl" + + "github.com/muun/libwallet/lnurl" ) func encode(url string) (string, error) { @@ -27,21 +29,30 @@ func (listener *testLNURLListener) OnUpdate(event *LNURLEvent) { switch event.Code { case lnurl.StatusContacting: if listener.status != 0 { - listener.ch <- fmt.Sprintf("expected withdraw status to be %v, got: %v", lnurl.StatusContacting, event.Code) + listener.ch <- fmt.Sprintf( + "expected withdraw status to be %v, got: %v", + lnurl.StatusContacting, event.Code, + ) } else { listener.status = 1 } case lnurl.StatusInvoiceCreated: if listener.status != 1 { - listener.ch <- fmt.Sprintf("expected withdraw status to be %v, got: %v", lnurl.StatusInvoiceCreated, event.Code) + listener.ch <- fmt.Sprintf( + "expected withdraw status to be %v, got: %v", + lnurl.StatusInvoiceCreated, event.Code, + ) } else { listener.status = 2 } case lnurl.StatusReceiving: if listener.status != 2 { - listener.ch <- fmt.Sprintf("expected withdraw status to be %v, got: %v", lnurl.StatusReceiving, event.Code) + listener.ch <- fmt.Sprintf( + "expected withdraw status to be %v, got: %v", + lnurl.StatusReceiving, event.Code, + ) } else { listener.status = 3 listener.ch <- "DONE" @@ -76,11 +87,14 @@ func TestLNURLWithdrawAllowUnsafe(t *testing.T) { Tag: "withdrawRequest", }) }) - mux.HandleFunc("/withdraw/complete", func(w http.ResponseWriter, r *http.Request) { - json.NewEncoder(w).Encode(&lnurl.Response{ - Status: lnurl.StatusOK, - }) - }) + mux.HandleFunc( + "/withdraw/complete", + func(w http.ResponseWriter, r *http.Request) { //nolint:revive // TODO: use or remove r + json.NewEncoder(w).Encode(&lnurl.Response{ + Status: lnurl.StatusOK, + }) + }, + ) server := httptest.NewServer(mux) defer server.Close() @@ -93,7 +107,7 @@ func TestLNURLWithdrawAllowUnsafe(t *testing.T) { invoiceBuilder.Network(network) invoiceBuilder.UserKey(userKey) invoiceBuilder.AddRouteHints(&RouteHints{ - Pubkey: "03c48d1ff96fa32e2776f71bba02102ffc2a1b91e2136586418607d32e762869fd", + Pubkey: "03c48d1ff96fa32e2776f71bba02102ffc2a1b91e2136586418607d32e762869fd", //nolint:lll FeeBaseMsat: 1000, FeeProportionalMillionths: 1000, CltvExpiryDelta: 8, diff --git a/libwallet/log/log.go b/libwallet/log/log.go index 6864f5b8..0e76484d 100644 --- a/libwallet/log/log.go +++ b/libwallet/log/log.go @@ -7,8 +7,7 @@ import ( "strings" ) -// NewBridgeLogHandler returns a [slog.JSONHandler]] that forwards logs to the provided -// sink. +// NewBridgeLogHandler returns a [slog.JSONHandler]] that forwards logs to the provided sink. func NewBridgeLogHandler(sink io.Writer, level slog.Level) *slog.JSONHandler { opts := &slog.HandlerOptions{ AddSource: true, @@ -19,7 +18,10 @@ func NewBridgeLogHandler(sink io.Writer, level slog.Level) *slog.JSONHandler { return slog.NewJSONHandler(sink, opts) } -func replaceAttrs(groups []string, a slog.Attr) slog.Attr { +func replaceAttrs( + groups []string, //nolint:revive // TODO: use or remove groups + a slog.Attr, +) slog.Attr { // Trim the values in the source key. if a.Key == slog.SourceKey { source := a.Value.Any().(*slog.Source) diff --git a/libwallet/musig/impl.go b/libwallet/musig/impl.go deleted file mode 100644 index ab08f126..00000000 --- a/libwallet/musig/impl.go +++ /dev/null @@ -1,194 +0,0 @@ -package musig - -// This file contains Muun specific function that interact with the MuSig2 -// contexts. Code specific to differentiate MuSig versions should not exist in -// this file. - -import ( - "crypto/rand" - "errors" - - "github.com/btcsuite/btcd/btcec/v2/schnorr" - "github.com/decred/dcrd/dcrec/secp256k1/v4" -) - -// RandomSessionId returns a safe random session id. Session IDs must not be -// repeated otherwise private keys are compromised. -func RandomSessionId() [32]byte { - var buf [32]byte - _, err := rand.Read(buf[:]) - if err != nil { - panic("couldn't read random bytes") - } - - return buf -} - -func VerifySignature( - musigVersion MusigVersion, - data []byte, - publicKey []byte, - signature []byte, -) (bool, error) { - - pubKey, err := ParsePubKey(musigVersion, publicKey) - if err != nil { - return false, err - } - - sig, err := schnorr.ParseSignature(signature) - if err != nil { - return false, err - } - - return sig.Verify(data, pubKey), nil -} - -// Computes the first part of the 2-2 signature. -// Returns a valid partial signature. -func ComputeMuunPartialSignature( - musigVersion MusigVersion, - data []byte, - userPublicKeyBytes []byte, - muunPrivateKeyBytes []byte, - rawUserPublicNonce []byte, - muunSessionId []byte, - tweak *MuSig2Tweaks, -) ([]byte, error) { - - muunPrivateKey := secp256k1.PrivKeyFromBytes(muunPrivateKeyBytes) - muunPublicKey := muunPrivateKey.PubKey() - muunPublicKeyBytes := muunPublicKey.SerializeCompressed() - - signerPublicKeys, err := MuSig2ParsePubKeys(musigVersion, [][]byte{ - userPublicKeyBytes, - muunPublicKeyBytes, - }) - if err != nil { - return nil, err - } - - // As we'd like the local nonce we send over to be generated - // deterministically, we'll provide a random sessionId as the primary - // randomness source. - muunNonce, err := MuSig2GenerateNonce(musigVersion, muunSessionId, muunPublicKeyBytes) - if err != nil { - return nil, err - } - - // Create a signing context and session with the given private key and - // list of all known signer public keys. - _, session, err := MuSig2CreateContext( - musigVersion, - muunPrivateKey, - signerPublicKeys, - tweak, - muunNonce, - ) - if err != nil { - return nil, err - } - - // Add all nonces we might've learned so far. - haveAllNonces := false - if haveAllNonces, err = session.RegisterPubNonce( - [66]byte(rawUserPublicNonce)); err != nil { - return nil, err - } - if !haveAllNonces { - return nil, errors.New("some nonces are missing") - } - - sig, err := MuSig2Sign(session, ([32]byte)(data)) - if err != nil { - return nil, err - } - - ret, err := SerializePartialSignature(sig) - if err != nil { - return nil, err - } - - return ret[:], nil -} - -// Computes the last part of the 2-2 signature. -// Final signature is ensured to be valid. -func ComputeUserPartialSignature( - musigVersion MusigVersion, - data []byte, - userPrivateKeyBytes []byte, - muunPublicKeyBytes []byte, - muunPartialSigBytes []byte, - muunPublicNonceBytes []byte, - userSessionId []byte, - tweak *MuSig2Tweaks, -) ([]byte, error) { - - userPrivateKey := secp256k1.PrivKeyFromBytes(userPrivateKeyBytes) - userPublicKey := userPrivateKey.PubKey() - userPublicKeyBytes := userPublicKey.SerializeCompressed() - - pubKeys := [][]byte{ - userPublicKeyBytes, - muunPublicKeyBytes, - } - signerPublicKeys, err := MuSig2ParsePubKeys(musigVersion, pubKeys) - if err != nil { - return nil, err - } - - // As we'd like the local nonce we send over to be generated - // deterministically, we'll provide a random sessionId as the primary - // randomness source. - userNonce, err := MuSig2GenerateNonce(musigVersion, userSessionId, userPublicKeyBytes) - if err != nil { - return nil, err - } - - // Create a signing context and session with the given private key and - // list of all known signer public keys. - _, session, err := MuSig2CreateContext( - musigVersion, - userPrivateKey, - signerPublicKeys, - tweak, - userNonce, - ) - if err != nil { - return nil, err - } - - // Add all nonces we might've learned so far. - haveAllNonces := false - if haveAllNonces, err = session.RegisterPubNonce( - [66]byte(muunPublicNonceBytes)); err != nil { - return nil, err - } - if !haveAllNonces { - return nil, errors.New("some nonces are missing") - } - - _, err = MuSig2Sign(session, ([32]byte)(data)) - if err != nil { - return nil, err - } - - muunSig, err := DeserializePartialSignature(muunPartialSigBytes) - if err != nil { - return nil, err - } - - haveAllSigs, err := MuSig2CombineSig(session, muunSig) - if err != nil { - return nil, err - } - if !haveAllSigs { - return nil, errors.New("some signatures are still missing") - } - - // FinalSig() also validates the signature - sig := session.FinalSig() - - return sig.Serialize()[:], nil -} diff --git a/libwallet/musig/musig2.go b/libwallet/musig/musig2.go index 6987da49..0696adec 100644 --- a/libwallet/musig/musig2.go +++ b/libwallet/musig/musig2.go @@ -1,485 +1,174 @@ package musig -// This file contains generic adapters for both versions musig2v040 and v100 +// This file contains Muun specific function that interact with the MuSig2 +// contexts. Code specific to differentiate MuSig versions should not exist in +// this file. import ( - "bytes" - "fmt" + "crypto/rand" - "github.com/btcsuite/btcd/btcec/v2" - "github.com/btcsuite/btcd/btcec/v2/schnorr" - musig2v100 "github.com/btcsuite/btcd/btcec/v2/schnorr/musig2" - "github.com/lightningnetwork/lnd/input" - "github.com/muun/libwallet/musig2v040" + "github.com/decred/dcrd/dcrec/secp256k1/v4" + "github.com/go-errors/errors" ) -type MusigVersion uint8 - -const ( - // Muun's variant of MuSig2 based on secp256k1_zkp implementation - // at commit https://github.com/jonasnick/secp256k1-zkp/tree/0aeaa5dfb19445f845890f3a4502c934550f4548 - // and nonces calculated with random entropy from sessionId (only). - // - not null scriptPath are not spendable with this implementation - // - key sorting is disabled, the order [user,muun] is enforced - // - xOnly keys are used - // - tapscript is not spendable - Musig2v040Muun MusigVersion = 40 - - // version 1.0.0rc2 of the MuSig2 BIP draft. - // It uses the github.com/btcsuite/btcd/btcec/v2/schnorr/musig2 package - // imported by go.mod - Musig2v100 MusigVersion = 100 -) - -func MuSig2GenerateNonce( - musigVersion MusigVersion, - sessionId []byte, - publicKeyBytes []byte, -) (*musig2v100.Nonces, error) { - - switch musigVersion { - case Musig2v040Muun: - return musig2v040.GenNonces( - musig2v040.WithCustomRand( - bytes.NewBuffer(sessionId), - ), - ) - - case Musig2v100: - if len(publicKeyBytes) == 0 { - return nil, fmt.Errorf("a public key must be provided to generate nonces for MuSig2v100") - } - - publicKey, err := ParsePubKey(musigVersion, publicKeyBytes) - if err != nil { - return nil, err - } - - return musig2v100.GenNonces( - musig2v100.WithPublicKey(publicKey), - musig2v100.WithCustomRand(bytes.NewBuffer(sessionId)), - ) - default: - return nil, fmt.Errorf("unknown address version: <%d>", - musigVersion) - } -} - -// ParsePubKey forces the kind of PublicKey needed for each MuSig version -func ParsePubKey(musigVersion MusigVersion, pubKeyBytes []byte) (*btcec.PublicKey, error) { - switch musigVersion { - case Musig2v040Muun: - var ( - pubKey *btcec.PublicKey - err error - ) - - if len(pubKeyBytes) == 33 { - // if the not xOnly compressed was provided, then remove the - // parity bit - pubKey, err = schnorr.ParsePubKey(pubKeyBytes[1:]) - if err != nil { - return nil, fmt.Errorf( - "error parsing public key for v0.4.0 (compressed format): %v", - err, - ) - } - } else { - pubKey, err = schnorr.ParsePubKey(pubKeyBytes) - if err != nil { - return nil, fmt.Errorf( - "error parsing public key for v0.4.0 (x-only format): %v", - err, - ) - } - } - - return pubKey, nil - case Musig2v100: - pubKey, err := btcec.ParsePubKey(pubKeyBytes) - if err != nil { - return nil, fmt.Errorf("error parsing public key for v1.0.0 ("+ - "compressed format): %v", err) - } - return pubKey, nil - default: - return nil, fmt.Errorf("unknown MuSig2 version: <%d>", - musigVersion) - } -} - -// MuSig2ParsePubKeys parses a list of raw public keys as the signing keys of a -// MuSig2 signing session. -func MuSig2ParsePubKeys(musigVersion MusigVersion, - rawPubKeys [][]byte) ([]*btcec.PublicKey, error) { - - allSignerPubKeys := make([]*btcec.PublicKey, len(rawPubKeys)) - if len(rawPubKeys) < 2 { - return nil, fmt.Errorf("need at least two signing public keys") - } - - for idx, pubKeyBytes := range rawPubKeys { - pubKey, err := ParsePubKey(musigVersion, pubKeyBytes) - if err != nil { - return nil, fmt.Errorf("error parsing signer "+ - "public key %d: %v", idx, err) - } - allSignerPubKeys[idx] = pubKey +// RandomSessionID returns a safe random session id. Session IDs must not be +// repeated otherwise private keys are compromised. +func RandomSessionID() [32]byte { + var buf [32]byte + _, err := rand.Read(buf[:]) + if err != nil { + panic("couldn't read random bytes") } - return allSignerPubKeys, nil + return buf } -// Computes the tweakedKey using a TapScript.merkleRoot or empty bytes as -// recommended by BIP0086. -// The tweakedKey is used to generate the output address: Bech32m(tweakedKey) -func Musig2CombinePubKeysWithTweak( +// ComputePartialSignature produces one signer's partial signature for an N-of-N +// MuSig2 session. allParticipantPublicKeys lists every participant's public key, +// and otherParticipantPublicNonces holds the public nonces of every +// other participant. +func ComputePartialSignature( musigVersion MusigVersion, - pubKeys [][]byte, - tweaks *MuSig2Tweaks, -) (*musig2v100.AggregateKey, error) { - - keys, err := MuSig2ParsePubKeys(musigVersion, pubKeys) + data []byte, + signerPrivateKeyBytes []byte, + allParticipantPublicKeys [][]byte, + otherParticipantPublicNonces [][]byte, + signerSessionID []byte, + tweak *MuSig2Tweaks, +) ([]byte, error) { + + signerPrivateKey := secp256k1.PrivKeyFromBytes(signerPrivateKeyBytes) + signerPublicKeyBytes := signerPrivateKey.PubKey().SerializeCompressed() + + signerPublicKeys, err := MuSig2ParsePubKeys(musigVersion, allParticipantPublicKeys) if err != nil { return nil, err } - return MuSig2CombineKeys(musigVersion, keys, tweaks) -} - -// MuSig2CombineKeys combines the given set of public keys into a single -// combined MuSig2 combined public key, applying the given tweaks. -func MuSig2CombineKeys(musigVersion MusigVersion, - allSignerPubKeys []*btcec.PublicKey, - tweaks *MuSig2Tweaks) (*musig2v100.AggregateKey, error) { - - sortKeys := musigVersion != Musig2v040Muun - - switch musigVersion { - case Musig2v040Muun: - return combineKeysV040(allSignerPubKeys, sortKeys, tweaks) - - case Musig2v100: - return combineKeysV100RC2(allSignerPubKeys, sortKeys, tweaks) - - default: - return nil, fmt.Errorf("unknown MuSig2 version: <%d>", - musigVersion) - } -} - -// combineKeysV100rc1 implements the MuSigCombineKeys logic for the MuSig2 BIP -// draft version 1.0.0rc2. -func combineKeysV100RC2(allSignerPubKeys []*btcec.PublicKey, sortKeys bool, - tweaks *MuSig2Tweaks) (*musig2v100.AggregateKey, error) { - - // Convert the tweak options into the appropriate MuSig2 API functional - // options. - var keyAggOpts []musig2v100.KeyAggOption - switch { - case tweaks.TaprootBIP0086Tweak: - keyAggOpts = append(keyAggOpts, musig2v100.WithBIP86KeyTweak()) - case len(tweaks.TaprootTweak) > 0: - keyAggOpts = append(keyAggOpts, musig2v100.WithTaprootKeyTweak( - tweaks.TaprootTweak, - )) - case len(tweaks.GenericTweaks) > 0: - keyAggOpts = append(keyAggOpts, musig2v100.WithKeyTweaks( - tweaks.GenericTweaks..., - )) - case len(tweaks.UnhardenedDerivationPath) > 0: - bip328tweaks, err := getKeyDerivationTweaksForMusig( - allSignerPubKeys, tweaks.UnhardenedDerivationPath) - if err != nil { - return nil, err - } - keyAggOpts = append(keyAggOpts, musig2v100.WithKeyTweaks( - bip328tweaks..., - )) - } - - // Then we'll use this information to compute the aggregated public key. - combinedKey, _, _, err := musig2v100.AggregateKeys( - allSignerPubKeys, sortKeys, keyAggOpts..., - ) - return combinedKey, err -} - -// returns a list of generic tweaks to derive a specific unhardened key for Musig2v100 -// as per BIP32 + BIP328 -func getKeyDerivationTweaksForMusig( - allSignerPubKeys []*btcec.PublicKey, - unhardenedDerivationPath []uint32, -) ([]musig2v100.KeyTweakDesc, error) { - - aggregatedKey, err := MuSig2CombineKeys( - Musig2v100, allSignerPubKeys, NoopTweak()) + // As we'd like the local nonce we send over to be generated + // deterministically, we'll provide a random sessionID as the primary + // randomness source. + signerNonce, err := MuSig2GenerateNonce(musigVersion, signerSessionID, signerPublicKeyBytes) if err != nil { return nil, err } - _, tweakDerivationSteps, err := getBip32TweaksForAggregatedKey( - aggregatedKey.PreTweakedKey, unhardenedDerivationPath) + // Create a signing context and session with the given private key and + // list of all known participant public keys. + _, session, err := MuSig2CreateContext( + musigVersion, + signerPrivateKey, + signerPublicKeys, + tweak, + signerNonce, + ) if err != nil { return nil, err } - return tweakDerivationSteps, nil -} - -// combineKeysV040 implements the MuSigCombineKeys logic for the MuSig2 BIP -// draft version 0.4.0. -func combineKeysV040(allSignerPubKeys []*btcec.PublicKey, sortKeys bool, - tweaks *MuSig2Tweaks) (*musig2v100.AggregateKey, error) { - - // Convert the tweak options into the appropriate MuSig2 API functional - // options. - var keyAggOpts []musig2v040.KeyAggOption - switch { - case tweaks.TaprootBIP0086Tweak: - keyAggOpts = append(keyAggOpts, musig2v040.WithBIP86KeyTweak()) - case len(tweaks.TaprootTweak) > 0: - return nil, fmt.Errorf( - "taproot tweak bytes are not allowed for MuSig2v040Muun") - case len(tweaks.GenericTweaks) > 0: - return nil, fmt.Errorf( - "generic tweaks are not available for Musig2v040Muun") - case len(tweaks.UnhardenedDerivationPath) > 0: - return nil, fmt.Errorf( - "unhardened derivation is not available for Musig2v040Muun") - } - - // Then we'll use this information to compute the aggregated public key. - combinedKey, _, _, err := musig2v040.AggregateKeys( - allSignerPubKeys, sortKeys, keyAggOpts..., - ) - - // Copy the result back into the default version's native type. - return &musig2v100.AggregateKey{ - FinalKey: combinedKey.FinalKey, - PreTweakedKey: combinedKey.PreTweakedKey, - }, err -} - -// MuSig2CreateContext creates a new MuSig2 signing context. -func MuSig2CreateContext( - musigVersion MusigVersion, - privKey *btcec.PrivateKey, - allSignerPubKeys []*btcec.PublicKey, - tweaks *MuSig2Tweaks, - localNonces *musig2v100.Nonces, -) (input.MuSig2Context, input.MuSig2Session, error) { - - switch musigVersion { - case Musig2v040Muun: - if len(tweaks.UnhardenedDerivationPath) > 0 { - return nil, nil, fmt.Errorf( - "unhardened derivation is not available for Musig2v040Muun") - } - - if len(tweaks.TaprootTweak) > 0 { - return nil, nil, fmt.Errorf( - "taproot tweak bytes are not allowed for MuSig2v040Muun") + // Add all nonces from the other participants. + haveAllNonces := false + for _, otherNonce := range otherParticipantPublicNonces { + haveAllNonces, err = session.RegisterPubNonce([66]byte(otherNonce)) + if err != nil { + return nil, err } - - return createContextV040( - privKey, allSignerPubKeys, tweaks, localNonces, - ) - - case Musig2v100: - return createContextV100RC2( - privKey, allSignerPubKeys, tweaks, localNonces, - ) - - default: - return nil, nil, fmt.Errorf("unknown MuSig2 : <%d>", - musigVersion) } -} - -// createContextV100RC2 implements the MuSig2CreateContext logic for the MuSig2 -// BIP draft version 1.0.0rc2. -func createContextV100RC2( - privKey *btcec.PrivateKey, - allSignerPubKeys []*btcec.PublicKey, - tweaks *MuSig2Tweaks, - localNonces *musig2v100.Nonces, -) (*musig2v100.Context, *musig2v100.Session, error) { - - if localNonces == nil { - return nil, nil, fmt.Errorf("error creating MuSig2 signing " + - "context: localNonces must be provided") + if !haveAllNonces { + return nil, errors.New("some nonces are missing") } - // The context keeps track of all signing keys and our local key. - options, err := tweaks.ToContextOptions(allSignerPubKeys) - if err != nil { - return nil, nil, err - } - allOpts := append(options, musig2v100.WithKnownSigners(allSignerPubKeys)) - muSigContext, err := musig2v100.NewContext(privKey, true, allOpts...) + sig, err := MuSig2Sign(session, ([32]byte)(data)) if err != nil { - return nil, nil, fmt.Errorf("error creating MuSig2 signing "+ - "context: %v", err) + return nil, err } - muSigSession, err := muSigContext.NewSession( - musig2v100.WithPreGeneratedNonce(localNonces), - ) + ret, err := SerializePartialSignature(sig) if err != nil { - return nil, nil, fmt.Errorf("error creating MuSig2 signing "+ - "session: %v", err) + return nil, err } - return muSigContext, muSigSession, nil + return ret[:], nil } -// createContextV040 implements the MuSig2CreateContext logic for the MuSig2 BIP -// draft version 0.4.0. -func createContextV040( - privKey *btcec.PrivateKey, - allSignerPubKeys []*btcec.PublicKey, - tweaks *MuSig2Tweaks, - localNonces *musig2v100.Nonces, -) (*musig2v040.Context, *musig2v040.Session, error) { - - if localNonces == nil { - return nil, nil, fmt.Errorf("error creating MuSig2 signing " + - "context: localNonces must be provided") +// ComputeFinalSignature produces the final aggregate signature for an N-of-N +// MuSig2 session. The calling signer contributes its own partial signature and +// combines it with otherParticipantPartialSignatures (the partials of every other participant). +// The returned signature is validated. +func ComputeFinalSignature( + musigVersion MusigVersion, + data []byte, + signerPrivateKeyBytes []byte, + allParticipantPublicKeys [][]byte, + otherParticipantPublicNonces [][]byte, + otherParticipantPartialSignatures [][]byte, + signerSessionID []byte, + tweak *MuSig2Tweaks, +) ([]byte, error) { + + signerPrivateKey := secp256k1.PrivKeyFromBytes(signerPrivateKeyBytes) + signerPublicKeyBytes := signerPrivateKey.PubKey().SerializeCompressed() + + signerPublicKeys, err := MuSig2ParsePubKeys(musigVersion, allParticipantPublicKeys) + if err != nil { + return nil, err } - // The context keeps track of all signing keys and our local key. - allOpts := append( - []musig2v040.ContextOption{ - musig2v040.WithKnownSigners(allSignerPubKeys), - }, - tweaks.ToV040ContextOptions()..., - ) - muSigContext, err := musig2v040.NewContext(privKey, false, allOpts...) + // As we'd like the local nonce we send over to be generated + // deterministically, we'll provide a random sessionID as the primary + // randomness source. + signerNonce, err := MuSig2GenerateNonce(musigVersion, signerSessionID, signerPublicKeyBytes) if err != nil { - return nil, nil, fmt.Errorf("error creating MuSig2 signing "+ - "context: %v", err) + return nil, err } - muSigSession, err := muSigContext.NewSession( - musig2v040.WithPreGeneratedNonce(localNonces), + // Create a signing context and session with the given private key and + // list of all known participant public keys. + _, session, err := MuSig2CreateContext( + musigVersion, + signerPrivateKey, + signerPublicKeys, + tweak, + signerNonce, ) if err != nil { - return nil, nil, fmt.Errorf("error creating MuSig2 signing "+ - "session: %v", err) + return nil, err } - return muSigContext, muSigSession, nil -} - -// MuSig2Sign calls the Sign() method on the given versioned signing session and -// returns the result in the most recent version of the MuSig2 API. -func MuSig2Sign( - session input.MuSig2Session, - msg [32]byte, -) (*musig2v100.PartialSignature, error) { - - switch s := session.(type) { - case *musig2v100.Session: - partialSig, err := s.Sign(msg, musig2v100.WithSortedKeys()) - if err != nil { - return nil, fmt.Errorf("error signing with local key: "+ - "%v", err) - } - - return partialSig, nil - - case *musig2v040.Session: - partialSig, err := s.Sign(msg) + // Add all nonces from the other participants. + haveAllNonces := false + for _, otherNonce := range otherParticipantPublicNonces { + haveAllNonces, err = session.RegisterPubNonce([66]byte(otherNonce)) if err != nil { - return nil, fmt.Errorf("error signing with local key: "+ - "%v", err) + return nil, err } - - return &musig2v100.PartialSignature{ - S: partialSig.S, - R: partialSig.R, - }, nil - - default: - return nil, fmt.Errorf("invalid session type <%T>", s) } -} + if !haveAllNonces { + return nil, errors.New("some nonces are missing") + } -// MuSig2CombineSig calls the CombineSig() method on the given versioned signing -// session and returns the result in the most recent version of the MuSig2 API. -func MuSig2CombineSig( - session input.MuSig2Session, - otherPartialSig *musig2v100.PartialSignature, -) (bool, error) { + if _, err = MuSig2Sign(session, ([32]byte)(data)); err != nil { + return nil, err + } - switch s := session.(type) { - case *musig2v100.Session: - haveAllSigs, err := s.CombineSig(otherPartialSig) + // Combine the partial signatures from the other participants. + haveAllSigs := false + for _, otherPartialSig := range otherParticipantPartialSignatures { + partialSig, err := DeserializePartialSignature(otherPartialSig) if err != nil { - return false, fmt.Errorf("error combining partial "+ - "signature: %v", err) + return nil, err } - - return haveAllSigs, nil - - case *musig2v040.Session: - haveAllSigs, err := s.CombineSig(&musig2v040.PartialSignature{ - S: otherPartialSig.S, - R: otherPartialSig.R, - }) + haveAllSigs, err = MuSig2CombineSig(session, partialSig) if err != nil { - return false, fmt.Errorf("error combining partial "+ - "signature: %v", err) + return nil, err } - - return haveAllSigs, nil - - default: - return false, fmt.Errorf("invalid session type <%T>", s) - } -} - -// SerializePartialSignature encodes the partial signature to a fixed size byte -// array. -func SerializePartialSignature( - sig *musig2v100.PartialSignature, -) ([input.MuSig2PartialSigSize]byte, error) { - - var ( - buf bytes.Buffer - result [input.MuSig2PartialSigSize]byte - ) - if err := sig.Encode(&buf); err != nil { - return result, fmt.Errorf("error encoding partial signature: "+ - "%v", err) - } - - if buf.Len() != input.MuSig2PartialSigSize { - return result, fmt.Errorf("invalid partial signature length, "+ - "got %d wanted %d", buf.Len(), input.MuSig2PartialSigSize) } - - copy(result[:], buf.Bytes()) - - return result, nil -} - -// DeserializePartialSignature decodes a partial signature from a byte slice. -func DeserializePartialSignature( - scalarBytes []byte, -) (*musig2v100.PartialSignature, error) { - - if len(scalarBytes) != input.MuSig2PartialSigSize { - return nil, fmt.Errorf("invalid partial signature length, got "+ - "%d wanted %d", len(scalarBytes), input.MuSig2PartialSigSize) + if !haveAllSigs { + return nil, errors.New("some signatures are still missing") } - sig := &musig2v100.PartialSignature{} - if err := sig.Decode(bytes.NewReader(scalarBytes)); err != nil { - return nil, fmt.Errorf("error decoding partial signature: %w", - err) - } + // FinalSig() also validates the signature + sig := session.FinalSig() - return sig, nil + return sig.Serialize()[:], nil } diff --git a/libwallet/musig/musig2_2_of_2.go b/libwallet/musig/musig2_2_of_2.go new file mode 100644 index 00000000..6185ac69 --- /dev/null +++ b/libwallet/musig/musig2_2_of_2.go @@ -0,0 +1,168 @@ +package musig + +import ( + "github.com/btcsuite/btcd/btcec/v2" + "github.com/decred/dcrd/dcrec/secp256k1/v4" +) + +// ComputeMuunPartialSignature computes the first part of the 2-2 signature. +// Returns a valid partial signature. +// +// Deprecated: only used in V5 and V6. New code uses MuSig2 v100 only; use +// ComputePartialSignature2Of2 instead. +func ComputeMuunPartialSignature( + musigVersion MusigVersion, + data []byte, + userPublicKeyBytes []byte, + muunPrivateKeyBytes []byte, + rawUserPublicNonce []byte, + muunSessionID []byte, + tweak *MuSig2Tweaks, +) ([]byte, error) { + + muunPrivateKey := secp256k1.PrivKeyFromBytes(muunPrivateKeyBytes) + muunPublicKeyBytes := muunPrivateKey.PubKey().SerializeCompressed() + + return ComputePartialSignature( + musigVersion, + data, + muunPrivateKeyBytes, + [][]byte{userPublicKeyBytes, muunPublicKeyBytes}, + [][]byte{rawUserPublicNonce}, + muunSessionID, + tweak, + ) +} + +// ComputeUserPartialSignature computes the last part of the 2-2 signature. +// Final signature is ensured to be valid. +// +// Deprecated: only used in V5 and V6. New code uses MuSig2 v100 only; use +// ComputeFinalSignature2Of2 instead. +func ComputeUserPartialSignature( + musigVersion MusigVersion, + data []byte, + userPrivateKeyBytes []byte, + muunPublicKeyBytes []byte, + muunPartialSigBytes []byte, + muunPublicNonceBytes []byte, + userSessionID []byte, + tweak *MuSig2Tweaks, +) ([]byte, error) { + + userPrivateKey := secp256k1.PrivKeyFromBytes(userPrivateKeyBytes) + userPublicKeyBytes := userPrivateKey.PubKey().SerializeCompressed() + + return ComputeFinalSignature( + musigVersion, + data, + userPrivateKeyBytes, + [][]byte{userPublicKeyBytes, muunPublicKeyBytes}, + [][]byte{muunPublicNonceBytes}, + [][]byte{muunPartialSigBytes}, + userSessionID, + tweak, + ) +} + +// ComputePartialSignature2Of2 computes the first part of the 2-of-2 signature +// using MuSig2 v100. Returns a valid partial signature. +func ComputePartialSignature2Of2( + data []byte, + currentSignerPrivateKeyBytes []byte, + otherSignerPublicKeyBytes []byte, + otherSignerPublicNonceBytes []byte, + currentSignerSessionID []byte, + tweak *MuSig2Tweaks, +) ([]byte, error) { + + currentSignerPrivateKey := secp256k1.PrivKeyFromBytes(currentSignerPrivateKeyBytes) + currentSignerPublicKeyBytes := currentSignerPrivateKey.PubKey().SerializeCompressed() + + return ComputePartialSignature( + Musig2v100, + data, + currentSignerPrivateKeyBytes, + [][]byte{currentSignerPublicKeyBytes, otherSignerPublicKeyBytes}, + [][]byte{otherSignerPublicNonceBytes}, + currentSignerSessionID, + tweak, + ) +} + +// ComputeFinalSignature2Of2 computes the last part of the 2-of-2 signature +// using MuSig2 v100. Final signature is ensured to be valid. +func ComputeFinalSignature2Of2( + data []byte, + currentSignerPrivateKeyBytes []byte, + otherSignerPublicKeyBytes []byte, + otherSignerPublicNonceBytes []byte, + otherSignerPartialSigBytes []byte, + currentSignerSessionID []byte, + tweak *MuSig2Tweaks, +) ([]byte, error) { + + currentSignerPrivateKey := secp256k1.PrivKeyFromBytes(currentSignerPrivateKeyBytes) + currentSignerPublicKeyBytes := currentSignerPrivateKey.PubKey().SerializeCompressed() + + return ComputeFinalSignature( + Musig2v100, + data, + currentSignerPrivateKeyBytes, + [][]byte{currentSignerPublicKeyBytes, otherSignerPublicKeyBytes}, + [][]byte{otherSignerPublicNonceBytes}, + [][]byte{otherSignerPartialSigBytes}, + currentSignerSessionID, + tweak, + ) +} + +// ComputeFullSignature2Of2 runs a full 2-of-2 MuSig2 session with both private +// keys using MuSig2 v100 and returns the final aggregated signature. +func ComputeFullSignature2Of2( + data []byte, + signerAKey, signerBKey *btcec.PrivateKey, + tweak *MuSig2Tweaks, +) ([]byte, error) { + + signerAPublicKeyBytes := signerAKey.PubKey().SerializeCompressed() + signerBPublicKeyBytes := signerBKey.PubKey().SerializeCompressed() + + signerASessionID := RandomSessionID() + signerBSessionID := RandomSessionID() + + signerANonce, err := MuSig2GenerateNonce( + Musig2v100, signerASessionID[:], signerAPublicKeyBytes, + ) + if err != nil { + return nil, err + } + signerBNonce, err := MuSig2GenerateNonce( + Musig2v100, signerBSessionID[:], signerBPublicKeyBytes, + ) + if err != nil { + return nil, err + } + + signerBPartialSig, err := ComputePartialSignature2Of2( + data, + signerBKey.Serialize(), + signerAPublicKeyBytes, + signerANonce.PubNonce[:], + signerBSessionID[:], + tweak, + ) + if err != nil { + return nil, err + } + + return ComputeFinalSignature2Of2( + data, + signerAKey.Serialize(), + signerBPublicKeyBytes, + signerBNonce.PubNonce[:], + signerBPartialSig, + signerASessionID[:], + tweak, + ) +} diff --git a/libwallet/musig/musig2_2_of_2_test.go b/libwallet/musig/musig2_2_of_2_test.go new file mode 100644 index 00000000..c65e7c53 --- /dev/null +++ b/libwallet/musig/musig2_2_of_2_test.go @@ -0,0 +1,144 @@ +package musig + +import ( + "crypto/sha256" + "encoding/hex" + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/decred/dcrd/dcrec/secp256k1/v4" + "github.com/stretchr/testify/require" +) + +// TestComputePartialAndFinalSignature2Of2 drives the two-step 2-of-2 flow +// (muun partial-signs first, the user signs last and combines) against pinned +// vectors. The vectors are the same as the "sanity 1 (v100)" case of +// TestMuSig2Tests2of2, so the v100-only functions are asserted byte-compatible +// with the deprecated version-parametric ones. +func TestComputePartialAndFinalSignature2Of2(t *testing.T) { + t.Parallel() + + userKey := hexDecode("507d881f0b5e1b12423cb0c84a196fb24227f3fe1540a1c7b20bf78d83de4533") + muunKey := hexDecode("b6f14c73ee5269f5a13a11f48ad54306293ee134e924f680fcd35f615881105b") + msg := hexDecode("ef2ecc1f48c0b28ccaf8f3a8c6477740d869964ebc152a2c5f93f19e7b84b103") + userSessionID := hexDecode("5c9360026e39ad06251a27916dcf086a7b2deb6789c5dcd75ba10e540cf37e13") + muunSessionID := hexDecode("cad3ec6737e2fb125d976bfe382441c59c6a4d46382bfab75e9d3f1b43a9b0a7") + tweak := KeySpendOnlyTweak() + + userPublicKeyBytes := secp256k1.PrivKeyFromBytes(userKey).PubKey().SerializeCompressed() + muunPublicKeyBytes := secp256k1.PrivKeyFromBytes(muunKey).PubKey().SerializeCompressed() + + combinedKey, err := Musig2CombinePubKeysWithTweak( + Musig2v100, + [][]byte{userPublicKeyBytes, muunPublicKeyBytes}, + tweak, + ) + require.NoError(t, err) + require.Equal( + t, + "03c367d7ef80b10687820dda279e0e6054dadccc30550c8eae7e21a945069cb7e0", + hex.EncodeToString(combinedKey.FinalKey.SerializeCompressed()), + ) + + userNonce, err := MuSig2GenerateNonce(Musig2v100, userSessionID, userPublicKeyBytes) + require.NoError(t, err) + muunNonce, err := MuSig2GenerateNonce(Musig2v100, muunSessionID, muunPublicKeyBytes) + require.NoError(t, err) + + muunPartialSignature, err := ComputePartialSignature2Of2( + msg, + muunKey, + userPublicKeyBytes, + userNonce.PubNonce[:], + muunSessionID, + tweak, + ) + require.NoError(t, err) + require.Equal( + t, + "18422b132ac447af9e98db197d45becb26c83aa4fa658312dd8357e1e8309ce4", + hex.EncodeToString(muunPartialSignature), + ) + + fullSignature, err := ComputeFinalSignature2Of2( + msg, + userKey, + muunPublicKeyBytes, + muunNonce.PubNonce[:], + muunPartialSignature, + userSessionID, + tweak, + ) + require.NoError(t, err) + require.Equal( + t, + "5e9034fe55b901308dd4751855e50a2181ec264b76fb5a986c87e78084202a9a83588f0e45e103645a01f313d7f3b03a5fcc5bc68ff7c41ae4d451271441d20b", //nolint:lll + hex.EncodeToString(fullSignature), + ) + + assertValidSignature(t, msg, combinedKey.FinalKey.SerializeCompressed(), fullSignature) +} + +// TestComputeFullSignature2Of2 runs a full 2-of-2 session with random keys and +// asserts the aggregated signature verifies against the combined key. +func TestComputeFullSignature2Of2(t *testing.T) { + t.Parallel() + + signerAKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + signerBKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + toSign := sha256.Sum256([]byte("muun-2-of-2")) + tweak := KeySpendOnlyTweak() + + combinedKey, err := Musig2CombinePubKeysWithTweak( + Musig2v100, + [][]byte{ + signerAKey.PubKey().SerializeCompressed(), + signerBKey.PubKey().SerializeCompressed(), + }, + tweak, + ) + require.NoError(t, err) + + fullSignature, err := ComputeFullSignature2Of2(toSign[:], signerAKey, signerBKey, tweak) + require.NoError(t, err) + + assertValidSignature( + t, toSign[:], combinedKey.FinalKey.SerializeCompressed(), fullSignature, + ) +} + +// TestComputeFullSignature2Of2WithScriptPath runs a full 2-of-2 session with a +// tapscript tweak and asserts the aggregated signature verifies against the +// scriptPath-committed combined key. +func TestComputeFullSignature2Of2WithScriptPath(t *testing.T) { + t.Parallel() + + signerAKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + signerBKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + toSign := sha256.Sum256([]byte("muun-2-of-2")) + scriptPath := RandomSessionID() // any random 32 bytes + tweak := TapScriptTweak(scriptPath[:]) + + combinedKey, err := Musig2CombinePubKeysWithTweak( + Musig2v100, + [][]byte{ + signerAKey.PubKey().SerializeCompressed(), + signerBKey.PubKey().SerializeCompressed(), + }, + tweak, + ) + require.NoError(t, err) + + fullSignature, err := ComputeFullSignature2Of2(toSign[:], signerAKey, signerBKey, tweak) + require.NoError(t, err) + + assertValidSignature( + t, toSign[:], combinedKey.FinalKey.SerializeCompressed(), fullSignature, + ) +} diff --git a/libwallet/musig/musig2_3_of_3.go b/libwallet/musig/musig2_3_of_3.go new file mode 100644 index 00000000..73c4d9b1 --- /dev/null +++ b/libwallet/musig/musig2_3_of_3.go @@ -0,0 +1,70 @@ +package musig + +import ( + "github.com/decred/dcrd/dcrec/secp256k1/v4" +) + +// ComputePartialSignature3Of3 computes one signer's part of the 3-of-3 +// signature using MuSig2 v100. Returns a valid partial signature. +func ComputePartialSignature3Of3( + data []byte, + currentSignerPrivateKeyBytes []byte, + otherSigner1PublicKeyBytes []byte, + otherSigner2PublicKeyBytes []byte, + otherSigner1PublicNonceBytes []byte, + otherSigner2PublicNonceBytes []byte, + currentSignerSessionID []byte, + tweak *MuSig2Tweaks, +) ([]byte, error) { + + currentSignerPrivateKey := secp256k1.PrivKeyFromBytes(currentSignerPrivateKeyBytes) + currentSignerPublicKeyBytes := currentSignerPrivateKey.PubKey().SerializeCompressed() + + return ComputePartialSignature( + Musig2v100, + data, + currentSignerPrivateKeyBytes, + [][]byte{ + currentSignerPublicKeyBytes, + otherSigner1PublicKeyBytes, + otherSigner2PublicKeyBytes, + }, + [][]byte{otherSigner1PublicNonceBytes, otherSigner2PublicNonceBytes}, + currentSignerSessionID, + tweak, + ) +} + +// ComputeFinalSignature3Of3 computes the last part of the 3-of-3 signature +// using MuSig2 v100. Final signature is ensured to be valid. +func ComputeFinalSignature3Of3( + data []byte, + currentSignerPrivateKeyBytes []byte, + otherSigner1PublicKeyBytes []byte, + otherSigner2PublicKeyBytes []byte, + otherSigner1PublicNonceBytes []byte, + otherSigner2PublicNonceBytes []byte, + otherSigner1PartialSigBytes []byte, + otherSigner2PartialSigBytes []byte, + currentSignerSessionID []byte, + tweak *MuSig2Tweaks, +) ([]byte, error) { + + currentSignerPrivateKey := secp256k1.PrivKeyFromBytes(currentSignerPrivateKeyBytes) + currentSignerPublicKeyBytes := currentSignerPrivateKey.PubKey().SerializeCompressed() + + return ComputeFinalSignature( + Musig2v100, + data, + currentSignerPrivateKeyBytes, + [][]byte{ + currentSignerPublicKeyBytes, + otherSigner1PublicKeyBytes, + otherSigner2PublicKeyBytes, + }, + [][]byte{otherSigner1PublicNonceBytes, otherSigner2PublicNonceBytes}, + [][]byte{otherSigner1PartialSigBytes, otherSigner2PartialSigBytes}, + currentSignerSessionID, + tweak, + ) +} diff --git a/libwallet/musig/musig2_3_of_3_test.go b/libwallet/musig/musig2_3_of_3_test.go new file mode 100644 index 00000000..f4b11dc0 --- /dev/null +++ b/libwallet/musig/musig2_3_of_3_test.go @@ -0,0 +1,396 @@ +package musig + +import ( + "crypto/sha256" + "encoding/hex" + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/decred/dcrd/dcrec/secp256k1/v4" + "github.com/stretchr/testify/require" +) + +func TestCombineAndSign(t *testing.T) { + roundTrip(t, nil) +} + +func TestCombineAndSignWithScriptPath(t *testing.T) { + scriptPath := RandomSessionID() // any random 32 bytes + roundTrip(t, scriptPath[:]) +} + +// TestDroppingScriptPathFails asserts that combining pubKeys with a scriptPath +// fails to produce a valid signature when the scriptPath is not given to the +// signing functions. +func TestDroppingScriptPathFails(t *testing.T) { + session := newRandomSigningSession(t) + scriptPath := RandomSessionID() // any random 32 bytes + toSign := sha256.Sum256([]byte("hole")) + + tweakedKey, _ := session.combinedKeys(t, TapScriptTweak(scriptPath[:])) + + // partial and final signatures computed without the scriptPath yield a + // signature that does not verify against the scriptPath-committed combined key + muunPartialSignature, swapServerPartialSignature, fullSignature := session.sign( + t, toSign[:], KeySpendOnlyTweak(), + ) + + valid, err := VerifySignature(Musig2v100, toSign[:], tweakedKey, fullSignature) + require.NoError(t, err) + require.False(t, valid) + + // finalizing with the scriptPath while the partials omitted it must fail loudly + _, err = ComputeFinalSignature( + Musig2v100, + toSign[:], + session.userKey, + session.allPubKeys, + [][]byte{session.muunNonce, session.swapServerNonce}, + [][]byte{muunPartialSignature, swapServerPartialSignature}, + session.userSessionID, + TapScriptTweak(scriptPath[:]), + ) + require.Error(t, err) +} + +// TestComputePartialSignatureMissingNonceFails asserts that, in a 3-signer +// session, providing the nonce of only one of the two other participants fails +// instead of producing a partial signature. +func TestComputePartialSignatureMissingNonceFails(t *testing.T) { + t.Parallel() + + session := newRandomSigningSession(t) + toSign := sha256.Sum256([]byte("muun-3-of-3")) + + _, err := ComputePartialSignature( + Musig2v100, + toSign[:], + session.muunKey, + session.allPubKeys, + [][]byte{session.userNonce}, + session.muunSessionID, + KeySpendOnlyTweak(), + ) + require.ErrorContains(t, err, "some nonces are missing") +} + +// TestComputeFinalSignatureMissingPartialSignatureFails asserts that, in a +// 3-signer session, combining the partial signature of only one of the two +// other participants fails instead of producing a final signature. +func TestComputeFinalSignatureMissingPartialSignatureFails(t *testing.T) { + t.Parallel() + + session := newRandomSigningSession(t) + toSign := sha256.Sum256([]byte("muun-3-of-3")) + + muunPartialSignature, err := ComputePartialSignature( + Musig2v100, + toSign[:], + session.muunKey, + session.allPubKeys, + [][]byte{session.userNonce, session.swapServerNonce}, + session.muunSessionID, + KeySpendOnlyTweak(), + ) + require.NoError(t, err) + + _, err = ComputeFinalSignature( + Musig2v100, + toSign[:], + session.userKey, + session.allPubKeys, + [][]byte{session.muunNonce, session.swapServerNonce}, + [][]byte{muunPartialSignature}, + session.userSessionID, + KeySpendOnlyTweak(), + ) + require.ErrorContains(t, err, "some signatures are still missing") +} + +func TestCrossCheckWithJavaWithKeySpendTweak(t *testing.T) { + t.Parallel() + + crossCheckWithJava( + t, + KeySpendOnlyTweak(), + "022ae899ba133dfde5661bdc53045f0c0aeffce6174a33c29c4aa1265e425e3e77", + "d66bf9847ecf4837d3a6f9f591c4f64c053c03c79f971aa88ceb6cd7edad641b", + "2cd6944762b86f502dc3762d35a338032846650c56f097aa46dd503d7f48c722", + "a394cdca042d08ab1c25ead40deabe5eb1297d705b666cb93ae73e198dfbbd7ea37b005126142a57c0a8a07a779df7f13589141e2058253800fc5463b17c2ad5", //nolint:lll + ) +} + +func TestCrossCheckWithJavaWithTapScriptTweak(t *testing.T) { + t.Parallel() + + crossCheckWithJava( + t, + TapScriptTweak( + hexDecode("7c2e1f3b5a4d6c8e9f0a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60"), + ), + "03db66ccb93a4f2f40825206182c4ccb79d14b2f15801140bdfdf86476eb060cd4", + "58ca36bf7bc2d4a769185696e28bce6e1a7d42ce5a84a02ead2680deb988b95d", + "a04a189d0f1336c73cf8a0fb6d906b46587b1df69b034163d2e1f2d873fcd0ca", + "bc63134cb4f8b0747bb625f893c863faaf8fed56e377e167fe8f7739ef974716ce833a3d605b576abbe5e76850b9f3a9e5ac4e3dc4e25651045c372d65423014", //nolint:lll + ) +} + +func TestCrossCheckWithJavaWithNoopTweak(t *testing.T) { + t.Parallel() + + crossCheckWithJava( + t, + NoopTweak(), + // Noop applies no tweak, so the combined key equals the internal key. + "03495c41e6968547775527ad75bce0a011d7a06d4647c90269c654638d926b3cf4", + "3836bef4525d6c1ff5f0aae1f080ef4693b804dfe8b3fcefe8126b9dc2c37a02", + "b1105ae9f360ba019ce56f31bcd4dbf8d1cb7d5938862633d00defc8521e3729", + "06629b3ab9f851069e85412345e88c23869c921ab2c27ddc4ad7268ef7ca8711d3544e1279106bbc0a303363beb8bc0afac262eeda93f88413cfc85d4de64373", //nolint:lll + ) +} + +// roundTrip drives a full 3-of-3 round trip with random keys: muun and the +// swap server each produce a partial signature, then the user signs last and +// combines everything. Asserts the resulting signature is valid and that +// tampering with it breaks verification. +func roundTrip(t *testing.T, scriptPath []byte) { + session := newRandomSigningSession(t) + toSign := sha256.Sum256([]byte("muun-3-of-3")) + + tweak := KeySpendOnlyTweak() + if scriptPath != nil { + tweak = TapScriptTweak(scriptPath) + } + + tweakedKey, _ := session.combinedKeys(t, tweak) + + _, _, fullSignature := session.sign(t, toSign[:], tweak) + + assertValidSignature(t, toSign[:], tweakedKey, fullSignature) +} + +// pinnedMsg is the message signed by all the pinned cross-check vectors. +var pinnedMsg = hexDecode( + "0102030000000000000000000000000000000000000000000000000000000000") + +// crossCheckWithJava drives the full 3-signer flow against pinned vectors: +// the combined keys, public nonces, partial signatures and final signature +// are asserted byte-exact. The same vectors are pinned by testCrossCheckWithLibwallet +// in backend-libs/taproot4j's Musig3Of3Test, so both sides of the JNI bridge +// assert the same contract. +func crossCheckWithJava( + t *testing.T, + tweak *MuSig2Tweaks, + expectedCombinedPub string, + expectedMuunPartialSignature string, + expectedSwapServerPartialSignature string, + expectedFullSignature string, +) { + t.Helper() + + session := newPinnedSigningSession(t) + + tweakedKey, internalKey := session.combinedKeys(t, tweak) + require.Equal(t, expectedCombinedPub, hex.EncodeToString(tweakedKey)) + + // the internal key is the aggregate before applying the taproot tweak, + // so it is the same for both vectors + require.Equal( + t, + "03495c41e6968547775527ad75bce0a011d7a06d4647c90269c654638d926b3cf4", + hex.EncodeToString(internalKey), + ) + + // nonce generation does not depend on the scriptPath tweak, so the expected + // nonces are the same for both vectors + require.Equal(t, "033851b4c20a8d2724c0beb2aa9d870fb40b9808953fa0210b9e0968634e7df300027d3c122a2b5ef8a99e272252439b5c2973a3672154c1f75dac65d373a3f280dd", hex.EncodeToString(session.userNonce)) //nolint:lll + require.Equal(t, "039d31580aa30e79b174b2b0170cb20e8fcf90a67e6cd26cff43e91f521a62d658025706076ac9aff93b1804499c7245f4d1bc684d4a29fb312cb1ffb654c9c616f1", hex.EncodeToString(session.muunNonce)) //nolint:lll + require.Equal(t, "025e350dd6c5c9fa2717ac5270f01ad7b70cb6676d60d847e0f5b61d8b89b77d6c02fc9aa7163d82d55c8fa97305755976358b13c9e577dc82aeeef8064f84d1e41e", hex.EncodeToString(session.swapServerNonce)) //nolint:lll + + muunPartialSignature, swapServerPartialSignature, fullSignature := session.sign( + t, pinnedMsg, tweak, + ) + require.Equal( + t, + expectedMuunPartialSignature, + hex.EncodeToString(muunPartialSignature), + ) + require.Equal( + t, + expectedSwapServerPartialSignature, + hex.EncodeToString(swapServerPartialSignature), + ) + require.Equal(t, expectedFullSignature, hex.EncodeToString(fullSignature)) + + assertValidSignature(t, pinnedMsg, tweakedKey, fullSignature) +} + +// signingSession holds the private keys, session ids and public +// nonces of the three participants of a 3-of-3 signing session. +type signingSession struct { + userKey []byte + muunKey []byte + swapServerKey []byte + allPubKeys [][]byte + userSessionID []byte + muunSessionID []byte + swapServerSessionID []byte + userNonce []byte + muunNonce []byte + swapServerNonce []byte +} + +// newRandomSigningSession builds a session with random keys and session ids. +func newRandomSigningSession(t *testing.T) *signingSession { + t.Helper() + + userKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + muunKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + swapServerKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + userSessionID := RandomSessionID() + muunSessionID := RandomSessionID() + swapServerSessionID := RandomSessionID() + + return newSigningSession( + t, + userKey.Serialize(), muunKey.Serialize(), swapServerKey.Serialize(), + userSessionID[:], muunSessionID[:], swapServerSessionID[:], + ) +} + +// newPinnedSigningSession builds the fixed session used by the pinned vector +// tests. +func newPinnedSigningSession(t *testing.T) *signingSession { + t.Helper() + + return newSigningSession( + t, + hexDecode("6e39c6add6323a5ac5f65e50231fb815026476e734eb9f4f66dce3298fddf1dc"), + hexDecode("b876ecf97c19588cf4be95ddc0b06c0d9f623f2cf679276c25e4dfb512b19743"), + hexDecode("c9b6e3a1f2d4857b0e9c6d3f5a8b1724e6f0a9d8c7b5e4f3a2918070605040e3"), + hexDecode("52fdfc072182654f163f5f0f9a621d729566c74d10037c4d7bbb0407d1e2c649"), + hexDecode("81855ad8681d0d86d1e91e00167939cb6694d2c422acd208a0072939487f6999"), + hexDecode("eb9d18a44784045d87f3c67cf22746e995af5a25367951baa2ff6cd471c483f1"), + ) +} + +func newSigningSession( + t *testing.T, + userKey, muunKey, swapServerKey []byte, + userSessionID, muunSessionID, swapServerSessionID []byte, +) *signingSession { + t.Helper() + + allPubKeys := [][]byte{ + secp256k1.PrivKeyFromBytes(userKey).PubKey().SerializeCompressed(), + secp256k1.PrivKeyFromBytes(muunKey).PubKey().SerializeCompressed(), + secp256k1.PrivKeyFromBytes(swapServerKey).PubKey().SerializeCompressed(), + } + + userNonce, err := MuSig2GenerateNonce(Musig2v100, userSessionID, allPubKeys[0]) + require.NoError(t, err) + + muunNonce, err := MuSig2GenerateNonce(Musig2v100, muunSessionID, allPubKeys[1]) + require.NoError(t, err) + + swapServerNonce, err := MuSig2GenerateNonce(Musig2v100, swapServerSessionID, allPubKeys[2]) + require.NoError(t, err) + + return &signingSession{ + userKey: userKey, + muunKey: muunKey, + swapServerKey: swapServerKey, + allPubKeys: allPubKeys, + userSessionID: userSessionID, + muunSessionID: muunSessionID, + swapServerSessionID: swapServerSessionID, + userNonce: userNonce.PubNonce[:], + muunNonce: muunNonce.PubNonce[:], + swapServerNonce: swapServerNonce.PubNonce[:], + } +} + +// combinedKeys combines the three participant keys with the given tweak and +// returns the compressed tweaked and non-tweaked keys. +func (s *signingSession) combinedKeys(t *testing.T, tweak *MuSig2Tweaks) ([]byte, []byte) { + t.Helper() + + combinedKey, err := Musig2CombinePubKeysWithTweak(Musig2v100, s.allPubKeys, tweak) + require.NoError(t, err) + + return combinedKey.FinalKey.SerializeCompressed(), + combinedKey.PreTweakedKey.SerializeCompressed() +} + +// sign runs the full 3-of-3 signing flow with the given tweak: muun and the +// swap server each produce a partial signature, then the user signs last and +// combines everything. Returns the muun and swap server partial signatures and +// the full signature. +func (s *signingSession) sign( + t *testing.T, + msg []byte, + tweak *MuSig2Tweaks, +) ([]byte, []byte, []byte) { + t.Helper() + + muunPartialSignature, err := ComputePartialSignature3Of3( + msg, + s.muunKey, + s.allPubKeys[0], + s.allPubKeys[2], + s.userNonce, + s.swapServerNonce, + s.muunSessionID, + tweak, + ) + require.NoError(t, err) + + swapServerPartialSignature, err := ComputePartialSignature3Of3( + msg, + s.swapServerKey, + s.allPubKeys[0], + s.allPubKeys[1], + s.userNonce, + s.muunNonce, + s.swapServerSessionID, + tweak, + ) + require.NoError(t, err) + + fullSignature, err := ComputeFinalSignature3Of3( + msg, + s.userKey, + s.allPubKeys[1], + s.allPubKeys[2], + s.muunNonce, + s.swapServerNonce, + muunPartialSignature, + swapServerPartialSignature, + s.userSessionID, + tweak, + ) + require.NoError(t, err) + + return muunPartialSignature, swapServerPartialSignature, fullSignature +} + +// assertValidSignature asserts the signature verifies against the combined key +// and that tampering with it breaks verification. +func assertValidSignature(t *testing.T, msg, combinedKey, signature []byte) { + t.Helper() + + valid, err := VerifySignature(Musig2v100, msg, combinedKey, signature) + require.NoError(t, err) + require.True(t, valid) + + // ensure verification is not `() -> true` + tampered := append([]byte{}, signature...) + tampered[1] = ^tampered[1] + valid, err = VerifySignature(Musig2v100, msg, combinedKey, tampered) + require.NoError(t, err) + require.False(t, valid) +} diff --git a/libwallet/musig/musig2_bip32.go b/libwallet/musig/musig2_bip32.go index 6c0a72c0..584f6cb8 100644 --- a/libwallet/musig/musig2_bip32.go +++ b/libwallet/musig/musig2_bip32.go @@ -4,11 +4,11 @@ import ( "crypto/hmac" "crypto/sha512" "encoding/binary" - "fmt" "github.com/btcsuite/btcd/btcec/v2" musig2v100 "github.com/btcsuite/btcd/btcec/v2/schnorr/musig2" "github.com/decred/dcrd/dcrec/secp256k1/v4" + "github.com/go-errors/errors" ) type bip32TweakStep struct { @@ -18,12 +18,14 @@ type bip32TweakStep struct { tweakBytes [32]byte } -// The following function receives an aggregatedKey and the unhardened -// derivation steps to produce a list of MuSig KeyTweaks. Those tweaks are used -// to produce a valid signature for the fresly derived key. To only derive the -// xpub, BIP32 can be used with a special chaincode. See the tests or BIP328 -// for details. -func getBip32TweaksForAggregatedKey(aggregatedKey *secp256k1.PublicKey, path []uint32) (*bip32TweakStep, []musig2v100.KeyTweakDesc, error) { +// The following function receives an aggregatedKey and the unhardened derivation steps to produce a +// list of MuSig KeyTweaks. Those tweaks are used to produce a valid signature for the fresly +// derived key. To only derive the xpub, BIP32 can be used with a special chaincode. +// See the tests or BIP328 for details. +func getBip32TweaksForAggregatedKey( + aggregatedKey *secp256k1.PublicKey, + path []uint32, +) (*bip32TweakStep, []musig2v100.KeyTweakDesc, error) { // chainCode := SHA256("MuSig2MuSig2MuSig2") chainCode := []byte{ 0x86, 0x80, 0x87, 0xca, 0x02, 0xa6, 0xf9, 0x74, @@ -61,11 +63,11 @@ func getBip32TweaksForAggregatedKey(aggregatedKey *secp256k1.PublicKey, path []u func (parent *bip32TweakStep) child(i uint32) (*bip32TweakStep, error) { // Prevent derivation of children beyond the max allowed depth. if parent.depth == 255 { - return nil, fmt.Errorf("trying to derive avobe max depth") + return nil, errors.Errorf("trying to derive avobe max depth") } if i >= 0x80000000 { - return nil, fmt.Errorf("trying to derive a hardened MuSig key") + return nil, errors.Errorf("trying to derive a hardened MuSig key") } // let data = serialize(parentPubKey) || serializeU32(i) @@ -97,7 +99,7 @@ func (parent *bip32TweakStep) child(i uint32) (*bip32TweakStep, error) { ilNum := new(btcec.ModNScalar) overflows := ilNum.SetBytes((*[32]byte)(il)) if overflows > 0 { - return nil, fmt.Errorf("generated IL overflows P %d", overflows) + return nil, errors.Errorf("generated IL overflows P %d", overflows) } // Convert the serialized compressed parent public key into X diff --git a/libwallet/musig/musig2_bip32_test.go b/libwallet/musig/musig2_bip32_test.go index 7d5b4b54..49223ad3 100644 --- a/libwallet/musig/musig2_bip32_test.go +++ b/libwallet/musig/musig2_bip32_test.go @@ -129,9 +129,7 @@ func TestMuSig2Bip32UnhardenedSignatureTaprootTweak2(t *testing.T) { // This test ensures that the MuSig helpers can successfully derive an // unhardened path from an aggregated key. It also ensures that the helpers can // successfully create a valid signature for it -// -// This test also ensures that the following output descriptor can be paid and -// redeemed. +// This test also ensures that the following output descriptor can be paid and redeemed. // // Output descriptor of this test: // @@ -156,30 +154,42 @@ func TestMuSig2Bip328UnhardenedTapscript(t *testing.T) { } // derive musig(user,muun)/1/2 - agg_1_2, err := MuSig2ComputeInternalKey(musigVersion, pubKeys, []uint32{1, 2}) + agg_1_2, err := MuSig2ComputeInternalKey( //nolint:staticcheck // TODO: should not use underscores in Go names; var agg_1_2 should be agg1_2 + musigVersion, + pubKeys, + []uint32{1, 2}, + ) require.NoError(t, err) - signerCombinedPubKey_1_2 := agg_1_2.FinalKey + signerCombinedPubKey_1_2 := agg_1_2.FinalKey //nolint:staticcheck // TODO: should not use underscores in Go names; var signerCombinedPubKey_1_2 should be signerCombinedPubKey1_2 // derive musig(user,muun)/88 - agg_88, err := MuSig2ComputeInternalKey(musigVersion, pubKeys, []uint32{88}) + agg_88, err := MuSig2ComputeInternalKey( //nolint:staticcheck // TODO: should not use underscores in Go names; var agg_88 should be agg88 + musigVersion, + pubKeys, + []uint32{88}, + ) require.NoError(t, err) - signerCombinedPubKey_88 := agg_88.FinalKey + signerCombinedPubKey_88 := agg_88.FinalKey //nolint:staticcheck // TODO: should not use underscores in Go names; var signerCombinedPubKey_88 should be signerCombinedPubKey88 // derive musig(user,muun)/123 internalKeyAgg, err := MuSig2ComputeInternalKey(musigVersion, pubKeys, []uint32{1, 2}) require.NoError(t, err) internalKey := internalKeyAgg.FinalKey - // We're going to commit to a script and spend the output using the - // script. This is just an OP_CHECKSIG with the combined MuSig2 public - // key. - leaf_88 := testScriptSchnorrSig(t, signerCombinedPubKey_88) - leaf_1_2 := testScriptSchnorrSig(t, signerCombinedPubKey_1_2) + // We're going to commit to a script and spend the output using the script. This is just an + // OP_CHECKSIG with the combined MuSig2 public key. + leaf_88 := testScriptSchnorrSig( //nolint:staticcheck // TODO: should not use underscores in Go names; var leaf_88 should be leaf88 + t, + signerCombinedPubKey_88, + ) + leaf_1_2 := testScriptSchnorrSig( //nolint:staticcheck // TODO: should not use underscores in Go names; var leaf_1_2 should be leaf1_2 + t, + signerCombinedPubKey_1_2, + ) tapScriptTree := txscript.AssembleTaprootScriptTree(leaf_88, leaf_1_2) - // Create final key for tr(musig(user,muun)/123, {...}) applying taproot - // tweak bytes for tapscript.rootMerlkeHash and bip32 tweaks for /123 - // derivation path + // Create final key for tr(musig(user,muun)/123, {...}) applying taproot tweak bytes for + // tapscript.rootMerlkeHash and bip32 tweaks for /123 derivation path rootMerkleHash := tapScriptTree.RootNode.TapHash() tweak := @@ -217,13 +227,8 @@ func TestMuSig2Bip328UnhardenedTapscript(t *testing.T) { }, }, { - // tr( - // musig(userKey, muunKey)/123, - // { - // musig(userKey, muunKey)/88, <------- redeem - // musig(userKey, muunKey)/1/2, - // } - // ) + // tr( musig(userKey, muunKey)/123, { musig(userKey, muunKey)/88, <------- redeem + // musig(userKey, muunKey)/1/2, } ) description: "tapscript with musig(user,muun)/88", internalKey: internalKey, @@ -264,9 +269,13 @@ func TestMuSig2Bip328UnhardenedTapscript(t *testing.T) { } } -// implementation of bip32 for musig, the naive way. use this function to validate -// against implementated code -func nativeBip32MusigDerivation(t *testing.T, aggregatedKey *secp256k1.PublicKey, path []uint32) *secp256k1.PublicKey { +// implementation of bip32 for musig, the naive way. use this function to validate against +// implementated code +func nativeBip32MusigDerivation( + t *testing.T, + aggregatedKey *secp256k1.PublicKey, + path []uint32, +) *secp256k1.PublicKey { chainCode, _ := hex.DecodeString( "868087ca02a6f974c4598924c36b57762d32cb45717167e300622c7167e38965") diff --git a/libwallet/musig/musig2_test.go b/libwallet/musig/musig2_test.go index 8cb27656..6017f7de 100644 --- a/libwallet/musig/musig2_test.go +++ b/libwallet/musig/musig2_test.go @@ -8,6 +8,7 @@ import ( "github.com/btcsuite/btcd/btcec/v2" "github.com/decred/dcrd/dcrec/secp256k1/v4" + "github.com/go-errors/errors" "github.com/stretchr/testify/require" ) @@ -39,8 +40,8 @@ func TestMuSig2Tests2of2(t *testing.T) { muunKey string tweak *MuSig2Tweaks msg string - muunSessionId string - userSessionId string + muunSessionID string + userSessionID string combinedPubUntweaked string combinedPub string userNonce string @@ -54,14 +55,14 @@ func TestMuSig2Tests2of2(t *testing.T) { muunKey: "b6f14c73ee5269f5a13a11f48ad54306293ee134e924f680fcd35f615881105b", tweak: KeySpendOnlyTweak(), msg: "ef2ecc1f48c0b28ccaf8f3a8c6477740d869964ebc152a2c5f93f19e7b84b103", - userSessionId: "5c9360026e39ad06251a27916dcf086a7b2deb6789c5dcd75ba10e540cf37e13", - muunSessionId: "cad3ec6737e2fb125d976bfe382441c59c6a4d46382bfab75e9d3f1b43a9b0a7", + userSessionID: "5c9360026e39ad06251a27916dcf086a7b2deb6789c5dcd75ba10e540cf37e13", + muunSessionID: "cad3ec6737e2fb125d976bfe382441c59c6a4d46382bfab75e9d3f1b43a9b0a7", combinedPubUntweaked: "5ecd943b359fa0c52ba88c0395ca7f7bfa256c8d3c63609527856768185cab16", combinedPub: "a2d0d99f9f2706846b18f070aaf95afb579332da15a58162987e48f59a90bb59", - userNonce: "03ce5914d3fd813391318ce9706227f325cb272a352b900be8bc9e813911f188b802ef1739fc0ca286ac81773daca310f6ff750f8ac0bbf11a253dcdcac93d2ede79", - muunNonce: "0294690a525328949fed6272f8308e48f7639ee270d83cad1d08b66e870d91a46e02e1a957ad994a6cbe6ee3db26a91e9031a38b92ec10b272ffd6cf8a303784bdb3", + userNonce: "03ce5914d3fd813391318ce9706227f325cb272a352b900be8bc9e813911f188b802ef1739fc0ca286ac81773daca310f6ff750f8ac0bbf11a253dcdcac93d2ede79", //nolint:lll + muunNonce: "0294690a525328949fed6272f8308e48f7639ee270d83cad1d08b66e870d91a46e02e1a957ad994a6cbe6ee3db26a91e9031a38b92ec10b272ffd6cf8a303784bdb3", //nolint:lll muunPartialSignature: "ef83958ce09b6859756a114a6e83d5654fb559064b6f21d78ddfd89e35f2a41c", - fullSignature: "6eda42c24fd743f88749c0ee662491fb5462db1f1e3bf3572eb75f4ae6ef6b19991c9d55038c56cb64132913b0ff8dc57bced644ed2ebbc8b3a3616ba373082d", + fullSignature: "6eda42c24fd743f88749c0ee662491fb5462db1f1e3bf3572eb75f4ae6ef6b19991c9d55038c56cb64132913b0ff8dc57bced644ed2ebbc8b3a3616ba373082d", //nolint:lll }, { name: "sanity 1 (v100)", version: Musig2v100, @@ -69,14 +70,14 @@ func TestMuSig2Tests2of2(t *testing.T) { muunKey: "b6f14c73ee5269f5a13a11f48ad54306293ee134e924f680fcd35f615881105b", tweak: KeySpendOnlyTweak(), msg: "ef2ecc1f48c0b28ccaf8f3a8c6477740d869964ebc152a2c5f93f19e7b84b103", - userSessionId: "5c9360026e39ad06251a27916dcf086a7b2deb6789c5dcd75ba10e540cf37e13", - muunSessionId: "cad3ec6737e2fb125d976bfe382441c59c6a4d46382bfab75e9d3f1b43a9b0a7", - combinedPubUntweaked: "03515ffb8569741ba605a113f9eb99bc81f9daacc8923d2578a31177e78aa0463c", // differs from 040 - combinedPub: "03c367d7ef80b10687820dda279e0e6054dadccc30550c8eae7e21a945069cb7e0", // differs from 040 - userNonce: "02b2c9e32786ab8612b4805e99f56086588d199486b7da5af99655402ffcc68cbd02aa0b18c2026742ed309fe00656cd9e68402bb10dea6aac05e69400f6f6c70ee5", - muunNonce: "0371c084c83326c3c721aeeefdf647d19d1417eba0ac9a09023bb363300eaeba7702539c3793f6a1a130b86a3168dbdb9ed686b04fdde57250dea20424047bc037ec", + userSessionID: "5c9360026e39ad06251a27916dcf086a7b2deb6789c5dcd75ba10e540cf37e13", + muunSessionID: "cad3ec6737e2fb125d976bfe382441c59c6a4d46382bfab75e9d3f1b43a9b0a7", + combinedPubUntweaked: "03515ffb8569741ba605a113f9eb99bc81f9daacc8923d2578a31177e78aa0463c", // differs from 040 //nolint:lll + combinedPub: "03c367d7ef80b10687820dda279e0e6054dadccc30550c8eae7e21a945069cb7e0", // differs from 040 //nolint:lll + userNonce: "02b2c9e32786ab8612b4805e99f56086588d199486b7da5af99655402ffcc68cbd02aa0b18c2026742ed309fe00656cd9e68402bb10dea6aac05e69400f6f6c70ee5", //nolint:lll + muunNonce: "0371c084c83326c3c721aeeefdf647d19d1417eba0ac9a09023bb363300eaeba7702539c3793f6a1a130b86a3168dbdb9ed686b04fdde57250dea20424047bc037ec", //nolint:lll muunPartialSignature: "18422b132ac447af9e98db197d45becb26c83aa4fa658312dd8357e1e8309ce4", - fullSignature: "5e9034fe55b901308dd4751855e50a2181ec264b76fb5a986c87e78084202a9a83588f0e45e103645a01f313d7f3b03a5fcc5bc68ff7c41ae4d451271441d20b", + fullSignature: "5e9034fe55b901308dd4751855e50a2181ec264b76fb5a986c87e78084202a9a83588f0e45e103645a01f313d7f3b03a5fcc5bc68ff7c41ae4d451271441d20b", //nolint:lll }, { name: "sanity 2 (v040)", version: Musig2v040Muun, @@ -84,27 +85,29 @@ func TestMuSig2Tests2of2(t *testing.T) { muunKey: "36bae62a1f853f8e0fd06a8dc92e40b279c8661cf331c098ee384430d51e0908", tweak: KeySpendOnlyTweak(), msg: "fc5b875bf3cba4b01ff541b30029c3a2b40d8839c4e5a59d17b83d5ea0746a00", - userSessionId: "7097c074c9e821e9f6f8b2305bd79cd4e244c1968e139704261954cb6195fdb8", - muunSessionId: "577515cff85d3574a8f5f407b8017b3e1f7afeb604244ca5ddd946e6c4a9360a", + userSessionID: "7097c074c9e821e9f6f8b2305bd79cd4e244c1968e139704261954cb6195fdb8", + muunSessionID: "577515cff85d3574a8f5f407b8017b3e1f7afeb604244ca5ddd946e6c4a9360a", combinedPub: "d5ed5fbfd6598566ff101c671e7e2194604bd7b4aaa2e6913d691794ceaee3fd", - userNonce: "03162fcde2205a9e7c44ad646c904afb879ae8ae1e93086e245e9b152734c762a802bd70bfd7c80c7d5e7e91017920603916bb8475ee81d0f09d2049d514b12ae82e", - muunNonce: "028d5ce815841f0001b69f7cc82bb70ae1295a5bb51dfda73d0991b91216a6becd03238845a40801636793ddb0dd1ff5104a6e9afd4db4a439b5cc98e227785e079e", + userNonce: "03162fcde2205a9e7c44ad646c904afb879ae8ae1e93086e245e9b152734c762a802bd70bfd7c80c7d5e7e91017920603916bb8475ee81d0f09d2049d514b12ae82e", //nolint:lll + muunNonce: "028d5ce815841f0001b69f7cc82bb70ae1295a5bb51dfda73d0991b91216a6becd03238845a40801636793ddb0dd1ff5104a6e9afd4db4a439b5cc98e227785e079e", //nolint:lll muunPartialSignature: "6b2e8c7878882e22a7dbdd1e89607105b2e64e95f72943a38908f9643900e11b", - fullSignature: "c1f24ddb83f8e6caf6fffc454a52f1e9dd67d9cfbccce309f0253361b94b7edcd6df44317e272f47a8a6dd823a65b7380055088bab914432f99ad92c277732aa", + fullSignature: "c1f24ddb83f8e6caf6fffc454a52f1e9dd67d9cfbccce309f0253361b94b7edcd6df44317e272f47a8a6dd823a65b7380055088bab914432f99ad92c277732aa", //nolint:lll }, { - name: "sanity 3 (v100)", - version: Musig2v100, - userKey: "e3be9721e2f3422d15a959b371b340c1f197fd9eb8a38b3f1801a759261f6766", - muunKey: "399508d30aa80e152bcfb6524ff900793d6a7ba0db0c1f6f74a80aca7482f131", - tweak: TapScriptTweak(hexDecode("83997adeb6df251a624c330f9f9b5dc1d825f1a5d73872d4aded55990f89a390")), + name: "sanity 3 (v100)", + version: Musig2v100, + userKey: "e3be9721e2f3422d15a959b371b340c1f197fd9eb8a38b3f1801a759261f6766", + muunKey: "399508d30aa80e152bcfb6524ff900793d6a7ba0db0c1f6f74a80aca7482f131", + tweak: TapScriptTweak( + hexDecode("83997adeb6df251a624c330f9f9b5dc1d825f1a5d73872d4aded55990f89a390"), + ), msg: "d1752376fb27655a076f4dc2d3d1154f48faf98a2b3cde703b2c7c3bd6ca1054", - userSessionId: "d3fb624d2253acde57525b5b1b9ea4fba686de69acbb4ecabc87800a8fdd94b3", - muunSessionId: "dbab8752a87f26c85d9dad982a8d4b0e399079c6a9bf400cd2619d3422eee446", + userSessionID: "d3fb624d2253acde57525b5b1b9ea4fba686de69acbb4ecabc87800a8fdd94b3", + muunSessionID: "dbab8752a87f26c85d9dad982a8d4b0e399079c6a9bf400cd2619d3422eee446", combinedPub: "02128ded81436e25b77192b02771729eb9417b1c0bac848a657316cb69ead4d2fc", - userNonce: "022716e86001574bd783eafba6e4faf580760b5a5289f4481d198a8abf39af9c5903b5c0740167645a66f4ba37a8f090d96df4690de004d39525ccb320f59a5df7c6", - muunNonce: "0217f8ea11abe0b92070a43f7e2553f37f4afc7905165e190b266ed6f1aca2e76f02976491a364e4459fee8fae9bc82d0f3b08385607adfda238833d45ddc33be66e", + userNonce: "022716e86001574bd783eafba6e4faf580760b5a5289f4481d198a8abf39af9c5903b5c0740167645a66f4ba37a8f090d96df4690de004d39525ccb320f59a5df7c6", //nolint:lll + muunNonce: "0217f8ea11abe0b92070a43f7e2553f37f4afc7905165e190b266ed6f1aca2e76f02976491a364e4459fee8fae9bc82d0f3b08385607adfda238833d45ddc33be66e", //nolint:lll muunPartialSignature: "a4843772adac7976e3133b68e4bf03dd93e9d233db9193a2e65ca2b352ae82d1", - fullSignature: "5dfafb89a5b7665a16f5391ada533e0c5f1283e126459e18c502383b21e60ede2b0c51206357a4974cd468cd8d6e9091b7fdcf82c33269e11bdba3e08286891a", + fullSignature: "5dfafb89a5b7665a16f5391ada533e0c5f1283e126459e18c502383b21e60ede2b0c51206357a4974cd468cd8d6e9091b7fdcf82c33269e11bdba3e08286891a", //nolint:lll }, { name: "sanity 4 (v040)", version: Musig2v040Muun, @@ -112,28 +115,30 @@ func TestMuSig2Tests2of2(t *testing.T) { muunKey: "234221af43d249604ed3972c958677fb25e453e57cd808e2b32901dc6936fe94", tweak: KeySpendOnlyTweak(), msg: "21a87f41d45b0a74d2d49328b580204f8b027ee4bb73bddfdf5388db8ab6bb49", - userSessionId: "b7aed480f9248d27d64744e5680ad91c6111d420a1e7c47db104b30fbc513899", - muunSessionId: "f7f7a39a0b434e4b9151f4f9797e12c4c1a8dc323c9efc4edd48ba6674c33762", + userSessionID: "b7aed480f9248d27d64744e5680ad91c6111d420a1e7c47db104b30fbc513899", + muunSessionID: "f7f7a39a0b434e4b9151f4f9797e12c4c1a8dc323c9efc4edd48ba6674c33762", combinedPubUntweaked: "ff591962a3be86cd58360ddd19212ce355a6fdd4566c219fe6bf0f6883a67f97", combinedPub: "6fbfbe50f81503feb8163d01229e6e69d5a959186a0aa2d6e6881042fa0e0022", - userNonce: "0341f6cafeda073092125d7752f1d06f5d50dc8caf43eee212e2caf0ace8c03d4202c8e244bbc650734b8f186512b69798bb9d54a190a5c77a0d353068fe4806049d", - muunNonce: "03a3095a17a728b36f78d5428e397fcd4d7aaa1fd3d4aaaa2fb95a98a4092385b30361d094f79948ecd771bbf3f99a5fcd43bd023a3ea1bed8492213f4cf2327f4f7", + userNonce: "0341f6cafeda073092125d7752f1d06f5d50dc8caf43eee212e2caf0ace8c03d4202c8e244bbc650734b8f186512b69798bb9d54a190a5c77a0d353068fe4806049d", //nolint:lll + muunNonce: "03a3095a17a728b36f78d5428e397fcd4d7aaa1fd3d4aaaa2fb95a98a4092385b30361d094f79948ecd771bbf3f99a5fcd43bd023a3ea1bed8492213f4cf2327f4f7", //nolint:lll muunPartialSignature: "a15f67ea4bfcb77b19cab3e44e37a9e03eb9921b80cdc8b3bf7d8a346af9139f", - fullSignature: "794e2dba30eacf59048559d471c382929b99ff86f657a760086492cdcb0c3a39e1f3d59367c585e083d3eadcdf8253b51c446f9d350c522ce7b2a5174b8b3513", + fullSignature: "794e2dba30eacf59048559d471c382929b99ff86f657a760086492cdcb0c3a39e1f3d59367c585e083d3eadcdf8253b51c446f9d350c522ce7b2a5174b8b3513", //nolint:lll }, { - name: "sanity 5 (v100)", - version: Musig2v100, - userKey: "856067e727dda8e93fe2d92b481c2eb0726c5d70074c134567fd9870138fa9a9", - muunKey: "d65faacf0e9c75741cd487e9c0d17fa5fb23abb117b0b2d3817dbc9cc9a89eac", - tweak: TapScriptTweak(hexDecode("ce535b4f4059c9414d44afa5582b5650e6693739be869fda6c186cb2add3df04")), + name: "sanity 5 (v100)", + version: Musig2v100, + userKey: "856067e727dda8e93fe2d92b481c2eb0726c5d70074c134567fd9870138fa9a9", + muunKey: "d65faacf0e9c75741cd487e9c0d17fa5fb23abb117b0b2d3817dbc9cc9a89eac", + tweak: TapScriptTweak( + hexDecode("ce535b4f4059c9414d44afa5582b5650e6693739be869fda6c186cb2add3df04"), + ), msg: "8ca8fbf5661b8cb7cf98eea348b8686c721b0d9d11f9ae7ea850322657c76dca", - userSessionId: "d74678ceb70ee4e875b52ab9a9b749d9882cfb3f184fbc13d9d96d1306ff9f54", - muunSessionId: "e92cd9db84ddea807401442036cc64878f03cdcc5030c78cecf187d4b166684f", + userSessionID: "d74678ceb70ee4e875b52ab9a9b749d9882cfb3f184fbc13d9d96d1306ff9f54", + muunSessionID: "e92cd9db84ddea807401442036cc64878f03cdcc5030c78cecf187d4b166684f", combinedPub: "02d26f1761a00851216ea9a87e479ab57bb03d38f7fd4d6d5a67f7954b7aed5517", - userNonce: "03d243bc9b76eb7212d9f71cfc8461fb3a32a0e72ffba9e41b056800722df329440363ab1202649637c2e6197f78edeb79bfc272a4f4ebac6d577baa6ec17eb8b7d2", - muunNonce: "03176ead83f618b25510ef0674d6a820fc23a6afe9ab6a507a68d251e0a615185203527ed0fa9ce1fd1c9993f41f52bd3edb441e069018a2637974558cde7a190cc2", + userNonce: "03d243bc9b76eb7212d9f71cfc8461fb3a32a0e72ffba9e41b056800722df329440363ab1202649637c2e6197f78edeb79bfc272a4f4ebac6d577baa6ec17eb8b7d2", //nolint:lll + muunNonce: "03176ead83f618b25510ef0674d6a820fc23a6afe9ab6a507a68d251e0a615185203527ed0fa9ce1fd1c9993f41f52bd3edb441e069018a2637974558cde7a190cc2", //nolint:lll muunPartialSignature: "7d8359f5159b7e3084000329abe5aa743d9535fa8c4ec37097b28d695371d6b2", - fullSignature: "4329e7aeb9467ace1af726b9f9101c4e4d7f776e348864cc9803a6928e83710de659b3bd81496e8497bed260d66070b982badd7eca20b3b32b2cb92f8c6fd902", + fullSignature: "4329e7aeb9467ace1af726b9f9101c4e4d7f776e348864cc9803a6928e83710de659b3bd81496e8497bed260d66070b982badd7eca20b3b32b2cb92f8c6fd902", //nolint:lll }, { name: "sanity 6 (v040)", version: Musig2v040Muun, @@ -141,13 +146,13 @@ func TestMuSig2Tests2of2(t *testing.T) { muunKey: "5ebf2750bdc86e49e589ddee207eab6638672e1e9d037a5461ae5fdc702f8621", tweak: KeySpendOnlyTweak(), msg: "8c664cb4843e6670d128469e3c5f53a131cd92939ba49efc015ae4d9db487e1b", - userSessionId: "eb8a7990a594347bbfbc1fc208230ba77e5aeb57968298ca989f278b675069ea", - muunSessionId: "cc9f909537f3ce40c7292a461910cf2133e3e2b1dbeee3cabe0964edffa45f2e", + userSessionID: "eb8a7990a594347bbfbc1fc208230ba77e5aeb57968298ca989f278b675069ea", + muunSessionID: "cc9f909537f3ce40c7292a461910cf2133e3e2b1dbeee3cabe0964edffa45f2e", combinedPub: "3aeb9ee01226a3e50f421d1472a064c5a0537034cc1770174834d8df44d4bac6", - userNonce: "02bdd624154b6b11a167a26aef832cfbd6d9d0fbeb2a6b1ef230502c92065474a00367af25653aa9f55e6b0a8b78824dd25a0af7945ebe4ed3da1d1b0f86b0f0fd54", - muunNonce: "022bdb069642c36ea5eb8f6ee4af5dafa602f5a1c4e87c1364dec9481d8769c7cd03940111b4ab28cbf1920360c80a4861eaabc0fbdf443ccb3f5e91c0ea95753ba4", + userNonce: "02bdd624154b6b11a167a26aef832cfbd6d9d0fbeb2a6b1ef230502c92065474a00367af25653aa9f55e6b0a8b78824dd25a0af7945ebe4ed3da1d1b0f86b0f0fd54", //nolint:lll + muunNonce: "022bdb069642c36ea5eb8f6ee4af5dafa602f5a1c4e87c1364dec9481d8769c7cd03940111b4ab28cbf1920360c80a4861eaabc0fbdf443ccb3f5e91c0ea95753ba4", //nolint:lll muunPartialSignature: "29ccc19ad440e90dd03e3011652af94a780e5ee9d5d2cb647fac0d8723f8f7e8", - fullSignature: "3bbf83075b8df8bfc1f493d9e51ed0eb22f419d22a3df6f9434fd666be9bacaf893b43fe38eff9c73c66f0525d2bc8b7a96cfd1ad5e145b97ed2e5a9813c53af", + fullSignature: "3bbf83075b8df8bfc1f493d9e51ed0eb22f419d22a3df6f9434fd666be9bacaf893b43fe38eff9c73c66f0525d2bc8b7a96cfd1ad5e145b97ed2e5a9813c53af", //nolint:lll }, { name: "sanity 7 (v040)", version: Musig2v040Muun, @@ -155,41 +160,45 @@ func TestMuSig2Tests2of2(t *testing.T) { muunKey: "29ef6014b97d3d6c2a9bf93395a245735137733b080913b59f53e1755636c5e2", tweak: KeySpendOnlyTweak(), msg: "6fe7c58ad896b25f1c86c26bfdb85524bc4ecc87fc38acd0a16448415db4845e", - userSessionId: "16ace9f016dd36d59b33576105eaf0b441ad2550bb8aa53940b0886dff30741c", - muunSessionId: "31e79edbd0849023181218d1573c67235c6f9f7b7ad88fb6b0ea817a5af22817", + userSessionID: "16ace9f016dd36d59b33576105eaf0b441ad2550bb8aa53940b0886dff30741c", + muunSessionID: "31e79edbd0849023181218d1573c67235c6f9f7b7ad88fb6b0ea817a5af22817", combinedPub: "3e33b66b01724807173ca68b264fc211f875501296606ee5699aed7cbf2732eb", - userNonce: "0283c130da2c27cde19073b7386ecd34fa297341427a6c190fa53b3efdd8e4d2c40378d05fcc62cfe78ace614e8fbab1124b141b635efe99e4ff686f40fa2b4c6b9d", - muunNonce: "0227fbea16aa0f54c5d4b4115edb9ff0d76ce7c808bea2705ee0d9bbe6fd4de19c0236e16ae2604e9d1f0ccb3943b7d1564a3dce418ba2d5a83ae4555347703079d2", + userNonce: "0283c130da2c27cde19073b7386ecd34fa297341427a6c190fa53b3efdd8e4d2c40378d05fcc62cfe78ace614e8fbab1124b141b635efe99e4ff686f40fa2b4c6b9d", //nolint:lll + muunNonce: "0227fbea16aa0f54c5d4b4115edb9ff0d76ce7c808bea2705ee0d9bbe6fd4de19c0236e16ae2604e9d1f0ccb3943b7d1564a3dce418ba2d5a83ae4555347703079d2", //nolint:lll muunPartialSignature: "c67cc164dde0b8a80208b220761c04a20490ec64ae5e4a7ec400cde8afa20fc0", - fullSignature: "c1b7b742705fa6888f44d045bb1a3436c234f6de42e26a4e3e3eb2682812e769198af2f2652b59aa00f0a6e102387c870105031e5ad714d91831501e89c2ae28", + fullSignature: "c1b7b742705fa6888f44d045bb1a3436c234f6de42e26a4e3e3eb2682812e769198af2f2652b59aa00f0a6e102387c870105031e5ad714d91831501e89c2ae28", //nolint:lll }, { - name: "sanity 8 (v100)", - version: Musig2v100, - userKey: "c96ed4f9da26be21374dce108bbe915541b1bfa47efc017980c4320dcb1d5a39", - muunKey: "60eb8658f768f9be266c69a181f4dc201ade73909232985127e2c10d6b11ded4", - tweak: TapScriptTweak(hexDecode("3f024acdf7597c4ee7b6344a6ff8c8e08b77acd2fe6e42c72d199eebb2c997b1")), + name: "sanity 8 (v100)", + version: Musig2v100, + userKey: "c96ed4f9da26be21374dce108bbe915541b1bfa47efc017980c4320dcb1d5a39", + muunKey: "60eb8658f768f9be266c69a181f4dc201ade73909232985127e2c10d6b11ded4", + tweak: TapScriptTweak( + hexDecode("3f024acdf7597c4ee7b6344a6ff8c8e08b77acd2fe6e42c72d199eebb2c997b1"), + ), msg: "f5eed1ed2561713a07f9b2e4889b0abd84cf0ff20783b4db952ac52162235763", - userSessionId: "8d586a38b6d3c9662faffae3b4cf9ede137bb4aba1e582b0cb4cf13a88e041ad", - muunSessionId: "03b942e5de91597f05c2ce50d44131c67936cad0af2fe67d8fce90a8cae0b925", + userSessionID: "8d586a38b6d3c9662faffae3b4cf9ede137bb4aba1e582b0cb4cf13a88e041ad", + muunSessionID: "03b942e5de91597f05c2ce50d44131c67936cad0af2fe67d8fce90a8cae0b925", combinedPub: "02b26c54bbeb897bed5e171109eaa765b980b0e8d94cb8a8924660d40a4a77c2b8", - userNonce: "02f73c3c63c659f9020444f09134f1a665b9ea4f945333266bcf480e05a5a73f1203936e2303f9f3b99cf43b66c736aa48fcfa6d88cc239e89e52250527d169bab96", - muunNonce: "03ab48e4612c3225f661b0a8d8f45753711c7de7e8ef2769e5130f391a7db75a1303556d0914aca06cd479f284ccbfcaf41a6165a773af10b99e421a5c7f36ed5a89", + userNonce: "02f73c3c63c659f9020444f09134f1a665b9ea4f945333266bcf480e05a5a73f1203936e2303f9f3b99cf43b66c736aa48fcfa6d88cc239e89e52250527d169bab96", //nolint:lll + muunNonce: "03ab48e4612c3225f661b0a8d8f45753711c7de7e8ef2769e5130f391a7db75a1303556d0914aca06cd479f284ccbfcaf41a6165a773af10b99e421a5c7f36ed5a89", //nolint:lll muunPartialSignature: "5d719ef048d33ac14c6a0011f1dd6f4693f2d0597d5dd340068f53a2b898d8f5", - fullSignature: "a508ef2e4e8fa795537c4e04fb62f548ec05b0f16c97732949904661554b52584ea8a5f7e2f4336f5679744d88205482092844ffc99282e1e1084411d2b7a714", + fullSignature: "a508ef2e4e8fa795537c4e04fb62f548ec05b0f16c97732949904661554b52584ea8a5f7e2f4336f5679744d88205482092844ffc99282e1e1084411d2b7a714", //nolint:lll }, { - name: "sanity 9 (v100)", - version: Musig2v100, - userKey: "f163af2ffd707882721f4f594fe9e5c09a5de9af45a5b9c9d81c873d3cbf3439", - muunKey: "e5b41cee7d37e82262678145ad6ac8f664506a7c43d67f73cc3a1471d28c93f4", - tweak: TapScriptTweak(hexDecode("53b0a68bd9224772ad17966c6c1d055dc47dc7450d065e61e9a65050ce48e85e")), + name: "sanity 9 (v100)", + version: Musig2v100, + userKey: "f163af2ffd707882721f4f594fe9e5c09a5de9af45a5b9c9d81c873d3cbf3439", + muunKey: "e5b41cee7d37e82262678145ad6ac8f664506a7c43d67f73cc3a1471d28c93f4", + tweak: TapScriptTweak( + hexDecode("53b0a68bd9224772ad17966c6c1d055dc47dc7450d065e61e9a65050ce48e85e"), + ), msg: "3ca3b9bb8e531664e8b25c638673a679b82f15c26f3b52d368d99f8c089ffd9a", - userSessionId: "1e81f6dc4801c1df619d817d366055cc5e15a51ff46aabd563df4b956855fa40", - muunSessionId: "98364c25dca3a8cec1ec59a9baf825863b208e532ce71336b0e14b63289523ba", + userSessionID: "1e81f6dc4801c1df619d817d366055cc5e15a51ff46aabd563df4b956855fa40", + muunSessionID: "98364c25dca3a8cec1ec59a9baf825863b208e532ce71336b0e14b63289523ba", combinedPub: "030b6113091eb6aedd1a12aa5b6a58f2bf4c24d5e22944be9d6d2e89fc3187469e", - userNonce: "0324761e824500b1798c1d2af367b6229c70ab96aa328fc89f9a4a3ddbc6b96d9503bb3b54ba779641c0700dbe1a8f5e72f9afdc7bfe329599ac5bb2c7b212953316", - muunNonce: "03309e05f403e2ee36c7c05975a7fea411a2ba169e4d32366bdb8931cc3d45e42a02d34f33695c07af5f76a476556ad3aed40c66a8c31af70c6fd77fd058e065d58c", + userNonce: "0324761e824500b1798c1d2af367b6229c70ab96aa328fc89f9a4a3ddbc6b96d9503bb3b54ba779641c0700dbe1a8f5e72f9afdc7bfe329599ac5bb2c7b212953316", //nolint:lll + muunNonce: "03309e05f403e2ee36c7c05975a7fea411a2ba169e4d32366bdb8931cc3d45e42a02d34f33695c07af5f76a476556ad3aed40c66a8c31af70c6fd77fd058e065d58c", //nolint:lll muunPartialSignature: "a3eea0cdf3d9564e354892c6090473ebdfb92611a488c0173ecbe53331734f55", - fullSignature: "3de568db8a74734c8823a106566cb13d00273668ac2dec68df8e80c492d678e293da485ec944e4fff9e94248665f9455d1a901132037395f413c7199a9fcd9b8", + fullSignature: "3de568db8a74734c8823a106566cb13d00273668ac2dec68df8e80c492d678e293da485ec944e4fff9e94248665f9455d1a901132037395f413c7199a9fcd9b8", //nolint:lll }, { name: "sanity 10 (v040)", version: Musig2v040Muun, @@ -197,14 +206,14 @@ func TestMuSig2Tests2of2(t *testing.T) { muunKey: "43889a9efda32bf3f39c364b3d373ba4f4b450da6e4ec7b920cf189127300e9b", tweak: KeySpendOnlyTweak(), msg: "c7d15407975a8a3ed8686b607ee955880745289fedde01c5bdfb4a933c73f98f", - userSessionId: "40c4aad751f1e50bf013623dd13fef7285b83155688287fe2f00b00cfcda62b1", - muunSessionId: "77df399174557b3a04db01fc9209efe2379b7750ac1cc0cb3fadb2e0632b77ca", + userSessionID: "40c4aad751f1e50bf013623dd13fef7285b83155688287fe2f00b00cfcda62b1", + muunSessionID: "77df399174557b3a04db01fc9209efe2379b7750ac1cc0cb3fadb2e0632b77ca", combinedPub: "53a65378992d5c521f9390d5716ca6402e2933047aab907a78de6066400adf0b", combinedPubUntweaked: "69fd98b014388378f286239e8115444d8a6ad77aeef8d3a4ac63d27cf395a473", - userNonce: "0287e08a04a2b96cdebdb59d88b34ea0e39dde004f40fe995408f0c5b423ed8cb0025b2b0d15298ad8cdb64af32849e27e4e628403188c571cfd7b0b1bda06fabc39", - muunNonce: "03b0514f0c78e8d6c85781e1f6c63a14add69bb6dce00827c3eacfe8599970cdc1032c8892752ac8ac2b444af84fa58263e3cb4d8301987c25d068249f554401efcd", + userNonce: "0287e08a04a2b96cdebdb59d88b34ea0e39dde004f40fe995408f0c5b423ed8cb0025b2b0d15298ad8cdb64af32849e27e4e628403188c571cfd7b0b1bda06fabc39", //nolint:lll + muunNonce: "03b0514f0c78e8d6c85781e1f6c63a14add69bb6dce00827c3eacfe8599970cdc1032c8892752ac8ac2b444af84fa58263e3cb4d8301987c25d068249f554401efcd", //nolint:lll muunPartialSignature: "fab9805aef289b0fb1a5b7e4285920f709fe9552b51633825c548140aff7283e", - fullSignature: "e50774777d17470b26631d6ab748d09f8632c56f2b3b29f2f4f81b7f5359d2814752ec522569f1409185cbf118c420219750ab0e2f0138413b28be7913ad9a23", + fullSignature: "e50774777d17470b26631d6ab748d09f8632c56f2b3b29f2f4f81b7f5359d2814752ec522569f1409185cbf118c420219750ab0e2f0138413b28be7913ad9a23", //nolint:lll }, { name: "sanity 10 (v100)", version: Musig2v100, @@ -212,14 +221,14 @@ func TestMuSig2Tests2of2(t *testing.T) { muunKey: "43889a9efda32bf3f39c364b3d373ba4f4b450da6e4ec7b920cf189127300e9b", tweak: KeySpendOnlyTweak(), msg: "c7d15407975a8a3ed8686b607ee955880745289fedde01c5bdfb4a933c73f98f", - userSessionId: "40c4aad751f1e50bf013623dd13fef7285b83155688287fe2f00b00cfcda62b1", - muunSessionId: "77df399174557b3a04db01fc9209efe2379b7750ac1cc0cb3fadb2e0632b77ca", + userSessionID: "40c4aad751f1e50bf013623dd13fef7285b83155688287fe2f00b00cfcda62b1", + muunSessionID: "77df399174557b3a04db01fc9209efe2379b7750ac1cc0cb3fadb2e0632b77ca", combinedPub: "03a5277614222885d7e9c9096483447785999ed9f1ad7eb5fd008917fecaeebf57", combinedPubUntweaked: "035b5ba78d7db091e64827f4f5fa24a644182ce2a3e7cf63720667ca86fc7d7a04", - userNonce: "026ec2cc8947d5b8a04e0d06d1fc6c603db250eb204a3ca6f9a88af3a08944767602c9919f05bc49d0d710dda3ac5409d66b82c47f9565c0779faea0420b6e6aefeb", - muunNonce: "0217abf0457b4d2b933ed13d6f9afdf3817883642f7ad99665a4a1ec2889743cf4020416bc7dac258f2ce9e37961465f0f8554f73127d72f82458b93e0663899bc1c", + userNonce: "026ec2cc8947d5b8a04e0d06d1fc6c603db250eb204a3ca6f9a88af3a08944767602c9919f05bc49d0d710dda3ac5409d66b82c47f9565c0779faea0420b6e6aefeb", //nolint:lll + muunNonce: "0217abf0457b4d2b933ed13d6f9afdf3817883642f7ad99665a4a1ec2889743cf4020416bc7dac258f2ce9e37961465f0f8554f73127d72f82458b93e0663899bc1c", //nolint:lll muunPartialSignature: "094ef219a2613edd393a656a800a24550926a091cb3c0c70522c0978b50d231f", - fullSignature: "c74f86e54264c8c4f5c88251ece57b0ee6e318dc03d48bb41f60955c7779c03183631112bfd5514645efe183e7b5e25c156a8edbc8fcc8830acb7eab0c3a83a3", + fullSignature: "c74f86e54264c8c4f5c88251ece57b0ee6e318dc03d48bb41f60955c7779c03183631112bfd5514645efe183e7b5e25c156a8edbc8fcc8830acb7eab0c3a83a3", //nolint:lll }, { name: "sanity 10 (v100) unhardened /1/2", version: Musig2v100, @@ -227,18 +236,18 @@ func TestMuSig2Tests2of2(t *testing.T) { muunKey: "43889a9efda32bf3f39c364b3d373ba4f4b450da6e4ec7b920cf189127300e9b", tweak: KeySpendOnlyTweak().WithUnhardenedDerivationPath([]uint32{1, 2}), msg: "c7d15407975a8a3ed8686b607ee955880745289fedde01c5bdfb4a933c73f98f", - userSessionId: "40c4aad751f1e50bf013623dd13fef7285b83155688287fe2f00b00cfcda62b1", - muunSessionId: "77df399174557b3a04db01fc9209efe2379b7750ac1cc0cb3fadb2e0632b77ca", + userSessionID: "40c4aad751f1e50bf013623dd13fef7285b83155688287fe2f00b00cfcda62b1", + muunSessionID: "77df399174557b3a04db01fc9209efe2379b7750ac1cc0cb3fadb2e0632b77ca", combinedPub: "03a5277614222885d7e9c9096483447785999ed9f1ad7eb5fd008917fecaeebf57", combinedPubUntweaked: "035b5ba78d7db091e64827f4f5fa24a644182ce2a3e7cf63720667ca86fc7d7a04", - userNonce: "026ec2cc8947d5b8a04e0d06d1fc6c603db250eb204a3ca6f9a88af3a08944767602c9919f05bc49d0d710dda3ac5409d66b82c47f9565c0779faea0420b6e6aefeb", - muunNonce: "0217abf0457b4d2b933ed13d6f9afdf3817883642f7ad99665a4a1ec2889743cf4020416bc7dac258f2ce9e37961465f0f8554f73127d72f82458b93e0663899bc1c", + userNonce: "026ec2cc8947d5b8a04e0d06d1fc6c603db250eb204a3ca6f9a88af3a08944767602c9919f05bc49d0d710dda3ac5409d66b82c47f9565c0779faea0420b6e6aefeb", //nolint:lll + muunNonce: "0217abf0457b4d2b933ed13d6f9afdf3817883642f7ad99665a4a1ec2889743cf4020416bc7dac258f2ce9e37961465f0f8554f73127d72f82458b93e0663899bc1c", //nolint:lll muunPartialSignature: "094ef219a2613edd393a656a800a24550926a091cb3c0c70522c0978b50d231f", - fullSignature: "c74f86e54264c8c4f5c88251ece57b0ee6e318dc03d48bb41f60955c7779c03183631112bfd5514645efe183e7b5e25c156a8edbc8fcc8830acb7eab0c3a83a3", + fullSignature: "c74f86e54264c8c4f5c88251ece57b0ee6e318dc03d48bb41f60955c7779c03183631112bfd5514645efe183e7b5e25c156a8edbc8fcc8830acb7eab0c3a83a3", //nolint:lll }} for _, tc := range testCases { - tc := tc + tc := tc //nolint:modernize // TODO: remove unnecessary variable copy t.Run(tc.name, func(tt *testing.T) { tt.Parallel() @@ -247,8 +256,12 @@ func TestMuSig2Tests2of2(t *testing.T) { userKey := hexDecode(tc.userKey) muunKey := hexDecode(tc.muunKey) msg := hexDecode(tc.msg) - muunSessionId := hexDecode(tc.muunSessionId) - userSessionId := hexDecode(tc.userSessionId) + muunSessionID := hexDecode( + tc.muunSessionID, + ) + userSessionID := hexDecode( + tc.userSessionID, + ) userPrivateKey := secp256k1.PrivKeyFromBytes(userKey) userPublicKeyBytes := SerializePublicKey(tc.version, userPrivateKey.PubKey()) @@ -274,12 +287,19 @@ func TestMuSig2Tests2of2(t *testing.T) { require.Equal(tt, tc.combinedPub, hex.EncodeToString(aggregateKeyBytes)) if len(tc.combinedPubUntweaked) > 0 { - aggregateKeyUntweakedBytes := SerializePublicKey(tc.version, aggregateKey.PreTweakedKey) - require.Equal(tt, tc.combinedPubUntweaked, hex.EncodeToString(aggregateKeyUntweakedBytes)) + aggregateKeyUntweakedBytes := SerializePublicKey( + tc.version, + aggregateKey.PreTweakedKey, + ) + require.Equal( + tt, + tc.combinedPubUntweaked, + hex.EncodeToString(aggregateKeyUntweakedBytes), + ) } // test user nonce - userNonce, err := MuSig2GenerateNonce(tc.version, userSessionId, userPublicKeyBytes) + userNonce, err := MuSig2GenerateNonce(tc.version, userSessionID, userPublicKeyBytes) if err != nil { if tc.expectedErr != "" { require.ErrorContains(tt, err, tc.expectedErr) @@ -291,7 +311,7 @@ func TestMuSig2Tests2of2(t *testing.T) { require.Equal(tt, tc.userNonce, hex.EncodeToString(userNonce.PubNonce[:])) // test muun nonce - muunNonce, err := MuSig2GenerateNonce(tc.version, muunSessionId, muunPublicKeyBytes) + muunNonce, err := MuSig2GenerateNonce(tc.version, muunSessionID, muunPublicKeyBytes) if err != nil { if tc.expectedErr != "" { require.ErrorContains(tt, err, tc.expectedErr) @@ -309,7 +329,7 @@ func TestMuSig2Tests2of2(t *testing.T) { userPublicKeyBytes, muunKey, userNonce.PubNonce[:], - muunSessionId, + muunSessionID, tc.tweak, ) if err != nil { @@ -320,7 +340,11 @@ func TestMuSig2Tests2of2(t *testing.T) { } return } - require.Equal(tt, tc.muunPartialSignature, hex.EncodeToString(muunPartialSignatureBytes)) + require.Equal( + tt, + tc.muunPartialSignature, + hex.EncodeToString(muunPartialSignatureBytes), + ) // finish 2-of-2 signature sig, err := ComputeUserPartialSignature( @@ -330,7 +354,7 @@ func TestMuSig2Tests2of2(t *testing.T) { muunPublicKeyBytes, muunPartialSignatureBytes, muunNonce.PubNonce[:], - userSessionId, + userSessionID, tc.tweak, ) if err != nil { @@ -360,7 +384,10 @@ func TestMuSig2Tests2of2(t *testing.T) { } } -func combinePubKeysWithTweak(userPublicKey, muunPublicKey *secp256k1.PublicKey, tweak *MuSig2Tweaks) (*secp256k1.PublicKey, error) { +func combinePubKeysWithTweak( + userPublicKey, muunPublicKey *secp256k1.PublicKey, + tweak *MuSig2Tweaks, +) (*secp256k1.PublicKey, error) { pubKeys := [][]byte{ userPublicKey.SerializeCompressed(), muunPublicKey.SerializeCompressed(), @@ -368,7 +395,7 @@ func combinePubKeysWithTweak(userPublicKey, muunPublicKey *secp256k1.PublicKey, aggregateKey, err := Musig2CombinePubKeysWithTweak(Musig2v040Muun, pubKeys, tweak) if err != nil { - return nil, fmt.Errorf("Error combining keys: %w", err) + return nil, errors.Errorf("Error combining keys: %w", err) } return aggregateKey.FinalKey, nil @@ -387,11 +414,11 @@ func TestSigning(t *testing.T) { t.Fatal(err) } - userSessionId := RandomSessionId() - muunSessionId := RandomSessionId() + userSessionID := RandomSessionID() + muunSessionID := RandomSessionID() - userPubNonces, _ := MuSig2GenerateNonce(Musig2v040Muun, userSessionId[:], nil) - muunPubNonces, _ := MuSig2GenerateNonce(Musig2v040Muun, muunSessionId[:], nil) + userPubNonces, _ := MuSig2GenerateNonce(Musig2v040Muun, userSessionID[:], nil) + muunPubNonces, _ := MuSig2GenerateNonce(Musig2v040Muun, muunSessionID[:], nil) muunSig, err := ComputeMuunPartialSignature( Musig2v040Muun, @@ -399,7 +426,7 @@ func TestSigning(t *testing.T) { userPriv.PubKey().SerializeCompressed(), muunPriv.Serialize(), userPubNonces.PubNonce[:], - muunSessionId[:], + muunSessionID[:], tweak, ) if err != nil { @@ -413,14 +440,19 @@ func TestSigning(t *testing.T) { muunPriv.PubKey().SerializeCompressed(), muunSig, muunPubNonces.PubNonce[:], - userSessionId[:], + userSessionID[:], tweak, ) if err != nil { t.Fatal(err) } - valid, err := VerifySignature(Musig2v040Muun, toSign[:], combined.SerializeCompressed(), fullSig[:]) + valid, err := VerifySignature( + Musig2v040Muun, + toSign[:], + combined.SerializeCompressed(), + fullSig[:], + ) if err != nil { t.Fatal(err) } @@ -438,12 +470,20 @@ func TestCrossWithJava(t *testing.T) { rawUserPriv := decode32Bytes("6e39c6add6323a5ac5f65e50231fb815026476e734eb9f4f66dce3298fddf1dc") rawMuunPriv := decode32Bytes("b876ecf97c19588cf4be95ddc0b06c0d9f623f2cf679276c25e4dfb512b19743") - userSessionId := decode32Bytes("52fdfc072182654f163f5f0f9a621d729566c74d10037c4d7bbb0407d1e2c649") - muunSessionId := decode32Bytes("81855ad8681d0d86d1e91e00167939cb6694d2c422acd208a0072939487f6999") + userSessionID := decode32Bytes( + "52fdfc072182654f163f5f0f9a621d729566c74d10037c4d7bbb0407d1e2c649", + ) + muunSessionID := decode32Bytes( + "81855ad8681d0d86d1e91e00167939cb6694d2c422acd208a0072939487f6999", + ) toSign := decode32Bytes("0102030000000000000000000000000000000000000000000000000000000000") - expectedKey, _ := hex.DecodeString("027ca7eab04c2ad445418fa6a0ed2a331f121444aedd043adef94bdc00040ff96c") - expectedSig, _ := hex.DecodeString("773ad923eb5eef593095a6787b674675de2a558335dc3e44fe40cf7b3736637e66d508e4eaadca28dab02e2fdcf5707392b561fffa1837205d2fa77b74cbc82f") + expectedKey, _ := hex.DecodeString( + "027ca7eab04c2ad445418fa6a0ed2a331f121444aedd043adef94bdc00040ff96c", + ) + expectedSig, _ := hex.DecodeString( + "773ad923eb5eef593095a6787b674675de2a558335dc3e44fe40cf7b3736637e66d508e4eaadca28dab02e2fdcf5707392b561fffa1837205d2fa77b74cbc82f", //nolint:lll + ) userPriv, userPub := btcec.PrivKeyFromBytes(rawUserPriv[:]) muunPriv, muunPub := btcec.PrivKeyFromBytes(rawMuunPriv[:]) @@ -460,8 +500,8 @@ func TestCrossWithJava(t *testing.T) { if !bytes.Equal(combined.SerializeCompressed(), expectedKey) { t.Fatal("Combined key doesn't match") } - userPubNonces, _ := MuSig2GenerateNonce(Musig2v040Muun, userSessionId[:], nil) - muunPubNonces, _ := MuSig2GenerateNonce(Musig2v040Muun, muunSessionId[:], nil) + userPubNonces, _ := MuSig2GenerateNonce(Musig2v040Muun, userSessionID[:], nil) + muunPubNonces, _ := MuSig2GenerateNonce(Musig2v040Muun, muunSessionID[:], nil) muunSig, err := ComputeMuunPartialSignature( Musig2v040Muun, @@ -469,7 +509,7 @@ func TestCrossWithJava(t *testing.T) { userPriv.PubKey().SerializeCompressed(), muunPriv.Serialize(), userPubNonces.PubNonce[:], - muunSessionId[:], + muunSessionID[:], tweak, ) if err != nil { @@ -483,14 +523,19 @@ func TestCrossWithJava(t *testing.T) { muunPriv.PubKey().SerializeCompressed(), muunSig, muunPubNonces.PubNonce[:], - userSessionId[:], + userSessionID[:], tweak, ) if err != nil { t.Fatal(err) } - valid, err := VerifySignature(Musig2v040Muun, toSign[:], combined.SerializeCompressed(), fullSig[:]) + valid, err := VerifySignature( + Musig2v040Muun, + toSign[:], + combined.SerializeCompressed(), + fullSig[:], + ) if err != nil { t.Fatal(err) } diff --git a/libwallet/musig/musig2_versions.go b/libwallet/musig/musig2_versions.go new file mode 100644 index 00000000..1b06025c --- /dev/null +++ b/libwallet/musig/musig2_versions.go @@ -0,0 +1,482 @@ +package musig + +// This file contains generic adapters for both versions musig2v040 and v100 + +import ( + "bytes" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" + musig2v100 "github.com/btcsuite/btcd/btcec/v2/schnorr/musig2" + "github.com/go-errors/errors" + "github.com/lightningnetwork/lnd/input" + + "github.com/muun/libwallet/musig2v040" +) + +type MusigVersion uint8 + +const ( + // Muun's variant of MuSig2 based on secp256k1_zkp implementation + // at commit https://github.com/jonasnick/secp256k1-zkp/tree/0aeaa5dfb19445f845890f3a4502c934550f4548 + // and nonces calculated with random entropy from sessionID (only). + // - not null scriptPath are not spendable with this implementation + // - key sorting is disabled, the order [user,muun] is enforced + // - xOnly keys are used + // - tapscript is not spendable + Musig2v040Muun MusigVersion = 40 + + // version 1.0.0rc2 of the MuSig2 BIP draft. + // It uses the github.com/btcsuite/btcd/btcec/v2/schnorr/musig2 package + // imported by go.mod + Musig2v100 MusigVersion = 100 +) + +func MuSig2GenerateNonce( + musigVersion MusigVersion, + sessionID []byte, + publicKeyBytes []byte, +) (*musig2v100.Nonces, error) { + + switch musigVersion { + case Musig2v040Muun: + return musig2v040.GenNonces( + musig2v040.WithCustomRand( + bytes.NewBuffer(sessionID), + ), + ) + + case Musig2v100: + if len(publicKeyBytes) == 0 { + return nil, errors.Errorf( + "a public key must be provided to generate nonces for MuSig2v100", + ) + } + + publicKey, err := ParsePubKey(musigVersion, publicKeyBytes) + if err != nil { + return nil, err + } + + return musig2v100.GenNonces( + musig2v100.WithPublicKey(publicKey), + musig2v100.WithCustomRand(bytes.NewBuffer(sessionID)), + ) + default: + return nil, errors.Errorf("unknown address version: <%d>", + musigVersion) + } +} + +// ParsePubKey forces the kind of PublicKey needed for each MuSig version +func ParsePubKey(musigVersion MusigVersion, pubKeyBytes []byte) (*btcec.PublicKey, error) { + switch musigVersion { + case Musig2v040Muun: + var ( + pubKey *btcec.PublicKey + err error + ) + + if len(pubKeyBytes) == 33 { + // if the not xOnly compressed was provided, then remove the parity bit + pubKey, err = schnorr.ParsePubKey(pubKeyBytes[1:]) + if err != nil { + return nil, errors.Errorf( + "error parsing public key for v0.4.0 (compressed format): %w", + err, + ) + } + } else { + pubKey, err = schnorr.ParsePubKey(pubKeyBytes) + if err != nil { + return nil, errors.Errorf( + "error parsing public key for v0.4.0 (x-only format): %w", + err, + ) + } + } + + return pubKey, nil + case Musig2v100: + pubKey, err := btcec.ParsePubKey(pubKeyBytes) + if err != nil { + return nil, errors.Errorf("error parsing public key for v1.0.0 ("+ + "compressed format): %w", err) + } + return pubKey, nil + default: + return nil, errors.Errorf("unknown MuSig2 version: <%d>", + musigVersion) + } +} + +// MuSig2ParsePubKeys parses a list of raw public keys as the signing keys of a +// MuSig2 signing session. +func MuSig2ParsePubKeys(musigVersion MusigVersion, + rawPubKeys [][]byte) ([]*btcec.PublicKey, error) { + + allSignerPubKeys := make([]*btcec.PublicKey, len(rawPubKeys)) + if len(rawPubKeys) < 2 { + return nil, errors.Errorf("need at least two signing public keys") + } + + for idx, pubKeyBytes := range rawPubKeys { + pubKey, err := ParsePubKey(musigVersion, pubKeyBytes) + if err != nil { + return nil, errors.Errorf("error parsing signer "+ + "public key %d: %w", idx, err) + } + allSignerPubKeys[idx] = pubKey + } + + return allSignerPubKeys, nil +} + +// Computes the tweakedKey using a TapScript.merkleRoot or empty bytes as recommended by BIP0086. +// The tweakedKey is used to generate the output address: Bech32m(tweakedKey) +func Musig2CombinePubKeysWithTweak( + musigVersion MusigVersion, + pubKeys [][]byte, + tweaks *MuSig2Tweaks, +) (*musig2v100.AggregateKey, error) { + + keys, err := MuSig2ParsePubKeys(musigVersion, pubKeys) + if err != nil { + return nil, err + } + + return MuSig2CombineKeys(musigVersion, keys, tweaks) +} + +// MuSig2CombineKeys combines the given set of public keys into a single +// combined MuSig2 combined public key, applying the given tweaks. +func MuSig2CombineKeys(musigVersion MusigVersion, + allSignerPubKeys []*btcec.PublicKey, + tweaks *MuSig2Tweaks) (*musig2v100.AggregateKey, error) { + + sortKeys := musigVersion != Musig2v040Muun + + switch musigVersion { + case Musig2v040Muun: + return combineKeysV040(allSignerPubKeys, sortKeys, tweaks) + + case Musig2v100: + return combineKeysV100RC2(allSignerPubKeys, sortKeys, tweaks) + + default: + return nil, errors.Errorf("unknown MuSig2 version: <%d>", + musigVersion) + } +} + +// combineKeysV100rc1 implements the MuSigCombineKeys logic for the MuSig2 BIP draft version +// 1.0.0rc2. +func combineKeysV100RC2(allSignerPubKeys []*btcec.PublicKey, sortKeys bool, + tweaks *MuSig2Tweaks) (*musig2v100.AggregateKey, error) { + + // Convert the tweak options into the appropriate MuSig2 API functional options. + var keyAggOpts []musig2v100.KeyAggOption + switch { + case tweaks.TaprootBIP0086Tweak: + keyAggOpts = append(keyAggOpts, musig2v100.WithBIP86KeyTweak()) + case len(tweaks.TaprootTweak) > 0: + keyAggOpts = append(keyAggOpts, musig2v100.WithTaprootKeyTweak( + tweaks.TaprootTweak, + )) + case len(tweaks.GenericTweaks) > 0: + keyAggOpts = append(keyAggOpts, musig2v100.WithKeyTweaks( + tweaks.GenericTweaks..., + )) + case len(tweaks.UnhardenedDerivationPath) > 0: + bip328tweaks, err := getKeyDerivationTweaksForMusig( + allSignerPubKeys, tweaks.UnhardenedDerivationPath) + if err != nil { + return nil, err + } + keyAggOpts = append(keyAggOpts, musig2v100.WithKeyTweaks( + bip328tweaks..., + )) + } + + // Then we'll use this information to compute the aggregated public key. + combinedKey, _, _, err := musig2v100.AggregateKeys( + allSignerPubKeys, sortKeys, keyAggOpts..., + ) + return combinedKey, err +} + +// returns a list of generic tweaks to derive a specific unhardened key for Musig2v100 +// as per BIP32 + BIP328 +func getKeyDerivationTweaksForMusig( + allSignerPubKeys []*btcec.PublicKey, + unhardenedDerivationPath []uint32, +) ([]musig2v100.KeyTweakDesc, error) { + + aggregatedKey, err := MuSig2CombineKeys( + Musig2v100, allSignerPubKeys, NoopTweak()) + if err != nil { + return nil, err + } + + _, tweakDerivationSteps, err := getBip32TweaksForAggregatedKey( + aggregatedKey.PreTweakedKey, unhardenedDerivationPath) + if err != nil { + return nil, err + } + + return tweakDerivationSteps, nil +} + +// combineKeysV040 implements the MuSigCombineKeys logic for the MuSig2 BIP draft version 0.4.0. +func combineKeysV040(allSignerPubKeys []*btcec.PublicKey, sortKeys bool, + tweaks *MuSig2Tweaks) (*musig2v100.AggregateKey, error) { + + // Convert the tweak options into the appropriate MuSig2 API functional options. + var keyAggOpts []musig2v040.KeyAggOption + switch { + case tweaks.TaprootBIP0086Tweak: + keyAggOpts = append(keyAggOpts, musig2v040.WithBIP86KeyTweak()) + case len(tweaks.TaprootTweak) > 0: + return nil, errors.Errorf( + "taproot tweak bytes are not allowed for MuSig2v040Muun") + case len(tweaks.GenericTweaks) > 0: + return nil, errors.Errorf( + "generic tweaks are not available for Musig2v040Muun") + case len(tweaks.UnhardenedDerivationPath) > 0: + return nil, errors.Errorf( + "unhardened derivation is not available for Musig2v040Muun") + } + + // Then we'll use this information to compute the aggregated public key. + combinedKey, _, _, err := musig2v040.AggregateKeys( + allSignerPubKeys, sortKeys, keyAggOpts..., + ) + + // Copy the result back into the default version's native type. + return &musig2v100.AggregateKey{ + FinalKey: combinedKey.FinalKey, + PreTweakedKey: combinedKey.PreTweakedKey, + }, err +} + +// MuSig2CreateContext creates a new MuSig2 signing context. +func MuSig2CreateContext( + musigVersion MusigVersion, + privKey *btcec.PrivateKey, + allSignerPubKeys []*btcec.PublicKey, + tweaks *MuSig2Tweaks, + localNonces *musig2v100.Nonces, +) (input.MuSig2Context, input.MuSig2Session, error) { + + switch musigVersion { + case Musig2v040Muun: + if len(tweaks.UnhardenedDerivationPath) > 0 { + return nil, nil, errors.Errorf( + "unhardened derivation is not available for Musig2v040Muun") + } + + if len(tweaks.TaprootTweak) > 0 { + return nil, nil, errors.Errorf( + "taproot tweak bytes are not allowed for MuSig2v040Muun") + } + + return createContextV040( + privKey, allSignerPubKeys, tweaks, localNonces, + ) + + case Musig2v100: + return createContextV100RC2( + privKey, allSignerPubKeys, tweaks, localNonces, + ) + + default: + return nil, nil, errors.Errorf("unknown MuSig2 : <%d>", + musigVersion) + } +} + +// createContextV100RC2 implements the MuSig2CreateContext logic for the MuSig2 +// BIP draft version 1.0.0rc2. +func createContextV100RC2( + privKey *btcec.PrivateKey, + allSignerPubKeys []*btcec.PublicKey, + tweaks *MuSig2Tweaks, + localNonces *musig2v100.Nonces, +) (*musig2v100.Context, *musig2v100.Session, error) { + + if localNonces == nil { + return nil, nil, errors.Errorf("error creating MuSig2 signing " + + "context: localNonces must be provided") + } + + // The context keeps track of all signing keys and our local key. + options, err := tweaks.ToContextOptions(allSignerPubKeys) + if err != nil { + return nil, nil, err + } + allOpts := append(options, musig2v100.WithKnownSigners(allSignerPubKeys)) + muSigContext, err := musig2v100.NewContext(privKey, true, allOpts...) + if err != nil { + return nil, nil, errors.Errorf("error creating MuSig2 signing "+ + "context: %w", err) + } + + muSigSession, err := muSigContext.NewSession( + musig2v100.WithPreGeneratedNonce(localNonces), + ) + if err != nil { + return nil, nil, errors.Errorf("error creating MuSig2 signing "+ + "session: %w", err) + } + + return muSigContext, muSigSession, nil +} + +// createContextV040 implements the MuSig2CreateContext logic for the MuSig2 BIP draft version +// 0.4.0. +func createContextV040( + privKey *btcec.PrivateKey, + allSignerPubKeys []*btcec.PublicKey, + tweaks *MuSig2Tweaks, + localNonces *musig2v100.Nonces, +) (*musig2v040.Context, *musig2v040.Session, error) { + + if localNonces == nil { + return nil, nil, errors.Errorf("error creating MuSig2 signing " + + "context: localNonces must be provided") + } + + // The context keeps track of all signing keys and our local key. + allOpts := append( + []musig2v040.ContextOption{ + musig2v040.WithKnownSigners(allSignerPubKeys), + }, + tweaks.ToV040ContextOptions()..., + ) + muSigContext, err := musig2v040.NewContext(privKey, false, allOpts...) + if err != nil { + return nil, nil, errors.Errorf("error creating MuSig2 signing "+ + "context: %w", err) + } + + muSigSession, err := muSigContext.NewSession( + musig2v040.WithPreGeneratedNonce(localNonces), + ) + if err != nil { + return nil, nil, errors.Errorf("error creating MuSig2 signing "+ + "session: %w", err) + } + + return muSigContext, muSigSession, nil +} + +// MuSig2Sign calls the Sign() method on the given versioned signing session and +// returns the result in the most recent version of the MuSig2 API. +func MuSig2Sign( + session input.MuSig2Session, + msg [32]byte, +) (*musig2v100.PartialSignature, error) { + + switch s := session.(type) { + case *musig2v100.Session: + partialSig, err := s.Sign(msg, musig2v100.WithSortedKeys()) + if err != nil { + return nil, errors.Errorf("error signing with local key: "+ + "%w", err) + } + + return partialSig, nil + + case *musig2v040.Session: + partialSig, err := s.Sign(msg) + if err != nil { + return nil, errors.Errorf("error signing with local key: "+ + "%w", err) + } + + return &musig2v100.PartialSignature{ + S: partialSig.S, + R: partialSig.R, + }, nil + + default: + return nil, errors.Errorf("invalid session type <%T>", s) + } +} + +// MuSig2CombineSig calls the CombineSig() method on the given versioned signing +// session and returns the result in the most recent version of the MuSig2 API. +func MuSig2CombineSig( + session input.MuSig2Session, + otherPartialSig *musig2v100.PartialSignature, +) (bool, error) { + + switch s := session.(type) { + case *musig2v100.Session: + haveAllSigs, err := s.CombineSig(otherPartialSig) + if err != nil { + return false, errors.Errorf("error combining partial "+ + "signature: %w", err) + } + + return haveAllSigs, nil + + case *musig2v040.Session: + haveAllSigs, err := s.CombineSig(&musig2v040.PartialSignature{ + S: otherPartialSig.S, + R: otherPartialSig.R, + }) + if err != nil { + return false, errors.Errorf("error combining partial "+ + "signature: %w", err) + } + + return haveAllSigs, nil + + default: + return false, errors.Errorf("invalid session type <%T>", s) + } +} + +// SerializePartialSignature encodes the partial signature to a fixed size byte array. +func SerializePartialSignature( + sig *musig2v100.PartialSignature, +) ([input.MuSig2PartialSigSize]byte, error) { + + var ( + buf bytes.Buffer + result [input.MuSig2PartialSigSize]byte + ) + if err := sig.Encode(&buf); err != nil { + return result, errors.Errorf("error encoding partial signature: "+ + "%w", err) + } + + if buf.Len() != input.MuSig2PartialSigSize { + return result, errors.Errorf("invalid partial signature length, "+ + "got %d wanted %d", buf.Len(), input.MuSig2PartialSigSize) + } + + copy(result[:], buf.Bytes()) + + return result, nil +} + +// DeserializePartialSignature decodes a partial signature from a byte slice. +func DeserializePartialSignature( + scalarBytes []byte, +) (*musig2v100.PartialSignature, error) { + + if len(scalarBytes) != input.MuSig2PartialSigSize { + return nil, errors.Errorf("invalid partial signature length, got "+ + "%d wanted %d", len(scalarBytes), input.MuSig2PartialSigSize) + } + + sig := &musig2v100.PartialSignature{} + if err := sig.Decode(bytes.NewReader(scalarBytes)); err != nil { + return nil, errors.Errorf("error decoding partial signature: %w", + err) + } + + return sig, nil +} diff --git a/libwallet/musig/schnorr.go b/libwallet/musig/schnorr.go new file mode 100644 index 00000000..aeb4c9a6 --- /dev/null +++ b/libwallet/musig/schnorr.go @@ -0,0 +1,43 @@ +package musig + +import ( + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/decred/dcrd/dcrec/secp256k1/v4" + "github.com/go-errors/errors" +) + +// VerifySignature checks that signature is a valid schnorr signature of data +// by publicKey. +func VerifySignature( + musigVersion MusigVersion, + data []byte, + publicKey []byte, + signature []byte, +) (bool, error) { + + pubKey, err := ParsePubKey(musigVersion, publicKey) + if err != nil { + return false, err + } + + sig, err := schnorr.ParseSignature(signature) + if err != nil { + return false, err + } + + return sig.Verify(data, pubKey), nil +} + +// SignSchnorr produces a BIP340 single-key Schnorr signature over the 32-byte data using the +// given 32-byte private key. Used for tapscript (BIP342) CHECKSIG spends, where each key signs +// on its own rather than as part of a MuSig aggregate. +func SignSchnorr(privateKeyBytes []byte, data []byte) ([]byte, error) { + privateKey := secp256k1.PrivKeyFromBytes(privateKeyBytes) + + sig, err := schnorr.Sign(privateKey, data) + if err != nil { + return nil, errors.Errorf("schnorr sign: %w", err) + } + + return sig.Serialize(), nil +} diff --git a/libwallet/musig/schnorr_test.go b/libwallet/musig/schnorr_test.go new file mode 100644 index 00000000..0fa2f3ef --- /dev/null +++ b/libwallet/musig/schnorr_test.go @@ -0,0 +1,63 @@ +package musig + +import ( + "encoding/hex" + "testing" + + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/decred/dcrd/dcrec/secp256k1/v4" + "github.com/stretchr/testify/require" +) + +func TestSignSchnorr(t *testing.T) { + t.Parallel() + + privateKeyBytes := hexDecode( + "507d881f0b5e1b12423cb0c84a196fb24227f3fe1540a1c7b20bf78d83de4533") + data := hexDecode( + "ef2ecc1f48c0b28ccaf8f3a8c6477740d869964ebc152a2c5f93f19e7b84b103") + + signature, err := SignSchnorr(privateKeyBytes, data) + require.NoError(t, err) + require.Len(t, signature, schnorr.SignatureSize) + + // The signature must verify under the signer's x-only public key. + sig, err := schnorr.ParseSignature(signature) + require.NoError(t, err) + + publicKey := secp256k1.PrivKeyFromBytes(privateKeyBytes).PubKey() + require.True(t, sig.Verify(data, publicKey)) +} + +// TestSignSchnorrCrossCheckWithJava asserts the signature byte-exact against a pinned +// vector. SignSchnorr is deterministic, and the same vector is pinned by +// testCrossCheckWithLibwallet in backend-libs/taproot4j's SchnorrTest, so both sides +// of the JNI bridge assert the same contract. +func TestSignSchnorrCrossCheckWithJava(t *testing.T) { + t.Parallel() + + privateKeyBytes := hexDecode( + "507d881f0b5e1b12423cb0c84a196fb24227f3fe1540a1c7b20bf78d83de4533") + data := hexDecode( + "ef2ecc1f48c0b28ccaf8f3a8c6477740d869964ebc152a2c5f93f19e7b84b103") + + signature, err := SignSchnorr(privateKeyBytes, data) + require.NoError(t, err) + + require.Equal( + t, + "a560ccf69c0e05b8c61114182c458e19e70da2e89192afb04359a19292bf7f62"+ + "ea026c4ddc20d0e7b9e1279c767d4fa0b347fda88eee0eca7c1d6fce0e499f98", + hex.EncodeToString(signature), + ) +} + +func TestSignSchnorrRejectsNon32ByteData(t *testing.T) { + t.Parallel() + + privateKeyBytes := hexDecode( + "507d881f0b5e1b12423cb0c84a196fb24227f3fe1540a1c7b20bf78d83de4533") + + _, err := SignSchnorr(privateKeyBytes, []byte{0x01, 0x02, 0x03}) + require.Error(t, err) +} diff --git a/libwallet/musig/tapscript_test.go b/libwallet/musig/tapscript_test.go index a25ea011..72298ea9 100644 --- a/libwallet/musig/tapscript_test.go +++ b/libwallet/musig/tapscript_test.go @@ -375,7 +375,8 @@ func TestSpendTapscriptMusig(t *testing.T) { // userKey, <-------------------- redeem // } // ) - description: "key-only(user) spend tapscript. redeeming from random keyspend+tapscript(user)", + description: "key-only(user) spend tapscript. " + + "redeeming from random keyspend+tapscript(user)", internalKey: randomPub, rootScript: tapScriptTree, @@ -592,8 +593,10 @@ func testTapscriptSpend(t *testing.T, tc tapscriptTestCase) { } // signs a message using the globally configured keys -func muunSignMusig(t *testing.T, musigVersion MusigVersion, msg []byte, tweak *MuSig2Tweaks) []byte { - // generate musig sessionId +func muunSignMusig( + t *testing.T, musigVersion MusigVersion, msg []byte, tweak *MuSig2Tweaks, +) []byte { + // generate musig sessionID userSession, err := secp256k1.GeneratePrivateKey() require.NoError(t, err) muunSession, err := secp256k1.GeneratePrivateKey() diff --git a/libwallet/musig/tweaks.go b/libwallet/musig/tweaks.go index d8c6da5f..4424d669 100644 --- a/libwallet/musig/tweaks.go +++ b/libwallet/musig/tweaks.go @@ -3,6 +3,7 @@ package musig import ( "github.com/btcsuite/btcd/btcec/v2" musig2v100 "github.com/btcsuite/btcd/btcec/v2/schnorr/musig2" + "github.com/muun/libwallet/musig2v040" ) @@ -16,20 +17,21 @@ type MuSig2Tweaks struct { // TaprootBIP0086Tweak indicates that the final key should use the // taproot tweak as defined in BIP 341, with the BIP 86 modification: // outputKey = internalKey + h_tapTweak(internalKey)*G. - // In this case, the aggregated key before the tweak will be used as the - // internal key. If this is set to true then TaprootTweak will be + // In this case, the aggregated key before the tweak will be used as + // the internal key. If this is set to true then TaprootTweak will be // ignored. TaprootBIP0086Tweak bool - // TaprootTweak specifies that the final key should use the taproot - // tweak as defined in BIP 341: + // TaprootTweak specifies that the final key should use the + // taproot tweak as defined in BIP 341: // outputKey = internalKey + h_tapTweak(internalKey || scriptRoot). - // In this case, the aggregated key before the tweak will be used as the - // internal key. Will be ignored if TaprootBIP0086Tweak is set to true. + // In this case, the aggregated key before the tweak will be used as + // the internal key. Will be ignored if TaprootBIP0086Tweak is set to + // true. TaprootTweak []byte // Unhardened derivation path specifies the unhardened path to follow in - // order to derivate the final key based on BIP32 + BIP328. This property + // order to derive the final key based on BIP32 + BIP328. This property // produces a list of GenericTweaks to be processed AFTER the provided // GenericTweaks UnhardenedDerivationPath []uint32 @@ -42,7 +44,9 @@ func (t *MuSig2Tweaks) HasTaprootTweak() bool { } // ToContextOptions converts the tweak descriptor to context options. -func (t *MuSig2Tweaks) ToContextOptions(allSignerPubKeys []*btcec.PublicKey) ([]musig2v100.ContextOption, error) { +func (t *MuSig2Tweaks) ToContextOptions( + allSignerPubKeys []*btcec.PublicKey, +) ([]musig2v100.ContextOption, error) { var tweakOpts []musig2v100.ContextOption if len(t.GenericTweaks) > 0 { @@ -63,8 +67,7 @@ func (t *MuSig2Tweaks) ToContextOptions(allSignerPubKeys []*btcec.PublicKey) ([] )) } - // The BIP0086 tweak and the taproot script tweak are mutually - // exclusive. + // The BIP0086 tweak and the taproot script tweak are mutually exclusive. if t.TaprootBIP0086Tweak { tweakOpts = append(tweakOpts, musig2v100.WithBip86TweakCtx()) } else if len(t.TaprootTweak) > 0 { @@ -95,8 +98,7 @@ func (t *MuSig2Tweaks) ToV040ContextOptions() []musig2v040.ContextOption { )) } - // The BIP0086 tweak and the taproot script tweak are mutually - // exclusive. + // The BIP0086 tweak and the taproot script tweak are mutually exclusive. if t.TaprootBIP0086Tweak { tweakOpts = append(tweakOpts, musig2v040.WithBip86TweakCtx()) } else if len(t.TaprootTweak) > 0 { diff --git a/libwallet/musig2v040/README.md b/libwallet/musig2v040/README.md index c127bb74..9eda60c8 100644 --- a/libwallet/musig2v040/README.md +++ b/libwallet/musig2v040/README.md @@ -6,7 +6,7 @@ This package contains a modified copy of the MuSig2 code as found in those modifications, the patch in this directory may become handy. The nature of the modifications is to adapt the behavior to what -`secp256k1-zkp` does. The nonces are generated only by providing a sessionId +`secp256k1-zkp` does. The nonces are generated only by providing a sessionID as rand parameter. Leaving the rest null, the serialization of the preimage of the nonce also varies. diff --git a/libwallet/musig2v040/context.go b/libwallet/musig2v040/context.go index 95fe1290..219ae7ad 100644 --- a/libwallet/musig2v040/context.go +++ b/libwallet/musig2v040/context.go @@ -3,64 +3,62 @@ package musig2v040 import ( - "errors" - "fmt" - "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcec/v2/schnorr" "github.com/btcsuite/btcd/btcec/v2/schnorr/musig2" + "github.com/go-errors/errors" ) var ( // ErrSignersNotSpecified is returned when a caller attempts to create // a context without specifying either the total number of signers, or // the complete set of singers. - ErrSignersNotSpecified = fmt.Errorf("total number of signers or all " + + ErrSignersNotSpecified = errors.Errorf("total number of signers or all " + "signers must be known") // ErrSignerNotInKeySet is returned when a the private key for a signer // isn't included in the set of signing public keys. - ErrSignerNotInKeySet = fmt.Errorf("signing key is not found in key" + + ErrSignerNotInKeySet = errors.Errorf("signing key is not found in key" + " set") // ErrAlredyHaveAllNonces is called when RegisterPubNonce is called too // many times for a given signing session. - ErrAlredyHaveAllNonces = fmt.Errorf("already have all nonces") + ErrAlredyHaveAllNonces = errors.Errorf("already have all nonces") // ErrNotEnoughSigners is returned when a caller attempts to create a // session from a context, but before all the required signers are // known. - ErrNotEnoughSigners = fmt.Errorf("not enough signers") + ErrNotEnoughSigners = errors.Errorf("not enough signers") // ErrAlredyHaveAllNonces is returned when a caller attempts to // register a signer, once we already have the total set of known // signers. - ErrAlreadyHaveAllSigners = fmt.Errorf("all signers registered") + ErrAlreadyHaveAllSigners = errors.Errorf("all signers registered") // ErrAlredyHaveAllSigs is called when CombineSig is called too many // times for a given signing session. - ErrAlredyHaveAllSigs = fmt.Errorf("already have all sigs") + ErrAlredyHaveAllSigs = errors.Errorf("already have all sigs") // ErrSigningContextReuse is returned if a user attempts to sign using // the same signing context more than once. - ErrSigningContextReuse = fmt.Errorf("nonce already used") + ErrSigningContextReuse = errors.Errorf("nonce already used") // ErrFinalSigInvalid is returned when the combined signature turns out // to be invalid. - ErrFinalSigInvalid = fmt.Errorf("final signature is invalid") + ErrFinalSigInvalid = errors.Errorf("final signature is invalid") // ErrCombinedNonceUnavailable is returned when a caller attempts to // sign a partial signature, without first having collected all the // required combined nonces. - ErrCombinedNonceUnavailable = fmt.Errorf("missing combined nonce") + ErrCombinedNonceUnavailable = errors.Errorf("missing combined nonce") // ErrTaprootInternalKeyUnavailable is returned when a user attempts to // obtain the - ErrTaprootInternalKeyUnavailable = fmt.Errorf("taproot tweak not used") + ErrTaprootInternalKeyUnavailable = errors.Errorf("taproot tweak not used") // ErrNotEnoughSigners is returned if a caller attempts to obtain an // early nonce when it wasn't specified - ErrNoEarlyNonce = fmt.Errorf("no early nonce available") + ErrNoEarlyNonce = errors.Errorf("no early nonce available") ) // Context is a managed signing context for musig2. It takes care of things diff --git a/libwallet/musig2v040/keys.go b/libwallet/musig2v040/keys.go index 9016f0b5..fb9da54b 100644 --- a/libwallet/musig2v040/keys.go +++ b/libwallet/musig2v040/keys.go @@ -4,13 +4,13 @@ package musig2v040 import ( "bytes" - "fmt" "sort" "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcec/v2/schnorr" "github.com/btcsuite/btcd/chaincfg/chainhash" secp "github.com/decred/dcrd/dcrec/secp256k1/v4" + "github.com/go-errors/errors" ) var ( @@ -24,11 +24,11 @@ var ( // ErrTweakedKeyIsInfinity is returned if while tweaking a key, we end // up with the point at infinity. - ErrTweakedKeyIsInfinity = fmt.Errorf("tweaked key is infinity point") + ErrTweakedKeyIsInfinity = errors.Errorf("tweaked key is infinity point") // ErrTweakedKeyOverflows is returned if a tweaking key is larger than // 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141. - ErrTweakedKeyOverflows = fmt.Errorf("tweaked key is to large") + ErrTweakedKeyOverflows = errors.Errorf("tweaked key is to large") ) // sortableKeys defines a type of slice of public keys that implements the sort diff --git a/libwallet/musig2v040/nonces.go b/libwallet/musig2v040/nonces.go index 96b8a147..d6dfc72c 100644 --- a/libwallet/musig2v040/nonces.go +++ b/libwallet/musig2v040/nonces.go @@ -55,16 +55,13 @@ func secNonceToPubNonce(secNonce [musig2.SecNonceSize]byte) [musig2.PubNonceSize return pubNonce } -// NonceGenOption is a function option that allows callers to modify how nonce -// generation happens. +// NonceGenOption is a function option that allows callers to modify how nonce generation happens. type NonceGenOption func(*nonceGenOpts) -// nonceGenOpts is the set of options that control how nonce generation -// happens. +// nonceGenOpts is the set of options that control how nonce generation happens. type nonceGenOpts struct { - // randReader is what we'll use to generate a set of random bytes. If - // unspecified, then the normal crypto/rand rand.Read method will be - // used in place. + // randReader is what we'll use to generate a set of random bytes. If unspecified, then the + // normal crypto/rand rand.Read method will be used in place. randReader io.Reader // secretKey is an optional argument that's used to further augment the @@ -75,18 +72,15 @@ type nonceGenOpts struct { // combined along with the nonce generation. combinedKey []byte - // msg is an optional argument that will be mixed into the nonce - // derivation algorithm. + // msg is an optional argument that will be mixed into the nonce derivation algorithm. msg []byte - // auxInput is an optional argument that will be mixed into the nonce - // derivation algorithm. + // auxInput is an optional argument that will be mixed into the nonce derivation algorithm. auxInput []byte } -// cryptoRandAdapter is an adapter struct that allows us to pass in the package -// level Read function from crypto/rand into a context that accepts an -// io.Reader. +// cryptoRandAdapter is an adapter struct that allows us to pass in the package level Read function +// from crypto/rand into a context that accepts an io.Reader. type cryptoRandAdapter struct { } @@ -104,9 +98,8 @@ func defaultNonceGenOpts() *nonceGenOpts { } } -// WithCustomRand allows a caller to use a custom random number generator in -// place for crypto/rand. This should only really be used to generate -// determinstic tests. +// WithCustomRand allows a caller to use a custom random number generator in place for crypto/rand. +// This should only really be used to generate determinstic tests. func WithCustomRand(r io.Reader) NonceGenOption { return func(o *nonceGenOpts) { o.randReader = r @@ -121,9 +114,8 @@ func WithNonceSecretKeyAux(secKey *btcec.PrivateKey) NonceGenOption { } } -// WithNonceCombinedKeyAux allows a caller to optionally specify the combined -// key used in this signing session to further augment the randomness used to -// generate nonces. +// WithNonceCombinedKeyAux allows a caller to optionally specify the combined key used in this +// signing session to further augment the randomness used to generate nonces. func WithNonceCombinedKeyAux(combinedKey *btcec.PublicKey) NonceGenOption { return func(o *nonceGenOpts) { o.combinedKey = schnorr.SerializePubKey(combinedKey) @@ -156,8 +148,8 @@ func WithNonceAuxInput(aux []byte) NonceGenOption { // - return sha256(seed || i) // // where i is the ith secret nonce being generated. -// -// Muun only provides the rand parameter as sessionId. All other parameters are encoded as if they were len=0 +// Muun only provides the rand parameter as sessionID. All other parameters are encoded as if they +// were len=0 func genNonceAuxBytes(rand []byte, i int) ([]byte, error) { var w bytes.Buffer @@ -194,9 +186,8 @@ func genNonceAuxBytes(rand []byte, i int) ([]byte, error) { return nil, err } - // Next we'll write out the interaction/index number which will - // uniquely generate two nonces given the rest of the possibly static - // parameters. + // Next we'll write out the interaction/index number which will uniquely generate two nonces + // given the rest of the possibly static parameters. ith := make([]byte, 1) ith[0] = uint8(i) @@ -212,7 +203,7 @@ func genNonceAuxBytes(rand []byte, i int) ([]byte, error) { // // Pseudo algorithm (|| means byte concat) // -// let seed = TaggedHash("MuSig/nonce", sessionId || 0 || 0 || 0 || 0) +// let seed = TaggedHash("MuSig/nonce", sessionID || 0 || 0 || 0 || 0) // let k = [sha256(seed || 0), sha256(seed || 1)] // let r = k*G // return toPublicKeyFormat(r) @@ -250,9 +241,8 @@ func GenNonces(options ...NonceGenOption) (*musig2.Nonces, error) { k1Mod.PutBytesUnchecked(nonces.SecNonce[:]) k2Mod.PutBytesUnchecked(nonces.SecNonce[btcec.PrivKeyBytesLen:]) - // Next, we'll generate R_1 = k_1*G and R_2 = k_2*G. Along the way we - // need to map our nonce values into mod n scalars so we can work with - // the btcec API. + // Next, we'll generate R_1 = k_1*G and R_2 = k_2*G. Along the way we need to map our nonce + // values into mod n scalars so we can work with the btcec API. nonces.PubNonce = secNonceToPubNonce(nonces.SecNonce) return &nonces, nil @@ -261,18 +251,16 @@ func GenNonces(options ...NonceGenOption) (*musig2.Nonces, error) { // AggregateNonces aggregates the set of a pair of public nonces for each party // into a single aggregated nonces to be used for multi-signing. func AggregateNonces(pubNonces [][musig2.PubNonceSize]byte) ([musig2.PubNonceSize]byte, error) { - // combineNonces is a helper function that aggregates (adds) up a - // series of nonces encoded in compressed format. It uses a slicing - // function to extra 33 bytes at a time from the packed 2x public - // nonces. + // combineNonces is a helper function that aggregates (adds) up a series of nonces encoded in + // compressed format. It uses a slicing function to extra 33 bytes at a time from the packed 2x + // public nonces. type nonceSlicer func([musig2.PubNonceSize]byte) []byte combineNonces := func(slicer nonceSlicer) (btcec.JacobianPoint, error) { // Convert the set of nonces into jacobian coordinates we can // use to accumulate them all into each other. pubNonceJs := make([]*btcec.JacobianPoint, len(pubNonces)) for i, pubNonceBytes := range pubNonces { - // Using the slicer, extract just the bytes we need to - // decode. + // Using the slicer, extract just the bytes we need to decode. var nonceJ btcec.JacobianPoint nonceJ, err := btcec.ParseJacobian(slicer(pubNonceBytes)) diff --git a/libwallet/musig2v040/sign.go b/libwallet/musig2v040/sign.go index 2bb8a618..f4c5740b 100644 --- a/libwallet/musig2v040/sign.go +++ b/libwallet/musig2v040/sign.go @@ -5,7 +5,6 @@ package musig2v040 import ( "bytes" "crypto/sha256" - "fmt" "io" "github.com/btcsuite/btcd/btcec/v2" @@ -13,6 +12,7 @@ import ( "github.com/btcsuite/btcd/btcec/v2/schnorr/musig2" "github.com/btcsuite/btcd/chaincfg/chainhash" secp "github.com/decred/dcrd/dcrec/secp256k1/v4" + "github.com/go-errors/errors" ) var ( @@ -21,20 +21,20 @@ var ( // ErrNoncePointAtInfinity is returned if during signing, the fully // combined public nonce is the point at infinity. - ErrNoncePointAtInfinity = fmt.Errorf("signing nonce is the infinity " + + ErrNoncePointAtInfinity = errors.Errorf("signing nonce is the infinity " + "point") // ErrPrivKeyZero is returned when the private key for signing is // actually zero. - ErrPrivKeyZero = fmt.Errorf("priv key is zero") + ErrPrivKeyZero = errors.Errorf("priv key is zero") // ErrPartialSigInvalid is returned when a partial is found to be // invalid. - ErrPartialSigInvalid = fmt.Errorf("partial signature is invalid") + ErrPartialSigInvalid = errors.Errorf("partial signature is invalid") // ErrSecretNonceZero is returned when a secret nonce is passed in a // zero. - ErrSecretNonceZero = fmt.Errorf("secret nonce is blank") + ErrSecretNonceZero = errors.Errorf("secret nonce is blank") ) // infinityPoint is the jacobian representation of the point at infinity. @@ -354,7 +354,7 @@ func Sign(secNonce [musig2.SecNonceSize]byte, privKey *btcec.PrivateKey, signOpts..., ) if !sigValid { - return nil, fmt.Errorf("sig is invalid") + return nil, errors.Errorf("sig is invalid") } } diff --git a/libwallet/newop/bridge_persistence_fee_bump_functions.go b/libwallet/newop/bridge_persistence_fee_bump_functions.go index 76c7f5d9..b0e24717 100644 --- a/libwallet/newop/bridge_persistence_fee_bump_functions.go +++ b/libwallet/newop/bridge_persistence_fee_bump_functions.go @@ -3,12 +3,11 @@ package newop import ( "encoding/base64" "encoding/binary" - "errors" - "fmt" "math" - "path" "time" + "github.com/go-errors/errors" + "github.com/muun/libwallet" "github.com/muun/libwallet/operation" "github.com/muun/libwallet/walletdb" @@ -18,7 +17,11 @@ const invalidationTimeInSeconds = 150.0 // PersistFeeBumpFunctions This is a bridge that stores fee bump functions // from native apps in the device's local database. -func PersistFeeBumpFunctions(encodedBase64Functions *libwallet.StringList, uuid string, refreshPolicy string) error { +func PersistFeeBumpFunctions( + encodedBase64Functions *libwallet.StringList, + uuid string, + refreshPolicy string, +) error { if encodedBase64Functions == nil { return errors.New("encoded base 64 function list is null") @@ -31,34 +34,23 @@ func PersistFeeBumpFunctions(encodedBase64Functions *libwallet.StringList, uuid feeBumpFunctions := convertToLibwalletFeeBumpFunctions(decodedFunctions, uuid, refreshPolicy) - db, err := walletdb.Open(path.Join(libwallet.Cfg.DataDir, "wallet.db")) - if err != nil { - return err - } - defer db.Close() - - repository := db.NewFeeBumpRepository() - - return repository.Store(feeBumpFunctions) + return libwallet.Pool.WithDB(func(db *walletdb.DB) error { + return db.NewFeeBumpRepository().Store(feeBumpFunctions) + }) } func AreFeeBumpFunctionsInvalidated() bool { - db, err := walletdb.Open(path.Join(libwallet.Cfg.DataDir, "wallet.db")) - if err != nil { - return true - } - defer db.Close() - - repository := db.NewFeeBumpRepository() - creationDate, err := repository.GetCreationDate() - - if err != nil || creationDate == nil { - return true - } - - durationInSeconds := time.Since(*creationDate).Seconds() - - return durationInSeconds >= invalidationTimeInSeconds + var invalidated bool + poolErr := libwallet.Pool.WithDB(func(db *walletdb.DB) error { + creationDate, err := db.NewFeeBumpRepository().GetCreationDate() + if err != nil || creationDate == nil { + invalidated = true + return nil + } + invalidated = time.Since(*creationDate).Seconds() >= invalidationTimeInSeconds + return nil + }) + return poolErr != nil || invalidated } func decodeFunctions(encodedFunctions []string) ([][][]float64, error) { @@ -81,7 +73,7 @@ func decodeFromBase64(base64Function string) ([][]float64, error) { const bytesPerFloat = 4 if len(decodedBytes)%bytesPerFloat != 0 { - return nil, fmt.Errorf( + return nil, errors.Errorf( "decoded bytes length: %d is invalid. It should by multiple of %d", len(decodedBytes), bytesPerFloat, @@ -103,8 +95,9 @@ func decodeFromBase64(base64Function string) ([][]float64, error) { for i := 0; i < len(listOfFloats); i += floatsPerTuple { end := i + floatsPerTuple if end > len(listOfFloats) { - return nil, fmt.Errorf( - "fee bump function was incorrectly encoded; it should be a multiply of %d float numbers, got: %d", + return nil, errors.Errorf( + "fee bump function was incorrectly encoded; "+ + "it should be a multiply of %d float numbers, got: %d", floatsPerTuple, len(listOfFloats), ) diff --git a/libwallet/newop/bridge_persistence_fee_bump_functions_test.go b/libwallet/newop/bridge_persistence_fee_bump_functions_test.go index 773cd9fa..1ffd45c9 100644 --- a/libwallet/newop/bridge_persistence_fee_bump_functions_test.go +++ b/libwallet/newop/bridge_persistence_fee_bump_functions_test.go @@ -69,8 +69,10 @@ func TestPersistFeeBumpFunctions(t *testing.T) { err bool }{ { - desc: "Persist single fee bump function", - encodedFunctionList: []string{"QsgAAAAAAAAAAAAAf4AAAD+AAABAAAAA"}, // [[100, 0, 0], [+Inf, 1, 2]] + desc: "Persist single fee bump function", + encodedFunctionList: []string{ + "QsgAAAAAAAAAAAAAf4AAAD+AAABAAAAA", + }, // [[100, 0, 0], [+Inf, 1, 2]] expectedFunctions: []*operation.FeeBumpFunction{ { PartialLinearFunctions: []*operation.PartialLinearFunction{ @@ -132,11 +134,12 @@ func TestPersistFeeBumpFunctions(t *testing.T) { // Set temporary file for testing libwallet.Init(&app_provided_data.Config{DataDir: t.TempDir()}) - db, err := walletdb.Open(path.Join(libwallet.Cfg.DataDir, "wallet.db")) - if err != nil { + var poolErr error + libwallet.Pool, poolErr = walletdb.NewPool(path.Join(libwallet.Cfg.DataDir, "wallet.db"), nil) + if poolErr != nil { t.Fatalf("error opening DB") } - defer db.Close() + t.Cleanup(func() { libwallet.Pool.Close() }) uuid := "uuid" refreshPolicy := "foreground" @@ -149,9 +152,12 @@ func TestPersistFeeBumpFunctions(t *testing.T) { t.Fatal(err) } - repository := db.NewFeeBumpRepository() - - feeBumpFunctionSet, err := repository.GetAll() + var feeBumpFunctionSet *operation.FeeBumpFunctionSet + err = libwallet.Pool.WithDB(func(db *walletdb.DB) error { + var dbErr error + feeBumpFunctionSet, dbErr = db.NewFeeBumpRepository().GetAll() + return dbErr + }) if err != nil { t.Fatalf("error getting bump functions") @@ -164,7 +170,10 @@ func TestPersistFeeBumpFunctions(t *testing.T) { } for i, expectedFunction := range tC.expectedFunctions { - if !reflect.DeepEqual(expectedFunction.PartialLinearFunctions, feeBumpFunctionSet.FeeBumpFunctions[i].PartialLinearFunctions) { + if !reflect.DeepEqual( + expectedFunction.PartialLinearFunctions, + feeBumpFunctionSet.FeeBumpFunctions[i].PartialLinearFunctions, + ) { t.Fatalf("fee bump functions were not saved properly") } } diff --git a/libwallet/newop/context.go b/libwallet/newop/context.go index b3dbaf9c..0dab3860 100644 --- a/libwallet/newop/context.go +++ b/libwallet/newop/context.go @@ -16,9 +16,8 @@ type InitialPaymentContext struct { // It comprises InitialPaymentContext with data from native apps, // adding properties loaded inside Libwallet. type PaymentContext struct { - //****** InitialPaymentContext ****** - // Copied from InitialPaymentContext to avoid awful nested hierarchy - // on native apps. + //****** InitialPaymentContext ****** Copied from InitialPaymentContext to avoid awful nested + //hierarchy on native apps. FeeWindow *FeeWindow NextTransactionSize *NextTransactionSize ExchangeRateWindow *ExchangeRateWindow @@ -61,7 +60,9 @@ func newPaymentAnalyzer(context *PaymentContext) *operation.PaymentAnalyzer { ) } -func (ipc *InitialPaymentContext) newPaymentContext(feeBumpFunctionSet *operation.FeeBumpFunctionSet) *PaymentContext { +func (ipc *InitialPaymentContext) newPaymentContext( + feeBumpFunctionSet *operation.FeeBumpFunctionSet, +) *PaymentContext { return &PaymentContext{ FeeWindow: ipc.FeeWindow, NextTransactionSize: ipc.NextTransactionSize, diff --git a/libwallet/newop/context_test.go b/libwallet/newop/context_test.go index c8e5e4e1..ef78e824 100644 --- a/libwallet/newop/context_test.go +++ b/libwallet/newop/context_test.go @@ -1,8 +1,9 @@ package newop import ( - "github.com/shopspring/decimal" "testing" + + "github.com/shopspring/decimal" ) var testPaymentContext = createTestPaymentContext() @@ -47,11 +48,17 @@ func TestPaymentContextToBitcoinAmount(t *testing.T) { btcAmount := testPaymentContext.toBitcoinAmount(100_000, "USD") if btcAmount.InSat != 100_000 { - t.Fatalf("expected bitcoin amount in sats to remain unchanged and be 100_000, got %v", btcAmount.InSat) + t.Fatalf( + "expected bitcoin amount in sats to remain unchanged and be 100_000, got %v", + btcAmount.InSat, + ) } if btcAmount.InInputCurrency.Currency != "USD" { - t.Fatalf("expected bitcoin amount input currency to be USD, got %v", btcAmount.InInputCurrency.Currency) + t.Fatalf( + "expected bitcoin amount input currency to be USD, got %v", + btcAmount.InInputCurrency.Currency, + ) } if btcAmount.InInputCurrency.Value.Cmp(decimal.NewFromInt(32)) != 0 { @@ -59,10 +66,16 @@ func TestPaymentContextToBitcoinAmount(t *testing.T) { } if btcAmount.InPrimaryCurrency.Currency != "BTC" { - t.Fatalf("expected bitcoin amount primary currency to be BTC, got %v", btcAmount.InPrimaryCurrency.Currency) + t.Fatalf( + "expected bitcoin amount primary currency to be BTC, got %v", + btcAmount.InPrimaryCurrency.Currency, + ) } if btcAmount.InPrimaryCurrency.Value.Cmp(decimal.NewFromFloat(0.001)) != 0 { - t.Fatalf("expected amount in primary currency to be 0.001, got %v", btcAmount.InPrimaryCurrency.Value) + t.Fatalf( + "expected amount in primary currency to be 0.001, got %v", + btcAmount.InPrimaryCurrency.Value, + ) } } diff --git a/libwallet/newop/exchange_rates.go b/libwallet/newop/exchange_rates.go index 1b9801b9..06ba28f3 100644 --- a/libwallet/newop/exchange_rates.go +++ b/libwallet/newop/exchange_rates.go @@ -1,17 +1,21 @@ package newop import ( - "github.com/muun/libwallet" "github.com/shopspring/decimal" + + "github.com/muun/libwallet" ) // ExchangeRateWindow holds a map of exchange rates from BTC to every currency we handle type ExchangeRateWindow struct { - WindowId int + WindowId int //nolint:staticcheck // TODO: struct field WindowId should be WindowID rates map[string]float64 } -func (w *ExchangeRateWindow) AddRate(currency string, rate float64) { +func (w *ExchangeRateWindow) AddRate( //nolint:staticcheck // TODO: methods on the same type should have the same receiver name (seen 1x "s", 3x "w") + currency string, + rate float64, +) { if w.rates == nil { w.rates = make(map[string]float64) } diff --git a/libwallet/newop/money.go b/libwallet/newop/money.go index fda534b4..c7ddfa73 100644 --- a/libwallet/newop/money.go +++ b/libwallet/newop/money.go @@ -39,7 +39,11 @@ func (m *MonetaryAmount) ValueAsString() string { } func (m *MonetaryAmount) String() string { - return fmt.Sprintf("%v %v", m.Value, m.Currency) // TODO(newop): this is just a stub implementation + return fmt.Sprintf( + "%v %v", + m.Value, + m.Currency, + ) // TODO(newop): this is just a stub implementation } func (m *MonetaryAmount) toBtc(window *ExchangeRateWindow) btcutil.Amount { @@ -59,7 +63,10 @@ func (m *MonetaryAmount) add(n *MonetaryAmount) *MonetaryAmount { } } -func (m *MonetaryAmount) toBitcoinAmount(window *ExchangeRateWindow, primaryCurrency string) *BitcoinAmount { +func (m *MonetaryAmount) toBitcoinAmount( + window *ExchangeRateWindow, + primaryCurrency string, +) *BitcoinAmount { return &BitcoinAmount{ InSat: int64(m.toBtc(window)), InInputCurrency: m, diff --git a/libwallet/newop/money_test.go b/libwallet/newop/money_test.go index 4f316e0e..9397faca 100644 --- a/libwallet/newop/money_test.go +++ b/libwallet/newop/money_test.go @@ -33,15 +33,24 @@ func TestMonetaryAmountToBitcoinAmount(t *testing.T) { } if bitcoinAmount.InPrimaryCurrency.Currency != "USD" { - t.Fatalf("expected converted currency to be USD, got %v", bitcoinAmount.InPrimaryCurrency.Currency) + t.Fatalf( + "expected converted currency to be USD, got %v", + bitcoinAmount.InPrimaryCurrency.Currency, + ) } if bitcoinAmount.InPrimaryCurrency.Value.Cmp(decimal.NewFromInt(32_000)) != 0 { - t.Fatalf("expected converted amount to be 32000, got %v", bitcoinAmount.InInputCurrency.Value) + t.Fatalf( + "expected converted amount to be 32000, got %v", + bitcoinAmount.InInputCurrency.Value, + ) } if bitcoinAmount.InInputCurrency.Currency != "BTC" { - t.Fatalf("expected intput currency to be BTC, got %v", bitcoinAmount.InInputCurrency.Currency) + t.Fatalf( + "expected intput currency to be BTC, got %v", + bitcoinAmount.InInputCurrency.Currency, + ) } if bitcoinAmount.InInputCurrency.Value.Cmp(decimal.NewFromInt(1)) != 0 { diff --git a/libwallet/newop/state.go b/libwallet/newop/state.go index db9b888e..06f969a3 100644 --- a/libwallet/newop/state.go +++ b/libwallet/newop/state.go @@ -3,11 +3,12 @@ package newop import ( "fmt" "log/slog" - "path" "strings" "time" "github.com/btcsuite/btcd/btcutil" + "github.com/go-errors/errors" + "github.com/muun/libwallet" "github.com/muun/libwallet/operation" "github.com/muun/libwallet/walletdb" @@ -97,7 +98,11 @@ func (r Resolved) emitError(error string) error { return nil } -func (r Resolved) emitBalanceError(error string, analysis *operation.PaymentAnalysis, inputCurrency string) error { +func (r Resolved) emitBalanceError( + error string, + analysis *operation.PaymentAnalysis, + inputCurrency string, +) error { toMonetaryAmount := func(sats int64) *MonetaryAmount { window := r.PaymentContext.ExchangeRateWindow @@ -137,7 +142,10 @@ type FeeBumpInfo struct { SecondsSinceLastUpdate int64 } -func NewFeeBumpInfo(feeBumpFunctionSet *operation.FeeBumpFunctionSet, bumpAmount int64) *FeeBumpInfo { +func NewFeeBumpInfo( + feeBumpFunctionSet *operation.FeeBumpFunctionSet, + bumpAmount int64, +) *FeeBumpInfo { if feeBumpFunctionSet == nil { return nil } @@ -248,7 +256,10 @@ func (s *StartState) resolveBip70(uri *libwallet.MuunPaymentURI, network *libwal } } -func (s *StartState) ResolveInvoice(invoice *libwallet.Invoice, network *libwallet.Network) error { +func (s *StartState) ResolveInvoice( + invoice *libwallet.Invoice, + network *libwallet.Network, //nolint:revive // TODO: use or remove network +) error { next := &ResolveState{ BaseState: s.BaseState, PaymentIntent: &PaymentIntent{ @@ -289,27 +300,28 @@ func (s *ResolveState) SetContext(initialContext *InitialPaymentContext) error { } func loadFeeBumpFunctions() (*operation.FeeBumpFunctionSet, error) { - db, err := walletdb.Open(path.Join(libwallet.Cfg.DataDir, "wallet.db")) - if err != nil { - return nil, err - } - defer db.Close() - - repository := db.NewFeeBumpRepository() - feeBumpFunctionSet, err := repository.GetAll() - - if err != nil { + var feeBumpFunctionSet *operation.FeeBumpFunctionSet + if err := libwallet.Pool.WithDB(func(db *walletdb.DB) error { + var err error + feeBumpFunctionSet, err = db.NewFeeBumpRepository().GetAll() + return err + }); err != nil { return nil, err } - return feeBumpFunctionSet, nil } -// setContextWithTime is meant only for testing, allows caller to use a fixed time to check invoice expiration -func (s *ResolveState) setContextWithTime(initialContext *InitialPaymentContext, now time.Time) error { +// setContextWithTime is meant only for testing, allows caller to use a fixed time to check invoice +// expiration +func (s *ResolveState) setContextWithTime( + initialContext *InitialPaymentContext, + now time.Time, +) error { var feeBumpFunctionSet *operation.FeeBumpFunctionSet - if libwallet.DetermineBackendActivatedFeatureStatus(libwallet.BackendFeatureEffectiveFeesCalculation) { + if libwallet.DetermineBackendActivatedFeatureStatus( + libwallet.BackendFeatureEffectiveFeesCalculation, + ) { // Load fee bump functions from local DB var err error feeBumpFunctionSet, err = loadFeeBumpFunctions() @@ -380,7 +392,11 @@ func (s *ResolveState) emitAmount(context *PaymentContext, totalBalance *Bitcoin return nil } -func (s *ResolveState) emitValidateLightning(context *PaymentContext, invoice *libwallet.Invoice, totalBalance *BitcoinAmount) error { +func (s *ResolveState) emitValidateLightning( + context *PaymentContext, + invoice *libwallet.Invoice, + totalBalance *BitcoinAmount, +) error { presetAmount := context.toBitcoinAmount(invoice.Sats, "BTC") presetNote := s.PaymentIntent.URI.Invoice.Description nextState := &ValidateLightningState{ @@ -403,7 +419,11 @@ func (s *ResolveState) emitValidateLightning(context *PaymentContext, invoice *l return nil } -func (s *ResolveState) emitValidate(resolved *Resolved, amount *BitcoinAmount, context *PaymentContext) error { +func (s *ResolveState) emitValidate( + resolved *Resolved, + amount *BitcoinAmount, + context *PaymentContext, +) error { nextState := &ValidateState{ Resolved: resolved, @@ -464,7 +484,7 @@ func (s *EnterAmountState) EnterAmount(amount *MonetaryAmount, takeFeeFromAmount FeeRateInSatsPerVByte: feeWindow.FastestFeeRate(), } - if s.Resolved.PaymentIntent.URI.Invoice != nil { + if s.Resolved.PaymentIntent.URI.Invoice != nil { //nolint:staticcheck // TODO: could remove embedded field "Resolved" from selector nextState := &ValidateLightningState{ Resolved: s.Resolved, @@ -478,7 +498,7 @@ func (s *EnterAmountState) EnterAmount(amount *MonetaryAmount, takeFeeFromAmount nextState := &ValidateState{ Resolved: s.Resolved, AmountInfo: amountInfo, - Note: s.Resolved.PaymentIntent.URI.Message, + Note: s.Resolved.PaymentIntent.URI.Message, //nolint:staticcheck // TODO: could remove embedded field "Resolved" from selector } nextState.emit() } @@ -508,11 +528,14 @@ func (s *EnterAmountState) ChangeCurrency(currency string) error { return s.ChangeCurrencyWithAmount(currency, s.Amount.InInputCurrency) } -// ChangeCurrencyWithAmount respond to the user action of changing the current input currency to a new one, -// while also updating the input amount, needed for performing the necessary conversion. -// Note: this state machine doesn't receive partial updates for the input amount each time the -// user types or deletes a digit, so ChangeCurrencyWithAmount needs to receive the updates input amount. -func (s *EnterAmountState) ChangeCurrencyWithAmount(currency string, inputAmount *MonetaryAmount) error { +// ChangeCurrencyWithAmount respond to the user action of changing the current input currency to a +// new one, while also updating the input amount, needed for performing the necessary conversion. +// Note: this state machine doesn't receive partial updates for the input amount each time the user +// types or deletes a digit, so ChangeCurrencyWithAmount needs to receive the updates input amount. +func (s *EnterAmountState) ChangeCurrencyWithAmount( + currency string, + inputAmount *MonetaryAmount, +) error { exchangeRateWindow := s.PaymentContext.ExchangeRateWindow newTotalBalance := s.PaymentContext.toBitcoinAmount( @@ -527,9 +550,11 @@ func (s *EnterAmountState) ChangeCurrencyWithAmount(currency string, inputAmount amount = newTotalBalance } else { amount = &BitcoinAmount{ - InSat: int64(inputAmount.toBtc(exchangeRateWindow)), - InInputCurrency: exchangeRateWindow.convert(inputAmount, currency), - InPrimaryCurrency: exchangeRateWindow.convert(inputAmount, s.PaymentContext.PrimaryCurrency), + InSat: int64(inputAmount.toBtc(exchangeRateWindow)), + InInputCurrency: exchangeRateWindow.convert(inputAmount, currency), + InPrimaryCurrency: exchangeRateWindow.convert( + inputAmount, s.PaymentContext.PrimaryCurrency, + ), } } @@ -716,20 +741,27 @@ func (s *ValidateState) Continue() error { return s.emitBalanceError(OperationErrorUnpayable, analysis, inputCurrency) case operation.AnalysisStatusAmountGreaterThanBalance: - return s.emitBalanceError(OperationErrorAmountGreaterThanBalance, analysis, inputCurrency) + return s.emitBalanceError( + OperationErrorAmountGreaterThanBalance, + analysis, + inputCurrency, + ) case operation.AnalysisStatusAmountTooSmall: return s.emitError(OperationErrorAmountTooSmall) } default: - return fmt.Errorf("unrecognized analysis status: %v", analysis.Status) + return errors.Errorf("unrecognized analysis status: %v", analysis.Status) } return nil } -func (s *ValidateState) emitAnalysisOk(analysis *operation.PaymentAnalysis, feeNeedsChange bool) error { +func (s *ValidateState) emitAnalysisOk( + analysis *operation.PaymentAnalysis, + feeNeedsChange bool, +) error { amount := s.Amount if s.TakeFeeFromAmount { @@ -753,9 +785,11 @@ func (s *ValidateState) emitAnalysisOk(analysis *operation.PaymentAnalysis, feeN FeeBumpInfo: feeBumpInfo, } - amountInfo := s.AmountInfo.mutating(func(info *AmountInfo) { - info.Amount = amount - }) + amountInfo := s.AmountInfo.mutating( //nolint:staticcheck // TODO: could remove embedded field "AmountInfo" from selector + func(info *AmountInfo) { + info.Amount = amount + }, + ) if s.PaymentIntent.URI.Message != "" || s.Note != "" { @@ -842,7 +876,7 @@ func (s *ValidateLightningState) Continue() error { return s.emitError(OperationErrorAmountTooSmall) default: - return fmt.Errorf("unrecognized analysis status: %v", analysis.Status) + return errors.Errorf("unrecognized analysis status: %v", analysis.Status) } return nil @@ -901,9 +935,11 @@ func (s *ValidateLightningState) emitAnalysisOk(analysis *operation.PaymentAnaly FeeBumpInfo: feeBumpInfo, } - amountInfo := s.AmountInfo.mutating(func(info *AmountInfo) { - info.Amount = amount - }) + amountInfo := s.AmountInfo.mutating( //nolint:staticcheck // TODO: could remove embedded field "AmountInfo" from selector + func(info *AmountInfo) { + info.Amount = amount + }, + ) if note != "" { @@ -1071,15 +1107,17 @@ func (s *EditFeeState) CalculateFee(rateInSatsPerVByte float64) (*FeeState, erro FeeBumpInfo: feeBumpInfo, }, nil default: - return nil, fmt.Errorf("unrecognized analysis status: %v", analysis.Status) + return nil, errors.Errorf("unrecognized analysis status: %v", analysis.Status) } } func (s *EditFeeState) SetFeeRate(rateInSatsPerVByte float64) error { // We deref to copy before mutating - amountInfo := s.AmountInfo.mutating(func(info *AmountInfo) { - info.FeeRateInSatsPerVByte = rateInSatsPerVByte - }) + amountInfo := s.AmountInfo.mutating( //nolint:staticcheck // TODO: could remove embedded field "AmountInfo" from selector + func(info *AmountInfo) { + info.FeeRateInSatsPerVByte = rateInSatsPerVByte + }, + ) nextState := &ValidateState{ Resolved: s.Resolved, diff --git a/libwallet/newop/state_test.go b/libwallet/newop/state_test.go index d159b674..b01d2a97 100644 --- a/libwallet/newop/state_test.go +++ b/libwallet/newop/state_test.go @@ -6,11 +6,12 @@ import ( "testing" "time" + "github.com/shopspring/decimal" + "github.com/muun/libwallet" "github.com/muun/libwallet/app_provided_data" "github.com/muun/libwallet/operation" "github.com/muun/libwallet/walletdb" - "github.com/shopspring/decimal" ) type testListener struct { @@ -44,7 +45,9 @@ var testContext = createInitialContext() type TestBackendActivatedFeatureStatusProvider struct{} -func (t TestBackendActivatedFeatureStatusProvider) IsBackendFlagEnabled(flag string) bool { +func (t TestBackendActivatedFeatureStatusProvider) IsBackendFlagEnabled( + flag string, //nolint:revive // TODO: use or remove flag +) bool { return true } @@ -76,11 +79,18 @@ func createInitialContext() *InitialPaymentContext { } func setupStateTests(t *testing.T) { - + dir := t.TempDir() libwallet.Init(&app_provided_data.Config{ - DataDir: t.TempDir(), + DataDir: dir, FeatureStatusProvider: TestBackendActivatedFeatureStatusProvider{}, }) + + pool, err := walletdb.NewPool(path.Join(dir, "wallet.db"), nil) + if err != nil { + t.Fatalf("failed to open test DB: %v", err) + } + libwallet.Pool = pool + t.Cleanup(func() { libwallet.Pool.Close() }) } //goland:noinspection GoUnhandledErrorResult @@ -90,7 +100,10 @@ func TestBarebonesOnChainFixedAmountFixedFee(t *testing.T) { listener := newTestListener() startState := NewOperationFlow(listener) - startState.Resolve("bitcoin:bcrt1qj35fkq34xend9w0ssthn432vl9pxxsuy0epzlu?amount=0.1&description=foo", libwallet.Regtest()) + startState.Resolve( + "bitcoin:bcrt1qj35fkq34xend9w0ssthn432vl9pxxsuy0epzlu?amount=0.1&description=foo", + libwallet.Regtest(), + ) resolveState := listener.next().(*ResolveState) @@ -108,7 +121,10 @@ func TestBarebonesOnChainFixedAmountFixedFee(t *testing.T) { t.Fatalf("expected note to match input, got '%v'", confirmState.Note) } if confirmState.Amount.InInputCurrency.String() != "0.1 BTC" { - t.Fatalf("expected amount to match resolved URI, got %v", confirmState.Amount.InInputCurrency) + t.Fatalf( + "expected amount to match resolved URI, got %v", + confirmState.Amount.InInputCurrency, + ) } if confirmState.Fee.InInputCurrency.String() != "0.00096 BTC" { t.Fatalf("expected fee to match, got %v", confirmState.Fee.InInputCurrency) @@ -125,7 +141,10 @@ func TestBarebonesOnChainFixedAmountFixedDescriptionFixedFee(t *testing.T) { listener := newTestListener() startState := NewOperationFlow(listener) - startState.Resolve("bitcoin:bcrt1qj35fkq34xend9w0ssthn432vl9pxxsuy0epzlu?amount=0.1&message=foo", libwallet.Regtest()) + startState.Resolve( + "bitcoin:bcrt1qj35fkq34xend9w0ssthn432vl9pxxsuy0epzlu?amount=0.1&message=foo", + libwallet.Regtest(), + ) resolveState := listener.next().(*ResolveState) @@ -140,7 +159,10 @@ func TestBarebonesOnChainFixedAmountFixedDescriptionFixedFee(t *testing.T) { t.Fatalf("expected note to match input, got '%v'", confirmState.Note) } if confirmState.Amount.InInputCurrency.String() != "0.1 BTC" { - t.Fatalf("expected amount to match resolved URI, got %v", confirmState.Amount.InInputCurrency) + t.Fatalf( + "expected amount to match resolved URI, got %v", + confirmState.Amount.InInputCurrency, + ) } if confirmState.Fee.InInputCurrency.String() != "0.00096 BTC" { t.Fatalf("expected fee to match, got %v", confirmState.Fee.InInputCurrency) @@ -156,7 +178,10 @@ func TestOnChainFixedAmountChangeFee(t *testing.T) { listener := newTestListener() startState := NewOperationFlow(listener) - startState.Resolve("bitcoin:bcrt1qj35fkq34xend9w0ssthn432vl9pxxsuy0epzlu?amount=0.1&description=foo", libwallet.Regtest()) + startState.Resolve( + "bitcoin:bcrt1qj35fkq34xend9w0ssthn432vl9pxxsuy0epzlu?amount=0.1&description=foo", + libwallet.Regtest(), + ) resolveState := listener.next().(*ResolveState) @@ -202,7 +227,10 @@ func TestOnChainFixedAmountChangeFee(t *testing.T) { t.Fatalf("expected note to match input, got '%v'", confirmState.Note) } if confirmState.Amount.InInputCurrency.String() != "0.1 BTC" { - t.Fatalf("expected amount to match resolved URI, got %v", confirmState.Amount.InInputCurrency) + t.Fatalf( + "expected amount to match resolved URI, got %v", + confirmState.Amount.InInputCurrency, + ) } if confirmState.Fee.InInputCurrency.String() != "0.000036 BTC" { t.Fatalf("expected fee to match, got %v", confirmState.Fee.InInputCurrency) @@ -218,7 +246,10 @@ func TestOnChainFixedAmountChangeFeeWithFeeBump(t *testing.T) { listener := newTestListener() startState := NewOperationFlow(listener) - startState.Resolve("bitcoin:bcrt1qj35fkq34xend9w0ssthn432vl9pxxsuy0epzlu?amount=1.0&description=foo", libwallet.Regtest()) + startState.Resolve( + "bitcoin:bcrt1qj35fkq34xend9w0ssthn432vl9pxxsuy0epzlu?amount=1.0&description=foo", + libwallet.Regtest(), + ) resolveState := listener.next().(*ResolveState) @@ -239,28 +270,25 @@ func TestOnChainFixedAmountChangeFeeWithFeeBump(t *testing.T) { }) context.NextTransactionSize = &nextTransactionSize - db, err := walletdb.Open(path.Join(libwallet.Cfg.DataDir, "wallet.db")) - if err != nil { - t.Fatal(err) - } - defer db.Close() - - repository := db.NewFeeBumpRepository() - repository.Store(&operation.FeeBumpFunctionSet{ - UUID: "uuid", - FeeBumpFunctions: []*operation.FeeBumpFunction{ - { - PartialLinearFunctions: []*operation.PartialLinearFunction{ - { - LeftClosedEndpoint: 0, - RightOpenEndpoint: math.Inf(1), - Slope: 2, - Intercept: 300, + if err := libwallet.Pool.WithDB(func(db *walletdb.DB) error { + return db.NewFeeBumpRepository().Store(&operation.FeeBumpFunctionSet{ + UUID: "uuid", + FeeBumpFunctions: []*operation.FeeBumpFunction{ + { + PartialLinearFunctions: []*operation.PartialLinearFunction{ + { + LeftClosedEndpoint: 0, + RightOpenEndpoint: math.Inf(1), + Slope: 2, + Intercept: 300, + }, }, }, }, - }, - }) + }) + }); err != nil { + t.Fatal(err) + } resolveState.SetContext(&context) @@ -350,7 +378,10 @@ func TestOnChainFixedAmountChangeFeeWithFeeBump(t *testing.T) { t.Fatalf("expected note to match input, got '%v'", confirmState.Note) } if confirmState.Amount.InInputCurrency.String() != "1 BTC" { - t.Fatalf("expected amount to match resolved URI, got %v", confirmState.Amount.InInputCurrency) + t.Fatalf( + "expected amount to match resolved URI, got %v", + confirmState.Amount.InInputCurrency, + ) } if confirmState.Fee.InInputCurrency.String() != "0.0000783 BTC" { t.Fatalf("expected fee to match, got %v", confirmState.Fee.InInputCurrency) @@ -366,7 +397,10 @@ func TestOnChainFixedAmountFeeNeedsChange(t *testing.T) { listener := newTestListener() startState := NewOperationFlow(listener) - startState.Resolve("bitcoin:bcrt1qj35fkq34xend9w0ssthn432vl9pxxsuy0epzlu?amount=0.9999&description=foo", libwallet.Regtest()) + startState.Resolve( + "bitcoin:bcrt1qj35fkq34xend9w0ssthn432vl9pxxsuy0epzlu?amount=0.9999&description=foo", + libwallet.Regtest(), + ) resolveState := listener.next().(*ResolveState) @@ -385,7 +419,10 @@ func TestOnChainFixedAmountFeeNeedsChange(t *testing.T) { } if !confirmState.FeeNeedsChange { - t.Fatalf("expected initial fee to be unpayable and need changing, got %v which is unpayable", confirmState.FeeRateInSatsPerVByte) + t.Fatalf( + "expected initial fee to be unpayable and need changing, got %v which is unpayable", + confirmState.FeeRateInSatsPerVByte, + ) } newFeeRate := 15.0 @@ -413,14 +450,20 @@ func TestOnChainFixedAmountFeeNeedsChange(t *testing.T) { confirmState = listener.next().(*ConfirmState) if confirmState.FeeNeedsChange { - t.Fatalf("expected fee to be payable, got %v which is not unpayable", confirmState.FeeRateInSatsPerVByte) + t.Fatalf( + "expected fee to be payable, got %v which is not unpayable", + confirmState.FeeRateInSatsPerVByte, + ) } if confirmState.Note != "bar" { t.Fatalf("expected note to match input, got '%v'", confirmState.Note) } if confirmState.Amount.InInputCurrency.String() != "0.9999 BTC" { - t.Fatalf("expected amount to match resolved URI, got %v", confirmState.Amount.InInputCurrency) + t.Fatalf( + "expected amount to match resolved URI, got %v", + confirmState.Amount.InInputCurrency, + ) } if confirmState.Fee.InInputCurrency.String() != "0.000036 BTC" { t.Fatalf("expected fee to match, got %v", confirmState.Fee.InInputCurrency) @@ -436,7 +479,10 @@ func TestOnChainFixedAmountNoPossibleFee(t *testing.T) { listener := newTestListener() startState := NewOperationFlow(listener) - startState.Resolve("bitcoin:bcrt1qj35fkq34xend9w0ssthn432vl9pxxsuy0epzlu?amount=0.9999999&description=foo", libwallet.Regtest()) + startState.Resolve( + "bitcoin:bcrt1qj35fkq34xend9w0ssthn432vl9pxxsuy0epzlu?amount=0.9999999&description=foo", + libwallet.Regtest(), + ) resolveState := listener.next().(*ResolveState) @@ -462,7 +508,10 @@ func TestOnChainFixedAmountTooSmall(t *testing.T) { listener := newTestListener() startState := NewOperationFlow(listener) - startState.Resolve("bitcoin:bcrt1qj35fkq34xend9w0ssthn432vl9pxxsuy0epzlu?amount=0.0000004&description=foo", libwallet.Regtest()) + startState.Resolve( + "bitcoin:bcrt1qj35fkq34xend9w0ssthn432vl9pxxsuy0epzlu?amount=0.0000004&description=foo", + libwallet.Regtest(), + ) resolveState := listener.next().(*ResolveState) @@ -480,7 +529,10 @@ func TestOnChainFixedAmountGreaterThanbalance(t *testing.T) { listener := newTestListener() startState := NewOperationFlow(listener) - startState.Resolve("bitcoin:bcrt1qj35fkq34xend9w0ssthn432vl9pxxsuy0epzlu?amount=2.0&description=foo", libwallet.Regtest()) + startState.Resolve( + "bitcoin:bcrt1qj35fkq34xend9w0ssthn432vl9pxxsuy0epzlu?amount=2.0&description=foo", + libwallet.Regtest(), + ) resolveState := listener.next().(*ResolveState) @@ -549,7 +601,10 @@ func TestOnChainTFFA(t *testing.T) { enterDescriptionState := listener.next().(*EnterDescriptionState) enterDescriptionState.EnterDescription("bar") if enterDescriptionState.Amount.InInputCurrency.String() != "0.99904 BTC" { - t.Fatalf("expected amount to match input, got %v", enterDescriptionState.Amount.InInputCurrency) + t.Fatalf( + "expected amount to match input, got %v", + enterDescriptionState.Amount.InInputCurrency, + ) } confirmState := listener.next().(*ConfirmState) @@ -575,7 +630,10 @@ func TestOnChainTFFA(t *testing.T) { t.Fatalf("expected note to match input, got '%v'", confirmState.Note) } if enterDescriptionState.Amount.InInputCurrency.String() != "0.99904 BTC" { - t.Fatalf("expected amount to match input, got %v", enterDescriptionState.Amount.InInputCurrency) + t.Fatalf( + "expected amount to match input, got %v", + enterDescriptionState.Amount.InInputCurrency, + ) } } @@ -585,7 +643,10 @@ func TestInvalidAmountEmitsInvalidAddress(t *testing.T) { listener := newTestListener() startState := NewOperationFlow(listener) - startState.Resolve("bitcoin:bcrt1qj35fkq34xend9w0ssthn432vl9pxxsuy0epzlu?amount=bananabanana", libwallet.Regtest()) + startState.Resolve( + "bitcoin:bcrt1qj35fkq34xend9w0ssthn432vl9pxxsuy0epzlu?amount=bananabanana", + libwallet.Regtest(), + ) resolveState := listener.next().(*ResolveState) @@ -626,7 +687,8 @@ func TestOnChainBack(t *testing.T) { enterDescriptionState.Back() enterAmountState = listener.next().(*EnterAmountState) - // TODO when deleting this method impl (deprecated) rm lines below up until the call to ChangeCurrencyWithAmount + // TODO when deleting this method impl (deprecated) rm lines below up until the call to + // ChangeCurrencyWithAmount enterAmountState.ChangeCurrency("USD") enterAmountState = listener.next().(*EnterAmountState) @@ -697,7 +759,11 @@ func TestOnChainChangeCurrency(t *testing.T) { inputAmountCurrency := enterAmountState.Amount.InInputCurrency.Currency balanceCurrency := enterAmountState.TotalBalance.InInputCurrency.Currency if inputAmountCurrency != balanceCurrency { - t.Fatalf("expected amount currency (%v) to match balance currency (%v)", inputAmountCurrency, balanceCurrency) + t.Fatalf( + "expected amount currency (%v) to match balance currency (%v)", + inputAmountCurrency, + balanceCurrency, + ) } enterAmountState.EnterAmount(NewMonetaryAmountFromSatoshis(100_000_000), true) @@ -706,13 +772,17 @@ func TestOnChainChangeCurrency(t *testing.T) { enterDescriptionState := listener.next().(*EnterDescriptionState) if enterDescriptionState.Amount.InInputCurrency.String() != "0.99904 BTC" { - t.Fatalf("expected amount to match input, got %v", enterDescriptionState.Amount.InInputCurrency) + t.Fatalf( + "expected amount to match input, got %v", + enterDescriptionState.Amount.InInputCurrency, + ) } enterDescriptionState.Back() enterAmountState = listener.next().(*EnterAmountState) - // TODO when deleting this method impl (deprecated) rm lines below up until the call to ChangeCurrencyWithAmount + // TODO when deleting this method impl (deprecated) rm lines below up until the call to + // ChangeCurrencyWithAmount enterAmountState.ChangeCurrency("USD") enterAmountState = listener.next().(*EnterAmountState) @@ -723,10 +793,16 @@ func TestOnChainChangeCurrency(t *testing.T) { t.Fatalf("expected amount to match 100_000_000, got '%v'", enterAmountState.Amount.InSat) } if enterAmountState.Amount.InInputCurrency.String() != "32000 USD" { - t.Fatalf("expected amount to match 32000 USD, got '%v'", enterAmountState.Amount.InInputCurrency.String()) + t.Fatalf( + "expected amount to match 32000 USD, got '%v'", + enterAmountState.Amount.InInputCurrency.String(), + ) } if enterAmountState.Amount.InPrimaryCurrency.String() != "1 BTC" { - t.Fatalf("expected amount to match 1 BTC, got '%v'", enterAmountState.Amount.InPrimaryCurrency.String()) + t.Fatalf( + "expected amount to match 1 BTC, got '%v'", + enterAmountState.Amount.InPrimaryCurrency.String(), + ) } enterAmountState.ChangeCurrency("BTC") @@ -738,10 +814,16 @@ func TestOnChainChangeCurrency(t *testing.T) { t.Fatalf("expected amount to match 100_000_000, got '%v'", enterAmountState.Amount.InSat) } if enterAmountState.Amount.InInputCurrency.String() != "1 BTC" { - t.Fatalf("expected amount to match 1 BTC, got '%v'", enterAmountState.Amount.InInputCurrency.String()) + t.Fatalf( + "expected amount to match 1 BTC, got '%v'", + enterAmountState.Amount.InInputCurrency.String(), + ) } if enterAmountState.Amount.InPrimaryCurrency.String() != "1 BTC" { - t.Fatalf("expected amount to match 1 BTC, got '%v'", enterAmountState.Amount.InPrimaryCurrency.String()) + t.Fatalf( + "expected amount to match 1 BTC, got '%v'", + enterAmountState.Amount.InPrimaryCurrency.String(), + ) } enterAmountState.ChangeCurrencyWithAmount("USD", NewMonetaryAmountFromSatoshis(1_000_000)) @@ -753,10 +835,16 @@ func TestOnChainChangeCurrency(t *testing.T) { t.Fatalf("expected amount to match 1_000_000, got '%v'", enterAmountState.Amount.InSat) } if enterAmountState.Amount.InInputCurrency.String() != "320 USD" { - t.Fatalf("expected amount to match 320 USD, got '%v'", enterAmountState.Amount.InInputCurrency.String()) + t.Fatalf( + "expected amount to match 320 USD, got '%v'", + enterAmountState.Amount.InInputCurrency.String(), + ) } if enterAmountState.Amount.InPrimaryCurrency.String() != "0.01 BTC" { - t.Fatalf("expected amount to match 0.01 BTC, got '%v'", enterAmountState.Amount.InPrimaryCurrency.String()) + t.Fatalf( + "expected amount to match 0.01 BTC, got '%v'", + enterAmountState.Amount.InPrimaryCurrency.String(), + ) } enterAmountState.ChangeCurrencyWithAmount("BTC", enterAmountState.Amount.InInputCurrency) @@ -768,10 +856,16 @@ func TestOnChainChangeCurrency(t *testing.T) { t.Fatalf("expected amount to match 1_000_000, got '%v'", enterAmountState.Amount.InSat) } if enterAmountState.Amount.InInputCurrency.String() != "0.01 BTC" { - t.Fatalf("expected amount to match 0.01 BTC, got '%v'", enterAmountState.Amount.InInputCurrency.String()) + t.Fatalf( + "expected amount to match 0.01 BTC, got '%v'", + enterAmountState.Amount.InInputCurrency.String(), + ) } if enterAmountState.Amount.InPrimaryCurrency.String() != "0.01 BTC" { - t.Fatalf("expected amount to match 0.01 BTC, got '%v'", enterAmountState.Amount.InPrimaryCurrency.String()) + t.Fatalf( + "expected amount to match 0.01 BTC, got '%v'", + enterAmountState.Amount.InPrimaryCurrency.String(), + ) } enterDescriptionState.EnterDescription("bar") @@ -798,7 +892,10 @@ func TestOnChainChangeCurrency(t *testing.T) { t.Fatalf("expected note to match input, got '%v'", confirmState.Note) } if enterDescriptionState.Amount.InInputCurrency.String() != "0.99904 BTC" { - t.Fatalf("expected amount to match input, got %v", enterDescriptionState.Amount.InInputCurrency) + t.Fatalf( + "expected amount to match input, got %v", + enterDescriptionState.Amount.InInputCurrency, + ) } } @@ -809,7 +906,10 @@ func TestLightningSendZeroFunds(t *testing.T) { listener := newTestListener() startState := NewOperationFlow(listener) - invoice, err := libwallet.ParseInvoice("lnbcrt1ps3l7zlpp5ngv7sl4wrjalma9navd0w9956pu0tcqwrltcnnzz83eeyk4rszxqdqqcqzpgrzjq2eawnq2ywdmcpe56nk02tfamgfmsn0acp0zcn8z8cr0djkgpslr5qqpkgqqqqgqqqqqqqlgqqqqqqgq9qrzjq2eawnq2ywdmcpe56nk02tfamgfmsn0acp0zcn8z8cr0djkgpslr5qqpkgqqqqsqqqqqqqlgqqqqqqgq9qsp5luyagw4mtcq735je8ldukhlkg063cxzycjhpz2x2hjfq2mgk5xns9qyyssq7ydzdwyl7yr6ldpzqjjspmgrevw4lxt4jwfy3cxm7we20wveqq8p8khjxuq9u3v953e7t9r8ysfzx5r874vu3nd7w5yx5eqfxu0tevspgxr607", libwallet.Regtest()) + invoice, err := libwallet.ParseInvoice( + "lnbcrt1ps3l7zlpp5ngv7sl4wrjalma9navd0w9956pu0tcqwrltcnnzz83eeyk4rszxqdqqcqzpgrzjq2eawnq2ywdmcpe56nk02tfamgfmsn0acp0zcn8z8cr0djkgpslr5qqpkgqqqqgqqqqqqqlgqqqqqqgq9qrzjq2eawnq2ywdmcpe56nk02tfamgfmsn0acp0zcn8z8cr0djkgpslr5qqpkgqqqqsqqqqqqqlgqqqqqqgq9qsp5luyagw4mtcq735je8ldukhlkg063cxzycjhpz2x2hjfq2mgk5xns9qyyssq7ydzdwyl7yr6ldpzqjjspmgrevw4lxt4jwfy3cxm7we20wveqq8p8khjxuq9u3v953e7t9r8ysfzx5r874vu3nd7w5yx5eqfxu0tevspgxr607", //nolint:lll + libwallet.Regtest(), + ) if err != nil { panic(err) } @@ -857,7 +957,10 @@ func TestLightningSendZeroFundsTFFA(t *testing.T) { listener := newTestListener() startState := NewOperationFlow(listener) - invoice, err := libwallet.ParseInvoice("lnbcrt1ps3l7zlpp5ngv7sl4wrjalma9navd0w9956pu0tcqwrltcnnzz83eeyk4rszxqdqqcqzpgrzjq2eawnq2ywdmcpe56nk02tfamgfmsn0acp0zcn8z8cr0djkgpslr5qqpkgqqqqgqqqqqqqlgqqqqqqgq9qrzjq2eawnq2ywdmcpe56nk02tfamgfmsn0acp0zcn8z8cr0djkgpslr5qqpkgqqqqsqqqqqqqlgqqqqqqgq9qsp5luyagw4mtcq735je8ldukhlkg063cxzycjhpz2x2hjfq2mgk5xns9qyyssq7ydzdwyl7yr6ldpzqjjspmgrevw4lxt4jwfy3cxm7we20wveqq8p8khjxuq9u3v953e7t9r8ysfzx5r874vu3nd7w5yx5eqfxu0tevspgxr607", libwallet.Regtest()) + invoice, err := libwallet.ParseInvoice( + "lnbcrt1ps3l7zlpp5ngv7sl4wrjalma9navd0w9956pu0tcqwrltcnnzz83eeyk4rszxqdqqcqzpgrzjq2eawnq2ywdmcpe56nk02tfamgfmsn0acp0zcn8z8cr0djkgpslr5qqpkgqqqqgqqqqqqqlgqqqqqqgq9qrzjq2eawnq2ywdmcpe56nk02tfamgfmsn0acp0zcn8z8cr0djkgpslr5qqpkgqqqqsqqqqqqqlgqqqqqqgq9qsp5luyagw4mtcq735je8ldukhlkg063cxzycjhpz2x2hjfq2mgk5xns9qyyssq7ydzdwyl7yr6ldpzqjjspmgrevw4lxt4jwfy3cxm7we20wveqq8p8khjxuq9u3v953e7t9r8ysfzx5r874vu3nd7w5yx5eqfxu0tevspgxr607", //nolint:lll + libwallet.Regtest(), + ) if err != nil { panic(err) } @@ -905,7 +1008,10 @@ func TestLightningSendNegativeFunds(t *testing.T) { listener := newTestListener() startState := NewOperationFlow(listener) - invoice, err := libwallet.ParseInvoice("lnbcrt1ps3l7zlpp5ngv7sl4wrjalma9navd0w9956pu0tcqwrltcnnzz83eeyk4rszxqdqqcqzpgrzjq2eawnq2ywdmcpe56nk02tfamgfmsn0acp0zcn8z8cr0djkgpslr5qqpkgqqqqgqqqqqqqlgqqqqqqgq9qrzjq2eawnq2ywdmcpe56nk02tfamgfmsn0acp0zcn8z8cr0djkgpslr5qqpkgqqqqsqqqqqqqlgqqqqqqgq9qsp5luyagw4mtcq735je8ldukhlkg063cxzycjhpz2x2hjfq2mgk5xns9qyyssq7ydzdwyl7yr6ldpzqjjspmgrevw4lxt4jwfy3cxm7we20wveqq8p8khjxuq9u3v953e7t9r8ysfzx5r874vu3nd7w5yx5eqfxu0tevspgxr607", libwallet.Regtest()) + invoice, err := libwallet.ParseInvoice( + "lnbcrt1ps3l7zlpp5ngv7sl4wrjalma9navd0w9956pu0tcqwrltcnnzz83eeyk4rszxqdqqcqzpgrzjq2eawnq2ywdmcpe56nk02tfamgfmsn0acp0zcn8z8cr0djkgpslr5qqpkgqqqqgqqqqqqqlgqqqqqqgq9qrzjq2eawnq2ywdmcpe56nk02tfamgfmsn0acp0zcn8z8cr0djkgpslr5qqpkgqqqqsqqqqqqqlgqqqqqqgq9qsp5luyagw4mtcq735je8ldukhlkg063cxzycjhpz2x2hjfq2mgk5xns9qyyssq7ydzdwyl7yr6ldpzqjjspmgrevw4lxt4jwfy3cxm7we20wveqq8p8khjxuq9u3v953e7t9r8ysfzx5r874vu3nd7w5yx5eqfxu0tevspgxr607", //nolint:lll + libwallet.Regtest(), + ) if err != nil { panic(err) } @@ -953,7 +1059,10 @@ func TestLightningSendNegativeFundsWithTFFA(t *testing.T) { listener := newTestListener() startState := NewOperationFlow(listener) - invoice, err := libwallet.ParseInvoice("lnbcrt1ps3l7zlpp5ngv7sl4wrjalma9navd0w9956pu0tcqwrltcnnzz83eeyk4rszxqdqqcqzpgrzjq2eawnq2ywdmcpe56nk02tfamgfmsn0acp0zcn8z8cr0djkgpslr5qqpkgqqqqgqqqqqqqlgqqqqqqgq9qrzjq2eawnq2ywdmcpe56nk02tfamgfmsn0acp0zcn8z8cr0djkgpslr5qqpkgqqqqsqqqqqqqlgqqqqqqgq9qsp5luyagw4mtcq735je8ldukhlkg063cxzycjhpz2x2hjfq2mgk5xns9qyyssq7ydzdwyl7yr6ldpzqjjspmgrevw4lxt4jwfy3cxm7we20wveqq8p8khjxuq9u3v953e7t9r8ysfzx5r874vu3nd7w5yx5eqfxu0tevspgxr607", libwallet.Regtest()) + invoice, err := libwallet.ParseInvoice( + "lnbcrt1ps3l7zlpp5ngv7sl4wrjalma9navd0w9956pu0tcqwrltcnnzz83eeyk4rszxqdqqcqzpgrzjq2eawnq2ywdmcpe56nk02tfamgfmsn0acp0zcn8z8cr0djkgpslr5qqpkgqqqqgqqqqqqqlgqqqqqqgq9qrzjq2eawnq2ywdmcpe56nk02tfamgfmsn0acp0zcn8z8cr0djkgpslr5qqpkgqqqqsqqqqqqqlgqqqqqqgq9qsp5luyagw4mtcq735je8ldukhlkg063cxzycjhpz2x2hjfq2mgk5xns9qyyssq7ydzdwyl7yr6ldpzqjjspmgrevw4lxt4jwfy3cxm7we20wveqq8p8khjxuq9u3v953e7t9r8ysfzx5r874vu3nd7w5yx5eqfxu0tevspgxr607", //nolint:lll + libwallet.Regtest(), + ) if err != nil { panic(err) } @@ -1001,7 +1110,10 @@ func TestLightningExpiredInvoice(t *testing.T) { listener := newTestListener() startState := NewOperationFlow(listener) - invoice, err := libwallet.ParseInvoice("lnbcrt100u1ps3kdrgpp5klwrzs0u63sqnca8elqu86p98swxycw3fgjtmeddm2ljd7ymrlwsdqqcqzpgsp5zs02vngwrywtqhwygu44wp464lgjtqyc5h76vae073064p72znas9qyyssqz2263utx4n7r7n85s9wg3ma2zmg3xtg46nj3e6nnr6g67tnj6jwn7urvx5qukhqjzmcnuc4t7uqlxhftqwq4hxha3ests23fcmt5evqpazdmg2", libwallet.Regtest()) + invoice, err := libwallet.ParseInvoice( + "lnbcrt100u1ps3kdrgpp5klwrzs0u63sqnca8elqu86p98swxycw3fgjtmeddm2ljd7ymrlwsdqqcqzpgsp5zs02vngwrywtqhwygu44wp464lgjtqyc5h76vae073064p72znas9qyyssqz2263utx4n7r7n85s9wg3ma2zmg3xtg46nj3e6nnr6g67tnj6jwn7urvx5qukhqjzmcnuc4t7uqlxhftqwq4hxha3ests23fcmt5evqpazdmg2", //nolint:lll + libwallet.Regtest(), + ) if err != nil { panic(err) } @@ -1025,7 +1137,10 @@ func TestLightningInvoiceWithAmount(t *testing.T) { listener := newTestListener() startState := NewOperationFlow(listener) - invoice, err := libwallet.ParseInvoice("lnbcrt100u1ps3l5eepp5njeddrlmsg9cd2a4v508mqucz7tdge90vvp4f5n23gh7kthnjjdqdqqcqzpgrzjq2eawnq2ywdmcpe56nk02tfamgfmsn0acp0zcn8z8cr0djkgpslr5qqpkgqqqqgqqqqqqqlgqqqqqqgq9qsp52qtk90062t5mha837ulm77vf04ph4kaxerm8xugjdkp9gk6d8yqs9qyyssqw09stp3vy33dfjc6vcrdfmf58trg5pte6efph9pj9gwlg0w7anhz6aelv0p3r9qj6vrjjw9jyj6s9tjujec2fm9k8ag3yvgvwszswxsqhl6equ", libwallet.Regtest()) + invoice, err := libwallet.ParseInvoice( + "lnbcrt100u1ps3l5eepp5njeddrlmsg9cd2a4v508mqucz7tdge90vvp4f5n23gh7kthnjjdqdqqcqzpgrzjq2eawnq2ywdmcpe56nk02tfamgfmsn0acp0zcn8z8cr0djkgpslr5qqpkgqqqqgqqqqqqqlgqqqqqqgq9qsp52qtk90062t5mha837ulm77vf04ph4kaxerm8xugjdkp9gk6d8yqs9qyyssqw09stp3vy33dfjc6vcrdfmf58trg5pte6efph9pj9gwlg0w7anhz6aelv0p3r9qj6vrjjw9jyj6s9tjujec2fm9k8ag3yvgvwszswxsqhl6equ", //nolint:lll + libwallet.Regtest(), + ) if err != nil { panic(err) } @@ -1060,7 +1175,10 @@ func TestLightningInvoiceWithAmount(t *testing.T) { t.Fatalf("expected note to match input, got '%v'", confirmState.Note) } if confirmState.Amount.InInputCurrency.String() != "0.0001 BTC" { - t.Fatalf("expected amount to match resolved URI, got %v", confirmState.Amount.InInputCurrency) + t.Fatalf( + "expected amount to match resolved URI, got %v", + confirmState.Amount.InInputCurrency, + ) } if confirmState.Fee.InInputCurrency.String() != "0.0000192 BTC" { t.Fatalf("expected fee to match, got %v", confirmState.Fee.InInputCurrency) @@ -1077,7 +1195,10 @@ func TestLightningWithAmountBack(t *testing.T) { listener := newTestListener() startState := NewOperationFlow(listener) - invoice, err := libwallet.ParseInvoice("lnbcrt100u1ps3l5eepp5njeddrlmsg9cd2a4v508mqucz7tdge90vvp4f5n23gh7kthnjjdqdqqcqzpgrzjq2eawnq2ywdmcpe56nk02tfamgfmsn0acp0zcn8z8cr0djkgpslr5qqpkgqqqqgqqqqqqqlgqqqqqqgq9qsp52qtk90062t5mha837ulm77vf04ph4kaxerm8xugjdkp9gk6d8yqs9qyyssqw09stp3vy33dfjc6vcrdfmf58trg5pte6efph9pj9gwlg0w7anhz6aelv0p3r9qj6vrjjw9jyj6s9tjujec2fm9k8ag3yvgvwszswxsqhl6equ", libwallet.Regtest()) + invoice, err := libwallet.ParseInvoice( + "lnbcrt100u1ps3l5eepp5njeddrlmsg9cd2a4v508mqucz7tdge90vvp4f5n23gh7kthnjjdqdqqcqzpgrzjq2eawnq2ywdmcpe56nk02tfamgfmsn0acp0zcn8z8cr0djkgpslr5qqpkgqqqqgqqqqqqqlgqqqqqqgq9qsp52qtk90062t5mha837ulm77vf04ph4kaxerm8xugjdkp9gk6d8yqs9qyyssqw09stp3vy33dfjc6vcrdfmf58trg5pte6efph9pj9gwlg0w7anhz6aelv0p3r9qj6vrjjw9jyj6s9tjujec2fm9k8ag3yvgvwszswxsqhl6equ", //nolint:lll + libwallet.Regtest(), + ) if err != nil { panic(err) } @@ -1121,7 +1242,10 @@ func TestLightningWithAmountBack(t *testing.T) { t.Fatalf("expected note to match input, got '%v'", confirmState.Note) } if confirmState.Amount.InInputCurrency.String() != "0.0001 BTC" { - t.Fatalf("expected amount to match resolved URI, got %v", confirmState.Amount.InInputCurrency) + t.Fatalf( + "expected amount to match resolved URI, got %v", + confirmState.Amount.InInputCurrency, + ) } if confirmState.Fee.InInputCurrency.String() != "0.0000192 BTC" { t.Fatalf("expected fee to match, got %v", confirmState.Fee.InInputCurrency) @@ -1138,7 +1262,10 @@ func TestLightningInvoiceWithAmountAndDescription(t *testing.T) { listener := newTestListener() startState := NewOperationFlow(listener) - invoice, err := libwallet.ParseInvoice("lnbcrt100u1psjg8k6pp5n895ngj22v4dczwd8jrvvq76qvur2642y29m6x2faq0rgle2zwwsdq2vehk7cnpwgcqzpgsp5x9yeys2j294q402ewq2kfcas6wn63mk5q86ehe79plljzfwhr69s9qyyssq4exlg7ly068zc8dfh6ls5r69x0pmvdy9la70hw2vqwz9p2g4p5fyxr0hlkzrfnkmlx3kjrlecedatk96zuzs8a3cj48qg7vne6zp5ygpgzwd2x", libwallet.Regtest()) + invoice, err := libwallet.ParseInvoice( + "lnbcrt100u1psjg8k6pp5n895ngj22v4dczwd8jrvvq76qvur2642y29m6x2faq0rgle2zwwsdq2vehk7cnpwgcqzpgsp5x9yeys2j294q402ewq2kfcas6wn63mk5q86ehe79plljzfwhr69s9qyyssq4exlg7ly068zc8dfh6ls5r69x0pmvdy9la70hw2vqwz9p2g4p5fyxr0hlkzrfnkmlx3kjrlecedatk96zuzs8a3cj48qg7vne6zp5ygpgzwd2x", //nolint:lll + libwallet.Regtest(), + ) if err != nil { panic(err) } @@ -1170,7 +1297,10 @@ func TestLightningInvoiceWithAmountAndDescription(t *testing.T) { t.Fatalf("expected note to match input, got '%v'", confirmState.Note) } if confirmState.Amount.InInputCurrency.String() != "0.0001 BTC" { - t.Fatalf("expected amount to match resolved URI, got %v", confirmState.Amount.InInputCurrency) + t.Fatalf( + "expected amount to match resolved URI, got %v", + confirmState.Amount.InInputCurrency, + ) } if confirmState.Fee.InInputCurrency.String() != "0.0000192 BTC" { t.Fatalf("expected fee to match, got %v", confirmState.Fee.InInputCurrency) @@ -1187,7 +1317,10 @@ func TestLightningAmountlessInvoice(t *testing.T) { listener := newTestListener() startState := NewOperationFlow(listener) - invoice, err := libwallet.ParseInvoice("lnbcrt1ps3l7zlpp5ngv7sl4wrjalma9navd0w9956pu0tcqwrltcnnzz83eeyk4rszxqdqqcqzpgrzjq2eawnq2ywdmcpe56nk02tfamgfmsn0acp0zcn8z8cr0djkgpslr5qqpkgqqqqgqqqqqqqlgqqqqqqgq9qrzjq2eawnq2ywdmcpe56nk02tfamgfmsn0acp0zcn8z8cr0djkgpslr5qqpkgqqqqsqqqqqqqlgqqqqqqgq9qsp5luyagw4mtcq735je8ldukhlkg063cxzycjhpz2x2hjfq2mgk5xns9qyyssq7ydzdwyl7yr6ldpzqjjspmgrevw4lxt4jwfy3cxm7we20wveqq8p8khjxuq9u3v953e7t9r8ysfzx5r874vu3nd7w5yx5eqfxu0tevspgxr607", libwallet.Regtest()) + invoice, err := libwallet.ParseInvoice( + "lnbcrt1ps3l7zlpp5ngv7sl4wrjalma9navd0w9956pu0tcqwrltcnnzz83eeyk4rszxqdqqcqzpgrzjq2eawnq2ywdmcpe56nk02tfamgfmsn0acp0zcn8z8cr0djkgpslr5qqpkgqqqqgqqqqqqqlgqqqqqqgq9qrzjq2eawnq2ywdmcpe56nk02tfamgfmsn0acp0zcn8z8cr0djkgpslr5qqpkgqqqqsqqqqqqqlgqqqqqqgq9qsp5luyagw4mtcq735je8ldukhlkg063cxzycjhpz2x2hjfq2mgk5xns9qyyssq7ydzdwyl7yr6ldpzqjjspmgrevw4lxt4jwfy3cxm7we20wveqq8p8khjxuq9u3v953e7t9r8ysfzx5r874vu3nd7w5yx5eqfxu0tevspgxr607", //nolint:lll + libwallet.Regtest(), + ) if err != nil { panic(err) } @@ -1229,7 +1362,10 @@ func TestLightningAmountlessInvoice(t *testing.T) { t.Fatalf("expected note to match input, got '%v'", confirmState.Note) } if confirmState.Amount.InInputCurrency.String() != "0.0001 BTC" { - t.Fatalf("expected amount to match resolved URI, got %v", confirmState.Amount.InInputCurrency) + t.Fatalf( + "expected amount to match resolved URI, got %v", + confirmState.Amount.InInputCurrency, + ) } if confirmState.Fee.InInputCurrency.String() != "0.0000292 BTC" { t.Fatalf("expected fee to match, got %v", confirmState.Fee.InInputCurrency) @@ -1256,7 +1392,10 @@ func TestInvoiceOneConf(t *testing.T) { listener := newTestListener() startState := NewOperationFlow(listener) - invoice, err := libwallet.ParseInvoice("lnbcrt1ps3l7zlpp5ngv7sl4wrjalma9navd0w9956pu0tcqwrltcnnzz83eeyk4rszxqdqqcqzpgrzjq2eawnq2ywdmcpe56nk02tfamgfmsn0acp0zcn8z8cr0djkgpslr5qqpkgqqqqgqqqqqqqlgqqqqqqgq9qrzjq2eawnq2ywdmcpe56nk02tfamgfmsn0acp0zcn8z8cr0djkgpslr5qqpkgqqqqsqqqqqqqlgqqqqqqgq9qsp5luyagw4mtcq735je8ldukhlkg063cxzycjhpz2x2hjfq2mgk5xns9qyyssq7ydzdwyl7yr6ldpzqjjspmgrevw4lxt4jwfy3cxm7we20wveqq8p8khjxuq9u3v953e7t9r8ysfzx5r874vu3nd7w5yx5eqfxu0tevspgxr607", libwallet.Regtest()) + invoice, err := libwallet.ParseInvoice( + "lnbcrt1ps3l7zlpp5ngv7sl4wrjalma9navd0w9956pu0tcqwrltcnnzz83eeyk4rszxqdqqcqzpgrzjq2eawnq2ywdmcpe56nk02tfamgfmsn0acp0zcn8z8cr0djkgpslr5qqpkgqqqqgqqqqqqqlgqqqqqqgq9qrzjq2eawnq2ywdmcpe56nk02tfamgfmsn0acp0zcn8z8cr0djkgpslr5qqpkgqqqqsqqqqqqqlgqqqqqqgq9qsp5luyagw4mtcq735je8ldukhlkg063cxzycjhpz2x2hjfq2mgk5xns9qyyssq7ydzdwyl7yr6ldpzqjjspmgrevw4lxt4jwfy3cxm7we20wveqq8p8khjxuq9u3v953e7t9r8ysfzx5r874vu3nd7w5yx5eqfxu0tevspgxr607", //nolint:lll + libwallet.Regtest(), + ) if err != nil { panic(err) } @@ -1301,7 +1440,10 @@ func TestInvoiceOneConf(t *testing.T) { t.Fatalf("expected note to match input, got '%v'", confirmState.Note) } if confirmState.Amount.InInputCurrency.String() != "0.0001 BTC" { - t.Fatalf("expected amount to match resolved URI, got %v", confirmState.Amount.InInputCurrency) + t.Fatalf( + "expected amount to match resolved URI, got %v", + confirmState.Amount.InInputCurrency, + ) } if confirmState.Fee.InInputCurrency.String() != "0.00097 BTC" { t.Fatalf("expected fee to match, got %v", confirmState.Fee.InInputCurrency) @@ -1317,17 +1459,17 @@ func TestInvoiceOneConf(t *testing.T) { //goland:noinspection GoUnhandledErrorResult func TestAmountConversion(t *testing.T) { - // This test repros a bug where we had: - // * Primary currency BTC - // * Fiat input - // Then, for amount/total the amount in sat and in primary currency differed - // in 1 sat. + // This test repros a bug where we had: * Primary currency BTC * Fiat input Then, for + // amount/total the amount in sat and in primary currency differed in 1 sat. setupStateTests(t) listener := newTestListener() startState := NewOperationFlow(listener) - invoice, err := libwallet.ParseInvoice("lnbcrt1ps3l7zlpp5ngv7sl4wrjalma9navd0w9956pu0tcqwrltcnnzz83eeyk4rszxqdqqcqzpgrzjq2eawnq2ywdmcpe56nk02tfamgfmsn0acp0zcn8z8cr0djkgpslr5qqpkgqqqqgqqqqqqqlgqqqqqqgq9qrzjq2eawnq2ywdmcpe56nk02tfamgfmsn0acp0zcn8z8cr0djkgpslr5qqpkgqqqqsqqqqqqqlgqqqqqqgq9qsp5luyagw4mtcq735je8ldukhlkg063cxzycjhpz2x2hjfq2mgk5xns9qyyssq7ydzdwyl7yr6ldpzqjjspmgrevw4lxt4jwfy3cxm7we20wveqq8p8khjxuq9u3v953e7t9r8ysfzx5r874vu3nd7w5yx5eqfxu0tevspgxr607", libwallet.Regtest()) + invoice, err := libwallet.ParseInvoice( + "lnbcrt1ps3l7zlpp5ngv7sl4wrjalma9navd0w9956pu0tcqwrltcnnzz83eeyk4rszxqdqqcqzpgrzjq2eawnq2ywdmcpe56nk02tfamgfmsn0acp0zcn8z8cr0djkgpslr5qqpkgqqqqgqqqqqqqlgqqqqqqgq9qrzjq2eawnq2ywdmcpe56nk02tfamgfmsn0acp0zcn8z8cr0djkgpslr5qqpkgqqqqsqqqqqqqlgqqqqqqgq9qsp5luyagw4mtcq735je8ldukhlkg063cxzycjhpz2x2hjfq2mgk5xns9qyyssq7ydzdwyl7yr6ldpzqjjspmgrevw4lxt4jwfy3cxm7we20wveqq8p8khjxuq9u3v953e7t9r8ysfzx5r874vu3nd7w5yx5eqfxu0tevspgxr607", //nolint:lll + libwallet.Regtest(), + ) if err != nil { panic(err) } @@ -1385,7 +1527,10 @@ func TestInvoiceUnpayable(t *testing.T) { listener := newTestListener() startState := NewOperationFlow(listener) - invoice, err := libwallet.ParseInvoice("lnbcrt1ps3l7zlpp5ngv7sl4wrjalma9navd0w9956pu0tcqwrltcnnzz83eeyk4rszxqdqqcqzpgrzjq2eawnq2ywdmcpe56nk02tfamgfmsn0acp0zcn8z8cr0djkgpslr5qqpkgqqqqgqqqqqqqlgqqqqqqgq9qrzjq2eawnq2ywdmcpe56nk02tfamgfmsn0acp0zcn8z8cr0djkgpslr5qqpkgqqqqsqqqqqqqlgqqqqqqgq9qsp5luyagw4mtcq735je8ldukhlkg063cxzycjhpz2x2hjfq2mgk5xns9qyyssq7ydzdwyl7yr6ldpzqjjspmgrevw4lxt4jwfy3cxm7we20wveqq8p8khjxuq9u3v953e7t9r8ysfzx5r874vu3nd7w5yx5eqfxu0tevspgxr607", libwallet.Regtest()) + invoice, err := libwallet.ParseInvoice( + "lnbcrt1ps3l7zlpp5ngv7sl4wrjalma9navd0w9956pu0tcqwrltcnnzz83eeyk4rszxqdqqcqzpgrzjq2eawnq2ywdmcpe56nk02tfamgfmsn0acp0zcn8z8cr0djkgpslr5qqpkgqqqqgqqqqqqqlgqqqqqqgq9qrzjq2eawnq2ywdmcpe56nk02tfamgfmsn0acp0zcn8z8cr0djkgpslr5qqpkgqqqqsqqqqqqqlgqqqqqqgq9qsp5luyagw4mtcq735je8ldukhlkg063cxzycjhpz2x2hjfq2mgk5xns9qyyssq7ydzdwyl7yr6ldpzqjjspmgrevw4lxt4jwfy3cxm7we20wveqq8p8khjxuq9u3v953e7t9r8ysfzx5r874vu3nd7w5yx5eqfxu0tevspgxr607", //nolint:lll + libwallet.Regtest(), + ) if err != nil { panic(err) } @@ -1438,7 +1583,10 @@ func TestInvoiceLend(t *testing.T) { listener := newTestListener() startState := NewOperationFlow(listener) - invoice, err := libwallet.ParseInvoice("lnbcrt1u1ps5ma8app59ujxjvtj8x34fyd7u7tghuq44dphjth8nqmkzeklg882y9ghjmvqdqqcqzpgsp5stzjqktxfh02dfz8tucfnh6rl3z87ctl2dumr40elhmrskhx5zlq9qyyssqnp4uhukcgxx6l0p5elppz5xc7a97n0hxvfm6lgr6ze06wqc2dnx95pet3vlalc9rqz20lu45y8sqg3n6fm6tqsftvzqp4l2zsrs0ndgpk2fyv5", libwallet.Regtest()) + invoice, err := libwallet.ParseInvoice( + "lnbcrt1u1ps5ma8app59ujxjvtj8x34fyd7u7tghuq44dphjth8nqmkzeklg882y9ghjmvqdqqcqzpgsp5stzjqktxfh02dfz8tucfnh6rl3z87ctl2dumr40elhmrskhx5zlq9qyyssqnp4uhukcgxx6l0p5elppz5xc7a97n0hxvfm6lgr6ze06wqc2dnx95pet3vlalc9rqz20lu45y8sqg3n6fm6tqsftvzqp4l2zsrs0ndgpk2fyv5", //nolint:lll + libwallet.Regtest(), + ) if err != nil { panic(err) } @@ -1473,7 +1621,10 @@ func TestInvoiceLend(t *testing.T) { t.Fatalf("expected note to match input, got '%v'", confirmState.Note) } if confirmState.Amount.InInputCurrency.String() != "0.000001 BTC" { - t.Fatalf("expected amount to match resolved URI, got %v", confirmState.Amount.InInputCurrency) + t.Fatalf( + "expected amount to match resolved URI, got %v", + confirmState.Amount.InInputCurrency, + ) } if confirmState.Fee.InInputCurrency.String() != "0 BTC" { t.Fatalf("expected fee to match, got %v", confirmState.Fee.InInputCurrency) @@ -1485,7 +1636,10 @@ func TestInvoiceLend(t *testing.T) { t.Fatalf("Expected debt type to be lend: %v", confirmState.SwapInfo.SwapFees.DebtType) } if confirmState.SwapInfo.SwapFees.DebtAmountInSat != 100 { - t.Fatalf("Expected debt amount to be 100 sats: %v", confirmState.SwapInfo.SwapFees.DebtAmountInSat) + t.Fatalf( + "Expected debt amount to be 100 sats: %v", + confirmState.SwapInfo.SwapFees.DebtAmountInSat, + ) } } @@ -1544,7 +1698,10 @@ func TestOnChainTFFAWithDebtFeeNeedsChangeBecauseOutputAmountLowerThanDust(t *te // Amount is not payable with fastes/highest fee, but it is with min fee (1 sat/vbyte) if enterDescriptionState.Amount.InInputCurrency.String() != "0 BTC" { - t.Fatalf("expected amount to match input, got %v", enterDescriptionState.Amount.InInputCurrency) + t.Fatalf( + "expected amount to match input, got %v", + enterDescriptionState.Amount.InInputCurrency, + ) } confirmState := listener.next().(*ConfirmState) diff --git a/libwallet/newop/swaps.go b/libwallet/newop/swaps.go index 68a1ad03..08c11a7a 100644 --- a/libwallet/newop/swaps.go +++ b/libwallet/newop/swaps.go @@ -2,6 +2,7 @@ package newop import ( "github.com/btcsuite/btcd/btcutil" + "github.com/muun/libwallet/fees" ) diff --git a/libwallet/nonces.go b/libwallet/nonces.go index a79ea5bd..dc6a200a 100644 --- a/libwallet/nonces.go +++ b/libwallet/nonces.go @@ -8,7 +8,7 @@ import ( ) type MusigNonces struct { - sessionIds [][32]byte + sessionIDs [][32]byte publicNonces [][66]byte addressVersions []int } @@ -24,15 +24,15 @@ func (m *MusigNonces) Length() int { // NOTE: this function only generates v040 nonces, used until GenerateNonce is fully adopted. // after that this function should be deleted. Currently, this function is only used by gomobile func GenerateMusigNonces(count int) *MusigNonces { - sessionIds := make([][32]byte, 0) + sessionIDs := make([][32]byte, 0) publicNonces := make([][66]byte, 0) addressVersions := make([]int, 0) for i := 0; i < count; i += 1 { - sessionIds = append(sessionIds, musig.RandomSessionId()) + sessionIDs = append(sessionIDs, musig.RandomSessionID()) nonce, _ := musig.MuSig2GenerateNonce( musig.Musig2v040Muun, - sessionIds[i][:], + sessionIDs[i][:], nil, ) publicNonces = append(publicNonces, nonce.PubNonce) @@ -40,19 +40,19 @@ func GenerateMusigNonces(count int) *MusigNonces { } return &MusigNonces{ - sessionIds, + sessionIDs, publicNonces, addressVersions, } } func EmptyMusigNonces() *MusigNonces { - sessionIds := make([][32]byte, 0) + sessionIDs := make([][32]byte, 0) publicNonces := make([][66]byte, 0) addressVersions := make([]int, 0) return &MusigNonces{ - sessionIds, + sessionIDs, publicNonces, addressVersions, } @@ -60,18 +60,25 @@ func EmptyMusigNonces() *MusigNonces { // Generates a nonce for a specific address version. Returns the index of the // generated nonce and reallocates the arrays of the current MusigNonces. -func (nonces *MusigNonces) GenerateNonce(addressVersion int, signerPubKeySerialized []byte) (int, error) { - sessionId := musig.RandomSessionId() +func (nonces *MusigNonces) GenerateNonce( //nolint:staticcheck // TODO: methods on the same type should have the same receiver name (seen 2x "m", 2x "nonces") + addressVersion int, + signerPubKeySerialized []byte, +) (int, error) { + sessionID := musig.RandomSessionID() - return nonces.generateStaticNonce(addressVersion, signerPubKeySerialized, sessionId) + return nonces.generateStaticNonce(addressVersion, signerPubKeySerialized, sessionID) } // PREFER GenerateNonce, this function exists for tests only. // // Generates a nonce for a specific address version. Returns the index of the // generated nonce and reallocates the arrays of the current MusigNonces. -// The provided sessionId MUST NOT be reused, it MUST be used only once. -func (nonces *MusigNonces) generateStaticNonce(addressVersion int, signerPubKeySerialized []byte, sessionId [32]byte) (int, error) { +// The provided sessionID MUST NOT be reused, it MUST be used only once. +func (nonces *MusigNonces) generateStaticNonce( + addressVersion int, + signerPubKeySerialized []byte, + sessionID [32]byte, +) (int, error) { musigVersion := addresses.MusigVersionForAddress(addressVersion) signerPubKey, err := musig.ParsePubKey(musigVersion, signerPubKeySerialized) @@ -81,7 +88,7 @@ func (nonces *MusigNonces) generateStaticNonce(addressVersion int, signerPubKeyS nonce, err := musig.MuSig2GenerateNonce( musigVersion, - sessionId[:], + sessionID[:], signerPubKey.SerializeCompressed(), ) if err != nil { @@ -89,8 +96,8 @@ func (nonces *MusigNonces) generateStaticNonce(addressVersion int, signerPubKeyS } nonces.addressVersions = append(nonces.addressVersions, addressVersion) - nonces.sessionIds = append(nonces.sessionIds, sessionId) + nonces.sessionIDs = append(nonces.sessionIDs, sessionID) nonces.publicNonces = append(nonces.publicNonces, nonce.PubNonce) - return len(nonces.sessionIds) - 1, nil + return len(nonces.sessionIDs) - 1, nil } diff --git a/libwallet/operation/fee_bump.go b/libwallet/operation/fee_bump.go index 5484e71d..70e78bf9 100644 --- a/libwallet/operation/fee_bump.go +++ b/libwallet/operation/fee_bump.go @@ -1,8 +1,9 @@ package operation import ( - "errors" "time" + + "github.com/go-errors/errors" ) type FeeBumpFunctionSet struct { @@ -29,7 +30,8 @@ type PartialLinearFunction struct { } func (pf *PartialLinearFunction) evaluate(feeRateInSatsPerVByte float64) (float64, error) { - if feeRateInSatsPerVByte >= pf.LeftClosedEndpoint && feeRateInSatsPerVByte < pf.RightOpenEndpoint { + if feeRateInSatsPerVByte >= pf.LeftClosedEndpoint && + feeRateInSatsPerVByte < pf.RightOpenEndpoint { return pf.Slope*feeRateInSatsPerVByte + pf.Intercept, nil } return 0, errors.New("fee rate does not belong to this interval") @@ -39,7 +41,8 @@ func (fb *FeeBumpFunction) getPartialLinearFunctionForFeeRate( feeRateInSatsPerVByte float64, ) (*PartialLinearFunction, error) { for _, partialLinearFunction := range fb.PartialLinearFunctions { - if feeRateInSatsPerVByte >= partialLinearFunction.LeftClosedEndpoint && feeRateInSatsPerVByte < partialLinearFunction.RightOpenEndpoint { + if feeRateInSatsPerVByte >= partialLinearFunction.LeftClosedEndpoint && + feeRateInSatsPerVByte < partialLinearFunction.RightOpenEndpoint { return partialLinearFunction, nil } } @@ -47,7 +50,9 @@ func (fb *FeeBumpFunction) getPartialLinearFunctionForFeeRate( } // GetBumpAmountForFeeRate assumes that there is no overlap between the intervals. -func (f *FeeBumpFunction) GetBumpAmountForFeeRate(feeRateInSatsPerVByte float64) (int64, error) { +func (f *FeeBumpFunction) GetBumpAmountForFeeRate( //nolint:staticcheck // TODO: methods on the same type should have the same receiver name (seen 1x "f", 1x "fb") + feeRateInSatsPerVByte float64, +) (int64, error) { if f.PartialLinearFunctions == nil { return 0, errors.New("fee bump function does not exist") } diff --git a/libwallet/operation/fee_bump_test.go b/libwallet/operation/fee_bump_test.go index ff13170c..57e61fed 100644 --- a/libwallet/operation/fee_bump_test.go +++ b/libwallet/operation/fee_bump_test.go @@ -60,7 +60,11 @@ func TestPartialLinearFunction(t *testing.T) { } if bumpAmount != tC.bumpAmountExpected { - t.Fatalf("expected fee bump to be %v, but got %v", bumpAmount, tC.bumpAmountExpected) + t.Fatalf( + "expected fee bump to be %v, but got %v", + bumpAmount, + tC.bumpAmountExpected, + ) } }) } @@ -148,7 +152,11 @@ func TestFeeBumpFunction(t *testing.T) { } if bumpAmount != tC.bumpAmountExpected { - t.Fatalf("expected fee bump to be %v, but got %v", bumpAmount, tC.bumpAmountExpected) + t.Fatalf( + "expected fee bump to be %v, but got %v", + bumpAmount, + tC.bumpAmountExpected, + ) } }) } diff --git a/libwallet/operation/fee_window.go b/libwallet/operation/fee_window.go index e0eaf99c..42973b4e 100644 --- a/libwallet/operation/fee_window.go +++ b/libwallet/operation/fee_window.go @@ -1,8 +1,9 @@ package operation import ( - "fmt" "math" + + "github.com/go-errors/errors" ) const swapV2ConfTarget = 250 // Approx 2 days @@ -29,7 +30,10 @@ func (f *FeeWindow) SwapFeeRate(confirmationsNeeded uint) (float64, error) { func (f *FeeWindow) MinimumFeeRate(confirmationTarget uint) (float64, error) { if confirmationTarget <= 0 { - return 0, fmt.Errorf("can't get feeRate. Expected positive confirmation target, got %v", confirmationTarget) + return 0, errors.Errorf( + "can't get feeRate. Expected positive confirmation target, got %v", + confirmationTarget, + ) } // Walk the available targets backwards, finding the highest target below the given one: diff --git a/libwallet/operation/fees.go b/libwallet/operation/fees.go index 17d21b6e..07e77007 100644 --- a/libwallet/operation/fees.go +++ b/libwallet/operation/fees.go @@ -1,20 +1,23 @@ package operation import ( - "errors" "log/slog" "math" + + "github.com/go-errors/errors" ) type feeCalculator struct { NextTransactionSize *NextTransactionSize - // there is a fee bump function for each unconfirmed utxo, in the same order as they appear in the NTS. + // there is a fee bump function for each unconfirmed utxo, in the same order as they appear in + // the NTS. feeBumpFunctions []*FeeBumpFunction } // Fee DOES NOT return error when amount > balance. Instead we return the fee it would take to -// spend all utxos and delegate to the caller the task of checking if that is spendable with the given -// amount. This is to avoid using go error handling. +// spend all utxos and delegate to the caller the task of checking if that +// is spendable with the given amount. +// This is to avoid using go error handling. // Consequences of this: // - we don't check balance whatsoever // - fee for COLLECT swap is exactly the same as normal case @@ -42,7 +45,8 @@ func (f *feeCalculator) calculateFee( lastUnconfirmedUtxoUsedIndex := -1 var feeBumpAmount int64 = 0 for _, sizeForAmount := range f.NextTransactionSize.SizeProgression { - // this code assumes that sizeProgression has the same order as used when fee bump functions was generated. + // this code assumes that sizeProgression has the same order as used when fee bump functions + // was generated. if sizeForAmount.UtxoStatus == UtxosStatusUnconfirmed { lastUnconfirmedUtxoUsedIndex++ } @@ -50,7 +54,10 @@ func (f *feeCalculator) calculateFee( feeBumpAmount = 0 if lastUnconfirmedUtxoUsedIndex >= 0 { var err error - feeBumpAmount, err = f.calculateFeeBumpAmount(lastUnconfirmedUtxoUsedIndex, feeRateInSatsPerVByte) + feeBumpAmount, err = f.calculateFeeBumpAmount( + lastUnconfirmedUtxoUsedIndex, + feeRateInSatsPerVByte, + ) if err != nil { slog.Error("error calculating fee bump amount.", slog.Any("error", err)) } @@ -75,10 +82,9 @@ func computeFee(sizeInVByte int64, feeRate float64, feeBumpAmount int64) int64 { } // calculateFeeBumpAmount calculates the fee needed to bump the unconfirmed ancestors of the -// transaction via child-pays-for-parent. -// If the order among unconfirmed utxos in NTS and fee bump functions is broken, -// the fee bump function related code will not work as expected. -// it handles the case when lastUnconfirmedUtxoUsedIndex is greater than feeBumpFunctions length +// transaction via child-pays-for-parent. If the order among unconfirmed utxos in NTS and fee bump +// functions is broken, the fee bump function related code will not work as expected. it handles the +// case when lastUnconfirmedUtxoUsedIndex is greater than feeBumpFunctions length func (f *feeCalculator) calculateFeeBumpAmount( lastUnconfirmedUtxoUsedIndex int, feeRateInSatsPerVByte float64, @@ -92,7 +98,9 @@ func (f *feeCalculator) calculateFeeBumpAmount( functionIndex := f.getFeeBumpFunctionIndex(lastUnconfirmedUtxoUsedIndex) - feeBumpAmount, err := f.feeBumpFunctions[functionIndex].GetBumpAmountForFeeRate(feeRateInSatsPerVByte) + feeBumpAmount, err := f.feeBumpFunctions[functionIndex].GetBumpAmountForFeeRate( + feeRateInSatsPerVByte, + ) if err != nil { return 0, err @@ -102,10 +110,10 @@ func (f *feeCalculator) calculateFeeBumpAmount( } func (f *feeCalculator) getFeeBumpFunctionIndex(lastUnconfirmedUtxoUsedIndex int) int { - // We might have fewer fee bump functions than unconfirmed UTXOs; in that case, - // we should use the last fee bump function. - // There are no gaps in the middle, meaning the last N UTXOs will use the last fee bump function. - // If this order is broken, the fee bump function related code will not work as expected. + // We might have fewer fee bump functions than unconfirmed UTXOs; in that case, we should use + // the last fee bump function. There are no gaps in the middle, meaning the last N UTXOs will + // use the last fee bump function. If this order is broken, the fee bump function related code + // will not work as expected. if lastUnconfirmedUtxoUsedIndex >= len(f.feeBumpFunctions) { lastUnconfirmedUtxoUsedIndex = len(f.feeBumpFunctions) - 1 } diff --git a/libwallet/operation/fees_test.go b/libwallet/operation/fees_test.go index e3dbd138..f0fd521b 100644 --- a/libwallet/operation/fees_test.go +++ b/libwallet/operation/fees_test.go @@ -74,19 +74,19 @@ var singleNegativeUtxoNts = &NextTransactionSize{ } var firstFeeBumpFunction = []*PartialLinearFunction{ - &PartialLinearFunction{ + { LeftClosedEndpoint: 0, RightOpenEndpoint: 50, Slope: 2, Intercept: 100, }, - &PartialLinearFunction{ + { LeftClosedEndpoint: 50, RightOpenEndpoint: 100, Slope: 3, Intercept: 200, }, - &PartialLinearFunction{ + { LeftClosedEndpoint: 100, RightOpenEndpoint: math.Inf(1), Slope: 4, @@ -95,19 +95,19 @@ var firstFeeBumpFunction = []*PartialLinearFunction{ } var secondFeeBumpFunction = []*PartialLinearFunction{ - &PartialLinearFunction{ + { LeftClosedEndpoint: 100, RightOpenEndpoint: math.Inf(1), Slope: 7, Intercept: 300, }, - &PartialLinearFunction{ + { LeftClosedEndpoint: 50, RightOpenEndpoint: 100, Slope: 6, Intercept: 200, }, - &PartialLinearFunction{ + { LeftClosedEndpoint: 0, RightOpenEndpoint: 50, Slope: 5, @@ -152,19 +152,31 @@ func TestFeeCalculatorForAmountZero(t *testing.T) { for _, nts := range allNts { calculator := feeCalculator{&nts, nil} - feeInSat, feeBumpInSat := calculator.Fee(0, tC.feeRateInSatsPerVbyte, tC.takeFeeFromAmount) + feeInSat, feeBumpInSat := calculator.Fee( + 0, + tC.feeRateInSatsPerVbyte, + tC.takeFeeFromAmount, + ) if feeInSat != tC.expectedFeeInSat { t.Fatalf("expected fee = %v, got %v", tC.expectedFeeInSat, feeInSat) } if feeBumpInSat != tC.expectedFeeBumpInSat { - t.Fatalf("expected fee bump = %v, got %v", tC.expectedFeeBumpInSat, feeBumpInSat) + t.Fatalf( + "expected fee bump = %v, got %v", + tC.expectedFeeBumpInSat, + feeBumpInSat, + ) } } calculator := feeCalculator{} - feeInSat, feeBumpInSat := calculator.Fee(0, tC.feeRateInSatsPerVbyte, tC.takeFeeFromAmount) + feeInSat, feeBumpInSat := calculator.Fee( + 0, + tC.feeRateInSatsPerVbyte, + tC.takeFeeFromAmount, + ) if feeInSat != tC.expectedFeeInSat { t.Fatalf("expected fee = %v, got %v", tC.expectedFeeInSat, feeInSat) @@ -280,8 +292,9 @@ func TestFeeCalculator(t *testing.T) { expectedFeeBumpInSat: 0, }, { - // This case can't really happen since our PaymentAnalyzer enforces amount == totalBalance for TFFA - // We don't handle that precondition in FeeCalculator to keep its API simple (no error handling) + // This case can't really happen since our PaymentAnalyzer enforces amount == + // totalBalance for TFFA. We don't handle that precondition in FeeCalculator + // to keep its API simple (no error handling) desc: "calculates when no amount is left after TFFA", amountInSat: 10, feeCalculator: &feeCalculator{defaultNts, nil}, @@ -300,7 +313,8 @@ func TestFeeCalculator(t *testing.T) { expectedFeeBumpInSat: 0, }, { - desc: "calculates when paying fee does not require an additional UTXO (1)", + desc: "calculates when paying fee does not " + + "require an additional UTXO (1)", amountInSat: defaultNts.SizeProgression[0].AmountInSat / 2, feeCalculator: &feeCalculator{defaultNts, nil}, feeRateInSatsPerVbyte: 10, @@ -309,7 +323,8 @@ func TestFeeCalculator(t *testing.T) { expectedFeeBumpInSat: 0, }, { - desc: "calculates when paying fee does not require an additional UTXO (2)", + desc: "calculates when paying fee does not " + + "require an additional UTXO (2)", amountInSat: defaultNts.SizeProgression[1].AmountInSat / 2, feeCalculator: &feeCalculator{defaultNts, nil}, feeRateInSatsPerVbyte: 10, @@ -318,7 +333,8 @@ func TestFeeCalculator(t *testing.T) { expectedFeeBumpInSat: 0, }, { - desc: "calculates when paying fee does not require an additional UTXO (3)", + desc: "calculates when paying fee does not " + + "require an additional UTXO (3)", amountInSat: defaultNts.SizeProgression[2].AmountInSat / 2, feeCalculator: &feeCalculator{defaultNts, nil}, feeRateInSatsPerVbyte: 10, @@ -327,7 +343,8 @@ func TestFeeCalculator(t *testing.T) { expectedFeeBumpInSat: 0, }, { - desc: "calculates when paying fee does not require an additional UTXO (4)", + desc: "calculates when paying fee does not " + + "require an additional UTXO (4)", amountInSat: defaultNts.SizeProgression[3].AmountInSat / 2, feeCalculator: &feeCalculator{defaultNts, nil}, feeRateInSatsPerVbyte: 10, @@ -377,8 +394,8 @@ func TestFeeCalculator(t *testing.T) { feeCalculator: &feeCalculator{ NextTransactionSize: defaultNts, feeBumpFunctions: []*FeeBumpFunction{ - &FeeBumpFunction{PartialLinearFunctions: firstFeeBumpFunction}, - &FeeBumpFunction{PartialLinearFunctions: secondFeeBumpFunction}, + {PartialLinearFunctions: firstFeeBumpFunction}, + {PartialLinearFunctions: secondFeeBumpFunction}, }, }, feeRateInSatsPerVbyte: 10, @@ -392,8 +409,8 @@ func TestFeeCalculator(t *testing.T) { feeCalculator: &feeCalculator{ NextTransactionSize: defaultNts, feeBumpFunctions: []*FeeBumpFunction{ - &FeeBumpFunction{PartialLinearFunctions: firstFeeBumpFunction}, - &FeeBumpFunction{PartialLinearFunctions: secondFeeBumpFunction}, + {PartialLinearFunctions: firstFeeBumpFunction}, + {PartialLinearFunctions: secondFeeBumpFunction}, }, }, feeRateInSatsPerVbyte: 10, @@ -402,12 +419,14 @@ func TestFeeCalculator(t *testing.T) { expectedFeeBumpInSat: 150, }, { - desc: "calculates when we have less feeBumpFunctions than unconfirmed utxos (use the last function)", + desc: "calculates when we have less " + + "feeBumpFunctions than unconfirmed " + + "utxos (use the last function)", amountInSat: defaultNts.SizeProgression[2].AmountInSat, feeCalculator: &feeCalculator{ NextTransactionSize: defaultNts, feeBumpFunctions: []*FeeBumpFunction{ - &FeeBumpFunction{PartialLinearFunctions: firstFeeBumpFunction}, + {PartialLinearFunctions: firstFeeBumpFunction}, }, }, feeRateInSatsPerVbyte: 10, @@ -421,8 +440,8 @@ func TestFeeCalculator(t *testing.T) { feeCalculator: &feeCalculator{ NextTransactionSize: singleNts, feeBumpFunctions: []*FeeBumpFunction{ - &FeeBumpFunction{PartialLinearFunctions: firstFeeBumpFunction}, - &FeeBumpFunction{PartialLinearFunctions: secondFeeBumpFunction}, + {PartialLinearFunctions: firstFeeBumpFunction}, + {PartialLinearFunctions: secondFeeBumpFunction}, }, }, feeRateInSatsPerVbyte: 10, @@ -436,8 +455,8 @@ func TestFeeCalculator(t *testing.T) { feeCalculator: &feeCalculator{ NextTransactionSize: defaultNts, feeBumpFunctions: []*FeeBumpFunction{ - &FeeBumpFunction{PartialLinearFunctions: firstFeeBumpFunction}, - &FeeBumpFunction{PartialLinearFunctions: secondFeeBumpFunction}, + {PartialLinearFunctions: firstFeeBumpFunction}, + {PartialLinearFunctions: secondFeeBumpFunction}, }, }, feeRateInSatsPerVbyte: 100, diff --git a/libwallet/operation/payment_analyzer.go b/libwallet/operation/payment_analyzer.go index 7d1b2220..20b854f4 100644 --- a/libwallet/operation/payment_analyzer.go +++ b/libwallet/operation/payment_analyzer.go @@ -1,11 +1,11 @@ package operation import ( - "errors" - "fmt" "strings" "github.com/btcsuite/btcd/btcutil" + "github.com/go-errors/errors" + "github.com/muun/libwallet/fees" ) @@ -29,8 +29,8 @@ import ( // collectAmount make `OutputAmount > uxtoBalance`. Hence it is an `StatusUnpayable`. // - We don't allow TFFA for LEND swaps (don't want to lend money if you're taking it all away) // - FeeCalculator DOES NOT return error when amount > balance -// - Instead we return the fee it would take to spend all utxos and delegate to the caller the -// task of checking if that is spendable with the given amount +// - Instead we return the fee it would take to spend all utxos and delegate to the +// caller the task of checking if that is spendable with the given amount // - This is to avoid using go error handling // - We finally renamed sweepFee to outputPadding since that's its only use. Here's how it works: // - Only makes sense for swaps @@ -52,11 +52,16 @@ type PaymentToAddress struct { } type PaymentToInvoice struct { - TakeFeeFromAmount bool - AmountInSat int64 - SwapFees *fees.SwapFees // Nullable before we know the paymentAmount for amountless invoice - BestRouteFees []fees.BestRouteFees // Nullable when we know the amount beforehand (invoice with amount) - FundingOutputPolicies *fees.FundingOutputPolicies // Nullable when we know the amount beforehand (invoice with amount) + TakeFeeFromAmount bool + AmountInSat int64 + // Nullable before we know the paymentAmount for amountless invoice + SwapFees *fees.SwapFees + // Nullable when we know the amount beforehand + // (invoice with amount) + BestRouteFees []fees.BestRouteFees + // Nullable when we know the amount beforehand + // (invoice with amount) + FundingOutputPolicies *fees.FundingOutputPolicies } type PaymentAnalyzer struct { @@ -116,14 +121,18 @@ const ( AnalysisStatusUnpayable AnalysisStatus = "Unpayable" ) -// PaymentAnalysis encodes whether a payment can be made or not and some important extra metadata about the payment. +// PaymentAnalysis encodes whether a payment can be made or not and some important extra metadata +// about the payment. type PaymentAnalysis struct { - Status AnalysisStatus // encodes the result of a payment's analysis - AmountInSat int64 // payment amount (e.g the amount the recipient will receive) - FeeTotalInSat int64 // encodes the onchain total fee (other fees may apply, e.g routing/lightning fee) + Status AnalysisStatus // encodes the result of a payment's analysis + AmountInSat int64 // payment amount (e.g the amount the recipient will receive) + // encodes the onchain total fee (other fees may apply, e.g routing/lightning fee) + FeeTotalInSat int64 FeeBumpInSat int64 // fee bump to apply CPFP in unconfirmed utxos SwapFees *fees.SwapFees // metadata related to the swap (if one exists for payment) - TotalInSat int64 // AmountInSat + fees (may include other than FeeTotalInSat). May provide extra information in case of error status (e.g payment can't be made). + // AmountInSat + fees (may include other than FeeTotalInSat). May provide extra information in case + // of error status (e.g payment can't be made). + TotalInSat int64 } func NewPaymentAnalyzer( @@ -159,7 +168,11 @@ func (a *PaymentAnalyzer) ToAddress(payment *PaymentToAddress) (*PaymentAnalysis }, nil } if payment.TakeFeeFromAmount && payment.AmountInSat != a.totalBalance() { - return nil, fmt.Errorf("amount (%v) != userBalance (%v) for TFFA", payment.AmountInSat, a.totalBalance()) + return nil, errors.Errorf( + "amount (%v) != userBalance (%v) for TFFA", + payment.AmountInSat, + a.totalBalance(), + ) } if payment.TakeFeeFromAmount { @@ -168,7 +181,9 @@ func (a *PaymentAnalyzer) ToAddress(payment *PaymentToAddress) (*PaymentAnalysis return a.analyzeFeeFromRemainingBalance(payment) } -func (a *PaymentAnalyzer) analyzeFeeFromAmount(payment *PaymentToAddress) (*PaymentAnalysis, error) { +func (a *PaymentAnalyzer) analyzeFeeFromAmount( + payment *PaymentToAddress, +) (*PaymentAnalysis, error) { fee, feeBump := a.feeCalculator.Fee(payment.AmountInSat, payment.FeeRateInSatsPerVByte, true) total := payment.AmountInSat @@ -197,7 +212,9 @@ func (a *PaymentAnalyzer) analyzeFeeFromAmount(payment *PaymentToAddress) (*Paym }, nil } -func (a *PaymentAnalyzer) analyzeFeeFromRemainingBalance(payment *PaymentToAddress) (*PaymentAnalysis, error) { +func (a *PaymentAnalyzer) analyzeFeeFromRemainingBalance( + payment *PaymentToAddress, +) (*PaymentAnalysis, error) { fee, feeBump := a.feeCalculator.Fee(payment.AmountInSat, payment.FeeRateInSatsPerVByte, false) total := payment.AmountInSat + fee @@ -234,10 +251,16 @@ func (a *PaymentAnalyzer) ToInvoice(payment *PaymentToInvoice) (*PaymentAnalysis } if payment.TakeFeeFromAmount { if payment.BestRouteFees == nil { - return nil, errors.New("fixed amount swap can't be TFFA since that would change the amount") + return nil, errors.New( + "fixed amount swap can't be TFFA since that would change the amount", + ) } if payment.AmountInSat != a.totalBalance() { - return nil, fmt.Errorf("amount (%v) != userBalance (%v) for TFFA", payment.AmountInSat, a.totalBalance()) + return nil, errors.Errorf( + "amount (%v) != userBalance (%v) for TFFA", + payment.AmountInSat, + a.totalBalance(), + ) } } @@ -260,25 +283,34 @@ func (a *PaymentAnalyzer) ToInvoice(payment *PaymentToInvoice) (*PaymentAnalysis } if payment.SwapFees == nil { - return nil, fmt.Errorf("payment is missing required swap fees data") + return nil, errors.Errorf("payment is missing required swap fees data") } return a.analyzeFixedAmountSwap(payment, payment.SwapFees) } -func (a *PaymentAnalyzer) analyzeFixedAmountSwap(payment *PaymentToInvoice, swapFees *fees.SwapFees) (*PaymentAnalysis, error) { +func (a *PaymentAnalyzer) analyzeFixedAmountSwap( + payment *PaymentToInvoice, + swapFees *fees.SwapFees, +) (*PaymentAnalysis, error) { switch swapFees.DebtType { case fees.DebtTypeLend: return a.analyzeLendSwap(payment, swapFees) case fees.DebtTypeCollect: fallthrough case fees.DebtTypeNone: - return a.analyzeCollectSwap(payment, swapFees) // a non-debt swap is just a collect swap with debtAmount = 0 + return a.analyzeCollectSwap( + payment, + swapFees, + ) // a non-debt swap is just a collect swap with debtAmount = 0 } - return nil, fmt.Errorf("unsupported debt type: %v", swapFees.DebtType) + return nil, errors.Errorf("unsupported debt type: %v", swapFees.DebtType) } -func (a *PaymentAnalyzer) analyzeLendSwap(payment *PaymentToInvoice, swapFees *fees.SwapFees) (*PaymentAnalysis, error) { +func (a *PaymentAnalyzer) analyzeLendSwap( + payment *PaymentToInvoice, + swapFees *fees.SwapFees, +) (*PaymentAnalysis, error) { amount := payment.AmountInSat total := amount + int64(swapFees.RoutingFee) @@ -304,9 +336,12 @@ func (a *PaymentAnalyzer) analyzeLendSwap(payment *PaymentToInvoice, swapFees *f }, nil } -// Analyze non LEND swaps (e.g both COLLECT and NON-DEBT swaps), understanding that both cases warrant -// the same analysis. A non-debt swap is just a collect swap with debtAmount = 0. -func (a *PaymentAnalyzer) analyzeCollectSwap(payment *PaymentToInvoice, swapFees *fees.SwapFees) (*PaymentAnalysis, error) { +// Analyze non LEND swaps (e.g both COLLECT and NON-DEBT swaps), understanding that both cases +// warrant the same analysis. A non-debt swap is just a collect swap with debtAmount = 0. +func (a *PaymentAnalyzer) analyzeCollectSwap( + payment *PaymentToInvoice, + swapFees *fees.SwapFees, +) (*PaymentAnalysis, error) { outputAmount := int64(swapFees.OutputAmount) collectAmount := int64(swapFees.DebtAmount) @@ -317,8 +352,11 @@ func (a *PaymentAnalyzer) analyzeCollectSwap(payment *PaymentToInvoice, swapFees collectAmount if outputAmount != expectedOutputAmount { - return nil, fmt.Errorf( - "swap integrity check failed (outputAmount=%v, original_amount=%v, routing_fee=%v, output_padding=%v, collect_amount=%v)", + return nil, errors.Errorf( + "swap integrity check failed"+ + " (outputAmount=%v, original_amount=%v,"+ + " routing_fee=%v, output_padding=%v,"+ + " collect_amount=%v)", outputAmount, payment.AmountInSat, int64(swapFees.RoutingFee), @@ -361,11 +399,12 @@ func (a *PaymentAnalyzer) analyzeCollectSwap(payment *PaymentToInvoice, swapFees // analyzeTFFAAmountlessInvoiceSwap takes care of the insurmountable task of deciding whether a take // take fee from amount payment for an amountless invoice swap can be made, and (if it can) what are // the fees and the destination/payment amount. -// This is particularly tricky since we have kind of a circular dependency: we don't know the payment amount which -// determines the fees (on-chain and lightning), and we need both to determine the number of confirmations required for -// the swap, which affects the on-chain fee, which affects the amount (since this is TFFA). -// For this implementation built from the assumptions that 0-conf on-chain fees are lower than 1-conf fees -// (since we don't have to wait for a block to make the payment). +// This is particularly tricky since we have kind of a circular dependency: we don't know the +// payment amount which determines the fees (on-chain and lightning), and we need both to determine +// the number of confirmations required for the swap, which affects the on-chain fee, which affects +// the amount (since this is TFFA). +// For this implementation built from the assumptions that 0-conf on-chain fees are lower than +// 1-conf fees (since we don't have to wait for a block to make the payment). // Here we go: // 1. We calculate the on-chain fee for a 0-conf swap spending all funds // - If that fee is greater than our balance -> payment can't be made (VERY low balance scenario) @@ -373,12 +412,16 @@ func (a *PaymentAnalyzer) analyzeCollectSwap(payment *PaymentToInvoice, swapFees // 2. We calculate the amount, routing fee and on-chain fee for a 0-conf TFFA swap // 3. We determine the number of confirmations required for the calculated amount and routing fee. // - If 0-conf -> we're good to continue -// - If 1-conf -> we perform step 2 for a 1-conf TFF swap (re calculate amount, routing fee and on-chain fee) +// - If 1-conf -> we perform step 2 for a 1-conf TFF swap (re calculate amount, routing fee and +// on-chain fee) // // 4. If amount <= 0 -> payment can't be made // If amount > 0 -> AWESOME! That's the payment amount. -// 5. We determine the params of the funding output (SwapFees) and perform final checks to decide if payment can be made -func (a *PaymentAnalyzer) analyzeTFFAAmountlessInvoiceSwap(payment *PaymentToInvoice) (*PaymentAnalysis, error) { +// 5. We determine the params of the funding output (SwapFees) and perform final checks to decide if +// payment can be made +func (a *PaymentAnalyzer) analyzeTFFAAmountlessInvoiceSwap( + payment *PaymentToInvoice, +) (*PaymentAnalysis, error) { zeroConfFeeRate, err := a.feeWindow.SwapFeeRate(0) if err != nil { @@ -398,7 +441,8 @@ func (a *PaymentAnalyzer) analyzeTFFAAmountlessInvoiceSwap(payment *PaymentToInv params, err := a.computeParamsForTFFASwap(payment, 0) if err != nil { - // This LITERALLY can never happen, as only source of error for computeParamsForTFFASwap are: + // This LITERALLY can never happen, as only source of error for + // computeParamsForTFFASwap are: // - negative conf target (we're using 0) // - no route for amount (should be guaranteed by BestRouteFees struct) return &PaymentAnalysis{ @@ -409,11 +453,15 @@ func (a *PaymentAnalyzer) analyzeTFFAAmountlessInvoiceSwap(payment *PaymentToInv }, nil } - confirmations := payment.FundingOutputPolicies.FundingConfirmations(params.Amount, params.RoutingFee) + confirmations := payment.FundingOutputPolicies.FundingConfirmations( + params.Amount, + params.RoutingFee, + ) if confirmations == 1 { params, err = a.computeParamsForTFFASwap(payment, 1) if err != nil { - // This LITERALLY can never happen, as only source of error for computeParamsForTFFASwap are: + // This LITERALLY can never happen, as only source of error for + // computeParamsForTFFASwap are: // - negative conf target (we're using 1) // - no route for amount (should be guaranteed by BestRouteFees struct) return &PaymentAnalysis{ @@ -442,7 +490,12 @@ func (a *PaymentAnalyzer) analyzeTFFAAmountlessInvoiceSwap(payment *PaymentToInv }, nil } - swapFees := fees.ComputeSwapFees(amount, payment.BestRouteFees, payment.FundingOutputPolicies, true) + swapFees := fees.ComputeSwapFees( + amount, + payment.BestRouteFees, + payment.FundingOutputPolicies, + true, + ) if swapFees.DebtType == fees.DebtTypeLend { return nil, errors.New("TFFA swap should not be a lend operation") @@ -452,8 +505,12 @@ func (a *PaymentAnalyzer) analyzeTFFAAmountlessInvoiceSwap(payment *PaymentToInv outputAmount := amount + lightningFee + swapFees.OutputPadding + swapFees.DebtAmount if lightningFee != swapFees.RoutingFee { - return nil, fmt.Errorf( - "integrity error: inconsistent lightning fee calculated for TFFA swap (lightning_fee=%v, output_amount=%v, original_amount=%v, routing_fee=%v, output_padding=%v)", + return nil, errors.Errorf( + "integrity error: inconsistent lightning fee"+ + " calculated for TFFA swap"+ + " (lightning_fee=%v, output_amount=%v,"+ + " original_amount=%v, routing_fee=%v,"+ + " output_padding=%v)", int64(lightningFee), int64(outputAmount), payment.AmountInSat, @@ -463,12 +520,14 @@ func (a *PaymentAnalyzer) analyzeTFFAAmountlessInvoiceSwap(payment *PaymentToInv } total := outputAmount + onChainFee - totalForDisplay := total - swapFees.DebtAmount // amount + lightningFee + outputPadding + onChainFee + // amount + lightningFee + outputPadding + onChainFee + totalForDisplay := total - swapFees.DebtAmount // We need to ensure we can spend on chain and that we have enough UI visible balance too // That is, the collect doesn't make us spend more than we really can and the amount + fee // doesn't default any debt. - canPay := total <= btcutil.Amount(a.utxoBalance()) && totalForDisplay <= btcutil.Amount(a.totalBalance()) + canPay := total <= btcutil.Amount(a.utxoBalance()) && + totalForDisplay <= btcutil.Amount(a.totalBalance()) if !canPay { return &PaymentAnalysis{ @@ -528,7 +587,6 @@ type swapParams struct { // Then, x + (a * x + b) = y - h // Then, x * (1 + a) = y - h - b // Then, x = (y - h - b) / (a + 1) (*1) -// // BUT, we can have different routes for different amounts, aka: // l_1(x) = a_1 * x + b_1 // l_2(x) = a_2 * x + b_2 @@ -548,7 +606,10 @@ type swapParams struct { // x = (y - h - b) / (FeeProportionalMillionth/1_000_000 + 1) // x = (y - h - b) / (FeeProportionalMillionth + 1_000_000) / 1_000_000 // x = ((y - h - b) * 1_000_000) / (FeeProportionalMillionth + 1_000_000) -func (a *PaymentAnalyzer) computeParamsForTFFASwap(payment *PaymentToInvoice, confs uint) (*swapParams, error) { +func (a *PaymentAnalyzer) computeParamsForTFFASwap( + payment *PaymentToInvoice, + confs uint, +) (*swapParams, error) { feeRate, err := a.feeWindow.SwapFeeRate(confs) if err != nil { @@ -576,11 +637,10 @@ func (a *PaymentAnalyzer) computeParamsForTFFASwap(payment *PaymentToInvoice, co if amount+lightningFee <= bestRouteFees.MaxCapacity { // There's a special comment to be made here for VERY edgy case where - // bestRouteFees.ForAmount(amount+1) == lightningFee+1. - // In this case adding 1 sat to the amount makes you need an extra sat for the - // routingFee, and these 2 extra sats make the total go over userBalance, but - // there's 1 sat available in our balance. What do we do it? Answer: nothing, - // it will be burn as on-chain fee. + // bestRouteFees.ForAmount(amount+1) == lightningFee+1. In this case adding 1 sat to the + // amount makes you need an extra sat for the routingFee, and these 2 extra sats make + // the total go over userBalance, but there's 1 sat available in our balance. What do we + // do it? Answer: nothing, it will be burn as on-chain fee. return &swapParams{ Amount: amount, diff --git a/libwallet/operation/payment_analyzer_test.go b/libwallet/operation/payment_analyzer_test.go index 20a661b9..fb78b899 100644 --- a/libwallet/operation/payment_analyzer_test.go +++ b/libwallet/operation/payment_analyzer_test.go @@ -219,22 +219,22 @@ func TestAnalyzeOnChain(t *testing.T) { { AmountInSat: 10_000, SizeInVByte: 240, - Outpoint: "0437cd7f8525ceed2324359c2d0ba26006d92d856a9c20fa0241106ee5a597c4:0", + Outpoint: "0437cd7f8525ceed2324359c2d0ba26006d92d856a9c20fa0241106ee5a597c4:0", //nolint:lll UtxoStatus: UtxosStatusUnconfirmed, }, }, ExpectedDebtInSat: 0, }, feeBump: []*FeeBumpFunction{ - &FeeBumpFunction{ + { []*PartialLinearFunction{ partialLinearFunction, }}, - &FeeBumpFunction{ + { []*PartialLinearFunction{ partialLinearFunction, }}, - &FeeBumpFunction{ + { []*PartialLinearFunction{ partialLinearFunction, }}, @@ -259,27 +259,27 @@ func TestAnalyzeOnChain(t *testing.T) { { AmountInSat: 10_000, SizeInVByte: 240, - Outpoint: "0437cd7f8525ceed2324359c2d0ba26006d92d856a9c20fa0241106ee5a597c4:0", + Outpoint: "0437cd7f8525ceed2324359c2d0ba26006d92d856a9c20fa0241106ee5a597c4:0", //nolint:lll UtxoStatus: UtxosStatusConfirmed, }, { AmountInSat: 20_000, SizeInVByte: 440, - Outpoint: "0437cd7f8525ceed2324359c2d0ba26006d92d856a9c20fa0241106ee5a597c3:0", + Outpoint: "0437cd7f8525ceed2324359c2d0ba26006d92d856a9c20fa0241106ee5a597c3:0", //nolint:lll UtxoStatus: UtxosStatusUnconfirmed, }, { AmountInSat: 30_000, SizeInVByte: 780, - Outpoint: "0437cd7f8525ceed2324359c2d0ba26006d92d856a9c20fa0241106ee5a597c2:0", + Outpoint: "0437cd7f8525ceed2324359c2d0ba26006d92d856a9c20fa0241106ee5a597c2:0", //nolint:lll UtxoStatus: UtxosStatusUnconfirmed, }, }, ExpectedDebtInSat: 0, }, feeBump: []*FeeBumpFunction{ - &FeeBumpFunction{firstFeeBumpFunction}, - &FeeBumpFunction{secondFeeBumpFunction}, + {firstFeeBumpFunction}, + {secondFeeBumpFunction}, }, payment: &PaymentToAddress{ TakeFeeFromAmount: false, @@ -301,27 +301,27 @@ func TestAnalyzeOnChain(t *testing.T) { { AmountInSat: 10_000, SizeInVByte: 240, - Outpoint: "0437cd7f8525ceed2324359c2d0ba26006d92d856a9c20fa0241106ee5a597c4:0", + Outpoint: "0437cd7f8525ceed2324359c2d0ba26006d92d856a9c20fa0241106ee5a597c4:0", //nolint:lll UtxoStatus: UtxosStatusConfirmed, }, { AmountInSat: 20_000, SizeInVByte: 440, - Outpoint: "0437cd7f8525ceed2324359c2d0ba26006d92d856a9c20fa0241106ee5a597c3:0", + Outpoint: "0437cd7f8525ceed2324359c2d0ba26006d92d856a9c20fa0241106ee5a597c3:0", //nolint:lll UtxoStatus: UtxosStatusUnconfirmed, }, { AmountInSat: 30_000, SizeInVByte: 780, - Outpoint: "0437cd7f8525ceed2324359c2d0ba26006d92d856a9c20fa0241106ee5a597c2:0", + Outpoint: "0437cd7f8525ceed2324359c2d0ba26006d92d856a9c20fa0241106ee5a597c2:0", //nolint:lll UtxoStatus: UtxosStatusUnconfirmed, }, }, ExpectedDebtInSat: 0, }, feeBump: []*FeeBumpFunction{ - &FeeBumpFunction{firstFeeBumpFunction}, - &FeeBumpFunction{secondFeeBumpFunction}, + {firstFeeBumpFunction}, + {secondFeeBumpFunction}, }, payment: &PaymentToAddress{ TakeFeeFromAmount: true, @@ -343,14 +343,14 @@ func TestAnalyzeOnChain(t *testing.T) { { AmountInSat: 10_000, SizeInVByte: 240, - Outpoint: "0437cd7f8525ceed2324359c2d0ba26006d92d856a9c20fa0241106ee5a597c4:0", + Outpoint: "0437cd7f8525ceed2324359c2d0ba26006d92d856a9c20fa0241106ee5a597c4:0", //nolint:lll UtxoStatus: UtxosStatusUnconfirmed, }, }, ExpectedDebtInSat: 0, }, feeBump: []*FeeBumpFunction{ - &FeeBumpFunction{ + { []*PartialLinearFunction{ partialLinearFunction, }}, @@ -616,7 +616,11 @@ func TestAnalyzeOnChain(t *testing.T) { t.Fatal(err) } if !reflect.DeepEqual(analysis, tC.expected) { - t.Fatalf("analysis does not match expected, got %+v, expected %+v", analysis, tC.expected) + t.Fatalf( + "analysis does not match expected, got %+v, expected %+v", + analysis, + tC.expected, + ) } }) } @@ -928,14 +932,14 @@ func TestAnalyzeOffChain(t *testing.T) { { AmountInSat: 10_000, SizeInVByte: 240, - Outpoint: "0437cd7f8525ceed2324359c2d0ba26006d92d856a9c20fa0241106ee5a597c4:0", + Outpoint: "0437cd7f8525ceed2324359c2d0ba26006d92d856a9c20fa0241106ee5a597c4:0", //nolint:lll UtxoStatus: UtxosStatusUnconfirmed, }, }, ExpectedDebtInSat: 0, }, feeBump: []*FeeBumpFunction{ - &FeeBumpFunction{ + { []*PartialLinearFunction{ partialLinearFunction, }}, @@ -1013,14 +1017,14 @@ func TestAnalyzeOffChain(t *testing.T) { { AmountInSat: 10_000, SizeInVByte: 240, - Outpoint: "0437cd7f8525ceed2324359c2d0ba26006d92d856a9c20fa0241106ee5a597c4:0", + Outpoint: "0437cd7f8525ceed2324359c2d0ba26006d92d856a9c20fa0241106ee5a597c4:0", //nolint:lll UtxoStatus: UtxosStatusUnconfirmed, }, }, ExpectedDebtInSat: 0, }, feeBump: []*FeeBumpFunction{ - &FeeBumpFunction{ + { []*PartialLinearFunction{ partialLinearFunction, }}, @@ -2521,7 +2525,9 @@ func TestAnalyzeOffChain(t *testing.T) { } if !reflect.DeepEqual(analysis, tC.expected) { t.Fatalf( - "analysis does not match expected\n analysis: got %+v, expected %+v\nswapfees: got %+v, expected %+v", + "analysis does not match expected\n"+ + " analysis: got %+v, expected %+v\n"+ + "swapfees: got %+v, expected %+v", analysis, tC.expected, analysis.SwapFees, diff --git a/libwallet/partiallysignedtransaction.go b/libwallet/partiallysignedtransaction.go index 0f26bf61..76d91db2 100644 --- a/libwallet/partiallysignedtransaction.go +++ b/libwallet/partiallysignedtransaction.go @@ -3,17 +3,15 @@ package libwallet import ( "bytes" "encoding/hex" - "errors" - "fmt" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/go-errors/errors" "github.com/muun/libwallet/addresses" "github.com/muun/libwallet/btcsuitew/btcutilw" "github.com/muun/libwallet/btcsuitew/txscriptw" - - "github.com/btcsuite/btcd/chaincfg/chainhash" - - "github.com/btcsuite/btcd/btcutil" - "github.com/btcsuite/btcd/wire" ) type SigningExpectations struct { @@ -24,7 +22,13 @@ type SigningExpectations struct { alternative bool } -func NewSigningExpectations(destination string, amount int64, change MuunAddress, fee int64, alternative bool) *SigningExpectations { +func NewSigningExpectations( + destination string, + amount int64, + change MuunAddress, + fee int64, + alternative bool, +) *SigningExpectations { return &SigningExpectations{ destination, amount, @@ -51,7 +55,7 @@ type MuunAddress interface { } type Outpoint interface { - TxId() []byte + TxId() []byte //nolint:staticcheck // should be TxID, but it's part of the gomobile contract with the apps Index() int Amount() int64 } @@ -128,7 +132,7 @@ func NewPartiallySignedTransaction( tx := wire.NewMsgTx(0) err := tx.Deserialize(bytes.NewReader(rawTx)) if err != nil { - return nil, fmt.Errorf("failed to decode tx: %w", err) + return nil, errors.Errorf("failed to decode tx: %w", err) } return &PartiallySignedTransaction{ @@ -169,12 +173,17 @@ func (p *PartiallySignedTransaction) createPrevOuts(net *Network) ([]*wire.TxOut decodedAddr, err := btcutilw.DecodeAddress(addr, net.network) if err != nil { - return nil, fmt.Errorf("failed to decode address %s in prevOut %d: %w", addr, i, err) + return nil, errors.Errorf("failed to decode address %s in prevOut %d: %w", addr, i, err) } script, err := txscriptw.PayToAddrScript(decodedAddr) if err != nil { - return nil, fmt.Errorf("failed to craft output script for %s in prevOut %d: %w", addr, i, err) + return nil, errors.Errorf( + "failed to craft output script for %s in prevOut %d: %w", + addr, + i, + err, + ) } prevOuts[i] = &wire.TxOut{Value: amount, PkScript: script} @@ -183,17 +192,20 @@ func (p *PartiallySignedTransaction) createPrevOuts(net *Network) ([]*wire.TxOut return prevOuts, nil } -func (p *PartiallySignedTransaction) Sign(userKey *HDPrivateKey, muunKey *HDPublicKey) (*Transaction, error) { +func (p *PartiallySignedTransaction) Sign( + userKey *HDPrivateKey, + muunKey *HDPublicKey, +) (*Transaction, error) { coins, err := p.coins(userKey.Network) if err != nil { - return nil, fmt.Errorf("could not convert input data to coin: %w", err) + return nil, errors.Errorf("could not convert input data to coin: %w", err) } for i, coin := range coins { err = coin.SignInput(i, p.tx, userKey, muunKey) if err != nil { - return nil, fmt.Errorf("failed to sign input: %w", err) + return nil, errors.Errorf("failed to sign input: %w", err) } } @@ -201,26 +213,33 @@ func (p *PartiallySignedTransaction) Sign(userKey *HDPrivateKey, muunKey *HDPubl } -func (p *PartiallySignedTransaction) FullySign(userKey, muunKey *HDPrivateKey) (*Transaction, error) { +func (p *PartiallySignedTransaction) FullySign( + userKey, muunKey *HDPrivateKey, +) (*Transaction, error) { coins, err := p.coins(userKey.Network) if err != nil { - return nil, fmt.Errorf("could not convert input data to coin: %w", err) + return nil, errors.Errorf("could not convert input data to coin: %w", err) } for i, coin := range coins { err = coin.FullySignInput(i, p.tx, userKey, muunKey) if err != nil { - return nil, fmt.Errorf("failed to sign input: %w", err) + return nil, errors.Errorf("failed to sign input: %w", err) } } return newTransaction(p.tx) } -func (p *PartiallySignedTransaction) Verify(expectations *SigningExpectations, userPublicKey *HDPublicKey, muunPublickKey *HDPublicKey) error { +func (p *PartiallySignedTransaction) Verify( + expectations *SigningExpectations, + userPublicKey *HDPublicKey, + muunPublickKey *HDPublicKey, +) error { - // TODO: We don't have enough information (yet) to check the inputs are actually ours and they exist. + // TODO: We don't have enough information (yet) to check the inputs are actually ours and they + // exist. network := userPublicKey.Network @@ -228,20 +247,25 @@ func (p *PartiallySignedTransaction) Verify(expectations *SigningExpectations, u // If we were to receive more than that, we consider it invalid. if expectations.change != nil { - // Alternative TXs with change output might not have the destination output, so we - // don't do a strict check but rather a sanity one. The strict check will be down - // the line. + // Alternative TXs with change output might not have the destination output, so we don't do + // a strict check but rather a sanity one. The strict check will be down the line. if expectations.alternative { if len(p.tx.TxOut) > 2 { - return fmt.Errorf("expected at most destination and change outputs but found %v", len(p.tx.TxOut)) + return errors.Errorf( + "expected at most destination and change outputs but found %v", + len(p.tx.TxOut), + ) } } else if len(p.tx.TxOut) != 2 { - return fmt.Errorf("expected destination and change outputs but found %v", len(p.tx.TxOut)) + return errors.Errorf( + "expected destination and change outputs but found %v", + len(p.tx.TxOut), + ) } } else if len(p.tx.TxOut) != 1 { - return fmt.Errorf("expected destination output only but found %v", len(p.tx.TxOut)) + return errors.Errorf("expected destination output only but found %v", len(p.tx.TxOut)) } // Build output script corresponding to the destination address. @@ -276,19 +300,24 @@ func (p *PartiallySignedTransaction) Verify(expectations *SigningExpectations, u if expectations.alternative { // Alternative TXs might not have a destination output if there's change present if toOutput == nil && changeOutput == nil { - return fmt.Errorf("expected at least one of destination and change outputs but found zero") + return errors.Errorf( + "expected at least one of destination and change outputs but found zero", + ) } if toOutput != nil && toOutput.Value >= expectedAmount { - return fmt.Errorf("destination amount is mismatched. found %v expected at most %v", toOutput.Value, expectedAmount) + return errors.Errorf( + "destination amount is mismatched. found %v expected at most %v", + toOutput.Value, + expectedAmount, + ) } if (toOutput == nil || changeOutput == nil) && len(p.tx.TxOut) > 1 { - return fmt.Errorf("expected exactly one output and found %v", len(p.tx.TxOut)) + return errors.Errorf("expected exactly one output and found %v", len(p.tx.TxOut)) } - // Re-adjust our expectations by moving the reduced destination amount - // to fee. + // Re-adjust our expectations by moving the reduced destination amount to fee. if toOutput == nil { expectedFee += expectedAmount expectedAmount = 0 @@ -305,14 +334,20 @@ func (p *PartiallySignedTransaction) Verify(expectations *SigningExpectations, u // Verify destination output value matches expected amount if toOutput.Value != expectedAmount { - return fmt.Errorf("destination amount is mismatched. found %v expected %v", toOutput.Value, expectedAmount) + return errors.Errorf( + "destination amount is mismatched. found %v expected %v", + toOutput.Value, + expectedAmount, + ) } } /* NOT CHECKED: outputs smaller than dustThreshold. - We removed this check, which could be exploited by the crafter to invalidate the transaction. Since failing the - integrity check ourselves would have the same effect (preventing us from signing) it doesn't make much sense. + We removed this check, which could be exploited by the crafter to + invalidate the transaction. Since failing the integrity check + ourselves would have the same effect (preventing us from signing) + it doesn't make much sense. */ var actualTotal int64 @@ -322,10 +357,13 @@ func (p *PartiallySignedTransaction) Verify(expectations *SigningExpectations, u /* NOT CHECKED: input amounts. - These are provided by the crafter, but for segwit inputs (scheme v3 and forward), the amount is part of - the data to sign. Thus, they can't be manipulated without invalidating the signature. - Client's using this code are all generating v3 or superior addresses. They could still have older UTXOs, but - they should be rare, only a handful of users ever used v1 and v2 addresses. + These are provided by the crafter, but for segwit inputs + (scheme v3 and forward), the amount is part of the data to sign. + Thus, they can't be manipulated without invalidating the + signature. Client's using this code are all generating v3 or + superior addresses. They could still have older UTXOs, but they + should be rare, only a handful of users ever used v1 and v2 + addresses. */ // Verify change output is spendable by the wallet. @@ -336,19 +374,19 @@ func (p *PartiallySignedTransaction) Verify(expectations *SigningExpectations, u expectedChangeAmount := actualTotal - expectedAmount - expectedFee if changeOutput.Value != expectedChangeAmount { - return fmt.Errorf("change amount is mismatched. found %v expected %v", + return errors.Errorf("change amount is mismatched. found %v expected %v", changeOutput.Value, expectedChangeAmount) } derivedUserKey, err := userPublicKey.DeriveTo(expectedChange.DerivationPath()) if err != nil { - return fmt.Errorf("failed to derive user key to change path %v: %w", + return errors.Errorf("failed to derive user key to change path %v: %w", expectedChange.DerivationPath(), err) } derivedMuunKey, err := muunPublickKey.DeriveTo(expectedChange.DerivationPath()) if err != nil { - return fmt.Errorf("failed to derive muun key to change path %v: %w", + return errors.Errorf("failed to derive muun key to change path %v: %w", expectedChange.DerivationPath(), err) } @@ -360,24 +398,24 @@ func (p *PartiallySignedTransaction) Verify(expectations *SigningExpectations, u network.network, ) if err != nil { - return fmt.Errorf("failed to build the change address with version %v: %w", + return errors.Errorf("failed to build the change address with version %v: %w", expectedChange.Version(), err) } if expectedChangeAddress.Address() != expectedChange.Address() { - return fmt.Errorf("mismatched change address. found %v, expected %v", + return errors.Errorf("mismatched change address. found %v, expected %v", expectedChange.Address(), expectedChangeAddress.Address()) } actualFee := actualTotal - expectedAmount - expectedChangeAmount if actualFee != expectedFee { - return fmt.Errorf("fee mismatched. found %v, expected %v", actualFee, expectedFee) + return errors.Errorf("fee mismatched. found %v, expected %v", actualFee, expectedFee) } } else { actualFee := actualTotal - expectedAmount if actualFee >= expectedFee+dustThreshold { - return fmt.Errorf( + return errors.Errorf( "change output is too big to be burned as fee. actual fee: %v, expected: %v", actualFee, expectedFee, ) @@ -386,9 +424,12 @@ func (p *PartiallySignedTransaction) Verify(expectations *SigningExpectations, u /* NOT CHECKED: locktimes. - Using locktimes set in the future would invalidate the transaction, so the crafter could prevent us from spending - money. However, we would inflict the same denial on ourselves by rejecting it. Also, we'll eventually rely on - locktimes ourselves and would then need version checks to decide whether to send them to specific clients. + Using locktimes set in the future would invalidate the + transaction, so the crafter could prevent us from spending money. + However, we would inflict the same denial on ourselves by + rejecting it. Also, we'll eventually rely on locktimes ourselves + and would then need version checks to decide whether to send + them to specific clients. */ return nil @@ -397,11 +438,11 @@ func (p *PartiallySignedTransaction) Verify(expectations *SigningExpectations, u func addressToScript(address string, network *Network) ([]byte, error) { parsedAddress, err := btcutilw.DecodeAddress(address, network.network) if err != nil { - return nil, fmt.Errorf("failed to parse address %v: %w", address, err) + return nil, errors.Errorf("failed to parse address %v: %w", address, err) } script, err := txscriptw.PayToAddrScript(parsedAddress) if err != nil { - return nil, fmt.Errorf("failed to generate script for address %v: %w", address, err) + return nil, errors.Errorf("failed to generate script for address %v: %w", address, err) } return script, nil } @@ -410,7 +451,7 @@ func newTransaction(tx *wire.MsgTx) (*Transaction, error) { var buf bytes.Buffer err := tx.Serialize(&buf) if err != nil { - return nil, fmt.Errorf("failed to encode tx: %w", err) + return nil, errors.Errorf("failed to encode tx: %w", err) } return &Transaction{ @@ -426,7 +467,13 @@ type coin interface { FullySignInput(index int, tx *wire.MsgTx, userKey, muunKey *HDPrivateKey) error } -func createCoin(index int, input Input, network *Network, sigHashes *txscriptw.TaprootSigHashes, userNonces *MusigNonces) (coin, error) { +func createCoin( + index int, + input Input, + network *Network, + sigHashes *txscriptw.TaprootSigHashes, + userNonces *MusigNonces, +) (coin, error) { txID, err := chainhash.NewHash(input.OutPoint().TxId()) if err != nil { return nil, err @@ -441,10 +488,10 @@ func createCoin(index int, input Input, network *Network, sigHashes *txscriptw.T version := input.Address().Version() if userNonces == nil { - return nil, fmt.Errorf("userNonces cannot be nil") + return nil, errors.Errorf("userNonces cannot be nil") } - if len(userNonces.sessionIds) <= index { - return nil, fmt.Errorf("not enough nonces were provided") + if len(userNonces.sessionIDs) <= index { + return nil, errors.Errorf("not enough nonces were provided") } switch version { @@ -487,7 +534,7 @@ func createCoin(index int, input Input, network *Network, sigHashes *txscriptw.T OutPoint: outPoint, KeyPath: keyPath, Amount: amount, - UserSessionId: userNonces.sessionIds[index], + UserSessionID: userNonces.sessionIDs[index], MuunPubNonce: nonce, MuunPartialSig: muunPartialSig, SigHashes: sigHashes, @@ -502,7 +549,7 @@ func createCoin(index int, input Input, network *Network, sigHashes *txscriptw.T OutPoint: outPoint, KeyPath: keyPath, Amount: amount, - UserSessionId: userNonces.sessionIds[index], + UserSessionID: userNonces.sessionIDs[index], MuunPubNonce: nonce, MuunPartialSig: muunPartialSig, SigHashes: sigHashes, @@ -561,6 +608,6 @@ func createCoin(index int, input Input, network *Network, sigHashes *txscriptw.T HtlcOutputKeyPath: swap.HtlcOutputKeyPath(), }, nil default: - return nil, fmt.Errorf("can't create coin from input version %v", version) + return nil, errors.Errorf("can't create coin from input version %v", version) } } diff --git a/libwallet/partiallysignedtransaction_test.go b/libwallet/partiallysignedtransaction_test.go index 28657e0c..72652c3d 100644 --- a/libwallet/partiallysignedtransaction_test.go +++ b/libwallet/partiallysignedtransaction_test.go @@ -10,11 +10,13 @@ import ( "encoding/hex" "testing" + "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" "github.com/test-go/testify/require" "github.com/muun/libwallet/addresses" + "github.com/muun/libwallet/musig" "github.com/muun/libwallet/walletdb" ) @@ -66,13 +68,13 @@ func (i *input) MuunPublicNonce() []byte { } type outpoint struct { - txId []byte + txID []byte index int amount int64 } -func (o *outpoint) TxId() []byte { - return o.txId +func (o *outpoint) TxId() []byte { //nolint:staticcheck // should be TxID, but it's part of the gomobile contract with the apps + return o.txID } func (o *outpoint) Index() int { @@ -182,21 +184,26 @@ func (i *inputIncomingSwap) CollectInSats() int64 { return i.collectInSats } -// generates test nonces using fixed sessionIds and a list of inputs -func createTestNonces(t *testing.T, userKey *HDPrivateKey, inputList *InputList, userSessionIds []string) *MusigNonces { +// generates test nonces using fixed sessionIDs and a list of inputs +func createTestNonces( + t *testing.T, + userKey *HDPrivateKey, + inputList *InputList, + userSessionIDs []string, +) *MusigNonces { nonces := EmptyMusigNonces() for index := range inputList.inputs { input := inputList.inputs[index] inputSignerKey, err := userKey.DeriveTo(input.Address().DerivationPath()) require.NoError(t, err) - var sessionId [32]byte - copy(sessionId[:], hexToBytes(userSessionIds[index])) + var sessionID [32]byte + copy(sessionID[:], hexToBytes(userSessionIDs[index])) nonces.generateStaticNonce( input.Address().Version(), inputSignerKey.PublicKey().Raw(), - sessionId, + sessionID, ) } return nonces @@ -204,7 +211,7 @@ func createTestNonces(t *testing.T, userKey *HDPrivateKey, inputList *InputList, func TestPartiallySignedTransaction_SignV1(t *testing.T) { const ( - hexTx = "0100000001706bcabdcdcfd519bdb4534f8ace9f8a3cd614e7b00f074cce0a58913eadfffb0100000000ffffffff022cf46905000000001976a914072b22dfb34153d4e084dce8c6655430d37f12d088aca4de8b00000000001976a914fded0987447ef3273cde87bf8b65a11d1fd9caca88ac00000000" + hexTx = "0100000001706bcabdcdcfd519bdb4534f8ace9f8a3cd614e7b00f074cce0a58913eadfffb0100000000ffffffff022cf46905000000001976a914072b22dfb34153d4e084dce8c6655430d37f12d088aca4de8b00000000001976a914fded0987447ef3273cde87bf8b65a11d1fd9caca88ac00000000" //nolint:lll hexTxOut = "fbffad3e91580ace4c070fb0e714d63c8a9fce8a4f53b4bd19d5cfcdbdca6b70" txIndex = 1 txAmount = 100000000 @@ -212,14 +219,14 @@ func TestPartiallySignedTransaction_SignV1(t *testing.T) { addressPath = "m/schema:1'/recovery:1'/external:1/1" originAddress = "n4fbDDpmfZgyjHsp93C5z7rd68Wq5kS2tj" - encodedUserKey = "tprv8eJiUjHpVRyTUM1p4XDRUdRZPJLfud22swAv48my1MxaCZztUNRrWxmN6ycdd9a2xfJwLchq5jW9m2jkNpwruijwvygCv41e6YrsqUvw7hQ" + encodedUserKey = "tprv8eJiUjHpVRyTUM1p4XDRUdRZPJLfud22swAv48my1MxaCZztUNRrWxmN6ycdd9a2xfJwLchq5jW9m2jkNpwruijwvygCv41e6YrsqUvw7hQ" //nolint:lll ) txOut1, _ := hex.DecodeString(hexTxOut) inputs := []Input{ &input{ - outpoint: outpoint{index: txIndex, amount: txAmount, txId: txOut1}, + outpoint: outpoint{index: txIndex, amount: txAmount, txID: txOut1}, address: addresses.New(addresses.V1, addressPath, originAddress), }, } @@ -240,43 +247,43 @@ func TestPartiallySignedTransaction_SignV1(t *testing.T) { signedTx := wire.NewMsgTx(0) signedTx.Deserialize(bytes.NewReader(signedRawTx.Bytes)) - verifyInput(t, signedTx, hexTx, txIndex, 0) + mustVerifyInput(t, signedTx, hexTx, txIndex, 0) } func TestPartiallySignedTransaction_SignV2(t *testing.T) { const ( - hexTx = "0100000004f3c15d23060a622bef5e0346ba3410ec118b959be0058c282a1e2045af511b720100000000ffffffffb8ac53a0702e45f7d0164cf6164b48fe66b56af23308e9478cb75e3a2627b74a0100000000ffffffff4e54dc96b07fb29f709c30007fc12abdcde6a20bcad73c8ec6124f34ce096f9b0000000000ffffffff4c11c4284a8e48baa4527fd26e7d0c3dda25ffb3a7f92aa2a248b5a76981d8a40000000000ffffffff01a9cbea0b0000000017a914dfca2abd2bb72cf911940a9d16de126cc1cd60368700000000" + hexTx = "0100000004f3c15d23060a622bef5e0346ba3410ec118b959be0058c282a1e2045af511b720100000000ffffffffb8ac53a0702e45f7d0164cf6164b48fe66b56af23308e9478cb75e3a2627b74a0100000000ffffffff4e54dc96b07fb29f709c30007fc12abdcde6a20bcad73c8ec6124f34ce096f9b0000000000ffffffff4c11c4284a8e48baa4527fd26e7d0c3dda25ffb3a7f92aa2a248b5a76981d8a40000000000ffffffff01a9cbea0b0000000017a914dfca2abd2bb72cf911940a9d16de126cc1cd60368700000000" //nolint:lll txIndex1 = 1 txAmount1 = 50000000 hexTxOut1 = "721b51af45201e2a288c05e09b958b11ec1034ba46035eef2b620a06235dc1f3" - hexMuunSig1 = "3045022100d07028674c49d8dabc536db47f1371c2f61fc578cb2c8797a570e3176f5e91c902206a83db8ad5b63e88c48d0ae4e67646fcf6e33d0177a88996c15b280494885e7b01" - hexTx1 = "0200000001020678c852c6d943cf0d3a9b5102b1a4e2ebccdb4ca2eaae7731c8f59b81172a000000004847304402204a3958c1bd6abcd7b5ec2291bd43391dcfe757068ff0e340dd8f502cb25435b0022076e865730e49e4d126b94675d276545e35afa84feea2873bb5f923b842d90f4801feffffff0224bf45220000000017a914cb81f4e1ff68249e6f4f17a7995007b5a478705b8780f0fa020000000017a914dfca2abd2bb72cf911940a9d16de126cc1cd60368794020000" + hexMuunSig1 = "3045022100d07028674c49d8dabc536db47f1371c2f61fc578cb2c8797a570e3176f5e91c902206a83db8ad5b63e88c48d0ae4e67646fcf6e33d0177a88996c15b280494885e7b01" //nolint:lll + hexTx1 = "0200000001020678c852c6d943cf0d3a9b5102b1a4e2ebccdb4ca2eaae7731c8f59b81172a000000004847304402204a3958c1bd6abcd7b5ec2291bd43391dcfe757068ff0e340dd8f502cb25435b0022076e865730e49e4d126b94675d276545e35afa84feea2873bb5f923b842d90f4801feffffff0224bf45220000000017a914cb81f4e1ff68249e6f4f17a7995007b5a478705b8780f0fa020000000017a914dfca2abd2bb72cf911940a9d16de126cc1cd60368794020000" //nolint:lll txIndex2 = 1 txAmount2 = 50000000 hexTxOut2 = "4ab727263a5eb78c47e90833f26ab566fe484b16f64c16d0f7452e70a053acb8" - hexMuunSig2 = "304402201b0c35179a5fa8e6255115450979a77dbb97d89157e236783df0312a5d7bdb2c022064bae7ad0cdc72e4339421067cc65e0c3d03690a5c2d98c32a6ef67f883558a001" - hexTx2 = "0200000001ff3f3b16506ef957b9ea80287f276ee415380597a4ede7ae45fff6e18d3e13d8000000004847304402204dbe876d7f0761a72ecc2d0e0e45c1ab32d6bd69d5062068984e26af02c4b27102202f2bd18a17821bdce155b13ea2c379bb78c9157f7f44e2e6a8cef1a154ec68ac01feffffff0224bf45220000000017a914684830d4ef58c54b6b3db6b4a3eb7818d418ae258780f0fa020000000017a914dfca2abd2bb72cf911940a9d16de126cc1cd60368794020000" + hexMuunSig2 = "304402201b0c35179a5fa8e6255115450979a77dbb97d89157e236783df0312a5d7bdb2c022064bae7ad0cdc72e4339421067cc65e0c3d03690a5c2d98c32a6ef67f883558a001" //nolint:lll + hexTx2 = "0200000001ff3f3b16506ef957b9ea80287f276ee415380597a4ede7ae45fff6e18d3e13d8000000004847304402204dbe876d7f0761a72ecc2d0e0e45c1ab32d6bd69d5062068984e26af02c4b27102202f2bd18a17821bdce155b13ea2c379bb78c9157f7f44e2e6a8cef1a154ec68ac01feffffff0224bf45220000000017a914684830d4ef58c54b6b3db6b4a3eb7818d418ae258780f0fa020000000017a914dfca2abd2bb72cf911940a9d16de126cc1cd60368794020000" //nolint:lll txIndex3 = 0 txAmount3 = 50000000 hexTxOut3 = "9b6f09ce344f12c68e3cd7ca0ba2e6cdbd2ac17f00309c709fb27fb096dc544e" - hexMuunSig3 = "30440220076b14b1c906089546cb40ce05dab38f0388ca65d0bc5183d3c3f7dcb98be52c022001eea4635d56726d990daa92ac26c52c9030c96dddcc92e5d623546580aaaef401" - hexTx3 = "02000000019fdde3b7eb40584d103a04dd253ffa0ceb458776db56fbee6489aee0d34402d6000000004847304402206abfb750561acac1be3d6ec3eabc1c88ac7ce11f28f5c8162428ce78dabb4d8e0220753c03bf8b9af9c9bf592f52586d39d8aa10c1111f105fea0ce0cf5c82a4574101feffffff0280f0fa020000000017a914dfca2abd2bb72cf911940a9d16de126cc1cd60368724bf45220000000017a9148d7814264268f1f0f98870f95dc69017bd0cce708794020000" + hexMuunSig3 = "30440220076b14b1c906089546cb40ce05dab38f0388ca65d0bc5183d3c3f7dcb98be52c022001eea4635d56726d990daa92ac26c52c9030c96dddcc92e5d623546580aaaef401" //nolint:lll + hexTx3 = "02000000019fdde3b7eb40584d103a04dd253ffa0ceb458776db56fbee6489aee0d34402d6000000004847304402206abfb750561acac1be3d6ec3eabc1c88ac7ce11f28f5c8162428ce78dabb4d8e0220753c03bf8b9af9c9bf592f52586d39d8aa10c1111f105fea0ce0cf5c82a4574101feffffff0280f0fa020000000017a914dfca2abd2bb72cf911940a9d16de126cc1cd60368724bf45220000000017a9148d7814264268f1f0f98870f95dc69017bd0cce708794020000" //nolint:lll txIndex4 = 0 txAmount4 = 50000000 hexTxOut4 = "a4d88169a7b548a2a22af9a7b3ff25da3d0c7d6ed27f52a4ba488e4a28c4114c" - hexMuunSig4 = "30440220145dcce0bf6cceda98b3a9635bd7611d92085ff3ad27690bcf471a6b39620e6c02205ca0a0bd93550e86468e236b291457a3ff84a3b5dedeb10067cc9d3233b5dafa01" - hexTx4 = "02000000019d657207178c19bb4fd45de6a5f83caadf86bd7519e1569c8daf078a46e565310000000048473044022033c864f4a6ab42ba29d09bb2dd110e55a3c4118fd0a68cbe5c461926cc64d3e9022029a5b57a2a6e24e6f66f4354b74d7ffc7affa6d43843797faa70c84ec47b7b8501feffffff0280f0fa020000000017a914dfca2abd2bb72cf911940a9d16de126cc1cd60368724bf45220000000017a914b392913e36a7017404c60424da4ebb48a53b5bb18794020000" + hexMuunSig4 = "30440220145dcce0bf6cceda98b3a9635bd7611d92085ff3ad27690bcf471a6b39620e6c02205ca0a0bd93550e86468e236b291457a3ff84a3b5dedeb10067cc9d3233b5dafa01" //nolint:lll + hexTx4 = "02000000019d657207178c19bb4fd45de6a5f83caadf86bd7519e1569c8daf078a46e565310000000048473044022033c864f4a6ab42ba29d09bb2dd110e55a3c4118fd0a68cbe5c461926cc64d3e9022029a5b57a2a6e24e6f66f4354b74d7ffc7affa6d43843797faa70c84ec47b7b8501feffffff0280f0fa020000000017a914dfca2abd2bb72cf911940a9d16de126cc1cd60368724bf45220000000017a914b392913e36a7017404c60424da4ebb48a53b5bb18794020000" //nolint:lll addressPath = "m/schema:1'/recovery:1'/external:1/0" originAddress = "2NDeWrsJEwvxwVnvtWzPjhDC5B2LYkFuX2s" - encodedMuunKey = "tpubDBYMnFoxYLdMBZThTk4uARTe4kGPeEYWdKcaEzaUxt1cesetnxtTqmAxVkzDRou51emWytommyLWcF91SdF5KecA6Ja8oHK1FF7d5U2hMxX" - encodedUserKey = "tprv8dfM4H5fYJirMai5Er3LguicgUAyxmcSQbFub5ens16amX1e1HAFiW4SXnFVw9nu9FedFQqTPGTTjPEmgfvvXMKww3UcRpFbbC4DFjbCcTb" + encodedMuunKey = "tpubDBYMnFoxYLdMBZThTk4uARTe4kGPeEYWdKcaEzaUxt1cesetnxtTqmAxVkzDRou51emWytommyLWcF91SdF5KecA6Ja8oHK1FF7d5U2hMxX" //nolint:lll + encodedUserKey = "tprv8dfM4H5fYJirMai5Er3LguicgUAyxmcSQbFub5ens16amX1e1HAFiW4SXnFVw9nu9FedFQqTPGTTjPEmgfvvXMKww3UcRpFbbC4DFjbCcTb" //nolint:lll basePath = "m/schema:1'/recovery:1'" ) @@ -291,19 +298,19 @@ func TestPartiallySignedTransaction_SignV2(t *testing.T) { inputs := []Input{ &input{ - outpoint: outpoint{index: txIndex1, amount: txAmount1, txId: txOut1}, + outpoint: outpoint{index: txIndex1, amount: txAmount1, txID: txOut1}, address: addresses.New(addresses.V2, addressPath, originAddress), muunSignature: muunSig1}, &input{ - outpoint: outpoint{index: txIndex2, amount: txAmount2, txId: txOut2}, + outpoint: outpoint{index: txIndex2, amount: txAmount2, txID: txOut2}, address: addresses.New(addresses.V2, addressPath, originAddress), muunSignature: muunSig2}, &input{ - outpoint: outpoint{index: txIndex3, amount: txAmount3, txId: txOut3}, + outpoint: outpoint{index: txIndex3, amount: txAmount3, txID: txOut3}, address: addresses.New(addresses.V2, addressPath, originAddress), muunSignature: muunSig3}, &input{ - outpoint: outpoint{index: txIndex4, amount: txAmount4, txId: txOut4}, + outpoint: outpoint{index: txIndex4, amount: txAmount4, txID: txOut4}, address: addresses.New(addresses.V2, addressPath, originAddress), muunSignature: muunSig4}, } @@ -324,25 +331,25 @@ func TestPartiallySignedTransaction_SignV2(t *testing.T) { signedTx := wire.NewMsgTx(0) signedTx.Deserialize(bytes.NewReader(signedRawTx.Bytes)) - verifyInput(t, signedTx, hexTx1, txIndex1, 0) - verifyInput(t, signedTx, hexTx2, txIndex2, 0) - verifyInput(t, signedTx, hexTx3, txIndex3, 0) - verifyInput(t, signedTx, hexTx4, txIndex4, 0) + mustVerifyInput(t, signedTx, hexTx1, txIndex1, 0) + mustVerifyInput(t, signedTx, hexTx2, txIndex2, 0) + mustVerifyInput(t, signedTx, hexTx3, txIndex3, 0) + mustVerifyInput(t, signedTx, hexTx4, txIndex4, 0) } func TestPartiallySignedTransaction_SignV3(t *testing.T) { const ( - hexTx = "01000000014a4ca718419999e9bfb675dc9f7deff6b65512c11469a23d169038267cd097040100000000ffffffff02916067590000000017a91437a2fceeb0c454b22b427c34eb565d8b1dc953ed8797c400000000000017a9142b0cabe5d058bc3c58f8a656dec2601d117262538700000000" + hexTx = "01000000014a4ca718419999e9bfb675dc9f7deff6b65512c11469a23d169038267cd097040100000000ffffffff02916067590000000017a91437a2fceeb0c454b22b427c34eb565d8b1dc953ed8797c400000000000017a9142b0cabe5d058bc3c58f8a656dec2601d117262538700000000" //nolint:lll txIndex1 = 1 txAmount1 = 1500000000 hexTxOut1 = "0497d07c263890163da26914c11255b6f6ef7d9fdc75b6bfe999994118a74c4a" - hexMuunSig1 = "3045022100d138caf8d3c19db84363b33e1ad002e1aee7907302ab5110edaf78d980c94e48022019e841da8759f63596fbcd81a3544219573288877206f8f651cae1023c397f0c01" - hexTx1 = "02000000014f1e7a952c72670bf03a040faa183687ec8c9e0fb7adf606d1ce13395fb663000000000017160014a89e2ded102b2dde96e8bc87219113c6d31a1fe4feffffff02240e5ea9cf00000017a9142773c1a1651ad774f4b867d955ae8b816ac806ad87002f68590000000017a9142b0cabe5d058bc3c58f8a656dec2601d117262538736010000" + hexMuunSig1 = "3045022100d138caf8d3c19db84363b33e1ad002e1aee7907302ab5110edaf78d980c94e48022019e841da8759f63596fbcd81a3544219573288877206f8f651cae1023c397f0c01" //nolint:lll + hexTx1 = "02000000014f1e7a952c72670bf03a040faa183687ec8c9e0fb7adf606d1ce13395fb663000000000017160014a89e2ded102b2dde96e8bc87219113c6d31a1fe4feffffff02240e5ea9cf00000017a9142773c1a1651ad774f4b867d955ae8b816ac806ad87002f68590000000017a9142b0cabe5d058bc3c58f8a656dec2601d117262538736010000" //nolint:lll - encodedMuunKey = "tpubDABPYHYrYQHXY2pYFdcsFd41aE2uZmMQZpRRGiKfgz7G7nU7PoSwrzMKeHHnoMjmn9woC87coUanF2T911R8X5HpUtZRJRf56u4r51gTrqD" - encodedUserKey = "tprv8ezdJAiJTZz4BJo1VysKviVqto1f8CAS3d2M9LWZ5oygiMrtb6NYcPnkWTcdP8b2AuKVVegnWe3Czzo7geDqH2MzXvzDu1SiKucVAG6KFvE" + encodedMuunKey = "tpubDABPYHYrYQHXY2pYFdcsFd41aE2uZmMQZpRRGiKfgz7G7nU7PoSwrzMKeHHnoMjmn9woC87coUanF2T911R8X5HpUtZRJRf56u4r51gTrqD" //nolint:lll + encodedUserKey = "tprv8ezdJAiJTZz4BJo1VysKviVqto1f8CAS3d2M9LWZ5oygiMrtb6NYcPnkWTcdP8b2AuKVVegnWe3Czzo7geDqH2MzXvzDu1SiKucVAG6KFvE" //nolint:lll addressPath = "m/schema:1'/recovery:1'/external:1/0" originAddress = "2MwArDxm83HCWKvoLKcKAg1Nv6ZG7fWYzMa" @@ -353,7 +360,7 @@ func TestPartiallySignedTransaction_SignV3(t *testing.T) { inputs := []Input{ &input{ - outpoint: outpoint{index: txIndex1, amount: txAmount1, txId: txOut1}, + outpoint: outpoint{index: txIndex1, amount: txAmount1, txID: txOut1}, address: addresses.New(addresses.V3, addressPath, originAddress), muunSignature: muunSig1}, } @@ -374,34 +381,48 @@ func TestPartiallySignedTransaction_SignV3(t *testing.T) { signedTx := wire.NewMsgTx(0) signedTx.Deserialize(bytes.NewReader(signedRawTx.Bytes)) - verifyInput(t, signedTx, hexTx1, txIndex1, 0) + mustVerifyInput(t, signedTx, hexTx1, txIndex1, 0) } func TestPartiallySignedTransaction_SignV5(t *testing.T) { var ( - encodedUserKey = "tprv8e6WDju7yhq6vuL8raFiMpCMVYpNEpjggzqcX3qW4zsjNBVnwKgAUmQ7vs7bDeHu598aG9teh7or5H8ifLJ2qhZGocBnDEBqAsTs3Gd6wG6" - encodedMuunKey = "tpubDBS2rf9CeryjGstPrQVSzQhLGLqFVqEq78xtK26h9fsN7udiokAMuu6DbSzwhSzqCwszcfC2L2zMYoFm9uoiJpkEwyUCuNr3j1XswbHcgAB" - hexTx = "0100000001239fc65d1212989754b0bb146ccc77db370de8db913a5bcbb1e5257ae75e03450100000000ffffffff0174850100000000002251203e3c9519c91c87e84de71a64f65fa481639c900da4e01ba4c23be539c9065ad400000000" - userSessionIds = []string{ + encodedUserKey = "tprv8e6WDju7yhq6vuL8raFiMpCMVYpNEpjggzqcX3qW4zsjNBVnwKgAUmQ7vs7bDeHu598aG9teh7or5H8ifLJ2qhZGocBnDEBqAsTs3Gd6wG6" //nolint:lll + encodedMuunKey = "tpubDBS2rf9CeryjGstPrQVSzQhLGLqFVqEq78xtK26h9fsN7udiokAMuu6DbSzwhSzqCwszcfC2L2zMYoFm9uoiJpkEwyUCuNr3j1XswbHcgAB" //nolint:lll + hexTx = "0100000001239fc65d1212989754b0bb146ccc77db370de8db913a5bcbb1e5257ae75e03450100000000ffffffff0174850100000000002251203e3c9519c91c87e84de71a64f65fa481639c900da4e01ba4c23be539c9065ad400000000" //nolint:lll + userSessionIDs = []string{ "3afcd7f2cc568aa60552866f7ee8d1de6a6b18293ae9a4cda167434588267f73", } inputTxRaw = []string{ - "020000000001019da62d921c36e14d8c8567adec86580e95154cddb922f28ed47552f1850272c10000000000feffffff028b4d042a01000000160014290cfc7b1cb593b8b7cfb70e8a5315cb04015fd7a0860100000000002251206814f05813d2505f80ec5a71ca6fbbd99df3aac5e0ed3a506a1c376beb484e9102473044022040d7a4204b14cf1ea32021040d373221f60fcfb42e4da82723b0eb3b7d7bd940022064160bd834d6b530292d9f6f17c07e032a619b88c8ffcb5ef6622a731236ba46012103ff6f07c524f89457d05a1ee9e2b8d1ae2520916748b2da0c7c1d3655f271855e25050000", + "020000000001019da62d921c36e14d8c8567adec86580e95154cddb922f28ed47552f1850272c10000000000feffffff028b4d042a01000000160014290cfc7b1cb593b8b7cfb70e8a5315cb04015fd7a0860100000000002251206814f05813d2505f80ec5a71ca6fbbd99df3aac5e0ed3a506a1c376beb484e9102473044022040d7a4204b14cf1ea32021040d373221f60fcfb42e4da82723b0eb3b7d7bd940022064160bd834d6b530292d9f6f17c07e032a619b88c8ffcb5ef6622a731236ba46012103ff6f07c524f89457d05a1ee9e2b8d1ae2520916748b2da0c7c1d3655f271855e25050000", //nolint:lll } ) inputList := &InputList{inputs: []Input{ &input{ - outpoint: outpoint{index: 1, amount: 100000, txId: hexToBytes("45035ee77a25e5b1cb5b3a91dbe80d37db77cc6c14bbb054979812125dc69f23")}, - address: addresses.New(5, "m/schema:1'/recovery:1'/external:1/0", "bcrt1pdq20qkqn6fg9lq8vtfcu5mammxwl82k9urkn55r2rsmkh66gf6gsumc8uw"), - muunPublicNonce: hexToBytes("02b44aef04d3ada7270e1304f4ba1fbf20cca0ca81a80e23be9bf8f3aea7c0a62103f6c8cd699fc2339a33df60f142bb89807c081632a2241bd4af583961a29da9a0"), - muunSignature: hexToBytes("591f2e7afd46a8b234e94428e582f13324cd541d436bde62cef760354771a377"), + outpoint: outpoint{ + index: 1, + amount: 100000, + txID: hexToBytes( + "45035ee77a25e5b1cb5b3a91dbe80d37db77cc6c14bbb054979812125dc69f23", + ), + }, + address: addresses.New( + 5, + "m/schema:1'/recovery:1'/external:1/0", + "bcrt1pdq20qkqn6fg9lq8vtfcu5mammxwl82k9urkn55r2rsmkh66gf6gsumc8uw", + ), + muunPublicNonce: hexToBytes( + "02b44aef04d3ada7270e1304f4ba1fbf20cca0ca81a80e23be9bf8f3aea7c0a62103f6c8cd699fc2339a33df60f142bb89807c081632a2241bd4af583961a29da9a0", //nolint:lll + ), + muunSignature: hexToBytes( + "591f2e7afd46a8b234e94428e582f13324cd541d436bde62cef760354771a377", + ), }, }} userKey, _ := NewHDPrivateKeyFromString(encodedUserKey, basePath, Regtest()) muunKey, _ := NewHDPublicKeyFromString(encodedMuunKey, basePath, Regtest()) - nonces := createTestNonces(t, userKey, inputList, userSessionIds) + nonces := createTestNonces(t, userKey, inputList, userSessionIDs) partial, _ := NewPartiallySignedTransaction(inputList, hexToBytes(hexTx), nonces) signedRawTx, err := partial.Sign(userKey, muunKey) @@ -414,35 +435,55 @@ func TestPartiallySignedTransaction_SignV5(t *testing.T) { // validate input signatures for index := range inputList.inputs { - verifyInput(t, signedTx, inputTxRaw[index], inputList.inputs[index].OutPoint().Index(), index) + mustVerifyInput( + t, + signedTx, + inputTxRaw[index], + inputList.inputs[index].OutPoint().Index(), + index, + ) } } func TestPartiallySignedTransaction_SignV6(t *testing.T) { var ( - encodedUserKey = "tprv8dUpNFvQ6NpkxtuYDoDaibgQYbvUnHBqz8GM3zBL4DjNeh9uzhXC49xKx2VksbyxaW3dSFviExbUw4GmkEKJoiTx7UXXi6pPnXMWpB5Lmtf" - encodedMuunKey = "tpubDBmgp5wQ4SYkroyXQG3SxUXVZsdmJnL89exWksCAEq9xzujjCd6jpKbYQyyVXLiQk4gBq8AaUULZDbwxFF8DhcTEPzDFYY8g2dsJ1x3xPwN" - hexTx = "01000000017e282eab04b710e5f149e6a3f7dfb8dcb5af006aefecc7e5d2ad1f4bbb4d78db0000000000ffffffff017485010000000000225120e67c60c89364bae43a399e6417a1cce9d2e0498e5eb0646f52d3f279833a2b6000000000" - userSessionIds = []string{ + encodedUserKey = "tprv8dUpNFvQ6NpkxtuYDoDaibgQYbvUnHBqz8GM3zBL4DjNeh9uzhXC49xKx2VksbyxaW3dSFviExbUw4GmkEKJoiTx7UXXi6pPnXMWpB5Lmtf" //nolint:lll + encodedMuunKey = "tpubDBmgp5wQ4SYkroyXQG3SxUXVZsdmJnL89exWksCAEq9xzujjCd6jpKbYQyyVXLiQk4gBq8AaUULZDbwxFF8DhcTEPzDFYY8g2dsJ1x3xPwN" //nolint:lll + hexTx = "01000000017e282eab04b710e5f149e6a3f7dfb8dcb5af006aefecc7e5d2ad1f4bbb4d78db0000000000ffffffff017485010000000000225120e67c60c89364bae43a399e6417a1cce9d2e0498e5eb0646f52d3f279833a2b6000000000" //nolint:lll + userSessionIDs = []string{ "98ca651de1178c9a656dfc51e00bb6ff3dd958922a15a84ef270a81c96bcf510", } inputTxRaw = []string{ - "02000000000101f8880208d09f48d849e68d042194b3295401a88dcc9fa5131ecc78130b7480840000000000feffffff02a086010000000000225120c6eae34415dcc0f4e40367348f483e7d6a30f969a54586fb699b5684988ddb1e8b4d042a01000000160014287cd946de86caf84c507e749f2fbe586bce57b30247304402203c30d6522a082228b6b9ee1bc93a576a46d37e17017db2ae7506c8f288ae181f02206f84ed0e17f70e8b49900450c29cfff84d321850a8e4f662216a4d77c3e80795012103ff6f07c524f89457d05a1ee9e2b8d1ae2520916748b2da0c7c1d3655f271855ef9040000", + "02000000000101f8880208d09f48d849e68d042194b3295401a88dcc9fa5131ecc78130b7480840000000000feffffff02a086010000000000225120c6eae34415dcc0f4e40367348f483e7d6a30f969a54586fb699b5684988ddb1e8b4d042a01000000160014287cd946de86caf84c507e749f2fbe586bce57b30247304402203c30d6522a082228b6b9ee1bc93a576a46d37e17017db2ae7506c8f288ae181f02206f84ed0e17f70e8b49900450c29cfff84d321850a8e4f662216a4d77c3e80795012103ff6f07c524f89457d05a1ee9e2b8d1ae2520916748b2da0c7c1d3655f271855ef9040000", //nolint:lll } ) inputList := &InputList{inputs: []Input{ &input{ - outpoint: outpoint{index: 0, amount: 100000, txId: hexToBytes("db784dbb4b1fadd2e5c7ecef6a00afb5dcb8dff7a3e649f1e510b704ab2e287e")}, - address: addresses.New(6, "m/schema:1'/recovery:1'/external:1/0", "bcrt1pcm4wx3q4mnq0feqrvu6g7jp7044rp7tf54zcd7mfndtgfxydmv0qnnmrdl"), - muunPublicNonce: hexToBytes("03722e555ae015f5e5b07ff8915fcf9a155ad74f77e27d5908b5a6c5ea313d71db0319258976bd5f967317c61ce634cf182ead2931fc54e31cc05729014b4d210f66"), - muunSignature: hexToBytes("f4d1fee38aebb1d17c0dc85b7a0e48474c0b1de351dfc7f0baa6e9330765881b"), + outpoint: outpoint{ + index: 0, + amount: 100000, + txID: hexToBytes( + "db784dbb4b1fadd2e5c7ecef6a00afb5dcb8dff7a3e649f1e510b704ab2e287e", + ), + }, + address: addresses.New( + 6, + "m/schema:1'/recovery:1'/external:1/0", + "bcrt1pcm4wx3q4mnq0feqrvu6g7jp7044rp7tf54zcd7mfndtgfxydmv0qnnmrdl", + ), + muunPublicNonce: hexToBytes( + "03722e555ae015f5e5b07ff8915fcf9a155ad74f77e27d5908b5a6c5ea313d71db0319258976bd5f967317c61ce634cf182ead2931fc54e31cc05729014b4d210f66", //nolint:lll + ), + muunSignature: hexToBytes( + "f4d1fee38aebb1d17c0dc85b7a0e48474c0b1de351dfc7f0baa6e9330765881b", + ), }, }} userKey, _ := NewHDPrivateKeyFromString(encodedUserKey, basePath, Regtest()) muunKey, _ := NewHDPublicKeyFromString(encodedMuunKey, basePath, Regtest()) - nonces := createTestNonces(t, userKey, inputList, userSessionIds) + nonces := createTestNonces(t, userKey, inputList, userSessionIDs) partial, _ := NewPartiallySignedTransaction(inputList, hexToBytes(hexTx), nonces) signedRawTx, err := partial.Sign(userKey, muunKey) @@ -455,16 +496,715 @@ func TestPartiallySignedTransaction_SignV6(t *testing.T) { // validate input signatures for index := range inputList.inputs { - verifyInput(t, signedTx, inputTxRaw[index], inputList.inputs[index].OutPoint().Index(), index) + mustVerifyInput( + t, + signedTx, + inputTxRaw[index], + inputList.inputs[index].OutPoint().Index(), + index, + ) + } +} + +func TestPartiallySignedTransaction_SignV7(t *testing.T) { + const ( + keyPath = "m/1/0" + timelock = int64(144) + amount = btcutil.Amount(100000) + ) + + tests := []struct { + name string + // sequence is the nSequence set on the spending input. OP_CHECKSEQUENCEVERIFY checks, + // at script level only, that the input's nSequence is a relative locktime >= the value the + // script pushes before calling OP_CSV. + // It does NOT check real on-chain age as we are only running the VM in these tests. + sequence uint32 + collaborative bool + wantErr bool + }{ + { + name: "collaborative 3-of-3 path", + sequence: wire.MaxTxInSequenceNum, + collaborative: true, + }, + { + name: "non-collaborative 2-of-2 + timelock path", + sequence: uint32(timelock), + collaborative: false, + }, + { + name: "non-collaborative path before timelock fails", + sequence: uint32(timelock) - 1, + collaborative: false, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + userKey, muunKey, peerKey := masterKey(t, 0x01), masterKey(t, 0x02), masterKey(t, 0x03) + derivedUserKey, err := userKey.DeriveTo(keyPath) + require.NoError(t, err) + derivedMuunKey, err := muunKey.DeriveTo(keyPath) + require.NoError(t, err) + derivedLightningPeerKey, err := peerKey.DeriveTo(keyPath) + require.NoError(t, err) + + address, err := CreateAddressV7( + derivedUserKey.PublicKey(), + derivedMuunKey.PublicKey(), + derivedLightningPeerKey.PublicKey(), + timelock, + ) + require.NoError(t, err) + prevTx := fundAddress(t, address.Address(), amount) + + coin := &coinV7{ + Network: Regtest().network, + OutPoint: wire.OutPoint{Hash: prevTx.TxHash(), Index: 0}, + KeyPath: keyPath, + Amount: amount, + BlocksForExpiration: timelock, + LightningPeerKey: peerKey.PublicKey(), + } + + spendingTx := spendTimelockedOutput(prevTx, tt.sequence) + + if tt.collaborative { + // The server signs for muun and the lightning peer; both signatures must be present + // before the user can complete the collaborative witness. + userPubKey, err := derivedUserKey.PublicKey().ECPubKey() + require.NoError(t, err) + muunPubKey, err := derivedMuunKey.PublicKey().ECPubKey() + require.NoError(t, err) + peerPubKey, err := derivedLightningPeerKey.PublicKey().ECPubKey() + require.NoError(t, err) + witnessScript, err := addresses.CreateWitnessScriptV7( + userPubKey, + muunPubKey, + peerPubKey, + timelock, + ) + require.NoError(t, err) + coin.MuunSignature, err = coin.signature( + 0, + spendingTx, + derivedMuunKey, + witnessScript, + ) + require.NoError(t, err) + coin.PeerSignature, err = coin.signature( + 0, + spendingTx, + derivedLightningPeerKey, + witnessScript, + ) + require.NoError(t, err) + + // The user adds the final signature locally. + require.NoError(t, coin.SignInput(0, spendingTx, userKey, muunKey.PublicKey())) + } else { + // The user signs with user and muun key. + require.NoError(t, coin.FullySignInput(0, spendingTx, userKey, muunKey)) + } + + var buf bytes.Buffer + require.NoError(t, prevTx.Serialize(&buf)) + txHex := hex.EncodeToString(buf.Bytes()) + + verifyErr := verifyInput(t, spendingTx, txHex, 0, 0) + if tt.wantErr { + require.Error(t, verifyErr) + } else { + require.NoError(t, verifyErr) + } + }) + } +} + +// TestPartiallySignedTransaction_SignV7CollaborativeCrossCheck pins the fully signed +// collaborative 3-of-3 spending tx, which is fully deterministic: keys and transaction are +// fixed and ECDSA nonces are RFC 6979. The backend's +// TransactionSchemeSignedTransactionLibwalletCrossTest must produce this exact transaction from +// the same inputs: any update to the pinned values here (keys, fixture parameters, signedTxHex) +// must be replicated there. +func TestPartiallySignedTransaction_SignV7CollaborativeCrossCheck(t *testing.T) { + const ( + keyPath = "m/1/0" + timelock = int64(144) + amount = btcutil.Amount(100000) + + signedTxHex = "0200000000010190f96d6cbf7fc93904e73a1a01a60feab793caae7db1f8460c1f4ff75ef597c200000000232200206ffcafb6a4fe440dcc76bb01f5f64b75276a0c58ca12742642f58172d0c20affffffffff01b88201000000000017a914367d2a0402da95d81f2e9ad44ede070f89845226870447304402206ad2953c5c68f4cb31c688f7b5503dab22e79c09751ba7b41d454bebeeac86df02200ab890abb9decd9f6565231513c904207fae2459c835fa052a2caaff379d65af01483045022100bab3a54bf1b32f7aa0dade11da9db45e47040113b87efc71b1b9e7c343971b1b0220373f9572284c1caf7bfde856bafa7ef23f474770452feccffb1050017fbd975201483045022100c335345e3a0813209888bdbd11a0ba7a6e4002cb7977a9d6aa575d41bdb130690220518ac6b3b94e1389695da05822fcddfb6bef0c2d13f3344d829e617275942b1101702102ae989a18ed80e13b6e3a7388effdda52d970ade06e20a57f851d7cf60237339fad210299d23c84ce303c843f4a26f41b32f52d66cbe080482ba8d862d5eed8ae4bc1bcad2102b9fdc52d4ff72fd4514d1ad1dbabb527856c55097a47a29e881fb42a9b25e270ac7364029000b26800000000" //nolint:lll + ) + + userKey, muunKey, peerKey := masterKey(t, 0x01), masterKey(t, 0x02), masterKey(t, 0x03) + derivedUserKey, err := userKey.DeriveTo(keyPath) + require.NoError(t, err) + derivedMuunKey, err := muunKey.DeriveTo(keyPath) + require.NoError(t, err) + derivedPeerKey, err := peerKey.DeriveTo(keyPath) + require.NoError(t, err) + + address, err := CreateAddressV7( + derivedUserKey.PublicKey(), + derivedMuunKey.PublicKey(), + derivedPeerKey.PublicKey(), + timelock, + ) + require.NoError(t, err) + prevTx := fundAddress(t, address.Address(), amount) + spendingTx := spendTimelockedOutput(prevTx, wire.MaxTxInSequenceNum) + + coin := &coinV7{ + Network: Regtest().network, + OutPoint: wire.OutPoint{Hash: prevTx.TxHash(), Index: 0}, + KeyPath: keyPath, + Amount: amount, + BlocksForExpiration: timelock, + LightningPeerKey: peerKey.PublicKey(), + } + + userPubKey, err := derivedUserKey.PublicKey().ECPubKey() + require.NoError(t, err) + muunPubKey, err := derivedMuunKey.PublicKey().ECPubKey() + require.NoError(t, err) + peerPubKey, err := derivedPeerKey.PublicKey().ECPubKey() + require.NoError(t, err) + witnessScript, err := addresses.CreateWitnessScriptV7( + userPubKey, + muunPubKey, + peerPubKey, + timelock, + ) + require.NoError(t, err) + + coin.MuunSignature, err = coin.signature(0, spendingTx, derivedMuunKey, witnessScript) + require.NoError(t, err) + coin.PeerSignature, err = coin.signature(0, spendingTx, derivedPeerKey, witnessScript) + require.NoError(t, err) + require.NoError(t, coin.SignInput(0, spendingTx, userKey, muunKey.PublicKey())) + + var buf bytes.Buffer + require.NoError(t, spendingTx.Serialize(&buf)) + require.Equal(t, signedTxHex, hex.EncodeToString(buf.Bytes())) +} + +// TestPartiallySignedTransaction_SignV7NonCollaborativeCrossCheck pins the fully signed +// non-collaborative (2-of-2 + timelock) spending tx, which is fully deterministic: keys and +// transaction are fixed and ECDSA nonces are RFC 6979. The backend's +// TransactionSchemeSignedTransactionLibwalletCrossTest must produce this exact transaction from +// the same inputs: any update to the pinned values here (keys, fixture parameters, signedTxHex) +// must be replicated there. +func TestPartiallySignedTransaction_SignV7NonCollaborativeCrossCheck(t *testing.T) { + const ( + keyPath = "m/1/0" + timelock = int64(144) + amount = btcutil.Amount(100000) + + signedTxHex = "0200000000010190f96d6cbf7fc93904e73a1a01a60feab793caae7db1f8460c1f4ff75ef597c200000000232200206ffcafb6a4fe440dcc76bb01f5f64b75276a0c58ca12742642f58172d0c20aff9000000001b88201000000000017a914367d2a0402da95d81f2e9ad44ede070f89845226870400483045022100e0a720502e2af0d7368dbd86b8a4f3fea76472e9902a642d841533a1eca9951202207f6f2e315bb3c3fbc6c44bbb17685b5e647afcf1dd3fb9f90262242a6e14ccc501483045022100ca9f1d97d57897b5d82e6c39363f4337ce8217888c8ba27ebe5a50a5c2f97f0402202e53ad82c574474e058707af299309a693200d26bc7b31aa2f42726dbf6fe78501702102ae989a18ed80e13b6e3a7388effdda52d970ade06e20a57f851d7cf60237339fad210299d23c84ce303c843f4a26f41b32f52d66cbe080482ba8d862d5eed8ae4bc1bcad2102b9fdc52d4ff72fd4514d1ad1dbabb527856c55097a47a29e881fb42a9b25e270ac7364029000b26800000000" //nolint:lll + ) + + userKey, muunKey, peerKey := masterKey(t, 0x01), masterKey(t, 0x02), masterKey(t, 0x03) + derivedUserKey, err := userKey.DeriveTo(keyPath) + require.NoError(t, err) + derivedMuunKey, err := muunKey.DeriveTo(keyPath) + require.NoError(t, err) + derivedPeerKey, err := peerKey.DeriveTo(keyPath) + require.NoError(t, err) + + address, err := CreateAddressV7( + derivedUserKey.PublicKey(), + derivedMuunKey.PublicKey(), + derivedPeerKey.PublicKey(), + timelock, + ) + require.NoError(t, err) + prevTx := fundAddress(t, address.Address(), amount) + spendingTx := spendTimelockedOutput(prevTx, uint32(timelock)) + + coin := &coinV7{ + Network: Regtest().network, + OutPoint: wire.OutPoint{Hash: prevTx.TxHash(), Index: 0}, + KeyPath: keyPath, + Amount: amount, + BlocksForExpiration: timelock, + LightningPeerKey: peerKey.PublicKey(), + } + + require.NoError(t, coin.FullySignInput(0, spendingTx, userKey, muunKey)) + + var buf bytes.Buffer + require.NoError(t, spendingTx.Serialize(&buf)) + require.Equal(t, signedTxHex, hex.EncodeToString(buf.Bytes())) +} + +func TestPartiallySignedTransaction_SignV8(t *testing.T) { + const ( + keyPath = "m/1/0" + timelock = int64(144) + amount = btcutil.Amount(100000) + ) + + tests := []struct { + name string + // sequence is the nSequence set on the spending input. OP_CHECKSEQUENCEVERIFY checks, + // at script level only, that the input's nSequence is a relative locktime >= the value the + // script pushes before calling OP_CSV. + // It does NOT check real on-chain age as we are only running the VM in these tests. + sequence uint32 + collaborative bool + wantErr bool + }{ + { + name: "collaborative 3-of-3 path", + sequence: wire.MaxTxInSequenceNum, + collaborative: true, + }, + { + name: "non-collaborative 2-of-2 + timelock path", + sequence: uint32(timelock), + collaborative: false, + }, + { + name: "non-collaborative path before timelock fails", + sequence: uint32(timelock) - 1, + collaborative: false, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + userKey, muunKey, peerKey := masterKey(t, 0x01), masterKey(t, 0x02), masterKey(t, 0x03) + derivedUserKey, err := userKey.DeriveTo(keyPath) + require.NoError(t, err) + derivedMuunKey, err := muunKey.DeriveTo(keyPath) + require.NoError(t, err) + derivedPeerKey, err := peerKey.DeriveTo(keyPath) + require.NoError(t, err) + + address, err := CreateAddressV8( + derivedUserKey.PublicKey(), + derivedMuunKey.PublicKey(), + derivedPeerKey.PublicKey(), + timelock, + ) + require.NoError(t, err) + prevTx := fundAddress(t, address.Address(), amount) + + coin := &coinV8{ + Network: Regtest().network, + OutPoint: wire.OutPoint{Hash: prevTx.TxHash(), Index: 0}, + KeyPath: keyPath, + Amount: amount, + BlocksForExpiration: timelock, + LightningPeerKey: peerKey.PublicKey(), + } + + spendingTx := spendTimelockedOutput(prevTx, tt.sequence) + + if tt.collaborative { + // The server signs for muun and the lightning peer; both signatures must be present + // before the user can complete the collaborative witness. + userPubKey, err := derivedUserKey.PublicKey().ECPubKey() + require.NoError(t, err) + muunPubKey, err := derivedMuunKey.PublicKey().ECPubKey() + require.NoError(t, err) + peerPubKey, err := derivedPeerKey.PublicKey().ECPubKey() + require.NoError(t, err) + witnessScript, err := addresses.CreateWitnessScriptV7( + userPubKey, + muunPubKey, + peerPubKey, + timelock, + ) + require.NoError(t, err) + + coin.MuunSignature, err = coin.signature( + 0, + spendingTx, + derivedMuunKey, + witnessScript, + ) + require.NoError(t, err) + coin.LightningPeerSignature, err = coin.signature( + 0, + spendingTx, + derivedPeerKey, + witnessScript, + ) + require.NoError(t, err) + + // The user adds the final signature locally. + require.NoError(t, coin.SignInput(0, spendingTx, userKey, muunKey.PublicKey())) + } else { + // The user signs with user and muun key. + require.NoError(t, coin.FullySignInput(0, spendingTx, userKey, muunKey)) + } + + var buf bytes.Buffer + require.NoError(t, prevTx.Serialize(&buf)) + txHex := hex.EncodeToString(buf.Bytes()) + + verifyErr := verifyInput(t, spendingTx, txHex, 0, 0) + if tt.wantErr { + require.Error(t, verifyErr) + } else { + require.NoError(t, verifyErr) + } + }) + } +} + +// TestPartiallySignedTransaction_SignV8CollaborativeCrossCheck pins the fully signed +// collaborative 3-of-3 spending tx, which is fully deterministic: keys and transaction are +// fixed and ECDSA nonces are RFC 6979. The backend's +// TransactionSchemeSignedTransactionLibwalletCrossTest must produce this exact transaction from +// the same inputs: any update to the pinned values here (keys, fixture parameters, signedTxHex) +// must be replicated there. +func TestPartiallySignedTransaction_SignV8CollaborativeCrossCheck(t *testing.T) { + const ( + keyPath = "m/1/0" + timelock = int64(144) + amount = btcutil.Amount(100000) + + signedTxHex = "020000000001017b5a61e0dfaed06c2dff00003086193337297d8f3f8b842a49b7d170855ddc450000000000ffffffff01b8820100000000002200206ffcafb6a4fe440dcc76bb01f5f64b75276a0c58ca12742642f58172d0c20aff04483045022100d3241a05ad5230829b3ac629dc4c3195becb3c4510ed399281eff17dabf20fa302205f161271faa8a0d877911791a9bd291e3cc466054ba29209ac8cd4dd02ccef70014730440220148e6faa0550237a258987bf9f9e961291ffe5d5efa6e825a9e9495955b963ed02201bc90aa2331fe706b37971a740bf02d85c79346c7e0fb6350e4a0ae6e9561509014730440220297f3b0fe8e48608478615d425ff881a8b817a9f8af9a6d7e9d9d3835f2202b1022046839e65539791c237857d375f07601140b6b97da81cc930cd5debc5eadbff9801702102ae989a18ed80e13b6e3a7388effdda52d970ade06e20a57f851d7cf60237339fad210299d23c84ce303c843f4a26f41b32f52d66cbe080482ba8d862d5eed8ae4bc1bcad2102b9fdc52d4ff72fd4514d1ad1dbabb527856c55097a47a29e881fb42a9b25e270ac7364029000b26800000000" //nolint:lll + ) + + userKey, muunKey, peerKey := masterKey(t, 0x01), masterKey(t, 0x02), masterKey(t, 0x03) + derivedUserKey, err := userKey.DeriveTo(keyPath) + require.NoError(t, err) + derivedMuunKey, err := muunKey.DeriveTo(keyPath) + require.NoError(t, err) + derivedPeerKey, err := peerKey.DeriveTo(keyPath) + require.NoError(t, err) + + address, err := CreateAddressV8( + derivedUserKey.PublicKey(), + derivedMuunKey.PublicKey(), + derivedPeerKey.PublicKey(), + timelock, + ) + require.NoError(t, err) + prevTx := fundAddress(t, address.Address(), amount) + spendingTx := spendTimelockedOutput(prevTx, wire.MaxTxInSequenceNum) + + coin := &coinV8{ + Network: Regtest().network, + OutPoint: wire.OutPoint{Hash: prevTx.TxHash(), Index: 0}, + KeyPath: keyPath, + Amount: amount, + BlocksForExpiration: timelock, + LightningPeerKey: peerKey.PublicKey(), } + + userPubKey, err := derivedUserKey.PublicKey().ECPubKey() + require.NoError(t, err) + muunPubKey, err := derivedMuunKey.PublicKey().ECPubKey() + require.NoError(t, err) + peerPubKey, err := derivedPeerKey.PublicKey().ECPubKey() + require.NoError(t, err) + witnessScript, err := addresses.CreateWitnessScriptV7( + userPubKey, + muunPubKey, + peerPubKey, + timelock, + ) + require.NoError(t, err) + + coin.MuunSignature, err = coin.signature(0, spendingTx, derivedMuunKey, witnessScript) + require.NoError(t, err) + coin.LightningPeerSignature, err = coin.signature(0, spendingTx, derivedPeerKey, witnessScript) + require.NoError(t, err) + require.NoError(t, coin.SignInput(0, spendingTx, userKey, muunKey.PublicKey())) + + var buf bytes.Buffer + require.NoError(t, spendingTx.Serialize(&buf)) + require.Equal(t, signedTxHex, hex.EncodeToString(buf.Bytes())) +} + +// TestPartiallySignedTransaction_SignV8NonCollaborativeCrossCheck pins the fully signed +// non-collaborative (2-of-2 + timelock) spending tx, which is fully deterministic: keys and +// transaction are fixed and ECDSA nonces are RFC 6979. The backend's +// TransactionSchemeSignedTransactionLibwalletCrossTest must produce this exact transaction from +// the same inputs: any update to the pinned values here (keys, fixture parameters, signedTxHex) +// must be replicated there. +func TestPartiallySignedTransaction_SignV8NonCollaborativeCrossCheck(t *testing.T) { + const ( + keyPath = "m/1/0" + timelock = int64(144) + amount = btcutil.Amount(100000) + + signedTxHex = "020000000001017b5a61e0dfaed06c2dff00003086193337297d8f3f8b842a49b7d170855ddc4500000000009000000001b8820100000000002200206ffcafb6a4fe440dcc76bb01f5f64b75276a0c58ca12742642f58172d0c20aff04004830450221008960310160b7fc4da6cf7e5c105912b8a4521697898ab4d3d89b9ac04919b4c5022051556346e13d9b628152734d87ea6a88a2c247f9594c45de8ed99c2f440bf0b7014730440220652c2a82e8ff549a8b1066591342cb9a2a0a2612ee2e9130f0c48c1433b2666d02204631e1a341649d35ab990ccda3aa27928f51ed60accadf7e5072d54e5c0bd1bd01702102ae989a18ed80e13b6e3a7388effdda52d970ade06e20a57f851d7cf60237339fad210299d23c84ce303c843f4a26f41b32f52d66cbe080482ba8d862d5eed8ae4bc1bcad2102b9fdc52d4ff72fd4514d1ad1dbabb527856c55097a47a29e881fb42a9b25e270ac7364029000b26800000000" //nolint:lll + ) + + userKey, muunKey, peerKey := masterKey(t, 0x01), masterKey(t, 0x02), masterKey(t, 0x03) + derivedUserKey, err := userKey.DeriveTo(keyPath) + require.NoError(t, err) + derivedMuunKey, err := muunKey.DeriveTo(keyPath) + require.NoError(t, err) + derivedPeerKey, err := peerKey.DeriveTo(keyPath) + require.NoError(t, err) + + address, err := CreateAddressV8( + derivedUserKey.PublicKey(), + derivedMuunKey.PublicKey(), + derivedPeerKey.PublicKey(), + timelock, + ) + require.NoError(t, err) + prevTx := fundAddress(t, address.Address(), amount) + spendingTx := spendTimelockedOutput(prevTx, uint32(timelock)) + + coin := &coinV8{ + Network: Regtest().network, + OutPoint: wire.OutPoint{Hash: prevTx.TxHash(), Index: 0}, + KeyPath: keyPath, + Amount: amount, + BlocksForExpiration: timelock, + LightningPeerKey: peerKey.PublicKey(), + } + + require.NoError(t, coin.FullySignInput(0, spendingTx, userKey, muunKey)) + + var buf bytes.Buffer + require.NoError(t, spendingTx.Serialize(&buf)) + require.Equal(t, signedTxHex, hex.EncodeToString(buf.Bytes())) +} + +func TestPartiallySignedTransaction_SignV9(t *testing.T) { + const ( + keyPath = "m/1/0" + timelock = int64(144) + amount = btcutil.Amount(100000) + ) + + tests := []struct { + name string + // sequence is the nSequence set on the spending input. OP_CHECKSEQUENCEVERIFY checks, + // at script level only, that the input's nSequence is a relative locktime >= the value the + // script pushes before calling OP_CSV. + // It does NOT check real on-chain age as we are only running the VM in these tests. + sequence uint32 + collaborative bool + wantErr bool + }{ + { + name: "collaborative 3-of-3 key path", + sequence: wire.MaxTxInSequenceNum, + collaborative: true, + }, + { + name: "non-collaborative 2-of-2 + timelock path", + sequence: uint32(timelock), + collaborative: false, + }, + { + name: "non-collaborative path before timelock fails", + sequence: uint32(timelock) - 1, + collaborative: false, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + userKey, muunKey, peerKey := masterKey(t, 0x01), masterKey(t, 0x02), masterKey(t, 0x03) + derivedUserKey, err := userKey.DeriveTo(keyPath) + require.NoError(t, err) + derivedMuunKey, err := muunKey.DeriveTo(keyPath) + require.NoError(t, err) + derivedLightningPeerKey, err := peerKey.DeriveTo(keyPath) + require.NoError(t, err) + + address, err := CreateAddressV9( + derivedUserKey.PublicKey(), + derivedMuunKey.PublicKey(), + derivedLightningPeerKey.PublicKey(), + timelock, + ) + require.NoError(t, err) + prevTx := fundAddress(t, address.Address(), amount) + + spendingTx := spendTimelockedOutput(prevTx, tt.sequence) + + prevOutFetcher := txscript.NewCannedPrevOutputFetcher( + prevTx.TxOut[0].PkScript, prevTx.TxOut[0].Value, + ) + sigHashes := txscript.NewTxSigHashes(spendingTx, prevOutFetcher) + coin := &coinV9{ + KeyPath: keyPath, + BlocksForExpiration: timelock, + LightningPeerKey: peerKey.PublicKey(), + SigHashes: sigHashes, + } + + if tt.collaborative { + // The server signs for muun and the lightning peer. + signCollaborativeV9( + t, + coin, + spendingTx, + derivedUserKey, + derivedMuunKey, + derivedLightningPeerKey, + timelock, + ) + // The user adds the final signature and does the aggregation + require.NoError(t, coin.SignInput(0, spendingTx, userKey, muunKey.PublicKey())) + } else { + // The user signs the non-collaborative leaf with both the user and muun keys. + require.NoError(t, coin.FullySignInput(0, spendingTx, userKey, muunKey)) + } + + var buf bytes.Buffer + require.NoError(t, prevTx.Serialize(&buf)) + txHex := hex.EncodeToString(buf.Bytes()) + + verifyErr := verifyInput(t, spendingTx, txHex, 0, 0) + if tt.wantErr { + require.Error(t, verifyErr) + } else { + require.NoError(t, verifyErr) + } + }) + } +} + +// signCollaborativeV9 adds muun and lightning peer keys' signatures simulating the server response. +func signCollaborativeV9( + t *testing.T, + coin *coinV9, + spendingTx *wire.MsgTx, + derivedUserKey, derivedMuunKey, derivedLightningPeerKey *HDPrivateKey, + timelock int64, +) { + t.Helper() + + userPub, err := derivedUserKey.PublicKey().ECPubKey() + require.NoError(t, err) + muunPriv, err := derivedMuunKey.key.ECPrivKey() + require.NoError(t, err) + peerPriv, err := derivedLightningPeerKey.key.ECPrivKey() + require.NoError(t, err) + muunPub, peerPub := muunPriv.PubKey(), peerPriv.PubKey() + + // The key-path signature commits to the internal key tweaked by the script tree root. + nonCollaborativeScript, err := addresses.CreateNonCollaborativeScriptV9( + userPub, + muunPub, + timelock, + ) + require.NoError(t, err) + scriptRootHash := txscript.NewBaseTapLeaf(nonCollaborativeScript).TapHash() + tweak := musig.TapScriptTweak(scriptRootHash[:]) + + sigHash, err := txscript.CalcTaprootSignatureHash( + coin.SigHashes, + txscript.SigHashDefault, + spendingTx, + 0, + nil, + ) + require.NoError(t, err) + + // Distinct deterministic session ids for each signer. + userSessionID := bytes.Repeat([]byte{0x10}, 32) + muunSessionID := bytes.Repeat([]byte{0x20}, 32) + lightningPeerSessionID := bytes.Repeat([]byte{0x30}, 32) + + userNonce, err := musig.MuSig2GenerateNonce( + musig.Musig2v100, + userSessionID, + userPub.SerializeCompressed(), + ) + require.NoError(t, err) + muunNonce, err := musig.MuSig2GenerateNonce( + musig.Musig2v100, + muunSessionID, + muunPub.SerializeCompressed(), + ) + require.NoError(t, err) + lightningPeerNonce, err := musig.MuSig2GenerateNonce( + musig.Musig2v100, + lightningPeerSessionID, + peerPub.SerializeCompressed(), + ) + require.NoError(t, err) + + muunPartialSig, err := musig.ComputePartialSignature3Of3( + sigHash, + muunPriv.Serialize(), + userPub.SerializeCompressed(), + peerPub.SerializeCompressed(), + userNonce.PubNonce[:], + lightningPeerNonce.PubNonce[:], + muunSessionID, + tweak, + ) + require.NoError(t, err) + lightningPeerPartialSig, err := musig.ComputePartialSignature3Of3( + sigHash, + peerPriv.Serialize(), + userPub.SerializeCompressed(), + muunPub.SerializeCompressed(), + userNonce.PubNonce[:], + muunNonce.PubNonce[:], + lightningPeerSessionID, + tweak, + ) + require.NoError(t, err) + + copy(coin.UserSessionID[:], userSessionID) + coin.MuunPubNonce = muunNonce.PubNonce + copy(coin.MuunPartialSig[:], muunPartialSig) + coin.LightningPeerPubNonce = lightningPeerNonce.PubNonce + copy(coin.LightningPeerPartialSig[:], lightningPeerPartialSig) +} + +// masterKey builds a deterministic master key from a single repeated byte. +func masterKey(t *testing.T, fill byte) *HDPrivateKey { + t.Helper() + raw := bytes.Repeat([]byte{fill}, 32) + master, err := NewMasterHDPrivateKeyFromBytes(raw, make([]byte, 32), Regtest()) + require.NoError(t, err) + return master +} + +// fundAddress builds a previous tx with a single output paying the given address. +func fundAddress(t *testing.T, address string, amount btcutil.Amount) *wire.MsgTx { + t.Helper() + decoded, err := btcutil.DecodeAddress(address, Regtest().network) + require.NoError(t, err) + pkScript, err := txscript.PayToAddrScript(decoded) + require.NoError(t, err) + + prevTx := wire.NewMsgTx(2) + prevTx.AddTxIn(&wire.TxIn{Sequence: wire.MaxTxInSequenceNum}) + prevTx.AddTxOut(wire.NewTxOut(int64(amount), pkScript)) + return prevTx +} + +// spendTimelockedOutput builds a tx spending prevTx's first output, with the given input sequence. +func spendTimelockedOutput(prevTx *wire.MsgTx, sequence uint32) *wire.MsgTx { + tx := wire.NewMsgTx(2) + tx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{Hash: prevTx.TxHash(), Index: 0}, + Sequence: sequence, + }) + // Send the funds (minus an arbitrary fee) back to the same script + tx.AddTxOut(wire.NewTxOut(prevTx.TxOut[0].Value-1000, prevTx.TxOut[0].PkScript)) + + return tx } func TestPartiallySignedTransaction_SignAll(t *testing.T) { var ( - encodedUserKey = "tprv8dhZ55jWbg1oQHf7BkxL8AFJMWB4gZhyp2tzbHtLQd3g3L7b2MBuq3dEJMdgRevAxQ8BFSjgCRoC5jp9zpDnphGvjq8pT5Q2aA111dg5pxS" - encodedMuunKey = "tpubDBCFedMe1hS3ba6qSTivfi2f6MieNcgms1b6b9KK1xD6wtvG82dSvYQFgQcF2MBs4kyWEp7MB8tXgYzxiYpxDBnSU2F1sxrict9bNikM9kc" - hexTx = "010000000676cc7d617672313b795a1cac0afe1b045aa2a56face4c61202b733fa543cb4c40000000000ffffffffb3f49f5cfcb0dbaee92be859a79ebd3120964ad3208fc178e3fec5968a194f4b0100000000ffffffff6854626a1687a052653df7630b272446e87bb8eb57a74e537d8ff116248821420100000000ffffffff51849b950f0becdc008da1d072250bd65ddeabfd4a80feed09e25bd0a9ca1e5f0100000000ffffffff7d69cb9ee91913b5d96a4f8c5a32e86a96560c4db3e40f0496772a51186bf0280100000000ffffffffe894cf036db7f07d56b003036477d5c8ed494bbd1cec8a38c355fc88609573140100000000ffffffff01b820090000000000225120a21f34d36f71805071d0e3cb9cd92b0f8267b7ac95db0d60a60d7ec6bbff12db00000000" - userSessionIds = []string{ + encodedUserKey = "tprv8dhZ55jWbg1oQHf7BkxL8AFJMWB4gZhyp2tzbHtLQd3g3L7b2MBuq3dEJMdgRevAxQ8BFSjgCRoC5jp9zpDnphGvjq8pT5Q2aA111dg5pxS" //nolint:lll + encodedMuunKey = "tpubDBCFedMe1hS3ba6qSTivfi2f6MieNcgms1b6b9KK1xD6wtvG82dSvYQFgQcF2MBs4kyWEp7MB8tXgYzxiYpxDBnSU2F1sxrict9bNikM9kc" //nolint:lll + hexTx = "010000000676cc7d617672313b795a1cac0afe1b045aa2a56face4c61202b733fa543cb4c40000000000ffffffffb3f49f5cfcb0dbaee92be859a79ebd3120964ad3208fc178e3fec5968a194f4b0100000000ffffffff6854626a1687a052653df7630b272446e87bb8eb57a74e537d8ff116248821420100000000ffffffff51849b950f0becdc008da1d072250bd65ddeabfd4a80feed09e25bd0a9ca1e5f0100000000ffffffff7d69cb9ee91913b5d96a4f8c5a32e86a96560c4db3e40f0496772a51186bf0280100000000ffffffffe894cf036db7f07d56b003036477d5c8ed494bbd1cec8a38c355fc88609573140100000000ffffffff01b820090000000000225120a21f34d36f71805071d0e3cb9cd92b0f8267b7ac95db0d60a60d7ec6bbff12db00000000" //nolint:lll + userSessionIDs = []string{ "bdc72b8cb2f8278ac5de74a585c8e60729d82635c574a0621e4f086121274227", "edb7f1cad77fabe63751fcc30040a91c02675f8d31df424c7a0c723e2134878a", "cdac321cead1a473bfdf17329f6c31baf30b9f6bd5931282e3dda5f7b2c16f61", @@ -473,52 +1213,126 @@ func TestPartiallySignedTransaction_SignAll(t *testing.T) { "0250df31823ed2e8fda46e928645a23ccbafddf39f7649579807c9e49fe5b143", } inputTxRaw = []string{ - "0200000000010148823860c5e3f3db0f1bd0cc70d02f0d773c878c0a366ffcd01be6444d3d0cc00000000000feffffff02a08601000000000017a914d88e83d1a5d3238decb7d4442c24f7bb3b8da52b87b04f042a01000000160014e21f5442bce128b113a9706a0ce53cc3f43943610247304402203a316d5941a810b5aa33cc08d10a88d754285e98fd88e26b6861b5f404b0b546022017024d58a8ab1ca0c33d081c5e6ca3192f3b1ba72676731e52270a5fb74ecb58012103ff6f07c524f89457d05a1ee9e2b8d1ae2520916748b2da0c7c1d3655f271855e25050000", - "02000000000101873c68ac3f94b583efe49c3f00ae3cc7f77c9a78bccc3fdf61920edd45545b7f0000000000feffffff02b04f042a01000000160014d66fdb6789560b3fd7b0d000da0d6f22b9c70252a08601000000000017a914b82751531d1ff4fefd7fa348b900ba66d17cdcc7870247304402204bddec0acf2a0d33f7ef89f1202c6d7d4c8151756d368cc45b1c3ed3a828e7800220245589b38c63e6f32333f14d6694fc613bd6354ae354f9309f6cd3fae677c50c012103ff6f07c524f89457d05a1ee9e2b8d1ae2520916748b2da0c7c1d3655f271855e25050000", - "0200000000010161e13760dbe0f3fcab9ed507538890d017812655f218f9bf2e1b38e5792841f80000000000feffffff028b4d042a01000000160014b00f4fabc6dec5f23326c693d92b65573c9433daa0860100000000002200204eb582ca70159962cff162ada5d0c4f6cdfe7eef173bf4c970ad177fe69ada6402473044022012d3ae236f46c2484be4e19792fb9ce7b4f51c76f6b808000e15f8872a280c7502202195bceec3fdb1d7333badf9be8950ae3210438e5ffb2e30a1573302ddeef36a012103ff6f07c524f89457d05a1ee9e2b8d1ae2520916748b2da0c7c1d3655f271855e25050000", - "020000000001019dcb550b07a53ec866ec2558f94d8b801ef60200378cd78afcde2f123a9d619a0000000000feffffff024d4f042a01000000160014af568af3154b649bcfd7c3bfdb814043412e817fa0860100000000001976a914320cf4affd84615973e948a47a96f9103d68f8c288ac02473044022075bf561de3708e652cc9910edfd56c0158e86d99e8b3e4e1284c1bef13e72d1802203ff834bd90443dafef0488793cc6a007946ec781d4ac0dea5947a28794dfc748012103ff6f07c524f89457d05a1ee9e2b8d1ae2520916748b2da0c7c1d3655f271855e25050000", - "02000000000101204b88f3b89ad992a9ce63bf857d08f26ee20a0e6bb4e912ddb41f56d145078c0000000000feffffff028b4d042a01000000160014032c403be2d0a8bb0f3db634a68b31d31fb498c9a0860100000000002251205cedceb4e29b9632835293f5b68fd0de48d93420e6c461b0a1c10dc8ced91be50247304402202523ae6ed227cc7b80f34eb7662d3e90681666828b9ae437249d125dfc14dba602207a82b7ac2ae18ed0e742245e8277364b6bec00a0c7dc428085b577c9a3e98ffe012103ff6f07c524f89457d05a1ee9e2b8d1ae2520916748b2da0c7c1d3655f271855e25050000", - "02000000000101e33e3ed531e1956474279b70325c8ad5283706fe8bd29b1358f1e1d4d0aac8a30000000000feffffff028b4d042a01000000160014da492a8cc860dab06f59d941c2dec6e88a29b545a08601000000000022512074300b4a8179bfe6eacd7e478c506cd00445ae3dd489634684626c9b04e4ec8d02473044022037b78bd154dac8dcda7ae9f109f4612add9396731bb01ad44f58e14011f7be0f022042b5b13e03815d9ae5748d28d9e70274d9474a315e928b37c4d5a3916572c0af012103ff6f07c524f89457d05a1ee9e2b8d1ae2520916748b2da0c7c1d3655f271855e1b050000", + "0200000000010148823860c5e3f3db0f1bd0cc70d02f0d773c878c0a366ffcd01be6444d3d0cc00000000000feffffff02a08601000000000017a914d88e83d1a5d3238decb7d4442c24f7bb3b8da52b87b04f042a01000000160014e21f5442bce128b113a9706a0ce53cc3f43943610247304402203a316d5941a810b5aa33cc08d10a88d754285e98fd88e26b6861b5f404b0b546022017024d58a8ab1ca0c33d081c5e6ca3192f3b1ba72676731e52270a5fb74ecb58012103ff6f07c524f89457d05a1ee9e2b8d1ae2520916748b2da0c7c1d3655f271855e25050000", //nolint:lll + "02000000000101873c68ac3f94b583efe49c3f00ae3cc7f77c9a78bccc3fdf61920edd45545b7f0000000000feffffff02b04f042a01000000160014d66fdb6789560b3fd7b0d000da0d6f22b9c70252a08601000000000017a914b82751531d1ff4fefd7fa348b900ba66d17cdcc7870247304402204bddec0acf2a0d33f7ef89f1202c6d7d4c8151756d368cc45b1c3ed3a828e7800220245589b38c63e6f32333f14d6694fc613bd6354ae354f9309f6cd3fae677c50c012103ff6f07c524f89457d05a1ee9e2b8d1ae2520916748b2da0c7c1d3655f271855e25050000", //nolint:lll + "0200000000010161e13760dbe0f3fcab9ed507538890d017812655f218f9bf2e1b38e5792841f80000000000feffffff028b4d042a01000000160014b00f4fabc6dec5f23326c693d92b65573c9433daa0860100000000002200204eb582ca70159962cff162ada5d0c4f6cdfe7eef173bf4c970ad177fe69ada6402473044022012d3ae236f46c2484be4e19792fb9ce7b4f51c76f6b808000e15f8872a280c7502202195bceec3fdb1d7333badf9be8950ae3210438e5ffb2e30a1573302ddeef36a012103ff6f07c524f89457d05a1ee9e2b8d1ae2520916748b2da0c7c1d3655f271855e25050000", //nolint:lll + "020000000001019dcb550b07a53ec866ec2558f94d8b801ef60200378cd78afcde2f123a9d619a0000000000feffffff024d4f042a01000000160014af568af3154b649bcfd7c3bfdb814043412e817fa0860100000000001976a914320cf4affd84615973e948a47a96f9103d68f8c288ac02473044022075bf561de3708e652cc9910edfd56c0158e86d99e8b3e4e1284c1bef13e72d1802203ff834bd90443dafef0488793cc6a007946ec781d4ac0dea5947a28794dfc748012103ff6f07c524f89457d05a1ee9e2b8d1ae2520916748b2da0c7c1d3655f271855e25050000", //nolint:lll + "02000000000101204b88f3b89ad992a9ce63bf857d08f26ee20a0e6bb4e912ddb41f56d145078c0000000000feffffff028b4d042a01000000160014032c403be2d0a8bb0f3db634a68b31d31fb498c9a0860100000000002251205cedceb4e29b9632835293f5b68fd0de48d93420e6c461b0a1c10dc8ced91be50247304402202523ae6ed227cc7b80f34eb7662d3e90681666828b9ae437249d125dfc14dba602207a82b7ac2ae18ed0e742245e8277364b6bec00a0c7dc428085b577c9a3e98ffe012103ff6f07c524f89457d05a1ee9e2b8d1ae2520916748b2da0c7c1d3655f271855e25050000", //nolint:lll + "02000000000101e33e3ed531e1956474279b70325c8ad5283706fe8bd29b1358f1e1d4d0aac8a30000000000feffffff028b4d042a01000000160014da492a8cc860dab06f59d941c2dec6e88a29b545a08601000000000022512074300b4a8179bfe6eacd7e478c506cd00445ae3dd489634684626c9b04e4ec8d02473044022037b78bd154dac8dcda7ae9f109f4612add9396731bb01ad44f58e14011f7be0f022042b5b13e03815d9ae5748d28d9e70274d9474a315e928b37c4d5a3916572c0af012103ff6f07c524f89457d05a1ee9e2b8d1ae2520916748b2da0c7c1d3655f271855e1b050000", //nolint:lll } ) inputList := &InputList{inputs: []Input{ &input{ - outpoint: outpoint{index: 0, amount: 100000, txId: hexToBytes("c4b43c54fa33b70212c6e4ac6fa5a25a041bfe0aac1c5a793b317276617dcc76")}, - address: addresses.New(3, "m/schema:1'/recovery:1'/external:1/2", "2NCzGfq4MurQzhodoSFtB2VSEJ8Tc5MN82j"), - muunSignature: hexToBytes("3045022100860c48eb2374dbb67ed68dd91198994407a4e933f92e4f3187706f93debf246b02206b7d0607eaad478f6323cb707344739ce0ba8583266a39b617dd0e5bb77633b501"), + outpoint: outpoint{ + index: 0, + amount: 100000, + txID: hexToBytes( + "c4b43c54fa33b70212c6e4ac6fa5a25a041bfe0aac1c5a793b317276617dcc76", + ), + }, + address: addresses.New( + 3, + "m/schema:1'/recovery:1'/external:1/2", + "2NCzGfq4MurQzhodoSFtB2VSEJ8Tc5MN82j", + ), + muunSignature: hexToBytes( + "3045022100860c48eb2374dbb67ed68dd91198994407a4e933f92e4f3187706f93debf246b02206b7d0607eaad478f6323cb707344739ce0ba8583266a39b617dd0e5bb77633b501", //nolint:lll + ), }, &input{ - outpoint: outpoint{index: 1, amount: 100000, txId: hexToBytes("4b4f198a96c5fee378c18f20d34a962031bd9ea759e82be9aedbb0fc5c9ff4b3")}, - address: addresses.New(2, "m/schema:1'/recovery:1'/external:1/1", "2NA2wRRMNCsfECwvjHwAdPmciFRGrBKzc6W"), - muunSignature: hexToBytes("3045022100c907055dd0033f4f28113f566f68a9c0c400f804d83faca36f5f77a92696bf960220379da01e954f5cdee9fc69dfdf59ac33d36b4af5ea801fd011c2911df2ace2b501"), + outpoint: outpoint{ + index: 1, + amount: 100000, + txID: hexToBytes( + "4b4f198a96c5fee378c18f20d34a962031bd9ea759e82be9aedbb0fc5c9ff4b3", + ), + }, + address: addresses.New( + 2, + "m/schema:1'/recovery:1'/external:1/1", + "2NA2wRRMNCsfECwvjHwAdPmciFRGrBKzc6W", + ), + muunSignature: hexToBytes( + "3045022100c907055dd0033f4f28113f566f68a9c0c400f804d83faca36f5f77a92696bf960220379da01e954f5cdee9fc69dfdf59ac33d36b4af5ea801fd011c2911df2ace2b501", //nolint:lll + ), }, &input{ - outpoint: outpoint{index: 1, amount: 100000, txId: hexToBytes("4221882416f18f7d534ea757ebb87be84624270b63f73d6552a087166a625468")}, - address: addresses.New(4, "m/schema:1'/recovery:1'/external:1/3", "bcrt1qf66c9jnszkvk9nl3v2k6t5xy7mxlulh0zualfjts45thle56mfjqvkvgh0"), - muunSignature: hexToBytes("3045022100bb2a22510930bc31ddb6b9245eebe5d22499c54d08b326eebd249403f9f6128b02207c10ec0fb8a336230f352b4d2ed10be91cc31d883fd545dfc5124ce8c63a01bf01"), + outpoint: outpoint{ + index: 1, + amount: 100000, + txID: hexToBytes( + "4221882416f18f7d534ea757ebb87be84624270b63f73d6552a087166a625468", + ), + }, + address: addresses.New( + 4, + "m/schema:1'/recovery:1'/external:1/3", + "bcrt1qf66c9jnszkvk9nl3v2k6t5xy7mxlulh0zualfjts45thle56mfjqvkvgh0", + ), + muunSignature: hexToBytes( + "3045022100bb2a22510930bc31ddb6b9245eebe5d22499c54d08b326eebd249403f9f6128b02207c10ec0fb8a336230f352b4d2ed10be91cc31d883fd545dfc5124ce8c63a01bf01", //nolint:lll + ), }, &input{ - outpoint: outpoint{index: 1, amount: 100000, txId: hexToBytes("5f1ecaa9d05be209edfe804afdabde5dd60b2572d0a18d00dcec0b0f959b8451")}, - address: addresses.New(1, "m/schema:1'/recovery:1'/external:1/0", "mk5bbq9qZ6Hh6u34wxvA4Hs6cEc4AF2HfL"), + outpoint: outpoint{ + index: 1, + amount: 100000, + txID: hexToBytes( + "5f1ecaa9d05be209edfe804afdabde5dd60b2572d0a18d00dcec0b0f959b8451", + ), + }, + address: addresses.New( + 1, + "m/schema:1'/recovery:1'/external:1/0", + "mk5bbq9qZ6Hh6u34wxvA4Hs6cEc4AF2HfL", + ), }, &input{ - outpoint: outpoint{index: 1, amount: 100000, txId: hexToBytes("28f06b18512a7796040fe4b34d0c56966ae8325a8c4f6ad9b51319e99ecb697d")}, - address: addresses.New(6, "m/schema:1'/recovery:1'/external:1/5", "bcrt1ptnkuad8znwtr9q6jj06mdr7smeydjdpqumzxrv9pcyxu3nker0js0h0dfc"), - muunPublicNonce: hexToBytes("020129f74df468dadc7a1af0305f12a550a4792559b260f756b67277d1de8d0d6e03fb1229bf2eb848532714bf50d79770b2790c589926b3893c4697106dec17a27f"), - muunSignature: hexToBytes("1be2d8c9f6781372950374bf6986ea32d40473beac5329ca9fe1fe459ce07e26"), + outpoint: outpoint{ + index: 1, + amount: 100000, + txID: hexToBytes( + "28f06b18512a7796040fe4b34d0c56966ae8325a8c4f6ad9b51319e99ecb697d", + ), + }, + address: addresses.New( + 6, + "m/schema:1'/recovery:1'/external:1/5", + "bcrt1ptnkuad8znwtr9q6jj06mdr7smeydjdpqumzxrv9pcyxu3nker0js0h0dfc", + ), + muunPublicNonce: hexToBytes( + "020129f74df468dadc7a1af0305f12a550a4792559b260f756b67277d1de8d0d6e03fb1229bf2eb848532714bf50d79770b2790c589926b3893c4697106dec17a27f", //nolint:lll + ), + muunSignature: hexToBytes( + "1be2d8c9f6781372950374bf6986ea32d40473beac5329ca9fe1fe459ce07e26", + ), }, &input{ - outpoint: outpoint{index: 1, amount: 100000, txId: hexToBytes("1473956088fc55c3388aec1cbd4b49edc8d577640303b0567df0b76d03cf94e8")}, - address: addresses.New(5, "m/schema:1'/recovery:1'/external:1/4", "bcrt1pwscqkj5p0xl7d6kd0ercc5rv6qzytt3a6jykx35yvfkfkp8yajxs77vg44"), - muunPublicNonce: hexToBytes("03b844359aea6df24a8c7e1766b6cc81d86827f6a0239eef26bdd02d19d0c7fed103b5d431f46f51b73572a619355d60592cec11f16491220810070fd5150216fbcc"), - muunSignature: hexToBytes("ffc0c05c178fedc4ab2c4edc80dbb1a79d0ef5e6c0e3bfb7744215540c70d53c"), + outpoint: outpoint{ + index: 1, + amount: 100000, + txID: hexToBytes( + "1473956088fc55c3388aec1cbd4b49edc8d577640303b0567df0b76d03cf94e8", + ), + }, + address: addresses.New( + 5, + "m/schema:1'/recovery:1'/external:1/4", + "bcrt1pwscqkj5p0xl7d6kd0ercc5rv6qzytt3a6jykx35yvfkfkp8yajxs77vg44", + ), + muunPublicNonce: hexToBytes( + "03b844359aea6df24a8c7e1766b6cc81d86827f6a0239eef26bdd02d19d0c7fed103b5d431f46f51b73572a619355d60592cec11f16491220810070fd5150216fbcc", //nolint:lll + ), + muunSignature: hexToBytes( + "ffc0c05c178fedc4ab2c4edc80dbb1a79d0ef5e6c0e3bfb7744215540c70d53c", + ), }, }} userKey, _ := NewHDPrivateKeyFromString(encodedUserKey, basePath, Regtest()) muunKey, _ := NewHDPublicKeyFromString(encodedMuunKey, basePath, Regtest()) - nonces := createTestNonces(t, userKey, inputList, userSessionIds) + nonces := createTestNonces(t, userKey, inputList, userSessionIDs) partial, _ := NewPartiallySignedTransaction(inputList, hexToBytes(hexTx), nonces) signedRawTx, err := partial.Sign(userKey, muunKey) @@ -529,28 +1343,42 @@ func TestPartiallySignedTransaction_SignAll(t *testing.T) { signedTx := wire.NewMsgTx(0) signedTx.Deserialize(bytes.NewReader(signedRawTx.Bytes)) + // Build a fetcher resolving every input's prevout since taproot addresses' sighash commits + // to all inputs' amounts and scripts. + prevOutFetcher := txscript.NewMultiPrevOutFetcher(nil) + for index := range inputList.inputs { + prevTx := wire.NewMsgTx(0) + rawPrevTx, _ := hex.DecodeString(inputTxRaw[index]) + prevTx.Deserialize(bytes.NewReader(rawPrevTx)) + + outPoint := signedTx.TxIn[index].PreviousOutPoint + prevOutFetcher.AddPrevOut(outPoint, prevTx.TxOut[outPoint.Index]) + } + // validate input signatures for index := range inputList.inputs { - verifyInput(t, signedTx, inputTxRaw[index], inputList.inputs[index].OutPoint().Index(), index) + if err := runInputScriptWithFetcher(t, signedTx, prevOutFetcher, index); err != nil { + t.Fatalf("failed to verify input %d: %v", index, err) + } } } func TestPartiallySignedTransaction_SignSubmarineSwapV1(t *testing.T) { const ( - hexTx = "01000000021a608c7d6e40586806c33b3b1036fbd305c37e9d38990d912cc02de7e7cec05e0000000000fffffffff18bce10875329410641316bf7c4d984e00780174b6983080e9225dc26e5bd8c0100000000feffffff01705bc0230000000017a91470fcbc29723c85fdbf9fb5189220f279e9be4508878f030000" + hexTx = "01000000021a608c7d6e40586806c33b3b1036fbd305c37e9d38990d912cc02de7e7cec05e0000000000fffffffff18bce10875329410641316bf7c4d984e00780174b6983080e9225dc26e5bd8c0100000000feffffff01705bc0230000000017a91470fcbc29723c85fdbf9fb5189220f279e9be4508878f030000" //nolint:lll txIndex1 = 0 txAmount1 = 599817960 hexTxOut1 = "5ec0cee7e72dc02c910d99389d7ec305d3fb36103b3bc3066858406e7d8c601a" - hexTx1 = "0100000006f65ae1c782a5b37795a203a8820719100b1c82f59a4aa1cf3bbcc121442636a50000000023220020f1dcb100a8f4249af53e2ef831e2164545f329a5e8cda589210c033896cd1f12fffffffff21cc482a9359d2762f0a3621eb825e4e728b848588767aecdd8f906833e578e0100000023220020f1dcb100a8f4249af53e2ef831e2164545f329a5e8cda589210c033896cd1f12ffffffff68b507462f19a913b7a6a2a6956cd1c514e66b669d50b3f6228cc21935b78b7f00000000232200203ec9de492dfda91c6d7e84a14f478b1fd6c4b3432aeb4262482133975f94e8f2fffffffff18bce10875329410641316bf7c4d984e00780174b6983080e9225dc26e5bd8c00000000232200209f60ba93792ab212523ad6e6daaefb06d3d0c14ba02ddeaa38582031578bbbd3ffffffff741c42cabd1464b5752e4050acc9d9dfa7ccb296d3847a0e7da6d90effa0d80b0000000023220020d4cf5b8c1ddaa1e2788596655df089cbe10ad33bae149160e07dd76b54e2a1e3ffffffffa609573ae63856433d80793d44d05b077b2c5ef1cc04d820de0d107303ce831b0000000023220020b90f5d2eaf489a24ec6f6d93a47536145fbae13b745fbc7ef9fc5a16d1fa2408ffffffff01e87ec0230000000017a91417c1f13d6ba17a62d6f1f784927c0d45ba22f6fa8700000000" + hexTx1 = "0100000006f65ae1c782a5b37795a203a8820719100b1c82f59a4aa1cf3bbcc121442636a50000000023220020f1dcb100a8f4249af53e2ef831e2164545f329a5e8cda589210c033896cd1f12fffffffff21cc482a9359d2762f0a3621eb825e4e728b848588767aecdd8f906833e578e0100000023220020f1dcb100a8f4249af53e2ef831e2164545f329a5e8cda589210c033896cd1f12ffffffff68b507462f19a913b7a6a2a6956cd1c514e66b669d50b3f6228cc21935b78b7f00000000232200203ec9de492dfda91c6d7e84a14f478b1fd6c4b3432aeb4262482133975f94e8f2fffffffff18bce10875329410641316bf7c4d984e00780174b6983080e9225dc26e5bd8c00000000232200209f60ba93792ab212523ad6e6daaefb06d3d0c14ba02ddeaa38582031578bbbd3ffffffff741c42cabd1464b5752e4050acc9d9dfa7ccb296d3847a0e7da6d90effa0d80b0000000023220020d4cf5b8c1ddaa1e2788596655df089cbe10ad33bae149160e07dd76b54e2a1e3ffffffffa609573ae63856433d80793d44d05b077b2c5ef1cc04d820de0d107303ce831b0000000023220020b90f5d2eaf489a24ec6f6d93a47536145fbae13b745fbc7ef9fc5a16d1fa2408ffffffff01e87ec0230000000017a91417c1f13d6ba17a62d6f1f784927c0d45ba22f6fa8700000000" //nolint:lll txAddressPath1 = "m/schema:1'/recovery:1'/external:1/2" txAddress1 = "2MuQqs3e42GpYteWDGEN16TqCQDC8oGCpiV" - txMuunSigHex1 = "3044022032b35746170883b2f46c2f14019eb95e2e7e4d800248e6a8b372e504dc48674b02202ff47b29abf8f1be8719e757cbd218a4111c214b0c1aa4bdfc7debaf1b46880f01" + txMuunSigHex1 = "3044022032b35746170883b2f46c2f14019eb95e2e7e4d800248e6a8b372e504dc48674b02202ff47b29abf8f1be8719e757cbd218a4111c214b0c1aa4bdfc7debaf1b46880f01" //nolint:lll txIndex2 = 1 txAmount2 = 18400 hexTxOut2 = "8cbde526dc25920e0883694b178007e084d9c4f76b3141064129538710ce8bf1" - hexTx2 = "0100000001c00ee241359fa47d45f4f08b67e37f7a31ebe996da59513dfc6c5af97a3959610100000023220020f1dcb100a8f4249af53e2ef831e2164545f329a5e8cda589210c033896cd1f12ffffffff02a064f5050000000017a914d2bf8b44779443e9a7571ab416c72cdee9e9d06e87e04700000000000017a9140c02072aee07d46ab06edb7d75d538c133ebd8c38700000000" + hexTx2 = "0100000001c00ee241359fa47d45f4f08b67e37f7a31ebe996da59513dfc6c5af97a3959610100000023220020f1dcb100a8f4249af53e2ef831e2164545f329a5e8cda589210c033896cd1f12ffffffff02a064f5050000000017a914d2bf8b44779443e9a7571ab416c72cdee9e9d06e87e04700000000000017a9140c02072aee07d46ab06edb7d75d538c133ebd8c38700000000" //nolint:lll txAddressPath2 = "m/schema:1'/recovery:1'/change:0/7" txAddress2 = "2MtLiXVbDBQdHKDAKwAL5AnsTo6LoCakjvg" txPaymentHashHex2 = "0634be42f7a600c0457ace25f2502e9e473b7d5f0e50172dcce25044c8538936" @@ -558,8 +1386,8 @@ func TestPartiallySignedTransaction_SignSubmarineSwapV1(t *testing.T) { txLockTime2 = 911 txRefundAddress2 = "n3yUtyw6xAnYNpfkbuVKPSqnGdbqsLNePr" - encodedMuunKey = "tpubDBZaivUL3Hv8r25JDupShPuWVkGcwM7NgbMBwkhQLfWu18iBbyQCbRdyg1wRMjoWdZN7Afg3F25zs4c8E6Q4VJrGqAw51DJeqacTFABV9u8" - encodedUserKey = "tprv8fFtghPy2BsdB8nrBZcrHSihQDb65yVJa5DfLcFdtjnRc8SQcV4d59hZAzn2auLdEom9KscWv5JAuxUG65gDYiBxwbGarcix7H2Vp8xXPnX" + encodedMuunKey = "tpubDBZaivUL3Hv8r25JDupShPuWVkGcwM7NgbMBwkhQLfWu18iBbyQCbRdyg1wRMjoWdZN7Afg3F25zs4c8E6Q4VJrGqAw51DJeqacTFABV9u8" //nolint:lll + encodedUserKey = "tprv8fFtghPy2BsdB8nrBZcrHSihQDb65yVJa5DfLcFdtjnRc8SQcV4d59hZAzn2auLdEom9KscWv5JAuxUG65gDYiBxwbGarcix7H2Vp8xXPnX" //nolint:lll ) txOut1, _ := hex.DecodeString(hexTxOut1) @@ -571,12 +1399,12 @@ func TestPartiallySignedTransaction_SignSubmarineSwapV1(t *testing.T) { inputs := []Input{ &input{ - outpoint: outpoint{index: txIndex1, amount: txAmount1, txId: txOut1}, + outpoint: outpoint{index: txIndex1, amount: txAmount1, txID: txOut1}, address: addresses.New(addresses.V3, txAddressPath1, txAddress1), muunSignature: muunSig1, }, &input{ - outpoint: outpoint{index: txIndex2, amount: txAmount2, txId: txOut2}, + outpoint: outpoint{index: txIndex2, amount: txAmount2, txID: txOut2}, address: addresses.New(addresses.SubmarineSwapV1, txAddressPath2, txAddress2), submarineSwapV1: inputSubmarineSwapV1{ refundAddress: txRefundAddress2, @@ -603,57 +1431,90 @@ func TestPartiallySignedTransaction_SignSubmarineSwapV1(t *testing.T) { signedTx := wire.NewMsgTx(0) signedTx.Deserialize(bytes.NewReader(signedRawTx.Bytes)) - verifyInput(t, signedTx, hexTx1, txIndex1, 0) - verifyInput(t, signedTx, hexTx2, txIndex2, 1) + mustVerifyInput(t, signedTx, hexTx1, txIndex1, 0) + mustVerifyInput(t, signedTx, hexTx2, txIndex2, 1) } -func verifyInput(t *testing.T, signedTx *wire.MsgTx, hexPrevTx string, prevIndex, index int) { +func mustVerifyInput(t *testing.T, signedTx *wire.MsgTx, hexPrevTx string, prevIndex, index int) { t.Helper() - // Uncomment the next block if you need to see what the script engine outputs - txscript.DisableLog() - // logger := btclog.NewBackend(os.Stderr).Logger("test") - // logger.SetLevel(btclog.LevelTrace) - // txscript.UseLogger(logger) + if err := verifyInput(t, signedTx, hexPrevTx, prevIndex, index); err != nil { + t.Fatalf("failed to verify script: %v", err) + } +} - prevTx := wire.NewMsgTx(0) +func verifyInput( + t *testing.T, signedTx *wire.MsgTx, hexPrevTx string, prevIndex, index int, +) error { + t.Helper() + prevTx := wire.NewMsgTx(0) rawPrevTx, _ := hex.DecodeString(hexPrevTx) prevTx.Deserialize(bytes.NewReader(rawPrevTx)) + prevOutFetcher := txscript.NewCannedPrevOutputFetcher( + prevTx.TxOut[prevIndex].PkScript, + prevTx.TxOut[prevIndex].Value, + ) + + return runInputScriptWithFetcher(t, signedTx, prevOutFetcher, index) +} + +// runInputScriptWithFetcher runs the script engine for one input, resolving prevouts through the +// given fetcher. +func runInputScriptWithFetcher( + t *testing.T, signedTx *wire.MsgTx, prevOutFetcher txscript.PrevOutputFetcher, index int, +) error { + t.Helper() + + // Uncomment the next block if you need to see what the script engine outputs + txscript.DisableLog() + // logger := btclog.NewBackend(os.Stderr).Logger("test") logger.SetLevel(btclog.LevelTrace) + // txscript.UseLogger(logger) + flags := txscript.ScriptBip16 | txscript.ScriptVerifyDERSignatures | txscript.ScriptStrictMultiSig | txscript.ScriptDiscourageUpgradableNops | txscript.ScriptVerifyStrictEncoding | txscript.ScriptVerifyLowS | - txscript.ScriptVerifyWitness | txscript.ScriptVerifyCheckLockTimeVerify - - prevOutFetcher := txscript.NewCannedPrevOutputFetcher(prevTx.TxOut[prevIndex].PkScript, prevTx.TxOut[prevIndex].Value) - vm, err := txscript.NewEngine(prevTx.TxOut[prevIndex].PkScript, signedTx, index, flags, nil, nil, prevTx.TxOut[prevIndex].Value, prevOutFetcher) + txscript.ScriptVerifyWitness | txscript.ScriptVerifyCheckLockTimeVerify | + txscript.ScriptVerifyCheckSequenceVerify | txscript.ScriptVerifyTaproot + + prevOut := prevOutFetcher.FetchPrevOutput(signedTx.TxIn[index].PreviousOutPoint) + sigHashes := txscript.NewTxSigHashes(signedTx, prevOutFetcher) + + vm, err := txscript.NewEngine( + prevOut.PkScript, + signedTx, + index, + flags, + nil, + sigHashes, + prevOut.Value, + prevOutFetcher, + ) if err != nil { t.Fatalf("failed to build script engine: %v", err) } - if err := vm.Execute(); err != nil { - t.Fatalf("failed to verify script: %v", err) - } + return vm.Execute() } func TestPartiallySignedTransaction_SignSubmarineSwapV2(t *testing.T) { const ( - hexTx = "010000000001010a1e9552f252c4f94dae951a3a2789263650d69de286ed4813333ac73179b4790000000023220020fc4ea5a79e0de596005a77df25fdc1d76a5bd2ca022b58260830b45dbf48005fffffffff0100000000000000001976a91476e6856729db9c3885fbd72c47bd225990eee4ad88ac03473044022038395a9846c02cc1b87655ea4679f3df127fa5f781c7db3598ee43acc65adab4022051f0f874a8c16544c4ab492b8a091b630703d742599ea17c61b2bfadb747f30e0147304402207bd5a91f032ed3d69a7999d170c696861f36991f6b54e24da4319eaf512ccac402203d3d14c42103261f605b3a870ab10b03ff8b84537575768067e41853d77d2b240187210310df0c435a58758d53821915501301581be8c18b63d5a0dab281aa7f98bcb6e67c210226048275203811ab30a61759f8271280cb754ede8c38b5c51fc662dec441511eac637c76a914f722e6b3c976eba035578a7b268de980682d60b1876375677cac6867029000b275ad76a9141528942b8aef6f523d8050ad6bab416d6199352288ac6800000000" + hexTx = "010000000001010a1e9552f252c4f94dae951a3a2789263650d69de286ed4813333ac73179b4790000000023220020fc4ea5a79e0de596005a77df25fdc1d76a5bd2ca022b58260830b45dbf48005fffffffff0100000000000000001976a91476e6856729db9c3885fbd72c47bd225990eee4ad88ac03473044022038395a9846c02cc1b87655ea4679f3df127fa5f781c7db3598ee43acc65adab4022051f0f874a8c16544c4ab492b8a091b630703d742599ea17c61b2bfadb747f30e0147304402207bd5a91f032ed3d69a7999d170c696861f36991f6b54e24da4319eaf512ccac402203d3d14c42103261f605b3a870ab10b03ff8b84537575768067e41853d77d2b240187210310df0c435a58758d53821915501301581be8c18b63d5a0dab281aa7f98bcb6e67c210226048275203811ab30a61759f8271280cb754ede8c38b5c51fc662dec441511eac637c76a914f722e6b3c976eba035578a7b268de980682d60b1876375677cac6867029000b275ad76a9141528942b8aef6f523d8050ad6bab416d6199352288ac6800000000" //nolint:lll txIndex2 = 0 txAmount2 = 1000 hexTxOut2 = "79b47931c73a331348ed86e29dd650362689273a1a95ae4df9c452f252951e0a" - hexTx2 = "0100000001b9c3208b3cd1c687d73fec2022ac6ce057c00cf8ae060e5579107a8d99681a7f000000006a473044022042d2e34afb3b66b27641c774b467ce854cfa5d4f9a1eaa462174fa3c688208840220651fdeab3a8134c65431dba040b654d9d21f50343f82bc1870b5280eaff89fc101210209d4e395ce720f13439f4f73b0dac8433f2fa17f094c5fcdaa6965bf96ece088ffffffff02e80300000000000017a914fc7ee7c4ce68ca09559d9e8776f0455039ea18d58718ee052a010000001976a9143447bbd5107cb1572eeb8550f74e5d31a4bf5bd888ac00000000" + hexTx2 = "0100000001b9c3208b3cd1c687d73fec2022ac6ce057c00cf8ae060e5579107a8d99681a7f000000006a473044022042d2e34afb3b66b27641c774b467ce854cfa5d4f9a1eaa462174fa3c688208840220651fdeab3a8134c65431dba040b654d9d21f50343f82bc1870b5280eaff89fc101210209d4e395ce720f13439f4f73b0dac8433f2fa17f094c5fcdaa6965bf96ece088ffffffff02e80300000000000017a914fc7ee7c4ce68ca09559d9e8776f0455039ea18d58718ee052a010000001976a9143447bbd5107cb1572eeb8550f74e5d31a4bf5bd888ac00000000" //nolint:lll txAddressPath2 = "m" txAddress2 = "2NGGJJARaFRcARRMDeSWQ46LwU46Z9oKNCZ" txPaymentHashHex2 = "cdb14d5fcf498e8785caff18940bbd713b98b4d425ab0503adb92ab08c5850e3" txServerPubKeyHex2 = "0226048275203811ab30a61759f8271280cb754ede8c38b5c51fc662dec441511e" txBlockForExpiration2 = 144 - txServerSignatureHex2 = "304402207bd5a91f032ed3d69a7999d170c696861f36991f6b54e24da4319eaf512ccac402203d3d14c42103261f605b3a870ab10b03ff8b84537575768067e41853d77d2b2401" + txServerSignatureHex2 = "304402207bd5a91f032ed3d69a7999d170c696861f36991f6b54e24da4319eaf512ccac402203d3d14c42103261f605b3a870ab10b03ff8b84537575768067e41853d77d2b2401" //nolint:lll - encodedMuunKey = "tpubD6NzVbkrYhZ4Yg872usw1wxNYrpCsUmiG4faYMaogSFwJFX9sz8MrR6GNKg4qUDjb3KUYcC9nrUL7tQYfK441qkFP9pwsw6fb8gTW7vJjXq" - encodedUserKey = "tprv8ZgxMBicQKsPdu1SiZiQbV4K2af648S6jf8Axu7RkgQborzWpQVRzrSvyoYWb5Rmy8VVyFBDjZobn7ZaK3Ax2hLvF9NxJ6gUWNLwgLxRav7" + encodedMuunKey = "tpubD6NzVbkrYhZ4Yg872usw1wxNYrpCsUmiG4faYMaogSFwJFX9sz8MrR6GNKg4qUDjb3KUYcC9nrUL7tQYfK441qkFP9pwsw6fb8gTW7vJjXq" //nolint:lll + encodedUserKey = "tprv8ZgxMBicQKsPdu1SiZiQbV4K2af648S6jf8Axu7RkgQborzWpQVRzrSvyoYWb5Rmy8VVyFBDjZobn7ZaK3Ax2hLvF9NxJ6gUWNLwgLxRav7" //nolint:lll ) txOut2, _ := hex.DecodeString(hexTxOut2) @@ -667,7 +1528,7 @@ func TestPartiallySignedTransaction_SignSubmarineSwapV2(t *testing.T) { inputs := []Input{ &input{ - outpoint: outpoint{index: txIndex2, amount: txAmount2, txId: txOut2}, + outpoint: outpoint{index: txIndex2, amount: txAmount2, txID: txOut2}, address: addresses.New(addresses.SubmarineSwapV2, txAddressPath2, txAddress2), submarineSwapV2: inputSubmarineSwapV2{ paymentHash256: paymentHash2, @@ -694,12 +1555,12 @@ func TestPartiallySignedTransaction_SignSubmarineSwapV2(t *testing.T) { signedTx := wire.NewMsgTx(0) signedTx.Deserialize(bytes.NewReader(signedRawTx.Bytes)) - verifyInput(t, signedTx, hexTx2, txIndex2, 0) + mustVerifyInput(t, signedTx, hexTx2, txIndex2, 0) } func TestPartiallySignedTransaction_SignIncomingSwap(t *testing.T) { const ( - hexTx = "0100000001e3d55a5423fd70679839f47ed496d61bd4d0964acfa556172c945041eddf3d400000000000ffffffff02f875000000000000220020f411b28870bf089c41f703dbc1a428d60eb7cce61a9d4fa4a5c28ead872d8551963d000000000000220020eee7f6df991fac39aa2fd8054c83ef045c9569507fe4a224c8320162c028267600000000" + hexTx = "0100000001e3d55a5423fd70679839f47ed496d61bd4d0964acfa556172c945041eddf3d400000000000ffffffff02f875000000000000220020f411b28870bf089c41f703dbc1a428d60eb7cce61a9d4fa4a5c28ead872d8551963d000000000000220020eee7f6df991fac39aa2fd8054c83ef045c9569507fe4a224c8320162c028267600000000" //nolint:lll txIndex = 0 txAmount = 46200 @@ -707,17 +1568,19 @@ func TestPartiallySignedTransaction_SignIncomingSwap(t *testing.T) { txAddressPath = "m/schema:1'/recovery:1'/invoices:4/1189547938/512484821/1" txAddress = "bcrt1qk3mqxrvcdddyhvyywqhwc0vftfqdqt877gt0pxtzety54z73rxsse9hyt9" paymentHashHex = "b0e74c22943fd1e2ee86b14fb6f6636c19649910705913f5bfc33014e0ca0fd4" - sphinxHex = "00035a24206be286645b5e2f81fe6d35bf26ceb70b15257f19e3b744c0ed855c3d8e60c5a8d7553d0a39dd162a50df5169f18129a737da3427095e1049c356e02bb71d9c70858bbf3936fb555c283d9015f4b85d629a24e84c61dc69d537545e4c0104a87a9ab6277083cf7cb21a56f10ed23e754adf357a638970fbcd38e985f42c44b69f1cfaac8dbf711a5b8edf56383d56ad4cafb297025fad5f9c3e79dad7d1342cabde86ea85950bb80237d95b676939461aed0447b88d0010023d653abf498780f7d8f9a1e5784638c893caefe95e23a85285b636ce2af87613c275e61da65255dba4f4bacde8d6efd1c29a4f8e3efb98a3881e280b8613d45dbe38b7b895621850322be927a6beb6aa183c9a11dbf29da8a3d2f6b0b6b8c7e1c62d4926f3dc1e06ead192daba315fbcd5edd2a7d08bcfa50d1b2cc799e98b3415202e7cd91ff54962e4e1d5716c339718ebea926db6e24aacf35ebc362aafcfde4d6264d56a6edf430ae4ade75cdf9c121c3708211407d5c7ad23e2bc8dfe0d71e588b01d2ada797830315ba616f6c79030481dde1d8aa1a37676aaa9a48aec1dcc535daf2547cff2d43c58acd7e09473c46cada1112e82b0502d057ce6a8a629836ea293be93c5d228169c46d0643378e20429ba09e0a236f8dc56a23e9d38509a72e3bb115dc7c959af913b7d561d17fd3df8d490e2d91c4ae16429a3ecfa45212dbd6ccd80d4ac5956ade21c46b4960c08570e0af69ee39d1c23b194f20bc4d5d5cb5ec0b1e3a376a51700d166dce2b09a6e0c2285af7d36c4d0178a1acad0bfd8d913c44506987df6406ad7f134927c5d46be261cc4025f6310e8cb8284f03ae75a75d4ca43ba1d578b1be69d76503370f95eb98a769eaf1e7c3032907f9ab50c450b7177a804b2e9cb8c5a6fe5bbeb07d0ad176961f817119989f090ad162cae302242651c0c69e7b52c36665be795538afc3aef77a1cd0a36a170d572f56eb79e07ee6544da446798b5e0a0ed92cd205288824335b0444e5eda4347d28be9b2a128d50f983c5b16ea2792eda5d352b609a08a15268e758e024dbde13ae42008c03c608c6bb1971c9eb7a1842129b056b9c0690a88c1aea43f9bc20d8e132575a1eae77ba2fe24ee780f42e6b73b7022049518f1c231fd4fe3e91ca443980e52507ebc97f8fc49036c6e141c0e74603ac02814aa0928381228f7aadd798dbaac3603099e94224dd0b51466d392d276f19b990e8b351b73d3e284fc24f1c1ee0bffad64d9415ae8ab358b01dbc7eedfadb181ab8080d0f9c151c445419ee670e8376a112a631c5ec4092aca077086299e406ab304c7f864da801147b0e09b9f8873c85e952550b62684ca9217d1c0763930b019871787b07cabe0ef8b541c2375bc7ceb4ec1153e6f8a48371f7f80c69dcd37a7d53053fde41f87231abdbed68f195ddc6082b9d0e55207fea2c4d0c8045d44bfe55de2fb71ed75f12c0105a2ea480678b73100a943c45b6d2d5556ece82f02bd12d8785f38ac96bba1167b27b40dbd4d1677cf0b96f9311382cc110f739ffba634fa5163c9e1bd6f0279356aeab301eca2398525ec136d9d3aab4634a1fe14b365c8ad4f98e217cdba327945dfe69f3a3b7f8cd932bd761b2b264a371b104559d0a69c8e7ac053512f3e7fd2cf64278f33f7288958042b3166ff0f05e174378c80ab8d01332e862f17e8cd5c74b3de9acb51e9526d8d3305fa51b447fcb289b26c96ff06e6d50a639514491077c9f70757b74c7e26800688a05274fdcf2697e69455742126dc0df95512e478417db81440a5d2f8c671df00d0bafca53a8ed4bf6c8ea0a4c8af39c7bb1103b828547b669b75f780d9d78ddd811dd1a639576b19805088c33e6e7855cc360827516f4de8f4788bf81feb45d6d31ba5277bca2c86d84f8a" - htlcTxHex = "020000000001042d54f0cbba265e7f2a0873ce9e03879b706b361d3ee18e8d4a29300c0948bee50000000017160014a411cc351bd7c5572a6ba5ba16e3f3f92106425bfeffffff330565534d05b1d0d4b8a1878f1a5c41d3f571f793c528a44d72af276a259bbd000000001716001443891b3727c96d1e1e91aec69b167123a429ed32feffffff4bb7cba13999b98510769270d161f2a8e0f80f1033c72579a78c105ab2b30ebf00000000171600141c9e61f3951956b00c6f215e75e7fb9a5d93988ffefffffffaa153f428dc6c0aa0be74fa56d6563556b4286d5f9b8a431d5acf0b08f00240000000001716001488ef3cc57c509e843387a7c74e55fdc27fe962f2feffffff0178b4000000000000220020b476030d986b5a4bb084702eec3d895a40d02cfef216f09962cac94a8bd119a102473044022059247039c8e3e95d2eb289e35f81b67e5811d5a757b2117cc9dab1cebf830b2e02204d15406ea075d3d4973344b9bf7c151bb39a7e2876551b8500057dce4591fb870121033d8377beee8caf5fccd958bfacfdd3b5dc1948dfad9fb09768d11e69abb76f3402473044022042e242e0c3adca4f8b3a19cbd8eefd80bf8a43debbe51c5759b2f607b7332bc6022020be1288b8dcddfdc1569c766490e8c0ea668ea1bc122d40b59cbe6589e6938701210243b39e7a2d42937b5aecf14d27b200f7486397d92cbb166ae9f3c11259caf9c602473044022024b16f6042b9a664a46ab0c8cfc3424f8a100309f4cb51e9969a62a257255e2b022053f09e518d2ba73f74d7f3feca98df2b38eb45870325a433457277caf1ce7e6a012103f0386dfb783fc1b55d50aa089326dc008fe2fa963b44d3851bc307bbc419d1c4024730440220346c877b9e983a20fbc52fca2132717e2647bba5489e4127e8032c263252c7d502202cea58c807f60c4b6e10262a2eb59daa8bf637465a4ad67db83c59768088bfb70121020dd5d5044667ebd71f0917a6182435afeb408b4142932c9f5d852b5623ca5d6800000000" + sphinxHex = "00035a24206be286645b5e2f81fe6d35bf26ceb70b15257f19e3b744c0ed855c3d8e60c5a8d7553d0a39dd162a50df5169f18129a737da3427095e1049c356e02bb71d9c70858bbf3936fb555c283d9015f4b85d629a24e84c61dc69d537545e4c0104a87a9ab6277083cf7cb21a56f10ed23e754adf357a638970fbcd38e985f42c44b69f1cfaac8dbf711a5b8edf56383d56ad4cafb297025fad5f9c3e79dad7d1342cabde86ea85950bb80237d95b676939461aed0447b88d0010023d653abf498780f7d8f9a1e5784638c893caefe95e23a85285b636ce2af87613c275e61da65255dba4f4bacde8d6efd1c29a4f8e3efb98a3881e280b8613d45dbe38b7b895621850322be927a6beb6aa183c9a11dbf29da8a3d2f6b0b6b8c7e1c62d4926f3dc1e06ead192daba315fbcd5edd2a7d08bcfa50d1b2cc799e98b3415202e7cd91ff54962e4e1d5716c339718ebea926db6e24aacf35ebc362aafcfde4d6264d56a6edf430ae4ade75cdf9c121c3708211407d5c7ad23e2bc8dfe0d71e588b01d2ada797830315ba616f6c79030481dde1d8aa1a37676aaa9a48aec1dcc535daf2547cff2d43c58acd7e09473c46cada1112e82b0502d057ce6a8a629836ea293be93c5d228169c46d0643378e20429ba09e0a236f8dc56a23e9d38509a72e3bb115dc7c959af913b7d561d17fd3df8d490e2d91c4ae16429a3ecfa45212dbd6ccd80d4ac5956ade21c46b4960c08570e0af69ee39d1c23b194f20bc4d5d5cb5ec0b1e3a376a51700d166dce2b09a6e0c2285af7d36c4d0178a1acad0bfd8d913c44506987df6406ad7f134927c5d46be261cc4025f6310e8cb8284f03ae75a75d4ca43ba1d578b1be69d76503370f95eb98a769eaf1e7c3032907f9ab50c450b7177a804b2e9cb8c5a6fe5bbeb07d0ad176961f817119989f090ad162cae302242651c0c69e7b52c36665be795538afc3aef77a1cd0a36a170d572f56eb79e07ee6544da446798b5e0a0ed92cd205288824335b0444e5eda4347d28be9b2a128d50f983c5b16ea2792eda5d352b609a08a15268e758e024dbde13ae42008c03c608c6bb1971c9eb7a1842129b056b9c0690a88c1aea43f9bc20d8e132575a1eae77ba2fe24ee780f42e6b73b7022049518f1c231fd4fe3e91ca443980e52507ebc97f8fc49036c6e141c0e74603ac02814aa0928381228f7aadd798dbaac3603099e94224dd0b51466d392d276f19b990e8b351b73d3e284fc24f1c1ee0bffad64d9415ae8ab358b01dbc7eedfadb181ab8080d0f9c151c445419ee670e8376a112a631c5ec4092aca077086299e406ab304c7f864da801147b0e09b9f8873c85e952550b62684ca9217d1c0763930b019871787b07cabe0ef8b541c2375bc7ceb4ec1153e6f8a48371f7f80c69dcd37a7d53053fde41f87231abdbed68f195ddc6082b9d0e55207fea2c4d0c8045d44bfe55de2fb71ed75f12c0105a2ea480678b73100a943c45b6d2d5556ece82f02bd12d8785f38ac96bba1167b27b40dbd4d1677cf0b96f9311382cc110f739ffba634fa5163c9e1bd6f0279356aeab301eca2398525ec136d9d3aab4634a1fe14b365c8ad4f98e217cdba327945dfe69f3a3b7f8cd932bd761b2b264a371b104559d0a69c8e7ac053512f3e7fd2cf64278f33f7288958042b3166ff0f05e174378c80ab8d01332e862f17e8cd5c74b3de9acb51e9526d8d3305fa51b447fcb289b26c96ff06e6d50a639514491077c9f70757b74c7e26800688a05274fdcf2697e69455742126dc0df95512e478417db81440a5d2f8c671df00d0bafca53a8ed4bf6c8ea0a4c8af39c7bb1103b828547b669b75f780d9d78ddd811dd1a639576b19805088c33e6e7855cc360827516f4de8f4788bf81feb45d6d31ba5277bca2c86d84f8a" //nolint:lll + htlcTxHex = "020000000001042d54f0cbba265e7f2a0873ce9e03879b706b361d3ee18e8d4a29300c0948bee50000000017160014a411cc351bd7c5572a6ba5ba16e3f3f92106425bfeffffff330565534d05b1d0d4b8a1878f1a5c41d3f571f793c528a44d72af276a259bbd000000001716001443891b3727c96d1e1e91aec69b167123a429ed32feffffff4bb7cba13999b98510769270d161f2a8e0f80f1033c72579a78c105ab2b30ebf00000000171600141c9e61f3951956b00c6f215e75e7fb9a5d93988ffefffffffaa153f428dc6c0aa0be74fa56d6563556b4286d5f9b8a431d5acf0b08f00240000000001716001488ef3cc57c509e843387a7c74e55fdc27fe962f2feffffff0178b4000000000000220020b476030d986b5a4bb084702eec3d895a40d02cfef216f09962cac94a8bd119a102473044022059247039c8e3e95d2eb289e35f81b67e5811d5a757b2117cc9dab1cebf830b2e02204d15406ea075d3d4973344b9bf7c151bb39a7e2876551b8500057dce4591fb870121033d8377beee8caf5fccd958bfacfdd3b5dc1948dfad9fb09768d11e69abb76f3402473044022042e242e0c3adca4f8b3a19cbd8eefd80bf8a43debbe51c5759b2f607b7332bc6022020be1288b8dcddfdc1569c766490e8c0ea668ea1bc122d40b59cbe6589e6938701210243b39e7a2d42937b5aecf14d27b200f7486397d92cbb166ae9f3c11259caf9c602473044022024b16f6042b9a664a46ab0c8cfc3424f8a100309f4cb51e9969a62a257255e2b022053f09e518d2ba73f74d7f3feca98df2b38eb45870325a433457277caf1ce7e6a012103f0386dfb783fc1b55d50aa089326dc008fe2fa963b44d3851bc307bbc419d1c4024730440220346c877b9e983a20fbc52fca2132717e2647bba5489e4127e8032c263252c7d502202cea58c807f60c4b6e10262a2eb59daa8bf637465a4ad67db83c59768088bfb70121020dd5d5044667ebd71f0917a6182435afeb408b4142932c9f5d852b5623ca5d6800000000" //nolint:lll preimageHex = "D7EA6B6FE58119AA061CBA3A3C1B556DE966053EE0B8A455A2FA5BA6EAE978FA" paymentSecretHex = "E06E5076678201F6B1324421315E16B093D7E24CC6F3D76F5A900D6D5DB6313A" - shortChanId = uint64(17665301721646554283) + shortChanId = uint64( //nolint:staticcheck // TODO: const shortChanId should be shortChanID + 17665301721646554283, + ) - encodedMuunKey = "tpubDBZaivUL3Hv8r25JDupShPuWVkGcwM7NgbMBwkhQLfWu18iBbyQCbRdyg1wRMjoWdZN7Afg3F25zs4c8E6Q4VJrGqAw51DJeqacTFABV9u8" - encodedUserKey = "tprv8deMke4d4jbc5wVYMaDpoqsXYuEPvwLPN43iRRwdZqVJCr9Wc9xh5194mMJeLTkLfQHS5CgkuXbZ9uwK9Eogcx2t7JoscYtrFirGsc3kgCr" + encodedMuunKey = "tpubDBZaivUL3Hv8r25JDupShPuWVkGcwM7NgbMBwkhQLfWu18iBbyQCbRdyg1wRMjoWdZN7Afg3F25zs4c8E6Q4VJrGqAw51DJeqacTFABV9u8" //nolint:lll + encodedUserKey = "tprv8deMke4d4jbc5wVYMaDpoqsXYuEPvwLPN43iRRwdZqVJCr9Wc9xh5194mMJeLTkLfQHS5CgkuXbZ9uwK9Eogcx2t7JoscYtrFirGsc3kgCr" //nolint:lll - muunSigHex = "3045022100c4bef5d32c5ed3530cd258df645dfb0298744dee7820095aca1a188a3b2138c102201669e21db8ee4d2b090cbb18e3e52bce40fc5e07be73c1be4d26c9f13c02e69701" + muunSigHex = "3045022100c4bef5d32c5ed3530cd258df645dfb0298744dee7820095aca1a188a3b2138c102201669e21db8ee4d2b090cbb18e3e52bce40fc5e07be73c1be4d26c9f13c02e69701" //nolint:lll ) txOut, _ := hex.DecodeString(hexTxOut) @@ -736,14 +1599,14 @@ func TestPartiallySignedTransaction_SignIncomingSwap(t *testing.T) { inputs := []Input{ &input{ - outpoint: outpoint{index: txIndex, amount: txAmount, txId: txOut}, + outpoint: outpoint{index: txIndex, amount: txAmount, txID: txOut}, address: addresses.New(addresses.IncomingSwap, txAddressPath, txAddress), muunSignature: muunSig, incomingSwap: inputIncomingSwap{ sphinx: sphinx, htlcTx: htlcTx, paymentHash: paymentHash, - swapServerPublicKey: "03912b4cfbd725133cbc319b444c1dad96a8bb0fcf840adc28c8e05e84ecbaa89b", + swapServerPublicKey: "03912b4cfbd725133cbc319b444c1dad96a8bb0fcf840adc28c8e05e84ecbaa89b", //nolint:lll expirationHeight: 5528, collectInSats: 0, }, @@ -755,16 +1618,18 @@ func TestPartiallySignedTransaction_SignIncomingSwap(t *testing.T) { setup() - db, _ := openDB() - db.CreateInvoice(&walletdb.Invoice{ - Preimage: preimage, - PaymentHash: paymentHash, - PaymentSecret: paymentSecret, - KeyPath: "m/schema:1'/recovery:1'/invoices:4/1189547938/512484821", - ShortChanId: shortChanId, - AmountSat: txAmount, - State: walletdb.InvoiceStateUsed, + err := Pool.WithDB(func(db *walletdb.DB) error { + return db.CreateInvoice(&walletdb.Invoice{ + Preimage: preimage, + PaymentHash: paymentHash, + PaymentSecret: paymentSecret, + KeyPath: "m/schema:1'/recovery:1'/invoices:4/1189547938/512484821", + ShortChanId: shortChanId, + AmountSat: txAmount, + State: walletdb.InvoiceStateUsed, + }) }) + require.NoError(t, err) nonces := GenerateMusigNonces(len(inputList.inputs)) partial, _ := NewPartiallySignedTransaction(inputList, rawTx, nonces) @@ -778,12 +1643,12 @@ func TestPartiallySignedTransaction_SignIncomingSwap(t *testing.T) { signedTx := wire.NewMsgTx(0) signedTx.Deserialize(bytes.NewReader(signedRawTx.Bytes)) - verifyInput(t, signedTx, htlcTxHex, txIndex, 0) + mustVerifyInput(t, signedTx, htlcTxHex, txIndex, 0) } func TestPartiallySignedTransaction_SignIncomingSwapCollaboratively(t *testing.T) { const ( - hexTx = "0100000001e3d55a5423fd70679839f47ed496d61bd4d0964acfa556172c945041eddf3d400000000000ffffffff02f875000000000000220020f411b28870bf089c41f703dbc1a428d60eb7cce61a9d4fa4a5c28ead872d8551963d000000000000220020eee7f6df991fac39aa2fd8054c83ef045c9569507fe4a224c8320162c028267600000000" + hexTx = "0100000001e3d55a5423fd70679839f47ed496d61bd4d0964acfa556172c945041eddf3d400000000000ffffffff02f875000000000000220020f411b28870bf089c41f703dbc1a428d60eb7cce61a9d4fa4a5c28ead872d8551963d000000000000220020eee7f6df991fac39aa2fd8054c83ef045c9569507fe4a224c8320162c028267600000000" //nolint:lll txIndex = 0 txAmount = 46200 @@ -791,15 +1656,15 @@ func TestPartiallySignedTransaction_SignIncomingSwapCollaboratively(t *testing.T txAddressPath = "m/schema:1'/recovery:1'/invoices:4/1189547938/512484821/1" txAddress = "bcrt1qk3mqxrvcdddyhvyywqhwc0vftfqdqt877gt0pxtzety54z73rxsse9hyt9" paymentHashHex = "b0e74c22943fd1e2ee86b14fb6f6636c19649910705913f5bfc33014e0ca0fd4" - sphinxHex = "00035a24206be286645b5e2f81fe6d35bf26ceb70b15257f19e3b744c0ed855c3d8e60c5a8d7553d0a39dd162a50df5169f18129a737da3427095e1049c356e02bb71d9c70858bbf3936fb555c283d9015f4b85d629a24e84c61dc69d537545e4c0104a87a9ab6277083cf7cb21a56f10ed23e754adf357a638970fbcd38e985f42c44b69f1cfaac8dbf711a5b8edf56383d56ad4cafb297025fad5f9c3e79dad7d1342cabde86ea85950bb80237d95b676939461aed0447b88d0010023d653abf498780f7d8f9a1e5784638c893caefe95e23a85285b636ce2af87613c275e61da65255dba4f4bacde8d6efd1c29a4f8e3efb98a3881e280b8613d45dbe38b7b895621850322be927a6beb6aa183c9a11dbf29da8a3d2f6b0b6b8c7e1c62d4926f3dc1e06ead192daba315fbcd5edd2a7d08bcfa50d1b2cc799e98b3415202e7cd91ff54962e4e1d5716c339718ebea926db6e24aacf35ebc362aafcfde4d6264d56a6edf430ae4ade75cdf9c121c3708211407d5c7ad23e2bc8dfe0d71e588b01d2ada797830315ba616f6c79030481dde1d8aa1a37676aaa9a48aec1dcc535daf2547cff2d43c58acd7e09473c46cada1112e82b0502d057ce6a8a629836ea293be93c5d228169c46d0643378e20429ba09e0a236f8dc56a23e9d38509a72e3bb115dc7c959af913b7d561d17fd3df8d490e2d91c4ae16429a3ecfa45212dbd6ccd80d4ac5956ade21c46b4960c08570e0af69ee39d1c23b194f20bc4d5d5cb5ec0b1e3a376a51700d166dce2b09a6e0c2285af7d36c4d0178a1acad0bfd8d913c44506987df6406ad7f134927c5d46be261cc4025f6310e8cb8284f03ae75a75d4ca43ba1d578b1be69d76503370f95eb98a769eaf1e7c3032907f9ab50c450b7177a804b2e9cb8c5a6fe5bbeb07d0ad176961f817119989f090ad162cae302242651c0c69e7b52c36665be795538afc3aef77a1cd0a36a170d572f56eb79e07ee6544da446798b5e0a0ed92cd205288824335b0444e5eda4347d28be9b2a128d50f983c5b16ea2792eda5d352b609a08a15268e758e024dbde13ae42008c03c608c6bb1971c9eb7a1842129b056b9c0690a88c1aea43f9bc20d8e132575a1eae77ba2fe24ee780f42e6b73b7022049518f1c231fd4fe3e91ca443980e52507ebc97f8fc49036c6e141c0e74603ac02814aa0928381228f7aadd798dbaac3603099e94224dd0b51466d392d276f19b990e8b351b73d3e284fc24f1c1ee0bffad64d9415ae8ab358b01dbc7eedfadb181ab8080d0f9c151c445419ee670e8376a112a631c5ec4092aca077086299e406ab304c7f864da801147b0e09b9f8873c85e952550b62684ca9217d1c0763930b019871787b07cabe0ef8b541c2375bc7ceb4ec1153e6f8a48371f7f80c69dcd37a7d53053fde41f87231abdbed68f195ddc6082b9d0e55207fea2c4d0c8045d44bfe55de2fb71ed75f12c0105a2ea480678b73100a943c45b6d2d5556ece82f02bd12d8785f38ac96bba1167b27b40dbd4d1677cf0b96f9311382cc110f739ffba634fa5163c9e1bd6f0279356aeab301eca2398525ec136d9d3aab4634a1fe14b365c8ad4f98e217cdba327945dfe69f3a3b7f8cd932bd761b2b264a371b104559d0a69c8e7ac053512f3e7fd2cf64278f33f7288958042b3166ff0f05e174378c80ab8d01332e862f17e8cd5c74b3de9acb51e9526d8d3305fa51b447fcb289b26c96ff06e6d50a639514491077c9f70757b74c7e26800688a05274fdcf2697e69455742126dc0df95512e478417db81440a5d2f8c671df00d0bafca53a8ed4bf6c8ea0a4c8af39c7bb1103b828547b669b75f780d9d78ddd811dd1a639576b19805088c33e6e7855cc360827516f4de8f4788bf81feb45d6d31ba5277bca2c86d84f8a" - htlcTxHex = "020000000001042d54f0cbba265e7f2a0873ce9e03879b706b361d3ee18e8d4a29300c0948bee50000000017160014a411cc351bd7c5572a6ba5ba16e3f3f92106425bfeffffff330565534d05b1d0d4b8a1878f1a5c41d3f571f793c528a44d72af276a259bbd000000001716001443891b3727c96d1e1e91aec69b167123a429ed32feffffff4bb7cba13999b98510769270d161f2a8e0f80f1033c72579a78c105ab2b30ebf00000000171600141c9e61f3951956b00c6f215e75e7fb9a5d93988ffefffffffaa153f428dc6c0aa0be74fa56d6563556b4286d5f9b8a431d5acf0b08f00240000000001716001488ef3cc57c509e843387a7c74e55fdc27fe962f2feffffff0178b4000000000000220020b476030d986b5a4bb084702eec3d895a40d02cfef216f09962cac94a8bd119a102473044022059247039c8e3e95d2eb289e35f81b67e5811d5a757b2117cc9dab1cebf830b2e02204d15406ea075d3d4973344b9bf7c151bb39a7e2876551b8500057dce4591fb870121033d8377beee8caf5fccd958bfacfdd3b5dc1948dfad9fb09768d11e69abb76f3402473044022042e242e0c3adca4f8b3a19cbd8eefd80bf8a43debbe51c5759b2f607b7332bc6022020be1288b8dcddfdc1569c766490e8c0ea668ea1bc122d40b59cbe6589e6938701210243b39e7a2d42937b5aecf14d27b200f7486397d92cbb166ae9f3c11259caf9c602473044022024b16f6042b9a664a46ab0c8cfc3424f8a100309f4cb51e9969a62a257255e2b022053f09e518d2ba73f74d7f3feca98df2b38eb45870325a433457277caf1ce7e6a012103f0386dfb783fc1b55d50aa089326dc008fe2fa963b44d3851bc307bbc419d1c4024730440220346c877b9e983a20fbc52fca2132717e2647bba5489e4127e8032c263252c7d502202cea58c807f60c4b6e10262a2eb59daa8bf637465a4ad67db83c59768088bfb70121020dd5d5044667ebd71f0917a6182435afeb408b4142932c9f5d852b5623ca5d6800000000" + sphinxHex = "00035a24206be286645b5e2f81fe6d35bf26ceb70b15257f19e3b744c0ed855c3d8e60c5a8d7553d0a39dd162a50df5169f18129a737da3427095e1049c356e02bb71d9c70858bbf3936fb555c283d9015f4b85d629a24e84c61dc69d537545e4c0104a87a9ab6277083cf7cb21a56f10ed23e754adf357a638970fbcd38e985f42c44b69f1cfaac8dbf711a5b8edf56383d56ad4cafb297025fad5f9c3e79dad7d1342cabde86ea85950bb80237d95b676939461aed0447b88d0010023d653abf498780f7d8f9a1e5784638c893caefe95e23a85285b636ce2af87613c275e61da65255dba4f4bacde8d6efd1c29a4f8e3efb98a3881e280b8613d45dbe38b7b895621850322be927a6beb6aa183c9a11dbf29da8a3d2f6b0b6b8c7e1c62d4926f3dc1e06ead192daba315fbcd5edd2a7d08bcfa50d1b2cc799e98b3415202e7cd91ff54962e4e1d5716c339718ebea926db6e24aacf35ebc362aafcfde4d6264d56a6edf430ae4ade75cdf9c121c3708211407d5c7ad23e2bc8dfe0d71e588b01d2ada797830315ba616f6c79030481dde1d8aa1a37676aaa9a48aec1dcc535daf2547cff2d43c58acd7e09473c46cada1112e82b0502d057ce6a8a629836ea293be93c5d228169c46d0643378e20429ba09e0a236f8dc56a23e9d38509a72e3bb115dc7c959af913b7d561d17fd3df8d490e2d91c4ae16429a3ecfa45212dbd6ccd80d4ac5956ade21c46b4960c08570e0af69ee39d1c23b194f20bc4d5d5cb5ec0b1e3a376a51700d166dce2b09a6e0c2285af7d36c4d0178a1acad0bfd8d913c44506987df6406ad7f134927c5d46be261cc4025f6310e8cb8284f03ae75a75d4ca43ba1d578b1be69d76503370f95eb98a769eaf1e7c3032907f9ab50c450b7177a804b2e9cb8c5a6fe5bbeb07d0ad176961f817119989f090ad162cae302242651c0c69e7b52c36665be795538afc3aef77a1cd0a36a170d572f56eb79e07ee6544da446798b5e0a0ed92cd205288824335b0444e5eda4347d28be9b2a128d50f983c5b16ea2792eda5d352b609a08a15268e758e024dbde13ae42008c03c608c6bb1971c9eb7a1842129b056b9c0690a88c1aea43f9bc20d8e132575a1eae77ba2fe24ee780f42e6b73b7022049518f1c231fd4fe3e91ca443980e52507ebc97f8fc49036c6e141c0e74603ac02814aa0928381228f7aadd798dbaac3603099e94224dd0b51466d392d276f19b990e8b351b73d3e284fc24f1c1ee0bffad64d9415ae8ab358b01dbc7eedfadb181ab8080d0f9c151c445419ee670e8376a112a631c5ec4092aca077086299e406ab304c7f864da801147b0e09b9f8873c85e952550b62684ca9217d1c0763930b019871787b07cabe0ef8b541c2375bc7ceb4ec1153e6f8a48371f7f80c69dcd37a7d53053fde41f87231abdbed68f195ddc6082b9d0e55207fea2c4d0c8045d44bfe55de2fb71ed75f12c0105a2ea480678b73100a943c45b6d2d5556ece82f02bd12d8785f38ac96bba1167b27b40dbd4d1677cf0b96f9311382cc110f739ffba634fa5163c9e1bd6f0279356aeab301eca2398525ec136d9d3aab4634a1fe14b365c8ad4f98e217cdba327945dfe69f3a3b7f8cd932bd761b2b264a371b104559d0a69c8e7ac053512f3e7fd2cf64278f33f7288958042b3166ff0f05e174378c80ab8d01332e862f17e8cd5c74b3de9acb51e9526d8d3305fa51b447fcb289b26c96ff06e6d50a639514491077c9f70757b74c7e26800688a05274fdcf2697e69455742126dc0df95512e478417db81440a5d2f8c671df00d0bafca53a8ed4bf6c8ea0a4c8af39c7bb1103b828547b669b75f780d9d78ddd811dd1a639576b19805088c33e6e7855cc360827516f4de8f4788bf81feb45d6d31ba5277bca2c86d84f8a" //nolint:lll + htlcTxHex = "020000000001042d54f0cbba265e7f2a0873ce9e03879b706b361d3ee18e8d4a29300c0948bee50000000017160014a411cc351bd7c5572a6ba5ba16e3f3f92106425bfeffffff330565534d05b1d0d4b8a1878f1a5c41d3f571f793c528a44d72af276a259bbd000000001716001443891b3727c96d1e1e91aec69b167123a429ed32feffffff4bb7cba13999b98510769270d161f2a8e0f80f1033c72579a78c105ab2b30ebf00000000171600141c9e61f3951956b00c6f215e75e7fb9a5d93988ffefffffffaa153f428dc6c0aa0be74fa56d6563556b4286d5f9b8a431d5acf0b08f00240000000001716001488ef3cc57c509e843387a7c74e55fdc27fe962f2feffffff0178b4000000000000220020b476030d986b5a4bb084702eec3d895a40d02cfef216f09962cac94a8bd119a102473044022059247039c8e3e95d2eb289e35f81b67e5811d5a757b2117cc9dab1cebf830b2e02204d15406ea075d3d4973344b9bf7c151bb39a7e2876551b8500057dce4591fb870121033d8377beee8caf5fccd958bfacfdd3b5dc1948dfad9fb09768d11e69abb76f3402473044022042e242e0c3adca4f8b3a19cbd8eefd80bf8a43debbe51c5759b2f607b7332bc6022020be1288b8dcddfdc1569c766490e8c0ea668ea1bc122d40b59cbe6589e6938701210243b39e7a2d42937b5aecf14d27b200f7486397d92cbb166ae9f3c11259caf9c602473044022024b16f6042b9a664a46ab0c8cfc3424f8a100309f4cb51e9969a62a257255e2b022053f09e518d2ba73f74d7f3feca98df2b38eb45870325a433457277caf1ce7e6a012103f0386dfb783fc1b55d50aa089326dc008fe2fa963b44d3851bc307bbc419d1c4024730440220346c877b9e983a20fbc52fca2132717e2647bba5489e4127e8032c263252c7d502202cea58c807f60c4b6e10262a2eb59daa8bf637465a4ad67db83c59768088bfb70121020dd5d5044667ebd71f0917a6182435afeb408b4142932c9f5d852b5623ca5d6800000000" //nolint:lll preimageHex = "D7EA6B6FE58119AA061CBA3A3C1B556DE966053EE0B8A455A2FA5BA6EAE978FA" - encodedMuunKey = "tpubDBZaivUL3Hv8r25JDupShPuWVkGcwM7NgbMBwkhQLfWu18iBbyQCbRdyg1wRMjoWdZN7Afg3F25zs4c8E6Q4VJrGqAw51DJeqacTFABV9u8" - encodedUserKey = "tprv8deMke4d4jbc5wVYMaDpoqsXYuEPvwLPN43iRRwdZqVJCr9Wc9xh5194mMJeLTkLfQHS5CgkuXbZ9uwK9Eogcx2t7JoscYtrFirGsc3kgCr" + encodedMuunKey = "tpubDBZaivUL3Hv8r25JDupShPuWVkGcwM7NgbMBwkhQLfWu18iBbyQCbRdyg1wRMjoWdZN7Afg3F25zs4c8E6Q4VJrGqAw51DJeqacTFABV9u8" //nolint:lll + encodedUserKey = "tprv8deMke4d4jbc5wVYMaDpoqsXYuEPvwLPN43iRRwdZqVJCr9Wc9xh5194mMJeLTkLfQHS5CgkuXbZ9uwK9Eogcx2t7JoscYtrFirGsc3kgCr" //nolint:lll - muunSigHex = "3045022100c4bef5d32c5ed3530cd258df645dfb0298744dee7820095aca1a188a3b2138c102201669e21db8ee4d2b090cbb18e3e52bce40fc5e07be73c1be4d26c9f13c02e69701" + muunSigHex = "3045022100c4bef5d32c5ed3530cd258df645dfb0298744dee7820095aca1a188a3b2138c102201669e21db8ee4d2b090cbb18e3e52bce40fc5e07be73c1be4d26c9f13c02e69701" //nolint:lll ) txOut, _ := hex.DecodeString(hexTxOut) @@ -817,19 +1682,20 @@ func TestPartiallySignedTransaction_SignIncomingSwapCollaboratively(t *testing.T inputs := []Input{ &input{ - outpoint: outpoint{index: txIndex, amount: txAmount, txId: txOut}, + outpoint: outpoint{index: txIndex, amount: txAmount, txID: txOut}, address: addresses.New(addresses.IncomingSwap, txAddressPath, txAddress), muunSignature: muunSig, incomingSwap: inputIncomingSwap{ sphinx: sphinx, htlcTx: htlcTx, paymentHash: paymentHash, - swapServerPublicKey: "03912b4cfbd725133cbc319b444c1dad96a8bb0fcf840adc28c8e05e84ecbaa89b", + swapServerPublicKey: "03912b4cfbd725133cbc319b444c1dad96a8bb0fcf840adc28c8e05e84ecbaa89b", //nolint:lll expirationHeight: 5528, collectInSats: 0, preimage: preimage, htlcOutputKeyPath: "m/schema:1'/recovery:1'/invoices:4/1189547938/512484821/1", - // Instead of taking these last 2 values from the local/client's DB we take them from Houston's data + // Instead of taking these last 2 values from the local/client's DB we take them + // from Houston's data }, }, } @@ -851,24 +1717,24 @@ func TestPartiallySignedTransaction_SignIncomingSwapCollaboratively(t *testing.T signedTx := wire.NewMsgTx(0) signedTx.Deserialize(bytes.NewReader(signedRawTx.Bytes)) - verifyInput(t, signedTx, htlcTxHex, txIndex, 0) + mustVerifyInput(t, signedTx, htlcTxHex, txIndex, 0) } func TestPartiallySignedTransaction_Verify(t *testing.T) { const ( - hexTx1 = "0100000002a51cc04ab631dee48c989a7cd55c4abc451aa958b09d4579cc9852c52baa57ae0100000000ffffffffdf39591fa749826f87a3d7e5fd5f0468d338c3d81dd3b2c953534b0210f98c560000000000ffffffff02a8d6c20400000000220020452f4ae303ec79acd2bce8f7ddb6469f1060d9146003ea34887e5bbdf021c787000e2707000000002200202ccf0ca2c9b5077ce8345785af26a39277003886fb358877e4083a3fcc5cd66700000000" + hexTx1 = "0100000002a51cc04ab631dee48c989a7cd55c4abc451aa958b09d4579cc9852c52baa57ae0100000000ffffffffdf39591fa749826f87a3d7e5fd5f0468d338c3d81dd3b2c953534b0210f98c560000000000ffffffff02a8d6c20400000000220020452f4ae303ec79acd2bce8f7ddb6469f1060d9146003ea34887e5bbdf021c787000e2707000000002200202ccf0ca2c9b5077ce8345785af26a39277003886fb358877e4083a3fcc5cd66700000000" //nolint:lll txIndex1 = 1 txAmount1 = 100000000 - txIdHex1 = "ae57aa2bc55298cc79459db058a91a45bc4a5cd57c9a988ce4de31b64ac01ca5" + txIDHex1 = "ae57aa2bc55298cc79459db058a91a45bc4a5cd57c9a988ce4de31b64ac01ca5" txAddressPath1 = "m/schema:1'/recovery:1'/external:1/0" txAddress1 = "bcrt1q9n8segkfk5rhe6p527z67f4rjfmsqwyxlv6csalypqarlnzu6ens8cm8ye" txAddressVersion1 = addresses.V4 txIndex2 = 0 txAmount2 = 100000000 - txIdHex2 = "568cf910024b5353c9b2d31dd8c338d368045ffde5d7a3876f8249a71f5939df" + txIDHex2 = "568cf910024b5353c9b2d31dd8c338d368045ffde5d7a3876f8249a71f5939df" txAddressPath2 = "m/schema:1'/recovery:1'/external:1/0" txAddress2 = "bcrt1q9n8segkfk5rhe6p527z67f4rjfmsqwyxlv6csalypqarlnzu6ens8cm8ye" txAddressVersion2 = addresses.V4 @@ -877,26 +1743,26 @@ func TestPartiallySignedTransaction_Verify(t *testing.T) { changePath1 = "m/schema:1'/recovery:1'/change:0/1" changeVersion1 = addresses.V4 - hexTx2 = "01000000010ead2fa0d6866d0414aba97fd8f1b242fdc3d4c8e7771e40969402319b6e876b0000000000ffffffff02922988040000000017a914d1ac5d61107d2bef187d1aef5cfd3536f4fd5dbe87d6b2050100000000220020bac6de765432ee16e10ce268341062f8f5a417b15a7f6ee8fe903e6d7470f0f700000000" + hexTx2 = "01000000010ead2fa0d6866d0414aba97fd8f1b242fdc3d4c8e7771e40969402319b6e876b0000000000ffffffff02922988040000000017a914d1ac5d61107d2bef187d1aef5cfd3536f4fd5dbe87d6b2050100000000220020bac6de765432ee16e10ce268341062f8f5a417b15a7f6ee8fe903e6d7470f0f700000000" //nolint:lll txIndex3 = 0 txAmount3 = 93266680 - txIdHex3 = "6b876e9b31029496401e77e7c8d4c3fd42b2f1d87fa9ab14046d86d6a02fad0e" + txIDHex3 = "6b876e9b31029496401e77e7c8d4c3fd42b2f1d87fa9ab14046d86d6a02fad0e" txAddressPath3 = "m/schema:1'/recovery:1'/change:0/8" txAddress3 = "bcrt1q9yzsghvmmn7wv3esylrvn3c469s4ce4thk7qmxdly4tzk4f8vvjsqv0crh" txAddressVersion3 = addresses.V4 - hexTx4 = "0100000002a51cc04ab631dee48c989a7cd55c4abc451aa958b09d4579cc9852c52baa57ae0100000000ffffffffdf39591fa749826f87a3d7e5fd5f0468d338c3d81dd3b2c953534b0210f98c560000000000ffffffff01000e2707000000002200202ccf0ca2c9b5077ce8345785af26a39277003886fb358877e4083a3fcc5cd66700000000" + hexTx4 = "0100000002a51cc04ab631dee48c989a7cd55c4abc451aa958b09d4579cc9852c52baa57ae0100000000ffffffffdf39591fa749826f87a3d7e5fd5f0468d338c3d81dd3b2c953534b0210f98c560000000000ffffffff01000e2707000000002200202ccf0ca2c9b5077ce8345785af26a39277003886fb358877e4083a3fcc5cd66700000000" //nolint:lll changeAddress2 = "bcrt1qg5h54ccra3u6e54uarmamdjxnugxpkg5vqp75dyg0edmmuppc7rsdfcvcp" changePath2 = "m/schema:1'/recovery:1'/change:0/1" changeVersion2 = addresses.V4 - hexTx5 = "0100000001a51cc04ab631dee48c989a7cd55c4abc451aa958b09d4579cc9852c52baa57ae0100000000ffffffff02a086010000000000220020452f4ae303ec79acd2bce8f7ddb6469f1060d9146003ea34887e5bbdf021c787302dfa02000000002200202ccf0ca2c9b5077ce8345785af26a39277003886fb358877e4083a3fcc5cd66700000000" + hexTx5 = "0100000001a51cc04ab631dee48c989a7cd55c4abc451aa958b09d4579cc9852c52baa57ae0100000000ffffffff02a086010000000000220020452f4ae303ec79acd2bce8f7ddb6469f1060d9146003ea34887e5bbdf021c787302dfa02000000002200202ccf0ca2c9b5077ce8345785af26a39277003886fb358877e4083a3fcc5cd66700000000" //nolint:lll txIndex5 = 1 txAmount5 = 100000000 - txIdHex5 = "907e3c0c82b36b11b8543c9e058fe6e23d5ad35881f776e1ca9049e622f2cf80" + txIDHex5 = "907e3c0c82b36b11b8543c9e058fe6e23d5ad35881f776e1ca9049e622f2cf80" txAddressPath5 = "m/schema:1'/recovery:1'/external:1/0" txAddress5 = "bcrt1q9n8segkfk5rhe6p527z67f4rjfmsqwyxlv6csalypqarlnzu6ens8cm8ye" txAddressVersion5 = addresses.V4 @@ -905,11 +1771,11 @@ func TestPartiallySignedTransaction_Verify(t *testing.T) { changePath5 = "m/schema:1'/recovery:1'/change:0/1" changeVersion5 = addresses.V4 - hexTx6 = "0100000001a51cc04ab631dee48c989a7cd55c4abc451aa958b09d4579cc9852c52baa57ae0100000000ffffffff02a086010000000000220020452f4ae303ec79acd2bce8f7ddb6469f1060d9146003ea34887e5bbdf021c787e8030000000000002200202ccf0ca2c9b5077ce8345785af26a39277003886fb358877e4083a3fcc5cd66700000000" + hexTx6 = "0100000001a51cc04ab631dee48c989a7cd55c4abc451aa958b09d4579cc9852c52baa57ae0100000000ffffffff02a086010000000000220020452f4ae303ec79acd2bce8f7ddb6469f1060d9146003ea34887e5bbdf021c787e8030000000000002200202ccf0ca2c9b5077ce8345785af26a39277003886fb358877e4083a3fcc5cd66700000000" //nolint:lll txIndex6 = 1 txAmount6 = 100000000 - txIdHex6 = "69391b987ec374f8e61a5fabb94899cd5efe802ee0f4d890bbbdbd18b05cac0f" + txIDHex6 = "69391b987ec374f8e61a5fabb94899cd5efe802ee0f4d890bbbdbd18b05cac0f" txAddressPath6 = "m/schema:1'/recovery:1'/external:1/0" txAddress6 = "bcrt1q9n8segkfk5rhe6p527z67f4rjfmsqwyxlv6csalypqarlnzu6ens8cm8ye" txAddressVersion6 = addresses.V4 @@ -918,11 +1784,11 @@ func TestPartiallySignedTransaction_Verify(t *testing.T) { changePath6 = "m/schema:1'/recovery:1'/change:0/1" changeVersion6 = addresses.V4 - hexTx7 = "0100000001a51cc04ab631dee48c989a7cd55c4abc451aa958b09d4579cc9852c52baa57ae0100000000ffffffff01a086010000000000220020452f4ae303ec79acd2bce8f7ddb6469f1060d9146003ea34887e5bbdf021c78700000000" + hexTx7 = "0100000001a51cc04ab631dee48c989a7cd55c4abc451aa958b09d4579cc9852c52baa57ae0100000000ffffffff01a086010000000000220020452f4ae303ec79acd2bce8f7ddb6469f1060d9146003ea34887e5bbdf021c78700000000" //nolint:lll txIndex7 = 1 txAmount7 = 100000000 - txIdHex7 = "383136ae84ddd35059a087fb56571f4809d97f09c04eac779ba31f6121818461" + txIDHex7 = "383136ae84ddd35059a087fb56571f4809d97f09c04eac779ba31f6121818461" txAddressPath7 = "m/schema:1'/recovery:1'/external:1/0" txAddress7 = "bcrt1q9n8segkfk5rhe6p527z67f4rjfmsqwyxlv6csalypqarlnzu6ens8cm8ye" txAddressVersion7 = addresses.V4 @@ -931,18 +1797,18 @@ func TestPartiallySignedTransaction_Verify(t *testing.T) { changePath7 = "m/schema:1'/recovery:1'/change:0/1" changeVersion7 = addresses.V4 - encodedUserKey = "tpubDAKxNPypXDF3GNCpXFUh6sCdxz7DY9eKMgFxYBgyRSiYWXrBLgdtkPuMbQQzrsYLVyPPSHmNcduLRRd9TSMaYrGLryp8KNkkYBm6eka1Bem" - encodedMuunKey = "tpubDBZaivUL3Hv8r25JDupShPuWVkGcwM7NgbMBwkhQLfWu18iBbyQCbRdyg1wRMjoWdZN7Afg3F25zs4c8E6Q4VJrGqAw51DJeqacTFABV9u8" + encodedUserKey = "tpubDAKxNPypXDF3GNCpXFUh6sCdxz7DY9eKMgFxYBgyRSiYWXrBLgdtkPuMbQQzrsYLVyPPSHmNcduLRRd9TSMaYrGLryp8KNkkYBm6eka1Bem" //nolint:lll + encodedMuunKey = "tpubDBZaivUL3Hv8r25JDupShPuWVkGcwM7NgbMBwkhQLfWu18iBbyQCbRdyg1wRMjoWdZN7Afg3F25zs4c8E6Q4VJrGqAw51DJeqacTFABV9u8" //nolint:lll basePath = "m/schema:1'/recovery:1'" ) - txId1, _ := hex.DecodeString(txIdHex1) - txId2, _ := hex.DecodeString(txIdHex2) - txId3, _ := hex.DecodeString(txIdHex3) - txId5, _ := hex.DecodeString(txIdHex5) - txId6, _ := hex.DecodeString(txIdHex6) - txId7, _ := hex.DecodeString(txIdHex7) + txID1, _ := hex.DecodeString(txIDHex1) + txID2, _ := hex.DecodeString(txIDHex2) + txID3, _ := hex.DecodeString(txIDHex3) + txID5, _ := hex.DecodeString(txIDHex5) + txID6, _ := hex.DecodeString(txIDHex6) + txID7, _ := hex.DecodeString(txIDHex7) userPublicKey, _ := NewHDPublicKeyFromString( encodedUserKey, @@ -959,9 +1825,7 @@ func TestPartiallySignedTransaction_Verify(t *testing.T) { _ = tx.Deserialize(bytes.NewReader(txBytes)) // Only set one input to reduce boilerplate tx.TxIn = []*wire.TxIn{tx.TxIn[0]} - // have 100000000 as input - // say 100_000 as change - // destination + // have 100000000 as input say 100_000 as change destination tx.TxOut[1].Value = 1000 // change tx.TxOut[0].Value = 100_000 @@ -982,31 +1846,35 @@ func TestPartiallySignedTransaction_Verify(t *testing.T) { muunPublickKey *HDPublicKey } firstInput := input{ - outpoint: outpoint{index: txIndex1, amount: txAmount1, txId: txId1}, + outpoint: outpoint{index: txIndex1, amount: txAmount1, txID: txID1}, address: addresses.New(txAddressVersion1, txAddressPath1, txAddress1), } secondInput := input{ - outpoint: outpoint{index: txIndex2, amount: txAmount2, txId: txId2}, + outpoint: outpoint{index: txIndex2, amount: txAmount2, txID: txID2}, address: addresses.New(txAddressVersion2, txAddressPath2, txAddress2), } secondInputGeneratingDust := input{ - outpoint: outpoint{index: txIndex2, amount: 120000000 - txAmount1 + 122200 + 100 /* dust */, txId: txId2}, - address: addresses.New(txAddressVersion2, txAddressPath2, txAddress2), + outpoint: outpoint{ + index: txIndex2, + amount: 120000000 - txAmount1 + 122200 + 100, /* dust */ + txID: txID2, + }, + address: addresses.New(txAddressVersion2, txAddressPath2, txAddress2), } thirdInput := input{ - outpoint: outpoint{index: txIndex3, amount: txAmount3, txId: txId3}, + outpoint: outpoint{index: txIndex3, amount: txAmount3, txID: txID3}, address: addresses.New(txAddressVersion3, txAddressPath3, txAddress3), } inputForFifthTx := input{ - outpoint: outpoint{index: txIndex5, amount: txAmount5, txId: txId5}, + outpoint: outpoint{index: txIndex5, amount: txAmount5, txID: txID5}, address: addresses.New(txAddressVersion5, txAddressPath5, txAddress5), } inputForSixthTx := input{ - outpoint: outpoint{index: txIndex6, amount: txAmount6, txId: txId6}, + outpoint: outpoint{index: txIndex6, amount: txAmount6, txID: txID6}, address: addresses.New(txAddressVersion6, txAddressPath6, txAddress6), } inputForSeventhTx := input{ - outpoint: outpoint{index: txIndex7, amount: txAmount7, txId: txId7}, + outpoint: outpoint{index: txIndex7, amount: txAmount7, txID: txID7}, address: addresses.New(txAddressVersion7, txAddressPath7, txAddress7), } tests := []struct { @@ -1327,8 +2195,16 @@ func TestPartiallySignedTransaction_Verify(t *testing.T) { alternativeExpectations := tt.args.expectations.ForAlternativeTransaction() - errNonAlternative := p.Verify(&nonAlternativeExpectations, tt.args.userPublicKey, tt.args.muunPublickKey) - errAlternative := p.Verify(alternativeExpectations, tt.args.userPublicKey, tt.args.muunPublickKey) + errNonAlternative := p.Verify( + &nonAlternativeExpectations, + tt.args.userPublicKey, + tt.args.muunPublickKey, + ) + errAlternative := p.Verify( + alternativeExpectations, + tt.args.userPublicKey, + tt.args.muunPublickKey, + ) t.Logf("test %v non-alternative returned %v", tt.name, errNonAlternative) t.Logf("test %v alternative returned %v", tt.name, errAlternative) diff --git a/libwallet/platform/concurrency/memlock/doc.go b/libwallet/platform/concurrency/memlock/doc.go new file mode 100644 index 00000000..8e972466 --- /dev/null +++ b/libwallet/platform/concurrency/memlock/doc.go @@ -0,0 +1,2 @@ +// Package memlock provides in-memory synchronization primitives. +package memlock diff --git a/libwallet/platform/concurrency/memlock/named_lock.go b/libwallet/platform/concurrency/memlock/named_lock.go new file mode 100644 index 00000000..509d91ed --- /dev/null +++ b/libwallet/platform/concurrency/memlock/named_lock.go @@ -0,0 +1,23 @@ +package memlock + +import "sync" + +// NamedLock represents a held entity lock from NamedLocker. It is safe to call Release on a nil +// reference or multiple times; only the first non-nil call has any effect. +type NamedLock struct { + locker *NamedLocker + name string + once sync.Once +} + +// Release releases the entity lock. Only the first non-nil call has any effect. +func (u *NamedLock) Release() { + if u == nil { + // Lock was never taken + return + } + + u.once.Do(func() { + u.locker.release(u.name) + }) +} diff --git a/libwallet/platform/concurrency/memlock/named_locker.go b/libwallet/platform/concurrency/memlock/named_locker.go new file mode 100644 index 00000000..18980b48 --- /dev/null +++ b/libwallet/platform/concurrency/memlock/named_locker.go @@ -0,0 +1,172 @@ +package memlock + +import ( + "context" + "sync" + "time" + + "golang.org/x/sync/semaphore" +) + +// NamedLocker provides per-entity mutual exclusion that ensures at most one consumer holds the lock +// at a time for a given string name identifying the entity, while allowing different entities to +// proceed concurrently. +// The zero value is ready to use. +// +// # Architecture +// +// A guard mutex (sync.Mutex) protects a map of per-entity entries, each identified by a lockName. +// Each namedLockEntry holds a semaphore.Weighted(1) for context-aware blocking and a referenceCount +// for cleanup. Entries are created on first Acquire for a specific lockName and +// deleted when no consumer holds or is waiting for that lockName (referenceCount reaches +// zero), so the map does not grow unboundedly. +// +// The guard mutex is only held for map lookups and counter bumps, never while +// blocking on a per-entity semaphore, so contention on it is bounded by the +// cost of those fast operations, not by how long an entity lock is held. +type NamedLocker struct { + // mutex guards all access to the locks map and to namedLockEntry.referenceCount. + mutex sync.Mutex + locks map[string]*namedLockEntry +} + +// namedLockEntry is the per-entity state, created on first NamedLocker.Acquire for a lockName +// and deleted when referenceCount drops to zero. +type namedLockEntry struct { + // semaphore is a weighted semaphore of capacity 1, functioning as a + // context-aware mutex. Acquire(ctx,1) blocks until the entity is + // available or the context is cancelled. + semaphore *semaphore.Weighted + + // referenceCount counts goroutines that hold the semaphore or are blocked in Acquire. + // When it reaches zero, no goroutine references the namedLockEntry, + // and it is safe to delete from the map. + referenceCount int +} + +// Acquire acquires the lock for the given lockName, blocking until the lock +// is available, the timeout elapses, or ctx is done. On success, it returns a +// NamedLock that must be released by the caller: +// +// lock, err := locker.Acquire(ctx, lockName, 5*time.Second) +// defer lock.Release() +// if err != nil { return err } +// +// The timeout only bounds the wait to acquire. Once acquired, the returned +// NamedLock is held until Release is called regardless of the timeout. +// +// If ctx is cancelled or the timeout elapses while waiting, Acquire returns +// the context error and cleans up internal state. It is safe to call +// concurrently from any number of goroutines. +func (l *NamedLocker) Acquire( + ctx context.Context, + lockName string, + timeout time.Duration, +) (*NamedLock, error) { + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + return l.acquire(ctx, lockName) +} + +// Run acquires the lock for the given lockName with a timeout, calls f, and releases the lock when +// f returns. If the lock cannot be acquired, f is not called and the context error is returned. +func (l *NamedLocker) Run( + ctx context.Context, + lockName string, + timeout time.Duration, + f func() error, +) error { + lock, err := l.Acquire(ctx, lockName, timeout) + defer lock.Release() + if err != nil { + return err + } + + return f() +} + +// acquire is the internal implementation that blocks on ctx directly without applying its own +// timeout. +func (l *NamedLocker) acquire(ctx context.Context, name string) (*NamedLock, error) { + entry := l.acquireEntry(name) + + // l.mutex is not held here, so other goroutines can acquire/release different names + // concurrently. + if err := entry.semaphore.Acquire(ctx, 1); err != nil { + l.mutex.Lock() + defer l.mutex.Unlock() + + l.releaseEntry(name, entry) + + return nil, err + } + + return &NamedLock{locker: l, name: name}, nil +} + +// release releases the per-entity semaphore and decrements the waiter count, +// deleting the namedLockEntry when it reaches zero. +// +// Both namedLockEntry.semaphore.Release and the waiter decrement happen inside the guard mutex +// critical section. +// Release never blocks (it decrements a counter and wakes up a waiter), so the additional hold time +// on mutex is negligible. +// Keeping the entire "release → decrement → conditionally delete" sequence atomic with respect to +// new Acquire calls prevents a window where a new Acquire would create a fresh namedLockEntry for +// the same name while the old semaphore hasn't been released yet. +func (l *NamedLocker) release(name string) { + l.mutex.Lock() + defer l.mutex.Unlock() + + entry, ok := l.locks[name] + if !ok { + // Matches sync.Mutex convention: unlocking an unheld lock panics. + panic("memlock.NamedLocker: release of unknown lockName: " + name) + } + + entry.semaphore.Release(1) + + l.releaseEntry(name, entry) +} + +// acquireEntry acquires the namedLockEntry for a specific name. +// If the namedLockEntry doesn't exist yet, it creates it. +// The namedLockEntry.referenceCount of the entry is increased. +func (l *NamedLocker) acquireEntry(name string) *namedLockEntry { + l.mutex.Lock() + defer l.mutex.Unlock() + + if l.locks == nil { + l.locks = make(map[string]*namedLockEntry) + } + + entry, ok := l.locks[name] + if !ok { + entry = &namedLockEntry{semaphore: semaphore.NewWeighted(1)} + l.locks[name] = entry + } + + entry.referenceCount++ + + return entry +} + +// releaseEntry releases the namedLockEntry associated with a specific name. +// The namedLockEntry.referenceCount of the entry is decreased. +// Removes the namedLockEntry if namedLockEntry.referenceCount reaches zero. +// Should always be run under NamedLocker.mutex. +func (l *NamedLocker) releaseEntry(name string, entry *namedLockEntry) { + entry.referenceCount-- + if entry.referenceCount == 0 { + delete(l.locks, name) + } +} + +// len returns the number of names currently tracked. +// Intended for testing. +func (l *NamedLocker) len() int { + l.mutex.Lock() + defer l.mutex.Unlock() + + return len(l.locks) +} diff --git a/libwallet/platform/concurrency/memlock/named_locker_test.go b/libwallet/platform/concurrency/memlock/named_locker_test.go new file mode 100644 index 00000000..50a41dd5 --- /dev/null +++ b/libwallet/platform/concurrency/memlock/named_locker_test.go @@ -0,0 +1,387 @@ +package memlock + +import ( + "context" + "errors" + "sync" + "testing" + "time" +) + +const testTimeout = 1 * time.Minute + +func TestLockUnlock(t *testing.T) { + var locker NamedLocker + + lock, err := locker.Acquire(context.Background(), "entity-1", testTimeout) + if err != nil { + t.Fatalf("Acquire returned error: %v", err) + } + if locker.len() != 1 { + t.Fatalf("expected len()==1 while locked, got %d", locker.len()) + } + + lock.Release() + + if locker.len() != 0 { + t.Fatalf("expected len()==0 after release, got %d", locker.len()) + } +} + +func TestLockSameEntity(t *testing.T) { + var locker NamedLocker + + // Goroutine 1 acquires the lock. + lock1, err := locker.Acquire(context.Background(), "entity-1", testTimeout) + if err != nil { + t.Fatalf("Acquire returned error: %v", err) + } + + done := make(chan struct{}) + acquired := make(chan struct{}) + go func() { + // Goroutine 2 blocks until goroutine 1 unlocks. + lock2, err := locker.Acquire(context.Background(), "entity-1", testTimeout) + if err != nil { + t.Errorf("second Acquire returned error: %v", err) + return + } + close(acquired) + lock2.Release() + close(done) + }() + + // Give goroutine 2 time to block. + select { + case <-acquired: + t.Fatal("second goroutine acquired lock while first still held it") + case <-time.After(50 * time.Millisecond): + } + + if locker.len() != 1 { + t.Fatalf("expected len()==1, got %d", locker.len()) + } + + select { + case <-done: + t.Fatal("second goroutine done lock while first still held it") + default: + // not done + } + + lock1.Release() + + select { + case <-acquired: + case <-time.After(time.Second): + t.Fatal("second goroutine did not acquire lock after first unlocked") + } + + // Wait for goroutine 2 to finish releasing before checking len. + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("second goroutine did not finish") + } + + if locker.len() != 0 { + t.Fatalf("expected len()==0, got %d", locker.len()) + } +} + +func TestLockDifferentEntities(t *testing.T) { + var locker NamedLocker + + lock1, err := locker.Acquire(context.Background(), "entity-1", testTimeout) + if err != nil { + t.Fatalf("Acquire entity-1 returned error: %v", err) + } + + // Locking a different entity should not block. + done := make(chan struct{}) + go func() { + lock2, err := locker.Acquire(context.Background(), "entity-2", testTimeout) + if err != nil { + t.Errorf("Acquire entity-2 returned error: %v", err) + return + } + lock2.Release() + close(done) + }() + + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("locking a different entity blocked unexpectedly") + } + + lock1.Release() +} + +func TestLockContextCancellation(t *testing.T) { + var locker NamedLocker + + lock1, err := locker.Acquire(context.Background(), "entity-1", testTimeout) + if err != nil { + t.Fatalf("Acquire returned error: %v", err) + } + + // Try to lock with an already-cancelled context. + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err = locker.Acquire(ctx, "entity-1", testTimeout) + if err == nil { + t.Fatal("expected error from cancelled context, got nil") + } + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context.Canceled error, got %v", err) + } + + // Entry should still exist (first lock is held). + if locker.len() != 1 { + t.Fatalf("expected len()==1, got %d", locker.len()) + } + + lock1.Release() + + if locker.len() != 0 { + t.Fatalf("expected len()==0 after release, got %d", locker.len()) + } +} + +func TestLockTimeout(t *testing.T) { + var locker NamedLocker + + lock1, err := locker.Acquire(context.Background(), "entity-1", testTimeout) + if err != nil { + t.Fatalf("Acquire returned error: %v", err) + } + + _, err = locker.Acquire(context.Background(), "entity-1", 50*time.Millisecond) + if err == nil { + t.Fatal("expected timeout error, got nil") + } + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("expected DeadlineExceeded error, got %v", err) + } + + lock1.Release() + + if locker.len() != 0 { + t.Fatalf("expected len()==0 after release, got %d", locker.len()) + } +} + +func TestDoubleRelease(t *testing.T) { + var locker NamedLocker + + lock, err := locker.Acquire(context.Background(), "entity-1", testTimeout) + if err != nil { + t.Fatalf("Acquire returned error: %v", err) + } + + lock.Release() + lock.Release() // must not panic +} + +func TestStress(t *testing.T) { + const ( + numEntities = 10 + numGoroutines = 100 + numIterations = 50 + ) + + var locker NamedLocker + counters := make([]int, numEntities) + var wg sync.WaitGroup + + for i := range numGoroutines { + wg.Add(1) + go func(id int) { + defer wg.Done() + entityIdx := id % numEntities + entityID := string(rune('A' + entityIdx)) + + for range numIterations { + lock, err := locker.Acquire(context.Background(), entityID, testTimeout) + if err != nil { + t.Errorf("Acquire returned error: %v", err) + return + } + counters[entityIdx]++ + lock.Release() + } + }(i) + } + + wg.Wait() + + if locker.len() != 0 { + t.Fatalf("expected len()==0 after all goroutines done, got %d", locker.len()) + } + + // Each entity gets (numGoroutines/numEntities) * numIterations increments. + expectedPerEntity := (numGoroutines / numEntities) * numIterations + for i, count := range counters { + if count != expectedPerEntity { + t.Errorf("counter[%d] = %d, expected %d", i, count, expectedPerEntity) + } + } +} + +func TestReuseAfterRelease(t *testing.T) { + var locker NamedLocker + + for i := range 3 { + lock, err := locker.Acquire(context.Background(), "entity-1", testTimeout) + if err != nil { + t.Fatalf("iteration %d: Acquire returned error: %v", i, err) + } + lock.Release() + } + + if locker.len() != 0 { + t.Fatalf("expected len()==0 after reuse cycle, got %d", locker.len()) + } +} + +func TestMultipleWaiters(t *testing.T) { + var locker NamedLocker + const numWaiters = 5 + + lock1, err := locker.Acquire(context.Background(), "entity-1", testTimeout) + if err != nil { + t.Fatalf("Acquire returned error: %v", err) + } + + // Start numWaiters goroutines all waiting on the same entity. + counter := 0 + var wg sync.WaitGroup + for range numWaiters { + wg.Add(1) + go func() { + defer wg.Done() + lock, err := locker.Acquire(context.Background(), "entity-1", testTimeout) + if err != nil { + t.Errorf("waiter Acquire returned error: %v", err) + return + } + counter++ + lock.Release() + }() + } + + // Let all waiters queue up. + time.Sleep(50 * time.Millisecond) + + lock1.Release() + wg.Wait() + + if counter != numWaiters { + t.Fatalf("expected counter==%d, got %d", numWaiters, counter) + } + if locker.len() != 0 { + t.Fatalf("expected len()==0, got %d", locker.len()) + } +} + +func TestRunSuccess(t *testing.T) { + var locker NamedLocker + + err := locker.Run(context.Background(), "entity-1", testTimeout, func() error { + if locker.len() != 1 { + t.Fatalf("expected len()==1 while locked, got %d", locker.len()) + } + return nil + }) + if err != nil { + t.Fatalf("Run returned error: %v", err) + } + + if locker.len() != 0 { + t.Fatalf("expected len()==0 after release, got %d", locker.len()) + } +} + +func TestRunErrorPropagation(t *testing.T) { + var locker NamedLocker + sentinel := context.DeadlineExceeded + + err := locker.Run(context.Background(), "entity-1", testTimeout, func() error { + return sentinel + }) + if !errors.Is(err, sentinel) { + t.Fatalf("expected sentinel error, got %v", err) + } + + if locker.len() != 0 { + t.Fatalf("expected len()==0 after Run returns, got %d", locker.len()) + } +} + +func TestRunTimeout(t *testing.T) { + var locker NamedLocker + + lock1, err := locker.Acquire(context.Background(), "entity-1", testTimeout) + if err != nil { + t.Fatalf("Acquire returned error: %v", err) + } + + called := false + err = locker.Run(context.Background(), "entity-1", 50*time.Millisecond, func() error { + called = true + return nil + }) + if err == nil { + t.Fatal("expected timeout error, got nil") + } + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("expected DeadlineExceeded error, got %v", err) + } + if called { + t.Fatal("callback should not have been called on timeout") + } + + lock1.Release() +} + +func TestRunCancel(t *testing.T) { + var locker NamedLocker + + lock1, err := locker.Acquire(context.Background(), "entity-1", testTimeout) + if err != nil { + t.Fatalf("Acquire returned error: %v", err) + } + + // Try to lock with an already-cancelled context. + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + hasRun := false + + err = locker.Run(ctx, "entity-1", testTimeout, func() error { + hasRun = true + return nil + }) + if err == nil { + t.Fatal("expected error from cancelled context, got nil") + } + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context.Canceled error, got %v", err) + } + if hasRun { + t.Fatal("expected cancelled callback to not have been called") + } + + // Entry should still exist (first lock is held). + if locker.len() != 1 { + t.Fatalf("expected len()==1, got %d", locker.len()) + } + + lock1.Release() + + if locker.len() != 0 { + t.Fatalf("expected len()==0 after release, got %d", locker.len()) + } +} diff --git a/libwallet/platform/doc.go b/libwallet/platform/doc.go new file mode 100644 index 00000000..a8662a70 --- /dev/null +++ b/libwallet/platform/doc.go @@ -0,0 +1,5 @@ +// Package platform provides non-functional infrastructure for libwallet: +// logging, tracing, concurrency primitives, security utilities, and test +// support. It is meant to be consumed by libwallet's functional packages, +// keeping cross-cutting concerns separate from domain logic. +package platform diff --git a/libwallet/platform/observability/slogctx/slogctx.go b/libwallet/platform/observability/slogctx/slogctx.go new file mode 100644 index 00000000..97719fba --- /dev/null +++ b/libwallet/platform/observability/slogctx/slogctx.go @@ -0,0 +1,51 @@ +package slogctx + +import ( + "context" + "log/slog" +) + +type ctxKey struct{} + +// WithLogger returns a new context with the given logger stored in it. +func WithLogger(ctx context.Context, logger *slog.Logger) context.Context { + return context.WithValue(ctx, ctxKey{}, logger) +} + +// Logger retrieves the logger from the context, or returns slog.Default() if none is set. +func Logger(ctx context.Context) *slog.Logger { + if logger, ok := ctx.Value(ctxKey{}).(*slog.Logger); ok && logger != nil { + return logger + } + return slog.Default() +} + +// With returns a new context whose logger has the given attributes added. +func With(ctx context.Context, args ...any) context.Context { + return WithLogger(ctx, Logger(ctx).With(args...)) +} + +// WithGroup returns a new context whose logger has the given group name. +func WithGroup(ctx context.Context, name string) context.Context { + return WithLogger(ctx, Logger(ctx).WithGroup(name)) +} + +// Debug logs at DebugLevel using the logger from ctx. +func Debug(ctx context.Context, msg string, args ...any) { + Logger(ctx).DebugContext(ctx, msg, args...) +} + +// Info logs at InfoLevel using the logger from ctx. +func Info(ctx context.Context, msg string, args ...any) { + Logger(ctx).InfoContext(ctx, msg, args...) +} + +// Warn logs at WarnLevel using the logger from ctx. +func Warn(ctx context.Context, msg string, args ...any) { + Logger(ctx).WarnContext(ctx, msg, args...) +} + +// Error logs at ErrorLevel using the logger from ctx. +func Error(ctx context.Context, msg string, args ...any) { + Logger(ctx).ErrorContext(ctx, msg, args...) +} diff --git a/libwallet/platform/preconditions/preconditions.go b/libwallet/platform/preconditions/preconditions.go new file mode 100644 index 00000000..76535548 --- /dev/null +++ b/libwallet/platform/preconditions/preconditions.go @@ -0,0 +1,132 @@ +// Package preconditions contains convenience functions that help a function check whether it was +// invoked correctly (whether its preconditions have been met). +// +// In case the precondition fails, a panic is issued communicating the failure reason. +// +// Preconditions should be used when the condition being checked is assumed to be true, and cases +// where they aren't the system state should be considered unknown. In these situations, +// panicking is the most reasonable action. +// +// Each precondition function has an -f terminated companion that accepts a formatted string that +// will be sent as the panic message upon failure. +// Non -f terminated functions issue a default error message. +package preconditions + +import ( + "reflect" + + "github.com/go-errors/errors" +) + +// PreconditionError is the error value sent to panic when the precondition fails. +type PreconditionError struct{ error } + +type unsigned interface { + ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr +} + +type signed interface { + ~int | ~int8 | ~int16 | ~int32 | ~int64 | ~float32 | ~float64 +} + +type number interface { + signed | unsigned +} + +func CheckState(expr bool) { + CheckStatef(expr, "state precondition failed") +} + +func CheckStatef(expr bool, format string, args ...any) { + if !expr { + fail(format, args...) + } +} + +func CheckNotNil[T any](ref T) T { + return CheckNotNilf(ref, "Expected value to not be nil") +} + +func CheckNotNilf[T any](ref T, format string, args ...any) T { + if interfaceOrUnderlyingValueIsNil(ref) { + fail(format, args...) + } + return ref +} + +func CheckNil[T any](ref T) T { + return CheckNilf(ref, "Expected %v to be null", ref) +} + +func CheckNilf[T any](ref T, format string, args ...any) T { + if !interfaceOrUnderlyingValueIsNil(ref) { + fail(format, args...) + } + return ref +} + +// interfaceOrUnderlyingValueIsNil handles the common Go gotcha where the interface has a concrete +// value, so it is not nil, but the underlying value is a pointer type with value nil. +// We check for both things, and fail for both cases. +func interfaceOrUnderlyingValueIsNil(ref any) bool { + if ref == nil { + return true + } + + v := reflect.ValueOf(ref) + switch v.Kind() { + case reflect.Chan, reflect.Func, reflect.Map, reflect.Pointer, reflect.UnsafePointer, + reflect.Slice: + return v.IsNil() + default: + return false + } +} + +func CheckNotEmpty(s string) string { + return CheckNotEmptyf(s, "Expected string value to be not empty") +} + +func CheckNotEmptyf(s string, format string, args ...any) string { + if s == "" { + fail(format, args...) + } + return s +} + +func CheckEmpty(s string) string { + return CheckEmptyf(s, "Expected string value to be empty") +} + +func CheckEmptyf(s string, format string, args ...any) string { + if s != "" { + fail(format, args...) + } + return s +} + +func CheckNotNegative[T signed](n T) T { + return CheckNotNegativef(n, "Expected %v to be not negative", n) +} + +func CheckNotNegativef[T signed](n T, format string, args ...any) T { + if n < 0 { + fail(format, args...) + } + return n +} + +func CheckPositive[T number](n T) T { + return CheckPositivef(n, "Expected %v to be strictly positive", n) +} + +func CheckPositivef[T number](n T, format string, args ...any) T { + if n <= 0 { + fail(format, args...) + } + return n +} + +func fail(format string, args ...any) { + panic(PreconditionError{errors.Errorf(format, args...)}) +} diff --git a/libwallet/platform/test/testbitcoind/client.go b/libwallet/platform/test/testbitcoind/client.go new file mode 100644 index 00000000..20c3efa5 --- /dev/null +++ b/libwallet/platform/test/testbitcoind/client.go @@ -0,0 +1,315 @@ +package testbitcoind + +import ( + "bytes" + "encoding/hex" + "testing" + + "github.com/btcsuite/btcd/btcjson" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + + "github.com/btcsuite/btcd/rpcclient" +) + +const ( + bitcoindHost = "localhost:18443" + bitcoindUser = "user" + bitcoindPass = "123" +) + +// Client wraps an rpcclient.Client to provide test-friendly helpers +// for interacting with a local regtest bitcoind node. Methods that encounter +// RPC errors call t.Fatal, so tests do not need explicit error checks. +type Client struct { + *rpcclient.Client + *testing.T + nodeAddress *btcutil.Address +} + +// NewClient connects to the local regtest bitcoind and registers +// a cleanup that shuts down the RPC connection when the test finishes. +func NewClient(t *testing.T) *Client { + t.Helper() + + client, err := rpcclient.New(&rpcclient.ConnConfig{ + Host: bitcoindHost + "/wallet/", + User: bitcoindUser, + Pass: bitcoindPass, + Params: "regtest", + DisableTLS: true, + HTTPPostMode: true, + }, nil) + if err != nil { + t.Fatalf("Failed to create bitcoind RPC client: %v", err) + } + + testClient := &Client{ + Client: client, + T: t, + } + t.Cleanup(func() { testClient.Shutdown() }) + + return testClient +} + +// Shutdown closes the underlying RPC connection. +func (b *Client) Shutdown() { + b.Helper() + + b.Client.Shutdown() +} + +// StartNewTest rebinds the client to a new sub-test's testing.T and restores +// the previous one on cleanup, allowing a shared client across tests. +func (b *Client) StartNewTest(t *testing.T) { + prevT := b.T + b.T = t + t.Cleanup(func() { b.T = prevT }) +} + +// GetNewAddress returns a new native-segwit address for the given account. +func (b *Client) GetNewAddress(account string) btcutil.Address { + b.Helper() + return b.GetNewAddressType(account, BtcAddressTypeSegwit) +} + +// GetNewAddressType returns a new address of the specified type for the given account. +func (b *Client) GetNewAddressType(account string, addressType BtcAddressType) btcutil.Address { + b.Helper() + + address, err := b.Client.GetNewAddressType(account, string(addressType)) + if err != nil { + b.Fatalf("Failed to get new address: %v", err) + } + return address +} + +// NodeAddress returns a cached wallet address used as the default destination +// for block rewards when mining with GenerateBlocks. +func (b *Client) NodeAddress() btcutil.Address { + if b.nodeAddress == nil { + nodeAddress := b.GetNewAddress("node") + b.nodeAddress = &nodeAddress + } + + return *b.nodeAddress +} + +// GenerateToAddress mines the given number of blocks with coinbase rewards +// sent to address, returning the hashes of the new blocks. +func (b *Client) GenerateToAddress(blocks int, address btcutil.Address) []*chainhash.Hash { + b.Helper() + + hashes, err := b.Client.GenerateToAddress(int64(blocks), address, nil) + if err != nil { + b.Fatalf("Failed to mine %d blocks to %v: %v", blocks, address, err) + } + return hashes +} + +// GenerateBlocks mines the given number of blocks to the node's own address. +func (b *Client) GenerateBlocks(blocks int) []*chainhash.Hash { + b.Helper() + return b.GenerateToAddress(blocks, b.NodeAddress()) +} + +// GetBalance returns the wallet's confirmed balance in BTC. +func (b *Client) GetBalance() float64 { + b.Helper() + return mustMakeRequest[float64](b, "getbalance") +} + +// GetBlockCount returns the current block height of the regtest chain. +func (b *Client) GetBlockCount() int32 { + b.Helper() + return mustMakeRequest[int32](b, "getblockcount") +} + +// SendRawTransaction broadcasts a serialized transaction and returns its txid. +func (b *Client) SendRawTransaction(txHex string) (string, error) { + b.Helper() + + txID, err := makeRequest[string](b, "sendrawtransaction", txHex) + if err != nil { + return "", err + } + + b.Logf("Broadcasted transaction: %s", txID) + + return txID, nil +} + +// MustSendRawTransaction is like SendRawTransaction but fails the test on error. +func (b *Client) MustSendRawTransaction(txHex string) string { + txID, err := b.SendRawTransaction(txHex) + if err != nil { + b.Fatal(err) + } + return txID +} + +// GetRawTransaction returns the hex-encoded raw transaction for the given txid, +// or an empty string if the transaction is not found. +func (b *Client) GetRawTransaction(txID string) string { + b.Helper() + + resultHex, err := makeRequest[string](b, "getrawtransaction", txID) + if err != nil { + if checkResponseCode(err, btcjson.ErrRPCInvalidAddressOrKey) { + return "" + } + b.Fatal(err) + } + + return resultHex +} + +// SendToAddress sends amount BTC from the wallet to address and returns the txid. +func (b *Client) SendToAddress(address btcutil.Address, amount float64) string { + b.Helper() + return mustMakeRequest[string](b, "sendtoaddress", address.EncodeAddress(), amount) +} + +// ListUnspentForAddress returns confirmed UTXOs belonging to address. +func (b *Client) ListUnspentForAddress(address btcutil.Address) []ListUnspentResult { + b.Helper() + return mustMakeRequest[[]ListUnspentResult](b, "listunspent", + 1, // minconfs + 9999999, // maxconfs + []string{address.EncodeAddress()}, // addresses + ) +} + +// LockUnspent locks UTXOs so the wallet will not use them in automatic coin selection +// (e.g. sendtoaddress). This prevents the wallet from spending UTXOs that are reserved +// for pre-signed but not-yet-broadcast transactions. +func (b *Client) LockUnspent(outpoints []Outpoint) { + b.Helper() + mustMakeRequest[bool](b, "lockunspent", false, outpoints) +} + +// UnlockUnspent releases previously locked UTXOs so the wallet can use them again. +// This variant ignores errors to allow being called as test cleanup, making sure that the UTXOs are +// released, but not caring if the test already spent them. +// See MustUnlockUnspent for an enforcing variant. +func (b *Client) UnlockUnspent(outpoints []Outpoint) { + b.Helper() + _, _ = makeRequest[bool](b, "lockunspent", true, outpoints) +} + +// MustUnlockUnspent releases previously locked UTXOs so the wallet can use them again. +// Fails on error. +func (b *Client) MustUnlockUnspent(outpoints []Outpoint) { + b.Helper() + mustMakeRequest[bool](b, "lockunspent", true, outpoints) +} + +// CreateRawTransaction builds an unsigned transaction spending the given inputs +// to the given outputs and returns its hex encoding. +func (b *Client) CreateRawTransaction( + inputs []Outpoint, + outputs []CreateRawTxOutput, +) string { + b.Helper() + + rawOutputs := make([]map[string]float64, len(outputs)) + for i, output := range outputs { + rawOutputs[i] = map[string]float64{output.Address.EncodeAddress(): output.Amount} + } + + return mustMakeRequest[string](b, "createrawtransaction", inputs, rawOutputs) +} + +// SignTransaction signs a raw transaction using the wallet's keys. +func (b *Client) SignTransaction( + rawTxHex string, + prevs []SignTransactionPrevOut, +) string { + b.Helper() + + if len(prevs) == 0 { + prevs = nil + } + + type response struct { + Hex string `json:"hex"` + Complete bool `json:"complete"` + } + + signResponse := mustMakeRequest[response](b, "signrawtransactionwithwallet", rawTxHex, prevs) + + if !signResponse.Complete { + b.Fatalf("Transaction signing not complete") + } + + return signResponse.Hex +} + +// DecodeRawTransaction deserializes a hex-encoded transaction without broadcasting it. +func (b *Client) DecodeRawTransaction(rawTxHex string) DecodeRawTransactionResult { + b.Helper() + return mustMakeRequest[DecodeRawTransactionResult](b, "decoderawtransaction", rawTxHex) +} + +// DropMempoolEntry drops an entry from the mempool given its transaction id. +func (b *Client) DropMempoolEntry(txID string) bool { + b.Helper() + return mustMakeRequest[bool](b, "dropmempoolentry", txID) +} + +// DecodeTx deserializes a raw bitcoin transaction. +func (b *Client) DecodeTx(rawTx string) wire.MsgTx { + b.Helper() + + rawBytes, err := hex.DecodeString(rawTx) + if err != nil { + b.Fatal(err, "failed to decode hex") + } + + var tx wire.MsgTx + err = tx.Deserialize(bytes.NewReader(rawBytes)) + if err != nil { + b.Fatal(err, "failed to deserialize tx") + } + + return tx +} + +// EnsureMatureCoins mines 101 blocks if the wallet balance is below 1 BTC, +// guaranteeing at least one spendable coinbase output. +func (b *Client) EnsureMatureCoins() { + b.Helper() + + // Check current balance. + balance := b.GetBalance() + + if balance < 1.0 { + // Mine 101 blocks to get mature coinbase outputs. + b.GenerateBlocks(101) + } +} + +// AssertTxIsBroadcasted checks that a tx is currently broadcasted (mempool or confirmed). +func (b *Client) AssertTxIsBroadcasted(txID string) { + b.Helper() + + // Check if the tx exists in bitcoind's mempool or chain. + rawTx := b.GetRawTransaction(txID) + if rawTx == "" { + b.Fatalf("Expected transaction %s to be broadcast, but it was not found", txID) + } + + b.Logf("Transaction %s confirmed as broadcast", txID) +} + +// AssertTxIsNotBroadcasted checks that a tx is NOT currently broadcasted (mempool or confirmed). +func (b *Client) AssertTxIsNotBroadcasted(txID string) { + b.Helper() + + rawTx := b.GetRawTransaction(txID) + if rawTx != "" { + b.Fatalf("Expected transaction %s to NOT be broadcast, but it was found", txID) + } +} diff --git a/libwallet/platform/test/testbitcoind/rpc.go b/libwallet/platform/test/testbitcoind/rpc.go new file mode 100644 index 00000000..702f0e09 --- /dev/null +++ b/libwallet/platform/test/testbitcoind/rpc.go @@ -0,0 +1,63 @@ +package testbitcoind + +import ( + "encoding/json" + "slices" + + "github.com/btcsuite/btcd/btcjson" + "github.com/go-errors/errors" +) + +func makeRequest[Result any](b *Client, method string, args ...any) (Result, error) { + b.Helper() + + // Remove nils + args = slices.DeleteFunc(args, func(arg any) bool { return arg == nil }) + + var result Result + + rawArgs := make([]json.RawMessage, len(args)) + for i, arg := range args { + data, err := json.Marshal(arg) + if err != nil { + return result, errors.Errorf("failed to marshal arg %d: %w", i, err) + } + rawArgs[i] = data + } + + rawResult, err := b.RawRequest(method, rawArgs) + if err != nil { + return result, errors.Errorf("method %s failed: %w", method, err) + } + + if err := json.Unmarshal(rawResult, &result); err != nil { + return result, errors.Errorf("failed to unmarshal %s response: %w", method, err) + } + + return result, nil +} + +func mustMakeRequest[Result any](b *Client, method string, args ...any) Result { + b.Helper() + + result, err := makeRequest[Result](b, method, args...) + if err != nil { + b.Fatal(err) + } + return result +} + +func checkResponseCode(err error, expectedCodes ...btcjson.RPCErrorCode) bool { + var rpcErr *btcjson.RPCError + if !errors.As(err, &rpcErr) { + return false + } + + for _, expectedCode := range expectedCodes { //nolint:modernize // TODO: use slices.Contains + if rpcErr.Code == expectedCode { + return true + } + } + + return false +} diff --git a/libwallet/platform/test/testbitcoind/types.go b/libwallet/platform/test/testbitcoind/types.go new file mode 100644 index 00000000..094841f9 --- /dev/null +++ b/libwallet/platform/test/testbitcoind/types.go @@ -0,0 +1,88 @@ +package testbitcoind + +import "github.com/btcsuite/btcd/btcutil" + +// BtcAddressType is the address type parameter for Bitcoin Core's `getnewaddress` RPC. +type BtcAddressType string + +const ( + BtcAddressTypeLegacy BtcAddressType = "legacy" + BtcAddressTypeWrappedSegwit BtcAddressType = "p2sh-segwit" + BtcAddressTypeSegwit BtcAddressType = "bech32" + BtcAddressTypeTaproot BtcAddressType = "bech32m" +) + +type Outpoint struct { + TxID string `json:"txid"` + Vout uint32 `json:"vout"` +} + +// ListUnspentResult models a single UTXO entry of the list returned by the `listunspent` Bitcoin +// Core RPC call. +type ListUnspentResult struct { + Outpoint + Amount float64 `json:"amount"` + ScriptPubKey string `json:"scriptPubKey"` + // More fields are available +} + +// CreateRawTxOutput models a single entry of the list received as second param +// by the `createrawtransaction` Bitcoin Core RPC call. +type CreateRawTxOutput struct { + Address btcutil.Address + Amount float64 +} + +// SignTransactionPrevOut models a single entry of the list received as second param +// by the `signrawtransactionwithwallet` Bitcoin Core RPC call. +// It describes a previous output that the transaction spends, used when +// the output may not yet be in the blockchain. +type SignTransactionPrevOut struct { + Outpoint + ScriptPubKey string `json:"scriptPubKey"` + Amount float64 `json:"amount"` +} + +// ScriptSig represents the unlocking script of a transaction input. +type ScriptSig struct { + Asm string `json:"asm"` + Hex string `json:"hex"` +} + +// ScriptPubKey represents the locking script of a transaction output. +type ScriptPubKey struct { + Asm string `json:"asm"` + Hex string `json:"hex"` + Desc string `json:"desc"` + Type string `json:"type"` + Address string `json:"address,omitempty"` +} + +// Vin represents a single transaction input. +type Vin struct { + Outpoint + ScriptSig ScriptSig `json:"scriptSig"` + TxInWitness []string `json:"txinwitness,omitempty"` + Sequence uint32 `json:"sequence"` +} + +// Vout represents a single transaction output. +type Vout struct { + Value float64 `json:"value"` + N uint32 `json:"n"` + ScriptPubKey ScriptPubKey `json:"scriptPubKey"` +} + +// DecodeRawTransactionResult models the response from the `decoderawtransaction` Bitcoin Core RPC +// call. +type DecodeRawTransactionResult struct { + TxID string `json:"txid"` + Hash string `json:"hash"` + Version int32 `json:"version"` + Size int64 `json:"size"` + VSize int64 `json:"vsize"` + Weight int64 `json:"weight"` + LockTime uint32 `json:"locktime"` + Vin []Vin `json:"vin"` + Vout []Vout `json:"vout"` +} diff --git a/libwallet/platform/test/testelectrum/client.go b/libwallet/platform/test/testelectrum/client.go new file mode 100644 index 00000000..d091371e --- /dev/null +++ b/libwallet/platform/test/testelectrum/client.go @@ -0,0 +1,175 @@ +package testelectrum + +import ( + "bytes" + "context" + "encoding/hex" + "testing" + "time" + + "github.com/btcsuite/btcd/wire" + "github.com/go-errors/errors" + + electrum "github.com/muun/libwallet/electrum/v2" + "github.com/muun/libwallet/platform/test/testbitcoind" +) + +const ( + electrumHost = "localhost:60002" // electrum-test-electrs +) + +// Client wraps an electrum.Client to provide test-friendly helpers +// for querying a local Electrum server. Methods that encounter errors call +// t.Fatal, so tests do not need explicit error checks. +type Client struct { + electrum.Client + *testing.T +} + +// NewClient connects to the local Electrum server and registers +// a cleanup that disconnects when the test finishes. +func NewClient(ctx context.Context, t *testing.T) *Client { + t.Helper() + + client, err := electrum.NewClient(ctx, electrumHost, electrum.RequireTCP) + if err != nil { + t.Fatalf("Failed to connect to Electrum at %s: %v", electrumHost, err) + } + + testClient := &Client{ + Client: client, + T: t, + } + t.Cleanup(func() { client.Disconnect(ctx) }) + + return testClient +} + +// StartNewTest rebinds the client to a new sub-test's testing.T and restores +// the previous one on cleanup, allowing a shared client across table tests. +func (e *Client) StartNewTest(t *testing.T) { + prevT := e.T + e.T = t + t.Cleanup(func() { e.T = prevT }) +} + +// GetBlockHeight returns the current tip height as reported by the Electrum server. +func (e *Client) GetBlockHeight(ctx context.Context) int32 { + headers, err := e.GetHeaders(ctx) + if err != nil { + e.Fatalf("Failed to get electrum headers: %v", err) + } + return headers.Height +} + +// WaitForIndexedTx polls electrum until the given transaction is indexed. +func (e *Client) WaitForIndexedTx(ctx context.Context, txID string) { + e.Helper() + + err := e.waitFor(func() bool { + e.Helper() + + txHex, err := e.GetTransaction(ctx, txID) + if err != nil || txHex == "" { + return false + } + + e.Logf("Electrum indexed transaction %s", txID) + return true + }) + if err != nil { + e.Fatalf("Electrum did not index transaction %s: %v", txID, err) + } +} + +// WaitForConfirmedTx polls electrum until the given transaction appears +// with a positive height (confirmed) in the scripthash history for the given +// script hash. +// Note that this guarantees that electrum not only indexed the relevant tx, +// but also the block containing it. +func (e *Client) WaitForConfirmedTx(ctx context.Context, scriptHash, txID string) { + e.Helper() + + err := e.waitFor(func() bool { + e.Helper() + + history, err := e.GetScriptHashHistory(ctx, scriptHash) + if err != nil { + return false + } + + for _, entry := range history { + if entry.TxHash == txID && entry.Height > 0 { + e.Logf("Electrum confirmed transaction %s at height %d", txID, entry.Height) + return true + } + } + + return false + }) + if err != nil { + e.Fatalf("Electrum did not confirm transaction %s: %v", txID, err) + } +} + +// WaitForBlockHeight polls until the Electrum server reaches at least the given height. +func (e *Client) WaitForBlockHeight(ctx context.Context, blockHeight int32) { + e.Helper() + + err := e.waitFor(func() bool { + e.Helper() + return e.GetBlockHeight(ctx) >= blockHeight + }) + if err != nil { + e.Fatalf("Electrum didn't reach block height %d: %v", blockHeight, err) + } +} + +// SyncWithBitcoind waits until the Electrum server has indexed up to bitcoind's current height. +func (e *Client) SyncWithBitcoind(ctx context.Context, bitcoind *testbitcoind.Client) { + e.WaitForBlockHeight(ctx, bitcoind.GetBlockCount()) +} + +// AssertTxNotInMempool checks that a transaction with the given hex has NOT been broadcast +// by verifying its txid is not known to electrum. +func (e *Client) AssertTxNotInMempool(ctx context.Context, txHex string) { + e.Helper() + + if txHex == "" { + e.Fatalf("txHex is empty") + } + + rawBytes, err := hex.DecodeString(txHex) + if err != nil { + e.Fatalf("failed to decode hex: %v", err) + } + + var tx wire.MsgTx + if err = tx.Deserialize(bytes.NewReader(rawBytes)); err != nil { + e.Fatalf("failed to deserialize tx: %v", err) + } + + txID := tx.TxHash().String() + + txResult, err := e.GetTransaction(ctx, txID) + if !errors.As(err, &electrum.ElectrumError{}) || txResult != "" { + e.Fatalf("Expected transaction %s to NOT be broadcast, but it was found", txID) + } +} + +func (e *Client) waitFor(condition func() bool) error { + e.Helper() + + const pollInterval = 500 * time.Millisecond + const maxWait = 30 * time.Second + deadline := time.Now().Add(maxWait) + + for time.Now().Before(deadline) { + if condition() { + return nil + } + time.Sleep(pollInterval) + } + + return errors.Errorf("timeouted after %d seconds", maxWait/time.Second) +} diff --git a/libwallet/platform/test/testsuite/base_integration_suite.go b/libwallet/platform/test/testsuite/base_integration_suite.go new file mode 100644 index 00000000..d39c2db4 --- /dev/null +++ b/libwallet/platform/test/testsuite/base_integration_suite.go @@ -0,0 +1,48 @@ +package testsuite + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +// Run takes a test suite and runs all the tests attached to it. +// This function is meant to run integration tests. +func Run(t *testing.T, s suite.TestingSuite) { + if testing.Short() { + t.Skip("skipping integration test suite") + } + suite.Run(t, s) +} + +// BaseIntegrationSuite is the base suite to be used for integration test suites. +// It contains logic to be applied to all integration tests. +// Embed this struct in a concrete suite struct instead of suite.Suite. +type BaseIntegrationSuite struct { + suite.Suite + + // Overrides testify default so that all failed assertions stop the test execution. + *require.Assertions + + // Ctx is the per-test context to be used during tests. + // Carries test configuration. + Ctx context.Context +} + +// SetupSuite is run automatically before all tests in the suite. +// If the concrete suite overrides this method, then it needs to be manually called. +func (s *BaseIntegrationSuite) SetupSuite() { + s.Assertions = require.New(s.T()) + + s.Ctx = s.T().Context() +} + +// SetupTest is automatically run before each test in the suite. +// If the concrete suite overrides this method, then it needs to be manually called. +func (s *BaseIntegrationSuite) SetupTest() { + s.Assertions = require.New(s.T()) + + s.Ctx = s.T().Context() +} diff --git a/libwallet/presentation/api/wallet_service.pb.go b/libwallet/presentation/api/wallet_service.pb.go index 9b4e222f..97d60e96 100644 --- a/libwallet/presentation/api/wallet_service.pb.go +++ b/libwallet/presentation/api/wallet_service.pb.go @@ -202,27 +202,28 @@ func (b0 ErrorDetail_builder) Build() *ErrorDetail { return m0 } -type XpubResponse struct { - state protoimpl.MessageState `protogen:"opaque.v1"` - xxx_hidden_Base58Xpub string `protobuf:"bytes,1,opt,name=base58_xpub,json=base58Xpub,proto3"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache +type SetupSecurityCardResponse struct { + state protoimpl.MessageState `protogen:"opaque.v1"` + xxx_hidden_IsKnownProvider bool `protobuf:"varint,1,opt,name=is_known_provider,json=isKnownProvider,proto3"` + xxx_hidden_IsCardAlreadyUsed bool `protobuf:"varint,2,opt,name=is_card_already_used,json=isCardAlreadyUsed,proto3"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } -func (x *XpubResponse) Reset() { - *x = XpubResponse{} +func (x *SetupSecurityCardResponse) Reset() { + *x = SetupSecurityCardResponse{} mi := &file_wallet_service_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *XpubResponse) String() string { +func (x *SetupSecurityCardResponse) String() string { return protoimpl.X.MessageStringOf(x) } -func (*XpubResponse) ProtoMessage() {} +func (*SetupSecurityCardResponse) ProtoMessage() {} -func (x *XpubResponse) ProtoReflect() protoreflect.Message { +func (x *SetupSecurityCardResponse) ProtoReflect() protoreflect.Message { mi := &file_wallet_service_proto_msgTypes[1] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -234,32 +235,45 @@ func (x *XpubResponse) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -func (x *XpubResponse) GetBase58Xpub() string { +func (x *SetupSecurityCardResponse) GetIsKnownProvider() bool { + if x != nil { + return x.xxx_hidden_IsKnownProvider + } + return false +} + +func (x *SetupSecurityCardResponse) GetIsCardAlreadyUsed() bool { if x != nil { - return x.xxx_hidden_Base58Xpub + return x.xxx_hidden_IsCardAlreadyUsed } - return "" + return false +} + +func (x *SetupSecurityCardResponse) SetIsKnownProvider(v bool) { + x.xxx_hidden_IsKnownProvider = v } -func (x *XpubResponse) SetBase58Xpub(v string) { - x.xxx_hidden_Base58Xpub = v +func (x *SetupSecurityCardResponse) SetIsCardAlreadyUsed(v bool) { + x.xxx_hidden_IsCardAlreadyUsed = v } -type XpubResponse_builder struct { +type SetupSecurityCardResponse_builder struct { _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. - Base58Xpub string + IsKnownProvider bool + IsCardAlreadyUsed bool } -func (b0 XpubResponse_builder) Build() *XpubResponse { - m0 := &XpubResponse{} +func (b0 SetupSecurityCardResponse_builder) Build() *SetupSecurityCardResponse { + m0 := &SetupSecurityCardResponse{} b, x := &b0, m0 _, _ = b, x - x.xxx_hidden_Base58Xpub = b.Base58Xpub + x.xxx_hidden_IsKnownProvider = b.IsKnownProvider + x.xxx_hidden_IsCardAlreadyUsed = b.IsCardAlreadyUsed return m0 } -type SetupSecurityCardResponse struct { +type PairSignAndSubmitChallengeResponse struct { state protoimpl.MessageState `protogen:"opaque.v1"` xxx_hidden_IsKnownProvider bool `protobuf:"varint,1,opt,name=is_known_provider,json=isKnownProvider,proto3"` xxx_hidden_IsCardAlreadyUsed bool `protobuf:"varint,2,opt,name=is_card_already_used,json=isCardAlreadyUsed,proto3"` @@ -267,20 +281,20 @@ type SetupSecurityCardResponse struct { sizeCache protoimpl.SizeCache } -func (x *SetupSecurityCardResponse) Reset() { - *x = SetupSecurityCardResponse{} +func (x *PairSignAndSubmitChallengeResponse) Reset() { + *x = PairSignAndSubmitChallengeResponse{} mi := &file_wallet_service_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *SetupSecurityCardResponse) String() string { +func (x *PairSignAndSubmitChallengeResponse) String() string { return protoimpl.X.MessageStringOf(x) } -func (*SetupSecurityCardResponse) ProtoMessage() {} +func (*PairSignAndSubmitChallengeResponse) ProtoMessage() {} -func (x *SetupSecurityCardResponse) ProtoReflect() protoreflect.Message { +func (x *PairSignAndSubmitChallengeResponse) ProtoReflect() protoreflect.Message { mi := &file_wallet_service_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -292,37 +306,37 @@ func (x *SetupSecurityCardResponse) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -func (x *SetupSecurityCardResponse) GetIsKnownProvider() bool { +func (x *PairSignAndSubmitChallengeResponse) GetIsKnownProvider() bool { if x != nil { return x.xxx_hidden_IsKnownProvider } return false } -func (x *SetupSecurityCardResponse) GetIsCardAlreadyUsed() bool { +func (x *PairSignAndSubmitChallengeResponse) GetIsCardAlreadyUsed() bool { if x != nil { return x.xxx_hidden_IsCardAlreadyUsed } return false } -func (x *SetupSecurityCardResponse) SetIsKnownProvider(v bool) { +func (x *PairSignAndSubmitChallengeResponse) SetIsKnownProvider(v bool) { x.xxx_hidden_IsKnownProvider = v } -func (x *SetupSecurityCardResponse) SetIsCardAlreadyUsed(v bool) { +func (x *PairSignAndSubmitChallengeResponse) SetIsCardAlreadyUsed(v bool) { x.xxx_hidden_IsCardAlreadyUsed = v } -type SetupSecurityCardResponse_builder struct { +type PairSignAndSubmitChallengeResponse_builder struct { _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. IsKnownProvider bool IsCardAlreadyUsed bool } -func (b0 SetupSecurityCardResponse_builder) Build() *SetupSecurityCardResponse { - m0 := &SetupSecurityCardResponse{} +func (b0 PairSignAndSubmitChallengeResponse_builder) Build() *PairSignAndSubmitChallengeResponse { + m0 := &PairSignAndSubmitChallengeResponse{} b, x := &b0, m0 _, _ = b, x x.xxx_hidden_IsKnownProvider = b.IsKnownProvider @@ -330,27 +344,29 @@ func (b0 SetupSecurityCardResponse_builder) Build() *SetupSecurityCardResponse { return m0 } -type SignMessageSecurityCardRequest struct { - state protoimpl.MessageState `protogen:"opaque.v1"` - xxx_hidden_MessageHex string `protobuf:"bytes,1,opt,name=message_hex,json=messageHex,proto3"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache +// Progress event streamed by PairSignAndSubmitChallenge. Exactly one +// variant of the oneof is set per event. +type PairSignAndSubmitChallengeProgress struct { + state protoimpl.MessageState `protogen:"opaque.v1"` + xxx_hidden_Event isPairSignAndSubmitChallengeProgress_Event `protobuf_oneof:"event"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } -func (x *SignMessageSecurityCardRequest) Reset() { - *x = SignMessageSecurityCardRequest{} +func (x *PairSignAndSubmitChallengeProgress) Reset() { + *x = PairSignAndSubmitChallengeProgress{} mi := &file_wallet_service_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *SignMessageSecurityCardRequest) String() string { +func (x *PairSignAndSubmitChallengeProgress) String() string { return protoimpl.X.MessageStringOf(x) } -func (*SignMessageSecurityCardRequest) ProtoMessage() {} +func (*PairSignAndSubmitChallengeProgress) ProtoMessage() {} -func (x *SignMessageSecurityCardRequest) ProtoReflect() protoreflect.Message { +func (x *PairSignAndSubmitChallengeProgress) ProtoReflect() protoreflect.Message { mi := &file_wallet_service_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -362,53 +378,213 @@ func (x *SignMessageSecurityCardRequest) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -func (x *SignMessageSecurityCardRequest) GetMessageHex() string { +func (x *PairSignAndSubmitChallengeProgress) GetRefreshingChallenge() *RefreshingChallenge { if x != nil { - return x.xxx_hidden_MessageHex + if x, ok := x.xxx_hidden_Event.(*pairSignAndSubmitChallengeProgress_RefreshingChallenge); ok { + return x.RefreshingChallenge + } + } + return nil +} + +func (x *PairSignAndSubmitChallengeProgress) GetChallengeSigned() *ChallengeSigned { + if x != nil { + if x, ok := x.xxx_hidden_Event.(*pairSignAndSubmitChallengeProgress_ChallengeSigned); ok { + return x.ChallengeSigned + } + } + return nil +} + +func (x *PairSignAndSubmitChallengeProgress) GetCompleted() *PairSignAndSubmitChallengeResponse { + if x != nil { + if x, ok := x.xxx_hidden_Event.(*pairSignAndSubmitChallengeProgress_Completed); ok { + return x.Completed + } + } + return nil +} + +func (x *PairSignAndSubmitChallengeProgress) SetRefreshingChallenge(v *RefreshingChallenge) { + if v == nil { + x.xxx_hidden_Event = nil + return + } + x.xxx_hidden_Event = &pairSignAndSubmitChallengeProgress_RefreshingChallenge{v} +} + +func (x *PairSignAndSubmitChallengeProgress) SetChallengeSigned(v *ChallengeSigned) { + if v == nil { + x.xxx_hidden_Event = nil + return + } + x.xxx_hidden_Event = &pairSignAndSubmitChallengeProgress_ChallengeSigned{v} +} + +func (x *PairSignAndSubmitChallengeProgress) SetCompleted(v *PairSignAndSubmitChallengeResponse) { + if v == nil { + x.xxx_hidden_Event = nil + return + } + x.xxx_hidden_Event = &pairSignAndSubmitChallengeProgress_Completed{v} +} + +func (x *PairSignAndSubmitChallengeProgress) HasEvent() bool { + if x == nil { + return false + } + return x.xxx_hidden_Event != nil +} + +func (x *PairSignAndSubmitChallengeProgress) HasRefreshingChallenge() bool { + if x == nil { + return false + } + _, ok := x.xxx_hidden_Event.(*pairSignAndSubmitChallengeProgress_RefreshingChallenge) + return ok +} + +func (x *PairSignAndSubmitChallengeProgress) HasChallengeSigned() bool { + if x == nil { + return false + } + _, ok := x.xxx_hidden_Event.(*pairSignAndSubmitChallengeProgress_ChallengeSigned) + return ok +} + +func (x *PairSignAndSubmitChallengeProgress) HasCompleted() bool { + if x == nil { + return false + } + _, ok := x.xxx_hidden_Event.(*pairSignAndSubmitChallengeProgress_Completed) + return ok +} + +func (x *PairSignAndSubmitChallengeProgress) ClearEvent() { + x.xxx_hidden_Event = nil +} + +func (x *PairSignAndSubmitChallengeProgress) ClearRefreshingChallenge() { + if _, ok := x.xxx_hidden_Event.(*pairSignAndSubmitChallengeProgress_RefreshingChallenge); ok { + x.xxx_hidden_Event = nil + } +} + +func (x *PairSignAndSubmitChallengeProgress) ClearChallengeSigned() { + if _, ok := x.xxx_hidden_Event.(*pairSignAndSubmitChallengeProgress_ChallengeSigned); ok { + x.xxx_hidden_Event = nil + } +} + +func (x *PairSignAndSubmitChallengeProgress) ClearCompleted() { + if _, ok := x.xxx_hidden_Event.(*pairSignAndSubmitChallengeProgress_Completed); ok { + x.xxx_hidden_Event = nil } - return "" } -func (x *SignMessageSecurityCardRequest) SetMessageHex(v string) { - x.xxx_hidden_MessageHex = v +const PairSignAndSubmitChallengeProgress_Event_not_set_case case_PairSignAndSubmitChallengeProgress_Event = 0 +const PairSignAndSubmitChallengeProgress_RefreshingChallenge_case case_PairSignAndSubmitChallengeProgress_Event = 1 +const PairSignAndSubmitChallengeProgress_ChallengeSigned_case case_PairSignAndSubmitChallengeProgress_Event = 2 +const PairSignAndSubmitChallengeProgress_Completed_case case_PairSignAndSubmitChallengeProgress_Event = 3 + +func (x *PairSignAndSubmitChallengeProgress) WhichEvent() case_PairSignAndSubmitChallengeProgress_Event { + if x == nil { + return PairSignAndSubmitChallengeProgress_Event_not_set_case + } + switch x.xxx_hidden_Event.(type) { + case *pairSignAndSubmitChallengeProgress_RefreshingChallenge: + return PairSignAndSubmitChallengeProgress_RefreshingChallenge_case + case *pairSignAndSubmitChallengeProgress_ChallengeSigned: + return PairSignAndSubmitChallengeProgress_ChallengeSigned_case + case *pairSignAndSubmitChallengeProgress_Completed: + return PairSignAndSubmitChallengeProgress_Completed_case + default: + return PairSignAndSubmitChallengeProgress_Event_not_set_case + } } -type SignMessageSecurityCardRequest_builder struct { +type PairSignAndSubmitChallengeProgress_builder struct { _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. - MessageHex string + // Fields of oneof xxx_hidden_Event: + RefreshingChallenge *RefreshingChallenge + ChallengeSigned *ChallengeSigned + Completed *PairSignAndSubmitChallengeResponse + // -- end of xxx_hidden_Event } -func (b0 SignMessageSecurityCardRequest_builder) Build() *SignMessageSecurityCardRequest { - m0 := &SignMessageSecurityCardRequest{} +func (b0 PairSignAndSubmitChallengeProgress_builder) Build() *PairSignAndSubmitChallengeProgress { + m0 := &PairSignAndSubmitChallengeProgress{} b, x := &b0, m0 _, _ = b, x - x.xxx_hidden_MessageHex = b.MessageHex + if b.RefreshingChallenge != nil { + x.xxx_hidden_Event = &pairSignAndSubmitChallengeProgress_RefreshingChallenge{b.RefreshingChallenge} + } + if b.ChallengeSigned != nil { + x.xxx_hidden_Event = &pairSignAndSubmitChallengeProgress_ChallengeSigned{b.ChallengeSigned} + } + if b.Completed != nil { + x.xxx_hidden_Event = &pairSignAndSubmitChallengeProgress_Completed{b.Completed} + } return m0 } -type SignMessageSecurityCardResponse struct { - state protoimpl.MessageState `protogen:"opaque.v1"` - xxx_hidden_SignedMessageHex string `protobuf:"bytes,1,opt,name=signed_message_hex,json=signedMessageHex,proto3"` - xxx_hidden_IsValidated bool `protobuf:"varint,2,opt,name=is_validated,json=isValidated,proto3"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache +type case_PairSignAndSubmitChallengeProgress_Event protoreflect.FieldNumber + +func (x case_PairSignAndSubmitChallengeProgress_Event) String() string { + md := file_wallet_service_proto_msgTypes[3].Descriptor() + if x == 0 { + return "not set" + } + return protoimpl.X.MessageFieldStringOf(md, protoreflect.FieldNumber(x)) +} + +type isPairSignAndSubmitChallengeProgress_Event interface { + isPairSignAndSubmitChallengeProgress_Event() +} + +type pairSignAndSubmitChallengeProgress_RefreshingChallenge struct { + RefreshingChallenge *RefreshingChallenge `protobuf:"bytes,1,opt,name=refreshing_challenge,json=refreshingChallenge,proto3,oneof"` +} + +type pairSignAndSubmitChallengeProgress_ChallengeSigned struct { + ChallengeSigned *ChallengeSigned `protobuf:"bytes,2,opt,name=challenge_signed,json=challengeSigned,proto3,oneof"` +} + +type pairSignAndSubmitChallengeProgress_Completed struct { + Completed *PairSignAndSubmitChallengeResponse `protobuf:"bytes,3,opt,name=completed,proto3,oneof"` +} + +func (*pairSignAndSubmitChallengeProgress_RefreshingChallenge) isPairSignAndSubmitChallengeProgress_Event() { +} + +func (*pairSignAndSubmitChallengeProgress_ChallengeSigned) isPairSignAndSubmitChallengeProgress_Event() { +} + +func (*pairSignAndSubmitChallengeProgress_Completed) isPairSignAndSubmitChallengeProgress_Event() {} + +// Emitted just before the action refreshes a missing/stale pending +// challenge. Only fires when the self-heal path runs. +type RefreshingChallenge struct { + state protoimpl.MessageState `protogen:"opaque.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } -func (x *SignMessageSecurityCardResponse) Reset() { - *x = SignMessageSecurityCardResponse{} +func (x *RefreshingChallenge) Reset() { + *x = RefreshingChallenge{} mi := &file_wallet_service_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *SignMessageSecurityCardResponse) String() string { +func (x *RefreshingChallenge) String() string { return protoimpl.X.MessageStringOf(x) } -func (*SignMessageSecurityCardResponse) ProtoMessage() {} +func (*RefreshingChallenge) ProtoMessage() {} -func (x *SignMessageSecurityCardResponse) ProtoReflect() protoreflect.Message { +func (x *RefreshingChallenge) ProtoReflect() protoreflect.Message { mi := &file_wallet_service_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -420,41 +596,60 @@ func (x *SignMessageSecurityCardResponse) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -func (x *SignMessageSecurityCardResponse) GetSignedMessageHex() string { - if x != nil { - return x.xxx_hidden_SignedMessageHex - } - return "" +type RefreshingChallenge_builder struct { + _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. + } -func (x *SignMessageSecurityCardResponse) GetIsValidated() bool { - if x != nil { - return x.xxx_hidden_IsValidated - } - return false +func (b0 RefreshingChallenge_builder) Build() *RefreshingChallenge { + m0 := &RefreshingChallenge{} + b, x := &b0, m0 + _, _ = b, x + return m0 +} + +// Emitted right after the signing device (e.g. card) produces its response, +// before the action hands the signed challenge to Houston for validation. +type ChallengeSigned struct { + state protoimpl.MessageState `protogen:"opaque.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ChallengeSigned) Reset() { + *x = ChallengeSigned{} + mi := &file_wallet_service_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } -func (x *SignMessageSecurityCardResponse) SetSignedMessageHex(v string) { - x.xxx_hidden_SignedMessageHex = v +func (x *ChallengeSigned) String() string { + return protoimpl.X.MessageStringOf(x) } -func (x *SignMessageSecurityCardResponse) SetIsValidated(v bool) { - x.xxx_hidden_IsValidated = v +func (*ChallengeSigned) ProtoMessage() {} + +func (x *ChallengeSigned) ProtoReflect() protoreflect.Message { + mi := &file_wallet_service_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) } -type SignMessageSecurityCardResponse_builder struct { +type ChallengeSigned_builder struct { _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. - SignedMessageHex string - IsValidated bool } -func (b0 SignMessageSecurityCardResponse_builder) Build() *SignMessageSecurityCardResponse { - m0 := &SignMessageSecurityCardResponse{} +func (b0 ChallengeSigned_builder) Build() *ChallengeSigned { + m0 := &ChallengeSigned{} b, x := &b0, m0 _, _ = b, x - x.xxx_hidden_SignedMessageHex = b.SignedMessageHex - x.xxx_hidden_IsValidated = b.IsValidated return m0 } @@ -467,7 +662,7 @@ type DiagnosticSessionDescriptor struct { func (x *DiagnosticSessionDescriptor) Reset() { *x = DiagnosticSessionDescriptor{} - mi := &file_wallet_service_proto_msgTypes[5] + mi := &file_wallet_service_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -479,7 +674,7 @@ func (x *DiagnosticSessionDescriptor) String() string { func (*DiagnosticSessionDescriptor) ProtoMessage() {} func (x *DiagnosticSessionDescriptor) ProtoReflect() protoreflect.Message { - mi := &file_wallet_service_proto_msgTypes[5] + mi := &file_wallet_service_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -524,7 +719,7 @@ type ScanProgressUpdate struct { func (x *ScanProgressUpdate) Reset() { *x = ScanProgressUpdate{} - mi := &file_wallet_service_proto_msgTypes[6] + mi := &file_wallet_service_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -536,7 +731,7 @@ func (x *ScanProgressUpdate) String() string { func (*ScanProgressUpdate) ProtoMessage() {} func (x *ScanProgressUpdate) ProtoReflect() protoreflect.Message { - mi := &file_wallet_service_proto_msgTypes[6] + mi := &file_wallet_service_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -663,7 +858,7 @@ func (b0 ScanProgressUpdate_builder) Build() *ScanProgressUpdate { type case_ScanProgressUpdate_Update protoreflect.FieldNumber func (x case_ScanProgressUpdate_Update) String() string { - md := file_wallet_service_proto_msgTypes[6].Descriptor() + md := file_wallet_service_proto_msgTypes[7].Descriptor() if x == 0 { return "not set" } @@ -696,7 +891,7 @@ type FoundUtxoReport struct { func (x *FoundUtxoReport) Reset() { *x = FoundUtxoReport{} - mi := &file_wallet_service_proto_msgTypes[7] + mi := &file_wallet_service_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -708,7 +903,7 @@ func (x *FoundUtxoReport) String() string { func (*FoundUtxoReport) ProtoMessage() {} func (x *FoundUtxoReport) ProtoReflect() protoreflect.Message { - mi := &file_wallet_service_proto_msgTypes[7] + mi := &file_wallet_service_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -766,7 +961,7 @@ type ScanComplete struct { func (x *ScanComplete) Reset() { *x = ScanComplete{} - mi := &file_wallet_service_proto_msgTypes[8] + mi := &file_wallet_service_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -778,7 +973,7 @@ func (x *ScanComplete) String() string { func (*ScanComplete) ProtoMessage() {} func (x *ScanComplete) ProtoReflect() protoreflect.Message { - mi := &file_wallet_service_proto_msgTypes[8] + mi := &file_wallet_service_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -824,7 +1019,7 @@ type DiagnosticSubmitStatus struct { func (x *DiagnosticSubmitStatus) Reset() { *x = DiagnosticSubmitStatus{} - mi := &file_wallet_service_proto_msgTypes[9] + mi := &file_wallet_service_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -836,7 +1031,7 @@ func (x *DiagnosticSubmitStatus) String() string { func (*DiagnosticSubmitStatus) ProtoMessage() {} func (x *DiagnosticSubmitStatus) ProtoReflect() protoreflect.Message { - mi := &file_wallet_service_proto_msgTypes[9] + mi := &file_wallet_service_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -896,7 +1091,7 @@ type PrepareSweepTxRequest struct { func (x *PrepareSweepTxRequest) Reset() { *x = PrepareSweepTxRequest{} - mi := &file_wallet_service_proto_msgTypes[10] + mi := &file_wallet_service_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -908,7 +1103,7 @@ func (x *PrepareSweepTxRequest) String() string { func (*PrepareSweepTxRequest) ProtoMessage() {} func (x *PrepareSweepTxRequest) ProtoReflect() protoreflect.Message { - mi := &file_wallet_service_proto_msgTypes[10] + mi := &file_wallet_service_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -993,7 +1188,7 @@ type PrepareSweepTxResponse struct { func (x *PrepareSweepTxResponse) Reset() { *x = PrepareSweepTxResponse{} - mi := &file_wallet_service_proto_msgTypes[11] + mi := &file_wallet_service_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1005,7 +1200,7 @@ func (x *PrepareSweepTxResponse) String() string { func (*PrepareSweepTxResponse) ProtoMessage() {} func (x *PrepareSweepTxResponse) ProtoReflect() protoreflect.Message { - mi := &file_wallet_service_proto_msgTypes[11] + mi := &file_wallet_service_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1101,7 +1296,7 @@ type SignAndBroadcastSweepTxRequest struct { func (x *SignAndBroadcastSweepTxRequest) Reset() { *x = SignAndBroadcastSweepTxRequest{} - mi := &file_wallet_service_proto_msgTypes[12] + mi := &file_wallet_service_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1113,7 +1308,7 @@ func (x *SignAndBroadcastSweepTxRequest) String() string { func (*SignAndBroadcastSweepTxRequest) ProtoMessage() {} func (x *SignAndBroadcastSweepTxRequest) ProtoReflect() protoreflect.Message { - mi := &file_wallet_service_proto_msgTypes[12] + mi := &file_wallet_service_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1183,7 +1378,7 @@ type SignAndBroadcastSweepTxResponse struct { func (x *SignAndBroadcastSweepTxResponse) Reset() { *x = SignAndBroadcastSweepTxResponse{} - mi := &file_wallet_service_proto_msgTypes[13] + mi := &file_wallet_service_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1195,7 +1390,7 @@ func (x *SignAndBroadcastSweepTxResponse) String() string { func (*SignAndBroadcastSweepTxResponse) ProtoMessage() {} func (x *SignAndBroadcastSweepTxResponse) ProtoReflect() protoreflect.Message { - mi := &file_wallet_service_proto_msgTypes[13] + mi := &file_wallet_service_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1268,7 +1463,7 @@ type ChallengeSetupRequest struct { func (x *ChallengeSetupRequest) Reset() { *x = ChallengeSetupRequest{} - mi := &file_wallet_service_proto_msgTypes[14] + mi := &file_wallet_service_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1280,7 +1475,7 @@ func (x *ChallengeSetupRequest) String() string { func (*ChallengeSetupRequest) ProtoMessage() {} func (x *ChallengeSetupRequest) ProtoReflect() protoreflect.Message { - mi := &file_wallet_service_proto_msgTypes[14] + mi := &file_wallet_service_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1380,7 +1575,7 @@ type SetupChallengeResponse struct { func (x *SetupChallengeResponse) Reset() { *x = SetupChallengeResponse{} - mi := &file_wallet_service_proto_msgTypes[15] + mi := &file_wallet_service_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1392,7 +1587,7 @@ func (x *SetupChallengeResponse) String() string { func (*SetupChallengeResponse) ProtoMessage() {} func (x *SetupChallengeResponse) ProtoReflect() protoreflect.Message { - mi := &file_wallet_service_proto_msgTypes[15] + mi := &file_wallet_service_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1488,7 +1683,7 @@ type FinishRecoveryCodeSetupRequest struct { func (x *FinishRecoveryCodeSetupRequest) Reset() { *x = FinishRecoveryCodeSetupRequest{} - mi := &file_wallet_service_proto_msgTypes[16] + mi := &file_wallet_service_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1500,7 +1695,7 @@ func (x *FinishRecoveryCodeSetupRequest) String() string { func (*FinishRecoveryCodeSetupRequest) ProtoMessage() {} func (x *FinishRecoveryCodeSetupRequest) ProtoReflect() protoreflect.Message { - mi := &file_wallet_service_proto_msgTypes[16] + mi := &file_wallet_service_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1545,7 +1740,7 @@ type PopulateEncryptedMuunKeyRequest struct { func (x *PopulateEncryptedMuunKeyRequest) Reset() { *x = PopulateEncryptedMuunKeyRequest{} - mi := &file_wallet_service_proto_msgTypes[17] + mi := &file_wallet_service_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1557,7 +1752,7 @@ func (x *PopulateEncryptedMuunKeyRequest) String() string { func (*PopulateEncryptedMuunKeyRequest) ProtoMessage() {} func (x *PopulateEncryptedMuunKeyRequest) ProtoReflect() protoreflect.Message { - mi := &file_wallet_service_proto_msgTypes[17] + mi := &file_wallet_service_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1602,7 +1797,7 @@ type Struct struct { func (x *Struct) Reset() { *x = Struct{} - mi := &file_wallet_service_proto_msgTypes[18] + mi := &file_wallet_service_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1614,7 +1809,7 @@ func (x *Struct) String() string { func (*Struct) ProtoMessage() {} func (x *Struct) ProtoReflect() protoreflect.Message { - mi := &file_wallet_service_proto_msgTypes[18] + mi := &file_wallet_service_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1660,7 +1855,7 @@ type Value struct { func (x *Value) Reset() { *x = Value{} - mi := &file_wallet_service_proto_msgTypes[19] + mi := &file_wallet_service_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1672,7 +1867,7 @@ func (x *Value) String() string { func (*Value) ProtoMessage() {} func (x *Value) ProtoReflect() protoreflect.Message { - mi := &file_wallet_service_proto_msgTypes[19] + mi := &file_wallet_service_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1927,7 +2122,7 @@ func (b0 Value_builder) Build() *Value { type case_Value_Kind protoreflect.FieldNumber func (x case_Value_Kind) String() string { - md := file_wallet_service_proto_msgTypes[19].Descriptor() + md := file_wallet_service_proto_msgTypes[20].Descriptor() if x == 0 { return "not set" } @@ -1984,7 +2179,7 @@ type SaveRequest struct { func (x *SaveRequest) Reset() { *x = SaveRequest{} - mi := &file_wallet_service_proto_msgTypes[20] + mi := &file_wallet_service_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1996,7 +2191,7 @@ func (x *SaveRequest) String() string { func (*SaveRequest) ProtoMessage() {} func (x *SaveRequest) ProtoReflect() protoreflect.Message { - mi := &file_wallet_service_proto_msgTypes[20] + mi := &file_wallet_service_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2065,7 +2260,7 @@ type GetRequest struct { func (x *GetRequest) Reset() { *x = GetRequest{} - mi := &file_wallet_service_proto_msgTypes[21] + mi := &file_wallet_service_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2077,7 +2272,7 @@ func (x *GetRequest) String() string { func (*GetRequest) ProtoMessage() {} func (x *GetRequest) ProtoReflect() protoreflect.Message { - mi := &file_wallet_service_proto_msgTypes[21] + mi := &file_wallet_service_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2122,7 +2317,7 @@ type GetResponse struct { func (x *GetResponse) Reset() { *x = GetResponse{} - mi := &file_wallet_service_proto_msgTypes[22] + mi := &file_wallet_service_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2134,7 +2329,7 @@ func (x *GetResponse) String() string { func (*GetResponse) ProtoMessage() {} func (x *GetResponse) ProtoReflect() protoreflect.Message { - mi := &file_wallet_service_proto_msgTypes[22] + mi := &file_wallet_service_proto_msgTypes[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2190,7 +2385,7 @@ type DeleteRequest struct { func (x *DeleteRequest) Reset() { *x = DeleteRequest{} - mi := &file_wallet_service_proto_msgTypes[23] + mi := &file_wallet_service_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2202,7 +2397,7 @@ func (x *DeleteRequest) String() string { func (*DeleteRequest) ProtoMessage() {} func (x *DeleteRequest) ProtoReflect() protoreflect.Message { - mi := &file_wallet_service_proto_msgTypes[23] + mi := &file_wallet_service_proto_msgTypes[24] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2247,7 +2442,7 @@ type SaveBatchRequest struct { func (x *SaveBatchRequest) Reset() { *x = SaveBatchRequest{} - mi := &file_wallet_service_proto_msgTypes[24] + mi := &file_wallet_service_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2259,7 +2454,7 @@ func (x *SaveBatchRequest) String() string { func (*SaveBatchRequest) ProtoMessage() {} func (x *SaveBatchRequest) ProtoReflect() protoreflect.Message { - mi := &file_wallet_service_proto_msgTypes[24] + mi := &file_wallet_service_proto_msgTypes[25] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2315,7 +2510,7 @@ type GetBatchRequest struct { func (x *GetBatchRequest) Reset() { *x = GetBatchRequest{} - mi := &file_wallet_service_proto_msgTypes[25] + mi := &file_wallet_service_proto_msgTypes[26] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2327,7 +2522,7 @@ func (x *GetBatchRequest) String() string { func (*GetBatchRequest) ProtoMessage() {} func (x *GetBatchRequest) ProtoReflect() protoreflect.Message { - mi := &file_wallet_service_proto_msgTypes[25] + mi := &file_wallet_service_proto_msgTypes[26] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2372,7 +2567,7 @@ type GetBatchResponse struct { func (x *GetBatchResponse) Reset() { *x = GetBatchResponse{} - mi := &file_wallet_service_proto_msgTypes[26] + mi := &file_wallet_service_proto_msgTypes[27] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2384,7 +2579,7 @@ func (x *GetBatchResponse) String() string { func (*GetBatchResponse) ProtoMessage() {} func (x *GetBatchResponse) ProtoReflect() protoreflect.Message { - mi := &file_wallet_service_proto_msgTypes[26] + mi := &file_wallet_service_proto_msgTypes[27] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2440,7 +2635,7 @@ type GetByPrefixRequest struct { func (x *GetByPrefixRequest) Reset() { *x = GetByPrefixRequest{} - mi := &file_wallet_service_proto_msgTypes[27] + mi := &file_wallet_service_proto_msgTypes[28] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2452,7 +2647,7 @@ func (x *GetByPrefixRequest) String() string { func (*GetByPrefixRequest) ProtoMessage() {} func (x *GetByPrefixRequest) ProtoReflect() protoreflect.Message { - mi := &file_wallet_service_proto_msgTypes[27] + mi := &file_wallet_service_proto_msgTypes[28] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2488,34 +2683,29 @@ func (b0 GetByPrefixRequest_builder) Build() *GetByPrefixRequest { return m0 } -type SecurityCardsProvider struct { - state protoimpl.MessageState `protogen:"opaque.v1"` - xxx_hidden_Name string `protobuf:"bytes,1,opt,name=name,proto3"` - xxx_hidden_SecurityCards *[]*SecurityCard `protobuf:"bytes,2,rep,name=security_cards,json=securityCards,proto3"` - xxx_hidden_Currency string `protobuf:"bytes,3,opt,name=currency,proto3"` - xxx_hidden_ColorHex string `protobuf:"bytes,4,opt,name=color_hex,json=colorHex,proto3"` - xxx_hidden_Material string `protobuf:"bytes,5,opt,name=material,proto3"` - xxx_hidden_Price float64 `protobuf:"fixed64,6,opt,name=price,proto3"` - xxx_hidden_ShippingCost float64 `protobuf:"fixed64,7,opt,name=shipping_cost,json=shippingCost,proto3"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache +type SecurityCardsProviderTheme struct { + state protoimpl.MessageState `protogen:"opaque.v1"` + xxx_hidden_PrimaryColor string `protobuf:"bytes,1,opt,name=primary_color,json=primaryColor,proto3"` + xxx_hidden_SurfaceColor string `protobuf:"bytes,2,opt,name=surface_color,json=surfaceColor,proto3"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } -func (x *SecurityCardsProvider) Reset() { - *x = SecurityCardsProvider{} - mi := &file_wallet_service_proto_msgTypes[28] +func (x *SecurityCardsProviderTheme) Reset() { + *x = SecurityCardsProviderTheme{} + mi := &file_wallet_service_proto_msgTypes[29] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *SecurityCardsProvider) String() string { +func (x *SecurityCardsProviderTheme) String() string { return protoimpl.X.MessageStringOf(x) } -func (*SecurityCardsProvider) ProtoMessage() {} +func (*SecurityCardsProviderTheme) ProtoMessage() {} -func (x *SecurityCardsProvider) ProtoReflect() protoreflect.Message { - mi := &file_wallet_service_proto_msgTypes[28] +func (x *SecurityCardsProviderTheme) ProtoReflect() protoreflect.Message { + mi := &file_wallet_service_proto_msgTypes[29] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2526,134 +2716,139 @@ func (x *SecurityCardsProvider) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -func (x *SecurityCardsProvider) GetName() string { +func (x *SecurityCardsProviderTheme) GetPrimaryColor() string { if x != nil { - return x.xxx_hidden_Name + return x.xxx_hidden_PrimaryColor } return "" } -func (x *SecurityCardsProvider) GetSecurityCards() []*SecurityCard { +func (x *SecurityCardsProviderTheme) GetSurfaceColor() string { if x != nil { - if x.xxx_hidden_SecurityCards != nil { - return *x.xxx_hidden_SecurityCards - } + return x.xxx_hidden_SurfaceColor } - return nil + return "" } -func (x *SecurityCardsProvider) GetCurrency() string { - if x != nil { - return x.xxx_hidden_Currency - } - return "" +func (x *SecurityCardsProviderTheme) SetPrimaryColor(v string) { + x.xxx_hidden_PrimaryColor = v } -func (x *SecurityCardsProvider) GetColorHex() string { - if x != nil { - return x.xxx_hidden_ColorHex - } - return "" +func (x *SecurityCardsProviderTheme) SetSurfaceColor(v string) { + x.xxx_hidden_SurfaceColor = v } -func (x *SecurityCardsProvider) GetMaterial() string { - if x != nil { - return x.xxx_hidden_Material - } - return "" +type SecurityCardsProviderTheme_builder struct { + _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. + + PrimaryColor string + SurfaceColor string } -func (x *SecurityCardsProvider) GetPrice() float64 { - if x != nil { - return x.xxx_hidden_Price - } - return 0 +func (b0 SecurityCardsProviderTheme_builder) Build() *SecurityCardsProviderTheme { + m0 := &SecurityCardsProviderTheme{} + b, x := &b0, m0 + _, _ = b, x + x.xxx_hidden_PrimaryColor = b.PrimaryColor + x.xxx_hidden_SurfaceColor = b.SurfaceColor + return m0 } -func (x *SecurityCardsProvider) GetShippingCost() float64 { - if x != nil { - return x.xxx_hidden_ShippingCost - } - return 0 +type PriceInfo struct { + state protoimpl.MessageState `protogen:"opaque.v1"` + xxx_hidden_CurrencyCode string `protobuf:"bytes,1,opt,name=currency_code,json=currencyCode,proto3"` + xxx_hidden_Amount string `protobuf:"bytes,2,opt,name=amount,proto3"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } -func (x *SecurityCardsProvider) SetName(v string) { - x.xxx_hidden_Name = v +func (x *PriceInfo) Reset() { + *x = PriceInfo{} + mi := &file_wallet_service_proto_msgTypes[30] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } -func (x *SecurityCardsProvider) SetSecurityCards(v []*SecurityCard) { - x.xxx_hidden_SecurityCards = &v +func (x *PriceInfo) String() string { + return protoimpl.X.MessageStringOf(x) } -func (x *SecurityCardsProvider) SetCurrency(v string) { - x.xxx_hidden_Currency = v +func (*PriceInfo) ProtoMessage() {} + +func (x *PriceInfo) ProtoReflect() protoreflect.Message { + mi := &file_wallet_service_proto_msgTypes[30] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) } -func (x *SecurityCardsProvider) SetColorHex(v string) { - x.xxx_hidden_ColorHex = v +func (x *PriceInfo) GetCurrencyCode() string { + if x != nil { + return x.xxx_hidden_CurrencyCode + } + return "" } -func (x *SecurityCardsProvider) SetMaterial(v string) { - x.xxx_hidden_Material = v +func (x *PriceInfo) GetAmount() string { + if x != nil { + return x.xxx_hidden_Amount + } + return "" } -func (x *SecurityCardsProvider) SetPrice(v float64) { - x.xxx_hidden_Price = v +func (x *PriceInfo) SetCurrencyCode(v string) { + x.xxx_hidden_CurrencyCode = v } -func (x *SecurityCardsProvider) SetShippingCost(v float64) { - x.xxx_hidden_ShippingCost = v +func (x *PriceInfo) SetAmount(v string) { + x.xxx_hidden_Amount = v } -type SecurityCardsProvider_builder struct { +type PriceInfo_builder struct { _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. - Name string - SecurityCards []*SecurityCard - Currency string - ColorHex string - Material string - Price float64 - ShippingCost float64 + CurrencyCode string + Amount string } -func (b0 SecurityCardsProvider_builder) Build() *SecurityCardsProvider { - m0 := &SecurityCardsProvider{} +func (b0 PriceInfo_builder) Build() *PriceInfo { + m0 := &PriceInfo{} b, x := &b0, m0 _, _ = b, x - x.xxx_hidden_Name = b.Name - x.xxx_hidden_SecurityCards = &b.SecurityCards - x.xxx_hidden_Currency = b.Currency - x.xxx_hidden_ColorHex = b.ColorHex - x.xxx_hidden_Material = b.Material - x.xxx_hidden_Price = b.Price - x.xxx_hidden_ShippingCost = b.ShippingCost + x.xxx_hidden_CurrencyCode = b.CurrencyCode + x.xxx_hidden_Amount = b.Amount return m0 } -type SecurityCard struct { - state protoimpl.MessageState `protogen:"opaque.v1"` - xxx_hidden_Image string `protobuf:"bytes,1,opt,name=image,proto3"` - xxx_hidden_Stock int32 `protobuf:"varint,2,opt,name=stock,proto3"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache +type CountryInfo struct { + state protoimpl.MessageState `protogen:"opaque.v1"` + xxx_hidden_Code string `protobuf:"bytes,1,opt,name=code,proto3"` + xxx_hidden_Name string `protobuf:"bytes,2,opt,name=name,proto3"` + xxx_hidden_Flag string `protobuf:"bytes,3,opt,name=flag,proto3"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } -func (x *SecurityCard) Reset() { - *x = SecurityCard{} - mi := &file_wallet_service_proto_msgTypes[29] +func (x *CountryInfo) Reset() { + *x = CountryInfo{} + mi := &file_wallet_service_proto_msgTypes[31] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *SecurityCard) String() string { +func (x *CountryInfo) String() string { return protoimpl.X.MessageStringOf(x) } -func (*SecurityCard) ProtoMessage() {} +func (*CountryInfo) ProtoMessage() {} -func (x *SecurityCard) ProtoReflect() protoreflect.Message { - mi := &file_wallet_service_proto_msgTypes[29] +func (x *CountryInfo) ProtoReflect() protoreflect.Message { + mi := &file_wallet_service_proto_msgTypes[31] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2664,66 +2859,80 @@ func (x *SecurityCard) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -func (x *SecurityCard) GetImage() string { +func (x *CountryInfo) GetCode() string { if x != nil { - return x.xxx_hidden_Image + return x.xxx_hidden_Code } return "" } -func (x *SecurityCard) GetStock() int32 { +func (x *CountryInfo) GetName() string { if x != nil { - return x.xxx_hidden_Stock + return x.xxx_hidden_Name } - return 0 + return "" +} + +func (x *CountryInfo) GetFlag() string { + if x != nil { + return x.xxx_hidden_Flag + } + return "" } -func (x *SecurityCard) SetImage(v string) { - x.xxx_hidden_Image = v +func (x *CountryInfo) SetCode(v string) { + x.xxx_hidden_Code = v } -func (x *SecurityCard) SetStock(v int32) { - x.xxx_hidden_Stock = v +func (x *CountryInfo) SetName(v string) { + x.xxx_hidden_Name = v } -type SecurityCard_builder struct { +func (x *CountryInfo) SetFlag(v string) { + x.xxx_hidden_Flag = v +} + +type CountryInfo_builder struct { _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. - Image string - Stock int32 + Code string + Name string + Flag string } -func (b0 SecurityCard_builder) Build() *SecurityCard { - m0 := &SecurityCard{} +func (b0 CountryInfo_builder) Build() *CountryInfo { + m0 := &CountryInfo{} b, x := &b0, m0 _, _ = b, x - x.xxx_hidden_Image = b.Image - x.xxx_hidden_Stock = b.Stock + x.xxx_hidden_Code = b.Code + x.xxx_hidden_Name = b.Name + x.xxx_hidden_Flag = b.Flag return m0 } -type GetSecurityCardsMarketplaceResponse struct { - state protoimpl.MessageState `protogen:"opaque.v1"` - xxx_hidden_Providers *[]*SecurityCardsProvider `protobuf:"bytes,1,rep,name=providers,proto3"` +type ShippingPriceInfo struct { + state protoimpl.MessageState `protogen:"opaque.v1"` + xxx_hidden_Price *PriceInfo `protobuf:"bytes,1,opt,name=price,proto3"` + xxx_hidden_Countries *[]*CountryInfo `protobuf:"bytes,2,rep,name=countries,proto3"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } -func (x *GetSecurityCardsMarketplaceResponse) Reset() { - *x = GetSecurityCardsMarketplaceResponse{} - mi := &file_wallet_service_proto_msgTypes[30] +func (x *ShippingPriceInfo) Reset() { + *x = ShippingPriceInfo{} + mi := &file_wallet_service_proto_msgTypes[32] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *GetSecurityCardsMarketplaceResponse) String() string { +func (x *ShippingPriceInfo) String() string { return protoimpl.X.MessageStringOf(x) } -func (*GetSecurityCardsMarketplaceResponse) ProtoMessage() {} +func (*ShippingPriceInfo) ProtoMessage() {} -func (x *GetSecurityCardsMarketplaceResponse) ProtoReflect() protoreflect.Message { - mi := &file_wallet_service_proto_msgTypes[30] +func (x *ShippingPriceInfo) ProtoReflect() protoreflect.Message { + mi := &file_wallet_service_proto_msgTypes[32] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2734,59 +2943,86 @@ func (x *GetSecurityCardsMarketplaceResponse) ProtoReflect() protoreflect.Messag return mi.MessageOf(x) } -func (x *GetSecurityCardsMarketplaceResponse) GetProviders() []*SecurityCardsProvider { +func (x *ShippingPriceInfo) GetPrice() *PriceInfo { if x != nil { - if x.xxx_hidden_Providers != nil { - return *x.xxx_hidden_Providers + return x.xxx_hidden_Price + } + return nil +} + +func (x *ShippingPriceInfo) GetCountries() []*CountryInfo { + if x != nil { + if x.xxx_hidden_Countries != nil { + return *x.xxx_hidden_Countries } } return nil } -func (x *GetSecurityCardsMarketplaceResponse) SetProviders(v []*SecurityCardsProvider) { - x.xxx_hidden_Providers = &v +func (x *ShippingPriceInfo) SetPrice(v *PriceInfo) { + x.xxx_hidden_Price = v } -type GetSecurityCardsMarketplaceResponse_builder struct { +func (x *ShippingPriceInfo) SetCountries(v []*CountryInfo) { + x.xxx_hidden_Countries = &v +} + +func (x *ShippingPriceInfo) HasPrice() bool { + if x == nil { + return false + } + return x.xxx_hidden_Price != nil +} + +func (x *ShippingPriceInfo) ClearPrice() { + x.xxx_hidden_Price = nil +} + +type ShippingPriceInfo_builder struct { _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. - Providers []*SecurityCardsProvider + Price *PriceInfo + Countries []*CountryInfo } -func (b0 GetSecurityCardsMarketplaceResponse_builder) Build() *GetSecurityCardsMarketplaceResponse { - m0 := &GetSecurityCardsMarketplaceResponse{} +func (b0 ShippingPriceInfo_builder) Build() *ShippingPriceInfo { + m0 := &ShippingPriceInfo{} b, x := &b0, m0 _, _ = b, x - x.xxx_hidden_Providers = &b.Providers + x.xxx_hidden_Price = b.Price + x.xxx_hidden_Countries = &b.Countries return m0 } -type EKInputRequest struct { - state protoimpl.MessageState `protogen:"opaque.v1"` - xxx_hidden_FirstEncryptedKey string `protobuf:"bytes,1,opt,name=first_encrypted_key,json=firstEncryptedKey,proto3"` - xxx_hidden_FirstFingerprint string `protobuf:"bytes,2,opt,name=first_fingerprint,json=firstFingerprint,proto3"` - xxx_hidden_SecondEncryptedKey string `protobuf:"bytes,3,opt,name=second_encrypted_key,json=secondEncryptedKey,proto3"` - xxx_hidden_SecondFingerprint string `protobuf:"bytes,4,opt,name=second_fingerprint,json=secondFingerprint,proto3"` - xxx_hidden_RcChecksum string `protobuf:"bytes,5,opt,name=rc_checksum,json=rcChecksum,proto3"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache +type SecurityCardsProvider struct { + state protoimpl.MessageState `protogen:"opaque.v1"` + xxx_hidden_Id string `protobuf:"bytes,1,opt,name=id,proto3"` + xxx_hidden_Name string `protobuf:"bytes,2,opt,name=name,proto3"` + xxx_hidden_Description string `protobuf:"bytes,3,opt,name=description,proto3"` + xxx_hidden_SiteUrl string `protobuf:"bytes,4,opt,name=site_url,json=siteUrl,proto3"` + xxx_hidden_LightTheme *SecurityCardsProviderTheme `protobuf:"bytes,5,opt,name=light_theme,json=lightTheme,proto3"` + xxx_hidden_DarkTheme *SecurityCardsProviderTheme `protobuf:"bytes,6,opt,name=dark_theme,json=darkTheme,proto3"` + xxx_hidden_SecurityCards *[]*SecurityCard `protobuf:"bytes,7,rep,name=security_cards,json=securityCards,proto3"` + xxx_hidden_EstimatedShippingPrices *[]*ShippingPriceInfo `protobuf:"bytes,8,rep,name=estimated_shipping_prices,json=estimatedShippingPrices,proto3"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } -func (x *EKInputRequest) Reset() { - *x = EKInputRequest{} - mi := &file_wallet_service_proto_msgTypes[31] +func (x *SecurityCardsProvider) Reset() { + *x = SecurityCardsProvider{} + mi := &file_wallet_service_proto_msgTypes[33] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *EKInputRequest) String() string { +func (x *SecurityCardsProvider) String() string { return protoimpl.X.MessageStringOf(x) } -func (*EKInputRequest) ProtoMessage() {} +func (*SecurityCardsProvider) ProtoMessage() {} -func (x *EKInputRequest) ProtoReflect() protoreflect.Message { - mi := &file_wallet_service_proto_msgTypes[31] +func (x *SecurityCardsProvider) ProtoReflect() protoreflect.Message { + mi := &file_wallet_service_proto_msgTypes[33] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2797,107 +3033,1075 @@ func (x *EKInputRequest) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -func (x *EKInputRequest) GetFirstEncryptedKey() string { +func (x *SecurityCardsProvider) GetId() string { if x != nil { - return x.xxx_hidden_FirstEncryptedKey + return x.xxx_hidden_Id } return "" } -func (x *EKInputRequest) GetFirstFingerprint() string { +func (x *SecurityCardsProvider) GetName() string { if x != nil { - return x.xxx_hidden_FirstFingerprint + return x.xxx_hidden_Name } return "" } -func (x *EKInputRequest) GetSecondEncryptedKey() string { +func (x *SecurityCardsProvider) GetDescription() string { if x != nil { - return x.xxx_hidden_SecondEncryptedKey + return x.xxx_hidden_Description } return "" } -func (x *EKInputRequest) GetSecondFingerprint() string { +func (x *SecurityCardsProvider) GetSiteUrl() string { if x != nil { - return x.xxx_hidden_SecondFingerprint + return x.xxx_hidden_SiteUrl } return "" } -func (x *EKInputRequest) GetRcChecksum() string { +func (x *SecurityCardsProvider) GetLightTheme() *SecurityCardsProviderTheme { if x != nil { - return x.xxx_hidden_RcChecksum + return x.xxx_hidden_LightTheme } - return "" + return nil } -func (x *EKInputRequest) SetFirstEncryptedKey(v string) { - x.xxx_hidden_FirstEncryptedKey = v +func (x *SecurityCardsProvider) GetDarkTheme() *SecurityCardsProviderTheme { + if x != nil { + return x.xxx_hidden_DarkTheme + } + return nil } -func (x *EKInputRequest) SetFirstFingerprint(v string) { - x.xxx_hidden_FirstFingerprint = v +func (x *SecurityCardsProvider) GetSecurityCards() []*SecurityCard { + if x != nil { + if x.xxx_hidden_SecurityCards != nil { + return *x.xxx_hidden_SecurityCards + } + } + return nil } -func (x *EKInputRequest) SetSecondEncryptedKey(v string) { - x.xxx_hidden_SecondEncryptedKey = v +func (x *SecurityCardsProvider) GetEstimatedShippingPrices() []*ShippingPriceInfo { + if x != nil { + if x.xxx_hidden_EstimatedShippingPrices != nil { + return *x.xxx_hidden_EstimatedShippingPrices + } + } + return nil } -func (x *EKInputRequest) SetSecondFingerprint(v string) { - x.xxx_hidden_SecondFingerprint = v +func (x *SecurityCardsProvider) SetId(v string) { + x.xxx_hidden_Id = v } -func (x *EKInputRequest) SetRcChecksum(v string) { - x.xxx_hidden_RcChecksum = v +func (x *SecurityCardsProvider) SetName(v string) { + x.xxx_hidden_Name = v } -type EKInputRequest_builder struct { - _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. +func (x *SecurityCardsProvider) SetDescription(v string) { + x.xxx_hidden_Description = v +} - FirstEncryptedKey string - FirstFingerprint string - SecondEncryptedKey string - SecondFingerprint string - RcChecksum string +func (x *SecurityCardsProvider) SetSiteUrl(v string) { + x.xxx_hidden_SiteUrl = v } -func (b0 EKInputRequest_builder) Build() *EKInputRequest { - m0 := &EKInputRequest{} - b, x := &b0, m0 - _, _ = b, x - x.xxx_hidden_FirstEncryptedKey = b.FirstEncryptedKey - x.xxx_hidden_FirstFingerprint = b.FirstFingerprint - x.xxx_hidden_SecondEncryptedKey = b.SecondEncryptedKey - x.xxx_hidden_SecondFingerprint = b.SecondFingerprint - x.xxx_hidden_RcChecksum = b.RcChecksum - return m0 +func (x *SecurityCardsProvider) SetLightTheme(v *SecurityCardsProviderTheme) { + x.xxx_hidden_LightTheme = v } -type GenerateEmergencyKitPDFRequest struct { - state protoimpl.MessageState `protogen:"opaque.v1"` - xxx_hidden_EkInput *EKInputRequest `protobuf:"bytes,1,opt,name=ek_input,json=ekInput,proto3"` - xxx_hidden_OutputPath string `protobuf:"bytes,2,opt,name=output_path,json=outputPath,proto3"` - xxx_hidden_Language string `protobuf:"bytes,3,opt,name=language,proto3"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache +func (x *SecurityCardsProvider) SetDarkTheme(v *SecurityCardsProviderTheme) { + x.xxx_hidden_DarkTheme = v } -func (x *GenerateEmergencyKitPDFRequest) Reset() { - *x = GenerateEmergencyKitPDFRequest{} - mi := &file_wallet_service_proto_msgTypes[32] +func (x *SecurityCardsProvider) SetSecurityCards(v []*SecurityCard) { + x.xxx_hidden_SecurityCards = &v +} + +func (x *SecurityCardsProvider) SetEstimatedShippingPrices(v []*ShippingPriceInfo) { + x.xxx_hidden_EstimatedShippingPrices = &v +} + +func (x *SecurityCardsProvider) HasLightTheme() bool { + if x == nil { + return false + } + return x.xxx_hidden_LightTheme != nil +} + +func (x *SecurityCardsProvider) HasDarkTheme() bool { + if x == nil { + return false + } + return x.xxx_hidden_DarkTheme != nil +} + +func (x *SecurityCardsProvider) ClearLightTheme() { + x.xxx_hidden_LightTheme = nil +} + +func (x *SecurityCardsProvider) ClearDarkTheme() { + x.xxx_hidden_DarkTheme = nil +} + +type SecurityCardsProvider_builder struct { + _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. + + Id string + Name string + Description string + SiteUrl string + LightTheme *SecurityCardsProviderTheme + DarkTheme *SecurityCardsProviderTheme + SecurityCards []*SecurityCard + EstimatedShippingPrices []*ShippingPriceInfo +} + +func (b0 SecurityCardsProvider_builder) Build() *SecurityCardsProvider { + m0 := &SecurityCardsProvider{} + b, x := &b0, m0 + _, _ = b, x + x.xxx_hidden_Id = b.Id + x.xxx_hidden_Name = b.Name + x.xxx_hidden_Description = b.Description + x.xxx_hidden_SiteUrl = b.SiteUrl + x.xxx_hidden_LightTheme = b.LightTheme + x.xxx_hidden_DarkTheme = b.DarkTheme + x.xxx_hidden_SecurityCards = &b.SecurityCards + x.xxx_hidden_EstimatedShippingPrices = &b.EstimatedShippingPrices + return m0 +} + +type SecurityCard struct { + state protoimpl.MessageState `protogen:"opaque.v1"` + xxx_hidden_Id string `protobuf:"bytes,1,opt,name=id,proto3"` + xxx_hidden_AssetUrl string `protobuf:"bytes,2,opt,name=asset_url,json=assetUrl,proto3"` + xxx_hidden_Tag string `protobuf:"bytes,3,opt,name=tag,proto3"` + xxx_hidden_SpecId string `protobuf:"bytes,4,opt,name=spec_id,json=specId,proto3"` + xxx_hidden_CardCost *PriceInfo `protobuf:"bytes,5,opt,name=card_cost,json=cardCost,proto3"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SecurityCard) Reset() { + *x = SecurityCard{} + mi := &file_wallet_service_proto_msgTypes[34] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SecurityCard) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SecurityCard) ProtoMessage() {} + +func (x *SecurityCard) ProtoReflect() protoreflect.Message { + mi := &file_wallet_service_proto_msgTypes[34] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (x *SecurityCard) GetId() string { + if x != nil { + return x.xxx_hidden_Id + } + return "" +} + +func (x *SecurityCard) GetAssetUrl() string { + if x != nil { + return x.xxx_hidden_AssetUrl + } + return "" +} + +func (x *SecurityCard) GetTag() string { + if x != nil { + return x.xxx_hidden_Tag + } + return "" +} + +func (x *SecurityCard) GetSpecId() string { + if x != nil { + return x.xxx_hidden_SpecId + } + return "" +} + +func (x *SecurityCard) GetCardCost() *PriceInfo { + if x != nil { + return x.xxx_hidden_CardCost + } + return nil +} + +func (x *SecurityCard) SetId(v string) { + x.xxx_hidden_Id = v +} + +func (x *SecurityCard) SetAssetUrl(v string) { + x.xxx_hidden_AssetUrl = v +} + +func (x *SecurityCard) SetTag(v string) { + x.xxx_hidden_Tag = v +} + +func (x *SecurityCard) SetSpecId(v string) { + x.xxx_hidden_SpecId = v +} + +func (x *SecurityCard) SetCardCost(v *PriceInfo) { + x.xxx_hidden_CardCost = v +} + +func (x *SecurityCard) HasCardCost() bool { + if x == nil { + return false + } + return x.xxx_hidden_CardCost != nil +} + +func (x *SecurityCard) ClearCardCost() { + x.xxx_hidden_CardCost = nil +} + +type SecurityCard_builder struct { + _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. + + Id string + AssetUrl string + Tag string + SpecId string + CardCost *PriceInfo +} + +func (b0 SecurityCard_builder) Build() *SecurityCard { + m0 := &SecurityCard{} + b, x := &b0, m0 + _, _ = b, x + x.xxx_hidden_Id = b.Id + x.xxx_hidden_AssetUrl = b.AssetUrl + x.xxx_hidden_Tag = b.Tag + x.xxx_hidden_SpecId = b.SpecId + x.xxx_hidden_CardCost = b.CardCost + return m0 +} + +type SpecsItem struct { + state protoimpl.MessageState `protogen:"opaque.v1"` + xxx_hidden_IconUrl string `protobuf:"bytes,1,opt,name=icon_url,json=iconUrl,proto3"` + xxx_hidden_Label string `protobuf:"bytes,2,opt,name=label,proto3"` + xxx_hidden_Value string `protobuf:"bytes,3,opt,name=value,proto3"` + xxx_hidden_AdditionalData string `protobuf:"bytes,4,opt,name=additional_data,json=additionalData,proto3"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SpecsItem) Reset() { + *x = SpecsItem{} + mi := &file_wallet_service_proto_msgTypes[35] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SpecsItem) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SpecsItem) ProtoMessage() {} + +func (x *SpecsItem) ProtoReflect() protoreflect.Message { + mi := &file_wallet_service_proto_msgTypes[35] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (x *SpecsItem) GetIconUrl() string { + if x != nil { + return x.xxx_hidden_IconUrl + } + return "" +} + +func (x *SpecsItem) GetLabel() string { + if x != nil { + return x.xxx_hidden_Label + } + return "" +} + +func (x *SpecsItem) GetValue() string { + if x != nil { + return x.xxx_hidden_Value + } + return "" +} + +func (x *SpecsItem) GetAdditionalData() string { + if x != nil { + return x.xxx_hidden_AdditionalData + } + return "" +} + +func (x *SpecsItem) SetIconUrl(v string) { + x.xxx_hidden_IconUrl = v +} + +func (x *SpecsItem) SetLabel(v string) { + x.xxx_hidden_Label = v +} + +func (x *SpecsItem) SetValue(v string) { + x.xxx_hidden_Value = v +} + +func (x *SpecsItem) SetAdditionalData(v string) { + x.xxx_hidden_AdditionalData = v +} + +type SpecsItem_builder struct { + _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. + + IconUrl string + Label string + Value string + AdditionalData string +} + +func (b0 SpecsItem_builder) Build() *SpecsItem { + m0 := &SpecsItem{} + b, x := &b0, m0 + _, _ = b, x + x.xxx_hidden_IconUrl = b.IconUrl + x.xxx_hidden_Label = b.Label + x.xxx_hidden_Value = b.Value + x.xxx_hidden_AdditionalData = b.AdditionalData + return m0 +} + +type SpecsItemList struct { + state protoimpl.MessageState `protogen:"opaque.v1"` + xxx_hidden_Items *[]*SpecsItem `protobuf:"bytes,1,rep,name=items,proto3"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SpecsItemList) Reset() { + *x = SpecsItemList{} + mi := &file_wallet_service_proto_msgTypes[36] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SpecsItemList) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SpecsItemList) ProtoMessage() {} + +func (x *SpecsItemList) ProtoReflect() protoreflect.Message { + mi := &file_wallet_service_proto_msgTypes[36] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (x *SpecsItemList) GetItems() []*SpecsItem { + if x != nil { + if x.xxx_hidden_Items != nil { + return *x.xxx_hidden_Items + } + } + return nil +} + +func (x *SpecsItemList) SetItems(v []*SpecsItem) { + x.xxx_hidden_Items = &v +} + +type SpecsItemList_builder struct { + _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. + + Items []*SpecsItem +} + +func (b0 SpecsItemList_builder) Build() *SpecsItemList { + m0 := &SpecsItemList{} + b, x := &b0, m0 + _, _ = b, x + x.xxx_hidden_Items = &b.Items + return m0 +} + +type SecurityCardSpec struct { + state protoimpl.MessageState `protogen:"opaque.v1"` + xxx_hidden_SpecId string `protobuf:"bytes,1,opt,name=spec_id,json=specId,proto3"` + xxx_hidden_Items map[string]*SpecsItemList `protobuf:"bytes,2,rep,name=items,proto3" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SecurityCardSpec) Reset() { + *x = SecurityCardSpec{} + mi := &file_wallet_service_proto_msgTypes[37] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SecurityCardSpec) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SecurityCardSpec) ProtoMessage() {} + +func (x *SecurityCardSpec) ProtoReflect() protoreflect.Message { + mi := &file_wallet_service_proto_msgTypes[37] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (x *SecurityCardSpec) GetSpecId() string { + if x != nil { + return x.xxx_hidden_SpecId + } + return "" +} + +func (x *SecurityCardSpec) GetItems() map[string]*SpecsItemList { + if x != nil { + return x.xxx_hidden_Items + } + return nil +} + +func (x *SecurityCardSpec) SetSpecId(v string) { + x.xxx_hidden_SpecId = v +} + +func (x *SecurityCardSpec) SetItems(v map[string]*SpecsItemList) { + x.xxx_hidden_Items = v +} + +type SecurityCardSpec_builder struct { + _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. + + SpecId string + Items map[string]*SpecsItemList +} + +func (b0 SecurityCardSpec_builder) Build() *SecurityCardSpec { + m0 := &SecurityCardSpec{} + b, x := &b0, m0 + _, _ = b, x + x.xxx_hidden_SpecId = b.SpecId + x.xxx_hidden_Items = b.Items + return m0 +} + +type GetSecurityCardsMarketplaceResponse struct { + state protoimpl.MessageState `protogen:"opaque.v1"` + xxx_hidden_Providers *[]*SecurityCardsProvider `protobuf:"bytes,1,rep,name=providers,proto3"` + xxx_hidden_Specs *[]*SecurityCardSpec `protobuf:"bytes,2,rep,name=specs,proto3"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetSecurityCardsMarketplaceResponse) Reset() { + *x = GetSecurityCardsMarketplaceResponse{} + mi := &file_wallet_service_proto_msgTypes[38] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetSecurityCardsMarketplaceResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetSecurityCardsMarketplaceResponse) ProtoMessage() {} + +func (x *GetSecurityCardsMarketplaceResponse) ProtoReflect() protoreflect.Message { + mi := &file_wallet_service_proto_msgTypes[38] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (x *GetSecurityCardsMarketplaceResponse) GetProviders() []*SecurityCardsProvider { + if x != nil { + if x.xxx_hidden_Providers != nil { + return *x.xxx_hidden_Providers + } + } + return nil +} + +func (x *GetSecurityCardsMarketplaceResponse) GetSpecs() []*SecurityCardSpec { + if x != nil { + if x.xxx_hidden_Specs != nil { + return *x.xxx_hidden_Specs + } + } + return nil +} + +func (x *GetSecurityCardsMarketplaceResponse) SetProviders(v []*SecurityCardsProvider) { + x.xxx_hidden_Providers = &v +} + +func (x *GetSecurityCardsMarketplaceResponse) SetSpecs(v []*SecurityCardSpec) { + x.xxx_hidden_Specs = &v +} + +type GetSecurityCardsMarketplaceResponse_builder struct { + _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. + + Providers []*SecurityCardsProvider + Specs []*SecurityCardSpec +} + +func (b0 GetSecurityCardsMarketplaceResponse_builder) Build() *GetSecurityCardsMarketplaceResponse { + m0 := &GetSecurityCardsMarketplaceResponse{} + b, x := &b0, m0 + _, _ = b, x + x.xxx_hidden_Providers = &b.Providers + x.xxx_hidden_Specs = &b.Specs + return m0 +} + +type EKInputRequest struct { + state protoimpl.MessageState `protogen:"opaque.v1"` + xxx_hidden_FirstEncryptedKey string `protobuf:"bytes,1,opt,name=first_encrypted_key,json=firstEncryptedKey,proto3"` + xxx_hidden_FirstFingerprint string `protobuf:"bytes,2,opt,name=first_fingerprint,json=firstFingerprint,proto3"` + xxx_hidden_SecondEncryptedKey string `protobuf:"bytes,3,opt,name=second_encrypted_key,json=secondEncryptedKey,proto3"` + xxx_hidden_SecondFingerprint string `protobuf:"bytes,4,opt,name=second_fingerprint,json=secondFingerprint,proto3"` + xxx_hidden_RcChecksum string `protobuf:"bytes,5,opt,name=rc_checksum,json=rcChecksum,proto3"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EKInputRequest) Reset() { + *x = EKInputRequest{} + mi := &file_wallet_service_proto_msgTypes[39] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EKInputRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EKInputRequest) ProtoMessage() {} + +func (x *EKInputRequest) ProtoReflect() protoreflect.Message { + mi := &file_wallet_service_proto_msgTypes[39] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (x *EKInputRequest) GetFirstEncryptedKey() string { + if x != nil { + return x.xxx_hidden_FirstEncryptedKey + } + return "" +} + +func (x *EKInputRequest) GetFirstFingerprint() string { + if x != nil { + return x.xxx_hidden_FirstFingerprint + } + return "" +} + +func (x *EKInputRequest) GetSecondEncryptedKey() string { + if x != nil { + return x.xxx_hidden_SecondEncryptedKey + } + return "" +} + +func (x *EKInputRequest) GetSecondFingerprint() string { + if x != nil { + return x.xxx_hidden_SecondFingerprint + } + return "" +} + +func (x *EKInputRequest) GetRcChecksum() string { + if x != nil { + return x.xxx_hidden_RcChecksum + } + return "" +} + +func (x *EKInputRequest) SetFirstEncryptedKey(v string) { + x.xxx_hidden_FirstEncryptedKey = v +} + +func (x *EKInputRequest) SetFirstFingerprint(v string) { + x.xxx_hidden_FirstFingerprint = v +} + +func (x *EKInputRequest) SetSecondEncryptedKey(v string) { + x.xxx_hidden_SecondEncryptedKey = v +} + +func (x *EKInputRequest) SetSecondFingerprint(v string) { + x.xxx_hidden_SecondFingerprint = v +} + +func (x *EKInputRequest) SetRcChecksum(v string) { + x.xxx_hidden_RcChecksum = v +} + +type EKInputRequest_builder struct { + _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. + + FirstEncryptedKey string + FirstFingerprint string + SecondEncryptedKey string + SecondFingerprint string + RcChecksum string +} + +func (b0 EKInputRequest_builder) Build() *EKInputRequest { + m0 := &EKInputRequest{} + b, x := &b0, m0 + _, _ = b, x + x.xxx_hidden_FirstEncryptedKey = b.FirstEncryptedKey + x.xxx_hidden_FirstFingerprint = b.FirstFingerprint + x.xxx_hidden_SecondEncryptedKey = b.SecondEncryptedKey + x.xxx_hidden_SecondFingerprint = b.SecondFingerprint + x.xxx_hidden_RcChecksum = b.RcChecksum + return m0 +} + +type GenerateEmergencyKitPDFRequest struct { + state protoimpl.MessageState `protogen:"opaque.v1"` + xxx_hidden_EkInput *EKInputRequest `protobuf:"bytes,1,opt,name=ek_input,json=ekInput,proto3"` + xxx_hidden_OutputPath string `protobuf:"bytes,2,opt,name=output_path,json=outputPath,proto3"` + xxx_hidden_Language string `protobuf:"bytes,3,opt,name=language,proto3"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GenerateEmergencyKitPDFRequest) Reset() { + *x = GenerateEmergencyKitPDFRequest{} + mi := &file_wallet_service_proto_msgTypes[40] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GenerateEmergencyKitPDFRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GenerateEmergencyKitPDFRequest) ProtoMessage() {} + +func (x *GenerateEmergencyKitPDFRequest) ProtoReflect() protoreflect.Message { + mi := &file_wallet_service_proto_msgTypes[40] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (x *GenerateEmergencyKitPDFRequest) GetEkInput() *EKInputRequest { + if x != nil { + return x.xxx_hidden_EkInput + } + return nil +} + +func (x *GenerateEmergencyKitPDFRequest) GetOutputPath() string { + if x != nil { + return x.xxx_hidden_OutputPath + } + return "" +} + +func (x *GenerateEmergencyKitPDFRequest) GetLanguage() string { + if x != nil { + return x.xxx_hidden_Language + } + return "" +} + +func (x *GenerateEmergencyKitPDFRequest) SetEkInput(v *EKInputRequest) { + x.xxx_hidden_EkInput = v +} + +func (x *GenerateEmergencyKitPDFRequest) SetOutputPath(v string) { + x.xxx_hidden_OutputPath = v +} + +func (x *GenerateEmergencyKitPDFRequest) SetLanguage(v string) { + x.xxx_hidden_Language = v +} + +func (x *GenerateEmergencyKitPDFRequest) HasEkInput() bool { + if x == nil { + return false + } + return x.xxx_hidden_EkInput != nil +} + +func (x *GenerateEmergencyKitPDFRequest) ClearEkInput() { + x.xxx_hidden_EkInput = nil +} + +type GenerateEmergencyKitPDFRequest_builder struct { + _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. + + EkInput *EKInputRequest + OutputPath string + Language string +} + +func (b0 GenerateEmergencyKitPDFRequest_builder) Build() *GenerateEmergencyKitPDFRequest { + m0 := &GenerateEmergencyKitPDFRequest{} + b, x := &b0, m0 + _, _ = b, x + x.xxx_hidden_EkInput = b.EkInput + x.xxx_hidden_OutputPath = b.OutputPath + x.xxx_hidden_Language = b.Language + return m0 +} + +type GenerateEmergencyKitPDFResponse struct { + state protoimpl.MessageState `protogen:"opaque.v1"` + xxx_hidden_VerificationCode string `protobuf:"bytes,1,opt,name=verification_code,json=verificationCode,proto3"` + xxx_hidden_Version int32 `protobuf:"varint,2,opt,name=version,proto3"` + xxx_hidden_Profiling *RenderProfiling `protobuf:"bytes,3,opt,name=profiling,proto3"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GenerateEmergencyKitPDFResponse) Reset() { + *x = GenerateEmergencyKitPDFResponse{} + mi := &file_wallet_service_proto_msgTypes[41] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GenerateEmergencyKitPDFResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GenerateEmergencyKitPDFResponse) ProtoMessage() {} + +func (x *GenerateEmergencyKitPDFResponse) ProtoReflect() protoreflect.Message { + mi := &file_wallet_service_proto_msgTypes[41] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (x *GenerateEmergencyKitPDFResponse) GetVerificationCode() string { + if x != nil { + return x.xxx_hidden_VerificationCode + } + return "" +} + +func (x *GenerateEmergencyKitPDFResponse) GetVersion() int32 { + if x != nil { + return x.xxx_hidden_Version + } + return 0 +} + +func (x *GenerateEmergencyKitPDFResponse) GetProfiling() *RenderProfiling { + if x != nil { + return x.xxx_hidden_Profiling + } + return nil +} + +func (x *GenerateEmergencyKitPDFResponse) SetVerificationCode(v string) { + x.xxx_hidden_VerificationCode = v +} + +func (x *GenerateEmergencyKitPDFResponse) SetVersion(v int32) { + x.xxx_hidden_Version = v +} + +func (x *GenerateEmergencyKitPDFResponse) SetProfiling(v *RenderProfiling) { + x.xxx_hidden_Profiling = v +} + +func (x *GenerateEmergencyKitPDFResponse) HasProfiling() bool { + if x == nil { + return false + } + return x.xxx_hidden_Profiling != nil +} + +func (x *GenerateEmergencyKitPDFResponse) ClearProfiling() { + x.xxx_hidden_Profiling = nil +} + +type GenerateEmergencyKitPDFResponse_builder struct { + _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. + + VerificationCode string + Version int32 + // Per-render profiling, so prod telemetry can validate the offline profiling. + Profiling *RenderProfiling +} + +func (b0 GenerateEmergencyKitPDFResponse_builder) Build() *GenerateEmergencyKitPDFResponse { + m0 := &GenerateEmergencyKitPDFResponse{} + b, x := &b0, m0 + _, _ = b, x + x.xxx_hidden_VerificationCode = b.VerificationCode + x.xxx_hidden_Version = b.Version + x.xxx_hidden_Profiling = b.Profiling + return m0 +} + +// RenderProfiling carries per-stage durations (milliseconds) and total allocation stats for a +// single PDF render. Alloc stats are a process-wide delta and thus an approximation. +type RenderProfiling struct { + state protoimpl.MessageState `protogen:"opaque.v1"` + xxx_hidden_LoadTranslationsMs int64 `protobuf:"varint,1,opt,name=load_translations_ms,json=loadTranslationsMs,proto3"` + xxx_hidden_RegisterFontsMs int64 `protobuf:"varint,2,opt,name=register_fonts_ms,json=registerFontsMs,proto3"` + xxx_hidden_RegisterImagesMs int64 `protobuf:"varint,3,opt,name=register_images_ms,json=registerImagesMs,proto3"` + xxx_hidden_ComponentsRenderingMs int64 `protobuf:"varint,4,opt,name=components_rendering_ms,json=componentsRenderingMs,proto3"` + xxx_hidden_CreateAndSaveOnDiskMs int64 `protobuf:"varint,5,opt,name=create_and_save_on_disk_ms,json=createAndSaveOnDiskMs,proto3"` + xxx_hidden_TotalHeapAllocatedBytes int64 `protobuf:"varint,6,opt,name=total_heap_allocated_bytes,json=totalHeapAllocatedBytes,proto3"` + xxx_hidden_TotalObjectsAllocated int64 `protobuf:"varint,7,opt,name=total_objects_allocated,json=totalObjectsAllocated,proto3"` + xxx_hidden_EmbedMetadataMs int64 `protobuf:"varint,8,opt,name=embed_metadata_ms,json=embedMetadataMs,proto3"` + xxx_hidden_TotalInsideGoMs int64 `protobuf:"varint,9,opt,name=total_inside_go_ms,json=totalInsideGoMs,proto3"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RenderProfiling) Reset() { + *x = RenderProfiling{} + mi := &file_wallet_service_proto_msgTypes[42] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RenderProfiling) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RenderProfiling) ProtoMessage() {} + +func (x *RenderProfiling) ProtoReflect() protoreflect.Message { + mi := &file_wallet_service_proto_msgTypes[42] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (x *RenderProfiling) GetLoadTranslationsMs() int64 { + if x != nil { + return x.xxx_hidden_LoadTranslationsMs + } + return 0 +} + +func (x *RenderProfiling) GetRegisterFontsMs() int64 { + if x != nil { + return x.xxx_hidden_RegisterFontsMs + } + return 0 +} + +func (x *RenderProfiling) GetRegisterImagesMs() int64 { + if x != nil { + return x.xxx_hidden_RegisterImagesMs + } + return 0 +} + +func (x *RenderProfiling) GetComponentsRenderingMs() int64 { + if x != nil { + return x.xxx_hidden_ComponentsRenderingMs + } + return 0 +} + +func (x *RenderProfiling) GetCreateAndSaveOnDiskMs() int64 { + if x != nil { + return x.xxx_hidden_CreateAndSaveOnDiskMs + } + return 0 +} + +func (x *RenderProfiling) GetTotalHeapAllocatedBytes() int64 { + if x != nil { + return x.xxx_hidden_TotalHeapAllocatedBytes + } + return 0 +} + +func (x *RenderProfiling) GetTotalObjectsAllocated() int64 { + if x != nil { + return x.xxx_hidden_TotalObjectsAllocated + } + return 0 +} + +func (x *RenderProfiling) GetEmbedMetadataMs() int64 { + if x != nil { + return x.xxx_hidden_EmbedMetadataMs + } + return 0 +} + +func (x *RenderProfiling) GetTotalInsideGoMs() int64 { + if x != nil { + return x.xxx_hidden_TotalInsideGoMs + } + return 0 +} + +func (x *RenderProfiling) SetLoadTranslationsMs(v int64) { + x.xxx_hidden_LoadTranslationsMs = v +} + +func (x *RenderProfiling) SetRegisterFontsMs(v int64) { + x.xxx_hidden_RegisterFontsMs = v +} + +func (x *RenderProfiling) SetRegisterImagesMs(v int64) { + x.xxx_hidden_RegisterImagesMs = v +} + +func (x *RenderProfiling) SetComponentsRenderingMs(v int64) { + x.xxx_hidden_ComponentsRenderingMs = v +} + +func (x *RenderProfiling) SetCreateAndSaveOnDiskMs(v int64) { + x.xxx_hidden_CreateAndSaveOnDiskMs = v +} + +func (x *RenderProfiling) SetTotalHeapAllocatedBytes(v int64) { + x.xxx_hidden_TotalHeapAllocatedBytes = v +} + +func (x *RenderProfiling) SetTotalObjectsAllocated(v int64) { + x.xxx_hidden_TotalObjectsAllocated = v +} + +func (x *RenderProfiling) SetEmbedMetadataMs(v int64) { + x.xxx_hidden_EmbedMetadataMs = v +} + +func (x *RenderProfiling) SetTotalInsideGoMs(v int64) { + x.xxx_hidden_TotalInsideGoMs = v +} + +type RenderProfiling_builder struct { + _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. + + LoadTranslationsMs int64 + RegisterFontsMs int64 + RegisterImagesMs int64 + ComponentsRenderingMs int64 + CreateAndSaveOnDiskMs int64 + TotalHeapAllocatedBytes int64 + TotalObjectsAllocated int64 + EmbedMetadataMs int64 + TotalInsideGoMs int64 +} + +func (b0 RenderProfiling_builder) Build() *RenderProfiling { + m0 := &RenderProfiling{} + b, x := &b0, m0 + _, _ = b, x + x.xxx_hidden_LoadTranslationsMs = b.LoadTranslationsMs + x.xxx_hidden_RegisterFontsMs = b.RegisterFontsMs + x.xxx_hidden_RegisterImagesMs = b.RegisterImagesMs + x.xxx_hidden_ComponentsRenderingMs = b.ComponentsRenderingMs + x.xxx_hidden_CreateAndSaveOnDiskMs = b.CreateAndSaveOnDiskMs + x.xxx_hidden_TotalHeapAllocatedBytes = b.TotalHeapAllocatedBytes + x.xxx_hidden_TotalObjectsAllocated = b.TotalObjectsAllocated + x.xxx_hidden_EmbedMetadataMs = b.EmbedMetadataMs + x.xxx_hidden_TotalInsideGoMs = b.TotalInsideGoMs + return m0 +} + +type ZipDataDirRequest struct { + state protoimpl.MessageState `protogen:"opaque.v1"` + xxx_hidden_OutputPath string `protobuf:"bytes,1,opt,name=output_path,json=outputPath,proto3"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ZipDataDirRequest) Reset() { + *x = ZipDataDirRequest{} + mi := &file_wallet_service_proto_msgTypes[43] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *GenerateEmergencyKitPDFRequest) String() string { +func (x *ZipDataDirRequest) String() string { return protoimpl.X.MessageStringOf(x) } -func (*GenerateEmergencyKitPDFRequest) ProtoMessage() {} +func (*ZipDataDirRequest) ProtoMessage() {} -func (x *GenerateEmergencyKitPDFRequest) ProtoReflect() protoreflect.Message { - mi := &file_wallet_service_proto_msgTypes[32] +func (x *ZipDataDirRequest) ProtoReflect() protoreflect.Message { + mi := &file_wallet_service_proto_msgTypes[43] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2908,91 +4112,184 @@ func (x *GenerateEmergencyKitPDFRequest) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -func (x *GenerateEmergencyKitPDFRequest) GetEkInput() *EKInputRequest { +func (x *ZipDataDirRequest) GetOutputPath() string { if x != nil { - return x.xxx_hidden_EkInput + return x.xxx_hidden_OutputPath } - return nil + return "" } -func (x *GenerateEmergencyKitPDFRequest) GetOutputPath() string { +func (x *ZipDataDirRequest) SetOutputPath(v string) { + x.xxx_hidden_OutputPath = v +} + +type ZipDataDirRequest_builder struct { + _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. + + OutputPath string +} + +func (b0 ZipDataDirRequest_builder) Build() *ZipDataDirRequest { + m0 := &ZipDataDirRequest{} + b, x := &b0, m0 + _, _ = b, x + x.xxx_hidden_OutputPath = b.OutputPath + return m0 +} + +type SecureKeyValueStoragePutRequest struct { + state protoimpl.MessageState `protogen:"opaque.v1"` + xxx_hidden_Key string `protobuf:"bytes,1,opt,name=key,proto3"` + xxx_hidden_Value []byte `protobuf:"bytes,2,opt,name=value,proto3"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SecureKeyValueStoragePutRequest) Reset() { + *x = SecureKeyValueStoragePutRequest{} + mi := &file_wallet_service_proto_msgTypes[44] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SecureKeyValueStoragePutRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SecureKeyValueStoragePutRequest) ProtoMessage() {} + +func (x *SecureKeyValueStoragePutRequest) ProtoReflect() protoreflect.Message { + mi := &file_wallet_service_proto_msgTypes[44] if x != nil { - return x.xxx_hidden_OutputPath + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms } - return "" + return mi.MessageOf(x) } -func (x *GenerateEmergencyKitPDFRequest) GetLanguage() string { +func (x *SecureKeyValueStoragePutRequest) GetKey() string { if x != nil { - return x.xxx_hidden_Language + return x.xxx_hidden_Key } return "" } -func (x *GenerateEmergencyKitPDFRequest) SetEkInput(v *EKInputRequest) { - x.xxx_hidden_EkInput = v +func (x *SecureKeyValueStoragePutRequest) GetValue() []byte { + if x != nil { + return x.xxx_hidden_Value + } + return nil } -func (x *GenerateEmergencyKitPDFRequest) SetOutputPath(v string) { - x.xxx_hidden_OutputPath = v +func (x *SecureKeyValueStoragePutRequest) SetKey(v string) { + x.xxx_hidden_Key = v } -func (x *GenerateEmergencyKitPDFRequest) SetLanguage(v string) { - x.xxx_hidden_Language = v +func (x *SecureKeyValueStoragePutRequest) SetValue(v []byte) { + if v == nil { + v = []byte{} + } + x.xxx_hidden_Value = v } -func (x *GenerateEmergencyKitPDFRequest) HasEkInput() bool { - if x == nil { - return false +type SecureKeyValueStoragePutRequest_builder struct { + _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. + + Key string + Value []byte +} + +func (b0 SecureKeyValueStoragePutRequest_builder) Build() *SecureKeyValueStoragePutRequest { + m0 := &SecureKeyValueStoragePutRequest{} + b, x := &b0, m0 + _, _ = b, x + x.xxx_hidden_Key = b.Key + x.xxx_hidden_Value = b.Value + return m0 +} + +type SecureKeyValueStorageGetRequest struct { + state protoimpl.MessageState `protogen:"opaque.v1"` + xxx_hidden_Key string `protobuf:"bytes,1,opt,name=key,proto3"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SecureKeyValueStorageGetRequest) Reset() { + *x = SecureKeyValueStorageGetRequest{} + mi := &file_wallet_service_proto_msgTypes[45] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SecureKeyValueStorageGetRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SecureKeyValueStorageGetRequest) ProtoMessage() {} + +func (x *SecureKeyValueStorageGetRequest) ProtoReflect() protoreflect.Message { + mi := &file_wallet_service_proto_msgTypes[45] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms } - return x.xxx_hidden_EkInput != nil + return mi.MessageOf(x) } -func (x *GenerateEmergencyKitPDFRequest) ClearEkInput() { - x.xxx_hidden_EkInput = nil +func (x *SecureKeyValueStorageGetRequest) GetKey() string { + if x != nil { + return x.xxx_hidden_Key + } + return "" } -type GenerateEmergencyKitPDFRequest_builder struct { +func (x *SecureKeyValueStorageGetRequest) SetKey(v string) { + x.xxx_hidden_Key = v +} + +type SecureKeyValueStorageGetRequest_builder struct { _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. - EkInput *EKInputRequest - OutputPath string - Language string + Key string } -func (b0 GenerateEmergencyKitPDFRequest_builder) Build() *GenerateEmergencyKitPDFRequest { - m0 := &GenerateEmergencyKitPDFRequest{} +func (b0 SecureKeyValueStorageGetRequest_builder) Build() *SecureKeyValueStorageGetRequest { + m0 := &SecureKeyValueStorageGetRequest{} b, x := &b0, m0 _, _ = b, x - x.xxx_hidden_EkInput = b.EkInput - x.xxx_hidden_OutputPath = b.OutputPath - x.xxx_hidden_Language = b.Language + x.xxx_hidden_Key = b.Key return m0 } -type GenerateEmergencyKitPDFResponse struct { - state protoimpl.MessageState `protogen:"opaque.v1"` - xxx_hidden_VerificationCode string `protobuf:"bytes,1,opt,name=verification_code,json=verificationCode,proto3"` - xxx_hidden_Version int32 `protobuf:"varint,2,opt,name=version,proto3"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache +type SecureKeyValueStorageGetResponse struct { + state protoimpl.MessageState `protogen:"opaque.v1"` + xxx_hidden_Value []byte `protobuf:"bytes,1,opt,name=value,proto3"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } -func (x *GenerateEmergencyKitPDFResponse) Reset() { - *x = GenerateEmergencyKitPDFResponse{} - mi := &file_wallet_service_proto_msgTypes[33] +func (x *SecureKeyValueStorageGetResponse) Reset() { + *x = SecureKeyValueStorageGetResponse{} + mi := &file_wallet_service_proto_msgTypes[46] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *GenerateEmergencyKitPDFResponse) String() string { +func (x *SecureKeyValueStorageGetResponse) String() string { return protoimpl.X.MessageStringOf(x) } -func (*GenerateEmergencyKitPDFResponse) ProtoMessage() {} +func (*SecureKeyValueStorageGetResponse) ProtoMessage() {} -func (x *GenerateEmergencyKitPDFResponse) ProtoReflect() protoreflect.Message { - mi := &file_wallet_service_proto_msgTypes[33] +func (x *SecureKeyValueStorageGetResponse) ProtoReflect() protoreflect.Message { + mi := &file_wallet_service_proto_msgTypes[46] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3003,41 +4300,88 @@ func (x *GenerateEmergencyKitPDFResponse) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -func (x *GenerateEmergencyKitPDFResponse) GetVerificationCode() string { +func (x *SecureKeyValueStorageGetResponse) GetValue() []byte { if x != nil { - return x.xxx_hidden_VerificationCode + return x.xxx_hidden_Value } - return "" + return nil } -func (x *GenerateEmergencyKitPDFResponse) GetVersion() int32 { +func (x *SecureKeyValueStorageGetResponse) SetValue(v []byte) { + if v == nil { + v = []byte{} + } + x.xxx_hidden_Value = v +} + +type SecureKeyValueStorageGetResponse_builder struct { + _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. + + Value []byte +} + +func (b0 SecureKeyValueStorageGetResponse_builder) Build() *SecureKeyValueStorageGetResponse { + m0 := &SecureKeyValueStorageGetResponse{} + b, x := &b0, m0 + _, _ = b, x + x.xxx_hidden_Value = b.Value + return m0 +} + +type SecureKeyValueStorageDeleteRequest struct { + state protoimpl.MessageState `protogen:"opaque.v1"` + xxx_hidden_Key string `protobuf:"bytes,1,opt,name=key,proto3"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SecureKeyValueStorageDeleteRequest) Reset() { + *x = SecureKeyValueStorageDeleteRequest{} + mi := &file_wallet_service_proto_msgTypes[47] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SecureKeyValueStorageDeleteRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SecureKeyValueStorageDeleteRequest) ProtoMessage() {} + +func (x *SecureKeyValueStorageDeleteRequest) ProtoReflect() protoreflect.Message { + mi := &file_wallet_service_proto_msgTypes[47] if x != nil { - return x.xxx_hidden_Version + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms } - return 0 + return mi.MessageOf(x) } -func (x *GenerateEmergencyKitPDFResponse) SetVerificationCode(v string) { - x.xxx_hidden_VerificationCode = v +func (x *SecureKeyValueStorageDeleteRequest) GetKey() string { + if x != nil { + return x.xxx_hidden_Key + } + return "" } -func (x *GenerateEmergencyKitPDFResponse) SetVersion(v int32) { - x.xxx_hidden_Version = v +func (x *SecureKeyValueStorageDeleteRequest) SetKey(v string) { + x.xxx_hidden_Key = v } -type GenerateEmergencyKitPDFResponse_builder struct { +type SecureKeyValueStorageDeleteRequest_builder struct { _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. - VerificationCode string - Version int32 + Key string } -func (b0 GenerateEmergencyKitPDFResponse_builder) Build() *GenerateEmergencyKitPDFResponse { - m0 := &GenerateEmergencyKitPDFResponse{} +func (b0 SecureKeyValueStorageDeleteRequest_builder) Build() *SecureKeyValueStorageDeleteRequest { + m0 := &SecureKeyValueStorageDeleteRequest{} b, x := &b0, m0 _, _ = b, x - x.xxx_hidden_VerificationCode = b.VerificationCode - x.xxx_hidden_Version = b.Version + x.xxx_hidden_Key = b.Key return m0 } @@ -3056,70 +4400,70 @@ var file_wallet_service_proto_rawDesc = string([]byte{ 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x2b, 0x0a, 0x11, 0x64, 0x65, 0x76, 0x65, 0x6c, 0x6f, 0x70, 0x65, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x64, 0x65, 0x76, 0x65, 0x6c, 0x6f, 0x70, 0x65, 0x72, - 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x2f, 0x0a, 0x0c, 0x58, 0x70, 0x75, 0x62, 0x52, - 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x62, 0x61, 0x73, 0x65, 0x35, - 0x38, 0x5f, 0x78, 0x70, 0x75, 0x62, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x62, 0x61, - 0x73, 0x65, 0x35, 0x38, 0x58, 0x70, 0x75, 0x62, 0x22, 0x78, 0x0a, 0x19, 0x53, 0x65, 0x74, 0x75, - 0x70, 0x53, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x43, 0x61, 0x72, 0x64, 0x52, 0x65, 0x73, - 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2a, 0x0a, 0x11, 0x69, 0x73, 0x5f, 0x6b, 0x6e, 0x6f, 0x77, - 0x6e, 0x5f, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, - 0x52, 0x0f, 0x69, 0x73, 0x4b, 0x6e, 0x6f, 0x77, 0x6e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, - 0x72, 0x12, 0x2f, 0x0a, 0x14, 0x69, 0x73, 0x5f, 0x63, 0x61, 0x72, 0x64, 0x5f, 0x61, 0x6c, 0x72, - 0x65, 0x61, 0x64, 0x79, 0x5f, 0x75, 0x73, 0x65, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, - 0x11, 0x69, 0x73, 0x43, 0x61, 0x72, 0x64, 0x41, 0x6c, 0x72, 0x65, 0x61, 0x64, 0x79, 0x55, 0x73, - 0x65, 0x64, 0x22, 0x41, 0x0a, 0x1e, 0x53, 0x69, 0x67, 0x6e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, - 0x65, 0x53, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x43, 0x61, 0x72, 0x64, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x12, 0x1f, 0x0a, 0x0b, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x5f, - 0x68, 0x65, 0x78, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x6d, 0x65, 0x73, 0x73, 0x61, - 0x67, 0x65, 0x48, 0x65, 0x78, 0x22, 0x72, 0x0a, 0x1f, 0x53, 0x69, 0x67, 0x6e, 0x4d, 0x65, 0x73, - 0x73, 0x61, 0x67, 0x65, 0x53, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x43, 0x61, 0x72, 0x64, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2c, 0x0a, 0x12, 0x73, 0x69, 0x67, 0x6e, - 0x65, 0x64, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x5f, 0x68, 0x65, 0x78, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x73, 0x69, 0x67, 0x6e, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, - 0x61, 0x67, 0x65, 0x48, 0x65, 0x78, 0x12, 0x21, 0x0a, 0x0c, 0x69, 0x73, 0x5f, 0x76, 0x61, 0x6c, - 0x69, 0x64, 0x61, 0x74, 0x65, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0b, 0x69, 0x73, - 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x64, 0x22, 0x3c, 0x0a, 0x1b, 0x44, 0x69, 0x61, - 0x67, 0x6e, 0x6f, 0x73, 0x74, 0x69, 0x63, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x44, 0x65, - 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x6f, 0x72, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x73, 0x73, - 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, - 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x22, 0x9c, 0x01, 0x0a, 0x12, 0x53, 0x63, 0x61, 0x6e, - 0x50, 0x72, 0x6f, 0x67, 0x72, 0x65, 0x73, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x12, 0x42, - 0x0a, 0x11, 0x66, 0x6f, 0x75, 0x6e, 0x64, 0x5f, 0x75, 0x74, 0x78, 0x6f, 0x5f, 0x72, 0x65, 0x70, - 0x6f, 0x72, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x72, 0x70, 0x63, 0x2e, - 0x46, 0x6f, 0x75, 0x6e, 0x64, 0x55, 0x74, 0x78, 0x6f, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x48, - 0x00, 0x52, 0x0f, 0x66, 0x6f, 0x75, 0x6e, 0x64, 0x55, 0x74, 0x78, 0x6f, 0x52, 0x65, 0x70, 0x6f, - 0x72, 0x74, 0x12, 0x38, 0x0a, 0x0d, 0x73, 0x63, 0x61, 0x6e, 0x5f, 0x63, 0x6f, 0x6d, 0x70, 0x6c, - 0x65, 0x74, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x72, 0x70, 0x63, 0x2e, - 0x53, 0x63, 0x61, 0x6e, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x48, 0x00, 0x52, 0x0c, - 0x73, 0x63, 0x61, 0x6e, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x42, 0x08, 0x0a, 0x06, - 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x22, 0x43, 0x0a, 0x0f, 0x46, 0x6f, 0x75, 0x6e, 0x64, 0x55, - 0x74, 0x78, 0x6f, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x61, 0x64, 0x64, - 0x72, 0x65, 0x73, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x61, 0x64, 0x64, 0x72, - 0x65, 0x73, 0x73, 0x12, 0x16, 0x0a, 0x06, 0x61, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x02, 0x20, - 0x01, 0x28, 0x03, 0x52, 0x06, 0x61, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x22, 0x26, 0x0a, 0x0c, 0x53, - 0x63, 0x61, 0x6e, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x73, - 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x74, 0x61, - 0x74, 0x75, 0x73, 0x22, 0x60, 0x0a, 0x16, 0x44, 0x69, 0x61, 0x67, 0x6e, 0x6f, 0x73, 0x74, 0x69, - 0x63, 0x53, 0x75, 0x62, 0x6d, 0x69, 0x74, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x1f, 0x0a, - 0x0b, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x03, 0x52, 0x0a, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x43, 0x6f, 0x64, 0x65, 0x12, 0x25, - 0x0a, 0x0e, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x4d, 0x65, - 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0xcd, 0x01, 0x0a, 0x15, 0x50, 0x72, 0x65, 0x70, 0x61, 0x72, - 0x65, 0x53, 0x77, 0x65, 0x65, 0x70, 0x54, 0x78, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, - 0x4e, 0x0a, 0x11, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x44, 0x65, 0x73, 0x63, 0x72, 0x69, - 0x70, 0x74, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x20, 0x2e, 0x72, 0x70, 0x63, - 0x2e, 0x44, 0x69, 0x61, 0x67, 0x6e, 0x6f, 0x73, 0x74, 0x69, 0x63, 0x53, 0x65, 0x73, 0x73, 0x69, - 0x6f, 0x6e, 0x44, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x6f, 0x72, 0x52, 0x11, 0x73, 0x65, - 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x44, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x6f, 0x72, 0x12, - 0x2e, 0x0a, 0x12, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x41, 0x64, - 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x12, 0x64, 0x65, 0x73, - 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, - 0x34, 0x0a, 0x15, 0x66, 0x65, 0x65, 0x52, 0x61, 0x74, 0x65, 0x49, 0x6e, 0x53, 0x61, 0x74, 0x73, - 0x50, 0x65, 0x72, 0x56, 0x42, 0x79, 0x74, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x01, 0x52, 0x15, - 0x66, 0x65, 0x65, 0x52, 0x61, 0x74, 0x65, 0x49, 0x6e, 0x53, 0x61, 0x74, 0x73, 0x50, 0x65, 0x72, - 0x56, 0x42, 0x79, 0x74, 0x65, 0x22, 0xde, 0x01, 0x0a, 0x16, 0x50, 0x72, 0x65, 0x70, 0x61, 0x72, - 0x65, 0x53, 0x77, 0x65, 0x65, 0x70, 0x54, 0x78, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x78, 0x0a, 0x19, 0x53, 0x65, 0x74, 0x75, 0x70, + 0x53, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x43, 0x61, 0x72, 0x64, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2a, 0x0a, 0x11, 0x69, 0x73, 0x5f, 0x6b, 0x6e, 0x6f, 0x77, 0x6e, + 0x5f, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, + 0x0f, 0x69, 0x73, 0x4b, 0x6e, 0x6f, 0x77, 0x6e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, + 0x12, 0x2f, 0x0a, 0x14, 0x69, 0x73, 0x5f, 0x63, 0x61, 0x72, 0x64, 0x5f, 0x61, 0x6c, 0x72, 0x65, + 0x61, 0x64, 0x79, 0x5f, 0x75, 0x73, 0x65, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x11, + 0x69, 0x73, 0x43, 0x61, 0x72, 0x64, 0x41, 0x6c, 0x72, 0x65, 0x61, 0x64, 0x79, 0x55, 0x73, 0x65, + 0x64, 0x22, 0x81, 0x01, 0x0a, 0x22, 0x50, 0x61, 0x69, 0x72, 0x53, 0x69, 0x67, 0x6e, 0x41, 0x6e, + 0x64, 0x53, 0x75, 0x62, 0x6d, 0x69, 0x74, 0x43, 0x68, 0x61, 0x6c, 0x6c, 0x65, 0x6e, 0x67, 0x65, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2a, 0x0a, 0x11, 0x69, 0x73, 0x5f, 0x6b, + 0x6e, 0x6f, 0x77, 0x6e, 0x5f, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x08, 0x52, 0x0f, 0x69, 0x73, 0x4b, 0x6e, 0x6f, 0x77, 0x6e, 0x50, 0x72, 0x6f, 0x76, + 0x69, 0x64, 0x65, 0x72, 0x12, 0x2f, 0x0a, 0x14, 0x69, 0x73, 0x5f, 0x63, 0x61, 0x72, 0x64, 0x5f, + 0x61, 0x6c, 0x72, 0x65, 0x61, 0x64, 0x79, 0x5f, 0x75, 0x73, 0x65, 0x64, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x08, 0x52, 0x11, 0x69, 0x73, 0x43, 0x61, 0x72, 0x64, 0x41, 0x6c, 0x72, 0x65, 0x61, 0x64, + 0x79, 0x55, 0x73, 0x65, 0x64, 0x22, 0x88, 0x02, 0x0a, 0x22, 0x50, 0x61, 0x69, 0x72, 0x53, 0x69, + 0x67, 0x6e, 0x41, 0x6e, 0x64, 0x53, 0x75, 0x62, 0x6d, 0x69, 0x74, 0x43, 0x68, 0x61, 0x6c, 0x6c, + 0x65, 0x6e, 0x67, 0x65, 0x50, 0x72, 0x6f, 0x67, 0x72, 0x65, 0x73, 0x73, 0x12, 0x4d, 0x0a, 0x14, + 0x72, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x69, 0x6e, 0x67, 0x5f, 0x63, 0x68, 0x61, 0x6c, 0x6c, + 0x65, 0x6e, 0x67, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x72, 0x70, 0x63, + 0x2e, 0x52, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x69, 0x6e, 0x67, 0x43, 0x68, 0x61, 0x6c, 0x6c, + 0x65, 0x6e, 0x67, 0x65, 0x48, 0x00, 0x52, 0x13, 0x72, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x69, + 0x6e, 0x67, 0x43, 0x68, 0x61, 0x6c, 0x6c, 0x65, 0x6e, 0x67, 0x65, 0x12, 0x41, 0x0a, 0x10, 0x63, + 0x68, 0x61, 0x6c, 0x6c, 0x65, 0x6e, 0x67, 0x65, 0x5f, 0x73, 0x69, 0x67, 0x6e, 0x65, 0x64, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x43, 0x68, 0x61, 0x6c, + 0x6c, 0x65, 0x6e, 0x67, 0x65, 0x53, 0x69, 0x67, 0x6e, 0x65, 0x64, 0x48, 0x00, 0x52, 0x0f, 0x63, + 0x68, 0x61, 0x6c, 0x6c, 0x65, 0x6e, 0x67, 0x65, 0x53, 0x69, 0x67, 0x6e, 0x65, 0x64, 0x12, 0x47, + 0x0a, 0x09, 0x63, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x27, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x50, 0x61, 0x69, 0x72, 0x53, 0x69, 0x67, 0x6e, + 0x41, 0x6e, 0x64, 0x53, 0x75, 0x62, 0x6d, 0x69, 0x74, 0x43, 0x68, 0x61, 0x6c, 0x6c, 0x65, 0x6e, + 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x48, 0x00, 0x52, 0x09, 0x63, 0x6f, + 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x42, 0x07, 0x0a, 0x05, 0x65, 0x76, 0x65, 0x6e, 0x74, + 0x22, 0x15, 0x0a, 0x13, 0x52, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x69, 0x6e, 0x67, 0x43, 0x68, + 0x61, 0x6c, 0x6c, 0x65, 0x6e, 0x67, 0x65, 0x22, 0x11, 0x0a, 0x0f, 0x43, 0x68, 0x61, 0x6c, 0x6c, + 0x65, 0x6e, 0x67, 0x65, 0x53, 0x69, 0x67, 0x6e, 0x65, 0x64, 0x22, 0x3c, 0x0a, 0x1b, 0x44, 0x69, + 0x61, 0x67, 0x6e, 0x6f, 0x73, 0x74, 0x69, 0x63, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x44, + 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x6f, 0x72, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x73, + 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, + 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x22, 0x9c, 0x01, 0x0a, 0x12, 0x53, 0x63, 0x61, + 0x6e, 0x50, 0x72, 0x6f, 0x67, 0x72, 0x65, 0x73, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x12, + 0x42, 0x0a, 0x11, 0x66, 0x6f, 0x75, 0x6e, 0x64, 0x5f, 0x75, 0x74, 0x78, 0x6f, 0x5f, 0x72, 0x65, + 0x70, 0x6f, 0x72, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x72, 0x70, 0x63, + 0x2e, 0x46, 0x6f, 0x75, 0x6e, 0x64, 0x55, 0x74, 0x78, 0x6f, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, + 0x48, 0x00, 0x52, 0x0f, 0x66, 0x6f, 0x75, 0x6e, 0x64, 0x55, 0x74, 0x78, 0x6f, 0x52, 0x65, 0x70, + 0x6f, 0x72, 0x74, 0x12, 0x38, 0x0a, 0x0d, 0x73, 0x63, 0x61, 0x6e, 0x5f, 0x63, 0x6f, 0x6d, 0x70, + 0x6c, 0x65, 0x74, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x72, 0x70, 0x63, + 0x2e, 0x53, 0x63, 0x61, 0x6e, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x48, 0x00, 0x52, + 0x0c, 0x73, 0x63, 0x61, 0x6e, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x42, 0x08, 0x0a, + 0x06, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x22, 0x43, 0x0a, 0x0f, 0x46, 0x6f, 0x75, 0x6e, 0x64, + 0x55, 0x74, 0x78, 0x6f, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x61, 0x64, + 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x61, 0x64, 0x64, + 0x72, 0x65, 0x73, 0x73, 0x12, 0x16, 0x0a, 0x06, 0x61, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x61, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x22, 0x26, 0x0a, 0x0c, + 0x53, 0x63, 0x61, 0x6e, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x12, 0x16, 0x0a, 0x06, + 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x74, + 0x61, 0x74, 0x75, 0x73, 0x22, 0x60, 0x0a, 0x16, 0x44, 0x69, 0x61, 0x67, 0x6e, 0x6f, 0x73, 0x74, + 0x69, 0x63, 0x53, 0x75, 0x62, 0x6d, 0x69, 0x74, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x1f, + 0x0a, 0x0b, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x03, 0x52, 0x0a, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x43, 0x6f, 0x64, 0x65, 0x12, + 0x25, 0x0a, 0x0e, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, + 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x4d, + 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0xcd, 0x01, 0x0a, 0x15, 0x50, 0x72, 0x65, 0x70, 0x61, + 0x72, 0x65, 0x53, 0x77, 0x65, 0x65, 0x70, 0x54, 0x78, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x4e, 0x0a, 0x11, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x44, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x20, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x44, 0x69, 0x61, 0x67, 0x6e, 0x6f, 0x73, 0x74, 0x69, 0x63, 0x53, 0x65, 0x73, 0x73, @@ -3128,383 +4472,576 @@ var file_wallet_service_proto_rawDesc = string([]byte{ 0x12, 0x2e, 0x0a, 0x12, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x12, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, - 0x12, 0x24, 0x0a, 0x0d, 0x74, 0x78, 0x53, 0x69, 0x7a, 0x65, 0x49, 0x6e, 0x42, 0x79, 0x74, 0x65, - 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0d, 0x74, 0x78, 0x53, 0x69, 0x7a, 0x65, 0x49, - 0x6e, 0x42, 0x79, 0x74, 0x65, 0x73, 0x12, 0x1e, 0x0a, 0x0a, 0x74, 0x78, 0x54, 0x6f, 0x74, 0x61, - 0x6c, 0x46, 0x65, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0a, 0x74, 0x78, 0x54, 0x6f, - 0x74, 0x61, 0x6c, 0x46, 0x65, 0x65, 0x22, 0x94, 0x01, 0x0a, 0x1e, 0x53, 0x69, 0x67, 0x6e, 0x41, - 0x6e, 0x64, 0x42, 0x72, 0x6f, 0x61, 0x64, 0x63, 0x61, 0x73, 0x74, 0x53, 0x77, 0x65, 0x65, 0x70, - 0x54, 0x78, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x4e, 0x0a, 0x11, 0x73, 0x65, 0x73, - 0x73, 0x69, 0x6f, 0x6e, 0x44, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x6f, 0x72, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x0b, 0x32, 0x20, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x44, 0x69, 0x61, 0x67, 0x6e, - 0x6f, 0x73, 0x74, 0x69, 0x63, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x44, 0x65, 0x73, 0x63, - 0x72, 0x69, 0x70, 0x74, 0x6f, 0x72, 0x52, 0x11, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x44, - 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x6f, 0x72, 0x12, 0x22, 0x0a, 0x0c, 0x72, 0x65, 0x63, - 0x6f, 0x76, 0x65, 0x72, 0x79, 0x43, 0x6f, 0x64, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x0c, 0x72, 0x65, 0x63, 0x6f, 0x76, 0x65, 0x72, 0x79, 0x43, 0x6f, 0x64, 0x65, 0x22, 0x85, 0x01, - 0x0a, 0x1f, 0x53, 0x69, 0x67, 0x6e, 0x41, 0x6e, 0x64, 0x42, 0x72, 0x6f, 0x61, 0x64, 0x63, 0x61, - 0x73, 0x74, 0x53, 0x77, 0x65, 0x65, 0x70, 0x54, 0x78, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x12, 0x34, 0x0a, 0x15, 0x66, 0x65, 0x65, 0x52, 0x61, 0x74, 0x65, 0x49, 0x6e, 0x53, 0x61, 0x74, + 0x73, 0x50, 0x65, 0x72, 0x56, 0x42, 0x79, 0x74, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x01, 0x52, + 0x15, 0x66, 0x65, 0x65, 0x52, 0x61, 0x74, 0x65, 0x49, 0x6e, 0x53, 0x61, 0x74, 0x73, 0x50, 0x65, + 0x72, 0x56, 0x42, 0x79, 0x74, 0x65, 0x22, 0xde, 0x01, 0x0a, 0x16, 0x50, 0x72, 0x65, 0x70, 0x61, + 0x72, 0x65, 0x53, 0x77, 0x65, 0x65, 0x70, 0x54, 0x78, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4e, 0x0a, 0x11, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x44, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x20, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x44, 0x69, 0x61, 0x67, 0x6e, 0x6f, 0x73, 0x74, 0x69, 0x63, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x44, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x6f, 0x72, 0x52, 0x11, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x44, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x6f, - 0x72, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x78, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x04, 0x74, 0x78, 0x69, 0x64, 0x22, 0xac, 0x01, 0x0a, 0x15, 0x43, 0x68, 0x61, 0x6c, 0x6c, 0x65, - 0x6e, 0x67, 0x65, 0x53, 0x65, 0x74, 0x75, 0x70, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, - 0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, - 0x79, 0x70, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f, 0x6b, 0x65, - 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x4b, - 0x65, 0x79, 0x12, 0x12, 0x0a, 0x04, 0x73, 0x61, 0x6c, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x04, 0x73, 0x61, 0x6c, 0x74, 0x12, 0x32, 0x0a, 0x15, 0x65, 0x6e, 0x63, 0x72, 0x79, 0x70, - 0x74, 0x65, 0x64, 0x5f, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x5f, 0x6b, 0x65, 0x79, 0x18, - 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x13, 0x65, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, - 0x50, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x4b, 0x65, 0x79, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, - 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, 0x05, 0x52, 0x07, 0x76, 0x65, 0x72, - 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x95, 0x01, 0x0a, 0x16, 0x53, 0x65, 0x74, 0x75, 0x70, 0x43, 0x68, - 0x61, 0x6c, 0x6c, 0x65, 0x6e, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, - 0x1e, 0x0a, 0x08, 0x6d, 0x75, 0x75, 0x6e, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x09, 0x48, 0x00, 0x52, 0x07, 0x6d, 0x75, 0x75, 0x6e, 0x4b, 0x65, 0x79, 0x88, 0x01, 0x01, 0x12, - 0x35, 0x0a, 0x14, 0x6d, 0x75, 0x75, 0x6e, 0x5f, 0x6b, 0x65, 0x79, 0x5f, 0x66, 0x69, 0x6e, 0x67, - 0x65, 0x72, 0x70, 0x72, 0x69, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x48, 0x01, 0x52, - 0x12, 0x6d, 0x75, 0x75, 0x6e, 0x4b, 0x65, 0x79, 0x46, 0x69, 0x6e, 0x67, 0x65, 0x72, 0x70, 0x72, - 0x69, 0x6e, 0x74, 0x88, 0x01, 0x01, 0x42, 0x0b, 0x0a, 0x09, 0x5f, 0x6d, 0x75, 0x75, 0x6e, 0x5f, - 0x6b, 0x65, 0x79, 0x42, 0x17, 0x0a, 0x15, 0x5f, 0x6d, 0x75, 0x75, 0x6e, 0x5f, 0x6b, 0x65, 0x79, - 0x5f, 0x66, 0x69, 0x6e, 0x67, 0x65, 0x72, 0x70, 0x72, 0x69, 0x6e, 0x74, 0x22, 0x60, 0x0a, 0x1e, - 0x46, 0x69, 0x6e, 0x69, 0x73, 0x68, 0x52, 0x65, 0x63, 0x6f, 0x76, 0x65, 0x72, 0x79, 0x43, 0x6f, - 0x64, 0x65, 0x53, 0x65, 0x74, 0x75, 0x70, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x3e, - 0x0a, 0x1c, 0x72, 0x65, 0x63, 0x6f, 0x76, 0x65, 0x72, 0x79, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x5f, - 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f, 0x6b, 0x65, 0x79, 0x5f, 0x68, 0x65, 0x78, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x18, 0x72, 0x65, 0x63, 0x6f, 0x76, 0x65, 0x72, 0x79, 0x43, 0x6f, - 0x64, 0x65, 0x50, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x4b, 0x65, 0x79, 0x48, 0x65, 0x78, 0x22, 0x61, - 0x0a, 0x1f, 0x50, 0x6f, 0x70, 0x75, 0x6c, 0x61, 0x74, 0x65, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, - 0x74, 0x65, 0x64, 0x4d, 0x75, 0x75, 0x6e, 0x4b, 0x65, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x12, 0x3e, 0x0a, 0x1c, 0x72, 0x65, 0x63, 0x6f, 0x76, 0x65, 0x72, 0x79, 0x5f, 0x63, 0x6f, - 0x64, 0x65, 0x5f, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f, 0x6b, 0x65, 0x79, 0x5f, 0x68, 0x65, - 0x78, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x18, 0x72, 0x65, 0x63, 0x6f, 0x76, 0x65, 0x72, - 0x79, 0x43, 0x6f, 0x64, 0x65, 0x50, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x4b, 0x65, 0x79, 0x48, 0x65, - 0x78, 0x22, 0x80, 0x01, 0x0a, 0x06, 0x53, 0x74, 0x72, 0x75, 0x63, 0x74, 0x12, 0x2f, 0x0a, 0x06, - 0x66, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x72, - 0x70, 0x63, 0x2e, 0x53, 0x74, 0x72, 0x75, 0x63, 0x74, 0x2e, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, - 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x06, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x1a, 0x45, 0x0a, - 0x0b, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, - 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x20, - 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0a, 0x2e, - 0x72, 0x70, 0x63, 0x2e, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, - 0x3a, 0x02, 0x38, 0x01, 0x22, 0xeb, 0x01, 0x0a, 0x05, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x2f, - 0x0a, 0x0a, 0x6e, 0x75, 0x6c, 0x6c, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x0e, 0x32, 0x0e, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x4e, 0x75, 0x6c, 0x6c, 0x56, 0x61, 0x6c, - 0x75, 0x65, 0x48, 0x00, 0x52, 0x09, 0x6e, 0x75, 0x6c, 0x6c, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x12, - 0x23, 0x0a, 0x0c, 0x64, 0x6f, 0x75, 0x62, 0x6c, 0x65, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, - 0x02, 0x20, 0x01, 0x28, 0x01, 0x48, 0x00, 0x52, 0x0b, 0x64, 0x6f, 0x75, 0x62, 0x6c, 0x65, 0x56, - 0x61, 0x6c, 0x75, 0x65, 0x12, 0x1d, 0x0a, 0x09, 0x69, 0x6e, 0x74, 0x5f, 0x76, 0x61, 0x6c, 0x75, - 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x48, 0x00, 0x52, 0x08, 0x69, 0x6e, 0x74, 0x56, 0x61, - 0x6c, 0x75, 0x65, 0x12, 0x1f, 0x0a, 0x0a, 0x6c, 0x6f, 0x6e, 0x67, 0x5f, 0x76, 0x61, 0x6c, 0x75, - 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x48, 0x00, 0x52, 0x09, 0x6c, 0x6f, 0x6e, 0x67, 0x56, - 0x61, 0x6c, 0x75, 0x65, 0x12, 0x23, 0x0a, 0x0c, 0x73, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x5f, 0x76, - 0x61, 0x6c, 0x75, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x0b, 0x73, 0x74, - 0x72, 0x69, 0x6e, 0x67, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x1f, 0x0a, 0x0a, 0x62, 0x6f, 0x6f, - 0x6c, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x48, 0x00, 0x52, - 0x09, 0x62, 0x6f, 0x6f, 0x6c, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x42, 0x06, 0x0a, 0x04, 0x6b, 0x69, - 0x6e, 0x64, 0x22, 0x41, 0x0a, 0x0b, 0x53, 0x61, 0x76, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, - 0x6b, 0x65, 0x79, 0x12, 0x20, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, - 0x28, 0x0b, 0x32, 0x0a, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x05, - 0x76, 0x61, 0x6c, 0x75, 0x65, 0x22, 0x1e, 0x0a, 0x0a, 0x47, 0x65, 0x74, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x03, 0x6b, 0x65, 0x79, 0x22, 0x2f, 0x0a, 0x0b, 0x47, 0x65, 0x74, 0x52, 0x65, 0x73, 0x70, - 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x20, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x01, 0x20, - 0x01, 0x28, 0x0b, 0x32, 0x0a, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, - 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x22, 0x21, 0x0a, 0x0d, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, - 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x22, 0x35, 0x0a, 0x10, 0x53, 0x61, 0x76, - 0x65, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x21, 0x0a, - 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0b, 0x2e, 0x72, - 0x70, 0x63, 0x2e, 0x53, 0x74, 0x72, 0x75, 0x63, 0x74, 0x52, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, - 0x22, 0x25, 0x0a, 0x0f, 0x47, 0x65, 0x74, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x6b, 0x65, 0x79, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, - 0x09, 0x52, 0x04, 0x6b, 0x65, 0x79, 0x73, 0x22, 0x35, 0x0a, 0x10, 0x47, 0x65, 0x74, 0x42, 0x61, - 0x74, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x21, 0x0a, 0x05, 0x69, - 0x74, 0x65, 0x6d, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0b, 0x2e, 0x72, 0x70, 0x63, - 0x2e, 0x53, 0x74, 0x72, 0x75, 0x63, 0x74, 0x52, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x22, 0x2c, - 0x0a, 0x12, 0x47, 0x65, 0x74, 0x42, 0x79, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x22, 0xf5, 0x01, 0x0a, - 0x15, 0x53, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x43, 0x61, 0x72, 0x64, 0x73, 0x50, 0x72, - 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x38, 0x0a, 0x0e, 0x73, 0x65, - 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x5f, 0x63, 0x61, 0x72, 0x64, 0x73, 0x18, 0x02, 0x20, 0x03, - 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x53, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, - 0x79, 0x43, 0x61, 0x72, 0x64, 0x52, 0x0d, 0x73, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x43, - 0x61, 0x72, 0x64, 0x73, 0x12, 0x1a, 0x0a, 0x08, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x63, 0x79, - 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x63, 0x79, - 0x12, 0x1b, 0x0a, 0x09, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x5f, 0x68, 0x65, 0x78, 0x18, 0x04, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x08, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x48, 0x65, 0x78, 0x12, 0x1a, 0x0a, - 0x08, 0x6d, 0x61, 0x74, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x08, 0x6d, 0x61, 0x74, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x12, 0x14, 0x0a, 0x05, 0x70, 0x72, 0x69, - 0x63, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x01, 0x52, 0x05, 0x70, 0x72, 0x69, 0x63, 0x65, 0x12, - 0x23, 0x0a, 0x0d, 0x73, 0x68, 0x69, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x5f, 0x63, 0x6f, 0x73, 0x74, - 0x18, 0x07, 0x20, 0x01, 0x28, 0x01, 0x52, 0x0c, 0x73, 0x68, 0x69, 0x70, 0x70, 0x69, 0x6e, 0x67, - 0x43, 0x6f, 0x73, 0x74, 0x22, 0x3a, 0x0a, 0x0c, 0x53, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, - 0x43, 0x61, 0x72, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x69, 0x6d, 0x61, 0x67, 0x65, 0x18, 0x01, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x05, 0x69, 0x6d, 0x61, 0x67, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, - 0x6f, 0x63, 0x6b, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x05, 0x73, 0x74, 0x6f, 0x63, 0x6b, - 0x22, 0x5f, 0x0a, 0x23, 0x47, 0x65, 0x74, 0x53, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x43, - 0x61, 0x72, 0x64, 0x73, 0x4d, 0x61, 0x72, 0x6b, 0x65, 0x74, 0x70, 0x6c, 0x61, 0x63, 0x65, 0x52, - 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x38, 0x0a, 0x09, 0x70, 0x72, 0x6f, 0x76, 0x69, - 0x64, 0x65, 0x72, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x72, 0x70, 0x63, - 0x2e, 0x53, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x43, 0x61, 0x72, 0x64, 0x73, 0x50, 0x72, - 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x52, 0x09, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, - 0x73, 0x22, 0xef, 0x01, 0x0a, 0x0e, 0x45, 0x4b, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x12, 0x2e, 0x0a, 0x13, 0x66, 0x69, 0x72, 0x73, 0x74, 0x5f, 0x65, 0x6e, - 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x11, 0x66, 0x69, 0x72, 0x73, 0x74, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, - 0x64, 0x4b, 0x65, 0x79, 0x12, 0x2b, 0x0a, 0x11, 0x66, 0x69, 0x72, 0x73, 0x74, 0x5f, 0x66, 0x69, - 0x6e, 0x67, 0x65, 0x72, 0x70, 0x72, 0x69, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x10, 0x66, 0x69, 0x72, 0x73, 0x74, 0x46, 0x69, 0x6e, 0x67, 0x65, 0x72, 0x70, 0x72, 0x69, 0x6e, - 0x74, 0x12, 0x30, 0x0a, 0x14, 0x73, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x5f, 0x65, 0x6e, 0x63, 0x72, - 0x79, 0x70, 0x74, 0x65, 0x64, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x12, 0x73, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, - 0x4b, 0x65, 0x79, 0x12, 0x2d, 0x0a, 0x12, 0x73, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x5f, 0x66, 0x69, - 0x6e, 0x67, 0x65, 0x72, 0x70, 0x72, 0x69, 0x6e, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x11, 0x73, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x46, 0x69, 0x6e, 0x67, 0x65, 0x72, 0x70, 0x72, 0x69, - 0x6e, 0x74, 0x12, 0x1f, 0x0a, 0x0b, 0x72, 0x63, 0x5f, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x73, 0x75, - 0x6d, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x72, 0x63, 0x43, 0x68, 0x65, 0x63, 0x6b, - 0x73, 0x75, 0x6d, 0x22, 0x8d, 0x01, 0x0a, 0x1e, 0x47, 0x65, 0x6e, 0x65, 0x72, 0x61, 0x74, 0x65, - 0x45, 0x6d, 0x65, 0x72, 0x67, 0x65, 0x6e, 0x63, 0x79, 0x4b, 0x69, 0x74, 0x50, 0x44, 0x46, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2e, 0x0a, 0x08, 0x65, 0x6b, 0x5f, 0x69, 0x6e, 0x70, - 0x75, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x45, - 0x4b, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x52, 0x07, 0x65, - 0x6b, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x12, 0x1f, 0x0a, 0x0b, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, - 0x5f, 0x70, 0x61, 0x74, 0x68, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x6f, 0x75, 0x74, - 0x70, 0x75, 0x74, 0x50, 0x61, 0x74, 0x68, 0x12, 0x1a, 0x0a, 0x08, 0x6c, 0x61, 0x6e, 0x67, 0x75, - 0x61, 0x67, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x6c, 0x61, 0x6e, 0x67, 0x75, - 0x61, 0x67, 0x65, 0x22, 0x68, 0x0a, 0x1f, 0x47, 0x65, 0x6e, 0x65, 0x72, 0x61, 0x74, 0x65, 0x45, - 0x6d, 0x65, 0x72, 0x67, 0x65, 0x6e, 0x63, 0x79, 0x4b, 0x69, 0x74, 0x50, 0x44, 0x46, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2b, 0x0a, 0x11, 0x76, 0x65, 0x72, 0x69, 0x66, 0x69, - 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x10, 0x76, 0x65, 0x72, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x43, - 0x6f, 0x64, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x05, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x2a, 0x33, 0x0a, - 0x09, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x54, 0x79, 0x70, 0x65, 0x12, 0x0a, 0x0a, 0x06, 0x43, 0x4c, - 0x49, 0x45, 0x4e, 0x54, 0x10, 0x00, 0x12, 0x0d, 0x0a, 0x09, 0x4c, 0x49, 0x42, 0x57, 0x41, 0x4c, - 0x4c, 0x45, 0x54, 0x10, 0x01, 0x12, 0x0b, 0x0a, 0x07, 0x48, 0x4f, 0x55, 0x53, 0x54, 0x4f, 0x4e, - 0x10, 0x02, 0x2a, 0x1b, 0x0a, 0x09, 0x4e, 0x75, 0x6c, 0x6c, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x12, - 0x0e, 0x0a, 0x0a, 0x4e, 0x55, 0x4c, 0x4c, 0x5f, 0x56, 0x41, 0x4c, 0x55, 0x45, 0x10, 0x00, 0x32, - 0xde, 0x0c, 0x0a, 0x0d, 0x57, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, - 0x65, 0x12, 0x3e, 0x0a, 0x11, 0x53, 0x65, 0x74, 0x75, 0x70, 0x53, 0x65, 0x63, 0x75, 0x72, 0x69, - 0x74, 0x79, 0x43, 0x61, 0x72, 0x64, 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, - 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x11, - 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x58, 0x70, 0x75, 0x62, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, - 0x65, 0x12, 0x43, 0x0a, 0x11, 0x52, 0x65, 0x73, 0x65, 0x74, 0x53, 0x65, 0x63, 0x75, 0x72, 0x69, - 0x74, 0x79, 0x43, 0x61, 0x72, 0x64, 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, - 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x16, - 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, - 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x64, 0x0a, 0x17, 0x53, 0x69, 0x67, 0x6e, 0x4d, 0x65, - 0x73, 0x73, 0x61, 0x67, 0x65, 0x53, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x43, 0x61, 0x72, - 0x64, 0x12, 0x23, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x53, 0x69, 0x67, 0x6e, 0x4d, 0x65, 0x73, 0x73, - 0x61, 0x67, 0x65, 0x53, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x43, 0x61, 0x72, 0x64, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x53, 0x69, 0x67, - 0x6e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x53, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, - 0x43, 0x61, 0x72, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4d, 0x0a, 0x13, - 0x53, 0x65, 0x74, 0x75, 0x70, 0x53, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x43, 0x61, 0x72, - 0x64, 0x56, 0x32, 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, - 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x1e, 0x2e, 0x72, 0x70, - 0x63, 0x2e, 0x53, 0x65, 0x74, 0x75, 0x70, 0x53, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x43, - 0x61, 0x72, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4b, 0x0a, 0x19, 0x53, - 0x69, 0x67, 0x6e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x53, 0x65, 0x63, 0x75, 0x72, 0x69, - 0x74, 0x79, 0x43, 0x61, 0x72, 0x64, 0x56, 0x32, 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, - 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, - 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, - 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x52, 0x0a, 0x16, 0x53, 0x74, 0x61, 0x72, - 0x74, 0x44, 0x69, 0x61, 0x67, 0x6e, 0x6f, 0x73, 0x74, 0x69, 0x63, 0x53, 0x65, 0x73, 0x73, 0x69, - 0x6f, 0x6e, 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, - 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x20, 0x2e, 0x72, 0x70, 0x63, - 0x2e, 0x44, 0x69, 0x61, 0x67, 0x6e, 0x6f, 0x73, 0x74, 0x69, 0x63, 0x53, 0x65, 0x73, 0x73, 0x69, - 0x6f, 0x6e, 0x44, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x6f, 0x72, 0x12, 0x5c, 0x0a, 0x1d, - 0x50, 0x65, 0x72, 0x66, 0x6f, 0x72, 0x6d, 0x44, 0x69, 0x61, 0x67, 0x6e, 0x6f, 0x73, 0x74, 0x69, - 0x63, 0x53, 0x63, 0x61, 0x6e, 0x46, 0x6f, 0x72, 0x55, 0x74, 0x78, 0x6f, 0x73, 0x12, 0x20, 0x2e, + 0x72, 0x12, 0x2e, 0x0a, 0x12, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, + 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x12, 0x64, + 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, + 0x73, 0x12, 0x24, 0x0a, 0x0d, 0x74, 0x78, 0x53, 0x69, 0x7a, 0x65, 0x49, 0x6e, 0x42, 0x79, 0x74, + 0x65, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0d, 0x74, 0x78, 0x53, 0x69, 0x7a, 0x65, + 0x49, 0x6e, 0x42, 0x79, 0x74, 0x65, 0x73, 0x12, 0x1e, 0x0a, 0x0a, 0x74, 0x78, 0x54, 0x6f, 0x74, + 0x61, 0x6c, 0x46, 0x65, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0a, 0x74, 0x78, 0x54, + 0x6f, 0x74, 0x61, 0x6c, 0x46, 0x65, 0x65, 0x22, 0x94, 0x01, 0x0a, 0x1e, 0x53, 0x69, 0x67, 0x6e, + 0x41, 0x6e, 0x64, 0x42, 0x72, 0x6f, 0x61, 0x64, 0x63, 0x61, 0x73, 0x74, 0x53, 0x77, 0x65, 0x65, + 0x70, 0x54, 0x78, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x4e, 0x0a, 0x11, 0x73, 0x65, + 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x44, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x6f, 0x72, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x20, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x44, 0x69, 0x61, 0x67, + 0x6e, 0x6f, 0x73, 0x74, 0x69, 0x63, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x44, 0x65, 0x73, + 0x63, 0x72, 0x69, 0x70, 0x74, 0x6f, 0x72, 0x52, 0x11, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, + 0x44, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x6f, 0x72, 0x12, 0x22, 0x0a, 0x0c, 0x72, 0x65, + 0x63, 0x6f, 0x76, 0x65, 0x72, 0x79, 0x43, 0x6f, 0x64, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x0c, 0x72, 0x65, 0x63, 0x6f, 0x76, 0x65, 0x72, 0x79, 0x43, 0x6f, 0x64, 0x65, 0x22, 0x85, + 0x01, 0x0a, 0x1f, 0x53, 0x69, 0x67, 0x6e, 0x41, 0x6e, 0x64, 0x42, 0x72, 0x6f, 0x61, 0x64, 0x63, + 0x61, 0x73, 0x74, 0x53, 0x77, 0x65, 0x65, 0x70, 0x54, 0x78, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x12, 0x4e, 0x0a, 0x11, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x44, 0x65, 0x73, + 0x63, 0x72, 0x69, 0x70, 0x74, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x20, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x44, 0x69, 0x61, 0x67, 0x6e, 0x6f, 0x73, 0x74, 0x69, 0x63, 0x53, 0x65, - 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x44, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x6f, 0x72, 0x1a, - 0x17, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x53, 0x63, 0x61, 0x6e, 0x50, 0x72, 0x6f, 0x67, 0x72, 0x65, - 0x73, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x30, 0x01, 0x12, 0x54, 0x0a, 0x13, 0x53, 0x75, - 0x62, 0x6d, 0x69, 0x74, 0x44, 0x69, 0x61, 0x67, 0x6e, 0x6f, 0x73, 0x74, 0x69, 0x63, 0x4c, 0x6f, - 0x67, 0x12, 0x20, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x44, 0x69, 0x61, 0x67, 0x6e, 0x6f, 0x73, 0x74, - 0x69, 0x63, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x44, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, - 0x74, 0x6f, 0x72, 0x1a, 0x1b, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x44, 0x69, 0x61, 0x67, 0x6e, 0x6f, - 0x73, 0x74, 0x69, 0x63, 0x53, 0x75, 0x62, 0x6d, 0x69, 0x74, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, - 0x12, 0x49, 0x0a, 0x0e, 0x50, 0x72, 0x65, 0x70, 0x61, 0x72, 0x65, 0x53, 0x77, 0x65, 0x65, 0x70, - 0x54, 0x78, 0x12, 0x1a, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x50, 0x72, 0x65, 0x70, 0x61, 0x72, 0x65, - 0x53, 0x77, 0x65, 0x65, 0x70, 0x54, 0x78, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, - 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x50, 0x72, 0x65, 0x70, 0x61, 0x72, 0x65, 0x53, 0x77, 0x65, 0x65, - 0x70, 0x54, 0x78, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x64, 0x0a, 0x17, 0x53, - 0x69, 0x67, 0x6e, 0x41, 0x6e, 0x64, 0x42, 0x72, 0x6f, 0x61, 0x64, 0x63, 0x61, 0x73, 0x74, 0x53, - 0x77, 0x65, 0x65, 0x70, 0x54, 0x78, 0x12, 0x23, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x53, 0x69, 0x67, - 0x6e, 0x41, 0x6e, 0x64, 0x42, 0x72, 0x6f, 0x61, 0x64, 0x63, 0x61, 0x73, 0x74, 0x53, 0x77, 0x65, - 0x65, 0x70, 0x54, 0x78, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e, 0x72, 0x70, - 0x63, 0x2e, 0x53, 0x69, 0x67, 0x6e, 0x41, 0x6e, 0x64, 0x42, 0x72, 0x6f, 0x61, 0x64, 0x63, 0x61, - 0x73, 0x74, 0x53, 0x77, 0x65, 0x65, 0x70, 0x54, 0x78, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, - 0x65, 0x12, 0x4e, 0x0a, 0x13, 0x53, 0x74, 0x61, 0x72, 0x74, 0x43, 0x68, 0x61, 0x6c, 0x6c, 0x65, - 0x6e, 0x67, 0x65, 0x53, 0x65, 0x74, 0x75, 0x70, 0x12, 0x1a, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x43, - 0x68, 0x61, 0x6c, 0x6c, 0x65, 0x6e, 0x67, 0x65, 0x53, 0x65, 0x74, 0x75, 0x70, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x53, 0x65, 0x74, 0x75, 0x70, - 0x43, 0x68, 0x61, 0x6c, 0x6c, 0x65, 0x6e, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, - 0x65, 0x12, 0x56, 0x0a, 0x17, 0x46, 0x69, 0x6e, 0x69, 0x73, 0x68, 0x52, 0x65, 0x63, 0x6f, 0x76, - 0x65, 0x72, 0x79, 0x43, 0x6f, 0x64, 0x65, 0x53, 0x65, 0x74, 0x75, 0x70, 0x12, 0x23, 0x2e, 0x72, - 0x70, 0x63, 0x2e, 0x46, 0x69, 0x6e, 0x69, 0x73, 0x68, 0x52, 0x65, 0x63, 0x6f, 0x76, 0x65, 0x72, - 0x79, 0x43, 0x6f, 0x64, 0x65, 0x53, 0x65, 0x74, 0x75, 0x70, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, - 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x58, 0x0a, 0x18, 0x50, 0x6f, 0x70, - 0x75, 0x6c, 0x61, 0x74, 0x65, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x75, - 0x75, 0x6e, 0x4b, 0x65, 0x79, 0x12, 0x24, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x50, 0x6f, 0x70, 0x75, - 0x6c, 0x61, 0x74, 0x65, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x75, 0x75, - 0x6e, 0x4b, 0x65, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, - 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, - 0x70, 0x74, 0x79, 0x12, 0x30, 0x0a, 0x04, 0x53, 0x61, 0x76, 0x65, 0x12, 0x10, 0x2e, 0x72, 0x70, - 0x63, 0x2e, 0x53, 0x61, 0x76, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, - 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, - 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x28, 0x0a, 0x03, 0x47, 0x65, 0x74, 0x12, 0x0f, 0x2e, 0x72, - 0x70, 0x63, 0x2e, 0x47, 0x65, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x10, 0x2e, - 0x72, 0x70, 0x63, 0x2e, 0x47, 0x65, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, - 0x34, 0x0a, 0x06, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x12, 0x12, 0x2e, 0x72, 0x70, 0x63, 0x2e, - 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, + 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x44, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x6f, 0x72, 0x52, + 0x11, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x44, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, + 0x6f, 0x72, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x78, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x04, 0x74, 0x78, 0x69, 0x64, 0x22, 0xac, 0x01, 0x0a, 0x15, 0x43, 0x68, 0x61, 0x6c, 0x6c, + 0x65, 0x6e, 0x67, 0x65, 0x53, 0x65, 0x74, 0x75, 0x70, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x12, 0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, + 0x74, 0x79, 0x70, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f, 0x6b, + 0x65, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, + 0x4b, 0x65, 0x79, 0x12, 0x12, 0x0a, 0x04, 0x73, 0x61, 0x6c, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x04, 0x73, 0x61, 0x6c, 0x74, 0x12, 0x32, 0x0a, 0x15, 0x65, 0x6e, 0x63, 0x72, 0x79, + 0x70, 0x74, 0x65, 0x64, 0x5f, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x5f, 0x6b, 0x65, 0x79, + 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x13, 0x65, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, + 0x64, 0x50, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x4b, 0x65, 0x79, 0x12, 0x18, 0x0a, 0x07, 0x76, + 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, 0x05, 0x52, 0x07, 0x76, 0x65, + 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x95, 0x01, 0x0a, 0x16, 0x53, 0x65, 0x74, 0x75, 0x70, 0x43, + 0x68, 0x61, 0x6c, 0x6c, 0x65, 0x6e, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x12, 0x1e, 0x0a, 0x08, 0x6d, 0x75, 0x75, 0x6e, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x48, 0x00, 0x52, 0x07, 0x6d, 0x75, 0x75, 0x6e, 0x4b, 0x65, 0x79, 0x88, 0x01, 0x01, + 0x12, 0x35, 0x0a, 0x14, 0x6d, 0x75, 0x75, 0x6e, 0x5f, 0x6b, 0x65, 0x79, 0x5f, 0x66, 0x69, 0x6e, + 0x67, 0x65, 0x72, 0x70, 0x72, 0x69, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x48, 0x01, + 0x52, 0x12, 0x6d, 0x75, 0x75, 0x6e, 0x4b, 0x65, 0x79, 0x46, 0x69, 0x6e, 0x67, 0x65, 0x72, 0x70, + 0x72, 0x69, 0x6e, 0x74, 0x88, 0x01, 0x01, 0x42, 0x0b, 0x0a, 0x09, 0x5f, 0x6d, 0x75, 0x75, 0x6e, + 0x5f, 0x6b, 0x65, 0x79, 0x42, 0x17, 0x0a, 0x15, 0x5f, 0x6d, 0x75, 0x75, 0x6e, 0x5f, 0x6b, 0x65, + 0x79, 0x5f, 0x66, 0x69, 0x6e, 0x67, 0x65, 0x72, 0x70, 0x72, 0x69, 0x6e, 0x74, 0x22, 0x60, 0x0a, + 0x1e, 0x46, 0x69, 0x6e, 0x69, 0x73, 0x68, 0x52, 0x65, 0x63, 0x6f, 0x76, 0x65, 0x72, 0x79, 0x43, + 0x6f, 0x64, 0x65, 0x53, 0x65, 0x74, 0x75, 0x70, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, + 0x3e, 0x0a, 0x1c, 0x72, 0x65, 0x63, 0x6f, 0x76, 0x65, 0x72, 0x79, 0x5f, 0x63, 0x6f, 0x64, 0x65, + 0x5f, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f, 0x6b, 0x65, 0x79, 0x5f, 0x68, 0x65, 0x78, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x18, 0x72, 0x65, 0x63, 0x6f, 0x76, 0x65, 0x72, 0x79, 0x43, + 0x6f, 0x64, 0x65, 0x50, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x4b, 0x65, 0x79, 0x48, 0x65, 0x78, 0x22, + 0x61, 0x0a, 0x1f, 0x50, 0x6f, 0x70, 0x75, 0x6c, 0x61, 0x74, 0x65, 0x45, 0x6e, 0x63, 0x72, 0x79, + 0x70, 0x74, 0x65, 0x64, 0x4d, 0x75, 0x75, 0x6e, 0x4b, 0x65, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x12, 0x3e, 0x0a, 0x1c, 0x72, 0x65, 0x63, 0x6f, 0x76, 0x65, 0x72, 0x79, 0x5f, 0x63, + 0x6f, 0x64, 0x65, 0x5f, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f, 0x6b, 0x65, 0x79, 0x5f, 0x68, + 0x65, 0x78, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x18, 0x72, 0x65, 0x63, 0x6f, 0x76, 0x65, + 0x72, 0x79, 0x43, 0x6f, 0x64, 0x65, 0x50, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x4b, 0x65, 0x79, 0x48, + 0x65, 0x78, 0x22, 0x80, 0x01, 0x0a, 0x06, 0x53, 0x74, 0x72, 0x75, 0x63, 0x74, 0x12, 0x2f, 0x0a, + 0x06, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x17, 0x2e, + 0x72, 0x70, 0x63, 0x2e, 0x53, 0x74, 0x72, 0x75, 0x63, 0x74, 0x2e, 0x46, 0x69, 0x65, 0x6c, 0x64, + 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x06, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x1a, 0x45, + 0x0a, 0x0b, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, + 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, + 0x20, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0a, + 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, + 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0xeb, 0x01, 0x0a, 0x05, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x12, + 0x2f, 0x0a, 0x0a, 0x6e, 0x75, 0x6c, 0x6c, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x0e, 0x32, 0x0e, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x4e, 0x75, 0x6c, 0x6c, 0x56, 0x61, + 0x6c, 0x75, 0x65, 0x48, 0x00, 0x52, 0x09, 0x6e, 0x75, 0x6c, 0x6c, 0x56, 0x61, 0x6c, 0x75, 0x65, + 0x12, 0x23, 0x0a, 0x0c, 0x64, 0x6f, 0x75, 0x62, 0x6c, 0x65, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x01, 0x48, 0x00, 0x52, 0x0b, 0x64, 0x6f, 0x75, 0x62, 0x6c, 0x65, + 0x56, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x1d, 0x0a, 0x09, 0x69, 0x6e, 0x74, 0x5f, 0x76, 0x61, 0x6c, + 0x75, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x48, 0x00, 0x52, 0x08, 0x69, 0x6e, 0x74, 0x56, + 0x61, 0x6c, 0x75, 0x65, 0x12, 0x1f, 0x0a, 0x0a, 0x6c, 0x6f, 0x6e, 0x67, 0x5f, 0x76, 0x61, 0x6c, + 0x75, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x48, 0x00, 0x52, 0x09, 0x6c, 0x6f, 0x6e, 0x67, + 0x56, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x23, 0x0a, 0x0c, 0x73, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x5f, + 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x0b, 0x73, + 0x74, 0x72, 0x69, 0x6e, 0x67, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x1f, 0x0a, 0x0a, 0x62, 0x6f, + 0x6f, 0x6c, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x48, 0x00, + 0x52, 0x09, 0x62, 0x6f, 0x6f, 0x6c, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x42, 0x06, 0x0a, 0x04, 0x6b, + 0x69, 0x6e, 0x64, 0x22, 0x41, 0x0a, 0x0b, 0x53, 0x61, 0x76, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x03, 0x6b, 0x65, 0x79, 0x12, 0x20, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x0b, 0x32, 0x0a, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, + 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x22, 0x1e, 0x0a, 0x0a, 0x47, 0x65, 0x74, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x22, 0x2f, 0x0a, 0x0b, 0x47, 0x65, 0x74, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x20, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0a, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x56, 0x61, 0x6c, 0x75, 0x65, + 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x22, 0x21, 0x0a, 0x0d, 0x44, 0x65, 0x6c, 0x65, 0x74, + 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x22, 0x35, 0x0a, 0x10, 0x53, 0x61, + 0x76, 0x65, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x21, + 0x0a, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0b, 0x2e, + 0x72, 0x70, 0x63, 0x2e, 0x53, 0x74, 0x72, 0x75, 0x63, 0x74, 0x52, 0x05, 0x69, 0x74, 0x65, 0x6d, + 0x73, 0x22, 0x25, 0x0a, 0x0f, 0x47, 0x65, 0x74, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x6b, 0x65, 0x79, 0x73, 0x18, 0x01, 0x20, 0x03, + 0x28, 0x09, 0x52, 0x04, 0x6b, 0x65, 0x79, 0x73, 0x22, 0x35, 0x0a, 0x10, 0x47, 0x65, 0x74, 0x42, + 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x21, 0x0a, 0x05, + 0x69, 0x74, 0x65, 0x6d, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0b, 0x2e, 0x72, 0x70, + 0x63, 0x2e, 0x53, 0x74, 0x72, 0x75, 0x63, 0x74, 0x52, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x22, + 0x2c, 0x0a, 0x12, 0x47, 0x65, 0x74, 0x42, 0x79, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x22, 0x66, 0x0a, + 0x1a, 0x53, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x43, 0x61, 0x72, 0x64, 0x73, 0x50, 0x72, + 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x54, 0x68, 0x65, 0x6d, 0x65, 0x12, 0x23, 0x0a, 0x0d, 0x70, + 0x72, 0x69, 0x6d, 0x61, 0x72, 0x79, 0x5f, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x0c, 0x70, 0x72, 0x69, 0x6d, 0x61, 0x72, 0x79, 0x43, 0x6f, 0x6c, 0x6f, 0x72, + 0x12, 0x23, 0x0a, 0x0d, 0x73, 0x75, 0x72, 0x66, 0x61, 0x63, 0x65, 0x5f, 0x63, 0x6f, 0x6c, 0x6f, + 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x73, 0x75, 0x72, 0x66, 0x61, 0x63, 0x65, + 0x43, 0x6f, 0x6c, 0x6f, 0x72, 0x22, 0x48, 0x0a, 0x09, 0x50, 0x72, 0x69, 0x63, 0x65, 0x49, 0x6e, + 0x66, 0x6f, 0x12, 0x23, 0x0a, 0x0d, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x63, 0x79, 0x5f, 0x63, + 0x6f, 0x64, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x63, 0x75, 0x72, 0x72, 0x65, + 0x6e, 0x63, 0x79, 0x43, 0x6f, 0x64, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x61, 0x6d, 0x6f, 0x75, 0x6e, + 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x61, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x22, + 0x49, 0x0a, 0x0b, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x12, + 0x0a, 0x04, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x63, 0x6f, + 0x64, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x66, 0x6c, 0x61, 0x67, 0x18, 0x03, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x66, 0x6c, 0x61, 0x67, 0x22, 0x69, 0x0a, 0x11, 0x53, 0x68, + 0x69, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x50, 0x72, 0x69, 0x63, 0x65, 0x49, 0x6e, 0x66, 0x6f, 0x12, + 0x24, 0x0a, 0x05, 0x70, 0x72, 0x69, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, + 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x50, 0x72, 0x69, 0x63, 0x65, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x05, + 0x70, 0x72, 0x69, 0x63, 0x65, 0x12, 0x2e, 0x0a, 0x09, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x69, + 0x65, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x43, + 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x09, 0x63, 0x6f, 0x75, 0x6e, + 0x74, 0x72, 0x69, 0x65, 0x73, 0x22, 0x88, 0x03, 0x0a, 0x15, 0x53, 0x65, 0x63, 0x75, 0x72, 0x69, + 0x74, 0x79, 0x43, 0x61, 0x72, 0x64, 0x73, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x12, + 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, + 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, + 0x61, 0x6d, 0x65, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, + 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, + 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x19, 0x0a, 0x08, 0x73, 0x69, 0x74, 0x65, 0x5f, 0x75, 0x72, + 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x73, 0x69, 0x74, 0x65, 0x55, 0x72, 0x6c, + 0x12, 0x40, 0x0a, 0x0b, 0x6c, 0x69, 0x67, 0x68, 0x74, 0x5f, 0x74, 0x68, 0x65, 0x6d, 0x65, 0x18, + 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x53, 0x65, 0x63, 0x75, + 0x72, 0x69, 0x74, 0x79, 0x43, 0x61, 0x72, 0x64, 0x73, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, + 0x72, 0x54, 0x68, 0x65, 0x6d, 0x65, 0x52, 0x0a, 0x6c, 0x69, 0x67, 0x68, 0x74, 0x54, 0x68, 0x65, + 0x6d, 0x65, 0x12, 0x3e, 0x0a, 0x0a, 0x64, 0x61, 0x72, 0x6b, 0x5f, 0x74, 0x68, 0x65, 0x6d, 0x65, + 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x53, 0x65, 0x63, + 0x75, 0x72, 0x69, 0x74, 0x79, 0x43, 0x61, 0x72, 0x64, 0x73, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, + 0x65, 0x72, 0x54, 0x68, 0x65, 0x6d, 0x65, 0x52, 0x09, 0x64, 0x61, 0x72, 0x6b, 0x54, 0x68, 0x65, + 0x6d, 0x65, 0x12, 0x38, 0x0a, 0x0e, 0x73, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x5f, 0x63, + 0x61, 0x72, 0x64, 0x73, 0x18, 0x07, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x72, 0x70, 0x63, + 0x2e, 0x53, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x43, 0x61, 0x72, 0x64, 0x52, 0x0d, 0x73, + 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x43, 0x61, 0x72, 0x64, 0x73, 0x12, 0x52, 0x0a, 0x19, + 0x65, 0x73, 0x74, 0x69, 0x6d, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x73, 0x68, 0x69, 0x70, 0x70, 0x69, + 0x6e, 0x67, 0x5f, 0x70, 0x72, 0x69, 0x63, 0x65, 0x73, 0x18, 0x08, 0x20, 0x03, 0x28, 0x0b, 0x32, + 0x16, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x53, 0x68, 0x69, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x50, 0x72, + 0x69, 0x63, 0x65, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x17, 0x65, 0x73, 0x74, 0x69, 0x6d, 0x61, 0x74, + 0x65, 0x64, 0x53, 0x68, 0x69, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x50, 0x72, 0x69, 0x63, 0x65, 0x73, + 0x22, 0x93, 0x01, 0x0a, 0x0c, 0x53, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x43, 0x61, 0x72, + 0x64, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, + 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x61, 0x73, 0x73, 0x65, 0x74, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x61, 0x73, 0x73, 0x65, 0x74, 0x55, 0x72, 0x6c, 0x12, 0x10, + 0x0a, 0x03, 0x74, 0x61, 0x67, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x74, 0x61, 0x67, + 0x12, 0x17, 0x0a, 0x07, 0x73, 0x70, 0x65, 0x63, 0x5f, 0x69, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x06, 0x73, 0x70, 0x65, 0x63, 0x49, 0x64, 0x12, 0x2b, 0x0a, 0x09, 0x63, 0x61, 0x72, + 0x64, 0x5f, 0x63, 0x6f, 0x73, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x72, + 0x70, 0x63, 0x2e, 0x50, 0x72, 0x69, 0x63, 0x65, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x08, 0x63, 0x61, + 0x72, 0x64, 0x43, 0x6f, 0x73, 0x74, 0x22, 0x7b, 0x0a, 0x09, 0x53, 0x70, 0x65, 0x63, 0x73, 0x49, + 0x74, 0x65, 0x6d, 0x12, 0x19, 0x0a, 0x08, 0x69, 0x63, 0x6f, 0x6e, 0x5f, 0x75, 0x72, 0x6c, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x69, 0x63, 0x6f, 0x6e, 0x55, 0x72, 0x6c, 0x12, 0x14, + 0x0a, 0x05, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6c, + 0x61, 0x62, 0x65, 0x6c, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x03, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x27, 0x0a, 0x0f, 0x61, 0x64, + 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x61, 0x6c, 0x5f, 0x64, 0x61, 0x74, 0x61, 0x18, 0x04, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x0e, 0x61, 0x64, 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x61, 0x6c, 0x44, + 0x61, 0x74, 0x61, 0x22, 0x35, 0x0a, 0x0d, 0x53, 0x70, 0x65, 0x63, 0x73, 0x49, 0x74, 0x65, 0x6d, + 0x4c, 0x69, 0x73, 0x74, 0x12, 0x24, 0x0a, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x18, 0x01, 0x20, + 0x03, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x53, 0x70, 0x65, 0x63, 0x73, 0x49, + 0x74, 0x65, 0x6d, 0x52, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x22, 0xb1, 0x01, 0x0a, 0x10, 0x53, + 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x43, 0x61, 0x72, 0x64, 0x53, 0x70, 0x65, 0x63, 0x12, + 0x17, 0x0a, 0x07, 0x73, 0x70, 0x65, 0x63, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x06, 0x73, 0x70, 0x65, 0x63, 0x49, 0x64, 0x12, 0x36, 0x0a, 0x05, 0x69, 0x74, 0x65, 0x6d, + 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x20, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x53, 0x65, + 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x43, 0x61, 0x72, 0x64, 0x53, 0x70, 0x65, 0x63, 0x2e, 0x49, + 0x74, 0x65, 0x6d, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, + 0x1a, 0x4c, 0x0a, 0x0a, 0x49, 0x74, 0x65, 0x6d, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, + 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, + 0x12, 0x28, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, + 0x12, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x53, 0x70, 0x65, 0x63, 0x73, 0x49, 0x74, 0x65, 0x6d, 0x4c, + 0x69, 0x73, 0x74, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x8c, + 0x01, 0x0a, 0x23, 0x47, 0x65, 0x74, 0x53, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x43, 0x61, + 0x72, 0x64, 0x73, 0x4d, 0x61, 0x72, 0x6b, 0x65, 0x74, 0x70, 0x6c, 0x61, 0x63, 0x65, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x38, 0x0a, 0x09, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, + 0x65, 0x72, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x72, 0x70, 0x63, 0x2e, + 0x53, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x43, 0x61, 0x72, 0x64, 0x73, 0x50, 0x72, 0x6f, + 0x76, 0x69, 0x64, 0x65, 0x72, 0x52, 0x09, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x73, + 0x12, 0x2b, 0x0a, 0x05, 0x73, 0x70, 0x65, 0x63, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, + 0x15, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x53, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x43, 0x61, + 0x72, 0x64, 0x53, 0x70, 0x65, 0x63, 0x52, 0x05, 0x73, 0x70, 0x65, 0x63, 0x73, 0x22, 0xef, 0x01, + 0x0a, 0x0e, 0x45, 0x4b, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x12, 0x2e, 0x0a, 0x13, 0x66, 0x69, 0x72, 0x73, 0x74, 0x5f, 0x65, 0x6e, 0x63, 0x72, 0x79, 0x70, + 0x74, 0x65, 0x64, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x11, 0x66, + 0x69, 0x72, 0x73, 0x74, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4b, 0x65, 0x79, + 0x12, 0x2b, 0x0a, 0x11, 0x66, 0x69, 0x72, 0x73, 0x74, 0x5f, 0x66, 0x69, 0x6e, 0x67, 0x65, 0x72, + 0x70, 0x72, 0x69, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x66, 0x69, 0x72, + 0x73, 0x74, 0x46, 0x69, 0x6e, 0x67, 0x65, 0x72, 0x70, 0x72, 0x69, 0x6e, 0x74, 0x12, 0x30, 0x0a, + 0x14, 0x73, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x5f, 0x65, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, + 0x64, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x12, 0x73, 0x65, 0x63, + 0x6f, 0x6e, 0x64, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4b, 0x65, 0x79, 0x12, + 0x2d, 0x0a, 0x12, 0x73, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x5f, 0x66, 0x69, 0x6e, 0x67, 0x65, 0x72, + 0x70, 0x72, 0x69, 0x6e, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x11, 0x73, 0x65, 0x63, + 0x6f, 0x6e, 0x64, 0x46, 0x69, 0x6e, 0x67, 0x65, 0x72, 0x70, 0x72, 0x69, 0x6e, 0x74, 0x12, 0x1f, + 0x0a, 0x0b, 0x72, 0x63, 0x5f, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x73, 0x75, 0x6d, 0x18, 0x05, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x0a, 0x72, 0x63, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x73, 0x75, 0x6d, 0x22, + 0x8d, 0x01, 0x0a, 0x1e, 0x47, 0x65, 0x6e, 0x65, 0x72, 0x61, 0x74, 0x65, 0x45, 0x6d, 0x65, 0x72, + 0x67, 0x65, 0x6e, 0x63, 0x79, 0x4b, 0x69, 0x74, 0x50, 0x44, 0x46, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x12, 0x2e, 0x0a, 0x08, 0x65, 0x6b, 0x5f, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x45, 0x4b, 0x49, 0x6e, 0x70, + 0x75, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x52, 0x07, 0x65, 0x6b, 0x49, 0x6e, 0x70, + 0x75, 0x74, 0x12, 0x1f, 0x0a, 0x0b, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x5f, 0x70, 0x61, 0x74, + 0x68, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x50, + 0x61, 0x74, 0x68, 0x12, 0x1a, 0x0a, 0x08, 0x6c, 0x61, 0x6e, 0x67, 0x75, 0x61, 0x67, 0x65, 0x18, + 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x6c, 0x61, 0x6e, 0x67, 0x75, 0x61, 0x67, 0x65, 0x22, + 0x9c, 0x01, 0x0a, 0x1f, 0x47, 0x65, 0x6e, 0x65, 0x72, 0x61, 0x74, 0x65, 0x45, 0x6d, 0x65, 0x72, + 0x67, 0x65, 0x6e, 0x63, 0x79, 0x4b, 0x69, 0x74, 0x50, 0x44, 0x46, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x12, 0x2b, 0x0a, 0x11, 0x76, 0x65, 0x72, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, + 0x69, 0x6f, 0x6e, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, + 0x76, 0x65, 0x72, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x64, 0x65, + 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x05, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x32, 0x0a, 0x09, 0x70, 0x72, + 0x6f, 0x66, 0x69, 0x6c, 0x69, 0x6e, 0x67, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, + 0x72, 0x70, 0x63, 0x2e, 0x52, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c, + 0x69, 0x6e, 0x67, 0x52, 0x09, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x69, 0x6e, 0x67, 0x22, 0xde, + 0x03, 0x0a, 0x0f, 0x52, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x69, + 0x6e, 0x67, 0x12, 0x30, 0x0a, 0x14, 0x6c, 0x6f, 0x61, 0x64, 0x5f, 0x74, 0x72, 0x61, 0x6e, 0x73, + 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x5f, 0x6d, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, + 0x52, 0x12, 0x6c, 0x6f, 0x61, 0x64, 0x54, 0x72, 0x61, 0x6e, 0x73, 0x6c, 0x61, 0x74, 0x69, 0x6f, + 0x6e, 0x73, 0x4d, 0x73, 0x12, 0x2a, 0x0a, 0x11, 0x72, 0x65, 0x67, 0x69, 0x73, 0x74, 0x65, 0x72, + 0x5f, 0x66, 0x6f, 0x6e, 0x74, 0x73, 0x5f, 0x6d, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, + 0x0f, 0x72, 0x65, 0x67, 0x69, 0x73, 0x74, 0x65, 0x72, 0x46, 0x6f, 0x6e, 0x74, 0x73, 0x4d, 0x73, + 0x12, 0x2c, 0x0a, 0x12, 0x72, 0x65, 0x67, 0x69, 0x73, 0x74, 0x65, 0x72, 0x5f, 0x69, 0x6d, 0x61, + 0x67, 0x65, 0x73, 0x5f, 0x6d, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x10, 0x72, 0x65, + 0x67, 0x69, 0x73, 0x74, 0x65, 0x72, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x73, 0x4d, 0x73, 0x12, 0x36, + 0x0a, 0x17, 0x63, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x73, 0x5f, 0x72, 0x65, 0x6e, + 0x64, 0x65, 0x72, 0x69, 0x6e, 0x67, 0x5f, 0x6d, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, + 0x15, 0x63, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x6e, 0x64, 0x65, + 0x72, 0x69, 0x6e, 0x67, 0x4d, 0x73, 0x12, 0x39, 0x0a, 0x1a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, + 0x5f, 0x61, 0x6e, 0x64, 0x5f, 0x73, 0x61, 0x76, 0x65, 0x5f, 0x6f, 0x6e, 0x5f, 0x64, 0x69, 0x73, + 0x6b, 0x5f, 0x6d, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x15, 0x63, 0x72, 0x65, 0x61, + 0x74, 0x65, 0x41, 0x6e, 0x64, 0x53, 0x61, 0x76, 0x65, 0x4f, 0x6e, 0x44, 0x69, 0x73, 0x6b, 0x4d, + 0x73, 0x12, 0x3b, 0x0a, 0x1a, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x5f, 0x68, 0x65, 0x61, 0x70, 0x5f, + 0x61, 0x6c, 0x6c, 0x6f, 0x63, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x62, 0x79, 0x74, 0x65, 0x73, 0x18, + 0x06, 0x20, 0x01, 0x28, 0x03, 0x52, 0x17, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x48, 0x65, 0x61, 0x70, + 0x41, 0x6c, 0x6c, 0x6f, 0x63, 0x61, 0x74, 0x65, 0x64, 0x42, 0x79, 0x74, 0x65, 0x73, 0x12, 0x36, + 0x0a, 0x17, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x5f, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x5f, + 0x61, 0x6c, 0x6c, 0x6f, 0x63, 0x61, 0x74, 0x65, 0x64, 0x18, 0x07, 0x20, 0x01, 0x28, 0x03, 0x52, + 0x15, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x41, 0x6c, 0x6c, + 0x6f, 0x63, 0x61, 0x74, 0x65, 0x64, 0x12, 0x2a, 0x0a, 0x11, 0x65, 0x6d, 0x62, 0x65, 0x64, 0x5f, + 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x5f, 0x6d, 0x73, 0x18, 0x08, 0x20, 0x01, 0x28, + 0x03, 0x52, 0x0f, 0x65, 0x6d, 0x62, 0x65, 0x64, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, + 0x4d, 0x73, 0x12, 0x2b, 0x0a, 0x12, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x5f, 0x69, 0x6e, 0x73, 0x69, + 0x64, 0x65, 0x5f, 0x67, 0x6f, 0x5f, 0x6d, 0x73, 0x18, 0x09, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, + 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x49, 0x6e, 0x73, 0x69, 0x64, 0x65, 0x47, 0x6f, 0x4d, 0x73, 0x22, + 0x34, 0x0a, 0x11, 0x5a, 0x69, 0x70, 0x44, 0x61, 0x74, 0x61, 0x44, 0x69, 0x72, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x12, 0x1f, 0x0a, 0x0b, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x5f, 0x70, + 0x61, 0x74, 0x68, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x6f, 0x75, 0x74, 0x70, 0x75, + 0x74, 0x50, 0x61, 0x74, 0x68, 0x22, 0x49, 0x0a, 0x1f, 0x53, 0x65, 0x63, 0x75, 0x72, 0x65, 0x4b, + 0x65, 0x79, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x53, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x50, 0x75, + 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, + 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, + 0x22, 0x33, 0x0a, 0x1f, 0x53, 0x65, 0x63, 0x75, 0x72, 0x65, 0x4b, 0x65, 0x79, 0x56, 0x61, 0x6c, + 0x75, 0x65, 0x53, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x47, 0x65, 0x74, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x03, 0x6b, 0x65, 0x79, 0x22, 0x38, 0x0a, 0x20, 0x53, 0x65, 0x63, 0x75, 0x72, 0x65, 0x4b, + 0x65, 0x79, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x53, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x47, 0x65, + 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, + 0x75, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x22, + 0x36, 0x0a, 0x22, 0x53, 0x65, 0x63, 0x75, 0x72, 0x65, 0x4b, 0x65, 0x79, 0x56, 0x61, 0x6c, 0x75, + 0x65, 0x53, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x2a, 0x33, 0x0a, 0x09, 0x45, 0x72, 0x72, 0x6f, 0x72, + 0x54, 0x79, 0x70, 0x65, 0x12, 0x0a, 0x0a, 0x06, 0x43, 0x4c, 0x49, 0x45, 0x4e, 0x54, 0x10, 0x00, + 0x12, 0x0d, 0x0a, 0x09, 0x4c, 0x49, 0x42, 0x57, 0x41, 0x4c, 0x4c, 0x45, 0x54, 0x10, 0x01, 0x12, + 0x0b, 0x0a, 0x07, 0x48, 0x4f, 0x55, 0x53, 0x54, 0x4f, 0x4e, 0x10, 0x02, 0x2a, 0x1b, 0x0a, 0x09, + 0x4e, 0x75, 0x6c, 0x6c, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x0e, 0x0a, 0x0a, 0x4e, 0x55, 0x4c, + 0x4c, 0x5f, 0x56, 0x41, 0x4c, 0x55, 0x45, 0x10, 0x00, 0x32, 0x87, 0x10, 0x0a, 0x0d, 0x57, 0x61, + 0x6c, 0x6c, 0x65, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x4d, 0x0a, 0x13, 0x53, + 0x65, 0x74, 0x75, 0x70, 0x53, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x43, 0x61, 0x72, 0x64, + 0x56, 0x32, 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x1e, 0x2e, 0x72, 0x70, 0x63, + 0x2e, 0x53, 0x65, 0x74, 0x75, 0x70, 0x53, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x43, 0x61, + 0x72, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4b, 0x0a, 0x19, 0x53, 0x69, + 0x67, 0x6e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x53, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, + 0x79, 0x43, 0x61, 0x72, 0x64, 0x56, 0x32, 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, + 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, + 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, + 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x46, 0x0a, 0x14, 0x50, 0x61, 0x69, 0x72, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x43, 0x68, 0x61, 0x6c, 0x6c, 0x65, 0x6e, 0x67, 0x65, 0x12, + 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, + 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, + 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, + 0x5f, 0x0a, 0x1a, 0x50, 0x61, 0x69, 0x72, 0x53, 0x69, 0x67, 0x6e, 0x41, 0x6e, 0x64, 0x53, 0x75, + 0x62, 0x6d, 0x69, 0x74, 0x43, 0x68, 0x61, 0x6c, 0x6c, 0x65, 0x6e, 0x67, 0x65, 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, - 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x3a, 0x0a, 0x09, 0x53, 0x61, 0x76, 0x65, 0x42, 0x61, 0x74, - 0x63, 0x68, 0x12, 0x15, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x53, 0x61, 0x76, 0x65, 0x42, 0x61, 0x74, - 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, + 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x27, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x50, 0x61, 0x69, 0x72, + 0x53, 0x69, 0x67, 0x6e, 0x41, 0x6e, 0x64, 0x53, 0x75, 0x62, 0x6d, 0x69, 0x74, 0x43, 0x68, 0x61, + 0x6c, 0x6c, 0x65, 0x6e, 0x67, 0x65, 0x50, 0x72, 0x6f, 0x67, 0x72, 0x65, 0x73, 0x73, 0x30, 0x01, + 0x12, 0x52, 0x0a, 0x16, 0x53, 0x74, 0x61, 0x72, 0x74, 0x44, 0x69, 0x61, 0x67, 0x6e, 0x6f, 0x73, + 0x74, 0x69, 0x63, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f, + 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, + 0x74, 0x79, 0x1a, 0x20, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x44, 0x69, 0x61, 0x67, 0x6e, 0x6f, 0x73, + 0x74, 0x69, 0x63, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x44, 0x65, 0x73, 0x63, 0x72, 0x69, + 0x70, 0x74, 0x6f, 0x72, 0x12, 0x5c, 0x0a, 0x1d, 0x50, 0x65, 0x72, 0x66, 0x6f, 0x72, 0x6d, 0x44, + 0x69, 0x61, 0x67, 0x6e, 0x6f, 0x73, 0x74, 0x69, 0x63, 0x53, 0x63, 0x61, 0x6e, 0x46, 0x6f, 0x72, + 0x55, 0x74, 0x78, 0x6f, 0x73, 0x12, 0x20, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x44, 0x69, 0x61, 0x67, + 0x6e, 0x6f, 0x73, 0x74, 0x69, 0x63, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x44, 0x65, 0x73, + 0x63, 0x72, 0x69, 0x70, 0x74, 0x6f, 0x72, 0x1a, 0x17, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x53, 0x63, + 0x61, 0x6e, 0x50, 0x72, 0x6f, 0x67, 0x72, 0x65, 0x73, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, + 0x30, 0x01, 0x12, 0x54, 0x0a, 0x13, 0x53, 0x75, 0x62, 0x6d, 0x69, 0x74, 0x44, 0x69, 0x61, 0x67, + 0x6e, 0x6f, 0x73, 0x74, 0x69, 0x63, 0x4c, 0x6f, 0x67, 0x12, 0x20, 0x2e, 0x72, 0x70, 0x63, 0x2e, + 0x44, 0x69, 0x61, 0x67, 0x6e, 0x6f, 0x73, 0x74, 0x69, 0x63, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, + 0x6e, 0x44, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x6f, 0x72, 0x1a, 0x1b, 0x2e, 0x72, 0x70, + 0x63, 0x2e, 0x44, 0x69, 0x61, 0x67, 0x6e, 0x6f, 0x73, 0x74, 0x69, 0x63, 0x53, 0x75, 0x62, 0x6d, + 0x69, 0x74, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x49, 0x0a, 0x0e, 0x50, 0x72, 0x65, 0x70, + 0x61, 0x72, 0x65, 0x53, 0x77, 0x65, 0x65, 0x70, 0x54, 0x78, 0x12, 0x1a, 0x2e, 0x72, 0x70, 0x63, + 0x2e, 0x50, 0x72, 0x65, 0x70, 0x61, 0x72, 0x65, 0x53, 0x77, 0x65, 0x65, 0x70, 0x54, 0x78, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x50, 0x72, 0x65, + 0x70, 0x61, 0x72, 0x65, 0x53, 0x77, 0x65, 0x65, 0x70, 0x54, 0x78, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x12, 0x64, 0x0a, 0x17, 0x53, 0x69, 0x67, 0x6e, 0x41, 0x6e, 0x64, 0x42, 0x72, + 0x6f, 0x61, 0x64, 0x63, 0x61, 0x73, 0x74, 0x53, 0x77, 0x65, 0x65, 0x70, 0x54, 0x78, 0x12, 0x23, + 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x53, 0x69, 0x67, 0x6e, 0x41, 0x6e, 0x64, 0x42, 0x72, 0x6f, 0x61, + 0x64, 0x63, 0x61, 0x73, 0x74, 0x53, 0x77, 0x65, 0x65, 0x70, 0x54, 0x78, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x53, 0x69, 0x67, 0x6e, 0x41, 0x6e, + 0x64, 0x42, 0x72, 0x6f, 0x61, 0x64, 0x63, 0x61, 0x73, 0x74, 0x53, 0x77, 0x65, 0x65, 0x70, 0x54, + 0x78, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4e, 0x0a, 0x13, 0x53, 0x74, 0x61, + 0x72, 0x74, 0x43, 0x68, 0x61, 0x6c, 0x6c, 0x65, 0x6e, 0x67, 0x65, 0x53, 0x65, 0x74, 0x75, 0x70, + 0x12, 0x1a, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x43, 0x68, 0x61, 0x6c, 0x6c, 0x65, 0x6e, 0x67, 0x65, + 0x53, 0x65, 0x74, 0x75, 0x70, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x72, + 0x70, 0x63, 0x2e, 0x53, 0x65, 0x74, 0x75, 0x70, 0x43, 0x68, 0x61, 0x6c, 0x6c, 0x65, 0x6e, 0x67, + 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x56, 0x0a, 0x17, 0x46, 0x69, 0x6e, + 0x69, 0x73, 0x68, 0x52, 0x65, 0x63, 0x6f, 0x76, 0x65, 0x72, 0x79, 0x43, 0x6f, 0x64, 0x65, 0x53, + 0x65, 0x74, 0x75, 0x70, 0x12, 0x23, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x46, 0x69, 0x6e, 0x69, 0x73, + 0x68, 0x52, 0x65, 0x63, 0x6f, 0x76, 0x65, 0x72, 0x79, 0x43, 0x6f, 0x64, 0x65, 0x53, 0x65, 0x74, + 0x75, 0x70, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, - 0x79, 0x12, 0x37, 0x0a, 0x08, 0x47, 0x65, 0x74, 0x42, 0x61, 0x74, 0x63, 0x68, 0x12, 0x14, 0x2e, - 0x72, 0x70, 0x63, 0x2e, 0x47, 0x65, 0x74, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x1a, 0x15, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x47, 0x65, 0x74, 0x42, 0x61, 0x74, - 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3d, 0x0a, 0x0b, 0x47, 0x65, - 0x74, 0x42, 0x79, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x12, 0x17, 0x2e, 0x72, 0x70, 0x63, 0x2e, - 0x47, 0x65, 0x74, 0x42, 0x79, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x1a, 0x15, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x47, 0x65, 0x74, 0x42, 0x61, 0x74, 0x63, - 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5f, 0x0a, 0x1b, 0x47, 0x65, 0x74, - 0x53, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x43, 0x61, 0x72, 0x64, 0x73, 0x4d, 0x61, 0x72, - 0x6b, 0x65, 0x74, 0x70, 0x6c, 0x61, 0x63, 0x65, 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, - 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, - 0x1a, 0x28, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x47, 0x65, 0x74, 0x53, 0x65, 0x63, 0x75, 0x72, 0x69, - 0x74, 0x79, 0x43, 0x61, 0x72, 0x64, 0x73, 0x4d, 0x61, 0x72, 0x6b, 0x65, 0x74, 0x70, 0x6c, 0x61, - 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x64, 0x0a, 0x17, 0x47, 0x65, - 0x6e, 0x65, 0x72, 0x61, 0x74, 0x65, 0x45, 0x6d, 0x65, 0x72, 0x67, 0x65, 0x6e, 0x63, 0x79, 0x4b, - 0x69, 0x74, 0x50, 0x44, 0x46, 0x12, 0x23, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x47, 0x65, 0x6e, 0x65, - 0x72, 0x61, 0x74, 0x65, 0x45, 0x6d, 0x65, 0x72, 0x67, 0x65, 0x6e, 0x63, 0x79, 0x4b, 0x69, 0x74, - 0x50, 0x44, 0x46, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e, 0x72, 0x70, 0x63, - 0x2e, 0x47, 0x65, 0x6e, 0x65, 0x72, 0x61, 0x74, 0x65, 0x45, 0x6d, 0x65, 0x72, 0x67, 0x65, 0x6e, - 0x63, 0x79, 0x4b, 0x69, 0x74, 0x50, 0x44, 0x46, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, - 0x42, 0x1f, 0x5a, 0x1d, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6d, - 0x75, 0x75, 0x6e, 0x2f, 0x6c, 0x69, 0x62, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2f, 0x61, 0x70, - 0x69, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x79, 0x12, 0x58, 0x0a, 0x18, 0x50, 0x6f, 0x70, 0x75, 0x6c, 0x61, 0x74, 0x65, 0x45, 0x6e, 0x63, + 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x75, 0x75, 0x6e, 0x4b, 0x65, 0x79, 0x12, 0x24, 0x2e, + 0x72, 0x70, 0x63, 0x2e, 0x50, 0x6f, 0x70, 0x75, 0x6c, 0x61, 0x74, 0x65, 0x45, 0x6e, 0x63, 0x72, + 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x75, 0x75, 0x6e, 0x4b, 0x65, 0x79, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x30, 0x0a, 0x04, 0x53, + 0x61, 0x76, 0x65, 0x12, 0x10, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x53, 0x61, 0x76, 0x65, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x28, 0x0a, + 0x03, 0x47, 0x65, 0x74, 0x12, 0x0f, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x47, 0x65, 0x74, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x10, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x47, 0x65, 0x74, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x34, 0x0a, 0x06, 0x44, 0x65, 0x6c, 0x65, 0x74, + 0x65, 0x12, 0x12, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x3a, 0x0a, + 0x09, 0x53, 0x61, 0x76, 0x65, 0x42, 0x61, 0x74, 0x63, 0x68, 0x12, 0x15, 0x2e, 0x72, 0x70, 0x63, + 0x2e, 0x53, 0x61, 0x76, 0x65, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x37, 0x0a, 0x08, 0x47, 0x65, 0x74, + 0x42, 0x61, 0x74, 0x63, 0x68, 0x12, 0x14, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x47, 0x65, 0x74, 0x42, + 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x15, 0x2e, 0x72, 0x70, + 0x63, 0x2e, 0x47, 0x65, 0x74, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x12, 0x3d, 0x0a, 0x0b, 0x47, 0x65, 0x74, 0x42, 0x79, 0x50, 0x72, 0x65, 0x66, 0x69, + 0x78, 0x12, 0x17, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x47, 0x65, 0x74, 0x42, 0x79, 0x50, 0x72, 0x65, + 0x66, 0x69, 0x78, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x15, 0x2e, 0x72, 0x70, 0x63, + 0x2e, 0x47, 0x65, 0x74, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x12, 0x5f, 0x0a, 0x1b, 0x47, 0x65, 0x74, 0x53, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, + 0x43, 0x61, 0x72, 0x64, 0x73, 0x4d, 0x61, 0x72, 0x6b, 0x65, 0x74, 0x70, 0x6c, 0x61, 0x63, 0x65, + 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, + 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x28, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x47, + 0x65, 0x74, 0x53, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x43, 0x61, 0x72, 0x64, 0x73, 0x4d, + 0x61, 0x72, 0x6b, 0x65, 0x74, 0x70, 0x6c, 0x61, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x12, 0x64, 0x0a, 0x17, 0x47, 0x65, 0x6e, 0x65, 0x72, 0x61, 0x74, 0x65, 0x45, 0x6d, + 0x65, 0x72, 0x67, 0x65, 0x6e, 0x63, 0x79, 0x4b, 0x69, 0x74, 0x50, 0x44, 0x46, 0x12, 0x23, 0x2e, + 0x72, 0x70, 0x63, 0x2e, 0x47, 0x65, 0x6e, 0x65, 0x72, 0x61, 0x74, 0x65, 0x45, 0x6d, 0x65, 0x72, + 0x67, 0x65, 0x6e, 0x63, 0x79, 0x4b, 0x69, 0x74, 0x50, 0x44, 0x46, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x1a, 0x24, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x47, 0x65, 0x6e, 0x65, 0x72, 0x61, 0x74, + 0x65, 0x45, 0x6d, 0x65, 0x72, 0x67, 0x65, 0x6e, 0x63, 0x79, 0x4b, 0x69, 0x74, 0x50, 0x44, 0x46, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3b, 0x0a, 0x09, 0x52, 0x65, 0x73, 0x65, + 0x74, 0x44, 0x61, 0x74, 0x61, 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x16, 0x2e, + 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, + 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x3c, 0x0a, 0x0a, 0x5a, 0x69, 0x70, 0x44, 0x61, 0x74, 0x61, + 0x44, 0x69, 0x72, 0x12, 0x16, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x5a, 0x69, 0x70, 0x44, 0x61, 0x74, + 0x61, 0x44, 0x69, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, + 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, + 0x70, 0x74, 0x79, 0x12, 0x58, 0x0a, 0x18, 0x53, 0x65, 0x63, 0x75, 0x72, 0x65, 0x4b, 0x65, 0x79, + 0x56, 0x61, 0x6c, 0x75, 0x65, 0x53, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x50, 0x75, 0x74, 0x12, + 0x24, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x53, 0x65, 0x63, 0x75, 0x72, 0x65, 0x4b, 0x65, 0x79, 0x56, + 0x61, 0x6c, 0x75, 0x65, 0x53, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x50, 0x75, 0x74, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x67, 0x0a, + 0x18, 0x53, 0x65, 0x63, 0x75, 0x72, 0x65, 0x4b, 0x65, 0x79, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x53, + 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x47, 0x65, 0x74, 0x12, 0x24, 0x2e, 0x72, 0x70, 0x63, 0x2e, + 0x53, 0x65, 0x63, 0x75, 0x72, 0x65, 0x4b, 0x65, 0x79, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x53, 0x74, + 0x6f, 0x72, 0x61, 0x67, 0x65, 0x47, 0x65, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, + 0x25, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x53, 0x65, 0x63, 0x75, 0x72, 0x65, 0x4b, 0x65, 0x79, 0x56, + 0x61, 0x6c, 0x75, 0x65, 0x53, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x47, 0x65, 0x74, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5e, 0x0a, 0x1b, 0x53, 0x65, 0x63, 0x75, 0x72, 0x65, + 0x4b, 0x65, 0x79, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x53, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x44, + 0x65, 0x6c, 0x65, 0x74, 0x65, 0x12, 0x27, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x53, 0x65, 0x63, 0x75, + 0x72, 0x65, 0x4b, 0x65, 0x79, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x53, 0x74, 0x6f, 0x72, 0x61, 0x67, + 0x65, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, + 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, + 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x4b, 0x0a, 0x19, 0x53, 0x65, 0x63, 0x75, 0x72, 0x65, + 0x4b, 0x65, 0x79, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x53, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x57, + 0x69, 0x70, 0x65, 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x16, 0x2e, 0x67, 0x6f, + 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, + 0x70, 0x74, 0x79, 0x42, 0x1f, 0x5a, 0x1d, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, + 0x6d, 0x2f, 0x6d, 0x75, 0x75, 0x6e, 0x2f, 0x6c, 0x69, 0x62, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, + 0x2f, 0x61, 0x70, 0x69, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, }) var file_wallet_service_proto_enumTypes = make([]protoimpl.EnumInfo, 2) -var file_wallet_service_proto_msgTypes = make([]protoimpl.MessageInfo, 35) +var file_wallet_service_proto_msgTypes = make([]protoimpl.MessageInfo, 50) var file_wallet_service_proto_goTypes = []any{ (ErrorType)(0), // 0: rpc.ErrorType (NullValue)(0), // 1: rpc.NullValue (*ErrorDetail)(nil), // 2: rpc.ErrorDetail - (*XpubResponse)(nil), // 3: rpc.XpubResponse - (*SetupSecurityCardResponse)(nil), // 4: rpc.SetupSecurityCardResponse - (*SignMessageSecurityCardRequest)(nil), // 5: rpc.SignMessageSecurityCardRequest - (*SignMessageSecurityCardResponse)(nil), // 6: rpc.SignMessageSecurityCardResponse - (*DiagnosticSessionDescriptor)(nil), // 7: rpc.DiagnosticSessionDescriptor - (*ScanProgressUpdate)(nil), // 8: rpc.ScanProgressUpdate - (*FoundUtxoReport)(nil), // 9: rpc.FoundUtxoReport - (*ScanComplete)(nil), // 10: rpc.ScanComplete - (*DiagnosticSubmitStatus)(nil), // 11: rpc.DiagnosticSubmitStatus - (*PrepareSweepTxRequest)(nil), // 12: rpc.PrepareSweepTxRequest - (*PrepareSweepTxResponse)(nil), // 13: rpc.PrepareSweepTxResponse - (*SignAndBroadcastSweepTxRequest)(nil), // 14: rpc.SignAndBroadcastSweepTxRequest - (*SignAndBroadcastSweepTxResponse)(nil), // 15: rpc.SignAndBroadcastSweepTxResponse - (*ChallengeSetupRequest)(nil), // 16: rpc.ChallengeSetupRequest - (*SetupChallengeResponse)(nil), // 17: rpc.SetupChallengeResponse - (*FinishRecoveryCodeSetupRequest)(nil), // 18: rpc.FinishRecoveryCodeSetupRequest - (*PopulateEncryptedMuunKeyRequest)(nil), // 19: rpc.PopulateEncryptedMuunKeyRequest - (*Struct)(nil), // 20: rpc.Struct - (*Value)(nil), // 21: rpc.Value - (*SaveRequest)(nil), // 22: rpc.SaveRequest - (*GetRequest)(nil), // 23: rpc.GetRequest - (*GetResponse)(nil), // 24: rpc.GetResponse - (*DeleteRequest)(nil), // 25: rpc.DeleteRequest - (*SaveBatchRequest)(nil), // 26: rpc.SaveBatchRequest - (*GetBatchRequest)(nil), // 27: rpc.GetBatchRequest - (*GetBatchResponse)(nil), // 28: rpc.GetBatchResponse - (*GetByPrefixRequest)(nil), // 29: rpc.GetByPrefixRequest - (*SecurityCardsProvider)(nil), // 30: rpc.SecurityCardsProvider - (*SecurityCard)(nil), // 31: rpc.SecurityCard - (*GetSecurityCardsMarketplaceResponse)(nil), // 32: rpc.GetSecurityCardsMarketplaceResponse - (*EKInputRequest)(nil), // 33: rpc.EKInputRequest - (*GenerateEmergencyKitPDFRequest)(nil), // 34: rpc.GenerateEmergencyKitPDFRequest - (*GenerateEmergencyKitPDFResponse)(nil), // 35: rpc.GenerateEmergencyKitPDFResponse - nil, // 36: rpc.Struct.FieldsEntry - (*emptypb.Empty)(nil), // 37: google.protobuf.Empty + (*SetupSecurityCardResponse)(nil), // 3: rpc.SetupSecurityCardResponse + (*PairSignAndSubmitChallengeResponse)(nil), // 4: rpc.PairSignAndSubmitChallengeResponse + (*PairSignAndSubmitChallengeProgress)(nil), // 5: rpc.PairSignAndSubmitChallengeProgress + (*RefreshingChallenge)(nil), // 6: rpc.RefreshingChallenge + (*ChallengeSigned)(nil), // 7: rpc.ChallengeSigned + (*DiagnosticSessionDescriptor)(nil), // 8: rpc.DiagnosticSessionDescriptor + (*ScanProgressUpdate)(nil), // 9: rpc.ScanProgressUpdate + (*FoundUtxoReport)(nil), // 10: rpc.FoundUtxoReport + (*ScanComplete)(nil), // 11: rpc.ScanComplete + (*DiagnosticSubmitStatus)(nil), // 12: rpc.DiagnosticSubmitStatus + (*PrepareSweepTxRequest)(nil), // 13: rpc.PrepareSweepTxRequest + (*PrepareSweepTxResponse)(nil), // 14: rpc.PrepareSweepTxResponse + (*SignAndBroadcastSweepTxRequest)(nil), // 15: rpc.SignAndBroadcastSweepTxRequest + (*SignAndBroadcastSweepTxResponse)(nil), // 16: rpc.SignAndBroadcastSweepTxResponse + (*ChallengeSetupRequest)(nil), // 17: rpc.ChallengeSetupRequest + (*SetupChallengeResponse)(nil), // 18: rpc.SetupChallengeResponse + (*FinishRecoveryCodeSetupRequest)(nil), // 19: rpc.FinishRecoveryCodeSetupRequest + (*PopulateEncryptedMuunKeyRequest)(nil), // 20: rpc.PopulateEncryptedMuunKeyRequest + (*Struct)(nil), // 21: rpc.Struct + (*Value)(nil), // 22: rpc.Value + (*SaveRequest)(nil), // 23: rpc.SaveRequest + (*GetRequest)(nil), // 24: rpc.GetRequest + (*GetResponse)(nil), // 25: rpc.GetResponse + (*DeleteRequest)(nil), // 26: rpc.DeleteRequest + (*SaveBatchRequest)(nil), // 27: rpc.SaveBatchRequest + (*GetBatchRequest)(nil), // 28: rpc.GetBatchRequest + (*GetBatchResponse)(nil), // 29: rpc.GetBatchResponse + (*GetByPrefixRequest)(nil), // 30: rpc.GetByPrefixRequest + (*SecurityCardsProviderTheme)(nil), // 31: rpc.SecurityCardsProviderTheme + (*PriceInfo)(nil), // 32: rpc.PriceInfo + (*CountryInfo)(nil), // 33: rpc.CountryInfo + (*ShippingPriceInfo)(nil), // 34: rpc.ShippingPriceInfo + (*SecurityCardsProvider)(nil), // 35: rpc.SecurityCardsProvider + (*SecurityCard)(nil), // 36: rpc.SecurityCard + (*SpecsItem)(nil), // 37: rpc.SpecsItem + (*SpecsItemList)(nil), // 38: rpc.SpecsItemList + (*SecurityCardSpec)(nil), // 39: rpc.SecurityCardSpec + (*GetSecurityCardsMarketplaceResponse)(nil), // 40: rpc.GetSecurityCardsMarketplaceResponse + (*EKInputRequest)(nil), // 41: rpc.EKInputRequest + (*GenerateEmergencyKitPDFRequest)(nil), // 42: rpc.GenerateEmergencyKitPDFRequest + (*GenerateEmergencyKitPDFResponse)(nil), // 43: rpc.GenerateEmergencyKitPDFResponse + (*RenderProfiling)(nil), // 44: rpc.RenderProfiling + (*ZipDataDirRequest)(nil), // 45: rpc.ZipDataDirRequest + (*SecureKeyValueStoragePutRequest)(nil), // 46: rpc.SecureKeyValueStoragePutRequest + (*SecureKeyValueStorageGetRequest)(nil), // 47: rpc.SecureKeyValueStorageGetRequest + (*SecureKeyValueStorageGetResponse)(nil), // 48: rpc.SecureKeyValueStorageGetResponse + (*SecureKeyValueStorageDeleteRequest)(nil), // 49: rpc.SecureKeyValueStorageDeleteRequest + nil, // 50: rpc.Struct.FieldsEntry + nil, // 51: rpc.SecurityCardSpec.ItemsEntry + (*emptypb.Empty)(nil), // 52: google.protobuf.Empty } var file_wallet_service_proto_depIdxs = []int32{ 0, // 0: rpc.ErrorDetail.type:type_name -> rpc.ErrorType - 9, // 1: rpc.ScanProgressUpdate.found_utxo_report:type_name -> rpc.FoundUtxoReport - 10, // 2: rpc.ScanProgressUpdate.scan_complete:type_name -> rpc.ScanComplete - 7, // 3: rpc.PrepareSweepTxRequest.sessionDescriptor:type_name -> rpc.DiagnosticSessionDescriptor - 7, // 4: rpc.PrepareSweepTxResponse.sessionDescriptor:type_name -> rpc.DiagnosticSessionDescriptor - 7, // 5: rpc.SignAndBroadcastSweepTxRequest.sessionDescriptor:type_name -> rpc.DiagnosticSessionDescriptor - 7, // 6: rpc.SignAndBroadcastSweepTxResponse.sessionDescriptor:type_name -> rpc.DiagnosticSessionDescriptor - 36, // 7: rpc.Struct.fields:type_name -> rpc.Struct.FieldsEntry - 1, // 8: rpc.Value.null_value:type_name -> rpc.NullValue - 21, // 9: rpc.SaveRequest.value:type_name -> rpc.Value - 21, // 10: rpc.GetResponse.value:type_name -> rpc.Value - 20, // 11: rpc.SaveBatchRequest.items:type_name -> rpc.Struct - 20, // 12: rpc.GetBatchResponse.items:type_name -> rpc.Struct - 31, // 13: rpc.SecurityCardsProvider.security_cards:type_name -> rpc.SecurityCard - 30, // 14: rpc.GetSecurityCardsMarketplaceResponse.providers:type_name -> rpc.SecurityCardsProvider - 33, // 15: rpc.GenerateEmergencyKitPDFRequest.ek_input:type_name -> rpc.EKInputRequest - 21, // 16: rpc.Struct.FieldsEntry.value:type_name -> rpc.Value - 37, // 17: rpc.WalletService.SetupSecurityCard:input_type -> google.protobuf.Empty - 37, // 18: rpc.WalletService.ResetSecurityCard:input_type -> google.protobuf.Empty - 5, // 19: rpc.WalletService.SignMessageSecurityCard:input_type -> rpc.SignMessageSecurityCardRequest - 37, // 20: rpc.WalletService.SetupSecurityCardV2:input_type -> google.protobuf.Empty - 37, // 21: rpc.WalletService.SignMessageSecurityCardV2:input_type -> google.protobuf.Empty - 37, // 22: rpc.WalletService.StartDiagnosticSession:input_type -> google.protobuf.Empty - 7, // 23: rpc.WalletService.PerformDiagnosticScanForUtxos:input_type -> rpc.DiagnosticSessionDescriptor - 7, // 24: rpc.WalletService.SubmitDiagnosticLog:input_type -> rpc.DiagnosticSessionDescriptor - 12, // 25: rpc.WalletService.PrepareSweepTx:input_type -> rpc.PrepareSweepTxRequest - 14, // 26: rpc.WalletService.SignAndBroadcastSweepTx:input_type -> rpc.SignAndBroadcastSweepTxRequest - 16, // 27: rpc.WalletService.StartChallengeSetup:input_type -> rpc.ChallengeSetupRequest - 18, // 28: rpc.WalletService.FinishRecoveryCodeSetup:input_type -> rpc.FinishRecoveryCodeSetupRequest - 19, // 29: rpc.WalletService.PopulateEncryptedMuunKey:input_type -> rpc.PopulateEncryptedMuunKeyRequest - 22, // 30: rpc.WalletService.Save:input_type -> rpc.SaveRequest - 23, // 31: rpc.WalletService.Get:input_type -> rpc.GetRequest - 25, // 32: rpc.WalletService.Delete:input_type -> rpc.DeleteRequest - 26, // 33: rpc.WalletService.SaveBatch:input_type -> rpc.SaveBatchRequest - 27, // 34: rpc.WalletService.GetBatch:input_type -> rpc.GetBatchRequest - 29, // 35: rpc.WalletService.GetByPrefix:input_type -> rpc.GetByPrefixRequest - 37, // 36: rpc.WalletService.GetSecurityCardsMarketplace:input_type -> google.protobuf.Empty - 34, // 37: rpc.WalletService.GenerateEmergencyKitPDF:input_type -> rpc.GenerateEmergencyKitPDFRequest - 3, // 38: rpc.WalletService.SetupSecurityCard:output_type -> rpc.XpubResponse - 37, // 39: rpc.WalletService.ResetSecurityCard:output_type -> google.protobuf.Empty - 6, // 40: rpc.WalletService.SignMessageSecurityCard:output_type -> rpc.SignMessageSecurityCardResponse - 4, // 41: rpc.WalletService.SetupSecurityCardV2:output_type -> rpc.SetupSecurityCardResponse - 37, // 42: rpc.WalletService.SignMessageSecurityCardV2:output_type -> google.protobuf.Empty - 7, // 43: rpc.WalletService.StartDiagnosticSession:output_type -> rpc.DiagnosticSessionDescriptor - 8, // 44: rpc.WalletService.PerformDiagnosticScanForUtxos:output_type -> rpc.ScanProgressUpdate - 11, // 45: rpc.WalletService.SubmitDiagnosticLog:output_type -> rpc.DiagnosticSubmitStatus - 13, // 46: rpc.WalletService.PrepareSweepTx:output_type -> rpc.PrepareSweepTxResponse - 15, // 47: rpc.WalletService.SignAndBroadcastSweepTx:output_type -> rpc.SignAndBroadcastSweepTxResponse - 17, // 48: rpc.WalletService.StartChallengeSetup:output_type -> rpc.SetupChallengeResponse - 37, // 49: rpc.WalletService.FinishRecoveryCodeSetup:output_type -> google.protobuf.Empty - 37, // 50: rpc.WalletService.PopulateEncryptedMuunKey:output_type -> google.protobuf.Empty - 37, // 51: rpc.WalletService.Save:output_type -> google.protobuf.Empty - 24, // 52: rpc.WalletService.Get:output_type -> rpc.GetResponse - 37, // 53: rpc.WalletService.Delete:output_type -> google.protobuf.Empty - 37, // 54: rpc.WalletService.SaveBatch:output_type -> google.protobuf.Empty - 28, // 55: rpc.WalletService.GetBatch:output_type -> rpc.GetBatchResponse - 28, // 56: rpc.WalletService.GetByPrefix:output_type -> rpc.GetBatchResponse - 32, // 57: rpc.WalletService.GetSecurityCardsMarketplace:output_type -> rpc.GetSecurityCardsMarketplaceResponse - 35, // 58: rpc.WalletService.GenerateEmergencyKitPDF:output_type -> rpc.GenerateEmergencyKitPDFResponse - 38, // [38:59] is the sub-list for method output_type - 17, // [17:38] is the sub-list for method input_type - 17, // [17:17] is the sub-list for extension type_name - 17, // [17:17] is the sub-list for extension extendee - 0, // [0:17] is the sub-list for field type_name + 6, // 1: rpc.PairSignAndSubmitChallengeProgress.refreshing_challenge:type_name -> rpc.RefreshingChallenge + 7, // 2: rpc.PairSignAndSubmitChallengeProgress.challenge_signed:type_name -> rpc.ChallengeSigned + 4, // 3: rpc.PairSignAndSubmitChallengeProgress.completed:type_name -> rpc.PairSignAndSubmitChallengeResponse + 10, // 4: rpc.ScanProgressUpdate.found_utxo_report:type_name -> rpc.FoundUtxoReport + 11, // 5: rpc.ScanProgressUpdate.scan_complete:type_name -> rpc.ScanComplete + 8, // 6: rpc.PrepareSweepTxRequest.sessionDescriptor:type_name -> rpc.DiagnosticSessionDescriptor + 8, // 7: rpc.PrepareSweepTxResponse.sessionDescriptor:type_name -> rpc.DiagnosticSessionDescriptor + 8, // 8: rpc.SignAndBroadcastSweepTxRequest.sessionDescriptor:type_name -> rpc.DiagnosticSessionDescriptor + 8, // 9: rpc.SignAndBroadcastSweepTxResponse.sessionDescriptor:type_name -> rpc.DiagnosticSessionDescriptor + 50, // 10: rpc.Struct.fields:type_name -> rpc.Struct.FieldsEntry + 1, // 11: rpc.Value.null_value:type_name -> rpc.NullValue + 22, // 12: rpc.SaveRequest.value:type_name -> rpc.Value + 22, // 13: rpc.GetResponse.value:type_name -> rpc.Value + 21, // 14: rpc.SaveBatchRequest.items:type_name -> rpc.Struct + 21, // 15: rpc.GetBatchResponse.items:type_name -> rpc.Struct + 32, // 16: rpc.ShippingPriceInfo.price:type_name -> rpc.PriceInfo + 33, // 17: rpc.ShippingPriceInfo.countries:type_name -> rpc.CountryInfo + 31, // 18: rpc.SecurityCardsProvider.light_theme:type_name -> rpc.SecurityCardsProviderTheme + 31, // 19: rpc.SecurityCardsProvider.dark_theme:type_name -> rpc.SecurityCardsProviderTheme + 36, // 20: rpc.SecurityCardsProvider.security_cards:type_name -> rpc.SecurityCard + 34, // 21: rpc.SecurityCardsProvider.estimated_shipping_prices:type_name -> rpc.ShippingPriceInfo + 32, // 22: rpc.SecurityCard.card_cost:type_name -> rpc.PriceInfo + 37, // 23: rpc.SpecsItemList.items:type_name -> rpc.SpecsItem + 51, // 24: rpc.SecurityCardSpec.items:type_name -> rpc.SecurityCardSpec.ItemsEntry + 35, // 25: rpc.GetSecurityCardsMarketplaceResponse.providers:type_name -> rpc.SecurityCardsProvider + 39, // 26: rpc.GetSecurityCardsMarketplaceResponse.specs:type_name -> rpc.SecurityCardSpec + 41, // 27: rpc.GenerateEmergencyKitPDFRequest.ek_input:type_name -> rpc.EKInputRequest + 44, // 28: rpc.GenerateEmergencyKitPDFResponse.profiling:type_name -> rpc.RenderProfiling + 22, // 29: rpc.Struct.FieldsEntry.value:type_name -> rpc.Value + 38, // 30: rpc.SecurityCardSpec.ItemsEntry.value:type_name -> rpc.SpecsItemList + 52, // 31: rpc.WalletService.SetupSecurityCardV2:input_type -> google.protobuf.Empty + 52, // 32: rpc.WalletService.SignMessageSecurityCardV2:input_type -> google.protobuf.Empty + 52, // 33: rpc.WalletService.PairRequestChallenge:input_type -> google.protobuf.Empty + 52, // 34: rpc.WalletService.PairSignAndSubmitChallenge:input_type -> google.protobuf.Empty + 52, // 35: rpc.WalletService.StartDiagnosticSession:input_type -> google.protobuf.Empty + 8, // 36: rpc.WalletService.PerformDiagnosticScanForUtxos:input_type -> rpc.DiagnosticSessionDescriptor + 8, // 37: rpc.WalletService.SubmitDiagnosticLog:input_type -> rpc.DiagnosticSessionDescriptor + 13, // 38: rpc.WalletService.PrepareSweepTx:input_type -> rpc.PrepareSweepTxRequest + 15, // 39: rpc.WalletService.SignAndBroadcastSweepTx:input_type -> rpc.SignAndBroadcastSweepTxRequest + 17, // 40: rpc.WalletService.StartChallengeSetup:input_type -> rpc.ChallengeSetupRequest + 19, // 41: rpc.WalletService.FinishRecoveryCodeSetup:input_type -> rpc.FinishRecoveryCodeSetupRequest + 20, // 42: rpc.WalletService.PopulateEncryptedMuunKey:input_type -> rpc.PopulateEncryptedMuunKeyRequest + 23, // 43: rpc.WalletService.Save:input_type -> rpc.SaveRequest + 24, // 44: rpc.WalletService.Get:input_type -> rpc.GetRequest + 26, // 45: rpc.WalletService.Delete:input_type -> rpc.DeleteRequest + 27, // 46: rpc.WalletService.SaveBatch:input_type -> rpc.SaveBatchRequest + 28, // 47: rpc.WalletService.GetBatch:input_type -> rpc.GetBatchRequest + 30, // 48: rpc.WalletService.GetByPrefix:input_type -> rpc.GetByPrefixRequest + 52, // 49: rpc.WalletService.GetSecurityCardsMarketplace:input_type -> google.protobuf.Empty + 42, // 50: rpc.WalletService.GenerateEmergencyKitPDF:input_type -> rpc.GenerateEmergencyKitPDFRequest + 52, // 51: rpc.WalletService.ResetData:input_type -> google.protobuf.Empty + 45, // 52: rpc.WalletService.ZipDataDir:input_type -> rpc.ZipDataDirRequest + 46, // 53: rpc.WalletService.SecureKeyValueStoragePut:input_type -> rpc.SecureKeyValueStoragePutRequest + 47, // 54: rpc.WalletService.SecureKeyValueStorageGet:input_type -> rpc.SecureKeyValueStorageGetRequest + 49, // 55: rpc.WalletService.SecureKeyValueStorageDelete:input_type -> rpc.SecureKeyValueStorageDeleteRequest + 52, // 56: rpc.WalletService.SecureKeyValueStorageWipe:input_type -> google.protobuf.Empty + 3, // 57: rpc.WalletService.SetupSecurityCardV2:output_type -> rpc.SetupSecurityCardResponse + 52, // 58: rpc.WalletService.SignMessageSecurityCardV2:output_type -> google.protobuf.Empty + 52, // 59: rpc.WalletService.PairRequestChallenge:output_type -> google.protobuf.Empty + 5, // 60: rpc.WalletService.PairSignAndSubmitChallenge:output_type -> rpc.PairSignAndSubmitChallengeProgress + 8, // 61: rpc.WalletService.StartDiagnosticSession:output_type -> rpc.DiagnosticSessionDescriptor + 9, // 62: rpc.WalletService.PerformDiagnosticScanForUtxos:output_type -> rpc.ScanProgressUpdate + 12, // 63: rpc.WalletService.SubmitDiagnosticLog:output_type -> rpc.DiagnosticSubmitStatus + 14, // 64: rpc.WalletService.PrepareSweepTx:output_type -> rpc.PrepareSweepTxResponse + 16, // 65: rpc.WalletService.SignAndBroadcastSweepTx:output_type -> rpc.SignAndBroadcastSweepTxResponse + 18, // 66: rpc.WalletService.StartChallengeSetup:output_type -> rpc.SetupChallengeResponse + 52, // 67: rpc.WalletService.FinishRecoveryCodeSetup:output_type -> google.protobuf.Empty + 52, // 68: rpc.WalletService.PopulateEncryptedMuunKey:output_type -> google.protobuf.Empty + 52, // 69: rpc.WalletService.Save:output_type -> google.protobuf.Empty + 25, // 70: rpc.WalletService.Get:output_type -> rpc.GetResponse + 52, // 71: rpc.WalletService.Delete:output_type -> google.protobuf.Empty + 52, // 72: rpc.WalletService.SaveBatch:output_type -> google.protobuf.Empty + 29, // 73: rpc.WalletService.GetBatch:output_type -> rpc.GetBatchResponse + 29, // 74: rpc.WalletService.GetByPrefix:output_type -> rpc.GetBatchResponse + 40, // 75: rpc.WalletService.GetSecurityCardsMarketplace:output_type -> rpc.GetSecurityCardsMarketplaceResponse + 43, // 76: rpc.WalletService.GenerateEmergencyKitPDF:output_type -> rpc.GenerateEmergencyKitPDFResponse + 52, // 77: rpc.WalletService.ResetData:output_type -> google.protobuf.Empty + 52, // 78: rpc.WalletService.ZipDataDir:output_type -> google.protobuf.Empty + 52, // 79: rpc.WalletService.SecureKeyValueStoragePut:output_type -> google.protobuf.Empty + 48, // 80: rpc.WalletService.SecureKeyValueStorageGet:output_type -> rpc.SecureKeyValueStorageGetResponse + 52, // 81: rpc.WalletService.SecureKeyValueStorageDelete:output_type -> google.protobuf.Empty + 52, // 82: rpc.WalletService.SecureKeyValueStorageWipe:output_type -> google.protobuf.Empty + 57, // [57:83] is the sub-list for method output_type + 31, // [31:57] is the sub-list for method input_type + 31, // [31:31] is the sub-list for extension type_name + 31, // [31:31] is the sub-list for extension extendee + 0, // [0:31] is the sub-list for field type_name } func init() { file_wallet_service_proto_init() } @@ -3512,12 +5049,17 @@ func file_wallet_service_proto_init() { if File_wallet_service_proto != nil { return } - file_wallet_service_proto_msgTypes[6].OneofWrappers = []any{ + file_wallet_service_proto_msgTypes[3].OneofWrappers = []any{ + (*pairSignAndSubmitChallengeProgress_RefreshingChallenge)(nil), + (*pairSignAndSubmitChallengeProgress_ChallengeSigned)(nil), + (*pairSignAndSubmitChallengeProgress_Completed)(nil), + } + file_wallet_service_proto_msgTypes[7].OneofWrappers = []any{ (*scanProgressUpdate_FoundUtxoReport)(nil), (*scanProgressUpdate_ScanComplete)(nil), } - file_wallet_service_proto_msgTypes[15].OneofWrappers = []any{} - file_wallet_service_proto_msgTypes[19].OneofWrappers = []any{ + file_wallet_service_proto_msgTypes[16].OneofWrappers = []any{} + file_wallet_service_proto_msgTypes[20].OneofWrappers = []any{ (*value_NullValue)(nil), (*value_DoubleValue)(nil), (*value_IntValue)(nil), @@ -3531,7 +5073,7 @@ func file_wallet_service_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_wallet_service_proto_rawDesc), len(file_wallet_service_proto_rawDesc)), NumEnums: 2, - NumMessages: 35, + NumMessages: 50, NumExtensions: 0, NumServices: 1, }, diff --git a/libwallet/presentation/api/wallet_service.proto b/libwallet/presentation/api/wallet_service.proto index 8691c041..b1b6f9a4 100644 --- a/libwallet/presentation/api/wallet_service.proto +++ b/libwallet/presentation/api/wallet_service.proto @@ -7,13 +7,13 @@ import "google/protobuf/empty.proto"; option go_package = "github.com/muun/libwallet/api"; service WalletService { - // V3 - NFC security cards Native->Libwallet API - rpc SetupSecurityCard(google.protobuf.Empty) returns (XpubResponse); - rpc ResetSecurityCard(google.protobuf.Empty) returns (google.protobuf.Empty); - rpc SignMessageSecurityCard(SignMessageSecurityCardRequest) returns (SignMessageSecurityCardResponse); + // NFC security cards Native->Libwallet API rpc SetupSecurityCardV2(google.protobuf.Empty) returns (SetupSecurityCardResponse); rpc SignMessageSecurityCardV2(google.protobuf.Empty) returns (google.protobuf.Empty); + rpc PairRequestChallenge(google.protobuf.Empty) returns (google.protobuf.Empty); + rpc PairSignAndSubmitChallenge(google.protobuf.Empty) returns (stream PairSignAndSubmitChallengeProgress); + // Diagnostic Mode API rpc StartDiagnosticSession(google.protobuf.Empty) returns (DiagnosticSessionDescriptor); rpc PerformDiagnosticScanForUtxos(DiagnosticSessionDescriptor) returns (stream ScanProgressUpdate); @@ -38,6 +38,18 @@ service WalletService { // Emergency Kit PDF Generation rpc GenerateEmergencyKitPDF(GenerateEmergencyKitPDFRequest) returns (GenerateEmergencyKitPDFResponse); + + // Reset all data from the local SQLite database + rpc ResetData(google.protobuf.Empty) returns (google.protobuf.Empty); + + // Zip all regular files in the libwallet data directory for debug extraction + rpc ZipDataDir(ZipDataDirRequest) returns (google.protobuf.Empty); + + // Secure Key-Value Storage (native hardware-encrypted storage bridge) + rpc SecureKeyValueStoragePut(SecureKeyValueStoragePutRequest) returns (google.protobuf.Empty); + rpc SecureKeyValueStorageGet(SecureKeyValueStorageGetRequest) returns (SecureKeyValueStorageGetResponse); + rpc SecureKeyValueStorageDelete(SecureKeyValueStorageDeleteRequest) returns (google.protobuf.Empty); + rpc SecureKeyValueStorageWipe(google.protobuf.Empty) returns (google.protobuf.Empty); } enum ErrorType { @@ -53,24 +65,34 @@ message ErrorDetail { string developer_message = 4; } -message XpubResponse { - string base58_xpub = 1; -} - message SetupSecurityCardResponse { bool is_known_provider = 1; bool is_card_already_used = 2; } -message SignMessageSecurityCardRequest { - string message_hex = 1; +message PairSignAndSubmitChallengeResponse { + bool is_known_provider = 1; + bool is_card_already_used = 2; } -message SignMessageSecurityCardResponse { - string signed_message_hex = 1; - bool is_validated = 2; +// Progress event streamed by PairSignAndSubmitChallenge. Exactly one +// variant of the oneof is set per event. +message PairSignAndSubmitChallengeProgress { + oneof event { + RefreshingChallenge refreshing_challenge = 1; + ChallengeSigned challenge_signed = 2; + PairSignAndSubmitChallengeResponse completed = 3; + } } +// Emitted just before the action refreshes a missing/stale pending +// challenge. Only fires when the self-heal path runs. +message RefreshingChallenge {} + +// Emitted right after the signing device (e.g. card) produces its response, +// before the action hands the signed challenge to Houston for validation. +message ChallengeSigned {} + message DiagnosticSessionDescriptor { string session_id = 1; } @@ -193,23 +215,65 @@ message GetByPrefixRequest { string prefix = 1; } +message SecurityCardsProviderTheme { + string primary_color = 1; + string surface_color = 2; +} + +message PriceInfo { + string currency_code = 1; + string amount = 2; +} + +message CountryInfo { + string code = 1; + string name = 2; + string flag = 3; +} + +message ShippingPriceInfo { + PriceInfo price = 1; + repeated CountryInfo countries = 2; +} + message SecurityCardsProvider { - string name = 1; - repeated SecurityCard security_cards = 2; - string currency = 3; - string color_hex = 4; - string material = 5; - double price = 6; - double shipping_cost = 7; + string id = 1; + string name = 2; + string description = 3; + string site_url = 4; + SecurityCardsProviderTheme light_theme = 5; + SecurityCardsProviderTheme dark_theme = 6; + repeated SecurityCard security_cards = 7; + repeated ShippingPriceInfo estimated_shipping_prices = 8; } message SecurityCard { - string image = 1; - int32 stock = 2; + string id = 1; + string asset_url = 2; + string tag = 3; + string spec_id = 4; + PriceInfo card_cost = 5; +} + +message SpecsItem { + string icon_url = 1; + string label = 2; + string value = 3; + string additional_data = 4; +} + +message SpecsItemList { + repeated SpecsItem items = 1; +} + +message SecurityCardSpec { + string spec_id = 1; + map items = 2; } message GetSecurityCardsMarketplaceResponse { repeated SecurityCardsProvider providers = 1; + repeated SecurityCardSpec specs = 2; } message EKInputRequest { @@ -229,4 +293,41 @@ message GenerateEmergencyKitPDFRequest { message GenerateEmergencyKitPDFResponse { string verification_code = 1; int32 version = 2; + // Per-render profiling, so prod telemetry can validate the offline profiling. + RenderProfiling profiling = 3; +} + +// RenderProfiling carries per-stage durations (milliseconds) and total allocation stats for a +// single PDF render. Alloc stats are a process-wide delta and thus an approximation. +message RenderProfiling { + int64 load_translations_ms = 1; + int64 register_fonts_ms = 2; + int64 register_images_ms = 3; + int64 components_rendering_ms = 4; + int64 create_and_save_on_disk_ms = 5; + int64 total_heap_allocated_bytes = 6; + int64 total_objects_allocated = 7; + int64 embed_metadata_ms = 8; + int64 total_inside_go_ms = 9; +} + +message ZipDataDirRequest { + string output_path = 1; +} + +message SecureKeyValueStoragePutRequest { + string key = 1; + bytes value = 2; +} + +message SecureKeyValueStorageGetRequest { + string key = 1; +} + +message SecureKeyValueStorageGetResponse { + bytes value = 1; +} + +message SecureKeyValueStorageDeleteRequest { + string key = 1; } diff --git a/libwallet/presentation/api/wallet_service_grpc.pb.go b/libwallet/presentation/api/wallet_service_grpc.pb.go index 7fba4d5d..1920cb55 100644 --- a/libwallet/presentation/api/wallet_service_grpc.pb.go +++ b/libwallet/presentation/api/wallet_service_grpc.pb.go @@ -20,11 +20,10 @@ import ( const _ = grpc.SupportPackageIsVersion9 const ( - WalletService_SetupSecurityCard_FullMethodName = "/rpc.WalletService/SetupSecurityCard" - WalletService_ResetSecurityCard_FullMethodName = "/rpc.WalletService/ResetSecurityCard" - WalletService_SignMessageSecurityCard_FullMethodName = "/rpc.WalletService/SignMessageSecurityCard" WalletService_SetupSecurityCardV2_FullMethodName = "/rpc.WalletService/SetupSecurityCardV2" WalletService_SignMessageSecurityCardV2_FullMethodName = "/rpc.WalletService/SignMessageSecurityCardV2" + WalletService_PairRequestChallenge_FullMethodName = "/rpc.WalletService/PairRequestChallenge" + WalletService_PairSignAndSubmitChallenge_FullMethodName = "/rpc.WalletService/PairSignAndSubmitChallenge" WalletService_StartDiagnosticSession_FullMethodName = "/rpc.WalletService/StartDiagnosticSession" WalletService_PerformDiagnosticScanForUtxos_FullMethodName = "/rpc.WalletService/PerformDiagnosticScanForUtxos" WalletService_SubmitDiagnosticLog_FullMethodName = "/rpc.WalletService/SubmitDiagnosticLog" @@ -41,18 +40,23 @@ const ( WalletService_GetByPrefix_FullMethodName = "/rpc.WalletService/GetByPrefix" WalletService_GetSecurityCardsMarketplace_FullMethodName = "/rpc.WalletService/GetSecurityCardsMarketplace" WalletService_GenerateEmergencyKitPDF_FullMethodName = "/rpc.WalletService/GenerateEmergencyKitPDF" + WalletService_ResetData_FullMethodName = "/rpc.WalletService/ResetData" + WalletService_ZipDataDir_FullMethodName = "/rpc.WalletService/ZipDataDir" + WalletService_SecureKeyValueStoragePut_FullMethodName = "/rpc.WalletService/SecureKeyValueStoragePut" + WalletService_SecureKeyValueStorageGet_FullMethodName = "/rpc.WalletService/SecureKeyValueStorageGet" + WalletService_SecureKeyValueStorageDelete_FullMethodName = "/rpc.WalletService/SecureKeyValueStorageDelete" + WalletService_SecureKeyValueStorageWipe_FullMethodName = "/rpc.WalletService/SecureKeyValueStorageWipe" ) // WalletServiceClient is the client API for WalletService service. // // For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. type WalletServiceClient interface { - // V3 - NFC security cards Native->Libwallet API - SetupSecurityCard(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*XpubResponse, error) - ResetSecurityCard(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*emptypb.Empty, error) - SignMessageSecurityCard(ctx context.Context, in *SignMessageSecurityCardRequest, opts ...grpc.CallOption) (*SignMessageSecurityCardResponse, error) + // NFC security cards Native->Libwallet API SetupSecurityCardV2(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*SetupSecurityCardResponse, error) SignMessageSecurityCardV2(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*emptypb.Empty, error) + PairRequestChallenge(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*emptypb.Empty, error) + PairSignAndSubmitChallenge(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (grpc.ServerStreamingClient[PairSignAndSubmitChallengeProgress], error) // Diagnostic Mode API StartDiagnosticSession(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*DiagnosticSessionDescriptor, error) PerformDiagnosticScanForUtxos(ctx context.Context, in *DiagnosticSessionDescriptor, opts ...grpc.CallOption) (grpc.ServerStreamingClient[ScanProgressUpdate], error) @@ -73,6 +77,15 @@ type WalletServiceClient interface { GetSecurityCardsMarketplace(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*GetSecurityCardsMarketplaceResponse, error) // Emergency Kit PDF Generation GenerateEmergencyKitPDF(ctx context.Context, in *GenerateEmergencyKitPDFRequest, opts ...grpc.CallOption) (*GenerateEmergencyKitPDFResponse, error) + // Reset all data from the local SQLite database + ResetData(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*emptypb.Empty, error) + // Zip all regular files in the libwallet data directory for debug extraction + ZipDataDir(ctx context.Context, in *ZipDataDirRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + // Secure Key-Value Storage (native hardware-encrypted storage bridge) + SecureKeyValueStoragePut(ctx context.Context, in *SecureKeyValueStoragePutRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + SecureKeyValueStorageGet(ctx context.Context, in *SecureKeyValueStorageGetRequest, opts ...grpc.CallOption) (*SecureKeyValueStorageGetResponse, error) + SecureKeyValueStorageDelete(ctx context.Context, in *SecureKeyValueStorageDeleteRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + SecureKeyValueStorageWipe(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*emptypb.Empty, error) } type walletServiceClient struct { @@ -83,56 +96,55 @@ func NewWalletServiceClient(cc grpc.ClientConnInterface) WalletServiceClient { return &walletServiceClient{cc} } -func (c *walletServiceClient) SetupSecurityCard(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*XpubResponse, error) { +func (c *walletServiceClient) SetupSecurityCardV2(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*SetupSecurityCardResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(XpubResponse) - err := c.cc.Invoke(ctx, WalletService_SetupSecurityCard_FullMethodName, in, out, cOpts...) + out := new(SetupSecurityCardResponse) + err := c.cc.Invoke(ctx, WalletService_SetupSecurityCardV2_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } return out, nil } -func (c *walletServiceClient) ResetSecurityCard(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*emptypb.Empty, error) { +func (c *walletServiceClient) SignMessageSecurityCardV2(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*emptypb.Empty, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(emptypb.Empty) - err := c.cc.Invoke(ctx, WalletService_ResetSecurityCard_FullMethodName, in, out, cOpts...) + err := c.cc.Invoke(ctx, WalletService_SignMessageSecurityCardV2_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } return out, nil } -func (c *walletServiceClient) SignMessageSecurityCard(ctx context.Context, in *SignMessageSecurityCardRequest, opts ...grpc.CallOption) (*SignMessageSecurityCardResponse, error) { +func (c *walletServiceClient) PairRequestChallenge(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*emptypb.Empty, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(SignMessageSecurityCardResponse) - err := c.cc.Invoke(ctx, WalletService_SignMessageSecurityCard_FullMethodName, in, out, cOpts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, WalletService_PairRequestChallenge_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } return out, nil } -func (c *walletServiceClient) SetupSecurityCardV2(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*SetupSecurityCardResponse, error) { +func (c *walletServiceClient) PairSignAndSubmitChallenge(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (grpc.ServerStreamingClient[PairSignAndSubmitChallengeProgress], error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(SetupSecurityCardResponse) - err := c.cc.Invoke(ctx, WalletService_SetupSecurityCardV2_FullMethodName, in, out, cOpts...) + stream, err := c.cc.NewStream(ctx, &WalletService_ServiceDesc.Streams[0], WalletService_PairSignAndSubmitChallenge_FullMethodName, cOpts...) if err != nil { return nil, err } - return out, nil -} - -func (c *walletServiceClient) SignMessageSecurityCardV2(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*emptypb.Empty, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(emptypb.Empty) - err := c.cc.Invoke(ctx, WalletService_SignMessageSecurityCardV2_FullMethodName, in, out, cOpts...) - if err != nil { + x := &grpc.GenericClientStream[emptypb.Empty, PairSignAndSubmitChallengeProgress]{ClientStream: stream} + if err := x.ClientStream.SendMsg(in); err != nil { return nil, err } - return out, nil + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + return x, nil } +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type WalletService_PairSignAndSubmitChallengeClient = grpc.ServerStreamingClient[PairSignAndSubmitChallengeProgress] + func (c *walletServiceClient) StartDiagnosticSession(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*DiagnosticSessionDescriptor, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(DiagnosticSessionDescriptor) @@ -145,7 +157,7 @@ func (c *walletServiceClient) StartDiagnosticSession(ctx context.Context, in *em func (c *walletServiceClient) PerformDiagnosticScanForUtxos(ctx context.Context, in *DiagnosticSessionDescriptor, opts ...grpc.CallOption) (grpc.ServerStreamingClient[ScanProgressUpdate], error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - stream, err := c.cc.NewStream(ctx, &WalletService_ServiceDesc.Streams[0], WalletService_PerformDiagnosticScanForUtxos_FullMethodName, cOpts...) + stream, err := c.cc.NewStream(ctx, &WalletService_ServiceDesc.Streams[1], WalletService_PerformDiagnosticScanForUtxos_FullMethodName, cOpts...) if err != nil { return nil, err } @@ -302,16 +314,75 @@ func (c *walletServiceClient) GenerateEmergencyKitPDF(ctx context.Context, in *G return out, nil } +func (c *walletServiceClient) ResetData(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, WalletService_ResetData_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *walletServiceClient) ZipDataDir(ctx context.Context, in *ZipDataDirRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, WalletService_ZipDataDir_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *walletServiceClient) SecureKeyValueStoragePut(ctx context.Context, in *SecureKeyValueStoragePutRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, WalletService_SecureKeyValueStoragePut_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *walletServiceClient) SecureKeyValueStorageGet(ctx context.Context, in *SecureKeyValueStorageGetRequest, opts ...grpc.CallOption) (*SecureKeyValueStorageGetResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(SecureKeyValueStorageGetResponse) + err := c.cc.Invoke(ctx, WalletService_SecureKeyValueStorageGet_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *walletServiceClient) SecureKeyValueStorageDelete(ctx context.Context, in *SecureKeyValueStorageDeleteRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, WalletService_SecureKeyValueStorageDelete_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *walletServiceClient) SecureKeyValueStorageWipe(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, WalletService_SecureKeyValueStorageWipe_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + // WalletServiceServer is the server API for WalletService service. // All implementations must embed UnimplementedWalletServiceServer // for forward compatibility. type WalletServiceServer interface { - // V3 - NFC security cards Native->Libwallet API - SetupSecurityCard(context.Context, *emptypb.Empty) (*XpubResponse, error) - ResetSecurityCard(context.Context, *emptypb.Empty) (*emptypb.Empty, error) - SignMessageSecurityCard(context.Context, *SignMessageSecurityCardRequest) (*SignMessageSecurityCardResponse, error) + // NFC security cards Native->Libwallet API SetupSecurityCardV2(context.Context, *emptypb.Empty) (*SetupSecurityCardResponse, error) SignMessageSecurityCardV2(context.Context, *emptypb.Empty) (*emptypb.Empty, error) + PairRequestChallenge(context.Context, *emptypb.Empty) (*emptypb.Empty, error) + PairSignAndSubmitChallenge(*emptypb.Empty, grpc.ServerStreamingServer[PairSignAndSubmitChallengeProgress]) error // Diagnostic Mode API StartDiagnosticSession(context.Context, *emptypb.Empty) (*DiagnosticSessionDescriptor, error) PerformDiagnosticScanForUtxos(*DiagnosticSessionDescriptor, grpc.ServerStreamingServer[ScanProgressUpdate]) error @@ -332,6 +403,15 @@ type WalletServiceServer interface { GetSecurityCardsMarketplace(context.Context, *emptypb.Empty) (*GetSecurityCardsMarketplaceResponse, error) // Emergency Kit PDF Generation GenerateEmergencyKitPDF(context.Context, *GenerateEmergencyKitPDFRequest) (*GenerateEmergencyKitPDFResponse, error) + // Reset all data from the local SQLite database + ResetData(context.Context, *emptypb.Empty) (*emptypb.Empty, error) + // Zip all regular files in the libwallet data directory for debug extraction + ZipDataDir(context.Context, *ZipDataDirRequest) (*emptypb.Empty, error) + // Secure Key-Value Storage (native hardware-encrypted storage bridge) + SecureKeyValueStoragePut(context.Context, *SecureKeyValueStoragePutRequest) (*emptypb.Empty, error) + SecureKeyValueStorageGet(context.Context, *SecureKeyValueStorageGetRequest) (*SecureKeyValueStorageGetResponse, error) + SecureKeyValueStorageDelete(context.Context, *SecureKeyValueStorageDeleteRequest) (*emptypb.Empty, error) + SecureKeyValueStorageWipe(context.Context, *emptypb.Empty) (*emptypb.Empty, error) mustEmbedUnimplementedWalletServiceServer() } @@ -342,21 +422,18 @@ type WalletServiceServer interface { // pointer dereference when methods are called. type UnimplementedWalletServiceServer struct{} -func (UnimplementedWalletServiceServer) SetupSecurityCard(context.Context, *emptypb.Empty) (*XpubResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method SetupSecurityCard not implemented") -} -func (UnimplementedWalletServiceServer) ResetSecurityCard(context.Context, *emptypb.Empty) (*emptypb.Empty, error) { - return nil, status.Errorf(codes.Unimplemented, "method ResetSecurityCard not implemented") -} -func (UnimplementedWalletServiceServer) SignMessageSecurityCard(context.Context, *SignMessageSecurityCardRequest) (*SignMessageSecurityCardResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method SignMessageSecurityCard not implemented") -} func (UnimplementedWalletServiceServer) SetupSecurityCardV2(context.Context, *emptypb.Empty) (*SetupSecurityCardResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method SetupSecurityCardV2 not implemented") } func (UnimplementedWalletServiceServer) SignMessageSecurityCardV2(context.Context, *emptypb.Empty) (*emptypb.Empty, error) { return nil, status.Errorf(codes.Unimplemented, "method SignMessageSecurityCardV2 not implemented") } +func (UnimplementedWalletServiceServer) PairRequestChallenge(context.Context, *emptypb.Empty) (*emptypb.Empty, error) { + return nil, status.Errorf(codes.Unimplemented, "method PairRequestChallenge not implemented") +} +func (UnimplementedWalletServiceServer) PairSignAndSubmitChallenge(*emptypb.Empty, grpc.ServerStreamingServer[PairSignAndSubmitChallengeProgress]) error { + return status.Errorf(codes.Unimplemented, "method PairSignAndSubmitChallenge not implemented") +} func (UnimplementedWalletServiceServer) StartDiagnosticSession(context.Context, *emptypb.Empty) (*DiagnosticSessionDescriptor, error) { return nil, status.Errorf(codes.Unimplemented, "method StartDiagnosticSession not implemented") } @@ -405,6 +482,24 @@ func (UnimplementedWalletServiceServer) GetSecurityCardsMarketplace(context.Cont func (UnimplementedWalletServiceServer) GenerateEmergencyKitPDF(context.Context, *GenerateEmergencyKitPDFRequest) (*GenerateEmergencyKitPDFResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method GenerateEmergencyKitPDF not implemented") } +func (UnimplementedWalletServiceServer) ResetData(context.Context, *emptypb.Empty) (*emptypb.Empty, error) { + return nil, status.Errorf(codes.Unimplemented, "method ResetData not implemented") +} +func (UnimplementedWalletServiceServer) ZipDataDir(context.Context, *ZipDataDirRequest) (*emptypb.Empty, error) { + return nil, status.Errorf(codes.Unimplemented, "method ZipDataDir not implemented") +} +func (UnimplementedWalletServiceServer) SecureKeyValueStoragePut(context.Context, *SecureKeyValueStoragePutRequest) (*emptypb.Empty, error) { + return nil, status.Errorf(codes.Unimplemented, "method SecureKeyValueStoragePut not implemented") +} +func (UnimplementedWalletServiceServer) SecureKeyValueStorageGet(context.Context, *SecureKeyValueStorageGetRequest) (*SecureKeyValueStorageGetResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method SecureKeyValueStorageGet not implemented") +} +func (UnimplementedWalletServiceServer) SecureKeyValueStorageDelete(context.Context, *SecureKeyValueStorageDeleteRequest) (*emptypb.Empty, error) { + return nil, status.Errorf(codes.Unimplemented, "method SecureKeyValueStorageDelete not implemented") +} +func (UnimplementedWalletServiceServer) SecureKeyValueStorageWipe(context.Context, *emptypb.Empty) (*emptypb.Empty, error) { + return nil, status.Errorf(codes.Unimplemented, "method SecureKeyValueStorageWipe not implemented") +} func (UnimplementedWalletServiceServer) mustEmbedUnimplementedWalletServiceServer() {} func (UnimplementedWalletServiceServer) testEmbeddedByValue() {} @@ -426,96 +521,71 @@ func RegisterWalletServiceServer(s grpc.ServiceRegistrar, srv WalletServiceServe s.RegisterService(&WalletService_ServiceDesc, srv) } -func _WalletService_SetupSecurityCard_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { +func _WalletService_SetupSecurityCardV2_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(emptypb.Empty) if err := dec(in); err != nil { return nil, err } if interceptor == nil { - return srv.(WalletServiceServer).SetupSecurityCard(ctx, in) + return srv.(WalletServiceServer).SetupSecurityCardV2(ctx, in) } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: WalletService_SetupSecurityCard_FullMethodName, + FullMethod: WalletService_SetupSecurityCardV2_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(WalletServiceServer).SetupSecurityCard(ctx, req.(*emptypb.Empty)) + return srv.(WalletServiceServer).SetupSecurityCardV2(ctx, req.(*emptypb.Empty)) } return interceptor(ctx, in, info, handler) } -func _WalletService_ResetSecurityCard_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { +func _WalletService_SignMessageSecurityCardV2_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(emptypb.Empty) if err := dec(in); err != nil { return nil, err } if interceptor == nil { - return srv.(WalletServiceServer).ResetSecurityCard(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: WalletService_ResetSecurityCard_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(WalletServiceServer).ResetSecurityCard(ctx, req.(*emptypb.Empty)) - } - return interceptor(ctx, in, info, handler) -} - -func _WalletService_SignMessageSecurityCard_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(SignMessageSecurityCardRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(WalletServiceServer).SignMessageSecurityCard(ctx, in) + return srv.(WalletServiceServer).SignMessageSecurityCardV2(ctx, in) } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: WalletService_SignMessageSecurityCard_FullMethodName, + FullMethod: WalletService_SignMessageSecurityCardV2_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(WalletServiceServer).SignMessageSecurityCard(ctx, req.(*SignMessageSecurityCardRequest)) + return srv.(WalletServiceServer).SignMessageSecurityCardV2(ctx, req.(*emptypb.Empty)) } return interceptor(ctx, in, info, handler) } -func _WalletService_SetupSecurityCardV2_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { +func _WalletService_PairRequestChallenge_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(emptypb.Empty) if err := dec(in); err != nil { return nil, err } if interceptor == nil { - return srv.(WalletServiceServer).SetupSecurityCardV2(ctx, in) + return srv.(WalletServiceServer).PairRequestChallenge(ctx, in) } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: WalletService_SetupSecurityCardV2_FullMethodName, + FullMethod: WalletService_PairRequestChallenge_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(WalletServiceServer).SetupSecurityCardV2(ctx, req.(*emptypb.Empty)) + return srv.(WalletServiceServer).PairRequestChallenge(ctx, req.(*emptypb.Empty)) } return interceptor(ctx, in, info, handler) } -func _WalletService_SignMessageSecurityCardV2_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(emptypb.Empty) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(WalletServiceServer).SignMessageSecurityCardV2(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: WalletService_SignMessageSecurityCardV2_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(WalletServiceServer).SignMessageSecurityCardV2(ctx, req.(*emptypb.Empty)) +func _WalletService_PairSignAndSubmitChallenge_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(emptypb.Empty) + if err := stream.RecvMsg(m); err != nil { + return err } - return interceptor(ctx, in, info, handler) + return srv.(WalletServiceServer).PairSignAndSubmitChallenge(m, &grpc.GenericServerStream[emptypb.Empty, PairSignAndSubmitChallengeProgress]{ServerStream: stream}) } +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type WalletService_PairSignAndSubmitChallengeServer = grpc.ServerStreamingServer[PairSignAndSubmitChallengeProgress] + func _WalletService_StartDiagnosticSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(emptypb.Empty) if err := dec(in); err != nil { @@ -797,6 +867,114 @@ func _WalletService_GenerateEmergencyKitPDF_Handler(srv interface{}, ctx context return interceptor(ctx, in, info, handler) } +func _WalletService_ResetData_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(emptypb.Empty) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(WalletServiceServer).ResetData(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: WalletService_ResetData_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(WalletServiceServer).ResetData(ctx, req.(*emptypb.Empty)) + } + return interceptor(ctx, in, info, handler) +} + +func _WalletService_ZipDataDir_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ZipDataDirRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(WalletServiceServer).ZipDataDir(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: WalletService_ZipDataDir_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(WalletServiceServer).ZipDataDir(ctx, req.(*ZipDataDirRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _WalletService_SecureKeyValueStoragePut_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SecureKeyValueStoragePutRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(WalletServiceServer).SecureKeyValueStoragePut(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: WalletService_SecureKeyValueStoragePut_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(WalletServiceServer).SecureKeyValueStoragePut(ctx, req.(*SecureKeyValueStoragePutRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _WalletService_SecureKeyValueStorageGet_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SecureKeyValueStorageGetRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(WalletServiceServer).SecureKeyValueStorageGet(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: WalletService_SecureKeyValueStorageGet_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(WalletServiceServer).SecureKeyValueStorageGet(ctx, req.(*SecureKeyValueStorageGetRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _WalletService_SecureKeyValueStorageDelete_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SecureKeyValueStorageDeleteRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(WalletServiceServer).SecureKeyValueStorageDelete(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: WalletService_SecureKeyValueStorageDelete_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(WalletServiceServer).SecureKeyValueStorageDelete(ctx, req.(*SecureKeyValueStorageDeleteRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _WalletService_SecureKeyValueStorageWipe_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(emptypb.Empty) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(WalletServiceServer).SecureKeyValueStorageWipe(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: WalletService_SecureKeyValueStorageWipe_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(WalletServiceServer).SecureKeyValueStorageWipe(ctx, req.(*emptypb.Empty)) + } + return interceptor(ctx, in, info, handler) +} + // WalletService_ServiceDesc is the grpc.ServiceDesc for WalletService service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -804,18 +982,6 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{ ServiceName: "rpc.WalletService", HandlerType: (*WalletServiceServer)(nil), Methods: []grpc.MethodDesc{ - { - MethodName: "SetupSecurityCard", - Handler: _WalletService_SetupSecurityCard_Handler, - }, - { - MethodName: "ResetSecurityCard", - Handler: _WalletService_ResetSecurityCard_Handler, - }, - { - MethodName: "SignMessageSecurityCard", - Handler: _WalletService_SignMessageSecurityCard_Handler, - }, { MethodName: "SetupSecurityCardV2", Handler: _WalletService_SetupSecurityCardV2_Handler, @@ -824,6 +990,10 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{ MethodName: "SignMessageSecurityCardV2", Handler: _WalletService_SignMessageSecurityCardV2_Handler, }, + { + MethodName: "PairRequestChallenge", + Handler: _WalletService_PairRequestChallenge_Handler, + }, { MethodName: "StartDiagnosticSession", Handler: _WalletService_StartDiagnosticSession_Handler, @@ -884,8 +1054,37 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{ MethodName: "GenerateEmergencyKitPDF", Handler: _WalletService_GenerateEmergencyKitPDF_Handler, }, + { + MethodName: "ResetData", + Handler: _WalletService_ResetData_Handler, + }, + { + MethodName: "ZipDataDir", + Handler: _WalletService_ZipDataDir_Handler, + }, + { + MethodName: "SecureKeyValueStoragePut", + Handler: _WalletService_SecureKeyValueStoragePut_Handler, + }, + { + MethodName: "SecureKeyValueStorageGet", + Handler: _WalletService_SecureKeyValueStorageGet_Handler, + }, + { + MethodName: "SecureKeyValueStorageDelete", + Handler: _WalletService_SecureKeyValueStorageDelete_Handler, + }, + { + MethodName: "SecureKeyValueStorageWipe", + Handler: _WalletService_SecureKeyValueStorageWipe_Handler, + }, }, Streams: []grpc.StreamDesc{ + { + StreamName: "PairSignAndSubmitChallenge", + Handler: _WalletService_PairSignAndSubmitChallenge_Handler, + ServerStreams: true, + }, { StreamName: "PerformDiagnosticScanForUtxos", Handler: _WalletService_PerformDiagnosticScanForUtxos_Handler, diff --git a/libwallet/presentation/emergency_kit_generator_test.go b/libwallet/presentation/emergency_kit_generator_test.go index 3156072f..5c3e5647 100644 --- a/libwallet/presentation/emergency_kit_generator_test.go +++ b/libwallet/presentation/emergency_kit_generator_test.go @@ -10,8 +10,8 @@ import ( func TestGenerateEmergencyKitPDFGrpc(t *testing.T) { ekInput := api.EKInputRequest_builder{ - FirstEncryptedKey: "5zZPjShrCywaeaqK3bPxL9bG18eLcXwQ5DyAkVy8asPujTWK58PJFyjwixASB967rfQcG2PhnZJ6ksKVWasup29WmPtAyjN6heNYC7pQARUxMsVrUVD5pGc4aJH5W3QdXDFhyiRrszFsedz2T4s", - SecondEncryptedKey: "4UrzWNdJzNg5XYkypVCAqxLreHnK6uYyaUNTmuEkdet6T1dDhHKkCicTT7MKa2BCKA4TA39o4gAzjBCageg9bvRVZs2deazEykpTgPaY6yF25AK1ckdT1dVKE9NbmVfuf5N6qFVLRBe1myYS6eD", + FirstEncryptedKey: "5zZPjShrCywaeaqK3bPxL9bG18eLcXwQ5DyAkVy8asPujTWK58PJFyjwixASB967rfQcG2PhnZJ6ksKVWasup29WmPtAyjN6heNYC7pQARUxMsVrUVD5pGc4aJH5W3QdXDFhyiRrszFsedz2T4s", //nolint:lll + SecondEncryptedKey: "4UrzWNdJzNg5XYkypVCAqxLreHnK6uYyaUNTmuEkdet6T1dDhHKkCicTT7MKa2BCKA4TA39o4gAzjBCageg9bvRVZs2deazEykpTgPaY6yF25AK1ckdT1dVKE9NbmVfuf5N6qFVLRBe1myYS6eD", //nolint:lll FirstFingerprint: "af932357", SecondFingerprint: "61f4d2a0", RcChecksum: "checksum123", @@ -42,6 +42,18 @@ func TestGenerateEmergencyKitPDFGrpc(t *testing.T) { t.Fatalf("Version should be 3") } + profiling := result.GetProfiling() + if profiling == nil { + t.Fatal("expected render profiling in the response") + } + // registerImages and totalInsideGo are reliably multi-ms; smaller stages (incl. embed) can + // truncate to 0ms. Allocs stable. + if profiling.GetRegisterImagesMs() == 0 || + profiling.GetTotalHeapAllocatedBytes() == 0 || + profiling.GetTotalInsideGoMs() == 0 { + t.Fatalf("expected non-zero profiling stats, got %+v", profiling) + } + if _, err := os.Stat(outputPath); os.IsNotExist(err) { t.Fatalf("PDF file not created at expected path: %s", outputPath) } diff --git a/libwallet/presentation/grpc_errors.go b/libwallet/presentation/grpc_errors.go index 7d8fa532..fc8635b0 100644 --- a/libwallet/presentation/grpc_errors.go +++ b/libwallet/presentation/grpc_errors.go @@ -2,12 +2,14 @@ package presentation import ( "errors" + "log/slog" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + apierrors "github.com/muun/libwallet/errors" "github.com/muun/libwallet/presentation/api" "github.com/muun/libwallet/service" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" - "log/slog" ) func NewGrpcErrorFromCode(errorCode apierrors.ErrorCode) error { diff --git a/libwallet/presentation/interceptors.go b/libwallet/presentation/interceptors.go index ddf637a5..e2ace0df 100644 --- a/libwallet/presentation/interceptors.go +++ b/libwallet/presentation/interceptors.go @@ -3,23 +3,25 @@ package presentation import ( "context" "fmt" - "github.com/grpc-ecosystem/go-grpc-middleware/recovery" - "google.golang.org/grpc" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" "log/slog" "path" "runtime/debug" "time" + + "github.com/go-errors/errors" + grpc_recovery "github.com/grpc-ecosystem/go-grpc-middleware/recovery" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" ) -// RecoverUnknownErrorInterceptor converts UNKNOWN gRPC errors into INTERNAL gRPC errors +// RecoverUnknownErrorUnaryInterceptor converts UNKNOWN gRPC errors into INTERNAL gRPC errors // to ensure consistency when errors are not properly constructed in the presentation layer. -func RecoverUnknownErrorInterceptor() grpc.UnaryServerInterceptor { +func RecoverUnknownErrorUnaryInterceptor() grpc.UnaryServerInterceptor { return func( ctx context.Context, req any, - info *grpc.UnaryServerInfo, + info *grpc.UnaryServerInfo, //nolint:revive // TODO: use or remove info handler grpc.UnaryHandler, ) (resp any, err error) { @@ -33,25 +35,16 @@ func RecoverUnknownErrorInterceptor() grpc.UnaryServerInterceptor { } } -// RecoverPanicInterceptor catches panic errors during RPC execution +// RecoverPanicUnaryInterceptor catches panic errors during RPC execution // and converts them into INTERNAL gRPC errors. -func RecoverPanicInterceptor() grpc.UnaryServerInterceptor { +func RecoverPanicUnaryInterceptor() grpc.UnaryServerInterceptor { return grpc_recovery.UnaryServerInterceptor( - grpc_recovery.WithRecoveryHandler(func(p any) error { - - stack := debug.Stack() - slog.Error( - "recovery from panic", - slog.Any("panic", fmt.Sprintf("%v", p)), - slog.String("stack", string(stack)), - ) - return NewGrpcError(fmt.Errorf("panic: %v", p)) - }), + grpc_recovery.WithRecoveryHandler(panicRecoveryHandler), ) } -// LoggingInterceptor logs each incoming gRPC method, its duration and error status. -func LoggingInterceptor() grpc.UnaryServerInterceptor { +// LoggingUnaryInterceptor logs each incoming gRPC method, its duration and error status. +func LoggingUnaryInterceptor() grpc.UnaryServerInterceptor { return func( ctx context.Context, req any, @@ -61,21 +54,76 @@ func LoggingInterceptor() grpc.UnaryServerInterceptor { startTime := time.Now() resp, err = handler(ctx, req) duration := time.Since(startTime) - method := path.Base(info.FullMethod) - statusCode := status.Code(err) - attrs := []any{ - slog.String("method", method), - slog.Int("duration_ms", int(duration.Milliseconds())), - slog.String("status_code", statusCode.String()), - } + logCall(path.Base(info.FullMethod), duration, err) + return resp, err + } +} - if err != nil { - attrs = append(attrs, slog.Any("error", err)) - slog.Error("gRPC call failed", attrs...) - } else { - slog.Debug("gRPC call succeeded", attrs...) +// RecoverUnknownErrorStreamInterceptor converts UNKNOWN gRPC errors into INTERNAL gRPC errors +// for streaming RPCs, to ensure consistency when errors are not properly constructed in the +// presentation layer. +func RecoverUnknownErrorStreamInterceptor() grpc.StreamServerInterceptor { + return func( + srv any, + ss grpc.ServerStream, + _ *grpc.StreamServerInfo, + handler grpc.StreamHandler, + ) error { + err := handler(srv, ss) + st, ok := status.FromError(err) + if !ok || st.Code() == codes.Unknown { + return NewGrpcError(err) } + return err + } +} - return resp, err +// RecoverPanicStreamInterceptor catches panics during streaming RPC execution +// and converts them into INTERNAL gRPC errors. +func RecoverPanicStreamInterceptor() grpc.StreamServerInterceptor { + return grpc_recovery.StreamServerInterceptor( + grpc_recovery.WithRecoveryHandler(panicRecoveryHandler), + ) +} + +// LoggingStreamInterceptor logs each streaming gRPC method, its duration and error status. +func LoggingStreamInterceptor() grpc.StreamServerInterceptor { + return func( + srv any, + ss grpc.ServerStream, + info *grpc.StreamServerInfo, + handler grpc.StreamHandler, + ) error { + startTime := time.Now() + err := handler(srv, ss) + duration := time.Since(startTime) + logCall(path.Base(info.FullMethod), duration, err) + return err + } +} + +func panicRecoveryHandler(p any) error { + stack := debug.Stack() + slog.Error( + "recovery from panic", + slog.Any("panic", fmt.Sprintf("%v", p)), + slog.String("stack", string(stack)), + ) + return NewGrpcError(errors.Errorf("panic: %v", p)) +} + +func logCall(method string, duration time.Duration, err error) { + statusCode := status.Code(err) + attrs := []any{ + slog.String("method", method), + slog.Int("duration_ms", int(duration.Milliseconds())), + slog.String("status_code", statusCode.String()), + } + + if err != nil { + attrs = append(attrs, slog.Any("error", err)) + slog.Error("gRPC call failed", attrs...) + } else { + slog.Debug("gRPC call succeeded", attrs...) } } diff --git a/libwallet/presentation/muun_key_verification_test.go b/libwallet/presentation/muun_key_verification_test.go index 690abea1..489a350a 100644 --- a/libwallet/presentation/muun_key_verification_test.go +++ b/libwallet/presentation/muun_key_verification_test.go @@ -5,13 +5,15 @@ import ( "context" "crypto/rand" "encoding/hex" - "fmt" "io" "net/http" "testing" "time" "github.com/btcsuite/btcd/btcec/v2" + "github.com/go-errors/errors" + "github.com/test-go/testify/assert" + "github.com/muun/libwallet" "github.com/muun/libwallet/domain/action/challenge_keys" "github.com/muun/libwallet/domain/action/recovery" @@ -20,7 +22,6 @@ import ( "github.com/muun/libwallet/recoverycode" "github.com/muun/libwallet/service/model" "github.com/muun/libwallet/storage" - "github.com/test-go/testify/assert" ) func TestEncryptedMuunKeyAfterFinishSetupRecoveryCode_Integration(t *testing.T) { @@ -45,7 +46,10 @@ func TestEncryptedMuunKeyAfterFinishSetupRecoveryCode_Integration(t *testing.T) recoveryCodePublicKey := recoveryCodePrivateKey.PubKey() - createFirstSessionOkJson := createFirstSession(t, userPrivateKey.PublicKey()) + createFirstSessionOkJson := createFirstSession( //nolint:staticcheck // TODO: var createFirstSessionOkJson should be createFirstSessionOkJSON + t, + userPrivateKey.PublicKey(), + ) muunPublicKey, err := libwallet.NewHDPublicKeyFromString( createFirstSessionOkJson.CosigningPublicKey.Key, createFirstSessionOkJson.CosigningPublicKey.Path, @@ -132,7 +136,10 @@ func TestPollForVerifiedEncryptedMuunKey_Integration(t *testing.T) { recoveryCodePublicKey := recoveryCodePrivateKey.PubKey() - createFirstSessionOkJson := createFirstSession(t, userPrivateKey.PublicKey()) + createFirstSessionOkJson := createFirstSession( //nolint:staticcheck // TODO: var createFirstSessionOkJson should be createFirstSessionOkJSON + t, + userPrivateKey.PublicKey(), + ) muunPublicKey, err := libwallet.NewHDPublicKeyFromString( createFirstSessionOkJson.CosigningPublicKey.Key, createFirstSessionOkJson.CosigningPublicKey.Path, @@ -233,7 +240,8 @@ func TestPollForVerifiedEncryptedMuunKey_Integration(t *testing.T) { } // Polling should not modify the encrypted muun key - if *encryptedMuunKeyWithStatus.EncryptedMuunKey != *encryptedMuunKeyWithStatusAgain.EncryptedMuunKey { + if *encryptedMuunKeyWithStatus.EncryptedMuunKey != + *encryptedMuunKeyWithStatusAgain.EncryptedMuunKey { t.Fatal("The verified muun key should not change when polling") } @@ -267,7 +275,10 @@ func TestPollForVerifiedEncryptedMuunKeyWithDelay_Integration(t *testing.T) { recoveryCodePublicKey := recoveryCodePrivateKey.PubKey() - createFirstSessionOkJson := createFirstSession(t, userPrivateKey.PublicKey()) + createFirstSessionOkJson := createFirstSession( //nolint:staticcheck // TODO: var createFirstSessionOkJson should be createFirstSessionOkJSON + t, + userPrivateKey.PublicKey(), + ) muunPublicKey, err := libwallet.NewHDPublicKeyFromString( createFirstSessionOkJson.CosigningPublicKey.Key, createFirstSessionOkJson.CosigningPublicKey.Path, @@ -319,7 +330,9 @@ func TestPollForVerifiedEncryptedMuunKeyWithDelay_Integration(t *testing.T) { t.Fatal(err) } - mayRetrieveEncryptedMuunKey := recovery.NewMayRetrieveEncryptedMuunKeyAction(walletServer.keyValueStorage) + mayRetrieveEncryptedMuunKey := recovery.NewMayRetrieveEncryptedMuunKeyAction( + walletServer.keyValueStorage, + ) encryptedMuunKeyWithStatus, err := mayRetrieveEncryptedMuunKey.Run() if err != nil { t.Fatal(err) @@ -351,7 +364,8 @@ func TestPollForVerifiedEncryptedMuunKeyWithDelay_Integration(t *testing.T) { } // Polling should not modify the encrypted muun key - if *encryptedMuunKeyWithStatus.EncryptedMuunKey != *encryptedMuunKeyWithStatusAgain.EncryptedMuunKey { + if *encryptedMuunKeyWithStatus.EncryptedMuunKey != + *encryptedMuunKeyWithStatusAgain.EncryptedMuunKey { t.Fatal("The unverified muun key should not change when polling") } @@ -385,7 +399,10 @@ func TestVerifiedMuunKeyForExistingUsers_Integration(t *testing.T) { recoveryCodePublicKey := recoveryCodePrivateKey.PubKey() - createFirstSessionOkJson := createFirstSession(t, userPrivateKey.PublicKey()) + createFirstSessionOkJson := createFirstSession( //nolint:staticcheck // TODO: var createFirstSessionOkJson should be createFirstSessionOkJSON + t, + userPrivateKey.PublicKey(), + ) muunPublicKey, err := libwallet.NewHDPublicKeyFromString( createFirstSessionOkJson.CosigningPublicKey.Key, createFirstSessionOkJson.CosigningPublicKey.Path, @@ -507,7 +524,10 @@ func TestUnverifiedEncryptedMuunKeyForExistingUsers_Integration(t *testing.T) { recoveryCodePublicKey := recoveryCodePrivateKey.PubKey() - createFirstSessionOkJson := createFirstSession(t, userPrivateKey.PublicKey()) + createFirstSessionOkJson := createFirstSession( //nolint:staticcheck // TODO: var createFirstSessionOkJson should be createFirstSessionOkJSON + t, + userPrivateKey.PublicKey(), + ) muunPublicKey, err := libwallet.NewHDPublicKeyFromString( createFirstSessionOkJson.CosigningPublicKey.Key, createFirstSessionOkJson.CosigningPublicKey.Path, @@ -661,26 +681,30 @@ func WaitForCondition(timeout time.Duration, condition func() (bool, error)) err } if err != nil { - return fmt.Errorf("timed out waiting for condition, failed with error %w", err) + return errors.Errorf("timed out waiting for condition, failed with error %w", err) } else { - return fmt.Errorf("timed out waiting for condition") + return errors.Errorf("timed out waiting for condition") } } // 127.0.0.1 instead of localhost to avoid problems with network interfaces in local env -const proverUrl = "http://127.0.0.1:8130" +const proverUrl = "http://127.0.0.1:8130" //nolint:staticcheck // TODO: const proverUrl should be proverURL func delayProverJob(t *testing.T, recoveryCodePublicKey *btcec.PublicKey) { // This requests prevents the proof from completing, this exercising the unhappy path - requestUrl := proverUrl + "/testing/delay-job?pattern=" + hex.EncodeToString( + requestUrl := proverUrl + "/testing/delay-job?pattern=" + hex.EncodeToString( //nolint:staticcheck // TODO: var requestUrl should be requestURL recoveryCodePublicKey.SerializeCompressed(), ) - req, err := http.NewRequest("POST", requestUrl, nil) + req, err := http.NewRequest( //nolint:noctx // TODO: use http.NewRequestWithContext + "POST", + requestUrl, + nil, + ) if err != nil { t.Fatal(err) } - resp, err := http.DefaultClient.Do(req) + resp, err := http.DefaultClient.Do(req) //nolint:bodyclose // TODO: close response body if err != nil { t.Fatal(err) } diff --git a/libwallet/presentation/wallet_server.go b/libwallet/presentation/wallet_server.go index 4583f1ec..e6b8e653 100644 --- a/libwallet/presentation/wallet_server.go +++ b/libwallet/presentation/wallet_server.go @@ -5,30 +5,36 @@ import ( "context" "encoding/hex" "errors" - "fmt" - "github.com/muun/libwallet/domain/action/emergency_kit" "log/slog" + "time" "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil" - "github.com/btcsuite/btcd/btcutil/base58" + goerr "github.com/go-errors/errors" "github.com/google/uuid" + "google.golang.org/grpc" + "google.golang.org/protobuf/types/known/emptypb" + "github.com/muun/libwallet" "github.com/muun/libwallet/app_provided_data" "github.com/muun/libwallet/data/keys" + "github.com/muun/libwallet/data/securekv" "github.com/muun/libwallet/domain/action/challenge_keys" + "github.com/muun/libwallet/domain/action/debug" "github.com/muun/libwallet/domain/action/diagnostic_mode_reports" + "github.com/muun/libwallet/domain/action/emergency_kit" "github.com/muun/libwallet/domain/action/nfc" "github.com/muun/libwallet/domain/action/recovery" + "github.com/muun/libwallet/domain/action/reset" "github.com/muun/libwallet/domain/action/security_cards_marketplace" "github.com/muun/libwallet/domain/diagnostic_mode" + security_cards_marketplace_model "github.com/muun/libwallet/domain/model/security_cards_marketplace" apierrors "github.com/muun/libwallet/errors" + "github.com/muun/libwallet/platform/preconditions" "github.com/muun/libwallet/presentation/api" "github.com/muun/libwallet/service" "github.com/muun/libwallet/service/model" "github.com/muun/libwallet/storage" - "google.golang.org/grpc" - "google.golang.org/protobuf/types/known/emptypb" ) type WalletServer struct { @@ -38,6 +44,7 @@ type WalletServer struct { network *libwallet.Network houstonService service.HoustonService keyValueStorage *storage.KeyValueStorage + resetData reset.ResetDataAction startChallengeSetup *challenge_keys.StartChallengeSetupAction finishChallengeSetup *challenge_keys.FinishChallengeSetupAction populateEncryptedMuunKey *recovery.PopulateEncryptedMuunKeyAction @@ -45,13 +52,14 @@ type WalletServer struct { submitDiagnostic *diagnostic_mode_reports.SubmitDiagnosticAction buildSweepTx *recovery.BuildSweepTxAction signSweepTx *recovery.SignSweepTxAction - pairSecurityCard *nfc.PairSecurityCardAction - resetSecurityCard *nfc.ResetSecurityCardAction - signMessageSecurityCard *nfc.SignMessageSecurityCardAction pairSecurityCardV2 *nfc.PairSecurityCardActionV2 signMessageSecurityCardV2 *nfc.SignMessageSecurityCardActionV2 + pairRequestChallenge *nfc.PairRequestChallengeAction + pairSignAndSubmitChallenge *nfc.PairSignAndSubmitChallengeAction getSecurityCardsMarketplace *security_cards_marketplace.GetSecurityCardsMarketplaceAction generateEmergencyKitPDF *emergency_kit.GenerateEmergencyKitPDFAction + zipDataDir *debug.ZipDataDirAction + secureKeyValueStorage securekv.SecureKeyValueStorage } func NewWalletServer( @@ -60,20 +68,22 @@ func NewWalletServer( network *libwallet.Network, houstonService service.HoustonService, keyValueStorage *storage.KeyValueStorage, + resetData reset.ResetDataAction, startChallengeSetup *challenge_keys.StartChallengeSetupAction, finishChallengeSetup *challenge_keys.FinishChallengeSetupAction, obtainVerifiedEncryptedMuunKeyIfAbsent *recovery.PopulateEncryptedMuunKeyAction, scanForFunds *recovery.ScanForFundsAction, submitDiagnostic *diagnostic_mode_reports.SubmitDiagnosticAction, buildSweepTx *recovery.BuildSweepTxAction, - signSweepTxAction *recovery.SignSweepTxAction, - pairSecurityCard *nfc.PairSecurityCardAction, - resetSecurityCard *nfc.ResetSecurityCardAction, - signMessageSecurityCard *nfc.SignMessageSecurityCardAction, + signSweepTx *recovery.SignSweepTxAction, pairSecurityCardV2 *nfc.PairSecurityCardActionV2, signMessageSecurityCardV2 *nfc.SignMessageSecurityCardActionV2, + pairRequestChallenge *nfc.PairRequestChallengeAction, + pairSignAndSubmitChallenge *nfc.PairSignAndSubmitChallengeAction, getSecurityCardsMarketplace *security_cards_marketplace.GetSecurityCardsMarketplaceAction, generateEmergencyKitPDF *emergency_kit.GenerateEmergencyKitPDFAction, + zipDataDir *debug.ZipDataDirAction, + secureKeyValueStorage securekv.SecureKeyValueStorage, ) *WalletServer { return &WalletServer{ @@ -82,45 +92,31 @@ func NewWalletServer( network: network, houstonService: houstonService, keyValueStorage: keyValueStorage, + resetData: resetData, startChallengeSetup: startChallengeSetup, finishChallengeSetup: finishChallengeSetup, populateEncryptedMuunKey: obtainVerifiedEncryptedMuunKeyIfAbsent, scanForFunds: scanForFunds, submitDiagnostic: submitDiagnostic, buildSweepTx: buildSweepTx, - signSweepTx: signSweepTxAction, - pairSecurityCard: pairSecurityCard, - resetSecurityCard: resetSecurityCard, - signMessageSecurityCard: signMessageSecurityCard, + signSweepTx: signSweepTx, pairSecurityCardV2: pairSecurityCardV2, signMessageSecurityCardV2: signMessageSecurityCardV2, + pairRequestChallenge: pairRequestChallenge, + pairSignAndSubmitChallenge: pairSignAndSubmitChallenge, getSecurityCardsMarketplace: getSecurityCardsMarketplace, generateEmergencyKitPDF: generateEmergencyKitPDF, + zipDataDir: zipDataDir, + secureKeyValueStorage: secureKeyValueStorage, } } // Check we actually implement the interface var _ api.WalletServiceServer = (*WalletServer)(nil) -func (ws WalletServer) SetupSecurityCard( - ctx context.Context, - message *emptypb.Empty, -) (*api.XpubResponse, error) { - extendedPublicKey, err := ws.pairSecurityCard.Run() - if err != nil { - return nil, fmt.Errorf("error pairing security card: %w", err) - } - - base58Xpub := base58.Encode(extendedPublicKey.RawBytes) - - return api.XpubResponse_builder{ - Base58Xpub: base58Xpub, - }.Build(), nil -} - func (ws WalletServer) SetupSecurityCardV2( - ctx context.Context, - message *emptypb.Empty, + ctx context.Context, //nolint:revive // TODO: use or remove ctx + message *emptypb.Empty, //nolint:revive // TODO: use or remove message ) (*api.SetupSecurityCardResponse, error) { response, err := ws.pairSecurityCardV2.Run() if err != nil { @@ -146,39 +142,9 @@ func (ws WalletServer) SetupSecurityCardV2( }.Build(), nil } -func (ws WalletServer) ResetSecurityCard( - ctx context.Context, - message *emptypb.Empty, -) (*emptypb.Empty, error) { - err := ws.resetSecurityCard.Run() - if err != nil { - return nil, err - } - - return &emptypb.Empty{}, nil -} - -func (ws WalletServer) SignMessageSecurityCard( - ctx context.Context, - request *api.SignMessageSecurityCardRequest, -) (*api.SignMessageSecurityCardResponse, error) { - - signedMessage, err := ws.signMessageSecurityCard.Run(request.GetMessageHex()) - if err != nil { - return nil, err - } - - base58SignedMessage := base58.Encode(signedMessage.RawBytes) - - return api.SignMessageSecurityCardResponse_builder{ - SignedMessageHex: base58SignedMessage, - IsValidated: true, - }.Build(), nil -} - func (ws WalletServer) SignMessageSecurityCardV2( - ctx context.Context, - message *emptypb.Empty, + ctx context.Context, //nolint:revive // TODO: use or remove ctx + message *emptypb.Empty, //nolint:revive // TODO: use or remove message ) (*emptypb.Empty, error) { err := ws.signMessageSecurityCardV2.Run() if err != nil { @@ -206,11 +172,70 @@ func (ws WalletServer) SignMessageSecurityCardV2( return &emptypb.Empty{}, nil } +func (ws WalletServer) PairRequestChallenge( + _ context.Context, + _ *emptypb.Empty, +) (*emptypb.Empty, error) { + err := ws.pairRequestChallenge.Run() + if err != nil { + return nil, NewGrpcErrorFromCodeAndErr(apierrors.ErrorCodes.ErrPairInternalError, err) + } + return &emptypb.Empty{}, nil +} + +func (ws WalletServer) PairSignAndSubmitChallenge( + _ *emptypb.Empty, + g grpc.ServerStreamingServer[api.PairSignAndSubmitChallengeProgress], +) error { + cardPaired, err := ws.pairSignAndSubmitChallenge.Run(func(progress nfc.PairProgress) error { + switch progress { + case nfc.PairProgressRefreshingChallenge: + return g.Send(api.PairSignAndSubmitChallengeProgress_builder{ + RefreshingChallenge: &api.RefreshingChallenge{}, + }.Build()) + case nfc.PairProgressChallengeSigned: + // The user can stop holding the card (or whatever device) near to the phone + return g.Send(api.PairSignAndSubmitChallengeProgress_builder{ + ChallengeSigned: &api.ChallengeSigned{}, + }.Build()) + default: + slog.Error("PairProgress value was not handled") + } + return nil + }) + + if err != nil { + var noSlotsAvailableErr *nfc.NoSlotsAvailableError + var muunAppletNotFoundErr *nfc.MuunAppletNotFoundError + var invalidMacErr *nfc.InvalidMacError + var challengeExpiredErr *nfc.ChallengeExpiredError + switch { + case errors.As(err, &noSlotsAvailableErr): + return NewGrpcErrorFromCodeAndErr(apierrors.ErrorCodes.ErrNoSlotsAvailable, err) + case errors.As(err, &muunAppletNotFoundErr): + return NewGrpcErrorFromCodeAndErr(apierrors.ErrorCodes.ErrAppletNotFound, err) + case errors.As(err, &invalidMacErr): + return NewGrpcErrorFromCodeAndErr(apierrors.ErrorCodes.ErrSignMacValidation, err) + case errors.As(err, &challengeExpiredErr): + return NewGrpcErrorFromCodeAndErr(apierrors.ErrorCodes.ErrChallengeExpired, err) + default: + return NewGrpcErrorFromCodeAndErr(apierrors.ErrorCodes.ErrPairInternalError, err) + } + } + + return g.Send(api.PairSignAndSubmitChallengeProgress_builder{ + Completed: api.PairSignAndSubmitChallengeResponse_builder{ + IsKnownProvider: cardPaired.IsKnownProvider, + IsCardAlreadyUsed: cardPaired.IsCardAlreadyUsed, + }.Build(), + }.Build()) +} + func (ws WalletServer) StartDiagnosticSession( - ctx context.Context, - empty *emptypb.Empty, + ctx context.Context, //nolint:revive // TODO: use or remove ctx + empty *emptypb.Empty, //nolint:revive // TODO: use or remove empty ) (*api.DiagnosticSessionDescriptor, error) { - sessionId := uuid.NewString() + sessionID := uuid.NewString() logBuffer := bytes.NewBuffer(nil) textHandler := slog.NewTextHandler(logBuffer, &slog.HandlerOptions{ @@ -219,7 +244,7 @@ func (ws WalletServer) StartDiagnosticSession( debugLog := slog.New(textHandler) err := diagnostic_mode.AddDiagnosticSession(&diagnostic_mode.DiagnosticSessionData{ - Id: sessionId, + Id: sessionID, LogBuffer: logBuffer, Logger: debugLog, }) @@ -227,7 +252,7 @@ func (ws WalletServer) StartDiagnosticSession( return nil, err } return api.DiagnosticSessionDescriptor_builder{ - SessionId: sessionId, + SessionId: sessionID, }.Build(), nil } @@ -235,12 +260,12 @@ func (ws WalletServer) PerformDiagnosticScanForUtxos( descriptor *api.DiagnosticSessionDescriptor, g grpc.ServerStreamingServer[api.ScanProgressUpdate], ) error { - sessionId := descriptor.GetSessionId() + sessionID := descriptor.GetSessionId() - if sessionData, ok := diagnostic_mode.GetDiagnosticSession(sessionId); ok { + if sessionData, ok := diagnostic_mode.GetDiagnosticSession(sessionID); ok { reports, err := ws.scanForFunds.Run(sessionData.Logger) if err != nil { - return err + return NewGrpcError(goerr.Errorf("error scanning for funds: %w", err)) } for report := range reports { @@ -261,32 +286,35 @@ func (ws WalletServer) PerformDiagnosticScanForUtxos( }.Build(), }.Build()) } else { - return fmt.Errorf("invalid sessionId %s", descriptor.GetSessionId()) + return NewGrpcError(goerr.Errorf("invalid sessionID %s", descriptor.GetSessionId())) } } func (ws WalletServer) SubmitDiagnosticLog( - ctx context.Context, + ctx context.Context, //nolint:revive // TODO: use or remove ctx descriptor *api.DiagnosticSessionDescriptor, ) (*api.DiagnosticSubmitStatus, error) { - sessionId := descriptor.GetSessionId() - if session, ok := diagnostic_mode.GetDiagnosticSession(sessionId); ok { - err := ws.submitDiagnostic.Run(sessionId, session.LogBuffer.String()) + sessionID := descriptor.GetSessionId() + if session, ok := diagnostic_mode.GetDiagnosticSession(sessionID); ok { + err := ws.submitDiagnostic.Run(sessionID, session.LogBuffer.String()) if err != nil { return nil, err } - diagnostic_mode.DeleteDiagnosticSession(sessionId) + diagnostic_mode.DeleteDiagnosticSession(sessionID) return api.DiagnosticSubmitStatus_builder{ StatusCode: 200, StatusMessage: "OK", }.Build(), nil } else { - return nil, fmt.Errorf("invalid sessionId %s", descriptor.GetSessionId()) + return nil, goerr.Errorf("invalid sessionID %s", descriptor.GetSessionId()) } } -func (ws WalletServer) PrepareSweepTx(ctx context.Context, parameters *api.PrepareSweepTxRequest) (*api.PrepareSweepTxResponse, error) { +func (ws WalletServer) PrepareSweepTx( + ctx context.Context, //nolint:revive // TODO: use or remove ctx + parameters *api.PrepareSweepTxRequest, +) (*api.PrepareSweepTxResponse, error) { destinationAddressString := parameters.GetDestinationAddress() address, err := btcutil.DecodeAddress(destinationAddressString, ws.network.ToParams()) if err != nil { @@ -295,8 +323,8 @@ func (ws WalletServer) PrepareSweepTx(ctx context.Context, parameters *api.Prepa descriptor := parameters.GetSessionDescriptor() - sessionId := descriptor.GetSessionId() - if session, ok := diagnostic_mode.GetDiagnosticSession(sessionId); ok { + sessionID := descriptor.GetSessionId() + if session, ok := diagnostic_mode.GetDiagnosticSession(sessionID); ok { session.SweepTx, err = ws.buildSweepTx.Run( session.LastScanReport.UtxosFound, address, @@ -312,13 +340,16 @@ func (ws WalletServer) PrepareSweepTx(ctx context.Context, parameters *api.Prepa TxSizeInBytes: int64(session.SweepTx.SerializeSize()), }.Build(), nil } else { - return nil, fmt.Errorf("invalid sessionId %s", sessionId) + return nil, goerr.Errorf("invalid sessionID %s", sessionID) } } -func (ws WalletServer) SignAndBroadcastSweepTx(ctx context.Context, confirmation *api.SignAndBroadcastSweepTxRequest) (*api.SignAndBroadcastSweepTxResponse, error) { - sessionId := confirmation.GetSessionDescriptor().GetSessionId() - if session, ok := diagnostic_mode.GetDiagnosticSession(sessionId); ok { +func (ws WalletServer) SignAndBroadcastSweepTx( + ctx context.Context, //nolint:revive // TODO: use or remove ctx + confirmation *api.SignAndBroadcastSweepTxRequest, +) (*api.SignAndBroadcastSweepTxResponse, error) { + sessionID := confirmation.GetSessionDescriptor().GetSessionId() + if session, ok := diagnostic_mode.GetDiagnosticSession(sessionID); ok { signedTx, err := ws.signSweepTx.Run( session.LastScanReport.UtxosFound, session.SweepTx, @@ -334,9 +365,9 @@ func (ws WalletServer) SignAndBroadcastSweepTx(ctx context.Context, confirmation } txString := hex.EncodeToString(buf.Bytes()) - return nil, fmt.Errorf("signed tx %v but did not broadcast", txString) + return nil, goerr.Errorf("signed tx %v but did not broadcast", txString) } else { - return nil, fmt.Errorf("invalid sessionId %s", sessionId) + return nil, goerr.Errorf("invalid sessionID %s", sessionID) } } @@ -346,10 +377,10 @@ func (ws WalletServer) SignAndBroadcastSweepTx(ctx context.Context, confirmation // Future implementations should move native logic as much as possible to libwallet instead of // duplicating this pattern. func (ws WalletServer) StartChallengeSetup( - ctx context.Context, req *api.ChallengeSetupRequest, + ctx context.Context, req *api.ChallengeSetupRequest, //nolint:revive // TODO: use or remove ctx ) (*api.SetupChallengeResponse, error) { - challengeSetupJson := model.ChallengeSetupJson{ + challengeSetupJson := model.ChallengeSetupJson{ //nolint:staticcheck // TODO: var challengeSetupJson should be challengeSetupJSON Type: req.GetType(), PublicKey: req.GetPublicKey(), Salt: req.GetSalt(), @@ -357,9 +388,11 @@ func (ws WalletServer) StartChallengeSetup( Version: int(req.GetVersion()), } - setupChallengeResponseJson, err := ws.startChallengeSetup.Run(challengeSetupJson) + setupChallengeResponseJson, err := ws.startChallengeSetup.Run( //nolint:staticcheck // TODO: var setupChallengeResponseJson should be setupChallengeResponseJSON + challengeSetupJson, + ) if err != nil { - return nil, NewGrpcError(fmt.Errorf("failed to start challenge setup: %w", err)) + return nil, NewGrpcError(goerr.Errorf("failed to start challenge setup: %w", err)) } return api.SetupChallengeResponse_builder{ @@ -369,13 +402,13 @@ func (ws WalletServer) StartChallengeSetup( } func (ws WalletServer) FinishRecoveryCodeSetup( - ctx context.Context, + ctx context.Context, //nolint:revive // TODO: use or remove ctx req *api.FinishRecoveryCodeSetupRequest, ) (*emptypb.Empty, error) { recoveryCodePublicKey, err := hexToPublicKey(req.GetRecoveryCodePublicKeyHex()) if err != nil { - return nil, fmt.Errorf("error parsing recovery code public key: %w", err) + return nil, goerr.Errorf("error parsing recovery code public key: %w", err) } err = ws.finishChallengeSetup.Run(recoveryCodePublicKey) @@ -387,12 +420,12 @@ func (ws WalletServer) FinishRecoveryCodeSetup( } func (ws WalletServer) PopulateEncryptedMuunKey( - ctx context.Context, + ctx context.Context, //nolint:revive // TODO: use or remove ctx req *api.PopulateEncryptedMuunKeyRequest, ) (*emptypb.Empty, error) { recoveryCodePublicKey, err := hexToPublicKey(req.GetRecoveryCodePublicKeyHex()) if err != nil { - return nil, fmt.Errorf("error parsing recovery code public key: %w", err) + return nil, goerr.Errorf("error parsing recovery code public key: %w", err) } err = ws.populateEncryptedMuunKey.Run(recoveryCodePublicKey) @@ -423,12 +456,14 @@ func (ws WalletServer) Save(_ context.Context, req *api.SaveRequest) (*emptypb.E value, err := toAny(req.GetValue()) if err != nil { - return nil, NewGrpcError(fmt.Errorf("failed to convert proto Value to internal type: %w", err)) + return nil, NewGrpcError( + goerr.Errorf("failed to convert proto Value to internal type: %w", err), + ) } err = ws.keyValueStorage.Save(req.GetKey(), value) if err != nil { - return nil, NewGrpcError(fmt.Errorf("failed to save key with given data: %w", err)) + return nil, NewGrpcError(goerr.Errorf("failed to save key with given data: %w", err)) } return &emptypb.Empty{}, nil @@ -443,12 +478,12 @@ func (ws WalletServer) Get(_ context.Context, req *api.GetRequest) (*api.GetResp value, err := ws.keyValueStorage.Get(key) if err != nil { - return nil, NewGrpcError(fmt.Errorf("failed to get key: %w", err)) + return nil, NewGrpcError(goerr.Errorf("failed to get key: %w", err)) } protoValue, err := toProtoValue(value) if err != nil { - return nil, NewGrpcError(fmt.Errorf("failed to convert data to proto Value: %w", err)) + return nil, NewGrpcError(goerr.Errorf("failed to convert data to proto Value: %w", err)) } return api.GetResponse_builder{ @@ -463,30 +498,36 @@ func (ws WalletServer) Delete(_ context.Context, req *api.DeleteRequest) (*empty err := ws.keyValueStorage.Delete(req.GetKey()) if err != nil { - return nil, NewGrpcError(fmt.Errorf("failed to delete key: %w", err)) + return nil, NewGrpcError(goerr.Errorf("failed to delete key: %w", err)) } return &emptypb.Empty{}, nil } -func (ws WalletServer) SaveBatch(_ context.Context, req *api.SaveBatchRequest) (*emptypb.Empty, error) { +func (ws WalletServer) SaveBatch( + _ context.Context, + req *api.SaveBatchRequest, +) (*emptypb.Empty, error) { if req.GetItems() == nil { return nil, NewGrpcErrorFromCode(apierrors.ErrorCodes.ErrItemsEmpty) } items, err := toAnyMap(req.GetItems()) if err != nil { - return nil, NewGrpcError(fmt.Errorf("failed to convert proto Struct to map: %w", err)) + return nil, NewGrpcError(goerr.Errorf("failed to convert proto Struct to map: %w", err)) } err = ws.keyValueStorage.SaveBatch(items) if err != nil { - return nil, NewGrpcError(fmt.Errorf("failed to save batch with given data: %w", err)) + return nil, NewGrpcError(goerr.Errorf("failed to save batch with given data: %w", err)) } return &emptypb.Empty{}, nil } -func (ws WalletServer) GetBatch(_ context.Context, req *api.GetBatchRequest) (*api.GetBatchResponse, error) { +func (ws WalletServer) GetBatch( + _ context.Context, + req *api.GetBatchRequest, +) (*api.GetBatchResponse, error) { keys := req.GetKeys() if len(keys) == 0 { return nil, NewGrpcErrorFromCode(apierrors.ErrorCodes.ErrKeyEmpty) @@ -494,16 +535,16 @@ func (ws WalletServer) GetBatch(_ context.Context, req *api.GetBatchRequest) (*a items, err := ws.keyValueStorage.GetBatch(keys) if err != nil { - return nil, NewGrpcError(fmt.Errorf("failed to get batch with given keys: %w", err)) + return nil, NewGrpcError(goerr.Errorf("failed to get batch with given keys: %w", err)) } if len(items) == 0 { - return nil, NewGrpcError(fmt.Errorf("failed to found values for keys: %v", keys)) + return nil, NewGrpcError(goerr.Errorf("failed to found values for keys: %v", keys)) } protoItems, err := toProtoValueMap(items) if err != nil { - return nil, NewGrpcError(fmt.Errorf("failed to convert data to proto Struct: %w", err)) + return nil, NewGrpcError(goerr.Errorf("failed to convert data to proto Struct: %w", err)) } return api.GetBatchResponse_builder{ @@ -511,7 +552,10 @@ func (ws WalletServer) GetBatch(_ context.Context, req *api.GetBatchRequest) (*a }.Build(), nil } -func (ws WalletServer) GetByPrefix(_ context.Context, req *api.GetByPrefixRequest) (*api.GetBatchResponse, error) { +func (ws WalletServer) GetByPrefix( + _ context.Context, + req *api.GetByPrefixRequest, +) (*api.GetBatchResponse, error) { prefix := req.GetPrefix() if prefix == "" { return nil, NewGrpcErrorFromCode(apierrors.ErrorCodes.ErrKeyEmpty) @@ -519,12 +563,12 @@ func (ws WalletServer) GetByPrefix(_ context.Context, req *api.GetByPrefixReques items, err := ws.keyValueStorage.GetByPrefix(prefix) if err != nil { - return nil, NewGrpcError(fmt.Errorf("failed to get by prefix with given prefix: %w", err)) + return nil, NewGrpcError(goerr.Errorf("failed to get by prefix with given prefix: %w", err)) } protoItems, err := toProtoValueMap(items) if err != nil { - return nil, NewGrpcError(fmt.Errorf("failed to convert data to proto Struct: %w", err)) + return nil, NewGrpcError(goerr.Errorf("failed to convert data to proto Struct: %w", err)) } return api.GetBatchResponse_builder{ @@ -533,12 +577,14 @@ func (ws WalletServer) GetByPrefix(_ context.Context, req *api.GetByPrefixReques } func (ws WalletServer) GetSecurityCardsMarketplace( - ctx context.Context, req *emptypb.Empty, + ctx context.Context, req *emptypb.Empty, //nolint:revive // TODO: use or remove ctx ) (*api.GetSecurityCardsMarketplaceResponse, error) { marketplace, err := ws.getSecurityCardsMarketplace.Run() if err != nil { - return nil, NewGrpcError(fmt.Errorf("failed to get security cards marketplace data: %w", err)) + return nil, NewGrpcError( + goerr.Errorf("failed to get security cards marketplace data: %w", err), + ) } providers := make([]*api.SecurityCardsProvider, 0, len(marketplace.Providers)) @@ -548,27 +594,89 @@ func (ws WalletServer) GetSecurityCardsMarketplace( for _, securityCard := range provider.SecurityCards { securityCards = append(securityCards, api.SecurityCard_builder{ - Image: securityCard.Image, - Stock: securityCard.Stock, + Id: securityCard.Id, + AssetUrl: securityCard.AssetUrl, + Tag: securityCard.Tag, + SpecId: securityCard.SpecId, + CardCost: toProtoPriceInfo(securityCard.CardCost), + }.Build()) + } + + shippingPrices := make([]*api.ShippingPriceInfo, 0, len(provider.EstimatedShippingPrices)) + for _, shippingPrice := range provider.EstimatedShippingPrices { + + countries := make([]*api.CountryInfo, 0, len(shippingPrice.Countries)) + for _, country := range shippingPrice.Countries { + countries = append(countries, api.CountryInfo_builder{ + Code: country.Code, + Name: country.Name, + Flag: country.Flag, + }.Build()) + } + + shippingPrices = append(shippingPrices, api.ShippingPriceInfo_builder{ + Price: toProtoPriceInfo(shippingPrice.Price), + Countries: countries, }.Build()) } providers = append(providers, api.SecurityCardsProvider_builder{ - Name: provider.Name, - SecurityCards: securityCards, - Currency: provider.CurrencyCode, - ColorHex: provider.ColorHex, - Material: provider.Material, - Price: provider.Price, - ShippingCost: provider.ShippingCost, + Id: provider.Id, + Name: provider.Name, + Description: provider.Description, + SiteUrl: provider.SiteUrl, + LightTheme: toProtoProviderTheme(provider.LightTheme), + DarkTheme: toProtoProviderTheme(provider.DarkTheme), + SecurityCards: securityCards, + EstimatedShippingPrices: shippingPrices, + }.Build()) + } + + specs := make([]*api.SecurityCardSpec, 0, len(marketplace.Specs)) + for _, spec := range marketplace.Specs { + + items := make(map[string]*api.SpecsItemList, len(spec.Items)) + for locale, specItems := range spec.Items { + protoItems := make([]*api.SpecsItem, 0, len(specItems)) + for _, item := range specItems { + protoItems = append(protoItems, api.SpecsItem_builder{ + IconUrl: item.IconUrl, + Label: item.Label, + Value: item.Value, + AdditionalData: item.AdditionalData, + }.Build()) + } + items[locale] = api.SpecsItemList_builder{Items: protoItems}.Build() + } + + specs = append(specs, api.SecurityCardSpec_builder{ + SpecId: spec.SpecId, + Items: items, }.Build()) } return api.GetSecurityCardsMarketplaceResponse_builder{ Providers: providers, + Specs: specs, }.Build(), nil } +func toProtoPriceInfo(in security_cards_marketplace_model.Price) *api.PriceInfo { + return api.PriceInfo_builder{ + CurrencyCode: in.CurrencyCode, + Amount: in.Amount, + }.Build() +} + +func toProtoProviderTheme( + in security_cards_marketplace_model.ProviderTheme, +) *api.SecurityCardsProviderTheme { + return api.SecurityCardsProviderTheme_builder{ + PrimaryColor: in.PrimaryColor, + SurfaceColor: in.SurfaceColor, + }.Build() +} + func toAny(protoValue *api.Value) (any, error) { switch protoValue.WhichKind() { case api.Value_NullValue_case: @@ -584,7 +692,7 @@ func toAny(protoValue *api.Value) (any, error) { case api.Value_BoolValue_case: return protoValue.GetBoolValue(), nil default: - return nil, fmt.Errorf("invalid value kind: %s", protoValue.WhichKind().String()) + return nil, goerr.Errorf("invalid value kind: %s", protoValue.WhichKind().String()) } } @@ -610,14 +718,14 @@ func toProtoValue(value any) (*api.Value, error) { protoValue.SetBoolValue(v) return protoValue, nil default: - return nil, fmt.Errorf("unknown type %T", v) + return nil, goerr.Errorf("unknown type %T", v) } } func toAnyMap(protoItems *api.Struct) (map[string]any, error) { protoValues := protoItems.GetFields() if protoValues == nil { - return nil, fmt.Errorf("proto values are required") + return nil, goerr.Errorf("proto values are required") } items := make(map[string]any, len(protoValues)) for key, value := range protoValues { @@ -632,7 +740,7 @@ func toAnyMap(protoItems *api.Struct) (map[string]any, error) { func toProtoValueMap(items map[string]any) (*api.Struct, error) { if items == nil { - return nil, fmt.Errorf("items are required") + return nil, goerr.Errorf("items are required") } protoItems := make(map[string]*api.Value, len(items)) for key, value := range items { @@ -645,13 +753,25 @@ func toProtoValueMap(items map[string]any) (*api.Struct, error) { return api.Struct_builder{Fields: protoItems}.Build(), nil } -// GenerateEmergencyKitPDF outputPath must be the full path where the PDF should be saved (including filename). -// Example: "/path/to/documents/expected_kit_name.pdf" -// The directory will be created if it doesn't exist. +func (ws WalletServer) ResetData( + _ context.Context, + _ *emptypb.Empty, +) (*emptypb.Empty, error) { + err := ws.resetData.Run() + if err != nil { + return nil, NewGrpcError(goerr.Errorf("failed to reset database: %w", err)) + } + return &emptypb.Empty{}, nil +} + +// GenerateEmergencyKitPDF outputPath must be the full path where the PDF should be saved (including +// filename). Example: "/path/to/documents/expected_kit_name.pdf" The directory will be created if +// it doesn't exist. func (ws WalletServer) GenerateEmergencyKitPDF( - ctx context.Context, + ctx context.Context, //nolint:revive // TODO: use or remove ctx request *api.GenerateEmergencyKitPDFRequest, ) (*api.GenerateEmergencyKitPDFResponse, error) { + startGo := time.Now() ekInput := request.GetEkInput() ekParams := &libwallet.EKInput{ FirstEncryptedKey: ekInput.GetFirstEncryptedKey(), @@ -667,11 +787,120 @@ func (ws WalletServer) GenerateEmergencyKitPDF( request.GetLanguage(), ) if err != nil { - return nil, NewGrpcError(fmt.Errorf("failed to generate emergency kit PDF: %w", err)) + return nil, NewGrpcError(goerr.Errorf("failed to generate emergency kit PDF: %w", err)) } + profiling := result.Profiling return api.GenerateEmergencyKitPDFResponse_builder{ VerificationCode: result.VerificationCode, Version: int32(result.Version), + Profiling: api.RenderProfiling_builder{ + LoadTranslationsMs: profiling.LoadTranslationsMs, + RegisterFontsMs: profiling.RegisterFontsMs, + RegisterImagesMs: profiling.RegisterImagesMs, + ComponentsRenderingMs: profiling.ComponentsRenderingMs, + CreateAndSaveOnDiskMs: profiling.CreateAndSaveOnDiskMs, + TotalHeapAllocatedBytes: profiling.TotalHeapAllocatedBytes, + TotalObjectsAllocated: profiling.TotalObjectsAllocated, + EmbedMetadataMs: profiling.EmbedMetadataMs, + TotalInsideGoMs: time.Since(startGo).Milliseconds(), + }.Build(), }.Build(), nil } + +func (ws WalletServer) ZipDataDir( + _ context.Context, + req *api.ZipDataDirRequest, +) (*emptypb.Empty, error) { + err := ws.zipDataDir.Run(req.GetOutputPath()) + if err != nil { + return nil, NewGrpcError(goerr.Errorf("failed to zip data directory: %w", err)) + } + return &emptypb.Empty{}, nil +} + +func (ws WalletServer) SecureKeyValueStoragePut( + ctx context.Context, + request *api.SecureKeyValueStoragePutRequest, +) (*emptypb.Empty, error) { + preconditions.CheckStatef( + ws.secureKeyValueStorage != nil, + "secure key-value storage bridge not configured", + ) + err := ws.secureKeyValueStorage.Put(ctx, request.GetKey(), request.GetValue()) + if err != nil { + return nil, NewGrpcErrorFromCodeAndErr(apierrors.ErrorCodes.ErrSecureKvStorageFailed, err) + } + return &emptypb.Empty{}, nil +} + +func (ws WalletServer) SecureKeyValueStorageDelete( + ctx context.Context, + request *api.SecureKeyValueStorageDeleteRequest, +) (*emptypb.Empty, error) { + preconditions.CheckStatef( + ws.secureKeyValueStorage != nil, + "secure key-value storage bridge not configured", + ) + err := ws.secureKeyValueStorage.Delete(ctx, request.GetKey()) + if err != nil { + return nil, NewGrpcErrorFromCodeAndErr(apierrors.ErrorCodes.ErrSecureKvStorageFailed, err) + } + return &emptypb.Empty{}, nil +} + +func (ws WalletServer) SecureKeyValueStorageGet( + ctx context.Context, + request *api.SecureKeyValueStorageGetRequest, +) (*api.SecureKeyValueStorageGetResponse, error) { + preconditions.CheckStatef( + ws.secureKeyValueStorage != nil, + "secure key-value storage bridge not configured", + ) + secret, err := ws.secureKeyValueStorage.Get(ctx, request.GetKey()) + if err != nil { + return nil, NewGrpcErrorFromCodeAndErr(apierrors.ErrorCodes.ErrSecureKvStorageFailed, err) + } + + var response *api.SecureKeyValueStorageGetResponse + err = secret.WithSecret(func(b []byte) error { + response = api.SecureKeyValueStorageGetResponse_builder{ + Value: append([]byte(nil), b...), + }.Build() + return nil + }) + if err != nil { + var notFound *securekv.NotFoundError + var decryptionFailed *securekv.DecryptionFailedError + switch { + case errors.As(err, ¬Found): + return nil, NewGrpcErrorFromCodeAndErr(apierrors.ErrorCodes.ErrSecureKvNotFound, err) + case errors.As(err, &decryptionFailed): + return nil, NewGrpcErrorFromCodeAndErr( + apierrors.ErrorCodes.ErrSecureKvDecryptionFailed, + err, + ) + default: + return nil, NewGrpcErrorFromCodeAndErr( + apierrors.ErrorCodes.ErrSecureKvStorageFailed, + err, + ) + } + } + return response, nil +} + +func (ws WalletServer) SecureKeyValueStorageWipe( + ctx context.Context, + _ *emptypb.Empty, +) (*emptypb.Empty, error) { + preconditions.CheckStatef( + ws.secureKeyValueStorage != nil, + "secure key-value storage bridge not configured", + ) + err := ws.secureKeyValueStorage.Wipe(ctx) + if err != nil { + return nil, NewGrpcErrorFromCodeAndErr(apierrors.ErrorCodes.ErrSecureKvStorageFailed, err) + } + return &emptypb.Empty{}, nil +} diff --git a/libwallet/presentation/wallet_server_test.go b/libwallet/presentation/wallet_server_test.go index 8284dca6..07a7b15a 100644 --- a/libwallet/presentation/wallet_server_test.go +++ b/libwallet/presentation/wallet_server_test.go @@ -3,8 +3,6 @@ package presentation import ( "context" "encoding/hex" - "errors" - "fmt" "log" "net" "os" @@ -14,31 +12,35 @@ import ( "testing" "time" - "github.com/grpc-ecosystem/go-grpc-middleware" - "github.com/muun/libwallet/data/keys" - "github.com/muun/libwallet/domain/action/challenge_keys" - "github.com/muun/libwallet/domain/action/recovery" + goerr "github.com/go-errors/errors" + grpc_middleware "github.com/grpc-ecosystem/go-grpc-middleware" "google.golang.org/grpc/codes" "google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/resolver" "google.golang.org/grpc/status" + "google.golang.org/grpc" + "google.golang.org/grpc/test/bufconn" + "github.com/muun/libwallet" + "github.com/muun/libwallet/data/keys" + "github.com/muun/libwallet/domain/action/challenge_keys" + "github.com/muun/libwallet/domain/action/recovery" + "github.com/muun/libwallet/domain/action/reset" apierrors "github.com/muun/libwallet/errors" "github.com/muun/libwallet/presentation/api" "github.com/muun/libwallet/recoverycode" "github.com/muun/libwallet/service" "github.com/muun/libwallet/service/model" "github.com/muun/libwallet/storage" - "google.golang.org/grpc" - "google.golang.org/grpc/test/bufconn" + "github.com/muun/libwallet/walletdb" ) var bufconnListener *bufconn.Listener var walletServer = &WalletServer{} // 127.0.0.1 instead of localhost to avoid problems with network interfaces in local env -const houstonUrl string = "http://127.0.0.1:8080" +const houstonUrl string = "http://127.0.0.1:8080" //nolint:staticcheck // TODO: const houstonUrl should be houstonURL func defaultProvider() *service.TestProvider { return &service.TestProvider{ @@ -53,18 +55,27 @@ func defaultProvider() *service.TestProvider { func init() { walletServer.network = libwallet.Regtest() walletServer.houstonService = service.NewHoustonService(defaultProvider()) - walletServer.startChallengeSetup = challenge_keys.NewStartChallengeSetupAction(walletServer.houstonService) + walletServer.startChallengeSetup = challenge_keys.NewStartChallengeSetupAction( + walletServer.houstonService, + ) // Initialize grpc server of WalletService with bufconn bufconnListener = bufconn.Listen(1024 * 1024) - // Add our interceptor for panic in order to test it. + // Add our interceptors for panic/error recovery in order to test them. opts := []grpc.ServerOption{ grpc.UnaryInterceptor( grpc_middleware.ChainUnaryServer( - LoggingInterceptor(), - RecoverUnknownErrorInterceptor(), - RecoverPanicInterceptor(), + LoggingUnaryInterceptor(), + RecoverUnknownErrorUnaryInterceptor(), + RecoverPanicUnaryInterceptor(), + ), + ), + grpc.StreamInterceptor( + grpc_middleware.ChainStreamServer( + LoggingStreamInterceptor(), + RecoverUnknownErrorStreamInterceptor(), + RecoverPanicStreamInterceptor(), ), ), } @@ -111,7 +122,7 @@ func waitForHealthcheck() error { deadline := time.Now().Add(timeout) for { if time.Now().After(deadline) { - return fmt.Errorf("healthcheck failed after %s", timeout) + return goerr.Errorf("healthcheck failed after %s", timeout) } err := walletServer.houstonService.HealthCheck() @@ -284,7 +295,8 @@ func TestSaveAndGetAndDelete(t *testing.T) { t.Errorf("want %v, but got %v", wantMsg, gotMsg) } - wantDevMsg := "failed to save key with given data: classification not found for key: invalid-key" + wantDevMsg := "failed to save key with given data: " + + "classification not found for key: invalid-key" gotDevMsg := errorDetail.GetDeveloperMessage() if gotDevMsg != wantDevMsg { t.Errorf("want %v, but got %v", wantDevMsg, gotDevMsg) @@ -370,7 +382,13 @@ func TestSaveBatchAndGetBatch(t *testing.T) { // Create GetBatchRequest getBatchReq := api.GetBatchRequest_builder{ - Keys: []string{"primaryCurrency", "email", "isEmailVerified", "emergencyKitVersion", "gcmToken"}, + Keys: []string{ + "primaryCurrency", + "email", + "isEmailVerified", + "emergencyKitVersion", + "gcmToken", + }, }.Build() // Call grpc client with GetBatchRequest @@ -526,6 +544,70 @@ func TestGetByPrefix(t *testing.T) { }) } +func TestResetData(t *testing.T) { + t.Run("success when wiping all database entries", func(t *testing.T) { + setupKeyValueStorage(t, buildTestMigrationPlan()) + + conn, ctx := newGrpcClient(t) + defer conn.Close() + client := api.NewWalletServiceClient(conn) + + // Save several values of different types. + emergencyKitVersion := int32(7) + _, err := client.Save(ctx, api.SaveRequest_builder{ + Key: "emergencyKitVersion", + Value: api.Value_builder{IntValue: &emergencyKitVersion}.Build(), + }.Build()) + if err != nil { + failWithGrpcErrorDetails(t, err) + } + + gcmToken := "test-token" + _, err = client.Save(ctx, api.SaveRequest_builder{ + Key: "gcmToken", + Value: api.Value_builder{StringValue: &gcmToken}.Build(), + }.Build()) + if err != nil { + failWithGrpcErrorDetails(t, err) + } + + // Reset the database. + _, err = client.ResetData(ctx, nil) + if err != nil { + failWithGrpcErrorDetails(t, err) + } + + // All previously saved keys must return null after reset. + for _, key := range []string{"emergencyKitVersion", "gcmToken"} { + resp, err := client.Get(ctx, api.GetRequest_builder{Key: key}.Build()) + if err != nil { + failWithGrpcErrorDetails(t, err) + } + if !resp.GetValue().HasNullValue() { + t.Errorf("Get(%q) after ResetData: want null, got non-null", key) + } + } + + // DB must still accept writes after reset. + newVersion := int32(1) + _, err = client.Save(ctx, api.SaveRequest_builder{ + Key: "emergencyKitVersion", + Value: api.Value_builder{IntValue: &newVersion}.Build(), + }.Build()) + if err != nil { + failWithGrpcErrorDetails(t, err) + } + + resp, err := client.Get(ctx, api.GetRequest_builder{Key: "emergencyKitVersion"}.Build()) + if err != nil { + failWithGrpcErrorDetails(t, err) + } + if got := resp.GetValue().GetIntValue(); got != newVersion { + t.Errorf("Get() after ResetData + Save(): want %d, got %d", newVersion, got) + } + }) +} + func TestErrorInterceptors(t *testing.T) { t.Run("return internal error when rpc execution raises a panic", func(t *testing.T) { @@ -577,15 +659,15 @@ func TestErrorInterceptors(t *testing.T) { }) - t.Run("return internal error when intercepting a generic error", func(t *testing.T) { + t.Run("unary: return internal error when intercepting a generic error", func(t *testing.T) { // Create a generic error with fmt package wantDevMsg := "generic error for testing" - handler := func(ctx context.Context, req any) (any, error) { - return nil, errors.New(wantDevMsg) + handler := func(ctx context.Context, req any) (any, error) { //nolint:revive // TODO: use or remove ctx + return nil, goerr.New(wantDevMsg) } - interceptor := RecoverUnknownErrorInterceptor() + interceptor := RecoverUnknownErrorUnaryInterceptor() _, err := interceptor(context.Background(), nil, nil, handler) if err == nil { t.Fatalf("expect error") @@ -613,17 +695,17 @@ func TestErrorInterceptors(t *testing.T) { }) - t.Run("return internal error when intercepting unknown grpc error", func(t *testing.T) { + t.Run("unary: return internal error when intercepting unknown grpc error", func(t *testing.T) { // Create gRPC error with codes.Unknown errorMsg := "an unknown error for testing" unknownErrorStatus := status.New(codes.Unknown, errorMsg) - handler := func(ctx context.Context, req any) (any, error) { + handler := func(ctx context.Context, req any) (any, error) { //nolint:revive // TODO: use or remove ctx return nil, unknownErrorStatus.Err() } - interceptor := RecoverUnknownErrorInterceptor() + interceptor := RecoverUnknownErrorUnaryInterceptor() _, err := interceptor(context.Background(), nil, nil, handler) if err == nil { t.Fatalf("expect error") @@ -652,6 +734,79 @@ func TestErrorInterceptors(t *testing.T) { }) + t.Run("stream: return internal error when intercepting a generic error", func(t *testing.T) { + + wantDevMsg := "generic error for testing" + handler := func(_ any, _ grpc.ServerStream) error { + return goerr.New(wantDevMsg) + } + + interceptor := RecoverUnknownErrorStreamInterceptor() + err := interceptor(nil, nil, nil, handler) + if err == nil { + t.Fatalf("expect error") + } + + grpcStatus := status.Convert(err) + + // Verify we fail with codes.INTERNAL + if grpcStatus.Code() != codes.Internal { + t.Errorf("want %v, but got %v", codes.Internal, grpcStatus.Code()) + } + + // Verify we fail with error code ErrUnknown + wantCode := int64(apierrors.ErrorCodes.ErrUnknown.Code) + gotCode := getErrorDetail(t, grpcStatus).GetCode() + if gotCode != wantCode { + t.Errorf("want %v, but got %v", wantCode, gotCode) + } + + // Verify we fail catching the error message + got := getErrorDetail(t, grpcStatus).GetDeveloperMessage() + if got != wantDevMsg { + t.Errorf("want %v, but got %v", wantDevMsg, got) + } + + }) + + t.Run("stream: return internal error when intercepting unknown grpc error", func(t *testing.T) { + + errorMsg := "an unknown error for testing" + unknownErrorStatus := status.New(codes.Unknown, errorMsg) + + handler := func(_ any, _ grpc.ServerStream) error { + return unknownErrorStatus.Err() + } + + interceptor := RecoverUnknownErrorStreamInterceptor() + err := interceptor(nil, nil, nil, handler) + if err == nil { + t.Fatalf("expect error") + } + + grpcStatus := status.Convert(err) + + // Verify we fail with codes.INTERNAL + if grpcStatus.Code() != codes.Internal { + t.Errorf("want %v, but got %v", codes.Internal, grpcStatus.Code()) + } + + // Verify we fail with error code ErrUnknown + wantCode := int64(apierrors.ErrorCodes.ErrUnknown.Code) + gotCode := getErrorDetail(t, grpcStatus).GetCode() + if gotCode != wantCode { + t.Errorf("want %v, but got %v", wantCode, gotCode) + } + + // Verify we fail catching the original error message + wantDevMsg := "rpc error: code = Unknown desc = " + errorMsg + gotDevMsg := getErrorDetail(t, grpcStatus).GetDeveloperMessage() + if gotDevMsg != wantDevMsg { + t.Errorf("want %v, but got %v", wantDevMsg, gotDevMsg) + } + + }) + } func TestFinishRecoveryCodeSetupEndpoint_Integration(t *testing.T) { @@ -678,7 +833,10 @@ func TestFinishRecoveryCodeSetupEndpoint_Integration(t *testing.T) { recoveryCodePublicKey := recoveryCodePrivateKey.PubKey() - createFirstSessionOkJson := createFirstSession(t, userPrivateKey.PublicKey()) + createFirstSessionOkJson := createFirstSession( //nolint:staticcheck // TODO: var createFirstSessionOkJson should be createFirstSessionOkJSON + t, + userPrivateKey.PublicKey(), + ) muunPublicKey, err := libwallet.NewHDPublicKeyFromString( createFirstSessionOkJson.CosigningPublicKey.Key, createFirstSessionOkJson.CosigningPublicKey.Path, @@ -731,7 +889,7 @@ func createFirstSession(t *testing.T, key *libwallet.HDPublicKey) model.CreateFi if err != nil { t.Fatal(err) } - sessionJson := model.CreateFirstSessionJson{ + sessionJson := model.CreateFirstSessionJson{ //nolint:staticcheck // TODO: var sessionJson should be sessionJSON Client: model.ClientJson{ Type: provider.ClientType, BuildType: "debug", @@ -746,7 +904,9 @@ func createFirstSession(t *testing.T, key *libwallet.HDPublicKey) model.CreateFi Path: "m/schema:1'/recovery:1'", }, } - sessionOkJson, err := walletServer.houstonService.CreateFirstSession(sessionJson) + sessionOkJson, err := walletServer.houstonService.CreateFirstSession( //nolint:staticcheck // TODO: var sessionOkJson should be sessionOkJSON + sessionJson, + ) if err != nil { t.Fatal(err) } @@ -754,15 +914,21 @@ func createFirstSession(t *testing.T, key *libwallet.HDPublicKey) model.CreateFi } func setupKeyValueStorage(t *testing.T, migrationPlan []storage.Migration) { - // Create a new empty DB providing a new dataFilePath - dataFilePath := path.Join(t.TempDir(), "test.db") - schema, err := storage.RunKeyValueMigrations(dataFilePath, migrationPlan) + dbPath := path.Join(t.TempDir(), "test.db") + var schema map[string]storage.Classification + pool, err := walletdb.NewPool(dbPath, func(db *walletdb.DB) error { + var migErr error + schema, migErr = storage.RunKeyValueMigrations(db, migrationPlan) + return migErr + }) if err != nil { - t.Fatalf("failed to run KV migrations: %v", err) + t.Fatalf("failed to open db: %v", err) } - // For testing purpose, change reference to this new keyValueStorage - // in order to have a new empty DB - walletServer.keyValueStorage = storage.NewKeyValueStorage(dataFilePath, schema) + walletServer.keyValueStorage = storage.NewKeyValueStorage( + pool.NewKeyValueRepository(), + schema, + ) + walletServer.resetData = reset.NewResetDataAction(dbPath, pool, migrationPlan) } func newGrpcClient(t *testing.T) (*grpc.ClientConn, context.Context) { @@ -780,7 +946,7 @@ func newGrpcClient(t *testing.T) (*grpc.ClientConn, context.Context) { } func dialer() func(context.Context, string) (net.Conn, error) { - return func(ctx context.Context, s string) (net.Conn, error) { + return func(ctx context.Context, s string) (net.Conn, error) { //nolint:revive // TODO: use or remove ctx return bufconnListener.Dial() } } @@ -817,15 +983,69 @@ func failWithGrpcErrorDetails(t testing.TB, err error) { func buildTestMigrationPlan() []storage.Migration { return []storage.Migration{ {Description: "Schema for testing purpose", Changes: []storage.Change{ - storage.Define("email", storage.NoAutoBackup, storage.NotApplicable, false, &storage.StringType{}), - storage.Define("emergencyKitVersion", storage.NoAutoBackup, storage.NotApplicable, false, &storage.IntType{}), - storage.Define("gcmToken", storage.NoAutoBackup, storage.NotApplicable, false, &storage.StringType{}), - storage.Define("isEmailVerified", storage.NoAutoBackup, storage.NotApplicable, false, &storage.BoolType{}), - storage.Define("primaryCurrency", storage.NoAutoBackup, storage.NotApplicable, false, &storage.StringType{}), - storage.Define("featureFlag:useDiagnosticMode", storage.NoAutoBackup, storage.NotApplicable, false, &storage.BoolType{}), - storage.Define("featureFlag:isDogfood", storage.NoAutoBackup, storage.NotApplicable, false, &storage.BoolType{}), - storage.Define("featureFlag:supportsNfc", storage.NoAutoBackup, storage.NotApplicable, false, &storage.BoolType{}), - storage.Define("featureFlag:utxoSelectionStrategy", storage.NoAutoBackup, storage.NotApplicable, false, &storage.StringType{}), + storage.Define( + "email", + storage.NoAutoBackup, + storage.NotApplicable, + false, + &storage.StringType{}, + ), + storage.Define( + "emergencyKitVersion", + storage.NoAutoBackup, + storage.NotApplicable, + false, + &storage.IntType{}, + ), + storage.Define( + "gcmToken", + storage.NoAutoBackup, + storage.NotApplicable, + false, + &storage.StringType{}, + ), + storage.Define( + "isEmailVerified", + storage.NoAutoBackup, + storage.NotApplicable, + false, + &storage.BoolType{}, + ), + storage.Define( + "primaryCurrency", + storage.NoAutoBackup, + storage.NotApplicable, + false, + &storage.StringType{}, + ), + storage.Define( + "featureFlag:useDiagnosticMode", + storage.NoAutoBackup, + storage.NotApplicable, + false, + &storage.BoolType{}, + ), + storage.Define( + "featureFlag:isDogfood", + storage.NoAutoBackup, + storage.NotApplicable, + false, + &storage.BoolType{}, + ), + storage.Define( + "featureFlag:supportsNfc", + storage.NoAutoBackup, + storage.NotApplicable, + false, + &storage.BoolType{}, + ), + storage.Define( + "featureFlag:utxoSelectionStrategy", + storage.NoAutoBackup, + storage.NotApplicable, + false, + &storage.StringType{}, + ), }}, } } @@ -865,7 +1085,7 @@ func (m *mockKeyProvider) MaxDerivedIndex() int { } func (m *mockKeyProvider) EncryptedMuunPrivateKey() (*libwallet.EncryptedPrivateKeyInfo, error) { - return nil, errors.New("not implemented") + return nil, goerr.New("not implemented") } func (m *mockKeyProvider) SetMaxDerivedIndex(maxDerivedIndex int) { diff --git a/libwallet/publickey.go b/libwallet/publickey.go index 60340b52..6052a193 100644 --- a/libwallet/publickey.go +++ b/libwallet/publickey.go @@ -1,9 +1,8 @@ package libwallet import ( - "fmt" - "github.com/btcsuite/btcd/btcec/v2" + "github.com/go-errors/errors" ) type PublicKey struct { @@ -13,7 +12,7 @@ type PublicKey struct { func NewPublicKeyFromBytes(bytes []byte) (*PublicKey, error) { key, err := btcec.ParsePubKey(bytes) if err != nil { - return nil, fmt.Errorf("NewPublicKeyFromBytes: failed to parse pub key: %w", err) + return nil, errors.Errorf("NewPublicKeyFromBytes: failed to parse pub key: %w", err) } return &PublicKey{key}, nil diff --git a/libwallet/recoverycode/recoverycode.go b/libwallet/recoverycode/recoverycode.go index 99a26d2b..e5ce9e2e 100644 --- a/libwallet/recoverycode/recoverycode.go +++ b/libwallet/recoverycode/recoverycode.go @@ -5,14 +5,12 @@ import ( "crypto/rand" "crypto/sha256" "encoding/hex" - "errors" - "fmt" "math/big" "strings" - "golang.org/x/crypto/scrypt" - "github.com/btcsuite/btcd/btcec/v2" + "github.com/go-errors/errors" + "golang.org/x/crypto/scrypt" ) const ( @@ -44,7 +42,7 @@ func Generate() string { sb.WriteByte(Alphabet[CurrentVersion-2]) codeLen := 30 - for i := 0; i < codeLen; i++ { + for i := 0; i < codeLen; i++ { //nolint:modernize // TODO: use range over int sb.WriteByte(randChar(Alphabet)) j := i + 3 // we count the two bytes we wrote before the loop if j != 0 && i != codeLen-1 && j%4 == 0 { @@ -86,7 +84,7 @@ func ConvertToKey(code, salt string) (*btcec.PrivateKey, error) { case 1: saltBytes, err := hex.DecodeString(salt) if err != nil { - return nil, fmt.Errorf("failed to decode salt: %w", err) + return nil, errors.Errorf("failed to decode salt: %w", err) } input, err = scrypt.Key( @@ -120,7 +118,7 @@ func Validate(code string) error { // Version returns the version that this recovery code corresponds to. func Version(code string) (int, error) { if len(code) != 39 { // code contains 32 RC chars + 7 separator chars - return 0, fmt.Errorf("invalid recovery code length %v", len(code)) + return 0, errors.Errorf("invalid recovery code length %v", len(code)) } if code[0] == 'L' { // version 2+ codes always start with L idx := strings.IndexByte(Alphabet, code[1]) @@ -128,17 +126,17 @@ func Version(code string) (int, error) { return 0, errors.New("invalid recovery code version") } if !validateAlphabet(code, Alphabet) { - return 0, fmt.Errorf("invalid recovery code characters") + return 0, errors.Errorf("invalid recovery code characters") } // we add 2 to the idx because the first letter corresponds to code version 2 version := idx + 2 if version > CurrentVersion { - return 0, fmt.Errorf("unrecognized recovery code version: %d", version) + return 0, errors.Errorf("unrecognized recovery code version: %d", version) } return version, nil } if !validateAlphabet(code, AlphabetLegacy) { - return 0, fmt.Errorf("invalid recovery code characters") + return 0, errors.Errorf("invalid recovery code characters") } return 1, nil } diff --git a/libwallet/ripemd160_test.go b/libwallet/ripemd160_test.go index 27f1cd8b..0b7213e1 100644 --- a/libwallet/ripemd160_test.go +++ b/libwallet/ripemd160_test.go @@ -16,7 +16,28 @@ func Test_ripemd160(t *testing.T) { }{ {name: "simple", args: args{data: []byte{1, 2, 3}}, - want: []byte{121, 249, 1, 218, 38, 9, 240, 32, 173, 173, 191, 46, 95, 104, 161, 108, 140, 63, 125, 87}}, + want: []byte{ + 121, + 249, + 1, + 218, + 38, + 9, + 240, + 32, + 173, + 173, + 191, + 46, + 95, + 104, + 161, + 108, + 140, + 63, + 125, + 87, + }}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/libwallet/scanner/address_generator.go b/libwallet/scanner/address_generator.go index d799b04c..f198ddf1 100644 --- a/libwallet/scanner/address_generator.go +++ b/libwallet/scanner/address_generator.go @@ -2,8 +2,9 @@ package scanner import ( "fmt" - "github.com/muun/libwallet" "log/slog" + + "github.com/muun/libwallet" ) type AddressGenerator struct { @@ -13,7 +14,10 @@ type AddressGenerator struct { generateContacts bool } -func NewAddressGenerator(userKey, muunKey *libwallet.HDPublicKey, generateContacts bool) *AddressGenerator { +func NewAddressGenerator( + userKey, muunKey *libwallet.HDPublicKey, + generateContacts bool, +) *AddressGenerator { return &AddressGenerator{ addressCount: 0, userKey: userKey, @@ -34,7 +38,10 @@ func (g *AddressGenerator) Stream(countPerDerivationTree int64) chan libwallet.M return ch } -func (g *AddressGenerator) generate(consumer chan libwallet.MuunAddress, countPerDerivationTree int64) { +func (g *AddressGenerator) generate( + consumer chan libwallet.MuunAddress, + countPerDerivationTree int64, +) { g.generateChangeAddrs(consumer, countPerDerivationTree) g.generateExternalAddrs(consumer, countPerDerivationTree) if g.generateContacts { @@ -42,7 +49,10 @@ func (g *AddressGenerator) generate(consumer chan libwallet.MuunAddress, countPe } } -func (g *AddressGenerator) generateChangeAddrs(consumer chan libwallet.MuunAddress, countPerDerivationTree int64) { +func (g *AddressGenerator) generateChangeAddrs( + consumer chan libwallet.MuunAddress, + countPerDerivationTree int64, +) { const changePath = "m/1'/1'/0" changeUserKey, _ := g.userKey.DeriveTo(changePath) changeMuunKey, _ := g.muunKey.DeriveTo(changePath) @@ -50,7 +60,10 @@ func (g *AddressGenerator) generateChangeAddrs(consumer chan libwallet.MuunAddre g.deriveTree(consumer, changeUserKey, changeMuunKey, countPerDerivationTree, "change") } -func (g *AddressGenerator) generateExternalAddrs(consumer chan libwallet.MuunAddress, countPerDerivationTree int64) { +func (g *AddressGenerator) generateExternalAddrs( + consumer chan libwallet.MuunAddress, + countPerDerivationTree int64, +) { const externalPath = "m/1'/1'/1" externalUserKey, _ := g.userKey.DeriveTo(externalPath) externalMuunKey, _ := g.muunKey.DeriveTo(externalPath) @@ -58,7 +71,10 @@ func (g *AddressGenerator) generateExternalAddrs(consumer chan libwallet.MuunAdd g.deriveTree(consumer, externalUserKey, externalMuunKey, countPerDerivationTree, "external") } -func (g *AddressGenerator) generateContactAddrs(consumer chan libwallet.MuunAddress, numContacts int64) { +func (g *AddressGenerator) generateContactAddrs( + consumer chan libwallet.MuunAddress, + numContacts int64, +) { const addressPath = "m/1'/1'/2" contactUserKey, _ := g.userKey.DeriveTo(addressPath) contactMuunKey, _ := g.muunKey.DeriveTo(addressPath) diff --git a/libwallet/scanner/btc_core_helper_test.go b/libwallet/scanner/btc_core_helper_test.go index e6c9150c..abb4634f 100644 --- a/libwallet/scanner/btc_core_helper_test.go +++ b/libwallet/scanner/btc_core_helper_test.go @@ -3,12 +3,14 @@ package scanner import ( "encoding/json" "fmt" - "github.com/btcsuite/btcd/btcutil" - "github.com/btcsuite/btcd/chaincfg" - "github.com/btcsuite/btcd/rpcclient" + "strconv" "strings" "testing" "time" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/rpcclient" ) type AddressWithBalance struct { @@ -25,7 +27,10 @@ func (ws WalletState) getBalance(address string) btcutil.Amount { return ws.addressBalances[address] } -func getBitcoindRpcClient(t *testing.T, wallet string) *rpcclient.Client { +func getBitcoindRpcClient( //nolint:staticcheck // TODO: func getBitcoindRpcClient should be getBitcoindRPCClient + t *testing.T, + wallet string, +) *rpcclient.Client { const ( rpcHost = "localhost:38443" rpcUser = "user" @@ -54,7 +59,9 @@ func getBitcoindRpcClient(t *testing.T, wallet string) *rpcclient.Client { return client } -func mustMarshal(v interface{}) json.RawMessage { +func mustMarshal( + v interface{}, //nolint:modernize // TODO: use any instead of interface{} +) json.RawMessage { data, err := json.Marshal(v) if err != nil { panic(err) @@ -99,7 +106,7 @@ func rescanTheBlockchain(t *testing.T, rpc *rpcclient.Client) { t.Fatalf("Failed to get wallet info: %v", err) } - var info map[string]interface{} + var info map[string]interface{} //nolint:modernize // TODO: use any instead of interface{} if err := json.Unmarshal(result, &info); err != nil { t.Fatalf("Failed to unmarshal wallet info: %v", err) } @@ -116,7 +123,10 @@ func rescanTheBlockchain(t *testing.T, rpc *rpcclient.Client) { t.Fatalf("Rescan did not complete within %v", maxWait) } -func getWalletState(t *testing.T, walletRpc *rpcclient.Client) WalletState { +func getWalletState( + t *testing.T, + walletRpc *rpcclient.Client, //nolint:staticcheck // TODO: func parameter walletRpc should be walletRPC +) WalletState { const maxConfirmations = 9999999 unspentResult, err := walletRpc.RawRequest( @@ -127,7 +137,7 @@ func getWalletState(t *testing.T, walletRpc *rpcclient.Client) WalletState { t.Fatalf("Failed to list unspent outputs: %v", err) } - var unspents []map[string]interface{} + var unspents []map[string]interface{} //nolint:modernize // TODO: use any instead of interface{} if err := json.Unmarshal(unspentResult, &unspents); err != nil { t.Fatalf("Failed to unmarshal unspent outputs: %v", err) } @@ -156,7 +166,7 @@ func getWalletState(t *testing.T, walletRpc *rpcclient.Client) WalletState { func fundAddress( t *testing.T, - senderRpc *rpcclient.Client, + senderRpc *rpcclient.Client, //nolint:staticcheck // TODO: func parameter senderRpc should be senderRPC address string, amount btcutil.Amount, ) { @@ -164,11 +174,11 @@ func fundAddress( if err != nil { t.Fatalf("Failed to decode address %s: %v", address, err) } - txid, err := senderRpc.SendToAddress(decodedAddress, amount) + txID, err := senderRpc.SendToAddress(decodedAddress, amount) if err != nil { t.Fatalf("Failed to send %d to address %s: %v", amount, address, err) } - t.Logf("Funded %s with %d - txid: %s", address, amount, txid) + t.Logf("Funded %s with %d - txid: %s", address, amount, txID) } func generateBlock(t *testing.T, rpc *rpcclient.Client) { @@ -187,12 +197,12 @@ func generateBlock(t *testing.T, rpc *rpcclient.Client) { func checkFundsAdded( t *testing.T, - userWalletRpc *rpcclient.Client, + userWalletRpc *rpcclient.Client, //nolint:staticcheck // TODO: func parameter userWalletRpc should be userWalletRPC fundedAddresses []AddressWithBalance, walletStateBeforeFunding WalletState, ) { - // To get how much was added to an address we are comparing with the address balance - // before funding. + // To get how much was added to an address we are comparing with the address balance before + // funding. finalState := getWalletState(t, userWalletRpc) // Validate funding and calculate expected total @@ -225,8 +235,8 @@ func checkFundsAdded( func spendAllFundsFromUserWallet( t *testing.T, - userWalletRpc, - daemonRpc *rpcclient.Client, + userWalletRpc, //nolint:staticcheck // TODO: func parameter userWalletRpc should be userWalletRPC + daemonRpc *rpcclient.Client, //nolint:staticcheck // TODO: func parameter daemonRpc should be daemonRPC ) string { balancesBefore := getWalletState(t, userWalletRpc) @@ -240,7 +250,12 @@ func spendAllFundsFromUserWallet( } // Use PSBT for spending (required for MuSig2) - psbt := createFundedPSBT(t, userWalletRpc, destAddr.EncodeAddress(), balancesBefore.totalBalance) + psbt := createFundedPSBT( + t, + userWalletRpc, + destAddr.EncodeAddress(), + balancesBefore.totalBalance, + ) signedPsbt := signPSBT(t, userWalletRpc, psbt) txHex := finalizePSBT(t, userWalletRpc, signedPsbt) txid := broadcastTransaction(t, userWalletRpc, txHex) @@ -251,6 +266,127 @@ func spendAllFundsFromUserWallet( return txid } +// spendAllFundsWithTimelockSequence sweeps every UTXO back to the daemon, setting each input's +// nSequence to the given relative timelock. This is needed for relative-timelocked spends (e.g. the +// M3 non-collaborative path). +func spendAllFundsWithTimelockSequence( + t *testing.T, + userWalletRPC, daemonRPC *rpcclient.Client, + sequence int64, +) string { + utxos := listUnspent(t, userWalletRPC) + if len(utxos) == 0 { + t.Fatalf("No funds to spend") + } + + inputs := make([]map[string]any, 0, len(utxos)) + var total btcutil.Amount + for _, utxo := range utxos { + amount, err := btcutil.NewAmount(utxo.Amount) + if err != nil { + t.Fatalf("Failed to convert amount: %v", err) + } + total += amount + inputs = append(inputs, map[string]any{ + "txid": utxo.TxID, "vout": utxo.Vout, "sequence": sequence, + }) + } + + destAddr, err := daemonRPC.GetNewAddress("receive") + if err != nil { + t.Fatalf("Failed to get destination address: %v", err) + } + + const fee = btcutil.Amount(10000) + outAmount := total - fee + // Format to exactly 8 decimals so core's amount parser doesn't choke on float noise. + outValue := json.RawMessage(strconv.FormatFloat(outAmount.ToBTC(), 'f', 8, 64)) + outputs := map[string]json.RawMessage{destAddr.EncodeAddress(): outValue} + + rawTx := createRawTransaction(t, userWalletRPC, inputs, outputs) + psbt := convertToPSBT(t, userWalletRPC, rawTx) + signedPsbt := signPSBT(t, userWalletRPC, psbt) + txHex := finalizePSBT(t, userWalletRPC, signedPsbt) + txid := broadcastTransaction(t, userWalletRPC, txHex) + + t.Logf("Recovered %d sats with timelock sequence %d (txid: %s)", outAmount, sequence, txid) + generateBlock(t, daemonRPC) + + return txid +} + +// unspentOutput is a single unspent output as returned by listunspent. +type unspentOutput struct { + TxID string `json:"txid"` + Vout int `json:"vout"` + Amount float64 `json:"amount"` +} + +func listUnspent( + t *testing.T, + rpc *rpcclient.Client, +) []unspentOutput { + const maxConfirmations = 9999999 + + result, err := rpc.RawRequest( + "listunspent", + []json.RawMessage{mustMarshal(0), mustMarshal(maxConfirmations)}, + ) + if err != nil { + t.Fatalf("Failed to list unspent outputs: %v", err) + } + + var utxos []unspentOutput + if err := json.Unmarshal(result, &utxos); err != nil { + t.Fatalf("Failed to unmarshal unspent outputs: %v", err) + } + + return utxos +} + +func createRawTransaction( + t *testing.T, + rpc *rpcclient.Client, + inputs []map[string]any, + outputs map[string]json.RawMessage, +) string { + result, err := rpc.RawRequest( + "createrawtransaction", + []json.RawMessage{mustMarshal(inputs), mustMarshal(outputs)}, + ) + if err != nil { + t.Fatalf("Failed to create raw transaction: %v", err) + } + + var rawTx string + if err := json.Unmarshal(result, &rawTx); err != nil { + t.Fatalf("Failed to unmarshal raw transaction: %v", err) + } + + return rawTx +} + +func convertToPSBT( + t *testing.T, + rpc *rpcclient.Client, + rawTx string, +) string { + result, err := rpc.RawRequest( + "converttopsbt", + []json.RawMessage{mustMarshal(rawTx), mustMarshal(true)}, + ) + if err != nil { + t.Fatalf("Failed to convert raw transaction to PSBT: %v", err) + } + + var psbt string + if err := json.Unmarshal(result, &psbt); err != nil { + t.Fatalf("Failed to unmarshal PSBT: %v", err) + } + + return psbt +} + func getTxAmountAndFee( t *testing.T, walletRPC *rpcclient.Client, @@ -265,7 +401,7 @@ func getTxAmountAndFee( t.Fatalf("Failed to get transaction details: %v", err) } - var txDetails map[string]interface{} + var txDetails map[string]interface{} //nolint:modernize // TODO: use any instead of interface{} if err := json.Unmarshal(txResult, &txDetails); err != nil { t.Fatalf("Failed to unmarshal transaction details: %v", err) } @@ -287,15 +423,26 @@ func getTxAmountAndFee( return txAmount, txFee } -func createFundedPSBT(t *testing.T, rpc *rpcclient.Client, destAddr string, amount btcutil.Amount) string { +func createFundedPSBT( + t *testing.T, + rpc *rpcclient.Client, + destAddr string, + amount btcutil.Amount, +) string { amountStr := fmt.Sprintf("%.8f", amount.ToBTC()) result, err := rpc.RawRequest( "walletcreatefundedpsbt", []json.RawMessage{ - mustMarshal([]interface{}{}), // inputs (auto-select) - mustMarshal([]map[string]interface{}{{destAddr: amountStr}}), // outputs + mustMarshal( + []interface{}{}, //nolint:modernize // TODO: use any instead of interface{} // inputs (auto-select) + ), + mustMarshal( + []map[string]interface{}{{destAddr: amountStr}}, //nolint:modernize // TODO: use any instead of interface{} // outputs + ), mustMarshal(0), // locktime - mustMarshal(map[string]interface{}{"subtractFeeFromOutputs": []int{0}}), // options + mustMarshal( + map[string]interface{}{"subtractFeeFromOutputs": []int{0}}, //nolint:modernize // TODO: use any instead of interface{} // options + ), }, ) if err != nil { @@ -337,7 +484,7 @@ func finalizePSBT(t *testing.T, rpc *rpcclient.Client, psbt string) string { t.Fatalf("Failed to finalize PSBT: %v", err) } - var response map[string]interface{} + var response map[string]interface{} //nolint:modernize // TODO: use any instead of interface{} if err := json.Unmarshal(result, &response); err != nil { t.Fatalf("Failed to unmarshal finalized PSBT: %v", err) } @@ -373,7 +520,7 @@ func broadcastTransaction(t *testing.T, rpc *rpcclient.Client, txHex string) str } func extractPSBTFromResponse(t *testing.T, result json.RawMessage, description string) string { - var response map[string]interface{} + var response map[string]interface{} //nolint:modernize // TODO: use any instead of interface{} if err := json.Unmarshal(result, &response); err != nil { t.Fatalf("Failed to unmarshal %s response: %v", description, err) } diff --git a/libwallet/scanner/recovery_m3_test.go b/libwallet/scanner/recovery_m3_test.go new file mode 100644 index 00000000..12666ea1 --- /dev/null +++ b/libwallet/scanner/recovery_m3_test.go @@ -0,0 +1,248 @@ +package scanner + +import ( + "fmt" + "testing" + "time" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/rpcclient" + + "github.com/muun/libwallet" +) + +// This is the M3-family counterpart of TestKitToBtcCore_Integration (see recovery_test.go). It +// validates that, given only +// +// - Recovery Code +// - EKit keys (user + muun) +// - an in-app generated address +// +// a user can recover the funds held on an M3 address by talking to bitcoin core directly through an +// output descriptor. +// +// The crucial difference with the 2-of-2 schemes (V2/V3/V4/V6) is the spending path. An M3 address +// has two ways to spend: +// +// - collaborative (3-of-3): user + muun + lightning peer. +// - non-collaborative (2-of-2 + relative timelock): user + muun + older(N). +// +// A recovering user never has the lightning peer key, so recovery MUST go through the +// non-collaborative path: the descriptor carries the peer's PUBLIC key only, so bitcoin core can +// satisfy the input solely via the timelocked user+muun branch once the UTXO matures. +// +// This test depends on regtest-musig, that container will be up for this test on CI but not on local +// envs. If you want to run it locally use `docker compose up regtest-musig`. +func TestM3KitToBtcCore_Integration(t *testing.T) { + // MARK: - Step 1: Set up test data + const ( + // Same EKit fixtures as recovery_test.go. + encodedUserKey = "Fw11jm3oFyL4EEo8tZHpvApSdQ9DkCspVuxG7ZmH9ziTkfFkfpBg9itmFwwmi5GTekvaEwyghJG2phyBJkW4DkqKNqdZx1DRDCmL3s2PuyhticTA8pgfraQo26kLW9zrKVES2pvfgygHms1y" //nolint:lll + encodedMuunKey = "FvGKMF7cr7mTTF44ZHohs9M7Fh3L5LuUBnDjqJM8kBxuCnYz28i3cjKLEavim2wviGfH95LVBjuxwipbiTyBzDJWwMrQfTG8hq5X144rDeetHHAyGsXBDiyNFWxwN1u6qfQWH9bcC9TGNp6M" //nolint:lll + recoveryCode = "LAWN-AXNA-RQ8K-APEA-JKW5-BT2Y-QH75-DRQM" + + // Small relative timelock so the timelocked UTXOs mature within the test instead of the + // production ~1-year value. + nonCollaborativeTimelock = int64(10) + + m3SpendingConditionsExternal = "and_v(v:pk(%s/1/*),and_v(v:pk(%s/1/*),or_d(pk(%s/1/*),older(%d))))" //nolint:lll + m3SpendingConditionsChange = "and_v(v:pk(%s/0/*),and_v(v:pk(%s/0/*),or_d(pk(%s/0/*),older(%d))))" //nolint:lll + v9SpendingConditionsExternal = "tr(musig(%[1]s/1/*,%[2]s/1/*,%[3]s/1/*),and_v(v:pk(musig(%[1]s/1/*,%[2]s/1/*)),older(%[4]d)))" //nolint:lll + v9SpendingConditionsChange = "tr(musig(%[1]s/0/*,%[2]s/0/*,%[3]s/0/*),and_v(v:pk(musig(%[1]s/0/*,%[2]s/0/*)),older(%[4]d)))" //nolint:lll + ) + + walletDescriptors := []m3WalletDescriptor{ + // V7 external (wrapped segwit) + {template: "sh(wsh(" + m3SpendingConditionsExternal + "))", change: false}, + // V7 change (wrapped segwit) + {template: "sh(wsh(" + m3SpendingConditionsChange + "))", change: true}, + // V8 external (native segwit) + {template: "wsh(" + m3SpendingConditionsExternal + ")", change: false}, + // V8 change (native segwit) + {template: "wsh(" + m3SpendingConditionsChange + ")", change: true}, + // V9 external (taproot) + {template: v9SpendingConditionsExternal, change: false}, + // V9 change (taproot) + {template: v9SpendingConditionsChange, change: true}, + } + + // MARK: - Step 2: Decrypt keys + userKey, muunKey := decryptMuunKeys(t, encodedUserKey, encodedMuunKey, recoveryCode, nil) + + // The lightning peer is a third party. For recovery we only ever need its PUBLIC key. + peerKey := newPeerKey(t) + + // MARK: - Step 3: Create wallet importing one descriptor per M3 scheme (change + external) + daemonRPC := getBitcoindRpcClient(t, "") + userWalletRPC := loadM3Wallet( + t, + userKey, + muunKey, + peerKey, + nonCollaborativeTimelock, + walletDescriptors, + ) + + // MARK: - Step 4: Fund one address per M3 scheme and derivation tree. + userWalletStateBeforeFunding := getWalletState(t, userWalletRPC) + + fundedAddresses := fundM3Addresses( + t, + userKey, + muunKey, + peerKey, + daemonRPC, + nonCollaborativeTimelock, + ) + + // Mine past the relative timelock so the UTXOs are spendable through older(N). + for range nonCollaborativeTimelock { + generateBlock(t, daemonRPC) + } + rescanTheBlockchain(t, userWalletRPC) + + // MARK: - Step 5: Validate funding + checkFundsAdded(t, userWalletRPC, fundedAddresses, userWalletStateBeforeFunding) + + userWalletStateBeforeSpendAllFunds := getWalletState(t, userWalletRPC) + + // MARK: - Step 6: Spend all funds via the non-collaborative path + // The wallet lacks the peer key, so core spends the timelocked user+muun branch of every UTXO. + txID := spendAllFundsWithTimelockSequence(t, userWalletRPC, daemonRPC, nonCollaborativeTimelock) + + // MARK: - Step 7: Validate transaction + checkUserBalanceIsZero(t, userWalletRPC) + checkTxAmountIsConsistentWithUserBalanceBeforeSpend( + t, + userWalletRPC, + txID, + userWalletStateBeforeSpendAllFunds, + ) +} + +// newPeerKey builds a deterministic lightning-peer key. Only its public key is ever used (it goes +// into the descriptor as an xpub), since recovery never spends the collaborative branch. +func newPeerKey(t *testing.T) *libwallet.HDPrivateKey { + seed := make([]byte, 32) + for i := range seed { + seed[i] = 0x07 + } + peerKey, err := libwallet.NewMasterHDPrivateKeyFromBytes( + seed, make([]byte, 32), libwallet.Regtest(), + ) + if err != nil { + t.Fatalf("Failed to build peer key: %v", err) + } + return peerKey +} + +type m3WalletDescriptor struct { + template string + change bool +} + +// loadM3Wallet creates a fresh descriptor wallet and imports every M3 descriptor +// (user+muun xprivs, peer xpub). +func loadM3Wallet( + t *testing.T, + userKey, muunKey, peerKey *libwallet.HDPrivateKey, + blocksForExpiration int64, + walletDescriptors []m3WalletDescriptor, +) *rpcclient.Client { + walletName := fmt.Sprintf("recovery_m3_%d", time.Now().UnixNano()) + walletRPC := createDescriptorWallet(t, walletName) + + for _, desc := range walletDescriptors { + descriptor := fmt.Sprintf( + desc.template, + userKey.String(), + muunKey.String(), + peerKey.PublicKey().String(), + blocksForExpiration, + ) + + // The btcCore protocol requires the checksum in importDescriptor to be already added. + descriptorWithChecksum := addDescriptorChecksum(t, walletRPC, descriptor) + importDescriptor(t, walletRPC, descriptorWithChecksum, desc.change) + } + + rescanTheBlockchain(t, walletRPC) + + return walletRPC +} + +// fundM3Addresses funds one address per scheme version and derivation tree and returns +// what it funded. +func fundM3Addresses( + t *testing.T, + userKey, muunKey, peerKey *libwallet.HDPrivateKey, + daemonRPC *rpcclient.Client, + blocksForExpiration int64, +) []AddressWithBalance { + addresses := generateM3Addresses(t, userKey, muunKey, peerKey, blocksForExpiration) + var fundedAddresses []AddressWithBalance + + for _, addr := range addresses { + amount := btcutil.Amount(addr.Version() * 100000) + fundAddress(t, daemonRPC, addr.Address(), amount) + fundedAddresses = append(fundedAddresses, AddressWithBalance{ + address: addr.Address(), + balance: amount, + }) + } + + generateBlock(t, daemonRPC) + + return fundedAddresses +} + +// generateM3Addresses returns one libwallet-generated address per scheme version AND derivation +// tree. +func generateM3Addresses( + t *testing.T, + userKey, muunKey, peerKey *libwallet.HDPrivateKey, + blocksForExpiration int64, +) []libwallet.MuunAddress { + derivationPaths := []string{"m/0/0", "m/1/0"} + + var addresses []libwallet.MuunAddress + for _, path := range derivationPaths { + derivedUserKey, err := userKey.PublicKey().DeriveTo(path) + if err != nil { + t.Fatalf("Failed to derive user key at %s: %v", path, err) + } + derivedMuunKey, err := muunKey.PublicKey().DeriveTo(path) + if err != nil { + t.Fatalf("Failed to derive muun key at %s: %v", path, err) + } + derivedPeerKey, err := peerKey.PublicKey().DeriveTo(path) + if err != nil { + t.Fatalf("Failed to derive peer key at %s: %v", path, err) + } + + addrV7, err := libwallet.CreateAddressV7( + derivedUserKey, derivedMuunKey, derivedPeerKey, blocksForExpiration, + ) + if err != nil { + t.Fatalf("Failed to create V7 address at %s: %v", path, err) + } + addrV8, err := libwallet.CreateAddressV8( + derivedUserKey, derivedMuunKey, derivedPeerKey, blocksForExpiration, + ) + if err != nil { + t.Fatalf("Failed to create V8 address at %s: %v", path, err) + } + addrV9, err := libwallet.CreateAddressV9( + derivedUserKey, derivedMuunKey, derivedPeerKey, blocksForExpiration, + ) + if err != nil { + t.Fatalf("Failed to create V9 address at %s: %v", path, err) + } + + for _, addr := range []libwallet.MuunAddress{addrV7, addrV8, addrV9} { + addresses = append(addresses, addr) + t.Logf("Generated V%d (%s): %s", addr.Version(), addr.DerivationPath(), addr.Address()) + } + } + return addresses +} diff --git a/libwallet/scanner/recovery_test.go b/libwallet/scanner/recovery_test.go index 3bf813b0..bdc23c5a 100644 --- a/libwallet/scanner/recovery_test.go +++ b/libwallet/scanner/recovery_test.go @@ -3,11 +3,13 @@ package scanner import ( "encoding/json" "fmt" + "testing" + "time" + "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/rpcclient" + "github.com/muun/libwallet" - "testing" - "time" ) /* @@ -24,15 +26,16 @@ The idea is to test: - Given every single type of address we can generate with libwallet, there are output descriptors that bitcoin core can understand and use to move the user sats. -This test depends on regtest-musig, that container will be up for this test on CI but not on local envs. if you want to -run it locally use `docker compose up regtest-musig.` +This test depends on regtest-musig, that container will be up +for this test on CI but not on local envs. if you want to run +it locally use `docker compose up regtest-musig.` */ func TestKitToBtcCore_Integration(t *testing.T) { // MARK: - Step 1: Set up test data const ( // Test keys and recovery data - encodedUserKey = "Fw11jm3oFyL4EEo8tZHpvApSdQ9DkCspVuxG7ZmH9ziTkfFkfpBg9itmFwwmi5GTekvaEwyghJG2phyBJkW4DkqKNqdZx1DRDCmL3s2PuyhticTA8pgfraQo26kLW9zrKVES2pvfgygHms1y" - encodedMuunKey = "FvGKMF7cr7mTTF44ZHohs9M7Fh3L5LuUBnDjqJM8kBxuCnYz28i3cjKLEavim2wviGfH95LVBjuxwipbiTyBzDJWwMrQfTG8hq5X144rDeetHHAyGsXBDiyNFWxwN1u6qfQWH9bcC9TGNp6M" + encodedUserKey = "Fw11jm3oFyL4EEo8tZHpvApSdQ9DkCspVuxG7ZmH9ziTkfFkfpBg9itmFwwmi5GTekvaEwyghJG2phyBJkW4DkqKNqdZx1DRDCmL3s2PuyhticTA8pgfraQo26kLW9zrKVES2pvfgygHms1y" //nolint:lll + encodedMuunKey = "FvGKMF7cr7mTTF44ZHohs9M7Fh3L5LuUBnDjqJM8kBxuCnYz28i3cjKLEavim2wviGfH95LVBjuxwipbiTyBzDJWwMrQfTG8hq5X144rDeetHHAyGsXBDiyNFWxwN1u6qfQWH9bcC9TGNp6M" //nolint:lll recoveryCode = "LAWN-AXNA-RQ8K-APEA-JKW5-BT2Y-QH75-DRQM" inAppGeneratedAddress = "2N1PtMVLGB2cV3Afn4HwouPZJK1tkFE7GEi" @@ -56,18 +59,29 @@ func TestKitToBtcCore_Integration(t *testing.T) { } // MARK: - Step 2: Decrypt keys - // Convert encrypted keys to master keys using the recovery code + // Convert encrypted keys to master keys using the recovery code and label the user key + // accordingly. + userPath := "m/1'/1'" userKey, muunKey := decryptMuunKeys( t, encodedUserKey, encodedMuunKey, recoveryCode, + &userPath, ) // MARK: - Step 3: Create wallets // Use default wallet for funding operations (empty string uses wallet/ path) - daemonRpc := getBitcoindRpcClient(t, "") - userWalletRpc := loadUserWallet(t, userKey, muunKey, walletDescriptors) + daemonRpc := getBitcoindRpcClient( //nolint:staticcheck // TODO: var daemonRpc should be daemonRPC + t, + "", + ) + userWalletRpc := loadUserWallet( //nolint:staticcheck // TODO: var userWalletRpc should be userWalletRPC + t, + userKey, + muunKey, + walletDescriptors, + ) // MARK: - Step 4: Fund addresses // Generate and fund one address per version @@ -92,24 +106,31 @@ func TestKitToBtcCore_Integration(t *testing.T) { // MARK: - Step 6: Spend all funds // Transfer all funds from user wallet back to daemon - txid := spendAllFundsFromUserWallet(t, userWalletRpc, daemonRpc) + txID := spendAllFundsFromUserWallet(t, userWalletRpc, daemonRpc) // MARK: - Step 7: Validate transaction // Verify transaction amounts and fees match expected values checkUserBalanceIsZero(t, userWalletRpc) - checkTxAmountIsConsistentWithUserBalanceBeforeSpend(t, userWalletRpc, txid, userWalletStateBeforeSpendAllFunds) + checkTxAmountIsConsistentWithUserBalanceBeforeSpend( + t, + userWalletRpc, + txID, + userWalletStateBeforeSpendAllFunds, + ) } -func checkTxAmountIsConsistentWithUserBalanceBeforeSpend(t *testing.T, - userWalletRpc *rpcclient.Client, - txid string, - userWalletStateBeforeSpendAllFunds WalletState) { +func checkTxAmountIsConsistentWithUserBalanceBeforeSpend( + t *testing.T, + userWalletRpc *rpcclient.Client, //nolint:staticcheck // TODO: func parameter userWalletRpc should be userWalletRPC + txID string, + userWalletStateBeforeSpendAllFunds WalletState, +) { - txAmount, txFee := getTxAmountAndFee(t, userWalletRpc, txid) + txAmount, txFee := getTxAmountAndFee(t, userWalletRpc, txID) walletBalanceBeforeSpendAllFunds := userWalletStateBeforeSpendAllFunds.totalBalance - // Since we use subtractfeefromamount=true, the transaction amount + fee should equal - // wallet balance + // Since we use subtractfeefromamount=true, the transaction amount + fee should equal wallet + // balance if txAmount+txFee != walletBalanceBeforeSpendAllFunds { t.Fatalf( "Transaction validation failed: "+ @@ -119,8 +140,8 @@ func checkTxAmountIsConsistentWithUserBalanceBeforeSpend(t *testing.T, } t.Logf( - "✅ Transaction validation passed: %d sent with %d fee (txid: %s)", - txAmount, txFee, txid, + "✅ Transaction validation passed: %d sent with %d fee (txID: %s)", + txAmount, txFee, txID, ) t.Logf( "✅ Wallet emptied successfully: %d transferred", @@ -128,7 +149,10 @@ func checkTxAmountIsConsistentWithUserBalanceBeforeSpend(t *testing.T, ) } -func checkUserBalanceIsZero(t *testing.T, userWalletRpc *rpcclient.Client) { +func checkUserBalanceIsZero( + t *testing.T, + userWalletRpc *rpcclient.Client, //nolint:staticcheck // TODO: func parameter userWalletRpc should be userWalletRPC +) { userWalletStateAfterSpendingAllFunds := getWalletState(t, userWalletRpc) if userWalletStateAfterSpendingAllFunds.totalBalance != 0 { @@ -144,12 +168,15 @@ func loadUserWallet( userKey, muunKey *libwallet.HDPrivateKey, walletDescriptors []struct { - template string - internal bool -}, + template string + internal bool + }, ) *rpcclient.Client { walletName := fmt.Sprintf("recovery_%d", time.Now().UnixNano()) - walletRpc := createDescriptorWallet(t, walletName) + walletRpc := createDescriptorWallet( //nolint:staticcheck // TODO: var walletRpc should be walletRPC + t, + walletName, + ) for _, desc := range walletDescriptors { descriptor := fmt.Sprintf( @@ -158,7 +185,8 @@ func loadUserWallet( muunKey.String(), ) - // The btcCore protocol requires the checksum in the importDescriptor function to be already added. + // The btcCore protocol requires the checksum in the importDescriptor function to be already + // added. descriptorWithChecksum := addDescriptorChecksum(t, walletRpc, descriptor) importDescriptor(t, walletRpc, descriptorWithChecksum, desc.internal) } @@ -170,7 +198,7 @@ func loadUserWallet( func addDescriptorChecksum( t *testing.T, - walletRpc *rpcclient.Client, + walletRpc *rpcclient.Client, //nolint:staticcheck // TODO: func parameter walletRpc should be walletRPC descriptor string, ) string { result, err := walletRpc.RawRequest( @@ -181,12 +209,12 @@ func addDescriptorChecksum( t.Fatalf("Failed to get descriptor info for %s: %v", descriptor, err) } - var info map[string]interface{} + var info map[string]interface{} //nolint:modernize // TODO: use any instead of interface{} if err := json.Unmarshal(result, &info); err != nil { t.Fatalf("Failed to unmarshal descriptor info: %v", err) } - // Carefull here, getdescriptorinfo is responding with the complete descriptor plus the + // Careful here, getdescriptorinfo is responding with the complete descriptor plus the // checksum. DO NOT use it as the descriptor returned uses the xpub instead of the xpriv, // extract the checksum instead. if checksum, ok := info["checksum"].(string); ok { @@ -198,26 +226,28 @@ func addDescriptorChecksum( func importDescriptor( t *testing.T, - walletRpc *rpcclient.Client, + walletRpc *rpcclient.Client, //nolint:staticcheck // TODO: func parameter walletRpc should be walletRPC descriptorWithChecksum string, internal bool, ) { const maxDerivationIndex = 200 - importDesc := map[string]interface{}{ + importDesc := map[string]interface{}{ //nolint:modernize // TODO: use any instead of interface{} "desc": descriptorWithChecksum, "timestamp": 0, "active": true, "internal": internal, "range": [2]int{0, maxDerivationIndex}, } result, err := walletRpc.RawRequest( "importdescriptors", - []json.RawMessage{mustMarshal([]interface{}{importDesc})}, + []json.RawMessage{ + mustMarshal([]interface{}{importDesc}), //nolint:modernize // TODO: use any instead of interface{} + }, ) if err != nil { t.Fatalf("Failed to import descriptor %s: %v", descriptorWithChecksum, err) } - var results []map[string]interface{} + var results []map[string]interface{} //nolint:modernize // TODO: use any instead of interface{} if err := json.Unmarshal(result, &results); err != nil { t.Fatalf("Failed to unmarshal import results: %v", err) } @@ -239,9 +269,9 @@ func fundOneAddressPerVersion( t *testing.T, userKey *libwallet.HDPrivateKey, muunKey *libwallet.HDPrivateKey, - daemonRpc *rpcclient.Client, + daemonRpc *rpcclient.Client, //nolint:staticcheck // TODO: func parameter daemonRpc should be daemonRPC inAppGeneratedAddress string, - userWalletRpc *rpcclient.Client, + userWalletRpc *rpcclient.Client, //nolint:staticcheck // TODO: func parameter userWalletRpc should be userWalletRPC ) []AddressWithBalance { addressesByVersion := generateOneAddressPerVersion(t, userKey, muunKey) // Track what we're going to fund (only recoverable addresses) @@ -321,6 +351,7 @@ func decryptMuunKeys( userKey string, muunKey string, recoveryCode string, + userPath *string, ) (*libwallet.HDPrivateKey, *libwallet.HDPrivateKey) { // Decode encrypted keys userEncryptedKey, err := libwallet.DecodeEncryptedPrivateKey(userKey) @@ -348,6 +379,9 @@ func decryptMuunKeys( t.Fatalf("Failed to decrypt muun key: %v", err) } - decryptedUserKey.Key.Path = "m/1'/1'" + // Relabel the user key's path when the caller needs it. + if userPath != nil { + decryptedUserKey.Key.Path = *userPath + } return decryptedUserKey.Key, decryptedMuunKey.Key } diff --git a/libwallet/scanner/scanner.go b/libwallet/scanner/scanner.go index 4d8ce6ed..9c7ce2fc 100644 --- a/libwallet/scanner/scanner.go +++ b/libwallet/scanner/scanner.go @@ -7,6 +7,7 @@ import ( "time" "github.com/btcsuite/btcd/chaincfg" + "github.com/muun/libwallet" "github.com/muun/libwallet/electrum" ) @@ -21,13 +22,11 @@ const batchSize = 100 // // Servers are provided by a ServerProvider instance, and rotated when unreachable or faulty. We // trust ServerProvider to prioritize good targets. -// // Batching is leveraged when supported by a particular server, falling back to sequential requests // for single addresses (which is much slower, but can get us out of trouble when better servers are // not available). -// -// Timeouts and cancellations are an internal affair, not configurable by callers. See taskTimeout -// declared above. +// Timeouts and cancellations are an internal affair, not configurable by callers. +// See taskTimeout declared above. // // Concurrency control works by using an electrum.Pool, limiting access to clients, and not an // internal worker pool. This is the Go way (limiting access to resources rather than having a fixed @@ -72,7 +71,11 @@ type scanContext struct { } // NewScanner creates an initialized Scanner. -func NewScanner(connectionPool *electrum.Pool, electrumProvider *electrum.ServerProvider, chainParams *chaincfg.Params) *Scanner { +func NewScanner( + connectionPool *electrum.Pool, + electrumProvider *electrum.ServerProvider, + chainParams *chaincfg.Params, +) *Scanner { return &Scanner{ pool: connectionPool, servers: electrumProvider, @@ -112,7 +115,11 @@ func (s *Scanner) startCollect(ctx *scanContext) { for { select { case result := <-ctx.results: - s.log.Info(fmt.Sprintf("Scanned %d, found %d", len(result.Task.addresses), len(result.Utxos)), "error", result.Err) + s.log.Info( + fmt.Sprintf("Scanned %d, found %d", len(result.Task.addresses), len(result.Utxos)), + "error", + result.Err, + ) newReport := *ctx.reportCache // create a new private copy ctx.reportCache = &newReport @@ -122,8 +129,10 @@ func (s *Scanner) startCollect(ctx *scanContext) { ctx.reportCache.Err = result.Err ctx.reports <- ctx.reportCache - close(ctx.stopScan) // failed after several retries, we give up and terminate all tasks - close(ctx.reports) // close the report channel to let callers know we're done + close( + ctx.stopScan, + ) // failed after several retries, we give up and terminate all tasks + close(ctx.reports) // close the report channel to let callers know we're done return } @@ -173,12 +182,15 @@ func (s *Scanner) startScan(ctx *scanContext) { close(ctx.stopCollect) } -func (s *Scanner) scanBatch(ctx *scanContext, client *electrum.Client, batch []libwallet.MuunAddress) { - // NOTE: - // We begin by building the task, passing our selected Client. Since we're choosing the instance, - // it's our job to control acquisition and release of Clients to prevent sharing (remember, - // clients are single-user). The task won't enforce this safety measure (it can't), it's fully - // up to us. +func (s *Scanner) scanBatch( + ctx *scanContext, + client *electrum.Client, + batch []libwallet.MuunAddress, +) { + // NOTE: we begin by building the task, passing our selected Client. Since we're choosing the + // instance, it's our job to control acquisition and release of Clients to prevent sharing + // (remember, clients are single-user). The task won't enforce this safety measure (it can't), + // it's fully up to us. task := &scanTask{ servers: s.servers, client: client, diff --git a/libwallet/scanner/task.go b/libwallet/scanner/task.go index 6c0cd4a5..56016fa4 100644 --- a/libwallet/scanner/task.go +++ b/libwallet/scanner/task.go @@ -1,10 +1,11 @@ package scanner import ( - "fmt" "time" "github.com/btcsuite/btcd/chaincfg" + "github.com/go-errors/errors" + "github.com/muun/libwallet" "github.com/muun/libwallet/btcsuitew/btcutilw" "github.com/muun/libwallet/btcsuitew/txscriptw" @@ -40,8 +41,8 @@ func (t *scanTask) Execute() *scanTaskResult { // Attempt to run the task: go t.tryExecuteAsync(results) - // Wait until a result is sent, the timeout is reached or the task canceled, capturing errors - // errors along the way: + // Wait until a result is sent, the timeout is reached or the task canceled, capturing + // errors errors along the way: select { case <-t.exit: return t.exitResult() // stop retrying when we get the done signal @@ -54,7 +55,9 @@ func (t *scanTask) Execute() *scanTaskResult { lastError = result.Err // keep retrying when an attempt fails case <-timeout: - return t.errorResult(fmt.Errorf("task timed out. Last error: %w", lastError)) // stop on timeout + return t.errorResult( + errors.Errorf("task timed out. Last error: %w", lastError), + ) // stop on timeout } } } @@ -66,7 +69,7 @@ func (t *scanTask) tryExecuteAsync(results chan *scanTaskResult) { result := t.tryExecute() if result.Err != nil { - t.client.Disconnect() + t.client.Disconnect() //nolint:errcheck // TODO: check error } results <- result @@ -130,19 +133,21 @@ func (t *scanTask) tryExecute() *scanTaskResult { func (t *scanTask) listUnspentWithBatching(indexHashes []string) ([][]electrum.UnspentRef, error) { unspentRefGroups, err := t.client.ListUnspentBatch(indexHashes) if err != nil { - return nil, fmt.Errorf("listing with batching failed: %w", err) + return nil, errors.Errorf("listing with batching failed: %w", err) } return unspentRefGroups, nil } -func (t *scanTask) listUnspentWithoutBatching(indexHashes []string) ([][]electrum.UnspentRef, error) { +func (t *scanTask) listUnspentWithoutBatching( + indexHashes []string, +) ([][]electrum.UnspentRef, error) { var unspentRefGroups [][]electrum.UnspentRef for _, indexHash := range indexHashes { newGroup, err := t.client.ListUnspent(indexHash) if err != nil { - return nil, fmt.Errorf("listing without batching failed: %w", err) + return nil, errors.Errorf("listing without batching failed: %w", err) } unspentRefGroups = append(unspentRefGroups, newGroup) @@ -175,7 +180,10 @@ func getIndexHashes(outputScripts [][]byte) ([]string, error) { } // getOutputScripts creates all the scripts that send to an list of Bitcoin address. -func getOutputScripts(addresses []libwallet.MuunAddress, chainParams *chaincfg.Params) ([][]byte, error) { +func getOutputScripts( + addresses []libwallet.MuunAddress, + chainParams *chaincfg.Params, +) ([][]byte, error) { outputScripts := make([][]byte, len(addresses)) for i, address := range addresses { @@ -183,12 +191,12 @@ func getOutputScripts(addresses []libwallet.MuunAddress, chainParams *chaincfg.P decodedAddress, err := btcutilw.DecodeAddress(rawAddress, chainParams) if err != nil { - return nil, fmt.Errorf("failed to decode address %s: %w", rawAddress, err) + return nil, errors.Errorf("failed to decode address %s: %w", rawAddress, err) } outputScript, err := txscriptw.PayToAddrScript(decodedAddress) if err != nil { - return nil, fmt.Errorf("failed to craft script for %s: %w", rawAddress, err) + return nil, errors.Errorf("failed to craft script for %s: %w", rawAddress, err) } outputScripts[i] = outputScript diff --git a/libwallet/segwit.go b/libwallet/segwit.go index 5a27b308..5001b977 100644 --- a/libwallet/segwit.go +++ b/libwallet/segwit.go @@ -2,25 +2,39 @@ package libwallet import ( "crypto/sha256" - "fmt" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" + "github.com/go-errors/errors" lndinput "github.com/lightningnetwork/lnd/input" ) -func signNativeSegwitInputV0(index int, tx *wire.MsgTx, privateKey *HDPrivateKey, witnessScript []byte, amount btcutil.Amount) ([]byte, error) { +func signNativeSegwitInputV0( + index int, + tx *wire.MsgTx, + privateKey *HDPrivateKey, + witnessScript []byte, + amount btcutil.Amount, +) ([]byte, error) { privKey, err := privateKey.key.ECPrivKey() if err != nil { - return nil, fmt.Errorf("failed to produce EC priv key for signing: %w", err) + return nil, errors.Errorf("failed to produce EC priv key for signing: %w", err) } sigHashes := lndinput.NewTxSigHashesV0Only(tx) - sig, err := txscript.RawTxInWitnessSignature(tx, sigHashes, index, int64(amount), witnessScript, txscript.SigHashAll, privKey) + sig, err := txscript.RawTxInWitnessSignature( + tx, + sigHashes, + index, + int64(amount), + witnessScript, + txscript.SigHashAll, + privKey, + ) if err != nil { - return nil, fmt.Errorf("failed to sign V4 input: %w", err) + return nil, errors.Errorf("failed to sign V4 input: %w", err) } return sig, nil @@ -45,20 +59,22 @@ func signNonNativeSegwitInputV0(index int, tx *wire.MsgTx, privateKey *HDPrivate builder.AddData(redeemScript) script, err := builder.Script() if err != nil { - return nil, fmt.Errorf("failed to generate signing script: %w", err) + return nil, errors.Errorf("failed to generate signing script: %w", err) } txInput.SignatureScript = script privKey, err := privateKey.key.ECPrivKey() if err != nil { - return nil, fmt.Errorf("failed to produce EC priv key for signing: %w", err) + return nil, errors.Errorf("failed to produce EC priv key for signing: %w", err) } - sigHashes := lndinput.NewTxSigHashesV0Only(tx) // TODO: validate that segwit V0 is enough for this input + sigHashes := lndinput.NewTxSigHashesV0Only( + tx, + ) // TODO: validate that segwit V0 is enough for this input sig, err := txscript.RawTxInWitnessSignature( tx, sigHashes, index, int64(amount), witnessScript, txscript.SigHashAll, privKey) if err != nil { - return nil, fmt.Errorf("failed to sign V3 input: %w", err) + return nil, errors.Errorf("failed to sign V3 input: %w", err) } return sig, nil diff --git a/libwallet/service/api_objects_mapper.go b/libwallet/service/api_objects_mapper.go index bef045db..c5b18494 100644 --- a/libwallet/service/api_objects_mapper.go +++ b/libwallet/service/api_objects_mapper.go @@ -3,12 +3,14 @@ package service import ( "encoding/binary" "encoding/hex" - "fmt" + + "github.com/go-errors/errors" + "github.com/muun/libwallet/domain/nfc" "github.com/muun/libwallet/service/model" ) -func MapRegisterSecurityCardJson( +func MapRegisterSecurityCardJson( //nolint:staticcheck // TODO: func MapRegisterSecurityCardJson should be MapRegisterSecurityCardJSON pairingResponse *nfc.PairingResponse, clientPublicKey []byte, ) (*model.RegisterSecurityCardJson, error) { @@ -28,9 +30,11 @@ func MapRegisterSecurityCardJson( }, nil } -func mapSecurityCardMetadataJson(metadata *nfc.CardMetadata) (*model.SecurityCardMetadataJson, error) { +func mapSecurityCardMetadataJson( //nolint:staticcheck // TODO: func mapSecurityCardMetadataJson should be mapSecurityCardMetadataJSON + metadata *nfc.CardMetadata, +) (*model.SecurityCardMetadataJson, error) { if metadata == nil { - return nil, fmt.Errorf("missing card metadata in pairing response") + return nil, errors.Errorf("missing card metadata in pairing response") } globalPubCardInHex := hex.EncodeToString(metadata.GlobalPubCard[:]) @@ -39,7 +43,7 @@ func mapSecurityCardMetadataJson(metadata *nfc.CardMetadata) (*model.SecurityCar firmwareVersion := binary.BigEndian.Uint16(metadata.FirmwareVersion[:]) languageCodeInHex := hex.EncodeToString(metadata.LanguageCode[:]) - metadataJson := &model.SecurityCardMetadataJson{ + metadataJson := &model.SecurityCardMetadataJson{ //nolint:staticcheck // TODO: var metadataJson should be metadataJSON GlobalPublicKeyInHex: globalPubCardInHex, CardVendorInHex: cardVendorInHex, CardModelInHex: cardModelInHex, @@ -50,3 +54,22 @@ func mapSecurityCardMetadataJson(metadata *nfc.CardMetadata) (*model.SecurityCar return metadataJson, nil } + +func mapSecurityCardV3MetadataJSON( + metadata *nfc.CardMetadataV3, +) (*model.SecurityCardV3MetadataJSON, error) { + if metadata == nil { + return nil, errors.Errorf("missing card metadata in pairing response") + } + + return &model.SecurityCardV3MetadataJSON{ + AttestationPubKeyInHex: hex.EncodeToString(metadata.AttestationPub[:]), + CardVendorInHex: hex.EncodeToString(metadata.CardVendor[:]), + CardModelInHex: hex.EncodeToString(metadata.CardModel[:]), + FirmwareVersion: binary.BigEndian.Uint16(metadata.FirmwareVersion[:]), + CapabilitiesInHex: hex.EncodeToString(metadata.Capabilities[:]), + OperationCount: metadata.OperationCount, + ProviderPubKeyInHex: hex.EncodeToString(metadata.ProviderPub[:]), + ProviderSigInHex: hex.EncodeToString(metadata.ProviderSig), + }, nil +} diff --git a/libwallet/service/client.go b/libwallet/service/client.go index 09c68f9c..1088c167 100644 --- a/libwallet/service/client.go +++ b/libwallet/service/client.go @@ -3,8 +3,6 @@ package service import ( "bytes" "encoding/json" - "errors" - "fmt" "io" "log/slog" "net/http" @@ -13,7 +11,9 @@ import ( "strings" "time" + "github.com/go-errors/errors" "github.com/google/uuid" + "github.com/muun/libwallet/app_provided_data" ) @@ -34,13 +34,15 @@ type HoustonResponseError struct { DeveloperMessage string ErrorCode int Message string - RequestId int + RequestId int //nolint:staticcheck // TODO: struct field RequestId should be RequestID Status int } // Satisfy "error" interface for HoustonResponseError func (e *HoustonResponseError) Error() string { - errorJson, err := json.Marshal(e) + errorJson, err := json.Marshal( //nolint:staticcheck // TODO: var errorJson should be errorJSON + e, + ) if err != nil { // This should never happen since the error response should be unmarshalled previously slog.Error("failed to parse HoustonResponseError", slog.Any("error", err)) @@ -70,12 +72,12 @@ func (r request[T]) do(c *client) (T, error) { var result T if c.configurator == nil { - return zero, fmt.Errorf("client.Do: CurrentRequestConfigurator not set") + return zero, errors.Errorf("client.Do: CurrentRequestConfigurator not set") } session, err := c.configurator.Session() if err != nil { - return zero, fmt.Errorf("client.Do: failed getting global headers: %w", err) + return zero, errors.Errorf("client.Do: failed getting global headers: %w", err) } var body []byte @@ -83,29 +85,33 @@ func (r request[T]) do(c *client) (T, error) { // FIXME: make sure we serialize things same as houston, mainly dates body, err = json.Marshal(r.Body) if err != nil { - return zero, fmt.Errorf("client.Do: failed to marshall json: %w", err) + return zero, errors.Errorf("client.Do: failed to marshall json: %w", err) } } idempotencyKey := uuid.New().String() - baseUrl, err := url.Parse(session.BaseURL) + baseUrl, err := url.Parse( //nolint:staticcheck // TODO: var baseUrl should be baseURL + session.BaseURL, + ) if err != nil { - return zero, fmt.Errorf("client.Do: failed to parse session BaseUrl: %w", err) + return zero, errors.Errorf("client.Do: failed to parse session BaseUrl: %w", err) } relativePath, err := url.Parse(r.Path) if err != nil { - return zero, fmt.Errorf("client.Do: failed to parse request Path: %w", err) + return zero, errors.Errorf("client.Do: failed to parse request Path: %w", err) } - httpUrl := baseUrl.ResolveReference(relativePath) + httpUrl := baseUrl.ResolveReference( //nolint:staticcheck // TODO: var httpUrl should be httpURL + relativePath, + ) - httpRequest, err := http.NewRequest( + httpRequest, err := http.NewRequest( //nolint:noctx // TODO: use http.NewRequestWithContext r.Method, httpUrl.String(), bytes.NewReader(body), ) if err != nil { - return zero, fmt.Errorf("client.Do: failed to build request: %w", err) + return zero, errors.Errorf("client.Do: failed to build request: %w", err) } header := httpRequest.Header @@ -147,11 +153,14 @@ func (r request[T]) do(c *client) (T, error) { client := http.Client{ Timeout: timeout, } - response, err := client.Do(httpRequest) + response, err := client.Do(httpRequest) //nolint:bodyclose // TODO: close response body if err != nil { var urlError *url.Error - lastError := fmt.Errorf("client.Do: request failed: %w", err) - isUrlError := errors.As(err, &urlError) + lastError := errors.Errorf("client.Do: request failed: %w", err) + isUrlError := errors.As( //nolint:staticcheck // TODO: var isUrlError should be isURLError + err, + &urlError, + ) if isUrlError && (urlError.Timeout() || urlError.Temporary()) { // Retries are in order continue @@ -162,7 +171,7 @@ func (r request[T]) do(c *client) (T, error) { responseBody, err := io.ReadAll(response.Body) if err != nil { - return zero, fmt.Errorf("client.Do: failed to parse error response: %w", err) + return zero, errors.Errorf("client.Do: failed to parse error response: %w", err) } if response.StatusCode >= 400 { @@ -171,7 +180,7 @@ func (r request[T]) do(c *client) (T, error) { houstonError := &HoustonResponseError{} err = json.Unmarshal(responseBody, houstonError) if err != nil { - return zero, fmt.Errorf("client.Do: failed to parse error response: %w", err) + return zero, errors.Errorf("client.Do: failed to parse error response: %w", err) } if response.StatusCode >= 500 { diff --git a/libwallet/service/houston.go b/libwallet/service/houston.go index f3cfb614..2c0adf0b 100644 --- a/libwallet/service/houston.go +++ b/libwallet/service/houston.go @@ -9,15 +9,31 @@ type HoustonService interface { HealthCheck() error ChallengeKeySetupStart(req model.ChallengeSetupJson) (model.SetupChallengeResponseJson, error) ChallengeKeySetupFinish(req model.ChallengeSetupVerifyJson) error - ChallengeSetupFinishWithVerifiableMuunKey(req model.ChallengeSetupVerifyJson) (model.VerifiableMuunKeyJson, error) + ChallengeSetupFinishWithVerifiableMuunKey( + req model.ChallengeSetupVerifyJson, + ) (model.VerifiableMuunKeyJson, error) VerifiableMuunKey() (model.VerifiableMuunKeyJson, error) - CreateFirstSession(createSessionJson model.CreateFirstSessionJson) (model.CreateFirstSessionOkJson, error) + CreateFirstSession( + createSessionJson model.CreateFirstSessionJson, //nolint:staticcheck // TODO: interface method parameter createSessionJson should be createSessionJSON + ) (model.CreateFirstSessionOkJson, error) FetchFeeWindow() (model.FeeWindowJson, error) SubmitDiagnosticsScanData(req model.DiagnosticScanDataJson) error - ChallengeSecurityCardPair() (model.ChallengeSecurityCardPairJson, error) - RegisterSecurityCard(req model.RegisterSecurityCardJson) (model.RegisterSecurityCardOkJson, error) - ChallengeSecurityCardSign(req model.ChallengeSecurityCardSignJson) (model.ChallengeSecurityCardSignResponseJson, error) + PairRequestChallenge() (model.PairRequestChallengeResponseJSON, error) + PairSubmitSignedChallenge( + req model.PairSubmitSignedChallengeJSON, + ) (model.PairSubmitSignedChallengeResponseJSON, error) + SignRequestChallenge( + req model.SignRequestChallengeJSON, + ) (model.SignRequestChallengeResponseJSON, error) + SignSubmitSignedChallenge(req model.SignSubmitSignedChallengeJSON) error + RegisterSecurityCard( + req model.RegisterSecurityCardJson, + ) (model.RegisterSecurityCardOkJson, error) + ChallengeSecurityCardSign( + req model.ChallengeSecurityCardSignJson, + ) (model.ChallengeSecurityCardSignResponseJson, error) SolveSecurityCardChallenge(req model.SolveSecurityCardChallengeJson) error + FetchSecurityCardsMarketplace() (model.SecurityCardsMarketplaceJson, error) } type HoustonClient struct { @@ -40,7 +56,9 @@ func (h *HoustonClient) HealthCheck() error { return err } -func (h *HoustonClient) ChallengeKeySetupStart(req model.ChallengeSetupJson) (model.SetupChallengeResponseJson, error) { +func (h *HoustonClient) ChallengeKeySetupStart( + req model.ChallengeSetupJson, +) (model.SetupChallengeResponseJson, error) { r := request[model.SetupChallengeResponseJson]{ Method: MethodPost, Path: "/user/challenge/setup/start", @@ -60,7 +78,9 @@ func (h *HoustonClient) ChallengeKeySetupFinish(req model.ChallengeSetupVerifyJs return err } -func (h *HoustonClient) ChallengeSetupFinishWithVerifiableMuunKey(req model.ChallengeSetupVerifyJson) (model.VerifiableMuunKeyJson, error) { +func (h *HoustonClient) ChallengeSetupFinishWithVerifiableMuunKey( + req model.ChallengeSetupVerifyJson, +) (model.VerifiableMuunKeyJson, error) { r := request[model.VerifiableMuunKeyJson]{ Method: MethodPost, @@ -81,7 +101,7 @@ func (h *HoustonClient) VerifiableMuunKey() (model.VerifiableMuunKeyJson, error) } func (h *HoustonClient) CreateFirstSession( - createSessionJson model.CreateFirstSessionJson, + createSessionJson model.CreateFirstSessionJson, //nolint:staticcheck // TODO: method parameter createSessionJson should be createSessionJSON ) (model.CreateFirstSessionOkJson, error) { r := request[model.CreateFirstSessionOkJson]{ @@ -110,26 +130,57 @@ func (h *HoustonClient) SubmitDiagnosticsScanData(req model.DiagnosticScanDataJs return err } -func (h *HoustonClient) ChallengeSecurityCardPair() (model.ChallengeSecurityCardPairJson, error) { +func (h *HoustonClient) PairRequestChallenge() (model.PairRequestChallengeResponseJSON, error) { + //TODO implement me + panic("implement me") +} + +func (h *HoustonClient) PairSubmitSignedChallenge( + req model.PairSubmitSignedChallengeJSON, //nolint:revive // req is required by the interface; unused until this production stub is implemented +) (model.PairSubmitSignedChallengeResponseJSON, error) { + //TODO implement me + panic("implement me") +} + +func (h *HoustonClient) SignRequestChallenge( + req model.SignRequestChallengeJSON, //nolint:revive // req is required by the interface; unused until this production stub is implemented +) (model.SignRequestChallengeResponseJSON, error) { + //TODO implement me + panic("implement me") +} + +func (h *HoustonClient) SignSubmitSignedChallenge( + req model.SignSubmitSignedChallengeJSON, //nolint:revive // req is required by the interface; unused until this production stub is implemented +) error { //TODO implement me panic("implement me") } func (h *HoustonClient) RegisterSecurityCard( - req model.RegisterSecurityCardJson, + req model.RegisterSecurityCardJson, //nolint:revive // TODO: use or remove req ) (model.RegisterSecurityCardOkJson, error) { //TODO implement me panic("implement me") } func (h *HoustonClient) ChallengeSecurityCardSign( - req model.ChallengeSecurityCardSignJson, + req model.ChallengeSecurityCardSignJson, //nolint:revive // TODO: use or remove req ) (model.ChallengeSecurityCardSignResponseJson, error) { //TODO implement me panic("implement me") } -func (h *HoustonClient) SolveSecurityCardChallenge(req model.SolveSecurityCardChallengeJson) error { +func (h *HoustonClient) SolveSecurityCardChallenge( + req model.SolveSecurityCardChallengeJson, //nolint:revive // TODO: use or remove req +) error { + //TODO implement me + panic("implement me") +} + +func (h *HoustonClient) FetchSecurityCardsMarketplace() ( + model.SecurityCardsMarketplaceJson, + error, +) { //TODO implement me panic("implement me") } diff --git a/libwallet/service/houston_test.go b/libwallet/service/houston_test.go index ee94227b..92a8a91f 100644 --- a/libwallet/service/houston_test.go +++ b/libwallet/service/houston_test.go @@ -2,18 +2,20 @@ package service import ( "encoding/json" - "fmt" - "github.com/muun/libwallet/service/model" "log" "os" "strconv" "strings" "testing" "time" + + "github.com/go-errors/errors" + + "github.com/muun/libwallet/service/model" ) // 127.0.0.1 instead of localhost to avoid problems with network interfaces in local env -const houstonUrl string = "http://127.0.0.1:8080" +const houstonUrl string = "http://127.0.0.1:8080" //nolint:staticcheck // TODO: const houstonUrl should be houstonURL func defaultProvider() *TestProvider { return &TestProvider{ @@ -57,7 +59,7 @@ func waitForHealthcheck() error { deadline := time.Now().Add(timeout) for { if time.Now().After(deadline) { - return fmt.Errorf("healthcheck failed after %s", timeout) + return errors.Errorf("healthcheck failed after %s", timeout) } houstonService := NewHoustonService(defaultProvider()) @@ -223,7 +225,7 @@ func TestValidEndpointAndValidAuthToken_Integration(t *testing.T) { houstonService := NewHoustonService(&provider) // Create first session to get and set a valid AuthToken - sessionJson := model.CreateFirstSessionJson{ + sessionJson := model.CreateFirstSessionJson{ //nolint:staticcheck // TODO: var sessionJson should be sessionJSON Client: model.ClientJson{ Type: provider.ClientType, BuildType: "debug", @@ -234,11 +236,13 @@ func TestValidEndpointAndValidAuthToken_Integration(t *testing.T) { GcmToken: nil, PrimaryCurrency: "USD", BasePublicKey: model.PublicKeyJson{ - Key: "tpubDAygaiK3eZ9hpC3aQkxtu5fGSTK4P7QKTwwGExN8hGZytjpEfsrUjtM8ics8Y7YLrvf1GLBZTFjcpmkEP1KKTRyo8D2ku5zz49bRudDrngd", + Key: "tpubDAygaiK3eZ9hpC3aQkxtu5fGSTK4P7QKTwwGExN8hGZytjpEfsrUjtM8ics8Y7YLrvf1GLBZTFjcpmkEP1KKTRyo8D2ku5zz49bRudDrngd", //nolint:lll Path: "m/schema:1'/recovery:1'", }, } - sessionOkJson, err := houstonService.CreateFirstSession(sessionJson) + sessionOkJson, err := houstonService.CreateFirstSession( //nolint:staticcheck // TODO: var sessionOkJson should be sessionOkJSON + sessionJson, + ) if err != nil { t.Fatal(err) } diff --git a/libwallet/service/mock_houston.go b/libwallet/service/mock_houston.go index 91040a70..544ef6c5 100644 --- a/libwallet/service/mock_houston.go +++ b/libwallet/service/mock_houston.go @@ -4,19 +4,22 @@ import ( "crypto/ecdh" "crypto/ecdsa" "crypto/elliptic" + "crypto/hmac" "crypto/rand" "crypto/sha256" "encoding/hex" - "errors" "fmt" - "github.com/muun/libwallet/cryptography" - "github.com/muun/libwallet/domain/nfc" - "github.com/muun/libwallet/service/model" - "github.com/muun/libwallet/storage" "log/slog" "math/big" "reflect" "time" + + "github.com/go-errors/errors" + + "github.com/muun/libwallet/cryptography" + "github.com/muun/libwallet/domain/nfc" + "github.com/muun/libwallet/service/model" + "github.com/muun/libwallet/storage" ) type RandomPrivateKeyMetadata struct { @@ -29,6 +32,7 @@ type MockHoustonService struct { lastRandomPrivateKeyMetadata *RandomPrivateKeyMetadata secretCardBytes [32]byte securityCardUsageCount uint16 + replayCounter uint16 pairingSlot uint16 } @@ -57,7 +61,9 @@ func (m *MockHoustonService) HealthCheck() error { panic("implement me") } -func (m *MockHoustonService) ChallengeKeySetupStart(req model.ChallengeSetupJson) (model.SetupChallengeResponseJson, error) { +func (m *MockHoustonService) ChallengeKeySetupStart( + req model.ChallengeSetupJson, +) (model.SetupChallengeResponseJson, error) { //TODO implement me panic("implement me") } @@ -67,7 +73,9 @@ func (m *MockHoustonService) ChallengeKeySetupFinish(req model.ChallengeSetupVer panic("implement me") } -func (m *MockHoustonService) ChallengeSetupFinishWithVerifiableMuunKey(req model.ChallengeSetupVerifyJson) (model.VerifiableMuunKeyJson, error) { +func (m *MockHoustonService) ChallengeSetupFinishWithVerifiableMuunKey( + req model.ChallengeSetupVerifyJson, +) (model.VerifiableMuunKeyJson, error) { //TODO implement me panic("implement me") } @@ -77,7 +85,9 @@ func (m *MockHoustonService) VerifiableMuunKey() (model.VerifiableMuunKeyJson, e panic("implement me") } -func (m *MockHoustonService) CreateFirstSession(createSessionJson model.CreateFirstSessionJson) (model.CreateFirstSessionOkJson, error) { +func (m *MockHoustonService) CreateFirstSession( + createSessionJson model.CreateFirstSessionJson, +) (model.CreateFirstSessionOkJson, error) { //TODO implement me panic("implement me") } @@ -92,17 +102,17 @@ func (m *MockHoustonService) SubmitDiagnosticsScanData(req model.DiagnosticScanD panic("implement me") } -func (m *MockHoustonService) ChallengeSecurityCardPair() (model.ChallengeSecurityCardPairJson, error) { +func (m *MockHoustonService) PairRequestChallenge() (model.PairRequestChallengeResponseJSON, error) { err := m.loadCardData() if err != nil { houstonError := mapToInternalServerHoustonError("error loading houston data", err) - return model.ChallengeSecurityCardPairJson{}, houstonError + return model.PairRequestChallengeResponseJSON{}, houstonError } randomPrivateKey, err := ecdh.P256().GenerateKey(rand.Reader) if err != nil { houstonError := mapToInternalServerHoustonError("error generating private key", err) - return model.ChallengeSecurityCardPairJson{}, houstonError + return model.PairRequestChallengeResponseJSON{}, houstonError } m.lastRandomPrivateKeyMetadata = &RandomPrivateKeyMetadata{ @@ -110,13 +120,21 @@ func (m *MockHoustonService) ChallengeSecurityCardPair() (model.ChallengeSecurit timeStamp: time.Now(), } - randomPublicKey := randomPrivateKey.PublicKey().Bytes() + // Persists the fresh challenge so the upcoming submit loads it. + err = m.persistCardData() + if err != nil { + houstonError := mapToInternalServerHoustonError("error persisting houston data", err) + return model.PairRequestChallengeResponseJSON{}, houstonError + } - return model.ChallengeSecurityCardPairJson{ - ServerPublicKeyInHex: hex.EncodeToString(randomPublicKey), + randomPubKey := randomPrivateKey.PublicKey().Bytes() + + return model.PairRequestChallengeResponseJSON{ + ServerPubKeyInHex: hex.EncodeToString(randomPubKey), }, nil } +// Deprecated: V2 firmware only, will be removed once V3 is fully released. func (m *MockHoustonService) RegisterSecurityCard( req model.RegisterSecurityCardJson, ) (model.RegisterSecurityCardOkJson, error) { @@ -187,7 +205,7 @@ func (m *MockHoustonService) RegisterSecurityCard( return model.RegisterSecurityCardOkJson{}, &HoustonResponseError{ DeveloperMessage: err.Error(), ErrorCode: ErrInvalidMac, - Message: "mac verification failed: the message data has been tampered with or corrupted.", + Message: "invalid mac: the message data has been tampered with or corrupted.", RequestId: 0, Status: StatusClientFailure, } @@ -206,7 +224,11 @@ func (m *MockHoustonService) RegisterSecurityCard( } // Verify signed MAC with global public key - isValidated, err := m.verifySignature(globalPublicKeyBytes, receivedMacBytes, globalSignCardBytes) + isValidated, err := m.verifySignature( + globalPublicKeyBytes, + receivedMacBytes, + globalSignCardBytes, + ) if err != nil { houstonError := mapToInternalServerHoustonError("error with mac sig verification", err) return model.RegisterSecurityCardOkJson{}, houstonError @@ -244,6 +266,325 @@ func (m *MockHoustonService) RegisterSecurityCard( }, nil } +// PairSubmitSignedChallenge completes the V3 pairing flow. +func (m *MockHoustonService) PairSubmitSignedChallenge( + req model.PairSubmitSignedChallengeJSON, +) (model.PairSubmitSignedChallengeResponseJSON, error) { + err := m.loadCardData() + if err != nil { + houstonError := mapToInternalServerHoustonError("error loading houston data", err) + return model.PairSubmitSignedChallengeResponseJSON{}, houstonError + } + + if m.lastRandomPrivateKeyMetadata == nil { + return model.PairSubmitSignedChallengeResponseJSON{}, &HoustonResponseError{ + DeveloperMessage: "no pending pair challenge", + ErrorCode: ErrChallengeExpired, + Message: "no pending pair challenge", + RequestId: 0, + Status: StatusClientFailure, + } + } + + // Validates the challenge timeout (90s since PairRequestChallenge). + timeSinceLastChallenge := time.Since(m.lastRandomPrivateKeyMetadata.timeStamp).Seconds() + if timeSinceLastChallenge > challengeTimeoutInSeconds { + return model.PairSubmitSignedChallengeResponseJSON{}, &HoustonResponseError{ + DeveloperMessage: "challenge has expired", + ErrorCode: ErrChallengeExpired, + Message: "challenge has expired", + RequestId: 0, + Status: StatusClientFailure, + } + } + + metadataBytes, err := hex.DecodeString(req.MetadataInHex) + if err != nil { + houstonError := mapToInternalServerHoustonError("error decoding metadata", err) + return model.PairSubmitSignedChallengeResponseJSON{}, houstonError + } + + // Parses the metadata; ParseMetadataV3 also validates that + // attestationPub lies on secp256r1. + metadata, err := nfc.ParseMetadataV3(metadataBytes) + if err != nil { + houstonError := mapToInternalServerHoustonError("invalid metadata", err) + return model.PairSubmitSignedChallengeResponseJSON{}, houstonError + } + + // TODO (Houston): validate action_pub lies on secp256r1. + + // Validates P lies on secp256r1. + cardPubKeyBytes, err := hex.DecodeString(req.CardPubKeyInHex) + if err != nil { + houstonError := mapToInternalServerHoustonError("error decoding card pub key", err) + return model.PairSubmitSignedChallengeResponseJSON{}, houstonError + } + if err := cryptography.ValidateSecp256r1PublicKey(cardPubKeyBytes); err != nil { + houstonError := mapToInternalServerHoustonError("invalid card public key", err) + return model.PairSubmitSignedChallengeResponseJSON{}, houstonError + } + + // Computes DH1 = c·attestationPubKey and DH2 = muunPriv·P, then + // derives secret_card = HMAC("pairing-secret", DH1||DH2). + serverPrivKeyBytes := m.lastRandomPrivateKeyMetadata.privateKey.Bytes() + dh1, err := cryptography.ECDH(serverPrivKeyBytes, metadata.AttestationPub[:]) + if err != nil { + houstonError := mapToInternalServerHoustonError("DH1 error", err) + return model.PairSubmitSignedChallengeResponseJSON{}, houstonError + } + dh2, err := cryptography.ECDH(muunPrivDevBytes, cardPubKeyBytes) + if err != nil { + houstonError := mapToInternalServerHoustonError("DH2 error", err) + return model.PairSubmitSignedChallengeResponseJSON{}, houstonError + } + + ikm := make([]byte, 0, len(dh1)+len(dh2)) + ikm = append(ikm, dh1...) + ikm = append(ikm, dh2...) + secretCard := nfc.ComputeHMACSHA256([]byte("pairing-secret"), ikm) + + // Verifies MAC = HMAC(secret_card, "pairing-response"||C||P||index||metadata). + // Constant-time compare via hmac.Equal. + receivedMacBytes, err := hex.DecodeString(req.MacInHex) + if err != nil { + houstonError := mapToInternalServerHoustonError("error decoding mac", err) + return model.PairSubmitSignedChallengeResponseJSON{}, houstonError + } + + serverPubKeyBytes := m.lastRandomPrivateKeyMetadata.privateKey.PublicKey().Bytes() + err = verifyPairingMACV3( + secretCard, + serverPubKeyBytes, + cardPubKeyBytes, + req.Index, + metadataBytes, + receivedMacBytes, + ) + if err != nil { + return model.PairSubmitSignedChallengeResponseJSON{}, &HoustonResponseError{ + DeveloperMessage: err.Error(), + ErrorCode: ErrInvalidMac, + Message: "invalid mac: the message data has been tampered with or corrupted.", + RequestId: 0, + Status: StatusClientFailure, + } + } + + // TODO Houston: verify the attestation certificate chain. The metadata + // carries providerPubKey + providerSig where providerSig is an ECDSA + // signature over attestationPubKey. Houston should verify that + // providerPubKey belongs to a trusted provider allowlist and that + // providerSig validates attestationPubKey under it. On failure return + // SECURITY_CARD_INVALID_SIGNATURE; the response's + // IsKnownProvider flag mirrors whether the chain validated. The mock + // currently hardcodes IsKnownProvider=true because it has no allowlist. + + // TODO Houston: reject if a card with this attestationPubKey is already + // paired to this wallet. The attestationPubKey is the card's permanent + // identity, so collisions imply double-pairing. On failure return + // SECURITY_CARD_ALREADY_PAIRED_TO_THIS_WALLET. The mock cannot + // enforce this without a persisted card registry, which is out of + // scope here. + + // replayCounter starts at 0 for this slot — it tracks per-slot sign + // challenge usage for replay protection. + m.replayCounter = 0 + copy(m.secretCardBytes[:], secretCard) + m.pairingSlot = req.Index + + err = m.persistCardData() + if err != nil { + houstonError := mapToInternalServerHoustonError("error persisting houston data", err) + return model.PairSubmitSignedChallengeResponseJSON{}, houstonError + } + + metadataJSON, err := mapSecurityCardV3MetadataJSON(metadata) + if err != nil { + houstonError := mapToInternalServerHoustonError("error mapping metadata", err) + return model.PairSubmitSignedChallengeResponseJSON{}, houstonError + } + + return model.PairSubmitSignedChallengeResponseJSON{ + SecurityCard: model.PairedSecurityCardJSON{ + ID: 1, + Metadata: *metadataJSON, + PairedAt: time.Now().UTC().Format(time.RFC3339), + }, + IsKnownProvider: true, + IsCardAlreadyUsed: false, + }, nil +} + +// SignRequestChallenge issues a V3 sign challenge to approve a sensitive +// action (e.g. a new on-chain operation). The card MAC-verifies the +// challenge and emits a response after the user taps to approve. +func (m *MockHoustonService) SignRequestChallenge( + _ model.SignRequestChallengeJSON, +) (model.SignRequestChallengeResponseJSON, error) { + + // Loads the paired card state (secret_card, counter). + err := m.loadCardData() + if err != nil { + houstonError := mapToInternalServerHoustonError("error loading houston data", err) + return model.SignRequestChallengeResponseJSON{}, houstonError + } + + // Generates ephemeral (c, C), increments replayCounter. + randomPrivateKey, err := ecdh.P256().GenerateKey(rand.Reader) + if err != nil { + houstonError := mapToInternalServerHoustonError("error generating private key", err) + return model.SignRequestChallengeResponseJSON{}, houstonError + } + + // NOTE (Houston): issuing a new challenge should expire any open + // challenge already outstanding for this wallet. The mock holds a + // single in-flight challenge, so overwriting lastRandomPrivateKeyMetadata + // implicitly drops the previous one; real Houston must expire them + // explicitly so stale challenges can't be replayed. + m.lastRandomPrivateKeyMetadata = &RandomPrivateKeyMetadata{ + privateKey: randomPrivateKey, + timeStamp: time.Now(), + } + + m.replayCounter++ + serverPubKeyBytes := randomPrivateKey.PublicKey().Bytes() + + // Empty reason: the card displays this text on its screen during + // approval, so only screen-capable cards need it populated. The + // mock doesn't persist capabilities yet, so it defaults to the + // non-screen behavior (empty). + // TODO (Houston): generate the reason text for screen-capable cards. + var reasonBytes []byte + + // index is the pairing slot persisted at pair time. + // V3 firmware is single-slot today (so it is 0). + macBytes := computeSignChallengeMACV3( + m.secretCardBytes[:], + serverPubKeyBytes, + m.replayCounter, + m.pairingSlot, + reasonBytes, + ) + + // Persists the advanced counter and new ephemeral so the upcoming + // submit can verify the card's response. + err = m.persistCardData() + if err != nil { + houstonError := mapToInternalServerHoustonError("error persisting houston data", err) + return model.SignRequestChallengeResponseJSON{}, houstonError + } + + return model.SignRequestChallengeResponseJSON{ + ServerPubKeyInHex: hex.EncodeToString(serverPubKeyBytes), + ReasonInHex: hex.EncodeToString(reasonBytes), + PerCardPayloads: []model.SignChallengePerCardPayloadJSON{ + { + // TODO multi-pairing: leave empty until firmware + V3 + // client + Houston track attestationPubKey end-to-end. + AttestationPubKeyInHex: "", + Index: m.pairingSlot, + MacInHex: hex.EncodeToString(macBytes), + ReplayCounter: m.replayCounter, + }, + }, + }, nil +} + +// SignSubmitSignedChallenge completes the V3 sign flow. The client +// forwards the card's response (P, MAC) from the sign challenge. +func (m *MockHoustonService) SignSubmitSignedChallenge( + req model.SignSubmitSignedChallengeJSON, +) error { + // Loads the in-flight ephemeral c and the current secret_card. + err := m.loadCardData() + if err != nil { + return mapToInternalServerHoustonError("error loading houston data", err) + } + + if m.lastRandomPrivateKeyMetadata == nil { + return &HoustonResponseError{ + DeveloperMessage: "no pending sign challenge", + ErrorCode: ErrChallengeExpired, + Message: "no pending sign challenge", + RequestId: 0, + Status: StatusClientFailure, + } + } + + // Validates the challenge timeout (90s since SignRequestChallenge). + timeSinceLastChallenge := time.Since(m.lastRandomPrivateKeyMetadata.timeStamp).Seconds() + if timeSinceLastChallenge > challengeTimeoutInSeconds { + return &HoustonResponseError{ + DeveloperMessage: "challenge has expired", + ErrorCode: ErrChallengeExpired, + Message: "challenge has expired", + RequestId: 0, + Status: StatusClientFailure, + } + } + + // Decodes and validates P on secp256r1. + cardPubKeyBytes, err := hex.DecodeString(req.CardPubKeyInHex) + if err != nil { + return mapToInternalServerHoustonError("error decoding card pub key", err) + } + if err := cryptography.ValidateSecp256r1PublicKey(cardPubKeyBytes); err != nil { + return mapToInternalServerHoustonError("invalid card public key", err) + } + + // Verifies MAC = HMAC(secret_card, "challenge-response"||C||P). + receivedMacBytes, err := hex.DecodeString(req.MacInHex) + if err != nil { + return mapToInternalServerHoustonError("error decoding mac", err) + } + + serverPubKeyBytes := m.lastRandomPrivateKeyMetadata.privateKey.PublicKey().Bytes() + err = verifySignChallengeResponseMACV3( + m.secretCardBytes[:], + serverPubKeyBytes, + cardPubKeyBytes, + receivedMacBytes, + ) + if err != nil { + return &HoustonResponseError{ + DeveloperMessage: err.Error(), + ErrorCode: ErrInvalidMac, + Message: "invalid mac: the message data has been tampered with or corrupted.", + RequestId: 0, + Status: StatusClientFailure, + } + } + + // TODO (Houston): verify action_sig — the card's signature over the + // approved action — to prove the tap authorized this specific action. + + // Ratchet secret_card := HMAC(secret_card, "challenge-ratchet"||DH) + // where DH = ECDH(c, P). The card computes the same ratchet on its + // side from (p, C), so the next sign challenge uses a fresh secret + // on both ends. + dh, err := cryptography.ECDH( + m.lastRandomPrivateKeyMetadata.privateKey.Bytes(), + cardPubKeyBytes, + ) + if err != nil { + return mapToInternalServerHoustonError("ECDH error", err) + } + + ratchetIKM := append([]byte("challenge-ratchet"), dh...) + newSecretCard := nfc.ComputeHMACSHA256(m.secretCardBytes[:], ratchetIKM) + copy(m.secretCardBytes[:], newSecretCard) + + // Persists the new secret_card. + err = m.persistCardData() + if err != nil { + return mapToInternalServerHoustonError("error persisting houston data", err) + } + + return nil +} + +// Deprecated: V2 firmware only, will be removed once V3 is fully released. func (m *MockHoustonService) ChallengeSecurityCardSign( req model.ChallengeSecurityCardSignJson, ) (model.ChallengeSecurityCardSignResponseJson, error) { @@ -296,7 +637,10 @@ func (m *MockHoustonService) ChallengeSecurityCardSign( }, nil } -func (m *MockHoustonService) SolveSecurityCardChallenge(req model.SolveSecurityCardChallengeJson) error { +// Deprecated: V2 firmware only, will be removed once V3 is fully released. +func (m *MockHoustonService) SolveSecurityCardChallenge( + req model.SolveSecurityCardChallengeJson, +) error { timeSinceLastChallenge := time.Since(m.lastRandomPrivateKeyMetadata.timeStamp).Seconds() if timeSinceLastChallenge > challengeTimeoutInSeconds { return &HoustonResponseError{ @@ -326,7 +670,7 @@ func (m *MockHoustonService) SolveSecurityCardChallenge(req model.SolveSecurityC return &HoustonResponseError{ DeveloperMessage: err.Error(), ErrorCode: ErrInvalidMac, - Message: "mac verification failed: the message data has been tampered with or corrupted.", + Message: "invalid mac: the message data has been tampered with or corrupted.", RequestId: 0, Status: StatusClientFailure, } @@ -355,6 +699,313 @@ func (m *MockHoustonService) SolveSecurityCardChallenge(req model.SolveSecurityC return nil } +func (m *MockHoustonService) FetchSecurityCardsMarketplace() (model.SecurityCardsMarketplaceJson, error) { + argentina := model.CountryInfoJson{Code: "AR", Name: "Argentina", Flag: "🇦🇷"} + uruguay := model.CountryInfoJson{Code: "UY", Name: "Uruguay", Flag: "🇺🇾"} + brazil := model.CountryInfoJson{Code: "BR", Name: "Brazil", Flag: "🇧🇷"} + + return model.SecurityCardsMarketplaceJson{ + Providers: []model.SecurityCardsProviderJson{ + { + Id: "constellations", + Name: "Constellations", + Description: "Constellations are officially recognized patterns of stars in the night sky that form recognizable shapes, figures, or outlines.", + SiteUrl: "https://en.wikipedia.org/wiki/Constellation", + LightTheme: model.ProviderThemeJson{ + PrimaryColor: "#B19B6A", + SurfaceColor: "#0DB19B6A", + }, + DarkTheme: model.ProviderThemeJson{ + PrimaryColor: "#B19B6A", + SurfaceColor: "#0DB19B6A", + }, + SecurityCards: []model.SecurityCardJson{ + { + Id: "constellations_scorpius", + AssetUrl: "https://placehold.co/2594x1632/FFF8E7/AFC9FF/png?text=SCORPIUS", + SpecId: "constellations_spec", + CardCost: model.PriceInfoJson{CurrencyCode: "EUR", Amount: "10000"}, + }, + { + Id: "constellations_gemini", + AssetUrl: "https://placehold.co/2594x1632/FFF8E7/AFC9FF/png?text=GEMINI", + SpecId: "constellations_spec", + CardCost: model.PriceInfoJson{CurrencyCode: "EUR", Amount: "20000"}, + }, + { + Id: "constellations_sagitarius", + AssetUrl: "https://placehold.co/2594x1632/FFF8E7/AFC9FF/png?text=SAGITARIUS", + SpecId: "constellations_spec", + CardCost: model.PriceInfoJson{CurrencyCode: "EUR", Amount: "30000"}, + }, + { + Id: "constellations_virgo", + AssetUrl: "https://placehold.co/2594x1632/FFF8E7/AFC9FF/png?text=VIRGO", + SpecId: "constellations_spec", + CardCost: model.PriceInfoJson{CurrencyCode: "EUR", Amount: "30000"}, + }, + }, + EstimatedShippingPrices: []model.ShippingPriceInfoJson{ + { + Price: model.PriceInfoJson{CurrencyCode: "EUR", Amount: "1500"}, + Countries: []model.CountryInfoJson{argentina, uruguay}, + }, + { + Price: model.PriceInfoJson{CurrencyCode: "EUR", Amount: "3000"}, + Countries: []model.CountryInfoJson{brazil}, + }, + }, + }, + { + Id: "numbers", + Name: "Numbers", + Description: "Numbers are mathematical objects used for counting, measuring, and labeling, with primary types including natural numbers (1, 2, 3...), whole numbers, and integers", + SiteUrl: "https://en.wikipedia.org/wiki/Number", + LightTheme: model.ProviderThemeJson{ + PrimaryColor: "#D9DBDD", + SurfaceColor: "#0DD9DBDD", + }, + DarkTheme: model.ProviderThemeJson{ + PrimaryColor: "#D9DBDD", + SurfaceColor: "#0DD9DBDD", + }, + SecurityCards: []model.SecurityCardJson{ + { + Id: "numbers_1", + AssetUrl: "https://placehold.co/2594x1632/8B1A1A/4A0D0D/png?text=1", + SpecId: "numbers_spec", + CardCost: model.PriceInfoJson{CurrencyCode: "USD", Amount: "10000"}, + }, + { + Id: "numbers_2", + AssetUrl: "https://placehold.co/2594x1632/C9A227/7A5C10/png?text=2", + SpecId: "numbers_spec", + CardCost: model.PriceInfoJson{CurrencyCode: "ARS", Amount: "20000"}, + }, + { + Id: "numbers_3", + AssetUrl: "https://placehold.co/2594x1632/1A4E8C/0C2E5E/png?text=3", + SpecId: "numbers_spec", + CardCost: model.PriceInfoJson{CurrencyCode: "ARS", Amount: "30000"}, + }, + { + Id: "numbers_4", + AssetUrl: "https://placehold.co/2594x1632/C45A1A/6E2E08/png?text=4", + SpecId: "numbers_spec", + CardCost: model.PriceInfoJson{CurrencyCode: "ARS", Amount: "40000"}, + }, + { + Id: "numbers_5", + AssetUrl: "https://placehold.co/2594x1632/1A6E3A/0B3D1F/png?text=5", + SpecId: "numbers_spec", + CardCost: model.PriceInfoJson{CurrencyCode: "ARS", Amount: "50000"}, + }, + { + Id: "numbers_6", + AssetUrl: "https://placehold.co/2594x1632/5E2A7A/2E1240/png?text=6", + SpecId: "numbers_spec", + CardCost: model.PriceInfoJson{CurrencyCode: "ARS", Amount: "60000"}, + }, + { + Id: "numbers_7", + AssetUrl: "https://placehold.co/2594x1632/0E5E6E/04323E/png?text=7", + SpecId: "numbers_spec", + CardCost: model.PriceInfoJson{CurrencyCode: "ARS", Amount: "70000"}, + }, + { + Id: "numbers_8", + AssetUrl: "https://placehold.co/2594x1632/19A0B0/0A5E6A/png?text=8", + SpecId: "numbers_spec", + CardCost: model.PriceInfoJson{CurrencyCode: "ARS", Amount: "80000"}, + }, + { + Id: "numbers_9", + AssetUrl: "https://placehold.co/2594x1632/B02A8E/5C1149/png?text=9", + Tag: "OUT_OF_STOCK", + SpecId: "numbers_spec", + CardCost: model.PriceInfoJson{CurrencyCode: "ARS", Amount: "90000"}, + }, + }, + EstimatedShippingPrices: []model.ShippingPriceInfoJson{ + { + Price: model.PriceInfoJson{CurrencyCode: "USD", Amount: "1000"}, + Countries: []model.CountryInfoJson{argentina, uruguay}, + }, + { + Price: model.PriceInfoJson{CurrencyCode: "USD", Amount: "2000"}, + Countries: []model.CountryInfoJson{brazil}, + }, + }, + }, + { + Id: "planets", + Name: "Planets", + Description: "There are eight officially recognized planets in our solar system, orbiting the Sun in this order: Mercury, Venus, Earth, Mars, Jupiter, Saturn, Uranus, and Neptune.", + SiteUrl: "https://en.wikipedia.org/wiki/Planet", + LightTheme: model.ProviderThemeJson{ + PrimaryColor: "#158E5A", + SurfaceColor: "#0D158E5A", + }, + DarkTheme: model.ProviderThemeJson{ + PrimaryColor: "#158E5A", + SurfaceColor: "#0D158E5A", + }, + SecurityCards: []model.SecurityCardJson{ + { + Id: "planets_earth", + AssetUrl: "https://placehold.co/2594x1632/081448/3B5D38/png?text=EARTH", + Tag: "METAL", + SpecId: "planets_spec", + CardCost: model.PriceInfoJson{CurrencyCode: "USD", Amount: "10000"}, + }, + { + Id: "planets_mars", + AssetUrl: "https://placehold.co/2594x1632/081448/C1440E/png?text=MARS", + Tag: "METAL", + SpecId: "planets_spec", + CardCost: model.PriceInfoJson{CurrencyCode: "USD", Amount: "20000"}, + }, + }, + EstimatedShippingPrices: []model.ShippingPriceInfoJson{ + { + Price: model.PriceInfoJson{CurrencyCode: "USD", Amount: "1000"}, + Countries: []model.CountryInfoJson{argentina, uruguay}, + }, + { + Price: model.PriceInfoJson{CurrencyCode: "USD", Amount: "2000"}, + Countries: []model.CountryInfoJson{brazil}, + }, + }, + }, + }, + Specs: []model.SecurityCardSpecJson{ + { + SpecId: "constellations_spec", + Items: map[string][]model.SecurityCardSpecItemJson{ + "primary": { + {IconUrl: "https://placehold.co/16x16/FF0000/000000/png?text=ic", Label: "Material", Value: "Plastic"}, + {IconUrl: "https://placehold.co/16x16/FF0000/000000/png?text=ic", Label: "From", Value: "Sky"}, + {IconUrl: "https://placehold.co/16x16/FF0000/000000/png?text=ic", Label: "Arrives in", Value: "Already there"}, + }, + "specifications": { + {IconUrl: "https://placehold.co/16x16/FF0000/000000/png?text=ic", Label: "Material", Value: "Plastic"}, + {IconUrl: "https://placehold.co/16x16/FF0000/000000/png?text=ic", Label: "Thickness", Value: "0.8mm"}, + {IconUrl: "https://placehold.co/16x16/FF0000/000000/png?text=ic", Label: "Weight", Value: "5g"}, + }, + "security": { + { + IconUrl: "https://placehold.co/16x16/FF0000/000000/png?text=ic", + Label: "Secure Element", + Value: "EAL 5+", + AdditionalData: "Lorem Ipsum Lorem Ipsum Lorem Ipsum", + }, + { + IconUrl: "https://placehold.co/16x16/FF0000/000000/png?text=ic", + Label: "Firmware", + Value: "Designed by Muun", + AdditionalData: "Lorem Ipsum Lorem Ipsum Lorem Ipsum", + }, + {IconUrl: "https://placehold.co/16x16/FF0000/000000/png?text=ic", Label: "Packaging", Value: "Tamper resistant"}, + }, + "delivery": { + {IconUrl: "https://placehold.co/16x16/FF0000/000000/png?text=ic", Label: "Shipped by", Value: "Sky"}, + {IconUrl: "https://placehold.co/16x16/FF0000/000000/png?text=ic", Label: "From", Value: "Sky"}, + {IconUrl: "https://placehold.co/16x16/FF0000/000000/png?text=ic", Label: "Arrives in", Value: "Already there"}, + { + IconUrl: "https://placehold.co/16x16/FF0000/000000/png?text=ic", + Label: "Shipping data", + Value: "Under GDPR", + AdditionalData: "Lorem Ipsum Lorem Ipsum Lorem Ipsum", + }, + }, + }, + }, + { + SpecId: "numbers_spec", + Items: map[string][]model.SecurityCardSpecItemJson{ + "primary": { + {IconUrl: "https://placehold.co/16x16/FF0000/000000/png?text=ic", Label: "Material", Value: "Plastic"}, + {IconUrl: "https://placehold.co/16x16/FF0000/000000/png?text=ic", Label: "From", Value: "Math"}, + {IconUrl: "https://placehold.co/16x16/FF0000/000000/png?text=ic", Label: "Arrives in", Value: "Already here"}, + }, + "specifications": { + {IconUrl: "https://placehold.co/16x16/FF0000/000000/png?text=ic", Label: "Material", Value: "Plastic"}, + {IconUrl: "https://placehold.co/16x16/FF0000/000000/png?text=ic", Label: "Thickness", Value: "0.8mm"}, + {IconUrl: "https://placehold.co/16x16/FF0000/000000/png?text=ic", Label: "Weight", Value: "5g"}, + }, + "security": { + { + IconUrl: "https://placehold.co/16x16/FF0000/000000/png?text=ic", + Label: "Secure Element", + Value: "EAL 5+", + AdditionalData: "Lorem Ipsum Lorem Ipsum Lorem Ipsum", + }, + { + IconUrl: "https://placehold.co/16x16/FF0000/000000/png?text=ic", + Label: "Firmware", + Value: "Designed by Muun", + AdditionalData: "Lorem Ipsum Lorem Ipsum Lorem Ipsum", + }, + {IconUrl: "https://placehold.co/16x16/FF0000/000000/png?text=ic", Label: "Packaging", Value: "Tamper resistant"}, + }, + "delivery": { + {IconUrl: "https://placehold.co/16x16/FF0000/000000/png?text=ic", Label: "Shipped by", Value: "Math"}, + {IconUrl: "https://placehold.co/16x16/FF0000/000000/png?text=ic", Label: "From", Value: "Math"}, + {IconUrl: "https://placehold.co/16x16/FF0000/000000/png?text=ic", Label: "Arrives in", Value: "Already here"}, + { + IconUrl: "https://placehold.co/16x16/FF0000/000000/png?text=ic", + Label: "Shipping data", + Value: "Under GDPR", + AdditionalData: "Lorem Ipsum Lorem Ipsum Lorem Ipsum", + }, + }, + }, + }, + { + SpecId: "planets_spec", + Items: map[string][]model.SecurityCardSpecItemJson{ + "primary": { + {IconUrl: "https://placehold.co/16x16/FF0000/000000/png?text=ic", Label: "Material", Value: "Metal"}, + {IconUrl: "https://placehold.co/16x16/FF0000/000000/png?text=ic", Label: "From", Value: "Space"}, + {IconUrl: "https://placehold.co/16x16/FF0000/000000/png?text=ic", Label: "Arrives in", Value: "Now"}, + }, + "specifications": { + {IconUrl: "https://placehold.co/16x16/FF0000/000000/png?text=ic", Label: "Material", Value: "Metal"}, + {IconUrl: "https://placehold.co/16x16/FF0000/000000/png?text=ic", Label: "Thickness", Value: "1.2mm"}, + {IconUrl: "https://placehold.co/16x16/FF0000/000000/png?text=ic", Label: "Weight", Value: "10g"}, + }, + "security": { + { + IconUrl: "https://placehold.co/16x16/FF0000/000000/png?text=ic", + Label: "Secure Element", + Value: "EAL 6+", + AdditionalData: "Lorem Ipsum Lorem Ipsum Lorem Ipsum", + }, + { + IconUrl: "https://placehold.co/16x16/FF0000/000000/png?text=ic", + Label: "Firmware", + Value: "Designed by Muun", + AdditionalData: "Lorem Ipsum Lorem Ipsum Lorem Ipsum", + }, + {IconUrl: "https://placehold.co/16x16/FF0000/000000/png?text=ic", Label: "Packaging", Value: "Tamper resistant"}, + }, + "delivery": { + {IconUrl: "https://placehold.co/16x16/FF0000/000000/png?text=ic", Label: "Shipped by", Value: "BigBang"}, + {IconUrl: "https://placehold.co/16x16/FF0000/000000/png?text=ic", Label: "From", Value: "Space"}, + {IconUrl: "https://placehold.co/16x16/FF0000/000000/png?text=ic", Label: "Arrives in", Value: "Now"}, + { + IconUrl: "https://placehold.co/16x16/FF0000/000000/png?text=ic", + Label: "Shipping data", + Value: "Under GDPR", + AdditionalData: "Lorem Ipsum Lorem Ipsum Lorem Ipsum", + }, + }, + }, + }, + }, + }, nil +} + func (m *MockHoustonService) verifySolveChallengeMac( receivedMac, serverPublicKeyBytes, @@ -402,6 +1053,119 @@ func verifyPairingMAC(cardPublicKey, return nil } +// verifyPairingMACV3 verifies the V3 pair-response MAC: +// +// MAC = HMAC(secret_card, "pairing-response" || C || P || index || metadata) +// +// metadataBytes is the raw wire input the card signed. Re-serializing +// the struct fields would risk drift and break the MAC. +// +// Uses hmac.Equal for constant-time comparison. +func verifyPairingMACV3( + secretCard, + serverPubKey, + cardPubKey []byte, + index uint16, + metadataBytes, + receivedMac []byte, +) error { + tag := []byte("pairing-response") + macInput := make( + []byte, + 0, + len(tag)+len(serverPubKey)+len(cardPubKey)+2+len(metadataBytes), + ) + macInput = append(macInput, tag...) + macInput = append(macInput, serverPubKey...) + macInput = append(macInput, cardPubKey...) + macInput = append(macInput, nfc.IntTo2Bytes(index)...) + macInput = append(macInput, metadataBytes...) + + expectedMAC := nfc.ComputeHMACSHA256(secretCard, macInput) + if !hmac.Equal(receivedMac, expectedMAC) { + return errors.Errorf("v3 pairing MAC mismatch") + } + return nil +} + +// maxRawReasonSizeV3 is the reason-length boundary for how the MAC +// commits to the reason. It equals what fits raw in a single short APDU +// alongside the fixed request fields +// (255 − C(65) − counter(2) − index(2) − has_more(1) − mac(32) = 153). +// At or below it the MAC commits to the raw reason; above it the MAC +// commits to SHA256(reason). Houston mirrors it so its MAC matches the card's. +const maxRawReasonSizeV3 = 153 + +// computeSignChallengeMACV3 computes the V3 sign-request MAC: +// +// MAC = HMAC(secret_card, "challenge-request" || C || counter || index || reasonForMAC) +// +// C is the server's ephemeral pub key, counter is the per-slot monotonic +// count_card, index is the pairing slot, and reason is the human-readable +// action text that the card would display in screen-capable variants. + +// reasonForMAC is the raw reason when it fits a short APDU, else +// SHA256(reason), matching how the card computes the MAC. +func computeSignChallengeMACV3( + secretCard, + serverPubKey []byte, + counter, + index uint16, + reason []byte, +) []byte { + // The card commits to the raw reason at or below the short-APDU + // length, and to SHA256(reason) above it. + reasonForMAC := reason + if len(reason) > maxRawReasonSizeV3 { + h := sha256.Sum256(reason) + reasonForMAC = h[:] + } + + tag := []byte("challenge-request") + macInput := make( + []byte, + 0, + len(tag)+len(serverPubKey)+2+2+len(reasonForMAC), + ) + macInput = append(macInput, tag...) + macInput = append(macInput, serverPubKey...) + macInput = append(macInput, nfc.IntTo2Bytes(counter)...) + macInput = append(macInput, nfc.IntTo2Bytes(index)...) + macInput = append(macInput, reasonForMAC...) + return nfc.ComputeHMACSHA256(secretCard, macInput) +} + +// verifySignChallengeResponseMACV3 verifies the V3 sign-response MAC: +// +// MAC = HMAC(secret_card, "challenge-response" || C || P) +// +// C is the server's ephemeral pub key from the sign-request and P is +// the card's ephemeral pub key from its response. +// No counter binding on the response MAC — that's a deliberate spec choice; +// the request MAC already commits to the counter. +func verifySignChallengeResponseMACV3( + secretCard, + serverPubKey, + cardPubKey, + receivedMac []byte, +) error { + tag := []byte("challenge-response") + macInput := make( + []byte, + 0, + len(tag)+len(serverPubKey)+len(cardPubKey), + ) + macInput = append(macInput, tag...) + macInput = append(macInput, serverPubKey...) + macInput = append(macInput, cardPubKey...) + + expectedMAC := nfc.ComputeHMACSHA256(secretCard, macInput) + if !hmac.Equal(receivedMac, expectedMAC) { + return errors.Errorf("v3 sign challenge response MAC mismatch") + } + return nil +} + func SecurityCardMetadataToBytes(m model.SecurityCardMetadataJson) ([]byte, error) { handleError := func(err error) error { return fmt.Errorf("error decoding metadata: %w", err) @@ -447,7 +1211,9 @@ func SecurityCardMetadataToBytes(m model.SecurityCardMetadataJson) ([]byte, erro } // verifySignature verifies a signature from a muuncard. -func (m *MockHoustonService) verifySignature(publicKeyBytes, messageBytes, signedMessageBytes []byte) (bool, error) { +func (m *MockHoustonService) verifySignature( + publicKeyBytes, messageBytes, signedMessageBytes []byte, +) (bool, error) { // verify expected public key if len(publicKeyBytes) != 65 || publicKeyBytes[0] != 0x04 { @@ -511,6 +1277,7 @@ func (m *MockHoustonService) persistCardData() error { // Note: LibwalletStorage IntType maps to int32, so we cast to int32 items[storage.KeySecurityCardUsageCount] = int32(m.securityCardUsageCount) + items[storage.KeySecurityCardReplayCounter] = int32(m.replayCounter) items[storage.KeySecurityCardPairingSlot] = int32(m.pairingSlot) secretCardInHex := hex.EncodeToString(m.secretCardBytes[:]) @@ -530,6 +1297,7 @@ func (m *MockHoustonService) loadCardData() error { var keys = []string{ storage.KeyLastRandomPrivKeyInHex, storage.KeySecurityCardUsageCount, + storage.KeySecurityCardReplayCounter, storage.KeySecurityCardPairingSlot, storage.KeySecretCardBytesInHex, storage.KeyTimeSinceLastChallengeUnixMillis, @@ -570,6 +1338,10 @@ func (m *MockHoustonService) loadCardData() error { m.securityCardUsageCount = uint16(keyValues[storage.KeySecurityCardUsageCount].(int32)) } + if keyValues[storage.KeySecurityCardReplayCounter] != nil { + m.replayCounter = uint16(keyValues[storage.KeySecurityCardReplayCounter].(int32)) + } + if keyValues[storage.KeySecretCardBytesInHex] != nil { secretCard, err := hex.DecodeString(keyValues[storage.KeySecretCardBytesInHex].(string)) if err != nil { diff --git a/libwallet/service/mock_houston_dev_key.go b/libwallet/service/mock_houston_dev_key.go new file mode 100644 index 00000000..e74c5a62 --- /dev/null +++ b/libwallet/service/mock_houston_dev_key.go @@ -0,0 +1,48 @@ +package service + +import ( + "bytes" + "crypto/ecdh" + "encoding/hex" + + "github.com/go-errors/errors" +) + +// DEV ONLY — this file holds the Muun server identity keypair used by +// MockHoustonService to simulate the V3 pairing flow (DH2 = muunPriv·P +// on the server side; the matching muunPub is baked into the V3 firmware +// and the V3 card mock). Production HoustonClient does not read these +// constants — they only exist so the local mock can complete pairings +// against firmware that expects the real Muun identity binding. +// +// Values are byte-for-byte aligned with the firmware reference at +// cards/muuncardv3/main.go on the muuncard-proto-v3 branch so signatures +// and shared secrets match across components without manual coordination. + +const muunPrivDevHex = "f22989f8d74a5da5c911c605df8a290c0bf89de794f17cc03c3a663f9eb010f8" + +const muunPubDevHex = "04bb0ce9365239e80769f9101fbc6a0d346de17c839ef56e90419dc2c0d7b666270588cc9de355665ae3651c01898f0bc34415d2ddfa72a6c91842efbe99d83b4d" + +var muunPrivDevBytes = mustDecodeHexConst(muunPrivDevHex) + +func mustDecodeHexConst(s string) []byte { + b, err := hex.DecodeString(s) + if err != nil { + panic(errors.Errorf("mock_houston dev key: invalid hex constant: %v", err)) + } + return b +} + +// init verifies muunPriv and muunPub are a matching keypair. Catches the +// case where one constant is rotated without the other. +func init() { + privKey, err := ecdh.P256().NewPrivateKey(muunPrivDevBytes) + if err != nil { + panic(errors.Errorf("mock_houston dev key: invalid muunPriv: %v", err)) + } + derivedPub := privKey.PublicKey().Bytes() + expectedPub := mustDecodeHexConst(muunPubDevHex) + if !bytes.Equal(derivedPub, expectedPub) { + panic(errors.Errorf("mock_houston dev key: muunPriv does not derive to muunPub")) + } +} diff --git a/libwallet/service/model/action_descriptor.go b/libwallet/service/model/action_descriptor.go new file mode 100644 index 00000000..e79185c4 --- /dev/null +++ b/libwallet/service/model/action_descriptor.go @@ -0,0 +1,63 @@ +package model + +import "encoding/json" + +// ActionType identifies a variant of ActionDescriptor. +type ActionType string + +const ( + ActionTypeNewOp ActionType = "NEW_OP" +) + +// ActionDescriptor is the discriminated union of sensitive actions a +// security card can approve. Wire format is flat: +// +// {"type": "", ...variant fields at the same JSON level} +// +// Each variant is a concrete Go struct that returns its own ActionType +// and injects the type tag via MarshalJSON. Embedding json.Marshaler +// makes the marshal contract part of the interface — a variant that +// forgets it won't compile. +// +// Adding a new variant requires: +// 1. Define a new struct. +// 2. Implement ActionType() and MarshalJSON() (compile-enforced by the +// interface + the `var _ ActionDescriptor = X{}` check below). +type ActionDescriptor interface { + json.Marshaler + ActionType() ActionType +} + +// Compile-time interface checks. Every concrete variant of +// ActionDescriptor must appear here so the compiler catches missing +// methods (ActionType, MarshalJSON) at build time, not at runtime. +var ( + _ ActionDescriptor = NewOpAction{} +) + +// NewOpAction is the descriptor for approving a new on-chain operation. +type NewOpAction struct { + DestinationAddress string `json:"destinationAddress"` + AmountInSats int64 `json:"amountInSats"` +} + +// ActionType implements ActionDescriptor. +func (NewOpAction) ActionType() ActionType { return ActionTypeNewOp } + +// MarshalJSON injects the discriminator alongside the variant fields, +// matching the {"type": "NEW_OP", ...fields} flat wire format Houston +// emits via Jackson polymorphism (@JsonTypeInfo As.EXISTING_PROPERTY). +// +// The local "alias" type strips NewOpAction's MarshalJSON method so the +// embedded marshaling falls back to default field-by-field encoding +// (otherwise we'd recurse forever). +func (a NewOpAction) MarshalJSON() ([]byte, error) { + type alias NewOpAction + return json.Marshal(struct { + Type ActionType `json:"type"` + alias + }{ + Type: ActionTypeNewOp, + alias: alias(a), + }) +} diff --git a/libwallet/service/model/action_descriptor_test.go b/libwallet/service/model/action_descriptor_test.go new file mode 100644 index 00000000..74ec69e5 --- /dev/null +++ b/libwallet/service/model/action_descriptor_test.go @@ -0,0 +1,47 @@ +package model + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +// TestNewOpActionMarshalJSONFlatWireFormat pins the flat wire shape the +// V3 client and Houston (Jackson @JsonTypeInfo EXISTING_PROPERTY) must +// agree on: the discriminator sits at the same level as the variant +// fields, not nested. +func TestNewOpActionMarshalJSONFlatWireFormat(t *testing.T) { + action := NewOpAction{ + DestinationAddress: "bc1qexampleaddress", + AmountInSats: 12345, + } + + got, err := json.Marshal(action) + require.NoError(t, err) + + require.JSONEq(t, + `{"type":"NEW_OP","destinationAddress":"bc1qexampleaddress","amountInSats":12345}`, + string(got), + ) +} + +// TestNewOpActionMarshalsThroughInterface guards that the type tag +// survives when marshaling via the ActionDescriptor interface (the +// MarshalJSON dispatch is what injects it) and that ActionType matches +// the emitted discriminator. +func TestNewOpActionMarshalsThroughInterface(t *testing.T) { + var descriptor ActionDescriptor = NewOpAction{ + DestinationAddress: "addr", + AmountInSats: 1, + } + + got, err := json.Marshal(descriptor) + require.NoError(t, err) + + require.JSONEq(t, + `{"type":"NEW_OP","destinationAddress":"addr","amountInSats":1}`, + string(got), + ) + require.Equal(t, ActionTypeNewOp, descriptor.ActionType()) +} diff --git a/libwallet/service/model/challenge_security_card_pair_json.go b/libwallet/service/model/challenge_security_card_pair_json.go deleted file mode 100644 index aef18c5a..00000000 --- a/libwallet/service/model/challenge_security_card_pair_json.go +++ /dev/null @@ -1,5 +0,0 @@ -package model - -type ChallengeSecurityCardPairJson struct { - ServerPublicKeyInHex string `json:"serverPublicKeyInHex"` -} diff --git a/libwallet/service/model/challenge_security_card_sign_json.go b/libwallet/service/model/challenge_security_card_sign_json.go index 16613151..7dac0677 100644 --- a/libwallet/service/model/challenge_security_card_sign_json.go +++ b/libwallet/service/model/challenge_security_card_sign_json.go @@ -1,5 +1,5 @@ package model -type ChallengeSecurityCardSignJson struct { +type ChallengeSecurityCardSignJson struct { //nolint:staticcheck // TODO: type ChallengeSecurityCardSignJson should be ChallengeSecurityCardSignJSON ReasonInHex string `json:"reasonInHex"` } diff --git a/libwallet/service/model/challenge_security_card_sign_response_json.go b/libwallet/service/model/challenge_security_card_sign_response_json.go index ce6d86e7..b6addb6a 100644 --- a/libwallet/service/model/challenge_security_card_sign_response_json.go +++ b/libwallet/service/model/challenge_security_card_sign_response_json.go @@ -1,6 +1,6 @@ package model -type ChallengeSecurityCardSignResponseJson struct { +type ChallengeSecurityCardSignResponseJson struct { //nolint:staticcheck // TODO: type ChallengeSecurityCardSignResponseJson should be ChallengeSecurityCardSignResponseJSON ServerPublicKeyInHex string `json:"serverPublicKeyInHex"` MacInHex string `json:"macInHex"` CardUsageCount uint16 `json:"cardUsageCount"` diff --git a/libwallet/service/model/challenge_setup_json.go b/libwallet/service/model/challenge_setup_json.go index 3b230986..0ed55aaf 100644 --- a/libwallet/service/model/challenge_setup_json.go +++ b/libwallet/service/model/challenge_setup_json.go @@ -1,6 +1,6 @@ package model -type ChallengeSetupJson struct { +type ChallengeSetupJson struct { //nolint:staticcheck // TODO: type ChallengeSetupJson should be ChallengeSetupJSON Type string `json:"type"` PublicKey string `json:"passwordSecretPublicKey"` Salt string `json:"passwordSecretSalt"` diff --git a/libwallet/service/model/challenge_setup_verify_json.go b/libwallet/service/model/challenge_setup_verify_json.go index d37ef4f2..dd47c3e2 100644 --- a/libwallet/service/model/challenge_setup_verify_json.go +++ b/libwallet/service/model/challenge_setup_verify_json.go @@ -1,6 +1,6 @@ package model -type ChallengeSetupVerifyJson struct { +type ChallengeSetupVerifyJson struct { //nolint:staticcheck // TODO: type ChallengeSetupVerifyJson should be ChallengeSetupVerifyJSON ChallengeType string `json:"type"` PublicKey string `json:"publicKey"` } diff --git a/libwallet/service/model/client_json.go b/libwallet/service/model/client_json.go index 6b302465..c08aabcb 100644 --- a/libwallet/service/model/client_json.go +++ b/libwallet/service/model/client_json.go @@ -1,6 +1,6 @@ package model -type ClientJson struct { +type ClientJson struct { //nolint:staticcheck // TODO: type ClientJson should be ClientJSON Type string `json:"type"` BuildType string `json:"buildType"` Version int `json:"version"` diff --git a/libwallet/service/model/create_first_session_json.go b/libwallet/service/model/create_first_session_json.go index e7b3521b..3c4231d6 100644 --- a/libwallet/service/model/create_first_session_json.go +++ b/libwallet/service/model/create_first_session_json.go @@ -1,6 +1,6 @@ package model -type CreateFirstSessionJson struct { +type CreateFirstSessionJson struct { //nolint:staticcheck // TODO: type CreateFirstSessionJson should be CreateFirstSessionJSON Client ClientJson `json:"client"` GcmToken *string `json:"gcmToken,omitempty"` PrimaryCurrency string `json:"primaryCurrency"` diff --git a/libwallet/service/model/create_first_session_ok_json.go b/libwallet/service/model/create_first_session_ok_json.go index ee0bca1b..ad19ad54 100644 --- a/libwallet/service/model/create_first_session_ok_json.go +++ b/libwallet/service/model/create_first_session_ok_json.go @@ -1,6 +1,6 @@ package model -type CreateFirstSessionOkJson struct { +type CreateFirstSessionOkJson struct { //nolint:staticcheck // TODO: type CreateFirstSessionOkJson should be CreateFirstSessionOkJSON CosigningPublicKey PublicKeyJson `json:"cosigningPublicKey"` SwapServerPublicKey PublicKeyJson `json:"swapServerPublicKey"` // TODO: user UserJson `json:"client"` diff --git a/libwallet/service/model/diagnostic_scan_data_json.go b/libwallet/service/model/diagnostic_scan_data_json.go index d11d14f1..8b906c16 100644 --- a/libwallet/service/model/diagnostic_scan_data_json.go +++ b/libwallet/service/model/diagnostic_scan_data_json.go @@ -1,6 +1,6 @@ package model -type DiagnosticScanDataJson struct { - ScanId string `json:"scanId"` +type DiagnosticScanDataJson struct { //nolint:staticcheck // TODO: type DiagnosticScanDataJson should be DiagnosticScanDataJSON + ScanId string `json:"scanId"` //nolint:staticcheck // TODO: struct field ScanId should be ScanID Logs string `json:"logs"` } diff --git a/libwallet/service/model/fee_window_json.go b/libwallet/service/model/fee_window_json.go index bc209f29..e0d48c32 100644 --- a/libwallet/service/model/fee_window_json.go +++ b/libwallet/service/model/fee_window_json.go @@ -1,7 +1,7 @@ package model -type FeeWindowJson struct { - Id int64 `json:"id"` +type FeeWindowJson struct { //nolint:staticcheck // TODO: type FeeWindowJson should be FeeWindowJSON + Id int64 `json:"id"` //nolint:staticcheck // TODO: struct field Id should be ID // TODO: Using time.Time is ok when we unmarshal FetchDate, // but we need to test that the marshaling also works. FetchDate string `json:"fetchDate"` diff --git a/libwallet/service/model/pair_request_challenge_response_json.go b/libwallet/service/model/pair_request_challenge_response_json.go new file mode 100644 index 00000000..17493c62 --- /dev/null +++ b/libwallet/service/model/pair_request_challenge_response_json.go @@ -0,0 +1,8 @@ +package model + +// PairRequestChallengeResponseJSON is the V3 pair request challenge +// response: the server's ephemeral public key the card MAC-verifies +// against during pairing. +type PairRequestChallengeResponseJSON struct { + ServerPubKeyInHex string `json:"serverPubKeyInHex"` +} diff --git a/libwallet/service/model/pair_submit_signed_challenge_json.go b/libwallet/service/model/pair_submit_signed_challenge_json.go new file mode 100644 index 00000000..6055e0ec --- /dev/null +++ b/libwallet/service/model/pair_submit_signed_challenge_json.go @@ -0,0 +1,9 @@ +package model + +// PairSubmitSignedChallengeJSON is the V3 pair submit request. +type PairSubmitSignedChallengeJSON struct { + CardPubKeyInHex string `json:"cardPubKeyInHex"` + Index uint16 `json:"index"` + MetadataInHex string `json:"metadataInHex"` + MacInHex string `json:"macInHex"` +} diff --git a/libwallet/service/model/pair_submit_signed_challenge_response_json.go b/libwallet/service/model/pair_submit_signed_challenge_response_json.go new file mode 100644 index 00000000..e27dffa9 --- /dev/null +++ b/libwallet/service/model/pair_submit_signed_challenge_response_json.go @@ -0,0 +1,17 @@ +package model + +// PairSubmitSignedChallengeResponseJSON is the V3 pair submit response. Houston +// has already verified the MAC against the raw metadata bytes from the +// request, so the parsed metadata returned here is trustworthy for the +// client to store and display. +type PairSubmitSignedChallengeResponseJSON struct { + SecurityCard PairedSecurityCardJSON `json:"securityCard"` + IsKnownProvider bool `json:"isKnownProvider"` + IsCardAlreadyUsed bool `json:"isCardAlreadyUsed"` +} + +type PairedSecurityCardJSON struct { + ID uint64 `json:"id"` + Metadata SecurityCardV3MetadataJSON `json:"metadata"` + PairedAt string `json:"pairedAt"` +} diff --git a/libwallet/service/model/public_key_json.go b/libwallet/service/model/public_key_json.go index c50fc6af..503c2740 100644 --- a/libwallet/service/model/public_key_json.go +++ b/libwallet/service/model/public_key_json.go @@ -1,6 +1,6 @@ package model -type PublicKeyJson struct { +type PublicKeyJson struct { //nolint:staticcheck // TODO: type PublicKeyJson should be PublicKeyJSON Key string `json:"key"` Path string `json:"path"` } diff --git a/libwallet/service/model/register_security_card_json.go b/libwallet/service/model/register_security_card_json.go index c04d4849..c9680d09 100644 --- a/libwallet/service/model/register_security_card_json.go +++ b/libwallet/service/model/register_security_card_json.go @@ -1,6 +1,6 @@ package model -type RegisterSecurityCardJson struct { +type RegisterSecurityCardJson struct { //nolint:staticcheck // TODO: type RegisterSecurityCardJson should be RegisterSecurityCardJSON CardPublicKeyInHex string `json:"cardPublicKeyInHex"` ClientPublicKeyInHex string `json:"clientPublicKeyInHex"` PairingSlot uint16 `json:"pairingSlot"` diff --git a/libwallet/service/model/register_security_card_ok_json.go b/libwallet/service/model/register_security_card_ok_json.go index e39b425c..3cef1dd0 100644 --- a/libwallet/service/model/register_security_card_ok_json.go +++ b/libwallet/service/model/register_security_card_ok_json.go @@ -1,6 +1,6 @@ package model -type RegisterSecurityCardOkJson struct { +type RegisterSecurityCardOkJson struct { //nolint:staticcheck // TODO: type RegisterSecurityCardOkJson should be RegisterSecurityCardOkJSON Metadata SecurityCardMetadataJson `json:"metadata"` IsKnownProvider bool `json:"isKnownProvider"` IsCardAlreadyUsed bool `json:"isCardAlreadyUsed"` diff --git a/libwallet/service/model/security_card.go b/libwallet/service/model/security_card.go index 8113b8d1..2497038c 100644 --- a/libwallet/service/model/security_card.go +++ b/libwallet/service/model/security_card.go @@ -1,6 +1,9 @@ package model -type SecurityCardJson struct { - Image string `json:"image"` - Stock int32 `json:"stock"` +type SecurityCardJson struct { //nolint:staticcheck // TODO: type SecurityCardJson should be SecurityCardJSON + Id string `json:"id"` //nolint:staticcheck // TODO: struct field Id should be ID + AssetUrl string `json:"assetUrl"` //nolint:staticcheck // TODO: struct field AssetUrl should be AssetURL + Tag string `json:"tag"` + SpecId string `json:"specId"` //nolint:staticcheck // TODO: struct field SpecId should be SpecID + CardCost PriceInfoJson `json:"cardCost"` } diff --git a/libwallet/service/model/security_card_metadata.go b/libwallet/service/model/security_card_metadata.go index 514dc3f0..a147ccc7 100644 --- a/libwallet/service/model/security_card_metadata.go +++ b/libwallet/service/model/security_card_metadata.go @@ -1,6 +1,6 @@ package model -type SecurityCardMetadataJson struct { +type SecurityCardMetadataJson struct { //nolint:staticcheck // TODO: type SecurityCardMetadataJson should be SecurityCardMetadataJSON GlobalPublicKeyInHex string `json:"globalPublicKeyInHex"` CardVendorInHex string `json:"cardVendorInHex"` CardModelInHex string `json:"cardModelInHex"` diff --git a/libwallet/service/model/security_card_metadata_v3.go b/libwallet/service/model/security_card_metadata_v3.go new file mode 100644 index 00000000..852b6c9d --- /dev/null +++ b/libwallet/service/model/security_card_metadata_v3.go @@ -0,0 +1,20 @@ +package model + +// SecurityCardV3MetadataJSON exposes the parsed V3 metadata. Field names +// mirror the firmware spec (attestation/capabilities/operationCount) so +// the same vocabulary travels from firmware → libwallet → Houston. +// +// Provider attestation cert fields (providerPubKey + providerSig) are +// part of the V3 protocol: the card stores them after STORE_CERTIFICATE +// and emits them with every GET_METADATA. providerSig is the ECDSA +// signature of attestationPubKey under providerPubKey. +type SecurityCardV3MetadataJSON struct { + AttestationPubKeyInHex string `json:"attestationPubKeyInHex"` + CardVendorInHex string `json:"cardVendorInHex"` + CardModelInHex string `json:"cardModelInHex"` + FirmwareVersion uint16 `json:"firmwareVersion"` + CapabilitiesInHex string `json:"capabilitiesInHex"` + OperationCount uint16 `json:"operationCount"` + ProviderPubKeyInHex string `json:"providerPubKeyInHex"` + ProviderSigInHex string `json:"providerSigInHex"` +} diff --git a/libwallet/service/model/security_cards_marketplace_json.go b/libwallet/service/model/security_cards_marketplace_json.go index 6a7326a6..2a7400c7 100644 --- a/libwallet/service/model/security_cards_marketplace_json.go +++ b/libwallet/service/model/security_cards_marketplace_json.go @@ -1,5 +1,18 @@ package model -type SecurityCardsMarketplaceJson struct { +type SecurityCardsMarketplaceJson struct { //nolint:staticcheck // TODO: type SecurityCardsMarketplaceJson should be SecurityCardsMarketplaceJSON Providers []SecurityCardsProviderJson `json:"providers"` + Specs []SecurityCardSpecJson `json:"specs"` +} + +type SecurityCardSpecJson struct { //nolint:staticcheck // TODO: type SecurityCardSpecJson should be SecurityCardSpecJSON + SpecId string `json:"specId"` //nolint:staticcheck // TODO: struct field SpecId should be SpecID + Items map[string][]SecurityCardSpecItemJson `json:"items"` +} + +type SecurityCardSpecItemJson struct { //nolint:staticcheck // TODO: type SecurityCardSpecItemJson should be SecurityCardSpecItemJSON + IconUrl string `json:"iconUrl"` //nolint:staticcheck // TODO: struct field IconUrl should be IconURL + Label string `json:"label"` + Value string `json:"value"` + AdditionalData string `json:"additionalData"` } diff --git a/libwallet/service/model/security_cards_provider.go b/libwallet/service/model/security_cards_provider.go index c351da06..c64144f7 100644 --- a/libwallet/service/model/security_cards_provider.go +++ b/libwallet/service/model/security_cards_provider.go @@ -1,11 +1,33 @@ package model -type SecurityCardsProviderJson struct { - Name string `json:"name"` - SecurityCards []SecurityCardJson `json:"securityCards"` - CurrencyCode string `json:"currencyCode"` - ColorHex string `json:"colorHex"` - Material string `json:"material"` - Price float64 `json:"price"` - ShippingCost float64 `json:"shippingCost"` +type SecurityCardsProviderJson struct { //nolint:staticcheck // TODO: type SecurityCardsProviderJson should be SecurityCardsProviderJSON + Id string `json:"id"` //nolint:staticcheck // TODO: struct field Id should be ID + Name string `json:"name"` + Description string `json:"description"` + SiteUrl string `json:"siteUrl"` //nolint:staticcheck // TODO: struct field SiteUrl should be SiteURL + LightTheme ProviderThemeJson `json:"lightTheme"` + DarkTheme ProviderThemeJson `json:"darkTheme"` + SecurityCards []SecurityCardJson `json:"securityCards"` + EstimatedShippingPrices []ShippingPriceInfoJson `json:"estimatedShippingPrices"` +} + +type ProviderThemeJson struct { //nolint:staticcheck // TODO: type ProviderThemeJson should be ProviderThemeJSON + PrimaryColor string `json:"primaryColor"` + SurfaceColor string `json:"surfaceColor"` +} + +type ShippingPriceInfoJson struct { //nolint:staticcheck // TODO: type ShippingPriceInfoJson should be ShippingPriceInfoJSON + Price PriceInfoJson `json:"price"` + Countries []CountryInfoJson `json:"countries"` +} + +type PriceInfoJson struct { //nolint:staticcheck // TODO: type PriceInfoJson should be PriceInfoJSON + CurrencyCode string `json:"currencyCode"` + Amount string `json:"amount"` +} + +type CountryInfoJson struct { //nolint:staticcheck // TODO: type CountryInfoJson should be CountryInfoJSON + Code string `json:"code"` + Name string `json:"name"` + Flag string `json:"flag"` } diff --git a/libwallet/service/model/setup_challenge_response_json.go b/libwallet/service/model/setup_challenge_response_json.go index c2e4e95b..81767ebd 100644 --- a/libwallet/service/model/setup_challenge_response_json.go +++ b/libwallet/service/model/setup_challenge_response_json.go @@ -1,6 +1,6 @@ package model -type SetupChallengeResponseJson struct { +type SetupChallengeResponseJson struct { //nolint:staticcheck // TODO: type SetupChallengeResponseJson should be SetupChallengeResponseJSON MuunKey *string `json:"muunKey,omitempty"` MuunKeyFingerprint *string `json:"muunKeyFingerprint,omitempty"` } diff --git a/libwallet/service/model/sign_request_challenge_json.go b/libwallet/service/model/sign_request_challenge_json.go new file mode 100644 index 00000000..ae938b0b --- /dev/null +++ b/libwallet/service/model/sign_request_challenge_json.go @@ -0,0 +1,10 @@ +package model + +// SignRequestChallengeJSON is the V3 sign challenge request. The client +// asks Houston to issue a challenge that the card will sign to approve +// a sensitive action. Action identifies what is being approved — it is +// a discriminated union (see ActionDescriptor) carrying a "type" tag +// alongside the variant-specific fields. +type SignRequestChallengeJSON struct { + Action ActionDescriptor `json:"action"` +} diff --git a/libwallet/service/model/sign_request_challenge_response_json.go b/libwallet/service/model/sign_request_challenge_response_json.go new file mode 100644 index 00000000..8204f2aa --- /dev/null +++ b/libwallet/service/model/sign_request_challenge_response_json.go @@ -0,0 +1,17 @@ +package model + +type SignRequestChallengeResponseJSON struct { + ServerPubKeyInHex string `json:"serverPubKeyInHex"` + ReasonInHex string `json:"reasonInHex"` + PerCardPayloads []SignChallengePerCardPayloadJSON `json:"perCardPayloads"` +} + +// SignChallengePerCardPayloadJSON is the per-card slice of a sign +// challenge response. The client picks the right one by matching the +// tapped card's attestation key against AttestationPubKeyInHex. +type SignChallengePerCardPayloadJSON struct { + AttestationPubKeyInHex string `json:"attestationPubKeyInHex"` + Index uint16 `json:"index"` + MacInHex string `json:"macInHex"` + ReplayCounter uint16 `json:"replayCounter"` +} diff --git a/libwallet/service/model/sign_submit_signed_challenge_json.go b/libwallet/service/model/sign_submit_signed_challenge_json.go new file mode 100644 index 00000000..de202054 --- /dev/null +++ b/libwallet/service/model/sign_submit_signed_challenge_json.go @@ -0,0 +1,10 @@ +package model + +// SignSubmitSignedChallengeJSON is the V3 sign-submit request body. +type SignSubmitSignedChallengeJSON struct { + // TODO multi-pairing: identifies which paired card produced this + // response. Unused while the mock is single-slot. + AttestationPubKeyInHex string `json:"attestationPubKeyInHex"` + CardPubKeyInHex string `json:"cardPubKeyInHex"` + MacInHex string `json:"macInHex"` +} diff --git a/libwallet/service/model/solve_security_card_challenge_json.go b/libwallet/service/model/solve_security_card_challenge_json.go index 64e94f10..a455f6ff 100644 --- a/libwallet/service/model/solve_security_card_challenge_json.go +++ b/libwallet/service/model/solve_security_card_challenge_json.go @@ -1,6 +1,6 @@ package model -type SolveSecurityCardChallengeJson struct { +type SolveSecurityCardChallengeJson struct { //nolint:staticcheck // TODO: type SolveSecurityCardChallengeJson should be SolveSecurityCardChallengeJSON PublicKeyInHex string `json:"publicKeyInHex"` MacInHex string `json:"macInHex"` } diff --git a/libwallet/service/model/verifiable_muun_key_json.go b/libwallet/service/model/verifiable_muun_key_json.go index 002d2139..eb707df9 100644 --- a/libwallet/service/model/verifiable_muun_key_json.go +++ b/libwallet/service/model/verifiable_muun_key_json.go @@ -1,6 +1,6 @@ package model -type VerifiableMuunKeyJson struct { +type VerifiableMuunKeyJson struct { //nolint:staticcheck // TODO: type VerifiableMuunKeyJson should be VerifiableMuunKeyJSON FirstHalfKeyEncryptedToClient string `json:"firstHalfKeyEncryptedToClient"` SecondHalfKeyEncryptedToRecoveryCode string `json:"secondHalfKeyEncryptedToRecoveryCode"` Proof *string `json:"proof"` diff --git a/libwallet/service/model_objects_mapper.go b/libwallet/service/model_objects_mapper.go index 9bf50bfd..5dfdcbe3 100644 --- a/libwallet/service/model_objects_mapper.go +++ b/libwallet/service/model_objects_mapper.go @@ -2,7 +2,8 @@ package service import ( "encoding/hex" - "fmt" + + "github.com/go-errors/errors" "github.com/muun/libwallet/domain/model/security_card" "github.com/muun/libwallet/domain/model/security_cards_marketplace" @@ -10,22 +11,24 @@ import ( ) func MapSecurityCardPaired(in model.RegisterSecurityCardOkJson) *security_card.SecurityCardPaired { - return &security_card.SecurityCardPaired{ - Metadata: mapSecurityCardMetadata(in.Metadata), - IsKnownProvider: in.IsKnownProvider, - IsCardAlreadyUsed: in.IsCardAlreadyUsed, - } + return security_card.NewSecurityCardPaired( + mapSecurityCardMetadata(in.Metadata), + in.IsKnownProvider, + in.IsCardAlreadyUsed, + ) } -func mapSecurityCardMetadata(in model.SecurityCardMetadataJson) *security_card.SecurityCardMetadata { - return &security_card.SecurityCardMetadata{ - GlobalPublicKeyInHex: in.GlobalPublicKeyInHex, - CardVendorInHex: in.CardVendorInHex, - CardModelInHex: in.CardModelInHex, - FirmwareVersion: in.FirmwareVersion, - UsageCount: in.UsageCount, - LanguageCodeInHex: in.LanguageCodeInHex, - } +func mapSecurityCardMetadata( + in model.SecurityCardMetadataJson, +) *security_card.SecurityCardMetadata { + return security_card.NewSecurityCardMetadata( + in.GlobalPublicKeyInHex, + in.CardVendorInHex, + in.CardModelInHex, + in.FirmwareVersion, + in.UsageCount, + in.LanguageCodeInHex, + ) } func MapSecurityCardSignChallengeResponse( @@ -34,20 +37,20 @@ func MapSecurityCardSignChallengeResponse( serverPublicKeyBytes, err := hex.DecodeString(in.ServerPublicKeyInHex) if err != nil { - return nil, fmt.Errorf("error decoding server public key: %w", err) + return nil, errors.Errorf("error decoding server public key: %w", err) } macBytes, err := hex.DecodeString(in.MacInHex) if err != nil { - return nil, fmt.Errorf("error decoding mac: %w", err) + return nil, errors.Errorf("error decoding mac: %w", err) } - return &security_card.SecurityCardSignChallenge{ - ServerPublicKey: serverPublicKeyBytes, - Mac: macBytes, - PairingSlot: in.PairingSlot, - CardUsageCount: in.CardUsageCount, - }, nil + return security_card.NewSecurityCardSignChallenge( + serverPublicKeyBytes, + macBytes, + in.CardUsageCount, + in.PairingSlot, + ), nil } func MapSecurityCardsMarketplace( @@ -57,27 +60,85 @@ func MapSecurityCardsMarketplace( providers := make([]security_cards_marketplace.SecurityCardsProvider, 0, len(in.Providers)) for _, provider := range in.Providers { - securityCards := make([]security_cards_marketplace.SecurityCard, 0, len(provider.SecurityCards)) - for _, securityCard := range provider.SecurityCards { + securityCards := make( + []security_cards_marketplace.SecurityCard, + 0, + len(provider.SecurityCards), + ) + for _, sc := range provider.SecurityCards { + securityCards = append(securityCards, security_cards_marketplace.NewSecurityCard( + sc.Id, + sc.AssetUrl, + sc.Tag, + sc.SpecId, + mapPriceInfo(sc.CardCost), + )) + } + + shippingPrices := make( + []security_cards_marketplace.ShippingPrice, + 0, + len(provider.EstimatedShippingPrices), + ) + for _, shippingPrice := range provider.EstimatedShippingPrices { + + countries := make([]security_cards_marketplace.Country, 0, len(shippingPrice.Countries)) + for _, country := range shippingPrice.Countries { + countries = append(countries, security_cards_marketplace.NewCountry( + country.Code, + country.Name, + country.Flag, + )) + } - securityCards = append(securityCards, security_cards_marketplace.SecurityCard{ - Image: securityCard.Image, - Stock: securityCard.Stock, - }) + shippingPrices = append(shippingPrices, security_cards_marketplace.NewShippingPrice( + mapPriceInfo(shippingPrice.Price), + countries, + )) } - providers = append(providers, security_cards_marketplace.SecurityCardsProvider{ - Name: provider.Name, - CurrencyCode: provider.CurrencyCode, - ColorHex: provider.ColorHex, - Material: provider.Material, - Price: provider.Price, - ShippingCost: provider.ShippingCost, - SecurityCards: securityCards, - }) + providers = append(providers, security_cards_marketplace.NewSecurityCardsProvider( + provider.Id, + provider.Name, + provider.Description, + provider.SiteUrl, + mapProviderTheme(provider.LightTheme), + mapProviderTheme(provider.DarkTheme), + securityCards, + shippingPrices, + )) } - return &security_cards_marketplace.Marketplace{ - Providers: providers, - }, nil + specs := make([]security_cards_marketplace.SecurityCardSpec, 0, len(in.Specs)) + for _, spec := range in.Specs { + + items := make(map[string][]security_cards_marketplace.SpecItem, len(spec.Items)) + for category, specItems := range spec.Items { + mapped := make([]security_cards_marketplace.SpecItem, 0, len(specItems)) + for _, item := range specItems { + mapped = append(mapped, security_cards_marketplace.NewSpecItem( + item.IconUrl, + item.Label, + item.Value, + item.AdditionalData, + )) + } + items[category] = mapped + } + + specs = append(specs, security_cards_marketplace.NewSecurityCardSpec( + spec.SpecId, + items, + )) + } + + return security_cards_marketplace.NewMarketplace(providers, specs), nil +} + +func mapPriceInfo(in model.PriceInfoJson) security_cards_marketplace.Price { + return security_cards_marketplace.NewPrice(in.CurrencyCode, in.Amount) +} + +func mapProviderTheme(in model.ProviderThemeJson) security_cards_marketplace.ProviderTheme { + return security_cards_marketplace.NewProviderTheme(in.PrimaryColor, in.SurfaceColor) } diff --git a/libwallet/service/nfc_integration_test.go b/libwallet/service/nfc_integration_test.go index c3f8bfc2..4e0dee4d 100644 --- a/libwallet/service/nfc_integration_test.go +++ b/libwallet/service/nfc_integration_test.go @@ -11,6 +11,7 @@ import ( "github.com/muun/libwallet/domain/nfc" "github.com/muun/libwallet/service/model" "github.com/muun/libwallet/storage" + "github.com/muun/libwallet/walletdb" ) func TestMockCardPairCardSuccess(t *testing.T) { @@ -22,9 +23,16 @@ func TestMockCardPairCardSuccess(t *testing.T) { mockNfcBridge := nfc.NewMockJavaCard(mockCard) card := nfc.NewCardV2(mockNfcBridge) - // Create a new empty DB providing a new dataFilePath - dataFilePath := path.Join(t.TempDir(), "test.db") - keyValueStorage := storage.NewKeyValueStorage(dataFilePath, buildStorageSchemaForTests()) + // Create a new empty DB + pool, err := walletdb.NewPool(path.Join(t.TempDir(), "test.db"), nil) + if err != nil { + t.Fatalf("failed to open db: %v", err) + } + t.Cleanup(func() { pool.Close() }) + keyValueStorage := storage.NewKeyValueStorage( + pool.NewKeyValueRepository(), + buildStorageSchemaForTests(), + ) mockHouston := NewMockHoustonService(keyValueStorage) pairCardWithHouston(t, mockHouston, card) @@ -37,12 +45,12 @@ func pairCardWithHouston( card *nfc.MuunCardV2, ) { - challengePair, err := mockHouston.ChallengeSecurityCardPair() + challengePair, err := mockHouston.PairRequestChallenge() if err != nil { t.Fatalf("error requesting challenge to server: %v", err) } - serverPublicKey, err := hex.DecodeString(challengePair.ServerPublicKeyInHex) + serverPublicKey, err := hex.DecodeString(challengePair.ServerPubKeyInHex) if err != nil { t.Fatalf("error decoding server key: %v", err) } @@ -65,7 +73,10 @@ func pairCardWithHouston( } if pairingResp.CardPublicKey[0] != 0x04 { - t.Fatalf("Expected CardPublicKey to start with 0x04, got 0x%02x", pairingResp.CardPublicKey[0]) + t.Fatalf( + "Expected CardPublicKey to start with 0x04, got 0x%02x", + pairingResp.CardPublicKey[0], + ) } // Verify metadata in pairing response @@ -84,7 +95,10 @@ func registerPairingOnHouston( clientPubKey []byte, ) { - registerSecurityCardJson, err := MapRegisterSecurityCardJson(pairingResp, clientPubKey) + registerSecurityCardJson, err := MapRegisterSecurityCardJson( //nolint:staticcheck // TODO: var registerSecurityCardJson should be registerSecurityCardJSON + pairingResp, + clientPubKey, + ) if err != nil { t.Fatalf("failed to map pairing response %v", err) } @@ -104,8 +118,15 @@ func TestMockCardSignChallenge(t *testing.T) { mockNfcBridge := nfc.NewMockJavaCard(mockCard) card := nfc.NewCardV2(mockNfcBridge) - dataFilePath := path.Join(t.TempDir(), "test.db") - keyValueStorage := storage.NewKeyValueStorage(dataFilePath, buildStorageSchemaForTests()) + pool, err := walletdb.NewPool(path.Join(t.TempDir(), "test.db"), nil) + if err != nil { + t.Fatalf("failed to open db: %v", err) + } + t.Cleanup(func() { pool.Close() }) + keyValueStorage := storage.NewKeyValueStorage( + pool.NewKeyValueRepository(), + buildStorageSchemaForTests(), + ) mockHouston := NewMockHoustonService(keyValueStorage) // Pair card with Houston to enable challenge signing @@ -130,9 +151,11 @@ func testSignChallengeSuccess( reason []byte, ) { - challengeResponse, err := mockHouston.ChallengeSecurityCardSign(model.ChallengeSecurityCardSignJson{ - ReasonInHex: hex.EncodeToString(reason), - }) + challengeResponse, err := mockHouston.ChallengeSecurityCardSign( + model.ChallengeSecurityCardSignJson{ + ReasonInHex: hex.EncodeToString(reason), + }, + ) if err != nil { t.Fatalf("error requesting a challenge from houston: %v", err) } @@ -149,7 +172,7 @@ func testSignChallengeSuccess( cardPublicKeyInHex := hex.EncodeToString(signChallengeResponse.CardPublicKey) macInHex := hex.EncodeToString(signChallengeResponse.MAC) - securityCardChallengeJson := model.SolveSecurityCardChallengeJson{ + securityCardChallengeJson := model.SolveSecurityCardChallengeJson{ //nolint:staticcheck // TODO: var securityCardChallengeJson should be securityCardChallengeJSON PublicKeyInHex: cardPublicKeyInHex, MacInHex: macInHex, } @@ -166,9 +189,11 @@ func testSignChallengeInvalidCounter( card *nfc.MuunCardV2, reason []byte, ) { - challengeResponse, err := mockHouston.ChallengeSecurityCardSign(model.ChallengeSecurityCardSignJson{ - ReasonInHex: hex.EncodeToString(reason), - }) + challengeResponse, err := mockHouston.ChallengeSecurityCardSign( + model.ChallengeSecurityCardSignJson{ + ReasonInHex: hex.EncodeToString(reason), + }, + ) if err != nil { t.Fatalf("error requesting a challenge from houston: %v", err) } @@ -197,7 +222,7 @@ func testSignChallengeInvalidCounter( cardPublicKeyInHex := hex.EncodeToString(signChallengeResponse.CardPublicKey) macInHex := hex.EncodeToString(signChallengeResponse.MAC) - securityCardChallengeJson := model.SolveSecurityCardChallengeJson{ + securityCardChallengeJson := model.SolveSecurityCardChallengeJson{ //nolint:staticcheck // TODO: var securityCardChallengeJson should be securityCardChallengeJSON PublicKeyInHex: cardPublicKeyInHex, MacInHex: macInHex, } @@ -214,9 +239,11 @@ func testSignChallengeCounterAdvancesEvenIfSolveChallengeFails( card *nfc.MuunCardV2, reason []byte, ) { - challengeResponse, err := mockHouston.ChallengeSecurityCardSign(model.ChallengeSecurityCardSignJson{ - ReasonInHex: hex.EncodeToString(reason), - }) + challengeResponse, err := mockHouston.ChallengeSecurityCardSign( + model.ChallengeSecurityCardSignJson{ + ReasonInHex: hex.EncodeToString(reason), + }, + ) if err != nil { t.Fatalf("error requesting a challenge from houston: %v", err) } @@ -233,15 +260,17 @@ func testSignChallengeCounterAdvancesEvenIfSolveChallengeFails( t.Fatalf("should succeed with correct card counter, got: %v", err) } - // The card has now consumed `CardUsageCount` for this slot. - // We intentionally skip SolveChallenge here to simulate a failure between "sign" and "solve". - // The next challenge must use a strictly higher counter (server must not re-issue the same counter), - // otherwise the card would reject it with InvalidCounter. + // The card has now consumed `CardUsageCount` for this slot. We intentionally skip + // SolveChallenge here to simulate a failure between "sign" and "solve". The next challenge must + // use a strictly higher counter (server must not re-issue the same counter), otherwise the card + // would reject it with InvalidCounter. // Simulate failure: SolveSecurityCardChallenge` is not called for the first signed challenge. - challengeResponse2, err := mockHouston.ChallengeSecurityCardSign(model.ChallengeSecurityCardSignJson{ - ReasonInHex: hex.EncodeToString(reason), - }) + challengeResponse2, err := mockHouston.ChallengeSecurityCardSign( + model.ChallengeSecurityCardSignJson{ + ReasonInHex: hex.EncodeToString(reason), + }, + ) if err != nil { t.Fatalf("error requesting a challenge from houston: %v", err) } @@ -261,7 +290,7 @@ func testSignChallengeCounterAdvancesEvenIfSolveChallengeFails( cardPublicKeyInHex := hex.EncodeToString(signChallengeResponse.CardPublicKey) macInHex := hex.EncodeToString(signChallengeResponse.MAC) - securityCardChallengeJson := model.SolveSecurityCardChallengeJson{ + securityCardChallengeJson := model.SolveSecurityCardChallengeJson{ //nolint:staticcheck // TODO: var securityCardChallengeJson should be securityCardChallengeJSON PublicKeyInHex: cardPublicKeyInHex, MacInHex: macInHex, } @@ -278,9 +307,11 @@ func testSignChallengeInvalidSlot( card *nfc.MuunCardV2, reason []byte, ) { - challengeResponse, err := mockHouston.ChallengeSecurityCardSign(model.ChallengeSecurityCardSignJson{ - ReasonInHex: hex.EncodeToString(reason), - }) + challengeResponse, err := mockHouston.ChallengeSecurityCardSign( + model.ChallengeSecurityCardSignJson{ + ReasonInHex: hex.EncodeToString(reason), + }, + ) if err != nil { t.Fatalf("error requesting a challenge from houston: %v", err) } @@ -309,7 +340,7 @@ func testSignChallengeInvalidSlot( cardPublicKeyInHex := hex.EncodeToString(signChallengeResponse.CardPublicKey) macInHex := hex.EncodeToString(signChallengeResponse.MAC) - securityCardChallengeJson := model.SolveSecurityCardChallengeJson{ + securityCardChallengeJson := model.SolveSecurityCardChallengeJson{ //nolint:staticcheck // TODO: var securityCardChallengeJson should be securityCardChallengeJSON PublicKeyInHex: cardPublicKeyInHex, MacInHex: macInHex, } @@ -326,9 +357,11 @@ func testSignChallengeInvalidMac( card *nfc.MuunCardV2, reason []byte, ) { - challengeResponse, err := mockHouston.ChallengeSecurityCardSign(model.ChallengeSecurityCardSignJson{ - ReasonInHex: hex.EncodeToString(reason), - }) + challengeResponse, err := mockHouston.ChallengeSecurityCardSign( + model.ChallengeSecurityCardSignJson{ + ReasonInHex: hex.EncodeToString(reason), + }, + ) if err != nil { t.Fatalf("error requesting a challenge from houston: %v", err) } @@ -356,7 +389,7 @@ func testSignChallengeInvalidMac( cardPublicKeyInHex := hex.EncodeToString(signChallengeResponse.CardPublicKey) macInHex := hex.EncodeToString(signChallengeResponse.MAC) - securityCardChallengeJson := model.SolveSecurityCardChallengeJson{ + securityCardChallengeJson := model.SolveSecurityCardChallengeJson{ //nolint:staticcheck // TODO: var securityCardChallengeJson should be securityCardChallengeJSON PublicKeyInHex: cardPublicKeyInHex, MacInHex: macInHex, } @@ -368,10 +401,10 @@ func testSignChallengeInvalidMac( } func testSignChallengeSecretUpdates( - t *testing.T, - houston *MockHoustonService, - card *nfc.MuunCardV2, - reason1 []byte, + t *testing.T, //nolint:revive // TODO: use or remove t + houston *MockHoustonService, //nolint:revive // TODO: use or remove houston + card *nfc.MuunCardV2, //nolint:revive // TODO: use or remove card + reason1 []byte, //nolint:revive // TODO: use or remove reason1 ) { // challenge 1 should work with initial secret @@ -398,6 +431,12 @@ func buildStorageSchemaForTests() map[string]storage.Classification { SecurityCritical: false, ValueType: &storage.IntType{}, }, + storage.KeySecurityCardReplayCounter: { + BackupType: storage.NoAutoBackup, + BackupSecurity: storage.NotApplicable, + SecurityCritical: false, + ValueType: &storage.IntType{}, + }, storage.KeySecretCardBytesInHex: { BackupType: storage.NoAutoBackup, BackupSecurity: storage.NotApplicable, diff --git a/libwallet/sphinx/sphinx.go b/libwallet/sphinx/sphinx.go index e0c8b2c6..6a5a007c 100644 --- a/libwallet/sphinx/sphinx.go +++ b/libwallet/sphinx/sphinx.go @@ -2,12 +2,11 @@ package sphinx import ( "bytes" - "errors" - "fmt" "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btclog" + "github.com/go-errors/errors" lndsphinx "github.com/lightningnetwork/lightning-onion" "github.com/lightningnetwork/lnd/htlcswitch/hop" "github.com/lightningnetwork/lnd/lnwire" @@ -25,16 +24,20 @@ func Validate( net *chaincfg.Params, ) error { hop.UseLogger(btclog.Disabled) - router := lndsphinx.NewRouter(&lndsphinx.PrivKeyECDH{PrivKey: nodeKey}, net, lndsphinx.NewMemoryReplayLog()) + router := lndsphinx.NewRouter( + &lndsphinx.PrivKeyECDH{PrivKey: nodeKey}, + net, + lndsphinx.NewMemoryReplayLog(), + ) if err := router.Start(); err != nil { - return fmt.Errorf("could not start router for validating onion blob: %w", err) + return errors.Errorf("could not start router for validating onion blob: %w", err) } onionProcessor := hop.NewOnionProcessor(router) err := onionProcessor.Start() if err != nil { return err } - defer onionProcessor.Stop() + defer onionProcessor.Stop() //nolint:errcheck // TODO: check error iterator, err := onionProcessor.ReconstructHopIterator( bytes.NewReader(onionBlob), @@ -44,7 +47,7 @@ func Validate( }, ) if err != nil { - return fmt.Errorf("failed decode sphinx due to %w", err) + return errors.Errorf("failed decode sphinx due to %w", err) } payload, _, err := iterator.HopPayload() if err != nil { @@ -53,14 +56,14 @@ func Validate( amountToForward := payload.ForwardingInfo().AmountToForward if amount != 0 && amountToForward > amount { - return fmt.Errorf( + return errors.Errorf( "sphinx payment amount does not match (%v != %v)", amount, amountToForward, ) } // We require TLV onion if payload.MPP == nil { - return fmt.Errorf("TLV onion is missing") + return errors.Errorf("TLV onion is missing") } // We require payment secret @@ -72,7 +75,11 @@ func Validate( // We don't accept multipart total := payload.MultiPath().TotalMsat() if amountToForward < total { - return fmt.Errorf("payment is multipart. forwarded amt = %v, total amt = %v", amountToForward, total) + return errors.Errorf( + "payment is multipart. forwarded amt = %v, total amt = %v", + amountToForward, + total, + ) } return nil diff --git a/libwallet/storage/kv_migration_functions.go b/libwallet/storage/kv_migration_functions.go index 7d756b1c..beda1fac 100644 --- a/libwallet/storage/kv_migration_functions.go +++ b/libwallet/storage/kv_migration_functions.go @@ -4,6 +4,8 @@ import ( "fmt" "reflect" + "github.com/go-errors/errors" + "github.com/muun/libwallet/walletdb" ) @@ -22,7 +24,10 @@ type KeyDefinition struct { ValueType ValueType } -func (c KeyDefinition) apply(schema map[string]Classification, dbOps *[]func(repo walletdb.KeyValueRepository) error) { +func (c KeyDefinition) apply( + schema map[string]Classification, + dbOps *[]func(repo walletdb.KeyValueRepository) error, +) { _, exists := schema[c.Key] if exists { panic(fmt.Sprintf("kv migration: key '%s' is already defined", c.Key)) @@ -39,7 +44,13 @@ func (c KeyDefinition) apply(schema map[string]Classification, dbOps *[]func(rep } // Define returns a new KeyDefinition. -func Define(key string, backupType BackupType, backupSecurity BackupSecurity, securityCritical bool, valueType ValueType) Change { +func Define( + key string, + backupType BackupType, + backupSecurity BackupSecurity, + securityCritical bool, + valueType ValueType, +) Change { if reflect.TypeOf(valueType).Kind() != reflect.Ptr { panic(fmt.Sprintf( "kv migration: ValueType for key '%s' expects a pointer &%s{} but got value %s{}", @@ -89,16 +100,24 @@ type TypeMigration struct { NewType ValueType } -func (c TypeMigration) apply(schema map[string]Classification, dbOps *[]func(repo walletdb.KeyValueRepository) error) { +func (c TypeMigration) apply( + schema map[string]Classification, + dbOps *[]func(repo walletdb.KeyValueRepository) error, //nolint:revive // TODO: use or remove dbOps +) { classification, ok := schema[c.Key] if !ok { - panic(fmt.Sprintf("kv migration: attempted to migrate type for key '%s' which has not been defined yet", c.Key)) + panic(fmt.Sprintf( + "kv migration: attempted to migrate type for "+ + "key '%s' which has not been defined yet", + c.Key, + )) } oldType := classification.ValueType if !isTrivialConversion(oldType, c.NewType) { panic(fmt.Sprintf( - "kv migration: MigrateValueType cannot be used for a non-trivial conversion "+ - "from %T to %T for key '%s'. Use MigrateValueTypeWithMap instead.", + "kv migration: MigrateValueType cannot be used "+ + "for a non-trivial conversion from %T to %T "+ + "for key '%s'. Use MigrateValueTypeWithMap instead.", oldType, c.NewType, c.Key, )) } @@ -118,10 +137,17 @@ type MappedTypeMigration struct { OldToNewMap map[string]string } -func (c MappedTypeMigration) apply(schema map[string]Classification, dbOps *[]func(repo walletdb.KeyValueRepository) error) { +func (c MappedTypeMigration) apply( + schema map[string]Classification, + dbOps *[]func(repo walletdb.KeyValueRepository) error, +) { classification, ok := schema[c.Key] if !ok { - panic(fmt.Sprintf("kv migration: attempted to migrate type for key '%s' which has not been defined yet", c.Key)) + panic(fmt.Sprintf( + "kv migration: attempted to migrate type for "+ + "key '%s' which has not been defined yet", + c.Key, + )) } classification.ValueType = c.NewType schema[c.Key] = classification @@ -147,7 +173,7 @@ func (c MappedTypeMigration) apply(schema map[string]Classification, dbOps *[]fu return err } if !ok { - return fmt.Errorf("kv migration: unmapped value found for key '%s'", c.Key) + return errors.Errorf("kv migration: unmapped value found for key '%s'", c.Key) } return nil }) @@ -172,10 +198,18 @@ type MapUpdate struct { OldToNewMap map[string]string } -func (c MapUpdate) apply(schema map[string]Classification, dbOps *[]func(repo walletdb.KeyValueRepository) error) { +func (c MapUpdate) apply( + schema map[string]Classification, + dbOps *[]func(repo walletdb.KeyValueRepository) error, +) { _, exists := schema[c.Key] if !exists { - panic(fmt.Sprintf("kv migration: attempted to update key '%s' which has not been defined yet", c.Key)) + panic( + fmt.Sprintf( + "kv migration: attempted to update key '%s' which has not been defined yet", + c.Key, + ), + ) } *dbOps = append(*dbOps, func(repo walletdb.KeyValueRepository) error { @@ -198,7 +232,10 @@ type CustomChange struct { Step func(tx LimitedKeyValueRepository) error } -func (c CustomChange) apply(_ map[string]Classification, dbOps *[]func(repo walletdb.KeyValueRepository) error) { +func (c CustomChange) apply( + _ map[string]Classification, + dbOps *[]func(repo walletdb.KeyValueRepository) error, +) { *dbOps = append(*dbOps, func(repo walletdb.KeyValueRepository) error { // Wrap the full repo in the limited repo before passing it to the custom step. limitedRepo := &limitedKeyValueRepository{keyValueRepository: repo} @@ -220,18 +257,19 @@ func isTrivialConversion(from, to ValueType) bool { toType := reflect.TypeOf(to) // Trivial conversion to String from safe types. - if toType == reflect.TypeOf(&StringType{}) { + if toType == reflect.TypeOf(&StringType{}) { //nolint:modernize // TODO: use reflect.TypeFor switch fromType { - case reflect.TypeOf(&IntType{}), - reflect.TypeOf(&LongType{}), - reflect.TypeOf(&DoubleType{}), - reflect.TypeOf(&BoolType{}): + case reflect.TypeOf(&IntType{}), //nolint:modernize // TODO: use reflect.TypeFor + reflect.TypeOf(&LongType{}), //nolint:modernize // TODO: use reflect.TypeFor + reflect.TypeOf(&DoubleType{}), //nolint:modernize // TODO: use reflect.TypeFor + reflect.TypeOf(&BoolType{}): //nolint:modernize // TODO: use reflect.TypeFor return true } } // Trivial conversion from Int to Long. - if fromType == reflect.TypeOf(&IntType{}) && toType == reflect.TypeOf(&LongType{}) { + if fromType == reflect.TypeOf(&IntType{}) && //nolint:modernize // TODO: use reflect.TypeFor + toType == reflect.TypeOf(&LongType{}) { //nolint:modernize // TODO: use reflect.TypeFor return true } diff --git a/libwallet/storage/kv_migrations.go b/libwallet/storage/kv_migrations.go index 003ef55d..6ca2973a 100644 --- a/libwallet/storage/kv_migrations.go +++ b/libwallet/storage/kv_migrations.go @@ -5,7 +5,7 @@ package storage // BuildKVMigrationPlan provides the ordered history of the key-value schema and data migrations. func BuildKVMigrationPlan() []Migration { return []Migration{ - Migration{"Initial schema", []Change{ + {"Initial schema", []Change{ Define("isBalanceHidden", NoAutoBackup, NotApplicable, false, &BoolType{}), Define("nightMode", NoAutoBackup, NotApplicable, false, &StringType{}), // TODO: migrate to AsyncAutoBackup, Authenticated @@ -13,22 +13,88 @@ func BuildKVMigrationPlan() []Migration { Define("biometricsOptIn", NoAutoBackup, NotApplicable, false, &BoolType{}), Define("pinLength", NoAutoBackup, NotApplicable, false, &IntType{}), // TODO: migrate to AsyncAutoBackup, Plain - Define("unverifiedEncryptedMuungKeyPrototype", NoAutoBackup, NotApplicable, false, &StringType{}), + Define( + "unverifiedEncryptedMuungKeyPrototype", + NoAutoBackup, + NotApplicable, + false, + &StringType{}, + ), // TODO: migrate to AsyncAutoBackup, Authenticated, SecurityCritical - Define("verifiedEncryptedMuunKeyPrototype", NoAutoBackup, NotApplicable, false, &StringType{}), + Define( + "verifiedEncryptedMuunKeyPrototype", + NoAutoBackup, + NotApplicable, + false, + &StringType{}, + ), // TODO: migrate to AsyncAutoBackup, Authenticated, SecurityCritical Define("encryptedUserKeyPrototype", NoAutoBackup, NotApplicable, false, &StringType{}), // TODO: migrate to AsyncAutoBackup, Plain - Define("featureFlagOverrides:nfcCardV2", NoAutoBackup, NotApplicable, false, &BoolType{}), + Define( + "featureFlagOverrides:nfcCardV2", + NoAutoBackup, + NotApplicable, + false, + &BoolType{}, + ), // TODO: migrate to AsyncAutoBackup, Plain - Define("featureFlagOverrides:ekGoRendering", NoAutoBackup, NotApplicable, false, &BoolType{}), + Define( + "featureFlagOverrides:ekGoRendering", + NoAutoBackup, + NotApplicable, + false, + &BoolType{}, + ), }}, - Migration{"Mock Houston server state", []Change{ + {"Mock Houston server state", []Change{ Define("lastRandomPrivKeyInHex", NoAutoBackup, NotApplicable, false, &StringType{}), Define("securityCardUsageCount", NoAutoBackup, NotApplicable, false, &IntType{}), Define("secretCardBytesInHex", NoAutoBackup, NotApplicable, false, &StringType{}), Define("securityCardPairingSlot", NoAutoBackup, NotApplicable, false, &IntType{}), - Define("timeSinceLastChallengeUnixMillis", NoAutoBackup, NotApplicable, false, &LongType{}), + Define( + "timeSinceLastChallengeUnixMillis", + NoAutoBackup, + NotApplicable, + false, + &LongType{}, + ), + }}, + {"Pending pair challenge", []Change{ + // TODO: migrate to SecurityCritical + Define( + "pendingPairChallenge:serverPubKeyInHex", + NoAutoBackup, + NotApplicable, + false, + &StringType{}, + ), + Define( + "pendingPairChallenge:receivedAtUnixMillis", + NoAutoBackup, + NotApplicable, + false, + &LongType{}, + ), + }}, + {"Mock Houston V3 replay counter", []Change{ + Define("securityCardReplayCounter", NoAutoBackup, NotApplicable, false, &IntType{}), + }}, + {"NFC empirical Extended APDU signals", []Change{ + Define( + "nfc_extended_apdu_supported", + NoAutoBackup, + NotApplicable, + false, + &BoolType{}, + ), + Define( + "nfc_max_transceive_length", + NoAutoBackup, + NotApplicable, + false, + &IntType{}, + ), }}, } } diff --git a/libwallet/storage/kv_migrations_lock_test.go b/libwallet/storage/kv_migrations_lock_test.go index 66cc9247..ad181d6b 100644 --- a/libwallet/storage/kv_migrations_lock_test.go +++ b/libwallet/storage/kv_migrations_lock_test.go @@ -20,7 +20,11 @@ func TestMigrationsLockfileIsUpToDate(t *testing.T) { data, err := os.ReadFile("testdata/kv_migrations.lock") if err != nil { - t.Fatalf("failed to read testdata/kv_migrations.lock: %v \nrun: go generate ./storage/... (from libwallet/)", err) + t.Fatalf( + "failed to read testdata/kv_migrations.lock: %v \n"+ + "run: go generate ./storage/... (from libwallet/)", + err, + ) } var committed kvmigrationlock.Lockfile @@ -30,7 +34,8 @@ func TestMigrationsLockfileIsUpToDate(t *testing.T) { if len(committed.Migrations) != len(current.Migrations) { t.Fatalf( - "lockfile has %d migrations but plan has %d \nrun: go generate ./storage/... (from libwallet/)", + "lockfile has %d migrations but plan has %d \n"+ + "run: go generate ./storage/... (from libwallet/)", len(committed.Migrations), len(current.Migrations), ) @@ -39,7 +44,8 @@ func TestMigrationsLockfileIsUpToDate(t *testing.T) { for i, existing := range committed.Migrations { if existing.Hash != current.Migrations[i].Hash { t.Fatalf( - "migration %d ('%s') was modified \nrun: go generate ./storage/... (from libwallet/)", + "migration %d ('%s') was modified \n"+ + "run: go generate ./storage/... (from libwallet/)", i+1, existing.Description, ) diff --git a/libwallet/storage/kv_migrator.go b/libwallet/storage/kv_migrator.go index 7db0076e..73e99725 100644 --- a/libwallet/storage/kv_migrator.go +++ b/libwallet/storage/kv_migrator.go @@ -1,7 +1,7 @@ package storage import ( - "fmt" + "github.com/go-errors/errors" "github.com/muun/libwallet/walletdb" ) @@ -35,7 +35,10 @@ func (m *Migration) run(tx walletdb.KeyValueRepository, schema map[string]Classi } // RunKeyValueMigrations executes the entire migration plan, returning the final schema. -func RunKeyValueMigrations(dataFilePath string, migrations []Migration) (map[string]Classification, error) { +func RunKeyValueMigrations( + db *walletdb.DB, + migrations []Migration, +) (map[string]Classification, error) { // Validate that all CustomChange IDs are unique across all migrations. seenIDs := make(map[string]bool) @@ -43,7 +46,11 @@ func RunKeyValueMigrations(dataFilePath string, migrations []Migration) (map[str for _, change := range migration.Changes { if cc, ok := change.(CustomChange); ok { if seenIDs[cc.ID] { - return nil, fmt.Errorf("duplicate custom change id '%s' in migration '%s'", cc.ID, migration.Description) + return nil, errors.Errorf( + "duplicate custom change id '%s' in migration '%s'", + cc.ID, + migration.Description, + ) } seenIDs[cc.ID] = true } @@ -53,24 +60,18 @@ func RunKeyValueMigrations(dataFilePath string, migrations []Migration) (map[str // Build the final schema in memory by running all changes without DB operations. // This is done upfront to ensure we can return a valid schema even if DB operations fail. finalSchema := make(map[string]Classification) - var discardedDbOperations []func(walletdb.KeyValueRepository) error + var discardedDbOperations []func(walletdb.KeyValueRepository) error //nolint:staticcheck // TODO: var discardedDbOperations should be discardedDBOperations for i := range migrations { for _, change := range migrations[i].Changes { change.apply(finalSchema, &discardedDbOperations) } } - // Open DB and get current state. - db, err := walletdb.Open(dataFilePath) - if err != nil { - return finalSchema, fmt.Errorf("failed to open database: %w", err) - } - defer db.Close() - + // Get current schema version. schemaStateRepository := db.NewKVSchemaStateRepository() currentVersion, err := schemaStateRepository.GetCurrentSchemaVersion() if err != nil { - return finalSchema, fmt.Errorf("failed to get current schema version: %w", err) + return finalSchema, errors.Errorf("failed to get current schema version: %w", err) } // Early exit if no new migrations are needed. @@ -80,7 +81,7 @@ func RunKeyValueMigrations(dataFilePath string, migrations []Migration) (map[str // Re-build the schema state up to the current version before starting migrations. currentSchema := make(map[string]Classification) - for i := 0; i < currentVersion; i++ { + for i := 0; i < currentVersion; i++ { //nolint:modernize // TODO: use range over int for _, change := range migrations[i].Changes { change.apply(currentSchema, &discardedDbOperations) } @@ -91,29 +92,27 @@ func RunKeyValueMigrations(dataFilePath string, migrations []Migration) (map[str migration := &migrations[i] targetVersion := i + 1 - dbTx := db.Gorm().Begin() - if dbTx.Error != nil { - return finalSchema, fmt.Errorf("failed to begin transaction for migration %d: %w", targetVersion, dbTx.Error) - } - - txRepository := walletdb.NewKeyValueRepository(dbTx) - txSchemaStateRepository := walletdb.NewKVSchemaStateRepository(dbTx) - - err = migration.run(txRepository, currentSchema) - if err != nil { - dbTx.Rollback() - return finalSchema, fmt.Errorf("migration %d (%s) failed: %w", targetVersion, migration.Description, err) - } - - err = txSchemaStateRepository.BumpSchemaVersion(targetVersion) - if err != nil { - dbTx.Rollback() - return finalSchema, fmt.Errorf("failed to bump schema version to %d: %w", targetVersion, err) - } - - err = dbTx.Commit().Error + err = db.WithTx(func(txDB *walletdb.DB) error { + if err := migration.run(txDB.NewKeyValueRepository(), currentSchema); err != nil { + return errors.Errorf( + "migration %d (%s) failed: %w", + targetVersion, + migration.Description, + err, + ) + } + schemaRepo := txDB.NewKVSchemaStateRepository() + if err := schemaRepo.BumpSchemaVersion(targetVersion); err != nil { + return errors.Errorf( + "failed to bump schema version to %d: %w", + targetVersion, + err, + ) + } + return nil + }) if err != nil { - return finalSchema, fmt.Errorf("failed to commit transaction for migration %d: %w", targetVersion, err) + return finalSchema, err } } diff --git a/libwallet/storage/kv_migrator_test.go b/libwallet/storage/kv_migrator_test.go index 6d66b29e..2b9f2ec2 100644 --- a/libwallet/storage/kv_migrator_test.go +++ b/libwallet/storage/kv_migrator_test.go @@ -1,48 +1,51 @@ package storage import ( - "errors" "path/filepath" "testing" + "github.com/go-errors/errors" + "github.com/muun/libwallet/walletdb" ) func TestMigrateValueTypeWithMap_FromIntToString(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "wallet.db") + pool := openTestPool(t) + defer pool.Close() // V1 of the app: nightMode is an Int v1Plan := []Migration{ - Migration{"Initial schema", []Change{ + {"Initial schema", []Change{ Define("nightMode", NoAutoBackup, NotApplicable, false, &IntType{}), }}, } - schemaV1, err := RunKeyValueMigrations(dbPath, v1Plan) + schemaV1, err := migrateTestDB(t, pool, v1Plan) if err != nil { t.Fatalf("V1 migration failed: %v", err) } - storageV1 := NewKeyValueStorage(dbPath, schemaV1) - if err := storageV1.Save("nightMode", int32(0)); err != nil { + storageV1 := NewKeyValueStorage(pool.NewKeyValueRepository(), schemaV1) + err = storageV1.Save("nightMode", int32(0)) + if err != nil { t.Fatalf("V1 save failed: %v", err) } // V2 of the app: nightMode becomes a String v2Plan := []Migration{ - Migration{"Initial Schema", []Change{ + {"Initial Schema", []Change{ Define("nightMode", NoAutoBackup, NotApplicable, false, &IntType{}), }}, - Migration{"Migrate nightMode to string", []Change{ + {"Migrate nightMode to string", []Change{ MigrateValueTypeWithMap("nightMode", &StringType{}, map[string]string{ "0": "light", "1": "dark", }), }}, } - schemaV2, err := RunKeyValueMigrations(dbPath, v2Plan) + schemaV2, err := migrateTestDB(t, pool, v2Plan) if err != nil { t.Fatalf("V2 migration failed: %v", err) } - storageV2 := NewKeyValueStorage(dbPath, schemaV2) + storageV2 := NewKeyValueStorage(pool.NewKeyValueRepository(), schemaV2) // Verify migrated value value, err := storageV2.Get("nightMode") @@ -60,72 +63,77 @@ func TestMigrateValueTypeWithMap_FromIntToString(t *testing.T) { } func TestMigrateValueTypeWithMap_FailsWhenValueIsNotMapped(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "wallet.db") + pool := openTestPool(t) + defer pool.Close() // V1 of the app: nightMode is a String, and an unexpected value "system" was saved v1Plan := []Migration{ - Migration{"Initial schema", []Change{ + {"Initial schema", []Change{ Define("nightMode", NoAutoBackup, NotApplicable, false, &StringType{}), }}, } - schemaV1, err := RunKeyValueMigrations(dbPath, v1Plan) + schemaV1, err := migrateTestDB(t, pool, v1Plan) if err != nil { t.Fatalf("V1 migration failed: %v", err) } - storageV1 := NewKeyValueStorage(dbPath, schemaV1) - if err := storageV1.Save("nightMode", "system"); err != nil { + storageV1 := NewKeyValueStorage(pool.NewKeyValueRepository(), schemaV1) + err = storageV1.Save("nightMode", "system") + if err != nil { t.Fatalf("V1 save failed: %v", err) } // V2: nightMode becomes an Int, but the map only covers "light" and "dark", not "system" v2Plan := []Migration{ - Migration{"Initial schema", []Change{ + {"Initial schema", []Change{ Define("nightMode", NoAutoBackup, NotApplicable, false, &StringType{}), }}, - Migration{"Migrate nightMode to int", []Change{ + {"Migrate nightMode to int", []Change{ MigrateValueTypeWithMap("nightMode", &IntType{}, map[string]string{ "light": "0", "dark": "1", }), }}, } - _, err = RunKeyValueMigrations(dbPath, v2Plan) + _, err = migrateTestDB(t, pool, v2Plan) if err == nil { t.Fatal("Expected migration to fail due to unmapped value 'system', but it succeeded") } } func TestMigrateValueTypeWithMap_SucceedsWhenValueIsNull(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "wallet.db") + pool := openTestPool(t) + defer pool.Close() // V1: Define a key but never set its value, leaving it as NULL. v1Plan := []Migration{ - Migration{"Initial schema", []Change{ + {"Initial schema", []Change{ Define("nightMode", NoAutoBackup, NotApplicable, false, &IntType{}), }}, } - if _, err := RunKeyValueMigrations(dbPath, v1Plan); err != nil { + if _, err := migrateTestDB(t, pool, v1Plan); err != nil { t.Fatalf("V1 migration failed: %v", err) } // V2: NULL means absence of value, so the migration should succeed and leave the value as NULL v2Plan := []Migration{ - Migration{"Initial schema", []Change{ + {"Initial schema", []Change{ Define("nightMode", NoAutoBackup, NotApplicable, false, &IntType{}), }}, - Migration{"Migrate nightMode to string", []Change{ + {"Migrate nightMode to string", []Change{ MigrateValueTypeWithMap("nightMode", &StringType{}, map[string]string{ "0": "light", "1": "dark", }), }}, } - schema, err := RunKeyValueMigrations(dbPath, v2Plan) + schema, err := migrateTestDB(t, pool, v2Plan) if err != nil { t.Fatalf("Expected migration to succeed for NULL value, but got: %v", err) } - value, err := NewKeyValueStorage(dbPath, schema).Get("nightMode") + value, err := NewKeyValueStorage( + pool.NewKeyValueRepository(), schema, + ).Get("nightMode") if err != nil { t.Fatalf("Get failed: %v", err) } @@ -135,37 +143,40 @@ func TestMigrateValueTypeWithMap_SucceedsWhenValueIsNull(t *testing.T) { } func TestUpdateAccordingToMap_SucceedsWhenKeyHasNoValue(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "wallet.db") + pool := openTestPool(t) + defer pool.Close() // V1: Define a key but never set its value, leaving it as NULL. v1Plan := []Migration{ - Migration{"Initial schema", []Change{ + {"Initial schema", []Change{ Define("nightMode", NoAutoBackup, NotApplicable, false, &StringType{}), }}, } - if _, err := RunKeyValueMigrations(dbPath, v1Plan); err != nil { + if _, err := migrateTestDB(t, pool, v1Plan); err != nil { t.Fatalf("V1 migration failed: %v", err) } // V2: Update according to a map. Since no value is set, no rows should be affected v2Plan := []Migration{ - Migration{"Initial schema", []Change{ + {"Initial schema", []Change{ Define("nightMode", NoAutoBackup, NotApplicable, false, &StringType{}), }}, - Migration{"Update nightMode according to map", []Change{ + {"Update nightMode according to map", []Change{ UpdateAccordingToMap("nightMode", map[string]string{ "light": "0", "dark": "1", }), }}, } - schema, err := RunKeyValueMigrations(dbPath, v2Plan) + schema, err := migrateTestDB(t, pool, v2Plan) if err != nil { t.Fatalf("Expected migration to succeed when key has no value, but got: %v", err) } // Value should remain NULL. - value, err := NewKeyValueStorage(dbPath, schema).Get("nightMode") + value, err := NewKeyValueStorage( + pool.NewKeyValueRepository(), schema, + ).Get("nightMode") if err != nil { t.Fatalf("Get failed: %v", err) } @@ -175,11 +186,12 @@ func TestUpdateAccordingToMap_SucceedsWhenKeyHasNoValue(t *testing.T) { } func TestRunAllMigrations(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "wallet.db") + pool := openTestPool(t) + defer pool.Close() // V1 state: Define a schema that existed before all migrations ran v1Plan := []Migration{ - Migration{"Initial test schema", []Change{ + {"Initial test schema", []Change{ Define("nightMode", NoAutoBackup, NotApplicable, false, &IntType{}), Define("protocolForReceiving", NoAutoBackup, NotApplicable, false, &StringType{}), Define("useTurboChannels", NoAutoBackup, NotApplicable, false, &BoolType{}), @@ -188,13 +200,13 @@ func TestRunAllMigrations(t *testing.T) { Define("securityCardUsageCount", NoAutoBackup, NotApplicable, false, &IntType{}), }}, } - schemaV1, err := RunKeyValueMigrations(dbPath, v1Plan) + schemaV1, err := migrateTestDB(t, pool, v1Plan) if err != nil { t.Fatalf("V1 migration failed: %v", err) } // Save data for initial schema - storageV1 := NewKeyValueStorage(dbPath, schemaV1) + storageV1 := NewKeyValueStorage(pool.NewKeyValueRepository(), schemaV1) storageV1.Save("nightMode", int32(0)) storageV1.Save("protocolForReceiving", "UNIFIED") storageV1.Save("useTurboChannels", true) @@ -202,11 +214,11 @@ func TestRunAllMigrations(t *testing.T) { // V2 state: Run the full migration plan finalPlan := buildTestMigrationPlan() - finalSchema, err := RunKeyValueMigrations(dbPath, finalPlan) + finalSchema, err := migrateTestDB(t, pool, finalPlan) if err != nil { t.Fatalf("Final migration failed: %v", err) } - storageFinal := NewKeyValueStorage(dbPath, finalSchema) + storageFinal := NewKeyValueStorage(pool.NewKeyValueRepository(), finalSchema) // Assertions nightMode, _ := storageFinal.Get("nightMode") @@ -228,49 +240,55 @@ func TestRunAllMigrations(t *testing.T) { } func TestEarlyExitMigration(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "wallet.db") - db, _ := walletdb.Open(dbPath) - defer db.Close() - schemaStateRepository := db.NewKVSchemaStateRepository() - keyValueRepository := db.NewKeyValueRepository() + pool := openTestPool(t) + defer pool.Close() plan := buildTestMigrationPlan() // Set version to max to force an early-exit in the migration - err := schemaStateRepository.BumpSchemaVersion(len(plan)) - if err != nil { + if err := pool.WithDB(func(db *walletdb.DB) error { + return db.NewKVSchemaStateRepository().BumpSchemaVersion(len(plan)) + }); err != nil { t.Fatalf("BumpSchemaVersion failed: %v", err) } // Set a raw value that would be changed if migrations ran - err = keyValueRepository.Save("nightMode", strPtr("0")) - if err != nil { + if err := pool.WithDB(func(db *walletdb.DB) error { + return db.NewKeyValueRepository().Save("nightMode", strPtr("0")) + }); err != nil { t.Fatalf("Save failed: %v", err) } // When trying to run migrations, it should early-exit without running any migration - _, err = RunKeyValueMigrations(dbPath, plan) - if err != nil { + if _, err := migrateTestDB(t, pool, plan); err != nil { t.Fatalf("Migration failed: %v", err) } // Verify the raw value was not changed - val, _ := keyValueRepository.Get("nightMode") + var val *string + if err := pool.WithDB(func(db *walletdb.DB) error { + var err error + val, err = db.NewKeyValueRepository().Get("nightMode") + return err + }); err != nil { + t.Fatalf("Get failed: %v", err) + } if *val != "0" { t.Fatalf("Value was changed despite early exit: got %s", *val) } } func TestMigrationRollback(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "wallet.db") + pool := openTestPool(t) + defer pool.Close() // V1: Define a key and save an initial value v1Plan := []Migration{ - Migration{"Define nightMode", []Change{ + {"Define nightMode", []Change{ Define("nightMode", NoAutoBackup, NotApplicable, false, &StringType{})}, }, } - schemaV1, _ := RunKeyValueMigrations(dbPath, v1Plan) - storageV1 := NewKeyValueStorage(dbPath, schemaV1) + schemaV1, _ := migrateTestDB(t, pool, v1Plan) + storageV1 := NewKeyValueStorage(pool.NewKeyValueRepository(), schemaV1) err := storageV1.Save("nightMode", "dark") if err != nil { t.Fatalf("Save failed: %v", err) @@ -278,10 +296,10 @@ func TestMigrationRollback(t *testing.T) { // V2: A failing migration v2Plan := []Migration{ - Migration{"Define nightMode", []Change{ + {"Define nightMode", []Change{ Define("nightMode", NoAutoBackup, NotApplicable, false, &StringType{})}, }, - Migration{"This one will fail", []Change{ + {"This one will fail", []Change{ AddCustomChange("failing-change", func(tx LimitedKeyValueRepository) error { err := tx.Save("nightMode", strPtr("this_value_should_be_rolled_back")) if err != nil { @@ -291,21 +309,26 @@ func TestMigrationRollback(t *testing.T) { }), }}, } - finalSchema, err := RunKeyValueMigrations(dbPath, v2Plan) + finalSchema, err := migrateTestDB(t, pool, v2Plan) if err == nil { t.Fatal("Expected an error, but got none") } // Verify state - finalStorage := NewKeyValueStorage(dbPath, finalSchema) + finalStorage := NewKeyValueStorage(pool.NewKeyValueRepository(), finalSchema) value, _ := finalStorage.Get("nightMode") if value.(string) != "dark" { t.Fatalf("Expected 'dark', got %v", value) } - db, _ := walletdb.Open(dbPath) - defer db.Close() - version, _ := db.NewKVSchemaStateRepository().GetCurrentSchemaVersion() + var version int + if err := pool.WithDB(func(db *walletdb.DB) error { + var err error + version, err = db.NewKVSchemaStateRepository().GetCurrentSchemaVersion() + return err + }); err != nil { + t.Fatalf("GetCurrentSchemaVersion failed: %v", err) + } if version != 1 { t.Fatalf("Expected schema version 1, got %d", version) } @@ -319,13 +342,14 @@ func TestDefine_PanicsOnDuplicatedKeyDefinitionWithinSameMigration(t *testing.T) } }() plan := []Migration{ - Migration{"Duplicated keys", []Change{ + {"Duplicated keys", []Change{ Define("someKey", NoAutoBackup, NotApplicable, false, &StringType{}), Define("someKey", NoAutoBackup, NotApplicable, false, &StringType{}), }}, } - dbPath := filepath.Join(t.TempDir(), "wallet.db") - _, _ = RunKeyValueMigrations(dbPath, plan) + pool := openTestPool(t) + defer pool.Close() + _, _ = migrateTestDB(t, pool, plan) } func TestDefine_PanicsOnDuplicatedKeyDefinitionWhenUsingDifferentMigrations(t *testing.T) { @@ -336,50 +360,53 @@ func TestDefine_PanicsOnDuplicatedKeyDefinitionWhenUsingDifferentMigrations(t *t } }() plan := []Migration{ - Migration{"Define a key", []Change{ + {"Define a key", []Change{ Define("someKey", NoAutoBackup, NotApplicable, false, &StringType{}), }}, - Migration{"Duplicated key", []Change{ + {"Duplicated key", []Change{ Define("someKey", NoAutoBackup, NotApplicable, false, &StringType{}), }}, } - dbPath := filepath.Join(t.TempDir(), "wallet.db") - _, _ = RunKeyValueMigrations(dbPath, plan) + pool := openTestPool(t) + defer pool.Close() + _, _ = migrateTestDB(t, pool, plan) } func TestRunKeyValueMigrations_FailsOnDuplicatedCustomChangeID_WithinSameMigration(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "wallet.db") plan := []Migration{ - Migration{"A migration", []Change{ - AddCustomChange("duplicated-id", func(tx LimitedKeyValueRepository) error { + {"A migration", []Change{ + AddCustomChange("duplicated-id", func(_ LimitedKeyValueRepository) error { return nil }), - AddCustomChange("duplicated-id", func(tx LimitedKeyValueRepository) error { + AddCustomChange("duplicated-id", func(_ LimitedKeyValueRepository) error { return nil }), }}, } - _, err := RunKeyValueMigrations(dbPath, plan) + pool := openTestPool(t) + defer pool.Close() + _, err := migrateTestDB(t, pool, plan) if err == nil { t.Fatal("Expected an error due to duplicate CustomChange ID, but got none") } } -func TestRunKeyValueMigrations_FailsOnDuplicatedCustomChangeID_WhenUsingDifferentMigration(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "wallet.db") +func TestRunKVMigrations_FailsOnDuplicatedCustomChangeID_WhenUsingDifferentMigration(t *testing.T) { plan := []Migration{ - Migration{"First migration", []Change{ - AddCustomChange("duplicated-id", func(tx LimitedKeyValueRepository) error { + {"First migration", []Change{ + AddCustomChange("duplicated-id", func(_ LimitedKeyValueRepository) error { return nil }), }}, - Migration{"Second migration", []Change{ - AddCustomChange("duplicated-id", func(tx LimitedKeyValueRepository) error { + {"Second migration", []Change{ + AddCustomChange("duplicated-id", func(_ LimitedKeyValueRepository) error { return nil }), }}, } - _, err := RunKeyValueMigrations(dbPath, plan) + pool := openTestPool(t) + defer pool.Close() + _, err := migrateTestDB(t, pool, plan) if err == nil { t.Fatal("Expected an error due to duplicate CustomChange ID, but got none") } @@ -392,17 +419,18 @@ func TestDefine_PanicsWhenValueTypeIsNotAPointer(t *testing.T) { } }() plan := []Migration{ - Migration{"Value type is not a pointer", []Change{ + {"Value type is not a pointer", []Change{ Define("someKey", NoAutoBackup, NotApplicable, false, StringType{}), }}, } - dbPath := filepath.Join(t.TempDir(), "wallet.db") - _, _ = RunKeyValueMigrations(dbPath, plan) + pool := openTestPool(t) + defer pool.Close() + _, _ = migrateTestDB(t, pool, plan) } func buildTestMigrationPlan() []Migration { return []Migration{ - Migration{"Initial test schema", []Change{ + {"Initial test schema", []Change{ Define("nightMode", NoAutoBackup, NotApplicable, false, &IntType{}), Define("protocolForReceiving", NoAutoBackup, NotApplicable, false, &StringType{}), Define("securityCardUsageCount", NoAutoBackup, NotApplicable, false, &IntType{}), @@ -410,7 +438,7 @@ func buildTestMigrationPlan() []Migration { Define("useTaproot", NoAutoBackup, NotApplicable, false, &BoolType{}), Define("useDefaultConfig", NoAutoBackup, NotApplicable, false, &BoolType{}), }}, - Migration{"Migrate nightMode and protocol", []Change{ + {"Migrate nightMode and protocol", []Change{ MigrateValueTypeWithMap("nightMode", &StringType{}, map[string]string{ // Switch-case via map: "0"->"light", "1"->"dark" "0": "light", @@ -420,14 +448,17 @@ func buildTestMigrationPlan() []Migration { "UNIFIED": "LIGHTNING", }), }}, - Migration{"Initialize a key and update a second key based on other keys", []Change{ - AddCustomChange("initialize-security-card-usage-count", func(tx LimitedKeyValueRepository) error { - val, _ := tx.Get("securityCardUsageCount") - if val == nil { - return tx.Save("securityCardUsageCount", strPtr("0")) - } - return nil - }), + {"Initialize a key and update a second key based on other keys", []Change{ + AddCustomChange( + "initialize-security-card-usage-count", + func(tx LimitedKeyValueRepository) error { + val, _ := tx.Get("securityCardUsageCount") + if val == nil { + return tx.Save("securityCardUsageCount", strPtr("0")) + } + return nil + }, + ), AddCustomChange("set-use-default-config", func(tx LimitedKeyValueRepository) error { tc, _ := tx.Get("useTurboChannels") tr, _ := tx.Get("useTaproot") @@ -457,35 +488,40 @@ func TestMigrateValueType_TrivialConversions(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "wallet.db") + pool := openTestPool(t) + defer pool.Close() v1Plan := []Migration{ - Migration{"Initial schema", []Change{ + {"Initial schema", []Change{ Define("testKey", NoAutoBackup, NotApplicable, false, tc.fromType), }}, } - schemaV1, err := RunKeyValueMigrations(dbPath, v1Plan) + schemaV1, err := migrateTestDB(t, pool, v1Plan) if err != nil { t.Fatalf("V1 migration failed: %v", err) } - if err := NewKeyValueStorage(dbPath, schemaV1).Save("testKey", tc.initialValue); err != nil { + kvs := NewKeyValueStorage(pool.NewKeyValueRepository(), schemaV1) + err = kvs.Save("testKey", tc.initialValue) + if err != nil { t.Fatalf("V1 save failed: %v", err) } v2Plan := []Migration{ - Migration{"Initial schema", []Change{ + {"Initial schema", []Change{ Define("testKey", NoAutoBackup, NotApplicable, false, tc.fromType), }}, - Migration{"Migrate testKey", []Change{ + {"Migrate testKey", []Change{ MigrateValueType("testKey", tc.toType), }}, } - schemaV2, err := RunKeyValueMigrations(dbPath, v2Plan) + schemaV2, err := migrateTestDB(t, pool, v2Plan) if err != nil { t.Fatalf("V2 migration failed: %v", err) } - value, err := NewKeyValueStorage(dbPath, schemaV2).Get("testKey") + value, err := NewKeyValueStorage( + pool.NewKeyValueRepository(), schemaV2, + ).Get("testKey") if err != nil { t.Fatalf("Get failed: %v", err) } @@ -515,19 +551,24 @@ func TestMigrateValueType_PanicsOnNonTrivialConversions(t *testing.T) { t.Run(tc.name, func(t *testing.T) { defer func() { if r := recover(); r == nil { - t.Errorf("Expected panic for conversion from %T to %T, but did not panic", tc.fromType, tc.toType) + t.Errorf( + "Expected panic for conversion from %T to %T, but did not panic", + tc.fromType, + tc.toType, + ) } }() - dbPath := filepath.Join(t.TempDir(), "wallet.db") + pool := openTestPool(t) + defer pool.Close() plan := []Migration{ - Migration{"Initial schema", []Change{ + {"Initial schema", []Change{ Define("testKey", NoAutoBackup, NotApplicable, false, tc.fromType), }}, - Migration{"Migrate testKey", []Change{ + {"Migrate testKey", []Change{ MigrateValueType("testKey", tc.toType), }}, } - _, _ = RunKeyValueMigrations(dbPath, plan) + _, _ = migrateTestDB(t, pool, plan) }) } } @@ -549,7 +590,11 @@ func TestDefine_PanicsOnInvalidBackupSecurityCombination(t *testing.T) { t.Run(tc.name, func(t *testing.T) { defer func() { if r := recover(); r == nil { - t.Errorf("Expected panic for combination %v + %v, but did not panic", tc.backupType, tc.backupSecurity) + t.Errorf( + "Expected panic for combination %v + %v, but did not panic", + tc.backupType, + tc.backupSecurity, + ) } }() Define("someKey", tc.backupType, tc.backupSecurity, false, &StringType{}) @@ -587,6 +632,28 @@ func TestDefine_PanicsOnUnimplementedClassifications(t *testing.T) { }) } +func openTestPool(t *testing.T) *walletdb.Pool { + t.Helper() + pool, err := walletdb.NewPool(filepath.Join(t.TempDir(), "wallet.db"), nil) + if err != nil { + t.Fatalf("failed to open db: %v", err) + } + return pool +} + +func migrateTestDB( + t *testing.T, pool *walletdb.Pool, plan []Migration, +) (map[string]Classification, error) { + t.Helper() + var schema map[string]Classification + err := pool.WithDB(func(db *walletdb.DB) error { + var err error + schema, err = RunKeyValueMigrations(db, plan) + return err + }) + return schema, err +} + func strPtr(s string) *string { return &s } diff --git a/libwallet/storage/schema.go b/libwallet/storage/schema.go index 26bde0de..af358765 100644 --- a/libwallet/storage/schema.go +++ b/libwallet/storage/schema.go @@ -1,8 +1,9 @@ package storage import ( - "fmt" "strconv" + + "github.com/go-errors/errors" ) type BackupType int @@ -22,14 +23,20 @@ const ( Encrypted ) +// The following key constants are a convention, not a requirement. The KV schema is +// defined by the ordered migration plan in kv_migrations.go, and new keys must be +// added there via a Define(...) change — that is the source of truth. Adding a +// constant below is only useful when libwallet Go code itself references the key +// string; Kotlin/Swift callers pass raw strings over gRPC and never see these. const ( KeyIsBalanceHidden string = "isBalanceHidden" KeyNightMode string = "nightMode" KeySecurityCardXpubSerialized string = "securityCardXpubSerialized" KeyBiometricsOptIn string = "biometricsOptIn" KeyPinLength string = "pinLength" - // TODO: These three are marked as prototypes to avoid accidentally setting the non-prototype fields - // in a consumer device before finalizing the design. Before production, the "Prototype" suffix must be removed + // TODO: These three are marked as prototypes to avoid accidentally setting the non-prototype + // fields in a consumer device before finalizing the design. Before production, the "Prototype" + // suffix must be removed UnverifiedEncryptedMuunKey string = "unverifiedEncryptedMuungKeyPrototype" VerifiedEncryptedMuunKey string = "verifiedEncryptedMuunKeyPrototype" EncryptedUserKey string = "encryptedUserKeyPrototype" @@ -39,13 +46,21 @@ const ( FeatureFlagOverridesekGoRendering = "featureFlagOverrides:ekGoRendering" // ==== End of feature flag overrides ==== + // ==== Pending pair challenge (bridges PairRequestChallenge and PairSignAndSubmitChallenge RPCs) ==== + KeyPendingPairChallengeServerPubKeyInHex string = "pendingPairChallenge:serverPubKeyInHex" + KeyPendingPairChallengeReceivedAtUnixMillis string = "pendingPairChallenge:receivedAtUnixMillis" + // ==== End of pending pair challenge ==== // ==== Temporary keys for mock houston. Will remove soon ==== KeyLastRandomPrivKeyInHex string = "lastRandomPrivKeyInHex" KeySecurityCardUsageCount string = "securityCardUsageCount" + KeySecurityCardReplayCounter string = "securityCardReplayCounter" KeySecretCardBytesInHex string = "secretCardBytesInHex" KeySecurityCardPairingSlot string = "securityCardPairingSlot" KeyTimeSinceLastChallengeUnixMillis string = "timeSinceLastChallengeUnixMillis" // ==== End of temporary keys for mock houston ==== + + // ==== Lightning keys ==== + KeyLightningIncomingHTLCBatches string = "lightning:incomingHTLCBatches" ) type ValueType interface { @@ -72,7 +87,7 @@ func (IntType) ToString(value any) (string, error) { if ok { return strconv.Itoa(int(n)), nil } - return "", fmt.Errorf("IntType: invalid type, expected int32") + return "", errors.Errorf("IntType: invalid type, expected int32") } func (LongType) FromString(value string) (any, error) { @@ -88,7 +103,7 @@ func (LongType) ToString(value any) (string, error) { if ok { return strconv.FormatInt(n, 10), nil } - return "", fmt.Errorf("LongType: invalid type, expected int64") + return "", errors.Errorf("LongType: invalid type, expected int64") } func (DoubleType) FromString(value string) (any, error) { @@ -104,7 +119,7 @@ func (DoubleType) ToString(value any) (string, error) { if ok { return strconv.FormatFloat(f, 'f', -1, 64), nil } - return "", fmt.Errorf("DoubleType: invalid type, expected float64") + return "", errors.Errorf("DoubleType: invalid type, expected float64") } func (StringType) FromString(value string) (any, error) { @@ -116,7 +131,7 @@ func (StringType) ToString(value any) (string, error) { if ok { return str, nil } - return "", fmt.Errorf("StringType: invalid type, expected string") + return "", errors.Errorf("StringType: invalid type, expected string") } func (BoolType) FromString(value string) (any, error) { @@ -132,7 +147,7 @@ func (BoolType) ToString(value any) (string, error) { if ok { return strconv.FormatBool(bo), nil } - return "", fmt.Errorf("BoolType: invalid type, expected bool") + return "", errors.Errorf("BoolType: invalid type, expected bool") } // Classification that should contain each stored value @@ -142,4 +157,3 @@ type Classification struct { SecurityCritical bool ValueType ValueType } - diff --git a/libwallet/storage/storage.go b/libwallet/storage/storage.go index 807f04e5..92065dab 100644 --- a/libwallet/storage/storage.go +++ b/libwallet/storage/storage.go @@ -1,19 +1,23 @@ package storage import ( - "fmt" "strings" + "github.com/go-errors/errors" + "github.com/muun/libwallet/walletdb" ) type KeyValueStorage struct { - dataFilePath string + repo walletdb.KeyValueRepository keyClassificationMap map[string]Classification } -func NewKeyValueStorage(dataFilePath string, keyClassificationMap map[string]Classification) *KeyValueStorage { - return &KeyValueStorage{dataFilePath, keyClassificationMap} +func NewKeyValueStorage( + repo walletdb.KeyValueRepository, + keyClassificationMap map[string]Classification, +) *KeyValueStorage { + return &KeyValueStorage{repo, keyClassificationMap} } // Save or update a value for a key @@ -21,7 +25,7 @@ func (s *KeyValueStorage) Save(key string, value any) error { // Find the classification for the provided key classification, exists := s.keyClassificationMap[key] if !exists { - return fmt.Errorf("classification not found for key: %s", key) + return errors.Errorf("classification not found for key: %s", key) } // Transform value to string based on the value type @@ -29,23 +33,17 @@ func (s *KeyValueStorage) Save(key string, value any) error { if value != nil { str, err := classification.ValueType.ToString(value) if err != nil { - return fmt.Errorf("failed to convert value to string for key %s: %v", key, err) + return errors.Errorf( + "failed to convert value to string for key %s: %w", + key, + err, + ) } ptrStrValue = &str } // Insert or update a key-value on local database - db, err := walletdb.Open(s.dataFilePath) - if err != nil { - return err - } - defer db.Close() - err = db.NewKeyValueRepository().Save(key, ptrStrValue) - if err != nil { - return err - } - - return nil + return s.repo.Save(key, ptrStrValue) } // Get value by key @@ -53,18 +51,17 @@ func (s *KeyValueStorage) Get(key string) (any, error) { // Find the classification for the provided key classification, exists := s.keyClassificationMap[key] if !exists { - return nil, fmt.Errorf("classification not found for key: %s", key) + return nil, errors.Errorf("classification not found for key: %s", key) } // Fetch value by key from local database - db, err := walletdb.Open(s.dataFilePath) - if err != nil { - return nil, err - } - defer db.Close() - ptrStrValue, err := db.NewKeyValueRepository().Get(key) + ptrStrValue, err := s.repo.Get(key) if err != nil { - return nil, fmt.Errorf("failed to fetch from db for key %s: %v", key, err) + return nil, errors.Errorf( + "failed to fetch from db for key %s: %w", + key, + err, + ) } // Parse string to right type based on predefined key classification @@ -73,7 +70,11 @@ func (s *KeyValueStorage) Get(key string) (any, error) { } parsedValue, err := classification.ValueType.FromString(*ptrStrValue) if err != nil { - return nil, fmt.Errorf("failed to convert string to predefined type for key %s: %v", key, err) + return nil, errors.Errorf( + "failed to convert string to predefined type for key %s: %w", + key, + err, + ) } return parsedValue, nil @@ -83,21 +84,11 @@ func (s *KeyValueStorage) Delete(key string) error { // Check provided key is classified in our schema _, exists := s.keyClassificationMap[key] if !exists { - return fmt.Errorf("classification not found for key: %s", key) + return errors.Errorf("classification not found for key: %s", key) } // Remove key-value pair given the key - db, err := walletdb.Open(s.dataFilePath) - if err != nil { - return err - } - defer db.Close() - err = db.NewKeyValueRepository().Delete(key) - if err != nil { - return err - } - - return nil + return s.repo.Delete(key) } // SaveBatch saves or updates a map of key-values @@ -106,7 +97,7 @@ func (s *KeyValueStorage) SaveBatch(items map[string]any) error { for key := range items { _, exists := s.keyClassificationMap[key] if !exists { - return fmt.Errorf("classification not found for key: %s", key) + return errors.Errorf("classification not found for key: %s", key) } } @@ -117,17 +108,7 @@ func (s *KeyValueStorage) SaveBatch(items map[string]any) error { } // Insert or update key-values on local database - db, err := walletdb.Open(s.dataFilePath) - if err != nil { - return err - } - defer db.Close() - err = db.NewKeyValueRepository().SaveBatch(stringItems) - if err != nil { - return err - } - - return nil + return s.repo.SaveBatch(stringItems) } // GetBatch return a map of key-values given a key list @@ -137,25 +118,26 @@ func (s *KeyValueStorage) GetBatch(keys []string) (map[string]any, error) { for _, key := range keys { _, exists := s.keyClassificationMap[key] if !exists { - return nil, fmt.Errorf("classification not found for key: %s", key) + return nil, errors.Errorf("classification not found for key: %s", key) } } // Fetch key-values by keys from local database - db, err := walletdb.Open(s.dataFilePath) - if err != nil { - return nil, err - } - defer db.Close() - items, err := db.NewKeyValueRepository().GetBatch(keys) + items, err := s.repo.GetBatch(keys) if err != nil { - return nil, fmt.Errorf("failed to fetch from db: %v", err) + return nil, errors.Errorf( + "failed to fetch from db: %w", + err, + ) } // Parse string value of each key to its corresponding type parsedItems, err := parseMap(items, s.keyClassificationMap) if err != nil { - return nil, fmt.Errorf("failed to parse items to right type: %v", err) + return nil, errors.Errorf( + "failed to parse items to right type: %w", + err, + ) } return parsedItems, nil @@ -177,13 +159,17 @@ func (s *KeyValueStorage) GetByPrefix(prefix string) (map[string]any, error) { return s.GetBatch(matchingKeys) } -// Transform an 'any' map into a string map using predefined key classifications to determine how to convert each value -func transformToStringMap(items map[string]any, keyClassificationMap map[string]Classification) (map[string]*string, error) { +// Transform an 'any' map into a string map using predefined key classifications to determine how to +// convert each value +func transformToStringMap( + items map[string]any, + keyClassificationMap map[string]Classification, +) (map[string]*string, error) { stringItems := make(map[string]*string) for key, value := range items { classification, ok := keyClassificationMap[key] if !ok { - return nil, fmt.Errorf("classification not found for key: %s", key) + return nil, errors.Errorf("classification not found for key: %s", key) } if value == nil { @@ -193,7 +179,11 @@ func transformToStringMap(items map[string]any, keyClassificationMap map[string] stringValue, err := classification.ValueType.ToString(value) if err != nil { - return nil, fmt.Errorf("failed to convert value to string for key %s: %v", key, err) + return nil, errors.Errorf( + "failed to convert value to string for key %s: %w", + key, + err, + ) } stringItems[key] = &stringValue } @@ -201,7 +191,10 @@ func transformToStringMap(items map[string]any, keyClassificationMap map[string] } // Parse a string map based on predefined key classifications -func parseMap(stringMap map[string]*string, keyClassificationMap map[string]Classification) (map[string]any, error) { +func parseMap( + stringMap map[string]*string, + keyClassificationMap map[string]Classification, +) (map[string]any, error) { parsedItems := make(map[string]any) for key, ptrStrValue := range stringMap { @@ -218,7 +211,11 @@ func parseMap(stringMap map[string]*string, keyClassificationMap map[string]Clas parsed, err := classification.ValueType.FromString(*ptrStrValue) if err != nil { - return nil, fmt.Errorf("failed to convert string to predefined type for key %s: %v", key, err) + return nil, errors.Errorf( + "failed to convert string to predefined type for key %s: %w", + key, + err, + ) } parsedItems[key] = parsed diff --git a/libwallet/storage/storage_test.go b/libwallet/storage/storage_test.go index 0ef26671..5393e328 100644 --- a/libwallet/storage/storage_test.go +++ b/libwallet/storage/storage_test.go @@ -5,14 +5,27 @@ import ( "reflect" "strings" "testing" + + "github.com/muun/libwallet/walletdb" ) +func newKeyValueStorageForTesting(t *testing.T) *KeyValueStorage { + pool, err := walletdb.NewPool(path.Join(t.TempDir(), "test.db"), nil) + if err != nil { + t.Fatalf("failed to open db: %v", err) + } + t.Cleanup(func() { pool.Close() }) + return NewKeyValueStorage( + pool.NewKeyValueRepository(), + buildStorageSchemaForTests(), + ) +} + func TestGetAndSave(t *testing.T) { t.Run("returns error when key is not classified by saving data", func(t *testing.T) { // Setup - dataDir := path.Join(t.TempDir(), "test.db") - keyValueStorage := NewKeyValueStorage(dataDir, buildStorageSchemaForTests()) + keyValueStorage := newKeyValueStorageForTesting(t) // Test saving an invalid key. err := keyValueStorage.Save("invalid-key", nil) @@ -28,8 +41,7 @@ func TestGetAndSave(t *testing.T) { t.Run("returns error when key is not classified by reading data", func(t *testing.T) { // Setup - dataDir := path.Join(t.TempDir(), "test.db") - keyValueStorage := NewKeyValueStorage(dataDir, buildStorageSchemaForTests()) + keyValueStorage := newKeyValueStorageForTesting(t) // Test getting an invalid key. _, err := keyValueStorage.Get("invalid-key") @@ -45,8 +57,7 @@ func TestGetAndSave(t *testing.T) { t.Run("success when saving a key with nil value", func(t *testing.T) { // Setup - dataDir := path.Join(t.TempDir(), "test.db") - keyValueStorage := NewKeyValueStorage(dataDir, buildStorageSchemaForTests()) + keyValueStorage := newKeyValueStorageForTesting(t) // Try saving a valid key with nil as value. err := keyValueStorage.Save("gcmToken", nil) @@ -68,8 +79,7 @@ func TestGetAndSave(t *testing.T) { t.Run("returns error when key is not classified by deleting data", func(t *testing.T) { // Setup - dataDir := path.Join(t.TempDir(), "test.db") - keyValueStorage := NewKeyValueStorage(dataDir, buildStorageSchemaForTests()) + keyValueStorage := newKeyValueStorageForTesting(t) // Test deleting an invalid key. err := keyValueStorage.Delete("invalid-key") @@ -85,8 +95,7 @@ func TestGetAndSave(t *testing.T) { t.Run("success when key is classified appropriately", func(t *testing.T) { // Setup - dataDir := path.Join(t.TempDir(), "test.db") - keyValueStorage := NewKeyValueStorage(dataDir, buildStorageSchemaForTests()) + keyValueStorage := newKeyValueStorageForTesting(t) // Try to save a value with key that exists in the classification for our keys. err := keyValueStorage.Save("gcmToken", "abc123") @@ -104,8 +113,7 @@ func TestGetAndSave(t *testing.T) { t.Run("returns no error when there are no stored values for a key", func(t *testing.T) { // Setup - dataDir := path.Join(t.TempDir(), "test.db") - keyValueStorage := NewKeyValueStorage(dataDir, buildStorageSchemaForTests()) + keyValueStorage := newKeyValueStorageForTesting(t) // Try to get a key for which no one has saved a value before. var value any @@ -122,8 +130,7 @@ func TestGetAndSave(t *testing.T) { t.Run("returns error when value has invalid type", func(t *testing.T) { // Setup - dataDir := path.Join(t.TempDir(), "test.db") - keyValueStorage := NewKeyValueStorage(dataDir, buildStorageSchemaForTests()) + keyValueStorage := newKeyValueStorageForTesting(t) // Test saving a valid key with an invalid schema. var invalidTypeForValue = 3.14 @@ -141,8 +148,7 @@ func TestGetAndSave(t *testing.T) { t.Run("success when value with type Bool can be parsed", func(t *testing.T) { // Setup - dataDir := path.Join(t.TempDir(), "test.db") - keyValueStorage := NewKeyValueStorage(dataDir, buildStorageSchemaForTests()) + keyValueStorage := newKeyValueStorageForTesting(t) // Test saving a valid key with a valid type. err := keyValueStorage.Save("isEmailVerified", true) @@ -155,8 +161,7 @@ func TestGetAndSave(t *testing.T) { t.Run("success when value with type Int can be parsed", func(t *testing.T) { // Setup - dataDir := path.Join(t.TempDir(), "test.db") - keyValueStorage := NewKeyValueStorage(dataDir, buildStorageSchemaForTests()) + keyValueStorage := newKeyValueStorageForTesting(t) // Test saving a valid key with a valid type. err := keyValueStorage.Save("emergencyKitVersion", int32(1234)) @@ -169,8 +174,7 @@ func TestGetAndSave(t *testing.T) { t.Run("return no error when deleting a key-value pair", func(t *testing.T) { // Setup - dataDir := path.Join(t.TempDir(), "test.db") - keyValueStorage := NewKeyValueStorage(dataDir, buildStorageSchemaForTests()) + keyValueStorage := newKeyValueStorageForTesting(t) // Initially the value of a key is nil got, err := keyValueStorage.Get("gcmToken") @@ -219,8 +223,7 @@ func TestGetAndSave(t *testing.T) { t.Run("returns no error when updating a key with a new String value", func(t *testing.T) { // Setup - dataDir := path.Join(t.TempDir(), "test.db") - keyValueStorage := NewKeyValueStorage(dataDir, buildStorageSchemaForTests()) + keyValueStorage := newKeyValueStorageForTesting(t) // Initially the value of a key is nil got, err := keyValueStorage.Get("gcmToken") @@ -271,8 +274,7 @@ func TestGetAndSave(t *testing.T) { t.Run("returns no error when updating a key with a new Int value", func(t *testing.T) { // Setup - dataDir := path.Join(t.TempDir(), "test.db") - keyValueStorage := NewKeyValueStorage(dataDir, buildStorageSchemaForTests()) + keyValueStorage := newKeyValueStorageForTesting(t) // Initially the value of a key is nil got, err := keyValueStorage.Get("emergencyKitVersion") @@ -327,8 +329,7 @@ func TestGetBatchAndSaveBatch(t *testing.T) { t.Run("returns error when any key is not classified when saving batch", func(t *testing.T) { // Setup - dataDir := path.Join(t.TempDir(), "test.db") - keyValueStorage := NewKeyValueStorage(dataDir, buildStorageSchemaForTests()) + keyValueStorage := newKeyValueStorageForTesting(t) // Test saving items with an invalid key. var items = make(map[string]any) @@ -348,8 +349,7 @@ func TestGetBatchAndSaveBatch(t *testing.T) { t.Run("returns error when any key is not classified when getting batch", func(t *testing.T) { // Setup - dataDir := path.Join(t.TempDir(), "test.db") - keyValueStorage := NewKeyValueStorage(dataDir, buildStorageSchemaForTests()) + keyValueStorage := newKeyValueStorageForTesting(t) // Test getting items where one of the keys is invalid. var keys = []string{"primaryCurrency", "invalid-key"} @@ -367,8 +367,7 @@ func TestGetBatchAndSaveBatch(t *testing.T) { t.Run("returns no error when there are no stored values by getting batch", func(t *testing.T) { // Setup - dataDir := path.Join(t.TempDir(), "test.db") - keyValueStorage := NewKeyValueStorage(dataDir, buildStorageSchemaForTests()) + keyValueStorage := newKeyValueStorageForTesting(t) // Try to get keys for which no one has saved a value before. var keys = []string{"primaryCurrency", "isEmailVerified"} @@ -392,8 +391,7 @@ func TestGetBatchAndSaveBatch(t *testing.T) { t.Run("returns no error when reading, saving and updating batch", func(t *testing.T) { // Setup - dataDir := path.Join(t.TempDir(), "test.db") - keyValueStorage := NewKeyValueStorage(dataDir, buildStorageSchemaForTests()) + keyValueStorage := newKeyValueStorageForTesting(t) // Initially the value of the keys is nil var keys = []string{"primaryCurrency", "email", "isEmailVerified", "emergencyKitVersion"} @@ -456,8 +454,7 @@ func TestGetBatchAndSaveBatch(t *testing.T) { t.Run("returns no error when reading, saving and updating a single item", func(t *testing.T) { // Setup - dataDir := path.Join(t.TempDir(), "test.db") - keyValueStorage := NewKeyValueStorage(dataDir, buildStorageSchemaForTests()) + keyValueStorage := newKeyValueStorageForTesting(t) // Initially the value of the key is nil var keys = []string{"primaryCurrency"} @@ -515,8 +512,7 @@ func TestGetBatchAndSaveBatch(t *testing.T) { func TestGetByPrefix(t *testing.T) { t.Run("returns all feature flags", func(t *testing.T) { // Setup - dataDir := path.Join(t.TempDir(), "test.db") - keyValueStorage := NewKeyValueStorage(dataDir, buildStorageSchemaForTests()) + keyValueStorage := newKeyValueStorageForTesting(t) // Save some feature flags items := make(map[string]any) @@ -556,8 +552,7 @@ func TestGetByPrefix(t *testing.T) { t.Run("returns empty map if no keys match prefix", func(t *testing.T) { // Setup - dataDir := path.Join(t.TempDir(), "test.db") - keyValueStorage := NewKeyValueStorage(dataDir, buildStorageSchemaForTests()) + keyValueStorage := newKeyValueStorageForTesting(t) // Save some data items := make(map[string]any) @@ -583,28 +578,52 @@ func TestGetByPrefix(t *testing.T) { func buildStorageSchemaForTests() map[string]Classification { return map[string]Classification{ "email": { - BackupType: NoAutoBackup, BackupSecurity: NotApplicable, SecurityCritical: false, ValueType: &StringType{}, + BackupType: NoAutoBackup, + BackupSecurity: NotApplicable, + SecurityCritical: false, + ValueType: &StringType{}, }, "emergencyKitVersion": { - BackupType: NoAutoBackup, BackupSecurity: NotApplicable, SecurityCritical: false, ValueType: &IntType{}, + BackupType: NoAutoBackup, + BackupSecurity: NotApplicable, + SecurityCritical: false, + ValueType: &IntType{}, }, "gcmToken": { - BackupType: NoAutoBackup, BackupSecurity: NotApplicable, SecurityCritical: false, ValueType: &StringType{}, + BackupType: NoAutoBackup, + BackupSecurity: NotApplicable, + SecurityCritical: false, + ValueType: &StringType{}, }, "isEmailVerified": { - BackupType: NoAutoBackup, BackupSecurity: NotApplicable, SecurityCritical: false, ValueType: &BoolType{}, + BackupType: NoAutoBackup, + BackupSecurity: NotApplicable, + SecurityCritical: false, + ValueType: &BoolType{}, }, "primaryCurrency": { - BackupType: NoAutoBackup, BackupSecurity: NotApplicable, SecurityCritical: false, ValueType: &StringType{}, + BackupType: NoAutoBackup, + BackupSecurity: NotApplicable, + SecurityCritical: false, + ValueType: &StringType{}, }, "featureFlag:useDiagnosticMode": { - BackupType: NoAutoBackup, BackupSecurity: NotApplicable, SecurityCritical: false, ValueType: &BoolType{}, + BackupType: NoAutoBackup, + BackupSecurity: NotApplicable, + SecurityCritical: false, + ValueType: &BoolType{}, }, "featureFlag:isDogfood": { - BackupType: NoAutoBackup, BackupSecurity: NotApplicable, SecurityCritical: false, ValueType: &BoolType{}, + BackupType: NoAutoBackup, + BackupSecurity: NotApplicable, + SecurityCritical: false, + ValueType: &BoolType{}, }, "featureFlag:supportsNfc": { - BackupType: NoAutoBackup, BackupSecurity: NotApplicable, SecurityCritical: false, ValueType: &BoolType{}, + BackupType: NoAutoBackup, + BackupSecurity: NotApplicable, + SecurityCritical: false, + ValueType: &BoolType{}, }, } } diff --git a/libwallet/storage/testdata/kv_migrations.lock b/libwallet/storage/testdata/kv_migrations.lock index 0741232b..e674b644 100644 --- a/libwallet/storage/testdata/kv_migrations.lock +++ b/libwallet/storage/testdata/kv_migrations.lock @@ -27,6 +27,29 @@ "sha256:689fea473d3b8fa6a35c9793abea4b2b70249def662ed11d7e2f88897523c753", "sha256:6749b6f1376eb169e7e87429f7cf8e2e63882b33ed93b0836c3c9b97ad0b5a57" ] + }, + { + "description": "Pending pair challenge", + "hash": "sha256:5e092f5fdc53e9740f25a5b00ac3d7a0b3d7e91bf22ed1f9f8c29cb0995a976f", + "change_hashes": [ + "sha256:2b88612144adbdfcc661b1c499b357b6ab6bfdf7be69586351ceab6c5feca255", + "sha256:c293f2b646faca606959ea04da8ad77b22dd992682acdcf864e2b70182c21cbe" + ] + }, + { + "description": "Mock Houston V3 replay counter", + "hash": "sha256:faf2c1dfab791ce409c62c996402a7ba6862ca3fe9cc8cdc76465c0e86ad872b", + "change_hashes": [ + "sha256:0b93676ffe8eaa69bb67cae329ed896935ae0507215bf02fc926a02dde7369fe" + ] + }, + { + "description": "NFC empirical Extended APDU signals", + "hash": "sha256:93dc4e8e1b23f32ced540fb53d570faad480cee6703808da851c2d9c2ec29d42", + "change_hashes": [ + "sha256:12230437d604544f6f68fc81456a823fc9566c6e8ba0b2c243823a7d919bf3b8", + "sha256:5cf2a221e7747483a636767f21148750ebbdadba7767647468141b0e7536468e" + ] } ] } \ No newline at end of file diff --git a/libwallet/submarineSwap.go b/libwallet/submarineSwap.go index 1bc902fa..07d4804a 100644 --- a/libwallet/submarineSwap.go +++ b/libwallet/submarineSwap.go @@ -38,7 +38,14 @@ type SubmarineSwapFundingOutput interface { MuunPublicKey() *HDPublicKey } -func ValidateSubmarineSwap(rawInvoice string, userPublicKey *HDPublicKey, muunPublicKey *HDPublicKey, swap SubmarineSwap, originalExpirationInBlocks int64, network *Network) error { +func ValidateSubmarineSwap( + rawInvoice string, + userPublicKey *HDPublicKey, + muunPublicKey *HDPublicKey, + swap SubmarineSwap, + originalExpirationInBlocks int64, + network *Network, +) error { data := swaps.SubmarineSwap{ Invoice: swap.Invoice(), Receiver: swaps.SubmarineSwapReceiver{ diff --git a/libwallet/submarineSwapV1.go b/libwallet/submarineSwapV1.go index 84d6e075..7184d219 100644 --- a/libwallet/submarineSwapV1.go +++ b/libwallet/submarineSwapV1.go @@ -1,12 +1,11 @@ package libwallet import ( - "errors" - "fmt" - "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/wire" + "github.com/go-errors/errors" + "github.com/muun/libwallet/swaps" ) @@ -26,7 +25,7 @@ func (c *coinSubmarineSwapV1) SignInput(index int, tx *wire.MsgTx, userKey *HDPr userKey, err := userKey.DeriveTo(c.KeyPath) if err != nil { - return fmt.Errorf("failed to derive user key: %w", err) + return errors.Errorf("failed to derive user key: %w", err) } witnessScript, err := swaps.CreateWitnessScriptSubmarineSwapV1( @@ -42,7 +41,7 @@ func (c *coinSubmarineSwapV1) SignInput(index int, tx *wire.MsgTx, userKey *HDPr redeemScript, err := createNonNativeSegwitRedeemScript(witnessScript) if err != nil { - return fmt.Errorf("failed to build reedem script for signing: %w", err) + return errors.Errorf("failed to build reedem script for signing: %w", err) } sig, err := signNonNativeSegwitInputV0( @@ -57,6 +56,10 @@ func (c *coinSubmarineSwapV1) SignInput(index int, tx *wire.MsgTx, userKey *HDPr return nil } -func (c *coinSubmarineSwapV1) FullySignInput(index int, tx *wire.MsgTx, userKey, muunKey *HDPrivateKey) error { +func (c *coinSubmarineSwapV1) FullySignInput( + index int, //nolint:revive // TODO: use or remove index + tx *wire.MsgTx, //nolint:revive // TODO: use or remove tx + userKey, muunKey *HDPrivateKey, //nolint:revive // TODO: use or remove userKey +) error { return errors.New("cannot fully sign submarine swap transactions") } diff --git a/libwallet/submarineSwapV2.go b/libwallet/submarineSwapV2.go index 3f7f9af4..42ac4565 100644 --- a/libwallet/submarineSwapV2.go +++ b/libwallet/submarineSwapV2.go @@ -1,12 +1,11 @@ package libwallet import ( - "errors" - "fmt" - "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/wire" + "github.com/go-errors/errors" + "github.com/muun/libwallet/swaps" ) @@ -28,7 +27,7 @@ func (c *coinSubmarineSwapV2) SignInput(index int, tx *wire.MsgTx, userKey *HDPr userKey, err := userKey.DeriveTo(c.KeyPath) if err != nil { - return fmt.Errorf("failed to derive user key: %w", err) + return errors.Errorf("failed to derive user key: %w", err) } if len(c.ServerSignature) == 0 { @@ -61,6 +60,10 @@ func (c *coinSubmarineSwapV2) SignInput(index int, tx *wire.MsgTx, userKey *HDPr return nil } -func (c *coinSubmarineSwapV2) FullySignInput(index int, tx *wire.MsgTx, userKey, muunKey *HDPrivateKey) error { +func (c *coinSubmarineSwapV2) FullySignInput( + index int, //nolint:revive // TODO: use or remove index + tx *wire.MsgTx, //nolint:revive // TODO: use or remove tx + userKey, muunKey *HDPrivateKey, //nolint:revive // TODO: use or remove userKey +) error { return errors.New("cannot fully sign submarine swap transactions") } diff --git a/libwallet/swaps/swaps.go b/libwallet/swaps/swaps.go index b1907abe..60f4bcf1 100644 --- a/libwallet/swaps/swaps.go +++ b/libwallet/swaps/swaps.go @@ -2,15 +2,15 @@ package swaps import ( "crypto/sha256" - "fmt" - - "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/btcutil/hdkeychain" + "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/txscript" + "github.com/go-errors/errors" + hash "golang.org/x/crypto/ripemd160" //lint:ignore SA1019 using deprecated hash function for compatibility + "github.com/muun/libwallet/addresses" "github.com/muun/libwallet/hdpath" - hash "golang.org/x/crypto/ripemd160" //lint:ignore SA1019 using deprecated hash function for compatibility ) type SubmarineSwap struct { @@ -55,11 +55,11 @@ func (d *KeyDescriptor) DeriveTo(path string) (*hdkeychain.ExtendedKey, error) { currentPath, err := hdpath.Parse(d.Path) if err != nil { - return nil, fmt.Errorf("invalid current key path: %w", err) + return nil, errors.Errorf("invalid current key path: %w", err) } targetPath, err := hdpath.Parse(path) if err != nil { - return nil, fmt.Errorf("invalid target key path: %w", err) + return nil, errors.Errorf("invalid target key path: %w", err) } indexes := targetPath.IndexesFrom(currentPath) for _, index := range indexes { @@ -89,9 +89,15 @@ func (swap *SubmarineSwap) Validate( case addresses.SubmarineSwapV1: return swap.validateV1(rawInvoice, userPublicKey, muunPublicKey, network) case addresses.SubmarineSwapV2: - return swap.validateV2(rawInvoice, userPublicKey, muunPublicKey, originalExpirationInBlocks, network) + return swap.validateV2( + rawInvoice, + userPublicKey, + muunPublicKey, + originalExpirationInBlocks, + network, + ) default: - return fmt.Errorf("unknown swap version %v", version) + return errors.Errorf("unknown swap version %v", version) } } diff --git a/libwallet/swaps/v1.go b/libwallet/swaps/v1.go index a05eda51..31bd08ff 100644 --- a/libwallet/swaps/v1.go +++ b/libwallet/swaps/v1.go @@ -4,33 +4,41 @@ import ( "bytes" "crypto/sha256" "encoding/hex" - "fmt" - - "github.com/btcsuite/btcd/txscript" - "github.com/muun/libwallet/btcsuitew/btcutilw" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/txscript" + "github.com/go-errors/errors" "github.com/lightningnetwork/lnd/zpay32" + "github.com/muun/libwallet/addresses" + "github.com/muun/libwallet/btcsuitew/btcutilw" ) -func (swap *SubmarineSwap) validateV1(rawInvoice string, userPublicKey, muunPublicKey *KeyDescriptor, network *chaincfg.Params) error { +func (swap *SubmarineSwap) validateV1( + rawInvoice string, + userPublicKey, muunPublicKey *KeyDescriptor, + network *chaincfg.Params, +) error { invoice, err := zpay32.Decode(rawInvoice, network) if err != nil { - return fmt.Errorf("failed to decode invoice: %w", err) + return errors.Errorf("failed to decode invoice: %w", err) } // Check the payment hash matches serverPaymentHash, err := hex.DecodeString(swap.FundingOutput.ServerPaymentHashInHex) if err != nil { - return fmt.Errorf("server payment hash is not valid hex: %w", err) + return errors.Errorf("server payment hash is not valid hex: %w", err) } if !bytes.Equal(invoice.PaymentHash[:], serverPaymentHash) { - return fmt.Errorf("payment hash doesn't match %v != %v", hex.EncodeToString(invoice.PaymentHash[:]), swap.FundingOutput.ServerPaymentHashInHex) + return errors.Errorf( + "payment hash doesn't match %v != %v", + hex.EncodeToString(invoice.PaymentHash[:]), + swap.FundingOutput.ServerPaymentHashInHex, + ) } // TODO: check that timelock is acceptable @@ -40,11 +48,11 @@ func (swap *SubmarineSwap) validateV1(rawInvoice string, userPublicKey, muunPubl swapRefundAddress := swap.FundingOutput.UserRefundAddress derivedUserKey, err := userPublicKey.DeriveTo(swapRefundAddress.DerivationPath()) if err != nil { - return fmt.Errorf("failed to derive user key: %w", err) + return errors.Errorf("failed to derive user key: %w", err) } derivedMuunKey, err := muunPublicKey.DeriveTo(swapRefundAddress.DerivationPath()) if err != nil { - return fmt.Errorf("failed to derive muun key: %w", err) + return errors.Errorf("failed to derive muun key: %w", err) } refundAddress, err := addresses.Create( @@ -55,18 +63,22 @@ func (swap *SubmarineSwap) validateV1(rawInvoice string, userPublicKey, muunPubl network, ) if err != nil { - return fmt.Errorf("failed to generate refund address: %w", err) + return errors.Errorf("failed to generate refund address: %w", err) } if refundAddress.Address() != swapRefundAddress.Address() { - return fmt.Errorf("refund address doesn't match generated (%v != %v)", swapRefundAddress.Address(), refundAddress.Address()) + return errors.Errorf( + "refund address doesn't match generated (%v != %v)", + swapRefundAddress.Address(), + refundAddress.Address(), + ) } // Check the swap's witness script is a valid swap script serverPubKey, err := hex.DecodeString(swap.FundingOutput.ServerPublicKeyInHex) if err != nil { - return fmt.Errorf("server pub key is not hex: %w", err) + return errors.Errorf("server pub key is not hex: %w", err) } witnessScript, err := CreateWitnessScriptSubmarineSwapV1( @@ -76,47 +88,60 @@ func (swap *SubmarineSwap) validateV1(rawInvoice string, userPublicKey, muunPubl swap.FundingOutput.UserLockTime, network) if err != nil { - return fmt.Errorf("failed to compute witness script: %w", err) + return errors.Errorf("failed to compute witness script: %w", err) } redeemScript, err := createNonNativeSegwitRedeemScript(witnessScript) if err != nil { - return fmt.Errorf("failed to build redeem script: %w", err) + return errors.Errorf("failed to build redeem script: %w", err) } address, err := btcutil.NewAddressScriptHash(redeemScript, network) if err != nil { - return fmt.Errorf("failed to build address for swap script: %w", err) + return errors.Errorf("failed to build address for swap script: %w", err) } if address.EncodeAddress() != swap.FundingOutput.OutputAddress { - return fmt.Errorf("address for swap script mismatch (%v != %v)", address.EncodeAddress(), swap.FundingOutput.OutputAddress) + return errors.Errorf( + "address for swap script mismatch (%v != %v)", + address.EncodeAddress(), + swap.FundingOutput.OutputAddress, + ) } if len(swap.PreimageInHex) > 0 { preimage, err := hex.DecodeString(swap.PreimageInHex) if err != nil { - return fmt.Errorf("preimagehex is not actually hex: %w", err) + return errors.Errorf("preimagehex is not actually hex: %w", err) } calculatedPaymentHash := sha256.Sum256(preimage) if !bytes.Equal(invoice.PaymentHash[:], calculatedPaymentHash[:]) { - return fmt.Errorf("payment hash doesn't match preimage (%v != hash(%v)", invoice.PaymentHash, swap.PreimageInHex) + return errors.Errorf( + "payment hash doesn't match preimage (%v != hash(%v)", + invoice.PaymentHash, + swap.PreimageInHex, + ) } } return nil } -func CreateWitnessScriptSubmarineSwapV1(refundAddress string, paymentHash []byte, swapServerPubKey []byte, lockTime int64, network *chaincfg.Params) ([]byte, error) { +func CreateWitnessScriptSubmarineSwapV1( + refundAddress string, + paymentHash []byte, + swapServerPubKey []byte, + lockTime int64, + network *chaincfg.Params, +) ([]byte, error) { - // It turns out that the payment hash present in an invoice is just the SHA256 of the - // payment preimage, so we still have to do a pass of RIPEMD160 before pushing it to the - // script + // It turns out that the payment hash present in an invoice is just the SHA256 of the payment + // preimage, so we still have to do a pass of RIPEMD160 before pushing it to the script paymentHash160 := ripemd160(paymentHash) decodedRefundAddress, err := btcutilw.DecodeAddress(refundAddress, network) if err != nil { - return nil, fmt.Errorf("refund address is invalid: %w", err) + return nil, errors.Errorf("refund address is invalid: %w", err) } refundAddressHash := decodedRefundAddress.ScriptAddress() diff --git a/libwallet/swaps/v1_test.go b/libwallet/swaps/v1_test.go index 7ab46684..f5b9a3e2 100755 --- a/libwallet/swaps/v1_test.go +++ b/libwallet/swaps/v1_test.go @@ -5,6 +5,7 @@ import ( "github.com/btcsuite/btcd/btcutil/hdkeychain" "github.com/btcsuite/btcd/chaincfg" + "github.com/muun/libwallet/addresses" ) @@ -24,26 +25,38 @@ func TestValidateSubmarineSwapV1(t *testing.T) { { name: "successful", args: args{ - rawInvoice: "lnbcrt1p033394pp5sfcfh0ukkjfcvcg2vwk2hudue9d48lawqkacdan4msxne66w4krqdqqcqzpgsp5jelulm6a7q38j6jffa9qet3scz4qvcs08x6hfsyn0lfg34p2584q9qy9qsqjcq059jh8qeslj7qwl69ln69znalrxykhaj4kl0g0kfstwsa3warsyxx2d0rqs24tx896lz895wffqaj7l82zs896ec7r5arnw0cwtqpvzt8yd", + rawInvoice: "lnbcrt1p033394pp5sfcfh0ukkjfcvcg2vwk2hudue9d48lawqkacdan4msxne66w4krqdqqcqzpgsp5jelulm6a7q38j6jffa9qet3scz4qvcs08x6hfsyn0lfg34p2584q9qy9qsqjcq059jh8qeslj7qwl69ln69znalrxykhaj4kl0g0kfstwsa3warsyxx2d0rqs24tx896lz895wffqaj7l82zs896ec7r5arnw0cwtqpvzt8yd", //nolint:lll userPublicKey: &KeyDescriptor{ - Key: decodeKey("tpubD6NzVbkrYhZ4Y3iy9soFSA9zoYbpyhUFu3eAH1sDWyERxH2yJVZUhPUX5QsxD6bZfMWRKzxw28ohD5n6AZWmvZbDpZzgxSVxUnMevqzTXQk"), + Key: decodeKey( + "tpubD6NzVbkrYhZ4Y3iy9soFSA9zoYbpyhUFu3eAH1sDWyERxH2yJVZUhPUX5QsxD6bZfMWRKzxw28ohD5n6AZWmvZbDpZzgxSVxUnMevqzTXQk", //nolint:lll + ), Path: "m", }, muunPublicKey: &KeyDescriptor{ - Key: decodeKey("tpubD6NzVbkrYhZ4XbhomyY2axxKe3KB1FK2Wq2z7XYyDF3T4QCuEDZFBUyGfjfHChvEbsbP9RpaYA8cwxkZpQjEcNdaPfuj3cKGqCiHC5YeRTo"), + Key: decodeKey( + "tpubD6NzVbkrYhZ4XbhomyY2axxKe3KB1FK2Wq2z7XYyDF3T4QCuEDZFBUyGfjfHChvEbsbP9RpaYA8cwxkZpQjEcNdaPfuj3cKGqCiHC5YeRTo", //nolint:lll + ), Path: "m", }, swap: &SubmarineSwap{ FundingOutput: SubmarineSwapFundingOutput{ OutputAddress: "2MvW8nGkzFXnLWUca6ZGUh3yqEq5MKEyAxb", ExpirationInBlocks: 10, - ServerPaymentHashInHex: "82709bbf96b49386610a63acabf1bcc95b53ffae05bb86f675dc0d3ceb4ead86", - UserRefundAddress: addresses.New(addresses.V4, "m", "bcrt1q553urspdhwr49xavd67fvl35pzacz4853l4u09vntr8z06djnw7s95fgat"), - UserPublicKey: decodeKey("tpubD6NzVbkrYhZ4Y3iy9soFSA9zoYbpyhUFu3eAH1sDWyERxH2yJVZUhPUX5QsxD6bZfMWRKzxw28ohD5n6AZWmvZbDpZzgxSVxUnMevqzTXQk"), - MuunPublicKey: decodeKey("tpubD6NzVbkrYhZ4XbhomyY2axxKe3KB1FK2Wq2z7XYyDF3T4QCuEDZFBUyGfjfHChvEbsbP9RpaYA8cwxkZpQjEcNdaPfuj3cKGqCiHC5YeRTo"), + ServerPaymentHashInHex: "82709bbf96b49386610a63acabf1bcc95b53ffae05bb86f675dc0d3ceb4ead86", //nolint:lll + UserRefundAddress: addresses.New( + addresses.V4, + "m", + "bcrt1q553urspdhwr49xavd67fvl35pzacz4853l4u09vntr8z06djnw7s95fgat", + ), + UserPublicKey: decodeKey( + "tpubD6NzVbkrYhZ4Y3iy9soFSA9zoYbpyhUFu3eAH1sDWyERxH2yJVZUhPUX5QsxD6bZfMWRKzxw28ohD5n6AZWmvZbDpZzgxSVxUnMevqzTXQk", //nolint:lll + ), + MuunPublicKey: decodeKey( + "tpubD6NzVbkrYhZ4XbhomyY2axxKe3KB1FK2Wq2z7XYyDF3T4QCuEDZFBUyGfjfHChvEbsbP9RpaYA8cwxkZpQjEcNdaPfuj3cKGqCiHC5YeRTo", //nolint:lll + ), }, Receiver: SubmarineSwapReceiver{ - PublicKey: "02c9a35bdbeab0b93ee9542d85c38beab7d1e72ea1d9639e5b00b1d5feb64bcfdd", + PublicKey: "02c9a35bdbeab0b93ee9542d85c38beab7d1e72ea1d9639e5b00b1d5feb64bcfdd", //nolint:lll }, }, network: &chaincfg.RegressionNetParams, diff --git a/libwallet/swaps/v2.go b/libwallet/swaps/v2.go index 54d754c3..9270480b 100644 --- a/libwallet/swaps/v2.go +++ b/libwallet/swaps/v2.go @@ -4,46 +4,63 @@ import ( "bytes" "crypto/sha256" "encoding/hex" - "fmt" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/btcutil/hdkeychain" "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/txscript" + "github.com/go-errors/errors" "github.com/lightningnetwork/lnd/zpay32" ) -func (swap *SubmarineSwap) validateV2(rawInvoice string, userPublicKey, muunPublicKey *KeyDescriptor, originalExpirationInBlocks int64, network *chaincfg.Params) error { +func (swap *SubmarineSwap) validateV2( + rawInvoice string, + userPublicKey, muunPublicKey *KeyDescriptor, + originalExpirationInBlocks int64, + network *chaincfg.Params, +) error { fundingOutput := swap.FundingOutput invoice, err := zpay32.Decode(rawInvoice, network) if err != nil { - return fmt.Errorf("failed to decode invoice: %w", err) + return errors.Errorf("failed to decode invoice: %w", err) } // Check the payment hash matches serverPaymentHash, err := hex.DecodeString(fundingOutput.ServerPaymentHashInHex) if err != nil { - return fmt.Errorf("server payment hash is not valid hex: %w", err) + return errors.Errorf("server payment hash is not valid hex: %w", err) } if !bytes.Equal(invoice.PaymentHash[:], serverPaymentHash) { - return fmt.Errorf("payment hash doesn't match %v != %v", hex.EncodeToString(invoice.PaymentHash[:]), fundingOutput.ServerPaymentHashInHex) + return errors.Errorf( + "payment hash doesn't match %v != %v", + hex.EncodeToString(invoice.PaymentHash[:]), + fundingOutput.ServerPaymentHashInHex, + ) } destination, err := hex.DecodeString(swap.Receiver.PublicKey) if err != nil { - return fmt.Errorf("destination is not valid hex: %w", err) + return errors.Errorf("destination is not valid hex: %w", err) } if !bytes.Equal(invoice.Destination.SerializeCompressed(), destination) { - return fmt.Errorf("destination doesnt match %v != %v", invoice.Destination.SerializeCompressed(), swap.Receiver.PublicKey) + return errors.Errorf( + "destination doesnt match %v != %v", + invoice.Destination.SerializeCompressed(), + swap.Receiver.PublicKey, + ) } if fundingOutput.ExpirationInBlocks != originalExpirationInBlocks { - return fmt.Errorf("expiration in blocks doesnt match %v != %v", originalExpirationInBlocks, fundingOutput.ExpirationInBlocks) + return errors.Errorf( + "expiration in blocks doesnt match %v != %v", + originalExpirationInBlocks, + fundingOutput.ExpirationInBlocks, + ) } // Validate that we can derive the addresses involved @@ -51,27 +68,35 @@ func (swap *SubmarineSwap) validateV2(rawInvoice string, userPublicKey, muunPubl derivedUserKey, err := userPublicKey.DeriveTo(derivationPath) if err != nil { - return fmt.Errorf("failed to derive user key: %w", err) + return errors.Errorf("failed to derive user key: %w", err) } if derivedUserKey.String() != fundingOutput.UserPublicKey.String() { - return fmt.Errorf("user pub keys dont match %v != %v", derivedUserKey.String(), fundingOutput.UserPublicKey.String()) + return errors.Errorf( + "user pub keys dont match %v != %v", + derivedUserKey.String(), + fundingOutput.UserPublicKey.String(), + ) } derivedMuunKey, err := muunPublicKey.DeriveTo(derivationPath) if err != nil { - return fmt.Errorf("failed to derive muun key: %w", err) + return errors.Errorf("failed to derive muun key: %w", err) } if derivedMuunKey.String() != fundingOutput.MuunPublicKey.String() { - return fmt.Errorf("muun pub keys dont match %v != %v", derivedMuunKey.String(), fundingOutput.MuunPublicKey.String()) + return errors.Errorf( + "muun pub keys dont match %v != %v", + derivedMuunKey.String(), + fundingOutput.MuunPublicKey.String(), + ) } // Check the swap's witness script is a valid swap script serverPubKey, err := hex.DecodeString(swap.FundingOutput.ServerPublicKeyInHex) if err != nil { - return fmt.Errorf("server pub key is not hex: %w", err) + return errors.Errorf("server pub key is not hex: %w", err) } witnessScript, err := CreateWitnessScriptSubmarineSwapV2( @@ -81,50 +106,55 @@ func (swap *SubmarineSwap) validateV2(rawInvoice string, userPublicKey, muunPubl serverPubKey, swap.FundingOutput.ExpirationInBlocks) if err != nil { - return fmt.Errorf("failed to compute witness script: %w", err) + return errors.Errorf("failed to compute witness script: %w", err) } witnessScriptHash := sha256.Sum256(witnessScript) address, err := btcutil.NewAddressWitnessScriptHash(witnessScriptHash[:], network) if err != nil { - return fmt.Errorf("failed to build address for swap script: %w", err) + return errors.Errorf("failed to build address for swap script: %w", err) } if address.EncodeAddress() != swap.FundingOutput.OutputAddress { - return fmt.Errorf("address for swap script mismatch (%v != %v)", address.EncodeAddress(), swap.FundingOutput.OutputAddress) + return errors.Errorf( + "address for swap script mismatch (%v != %v)", + address.EncodeAddress(), + swap.FundingOutput.OutputAddress, + ) } if len(swap.PreimageInHex) > 0 { preimage, err := hex.DecodeString(swap.PreimageInHex) if err != nil { - return fmt.Errorf("preimageInHex is not valid hex: %w", err) + return errors.Errorf("preimageInHex is not valid hex: %w", err) } calculatedPaymentHash := sha256.Sum256(preimage) if !bytes.Equal(invoice.PaymentHash[:], calculatedPaymentHash[:]) { - return fmt.Errorf("payment hash doesn't match preimage (%v != hash(%v)", invoice.PaymentHash, swap.PreimageInHex) + return errors.Errorf( + "payment hash doesn't match preimage (%v != hash(%v)", + invoice.PaymentHash, + swap.PreimageInHex, + ) } } return nil } -func CreateWitnessScriptSubmarineSwapV2(paymentHash, userPubKey, muunPubKey, swapServerPubKey []byte, blocksForExpiration int64) ([]byte, error) { +func CreateWitnessScriptSubmarineSwapV2( + paymentHash, userPubKey, muunPubKey, swapServerPubKey []byte, + blocksForExpiration int64, +) ([]byte, error) { - // It turns out that the payment hash present in an invoice is just the SHA256 of the - // payment preimage, so we still have to do a pass of RIPEMD160 before pushing it to the - // script + // It turns out that the payment hash present in an invoice is just the SHA256 of the payment + // preimage, so we still have to do a pass of RIPEMD160 before pushing it to the script paymentHash160 := ripemd160(paymentHash) muunPublicKeyHash160 := btcutil.Hash160(muunPubKey) - // Equivalent miniscript (http://bitcoin.sipa.be/miniscript/): - // or( - // and(pk(userPublicKey), pk(swapServerPublicKey)), - // or( - // and(pk(swapServerPublicKey), hash160(swapPaymentHash160)), - // and(pk(userPublicKey), and(pk(muunPublicKey), older(numBlocksForExpiration))) - // ) - // ) + // Equivalent miniscript (http://bitcoin.sipa.be/miniscript/): or( and(pk(userPublicKey), + // pk(swapServerPublicKey)), or( and(pk(swapServerPublicKey), hash160(swapPaymentHash160)), + // and(pk(userPublicKey), and(pk(muunPublicKey), older(numBlocksForExpiration))) ) ) // // However, we differ in that the size of the script was heavily optimized for spending the // first two branches (the collaborative close and the unilateral close by swapper), which @@ -151,8 +181,7 @@ func CreateWitnessScriptSubmarineSwapV2(paymentHash, userPubKey, muunPubKey, swa // If the preimage was correct AddOp(txscript.OP_IF). - // We are done, leave just one true-ish item in the stack (there're 2 - // remaining items) + // We are done, leave just one true-ish item in the stack (there're 2 remaining items) AddOp(txscript.OP_DROP). // If the second stack item wasn't a valid payment preimage @@ -179,10 +208,9 @@ func CreateWitnessScriptSubmarineSwapV2(paymentHash, userPubKey, muunPubKey, swa AddData(muunPublicKeyHash160). AddOp(txscript.OP_EQUALVERIFY). - // Notice that instead of directly pushing the public key here and checking the - // signature P2PK-style, we pushed the hash of the public key, and require an - // extra stack item with the actual public key, verifying the signature and - // public key P2PKH-style. + // Notice that instead of directly pushing the public key here and checking the signature + // P2PK-style, we pushed the hash of the public key, and require an extra stack item with + // the actual public key, verifying the signature and public key P2PKH-style. // // This trick reduces the on-chain footprint of the muun key from 33 bytes to // 20 bytes for the collaborative, and swap server's non-collaborative branches, diff --git a/libwallet/swaps/v2_test.go b/libwallet/swaps/v2_test.go index 07a6a400..488c2967 100644 --- a/libwallet/swaps/v2_test.go +++ b/libwallet/swaps/v2_test.go @@ -28,11 +28,15 @@ func TestValidateSubmarineSwapV2(t *testing.T) { }, rawInvoice: "invalid", userPublicKey: &KeyDescriptor{ - Key: decodeKey("tpubD6NzVbkrYhZ4Y3iy9soFSA9zoYbpyhUFu3eAH1sDWyERxH2yJVZUhPUX5QsxD6bZfMWRKzxw28ohD5n6AZWmvZbDpZzgxSVxUnMevqzTXQk"), + Key: decodeKey( + "tpubD6NzVbkrYhZ4Y3iy9soFSA9zoYbpyhUFu3eAH1sDWyERxH2yJVZUhPUX5QsxD6bZfMWRKzxw28ohD5n6AZWmvZbDpZzgxSVxUnMevqzTXQk", //nolint:lll + ), Path: "m", }, muunPublicKey: &KeyDescriptor{ - Key: decodeKey("tpubD6NzVbkrYhZ4XbhomyY2axxKe3KB1FK2Wq2z7XYyDF3T4QCuEDZFBUyGfjfHChvEbsbP9RpaYA8cwxkZpQjEcNdaPfuj3cKGqCiHC5YeRTo"), + Key: decodeKey( + "tpubD6NzVbkrYhZ4XbhomyY2axxKe3KB1FK2Wq2z7XYyDF3T4QCuEDZFBUyGfjfHChvEbsbP9RpaYA8cwxkZpQjEcNdaPfuj3cKGqCiHC5YeRTo", //nolint:lll + ), Path: "m", }, originalExpirationInBlocks: 0, @@ -48,13 +52,17 @@ func TestValidateSubmarineSwapV2(t *testing.T) { ServerPaymentHashInHex: "invalid hex", }, }, - rawInvoice: "lnbcrt1p033394pp5sfcfh0ukkjfcvcg2vwk2hudue9d48lawqkacdan4msxne66w4krqdqqcqzpgsp5jelulm6a7q38j6jffa9qet3scz4qvcs08x6hfsyn0lfg34p2584q9qy9qsqjcq059jh8qeslj7qwl69ln69znalrxykhaj4kl0g0kfstwsa3warsyxx2d0rqs24tx896lz895wffqaj7l82zs896ec7r5arnw0cwtqpvzt8yd", + rawInvoice: "lnbcrt1p033394pp5sfcfh0ukkjfcvcg2vwk2hudue9d48lawqkacdan4msxne66w4krqdqqcqzpgsp5jelulm6a7q38j6jffa9qet3scz4qvcs08x6hfsyn0lfg34p2584q9qy9qsqjcq059jh8qeslj7qwl69ln69znalrxykhaj4kl0g0kfstwsa3warsyxx2d0rqs24tx896lz895wffqaj7l82zs896ec7r5arnw0cwtqpvzt8yd", //nolint:lll userPublicKey: &KeyDescriptor{ - Key: decodeKey("tpubD6NzVbkrYhZ4Y3iy9soFSA9zoYbpyhUFu3eAH1sDWyERxH2yJVZUhPUX5QsxD6bZfMWRKzxw28ohD5n6AZWmvZbDpZzgxSVxUnMevqzTXQk"), + Key: decodeKey( + "tpubD6NzVbkrYhZ4Y3iy9soFSA9zoYbpyhUFu3eAH1sDWyERxH2yJVZUhPUX5QsxD6bZfMWRKzxw28ohD5n6AZWmvZbDpZzgxSVxUnMevqzTXQk", //nolint:lll + ), Path: "m", }, muunPublicKey: &KeyDescriptor{ - Key: decodeKey("tpubD6NzVbkrYhZ4XbhomyY2axxKe3KB1FK2Wq2z7XYyDF3T4QCuEDZFBUyGfjfHChvEbsbP9RpaYA8cwxkZpQjEcNdaPfuj3cKGqCiHC5YeRTo"), + Key: decodeKey( + "tpubD6NzVbkrYhZ4XbhomyY2axxKe3KB1FK2Wq2z7XYyDF3T4QCuEDZFBUyGfjfHChvEbsbP9RpaYA8cwxkZpQjEcNdaPfuj3cKGqCiHC5YeRTo", //nolint:lll + ), Path: "m", }, originalExpirationInBlocks: 0, @@ -70,13 +78,17 @@ func TestValidateSubmarineSwapV2(t *testing.T) { ServerPaymentHashInHex: "112233445566778899", }, }, - rawInvoice: "lnbcrt1p033394pp5sfcfh0ukkjfcvcg2vwk2hudue9d48lawqkacdan4msxne66w4krqdqqcqzpgsp5jelulm6a7q38j6jffa9qet3scz4qvcs08x6hfsyn0lfg34p2584q9qy9qsqjcq059jh8qeslj7qwl69ln69znalrxykhaj4kl0g0kfstwsa3warsyxx2d0rqs24tx896lz895wffqaj7l82zs896ec7r5arnw0cwtqpvzt8yd", + rawInvoice: "lnbcrt1p033394pp5sfcfh0ukkjfcvcg2vwk2hudue9d48lawqkacdan4msxne66w4krqdqqcqzpgsp5jelulm6a7q38j6jffa9qet3scz4qvcs08x6hfsyn0lfg34p2584q9qy9qsqjcq059jh8qeslj7qwl69ln69znalrxykhaj4kl0g0kfstwsa3warsyxx2d0rqs24tx896lz895wffqaj7l82zs896ec7r5arnw0cwtqpvzt8yd", //nolint:lll userPublicKey: &KeyDescriptor{ - Key: decodeKey("tpubD6NzVbkrYhZ4Y3iy9soFSA9zoYbpyhUFu3eAH1sDWyERxH2yJVZUhPUX5QsxD6bZfMWRKzxw28ohD5n6AZWmvZbDpZzgxSVxUnMevqzTXQk"), + Key: decodeKey( + "tpubD6NzVbkrYhZ4Y3iy9soFSA9zoYbpyhUFu3eAH1sDWyERxH2yJVZUhPUX5QsxD6bZfMWRKzxw28ohD5n6AZWmvZbDpZzgxSVxUnMevqzTXQk", //nolint:lll + ), Path: "m", }, muunPublicKey: &KeyDescriptor{ - Key: decodeKey("tpubD6NzVbkrYhZ4XbhomyY2axxKe3KB1FK2Wq2z7XYyDF3T4QCuEDZFBUyGfjfHChvEbsbP9RpaYA8cwxkZpQjEcNdaPfuj3cKGqCiHC5YeRTo"), + Key: decodeKey( + "tpubD6NzVbkrYhZ4XbhomyY2axxKe3KB1FK2Wq2z7XYyDF3T4QCuEDZFBUyGfjfHChvEbsbP9RpaYA8cwxkZpQjEcNdaPfuj3cKGqCiHC5YeRTo", //nolint:lll + ), Path: "m", }, originalExpirationInBlocks: 0, @@ -90,24 +102,32 @@ func TestValidateSubmarineSwapV2(t *testing.T) { args: args{ swap: &SubmarineSwap{ FundingOutput: SubmarineSwapFundingOutput{ - OutputAddress: "bcrt1qk956axjf2pzmf6esd4jfrppkhmegn8eez2gl2zdkzje0w8lt2tmqvqvrut", + OutputAddress: "bcrt1qk956axjf2pzmf6esd4jfrppkhmegn8eez2gl2zdkzje0w8lt2tmqvqvrut", //nolint:lll ExpirationInBlocks: 10, - ServerPaymentHashInHex: "82709bbf96b49386610a63acabf1bcc95b53ffae05bb86f675dc0d3ceb4ead86", - UserPublicKey: decodeKey("tpubD6NzVbkrYhZ4Y3iy9soFSA9zoYbpyhUFu3eAH1sDWyERxH2yJVZUhPUX5QsxD6bZfMWRKzxw28ohD5n6AZWmvZbDpZzgxSVxUnMevqzTXQk"), - MuunPublicKey: decodeKey("tpubD6NzVbkrYhZ4XbhomyY2axxKe3KB1FK2Wq2z7XYyDF3T4QCuEDZFBUyGfjfHChvEbsbP9RpaYA8cwxkZpQjEcNdaPfuj3cKGqCiHC5YeRTo"), - KeyPath: "m", + ServerPaymentHashInHex: "82709bbf96b49386610a63acabf1bcc95b53ffae05bb86f675dc0d3ceb4ead86", //nolint:lll + UserPublicKey: decodeKey( + "tpubD6NzVbkrYhZ4Y3iy9soFSA9zoYbpyhUFu3eAH1sDWyERxH2yJVZUhPUX5QsxD6bZfMWRKzxw28ohD5n6AZWmvZbDpZzgxSVxUnMevqzTXQk", //nolint:lll + ), + MuunPublicKey: decodeKey( + "tpubD6NzVbkrYhZ4XbhomyY2axxKe3KB1FK2Wq2z7XYyDF3T4QCuEDZFBUyGfjfHChvEbsbP9RpaYA8cwxkZpQjEcNdaPfuj3cKGqCiHC5YeRTo", //nolint:lll + ), + KeyPath: "m", }, Receiver: SubmarineSwapReceiver{ - PublicKey: "02c9a35bdbeab0b93ee9542d85c38beab7d1e72ea1d9639e5b00b1d5feb64bcfdd", + PublicKey: "02c9a35bdbeab0b93ee9542d85c38beab7d1e72ea1d9639e5b00b1d5feb64bcfdd", //nolint:lll }, }, - rawInvoice: "lnbcrt1p033394pp5sfcfh0ukkjfcvcg2vwk2hudue9d48lawqkacdan4msxne66w4krqdqqcqzpgsp5jelulm6a7q38j6jffa9qet3scz4qvcs08x6hfsyn0lfg34p2584q9qy9qsqjcq059jh8qeslj7qwl69ln69znalrxykhaj4kl0g0kfstwsa3warsyxx2d0rqs24tx896lz895wffqaj7l82zs896ec7r5arnw0cwtqpvzt8yd", + rawInvoice: "lnbcrt1p033394pp5sfcfh0ukkjfcvcg2vwk2hudue9d48lawqkacdan4msxne66w4krqdqqcqzpgsp5jelulm6a7q38j6jffa9qet3scz4qvcs08x6hfsyn0lfg34p2584q9qy9qsqjcq059jh8qeslj7qwl69ln69znalrxykhaj4kl0g0kfstwsa3warsyxx2d0rqs24tx896lz895wffqaj7l82zs896ec7r5arnw0cwtqpvzt8yd", //nolint:lll userPublicKey: &KeyDescriptor{ - Key: decodeKey("tpubD6NzVbkrYhZ4Y3iy9soFSA9zoYbpyhUFu3eAH1sDWyERxH2yJVZUhPUX5QsxD6bZfMWRKzxw28ohD5n6AZWmvZbDpZzgxSVxUnMevqzTXQk"), + Key: decodeKey( + "tpubD6NzVbkrYhZ4Y3iy9soFSA9zoYbpyhUFu3eAH1sDWyERxH2yJVZUhPUX5QsxD6bZfMWRKzxw28ohD5n6AZWmvZbDpZzgxSVxUnMevqzTXQk", //nolint:lll + ), Path: "m", }, muunPublicKey: &KeyDescriptor{ - Key: decodeKey("tpubD6NzVbkrYhZ4XbhomyY2axxKe3KB1FK2Wq2z7XYyDF3T4QCuEDZFBUyGfjfHChvEbsbP9RpaYA8cwxkZpQjEcNdaPfuj3cKGqCiHC5YeRTo"), + Key: decodeKey( + "tpubD6NzVbkrYhZ4XbhomyY2axxKe3KB1FK2Wq2z7XYyDF3T4QCuEDZFBUyGfjfHChvEbsbP9RpaYA8cwxkZpQjEcNdaPfuj3cKGqCiHC5YeRTo", //nolint:lll + ), Path: "m", }, originalExpirationInBlocks: 10, diff --git a/libwallet/walletdb/fee_bump_repository.go b/libwallet/walletdb/fee_bump_repository.go index 9ede2e94..6c48e3ad 100644 --- a/libwallet/walletdb/fee_bump_repository.go +++ b/libwallet/walletdb/fee_bump_repository.go @@ -1,11 +1,12 @@ package walletdb import ( - "fmt" "time" + "github.com/go-errors/errors" "github.com/jinzhu/gorm" _ "github.com/jinzhu/gorm/dialects/sqlite" + "github.com/muun/libwallet/operation" ) @@ -26,10 +27,11 @@ type FeeBumpFunctionSet struct { type FeeBumpFunction struct { gorm.Model Position uint - // PartialLinearFunctions establishes a foreign key relationship with the PartialLinearFunction table, - // where 'FunctionPosition' in PartialLinearFunction references 'Position' in FeeBumpFunction. - PartialLinearFunctions []PartialLinearFunction `gorm:"foreignKey:FunctionPosition;references:Position;"` - SetID uint `sql:"not null"` + // PartialLinearFunctions establishes a foreign key relationship with the PartialLinearFunction + // table, where 'FunctionPosition' in PartialLinearFunction references 'Position' in + // FeeBumpFunction. + PartialLinearFunctions []PartialLinearFunction `gorm:"foreignKey:FunctionPosition;references:Position;"` //nolint:lll + SetID uint ` sql:"not null"` //nolint:lll } type PartialLinearFunction struct { @@ -41,65 +43,68 @@ type PartialLinearFunction struct { FunctionPosition uint } -type GORMFeeBumpRepository struct { - db *gorm.DB +type feeBumpRepository struct { + withDB gormProvider } -func (r *GORMFeeBumpRepository) Store(feeBumpFunctionSet *operation.FeeBumpFunctionSet) error { - dbFeeBumpFunctionSet := mapToDBFeeBumpFunctions(feeBumpFunctionSet) - - tx := r.db.Begin() - - // Remove old data before store updated functions - err := removeAllInTransaction(tx) - if err != nil { - return fmt.Errorf("error when trying to remove old fee bump functions: %w", err) - } - - if err := tx.Create(&dbFeeBumpFunctionSet).Error; err != nil { - tx.Rollback() - return err - } - - if err := tx.Commit().Error; err != nil { - return fmt.Errorf("failed to save fee bump functions: %w", err) - } - return nil +func (r *feeBumpRepository) gorm(fn gormOperation) error { + return r.withDB(fn) } -func (r *GORMFeeBumpRepository) GetAll() (*operation.FeeBumpFunctionSet, error) { - var dbFeeBumpFunctionSet FeeBumpFunctionSet - - result := r.db.Preload("FeeBumpFunctions.PartialLinearFunctions").Find(&dbFeeBumpFunctionSet) - - if result.Error != nil && !gorm.IsRecordNotFoundError(result.Error) { - return nil, result.Error - } - - feeBumpFunctionSet := mapToOperationFeeBumpFunctions(dbFeeBumpFunctionSet) - - return feeBumpFunctionSet, nil +func (r *feeBumpRepository) Store(feeBumpFunctionSet *operation.FeeBumpFunctionSet) error { + dbFeeBumpFunctionSet := mapToDBFeeBumpFunctions(feeBumpFunctionSet) + return r.gorm(func(db *gorm.DB) error { + tx := db.Begin() + if err := removeAllInTransaction(tx); err != nil { + return errors.Errorf("error when trying to remove old fee bump functions: %w", err) + } + if err := tx.Create(&dbFeeBumpFunctionSet).Error; err != nil { + tx.Rollback() + return err + } + if err := tx.Commit().Error; err != nil { + return errors.Errorf("failed to save fee bump functions: %w", err) + } + return nil + }) } -func (r *GORMFeeBumpRepository) GetCreationDate() (*time.Time, error) { - var dbFeeBumpFunctionSet FeeBumpFunctionSet - result := r.db.First(&dbFeeBumpFunctionSet) - - if result.Error != nil { - return nil, result.Error - } - - return &dbFeeBumpFunctionSet.CreatedAt, nil +func (r *feeBumpRepository) GetAll() (*operation.FeeBumpFunctionSet, error) { + var result *operation.FeeBumpFunctionSet + err := r.gorm(func(db *gorm.DB) error { + var dbFeeBumpFunctionSet FeeBumpFunctionSet + res := db.Preload("FeeBumpFunctions.PartialLinearFunctions").Find(&dbFeeBumpFunctionSet) + if res.Error != nil && !gorm.IsRecordNotFoundError(res.Error) { + return res.Error + } + result = mapToOperationFeeBumpFunctions(dbFeeBumpFunctionSet) + return nil + }) + return result, err } -func (r *GORMFeeBumpRepository) RemoveAll() error { - tx := r.db.Begin() - err := removeAllInTransaction(tx) - if err != nil { - return err - } +func (r *feeBumpRepository) GetCreationDate() (*time.Time, error) { + var result *time.Time + err := r.gorm(func(db *gorm.DB) error { + var dbFeeBumpFunctionSet FeeBumpFunctionSet + res := db.First(&dbFeeBumpFunctionSet) + if res.Error != nil { + return res.Error + } + result = &dbFeeBumpFunctionSet.CreatedAt + return nil + }) + return result, err +} - return tx.Commit().Error +func (r *feeBumpRepository) RemoveAll() error { + return r.gorm(func(db *gorm.DB) error { + tx := db.Begin() + if err := removeAllInTransaction(tx); err != nil { + return err + } + return tx.Commit().Error + }) } func removeAllInTransaction(tx *gorm.DB) error { @@ -149,18 +154,23 @@ func mapToDBFeeBumpFunctions(feeBumpFunctionSet *operation.FeeBumpFunctionSet) F } } -func mapToOperationFeeBumpFunctions(dbFeeBumpFunctionSet FeeBumpFunctionSet) *operation.FeeBumpFunctionSet { +func mapToOperationFeeBumpFunctions( + dbFeeBumpFunctionSet FeeBumpFunctionSet, +) *operation.FeeBumpFunctionSet { dbFeeBumpFunctions := dbFeeBumpFunctionSet.FeeBumpFunctions var feeBumpFunctions []*operation.FeeBumpFunction for _, dbFeeBumpFunction := range dbFeeBumpFunctions { var partialLinearFunctions []*operation.PartialLinearFunction for _, dbPartialLinearFunction := range dbFeeBumpFunction.PartialLinearFunctions { - partialLinearFunctions = append(partialLinearFunctions, &operation.PartialLinearFunction{ - LeftClosedEndpoint: dbPartialLinearFunction.LeftClosedEndpoint, - RightOpenEndpoint: dbPartialLinearFunction.RightOpenEndpoint, - Slope: dbPartialLinearFunction.Slope, - Intercept: dbPartialLinearFunction.Intercept, - }) + partialLinearFunctions = append( + partialLinearFunctions, + &operation.PartialLinearFunction{ + LeftClosedEndpoint: dbPartialLinearFunction.LeftClosedEndpoint, + RightOpenEndpoint: dbPartialLinearFunction.RightOpenEndpoint, + Slope: dbPartialLinearFunction.Slope, + Intercept: dbPartialLinearFunction.Intercept, + }, + ) } feeBumpFunctions = append( diff --git a/libwallet/walletdb/fee_bump_repository_test.go b/libwallet/walletdb/fee_bump_repository_test.go index e04af1ac..a9b232ca 100644 --- a/libwallet/walletdb/fee_bump_repository_test.go +++ b/libwallet/walletdb/fee_bump_repository_test.go @@ -86,7 +86,11 @@ func TestCreateFeeBumpFunctions(t *testing.T) { } if loadedFeeBumpFunctionSet.UUID != expectedFeeBumpFunctionSet.UUID { - t.Errorf("expected %v UUID, got %v", expectedFeeBumpFunctionSet.UUID, loadedFeeBumpFunctionSet.UUID) + t.Errorf( + "expected %v UUID, got %v", + expectedFeeBumpFunctionSet.UUID, + loadedFeeBumpFunctionSet.UUID, + ) } if loadedFeeBumpFunctionSet.RefreshPolicy != expectedFeeBumpFunctionSet.RefreshPolicy { @@ -97,7 +101,11 @@ func TestCreateFeeBumpFunctions(t *testing.T) { ) } - if len(loadedFeeBumpFunctionSet.FeeBumpFunctions) != len(expectedFeeBumpFunctionSet.FeeBumpFunctions) { + if len( + loadedFeeBumpFunctionSet.FeeBumpFunctions, + ) != len( + expectedFeeBumpFunctionSet.FeeBumpFunctions, + ) { t.Errorf( "expected %d fee bump functions, got %d", len(expectedFeeBumpFunctionSet.FeeBumpFunctions), @@ -106,7 +114,11 @@ func TestCreateFeeBumpFunctions(t *testing.T) { expectedFeeBumpFunctions := expectedFeeBumpFunctionSet.FeeBumpFunctions for i, loadedFeeBumpFunction := range loadedFeeBumpFunctionSet.FeeBumpFunctions { - if len(loadedFeeBumpFunction.PartialLinearFunctions) != len(expectedFeeBumpFunctions[i].PartialLinearFunctions) { + if len( + loadedFeeBumpFunction.PartialLinearFunctions, + ) != len( + expectedFeeBumpFunctions[i].PartialLinearFunctions, + ) { t.Errorf( "expected %d intervals, got %d", len(expectedFeeBumpFunctions[i].PartialLinearFunctions), @@ -130,7 +142,11 @@ func TestCreateFeeBumpFunctions(t *testing.T) { } if loadedFeeBumpFunctionSet.CreatedAt != *creationDate { - t.Fatalf("date mismatch: got: %v, expected: %v", *creationDate, loadedFeeBumpFunctionSet.CreatedAt) + t.Fatalf( + "date mismatch: got: %v, expected: %v", + *creationDate, + loadedFeeBumpFunctionSet.CreatedAt, + ) } err = repository.RemoveAll() @@ -164,10 +180,12 @@ func TestCreateFeeBumpFunctions(t *testing.T) { } } -func setupTestDb(t *testing.T) (*DB, error) { +func setupTestDb( //nolint:staticcheck // TODO: func setupTestDb should be setupTestDB + t *testing.T, +) (*DB, error) { dir := t.TempDir() - db, err := Open(path.Join(dir, "test.db")) + db, err := open(path.Join(dir, "test.db")) if err != nil { return nil, err } diff --git a/libwallet/walletdb/key_value_repository.go b/libwallet/walletdb/key_value_repository.go index 4fe2025e..42118029 100644 --- a/libwallet/walletdb/key_value_repository.go +++ b/libwallet/walletdb/key_value_repository.go @@ -2,11 +2,11 @@ package walletdb import ( "database/sql" - "errors" "fmt" "strings" "time" + "github.com/go-errors/errors" "github.com/jinzhu/gorm" ) @@ -28,80 +28,96 @@ type KeyValue struct { Value *string } -type GORMKeyValueRepository struct { - db *gorm.DB +type keyValueRepository struct { + withDB gormProvider } -// Create inserts a key with a null value if the key doesn't exist. -func (r *GORMKeyValueRepository) Create(key string) error { +func (r *keyValueRepository) gorm(fn gormOperation) error { + return r.withDB(fn) +} - now := time.Now().UTC() - query := ` - INSERT INTO key_values (key, created_at, updated_at) - VALUES (?, ?, ?) - ON CONFLICT(key) DO NOTHING - ` - _, err := r.db.CommonDB().Exec(query, key, now, now) - return err +// Create inserts a key with a null value if the key doesn't exist. +func (r *keyValueRepository) Create(key string) error { + return r.gorm(func(db *gorm.DB) error { + now := time.Now().UTC() + query := ` + INSERT INTO key_values (key, created_at, updated_at) + VALUES (?, ?, ?) + ON CONFLICT(key) DO NOTHING + ` + _, err := db.CommonDB().Exec(query, key, now, now) + return err + }) } // Save inserts or updates a key-value into database -func (r *GORMKeyValueRepository) Save(key string, value *string) error { - - now := time.Now().UTC() - query := ` - INSERT INTO key_values (key, value, created_at, updated_at) - VALUES (?, ?, ?, ?) - ON CONFLICT(key) DO UPDATE SET - value = excluded.value, - updated_at = excluded.updated_at; - ` - err := r.db.Exec(query, key, value, now, now).Error - if err != nil { - return fmt.Errorf("failed to save or update key-value: %w", err) - } - return nil +func (r *keyValueRepository) Save(key string, value *string) error { + return r.gorm(func(db *gorm.DB) error { + now := time.Now().UTC() + query := ` + INSERT INTO key_values (key, value, created_at, updated_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(key) DO UPDATE SET + value = excluded.value, + updated_at = excluded.updated_at; + ` + err := db.Exec(query, key, value, now, now).Error + if err != nil { + return errors.Errorf("failed to save or update key-value: %w", err) + } + return nil + }) } // Update updates the value of a key if it exists. -func (r *GORMKeyValueRepository) Update(key string, newValue string) error { - now := time.Now().UTC() - query := `UPDATE key_values SET value=?, updated_at=? WHERE key=?` - _, err := r.db.CommonDB().Exec(query, newValue, now, key) - return err +func (r *keyValueRepository) Update(key string, newValue string) error { + return r.gorm(func(db *gorm.DB) error { + now := time.Now().UTC() + query := `UPDATE key_values SET value=?, updated_at=? WHERE key=?` + _, err := db.CommonDB().Exec(query, newValue, now, key) + return err + }) } // Get value by key from database -func (r *GORMKeyValueRepository) Get(key string) (*string, error) { - var ns sql.NullString - err := r.db.Raw("SELECT value FROM key_values WHERE key = ?", key).Row().Scan(&ns) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - // If key is not found, return nil - return nil, nil +func (r *keyValueRepository) Get(key string) (*string, error) { + var result *string + err := r.gorm(func(db *gorm.DB) error { + var ns sql.NullString + err := db.Raw("SELECT value FROM key_values WHERE key = ?", key).Row().Scan(&ns) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + // If key is not found, return nil + return nil + } + return errors.Errorf( + "failed to fetch from db: %w", + err, + ) } - return nil, fmt.Errorf("failed to fetch from db: %v", err) - } - if ns.Valid { - return &ns.String, nil - } - return nil, nil + if ns.Valid { + result = &ns.String + } + return nil + }) + return result, err } // Delete key-value pair by key -func (r *GORMKeyValueRepository) Delete(key string) error { - - err := r.db.Exec("DELETE FROM key_values WHERE key = ?", key).Error - if err != nil { - return fmt.Errorf("failed to delete key-value: %w", err) - } - return nil +func (r *keyValueRepository) Delete(key string) error { + return r.gorm(func(db *gorm.DB) error { + err := db.Exec("DELETE FROM key_values WHERE key = ?", key).Error + if err != nil { + return errors.Errorf("failed to delete key-value: %w", err) + } + return nil + }) } // SaveBatch inserts or updates a map of key-value into database -func (r *GORMKeyValueRepository) SaveBatch(items map[string]*string) error { +func (r *keyValueRepository) SaveBatch(items map[string]*string) error { if len(items) == 0 { - return fmt.Errorf("no items provided for database insertion") + return errors.Errorf("no items provided for database insertion") } now := time.Now().UTC() @@ -121,18 +137,19 @@ func (r *GORMKeyValueRepository) SaveBatch(items map[string]*string) error { ` query := fmt.Sprintf(baseQuery, strings.Join(placeholders, ", ")) - err := r.db.Exec(query, args...).Error - if err != nil { - return fmt.Errorf("failed to save batch: %w", err) - } - - return nil + return r.gorm(func(db *gorm.DB) error { + err := db.Exec(query, args...).Error + if err != nil { + return errors.Errorf("failed to save batch: %w", err) + } + return nil + }) } // GetBatch returns a list of values from database given a key list -func (r *GORMKeyValueRepository) GetBatch(keys []string) (map[string]*string, error) { +func (r *keyValueRepository) GetBatch(keys []string) (map[string]*string, error) { if len(keys) == 0 { - return nil, fmt.Errorf("no keys provided") + return nil, errors.Errorf("no keys provided") } keyValues := make(map[string]*string) @@ -141,7 +158,10 @@ func (r *GORMKeyValueRepository) GetBatch(keys []string) (map[string]*string, er } placeholders := make([]string, len(keys)) - args := make([]interface{}, len(keys)) + args := make( + []interface{}, //nolint:modernize // TODO: use any instead of interface{} + len(keys), + ) for i, key := range keys { placeholders[i] = "?" args[i] = key @@ -152,26 +172,28 @@ func (r *GORMKeyValueRepository) GetBatch(keys []string) (map[string]*string, er ` query := fmt.Sprintf(baseQuery, strings.Join(placeholders, ",")) - rows, err := r.db.Raw(query, args...).Rows() - if err != nil { - return nil, err - } - defer rows.Close() - - for rows.Next() { - var key string - var value sql.NullString - - err := rows.Scan(&key, &value) + err := r.gorm(func(db *gorm.DB) error { + rows, err := db.Raw(query, args...).Rows() if err != nil { - return nil, err + return err } + defer rows.Close() //nolint:errcheck // TODO: check error + + for rows.Next() { + var key string + var value sql.NullString - if value.Valid { - keyValues[key] = &value.String + err := rows.Scan(&key, &value) + if err != nil { + return err + } + + if value.Valid { + keyValues[key] = &value.String + } } - } - err = rows.Err() + return rows.Err() + }) if err != nil { return nil, err } @@ -179,7 +201,10 @@ func (r *GORMKeyValueRepository) GetBatch(keys []string) (map[string]*string, er } // UpdateAccordingToMap updates key-values based on a map of old-to-new values. -func (r *GORMKeyValueRepository) UpdateAccordingToMap(key string, oldToNewMap map[string]string) (int, error) { +func (r *keyValueRepository) UpdateAccordingToMap( + key string, + oldToNewMap map[string]string, +) (int, error) { if len(oldToNewMap) == 0 { return 0, nil } @@ -194,16 +219,21 @@ func (r *GORMKeyValueRepository) UpdateAccordingToMap(key string, oldToNewMap ma queryBuilder.WriteString("ELSE value END, updated_at = ? WHERE key = ?") args = append(args, now, key) - result, err := r.db.CommonDB().Exec(queryBuilder.String(), args...) - if err != nil { - return 0, err - } - rowsAffected, _ := result.RowsAffected() - return int(rowsAffected), nil + var n int + err := r.gorm(func(db *gorm.DB) error { + res, err := db.CommonDB().Exec(queryBuilder.String(), args...) + if err != nil { + return err + } + rowsAffected, _ := res.RowsAffected() + n = int(rowsAffected) + return nil + }) + return n, err } // IsValueIn returns true if the current value of key is in allowedValues. -func (r *GORMKeyValueRepository) IsValueIn(key string, allowedValues []string) (bool, error) { +func (r *keyValueRepository) IsValueIn(key string, allowedValues []string) (bool, error) { if len(allowedValues) == 0 { return false, nil } @@ -221,10 +251,15 @@ func (r *GORMKeyValueRepository) IsValueIn(key string, allowedValues []string) ( strings.Join(placeholders, ","), ) - var count int - row := r.db.CommonDB().QueryRow(query, args...) - if err := row.Scan(&count); err != nil { - return false, err - } - return count > 0, nil + var result bool + err := r.gorm(func(db *gorm.DB) error { + var count int + row := db.CommonDB().QueryRow(query, args...) + if err := row.Scan(&count); err != nil { + return err + } + result = count > 0 + return nil + }) + return result, err } diff --git a/libwallet/walletdb/key_value_repository_test.go b/libwallet/walletdb/key_value_repository_test.go index 71843fa0..a3038743 100644 --- a/libwallet/walletdb/key_value_repository_test.go +++ b/libwallet/walletdb/key_value_repository_test.go @@ -7,7 +7,7 @@ import ( ) // TestKeyValueRepositoryTimestampsAreUTC verifies that every write method in -// GORMKeyValueRepository stores timestamps with a UTC timezone indicator ("Z"). +// keyValueRepository stores timestamps with a UTC timezone indicator ("Z"). // This guards against regressions where time.Now() without .UTC() would produce // a local-timezone timestamp. func TestKeyValueRepositoryTimestampsAreUTC(t *testing.T) { diff --git a/libwallet/walletdb/kv_schema_state_repository.go b/libwallet/walletdb/kv_schema_state_repository.go index 34ce79d7..30a0c663 100644 --- a/libwallet/walletdb/kv_schema_state_repository.go +++ b/libwallet/walletdb/kv_schema_state_repository.go @@ -13,34 +13,42 @@ type KVSchemaStateRepository interface { BumpSchemaVersion(v int) error } -type GORMKVSchemaStateRepository struct { - db *gorm.DB +type kvSchemaStateRepository struct { + withDB gormProvider +} + +func (r *kvSchemaStateRepository) gorm(fn gormOperation) error { + return r.withDB(fn) } // GetCurrentSchemaVersion returns the latest version from the schema state table. -func (r *GORMKVSchemaStateRepository) GetCurrentSchemaVersion() (int, error) { - var v sql.NullInt64 - row := r.db.CommonDB().QueryRow(`SELECT MAX(schema_version) FROM kv_schema_state`) - err := row.Scan(&v) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - // No version recorded yet - return 0, nil +func (r *kvSchemaStateRepository) GetCurrentSchemaVersion() (int, error) { + var result int + err := r.gorm(func(db *gorm.DB) error { + var v sql.NullInt64 + row := db.CommonDB().QueryRow(`SELECT MAX(schema_version) FROM kv_schema_state`) + if err := row.Scan(&v); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil + } + return err + } + if v.Valid { + result = int(v.Int64) } - return 0, err - } - if !v.Valid { - return 0, nil - } - return int(v.Int64), nil + return nil + }) + return result, err } // BumpSchemaVersion inserts a new version into the schema state table. -func (r *GORMKVSchemaStateRepository) BumpSchemaVersion(version int) error { - now := time.Now().UTC() - query := ` - INSERT INTO kv_schema_state (schema_version, applied_at) - VALUES (?, ?) - ` - return r.db.Exec(query, version, now).Error +func (r *kvSchemaStateRepository) BumpSchemaVersion(version int) error { + return r.gorm(func(db *gorm.DB) error { + now := time.Now().UTC() + query := ` + INSERT INTO kv_schema_state (schema_version, applied_at) + VALUES (?, ?) + ` + return db.Exec(query, version, now).Error + }) } diff --git a/libwallet/walletdb/pool.go b/libwallet/walletdb/pool.go new file mode 100644 index 00000000..87b9619a --- /dev/null +++ b/libwallet/walletdb/pool.go @@ -0,0 +1,97 @@ +package walletdb + +import ( + "log/slog" + "sync" + + "github.com/go-errors/errors" +) + +type Pool struct { + poolMutex sync.RWMutex + db *DB +} + +// NewPool opens a new database at path, calls setup(db) if non-nil, and returns a Pool. +func NewPool(path string, setup func(*DB) error) (*Pool, error) { + db, err := open(path) + if err != nil { + return nil, err + } + if setup != nil { + if err := setup(db); err != nil { + db.Close() + return nil, err + } + } + return &Pool{db: db}, nil +} + +// WithDB runs fn under a shared read lock. +// A nil pool returns an error instead of panicking on the receiver. +func (p *Pool) WithDB(fn func(*DB) error) error { + if p == nil { + return errors.New("pool: not initialized") + } + p.poolMutex.RLock() + defer p.poolMutex.RUnlock() + if p.db == nil { + return errors.New("pool: database is closed") + } + return fn(p.db) +} + +// ReplaceDB closes the current DB, runs fn under the exclusive write lock to get the path for the +// replacement DB, then opens it and runs setup if non-nil. p.db is set to nil on any error so +// subsequent reads get a clean error. +func (p *Pool) ReplaceDB(fn func() (string, error), setup func(*DB) error) error { + p.poolMutex.Lock() + defer p.poolMutex.Unlock() + if p.db != nil { + p.db.Close() + p.db = nil + } + path, err := fn() + if err != nil { + slog.Error("pool: replace failed, db unavailable until restart", + "step", "wipe", "error", err) + return err + } + newDB, err := open(path) + if err != nil { + slog.Error("pool: replace failed, db unavailable until restart", + "step", "open", "error", err) + return err + } + if setup != nil { + if err := setup(newDB); err != nil { + newDB.Close() + slog.Error("pool: replace failed, db unavailable until restart", + "step", "setup", "error", err) + return err + } + } + p.db = newDB + return nil +} + +func (p *Pool) NewKeyValueRepository() KeyValueRepository { + return &keyValueRepository{ + withDB: func(fn gormOperation) error { + return p.WithDB(func(db *DB) error { return fn(db.Gorm()) }) + }, + } +} + +// Close closes the DB and nils the pointer under the exclusive write lock. A nil pool is a no-op. +func (p *Pool) Close() { + if p == nil { + return + } + p.poolMutex.Lock() + defer p.poolMutex.Unlock() + if p.db != nil { + p.db.Close() + p.db = nil + } +} diff --git a/libwallet/walletdb/pool_test.go b/libwallet/walletdb/pool_test.go new file mode 100644 index 00000000..8310bed6 --- /dev/null +++ b/libwallet/walletdb/pool_test.go @@ -0,0 +1,43 @@ +package walletdb + +import "testing" + +// TestPool_NilReceiver guards the regression that motivated the nil checks: after StopServer (or +// before Init) the global pool pointer is nil, and callers still invoke WithDB/Close on it. These +// must return an error / no-op rather than panic on the receiver. +func TestPool_NilReceiver(t *testing.T) { + var p *Pool // nil, as the global is before Init and after StopServer + + called := false + err := p.WithDB(func(_ *DB) error { + called = true + return nil + }) + if err == nil { + t.Fatal("WithDB on a nil pool: expected an error, got nil") + } + if called { + t.Fatal("WithDB on a nil pool: fn must not run") + } + + // Must not panic. + p.Close() +} + +// TestPool_ClosedReturnsError covers the adjacent case: a live pool whose db has been cleared +// (e.g. Close, or a failed ReplaceDB) reports a distinct error instead of running fn. +func TestPool_ClosedReturnsError(t *testing.T) { + p := &Pool{} // db == nil + + called := false + err := p.WithDB(func(_ *DB) error { + called = true + return nil + }) + if err == nil { + t.Fatal("WithDB on a closed pool: expected an error, got nil") + } + if called { + t.Fatal("WithDB on a closed pool: fn must not run") + } +} diff --git a/libwallet/walletdb/walletdb.go b/libwallet/walletdb/walletdb.go index 6923ea23..afc03fe5 100644 --- a/libwallet/walletdb/walletdb.go +++ b/libwallet/walletdb/walletdb.go @@ -24,7 +24,7 @@ type Invoice struct { PaymentHash []byte PaymentSecret []byte KeyPath string - ShortChanId uint64 + ShortChanId uint64 //nolint:staticcheck // TODO: struct field ShortChanId should be ShortChanID AmountSat int64 State InvoiceState Metadata string @@ -35,11 +35,11 @@ type DB struct { db *gorm.DB } -func Open(path string) (*DB, error) { +func open(path string) (*DB, error) { // _busy_timeout: retry for up to 1s before returning "database is locked" on concurrent writes. - // Without it, SQLite fails immediately when two concurrent writes overlap. - // _journal_mode=WAL: improves read/write concurrency. - // Readers can proceed concurrently with a writer, though SQLite still allows only one writer at a time. + // Without it, SQLite fails immediately when two concurrent writes overlap. _journal_mode=WAL: + // improves read/write concurrency. Readers can proceed concurrently with a writer, though + // SQLite still allows only one writer at a time. db, err := gorm.Open("sqlite3", path+"?_busy_timeout=1000&_journal_mode=WAL") if err != nil { return nil, err @@ -55,28 +55,33 @@ func (d *DB) Gorm() *gorm.DB { return d.db } +type gormOperation func(*gorm.DB) error + +// gormProvider abstracts how a *gorm.DB is obtained for a repository +type gormProvider func(gormOperation) error + func (d *DB) NewFeeBumpRepository() FeeBumpRepository { - return &GORMFeeBumpRepository{db: d.db} + return &feeBumpRepository{withDB: func(fn gormOperation) error { return fn(d.db) }} } func (d *DB) NewKeyValueRepository() KeyValueRepository { - return &GORMKeyValueRepository{db: d.db} + return &keyValueRepository{withDB: func(fn gormOperation) error { return fn(d.db) }} } -func NewKeyValueRepository(db *gorm.DB) KeyValueRepository { - // This constructor is useful to build a new repository for key-value operations bound - // to a specific GORM instance (which can be a DB connection or a transaction). - return &GORMKeyValueRepository{db: db} +func (d *DB) WithTx(fn func(*DB) error) error { + tx := d.db.Begin() + if tx.Error != nil { + return tx.Error + } + if err := fn(&DB{db: tx}); err != nil { + tx.Rollback() + return err + } + return tx.Commit().Error } func (d *DB) NewKVSchemaStateRepository() KVSchemaStateRepository { - return &GORMKVSchemaStateRepository{db: d.db} -} - -func NewKVSchemaStateRepository(db *gorm.DB) KVSchemaStateRepository { - // This constructor is useful to build a new repository for schema state operations bound - // to a specific GORM instance (which can be a DB connection or a transaction). - return &GORMKVSchemaStateRepository{db: db} + return &kvSchemaStateRepository{withDB: func(fn gormOperation) error { return fn(d.db) }} } func migrate(db *gorm.DB) error { @@ -93,14 +98,13 @@ func migrate(db *gorm.DB) error { PaymentHash []byte PaymentSecret []byte KeyPath string - ShortChanId uint64 + ShortChanId uint64 //nolint:staticcheck // TODO: struct field ShortChanId should be ShortChanID State string UsedAt *time.Time } // This guard exists because at some point migrations were run outside a - // transactional context and a user experimented problems with an invoices - // table that was already created but whose migration had not been properly - // recorded. + // transactional context and a user experimented problems with an invoices table + // that was already created but whose migration had not been properly recorded. if !tx.HasTable(&Invoice{}) { return tx.CreateTable(&Invoice{}).Error } @@ -119,7 +123,7 @@ func migrate(db *gorm.DB) error { PaymentHash []byte PaymentSecret []byte KeyPath string - ShortChanId uint64 + ShortChanId uint64 //nolint:staticcheck // TODO: struct field ShortChanId should be ShortChanID AmountSat int64 State string UsedAt *time.Time @@ -139,7 +143,7 @@ func migrate(db *gorm.DB) error { PaymentHash []byte PaymentSecret []byte KeyPath string - ShortChanId uint64 + ShortChanId uint64 //nolint:staticcheck // TODO: struct field ShortChanId should be ShortChanID AmountSat int64 State InvoiceState Metadata string @@ -158,7 +162,7 @@ func migrate(db *gorm.DB) error { type FeeBumpFunction struct { gorm.Model Position uint - FeeBumpIntervals []PartialLinearFunction `gorm:"foreignKey:FunctionPosition;references:Position;"` + FeeBumpIntervals []PartialLinearFunction `gorm:"foreignKey:FunctionPosition;references:Position;"` //nolint:lll } type PartialLinearFunction struct { @@ -190,7 +194,7 @@ func migrate(db *gorm.DB) error { type FeeBumpFunction struct { gorm.Model Position uint - FeeBumpIntervals []PartialLinearFunction `gorm:"foreignKey:FunctionPosition;references:Position;"` + FeeBumpIntervals []PartialLinearFunction `gorm:"foreignKey:FunctionPosition;references:Position;"` //nolint:lll SetID uint `gorm:"default:0;not null"` } // Crea table FeeBumpFunctionSet and migrate FeeBumpFunction @@ -202,7 +206,8 @@ func migrate(db *gorm.DB) error { return err } - if err := tx.Table("fee_bump_functions").DropColumn(gorm.ToColumnName("SetID")).Error; err != nil { + col := gorm.ToColumnName("SetID") + if err := tx.Table("fee_bump_functions").DropColumn(col).Error; err != nil { return err } @@ -237,7 +242,8 @@ func migrate(db *gorm.DB) error { // AppliedAt time.Time `gorm:"not null"` // } // - // GORM will diff the struct against the real table regardless of how it was created. + // GORM will diff the struct against the real table regardless + // of how it was created. return tx.Exec(` CREATE TABLE IF NOT EXISTS kv_schema_state ( schema_version INTEGER PRIMARY KEY, @@ -275,7 +281,8 @@ func (d *DB) SaveInvoice(invoice *Invoice) error { func (d *DB) FindFirstUnusedInvoice() (*Invoice, error) { var invoice Invoice - if res := d.db.Where(&Invoice{State: InvoiceStateRegistered}).First(&invoice); res.Error != nil { + filter := &Invoice{State: InvoiceStateRegistered} + if res := d.db.Where(filter).First(&invoice); res.Error != nil { if errors.Is(res.Error, gorm.ErrRecordNotFound) { return nil, nil @@ -289,7 +296,8 @@ func (d *DB) FindFirstUnusedInvoice() (*Invoice, error) { func (d *DB) CountUnusedInvoices() (int, error) { var count int - if res := d.db.Model(&Invoice{}).Where(&Invoice{State: InvoiceStateRegistered}).Count(&count); res.Error != nil { + filter := &Invoice{State: InvoiceStateRegistered} + if res := d.db.Model(&Invoice{}).Where(filter).Count(&count); res.Error != nil { return 0, res.Error } return count, nil diff --git a/libwallet/walletdb/walletdb_test.go b/libwallet/walletdb/walletdb_test.go index ed3949d5..e1962120 100644 --- a/libwallet/walletdb/walletdb_test.go +++ b/libwallet/walletdb/walletdb_test.go @@ -70,7 +70,7 @@ func TestOpen(t *testing.T) { panic(err) } - db, err := Open(path.Join(dir, "test.db")) + db, err := open(path.Join(dir, "test.db")) if err != nil { t.Fatal(err) } @@ -83,13 +83,15 @@ func TestInvoices(t *testing.T) { panic(err) } - db, err := Open(path.Join(dir, "test.db")) + db, err := open(path.Join(dir, "test.db")) if err != nil { t.Fatal(err) } defer db.Close() - shortChanId := uint64((math.MaxInt64 - 5) | (1 << 63)) + shortChanId := uint64( //nolint:staticcheck // TODO: var shortChanId should be shortChanID + (math.MaxInt64 - 5) | (1 << 63), + ) paymentHash := randomBytes(32) err = db.CreateInvoice(&Invoice{ @@ -150,13 +152,13 @@ func TestBusyTimeout(t *testing.T) { dbPath := path.Join(dir, "test.db") // Open and close connection to ensure migrations are executed before acquiring any lock. - db, err := Open(dbPath) + db, err := open(dbPath) if err != nil { t.Fatal(err) } db.Close() - db, err = Open(dbPath) + db, err = open(dbPath) if err != nil { t.Fatal(err) } @@ -185,9 +187,9 @@ type lockHolder struct { stdin io.WriteCloser } -// startLockHolder re-executes the test binary as a subprocess that acquires an exclusive SQLite lock on dbPath -// and holds it until released. -// It returns once the subprocess has signalled that the lock is held. +// startLockHolder re-executes the test binary as a subprocess that acquires an exclusive SQLite +// lock on dbPath and holds it until released. It returns once the subprocess has signalled that the +// lock is held. // // A separate process is required because SQLite uses POSIX fcntl() advisory locks, // which are per open file description. @@ -203,7 +205,7 @@ func startLockHolder(t *testing.T, dbPath string) *lockHolder { t.Fatal(err) } - cmd := exec.Command(exe) + cmd := exec.Command(exe) //nolint:noctx // TODO: use exec.CommandContext cmd.Env = append(os.Environ(), "LOCK_HOLDER_DB_PATH="+dbPath) stdout, err := cmd.StdoutPipe()