diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index 0c78152e..6175c232 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -9,7 +9,7 @@ on: jobs: build-release: - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 steps: - name: Set up Docker Buildx uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # Matches tag v3.0.0 @@ -41,7 +41,7 @@ jobs: with: name: apk - path: apk/apolloui-prod-release-unsigned.apk + path: apk/apolloui-prod-*-release-unsigned.apk - name: Upload mapping uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # Matches tag v4.3.3 diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 9b417b22..477fe87d 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -6,7 +6,7 @@ on: pull_request jobs: pr: - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 steps: - name: Set up Docker Buildx uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # Matches tag v3.0.0 @@ -42,4 +42,4 @@ jobs: with: name: apk - path: apk/apolloui-prod-release-unsigned.apk + path: apk/apolloui-prod-*-release-unsigned.apk diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index 29aa048d..0ee84ec2 100644 --- a/android/CHANGELOG.md +++ b/android/CHANGELOG.md @@ -6,6 +6,35 @@ follow [https://changelog.md/](https://changelog.md/) guidelines. ## [Unreleased] +## [53.4] - 2025-05-29 + +### CHANGED + +- Added multi APK support to bypass 100MB APK size limit. + +## [53.3] - 2025-04-08 + +### FIXED + +- Proper fix for non ascii chars in http headers + +## [53.2] - 2025-03-24 + +### ADDED + +- Alternative transactions signing + +## [53.1] - 2025-02-21 + +### ADDED + +- Revamped real time fees calculation +- Background notification processing reliability improvements + +### FIXED + +- Non deterministic bug regarding hardened key derivation + ## [52.7] - 2025-01-30 ### ADDED diff --git a/android/Dockerfile b/android/Dockerfile index 2abb79f6..0e432253 100644 --- a/android/Dockerfile +++ b/android/Dockerfile @@ -67,5 +67,8 @@ RUN tools/bootstrap-gomobile.sh \ FROM scratch -COPY --from=build /src/android/apolloui/build/outputs/apk/prod/release/apolloui-prod-release-unsigned.apk apolloui-prod-release-unsigned.apk +COPY --from=build /src/android/apolloui/build/outputs/apk/prod/release/apolloui-prod-arm64-v8a-release-unsigned.apk apolloui-prod-arm64-v8a-release-unsigned.apk +COPY --from=build /src/android/apolloui/build/outputs/apk/prod/release/apolloui-prod-armeabi-v7a-release-unsigned.apk apolloui-prod-armeabi-v7a-release-unsigned.apk +COPY --from=build /src/android/apolloui/build/outputs/apk/prod/release/apolloui-prod-x86-release-unsigned.apk apolloui-prod-x86-release-unsigned.apk +COPY --from=build /src/android/apolloui/build/outputs/apk/prod/release/apolloui-prod-x86_64-release-unsigned.apk apolloui-prod-x86_64-release-unsigned.apk COPY --from=build /src/android/apolloui/build/outputs/mapping/prodRelease/mapping.txt mapping.txt diff --git a/android/apollo/src/main/java/io/muun/apollo/data/Extensions.kt b/android/apollo/src/main/java/io/muun/apollo/data/Extensions.kt index e49b6e1c..b5d3f167 100644 --- a/android/apollo/src/main/java/io/muun/apollo/data/Extensions.kt +++ b/android/apollo/src/main/java/io/muun/apollo/data/Extensions.kt @@ -16,3 +16,9 @@ fun MuunZonedDateTime?.toApolloModel(): ZonedDateTime? { (this as ApolloZonedDateTime).dateTime } } + +fun String.toSafeAscii() = + this.map { if (it.code > 127) "$UNICODE_PREFIX${it.code.toString(16).padStart(4, '0')}" else it } + .joinToString("") + +private const val UNICODE_PREFIX = "\\u" diff --git a/android/apollo/src/main/java/io/muun/apollo/data/di/DataModule.java b/android/apollo/src/main/java/io/muun/apollo/data/di/DataModule.java index 05d51ee1..963cad34 100644 --- a/android/apollo/src/main/java/io/muun/apollo/data/di/DataModule.java +++ b/android/apollo/src/main/java/io/muun/apollo/data/di/DataModule.java @@ -18,6 +18,7 @@ import io.muun.apollo.data.os.execution.ExecutionTransformerFactory; import io.muun.apollo.data.os.execution.JobExecutor; import io.muun.apollo.data.preferences.RepositoryRegistry; +import io.muun.apollo.data.preferences.UserRepository; import io.muun.apollo.domain.action.NotificationActions; import io.muun.apollo.domain.action.NotificationPoller; import io.muun.apollo.domain.libwallet.GoLibwalletService; @@ -47,7 +48,7 @@ public class DataModule { private final Func3< Context, ExecutionTransformerFactory, - RepositoryRegistry, + UserRepository, NotificationService > notificationServiceFactory; @@ -64,7 +65,7 @@ public DataModule( Func3< Context, ExecutionTransformerFactory, - RepositoryRegistry, + UserRepository, NotificationService > notificationServiceFactory, Func1 appStandbyBucketProviderFactory, @@ -127,9 +128,13 @@ DaoManager provideDaoManager( NotificationService provideNotificationService( Context context, ExecutionTransformerFactory executionTransformerFactory, - RepositoryRegistry repoRegistry + UserRepository userRepository ) { - return notificationServiceFactory.call(context, executionTransformerFactory, repoRegistry); + return notificationServiceFactory.call( + context, + executionTransformerFactory, + userRepository + ); } @Provides diff --git a/android/apollo/src/main/java/io/muun/apollo/data/external/Gen.kt b/android/apollo/src/main/java/io/muun/apollo/data/external/Gen.kt index b814635a..1db4e3e6 100644 --- a/android/apollo/src/main/java/io/muun/apollo/data/external/Gen.kt +++ b/android/apollo/src/main/java/io/muun/apollo/data/external/Gen.kt @@ -4,6 +4,7 @@ import io.muun.apollo.data.preferences.stored.StoredEkVerificationCodes import io.muun.apollo.domain.model.BitcoinAmount import io.muun.apollo.domain.model.Contact import io.muun.apollo.domain.model.ExchangeRateWindow +import io.muun.apollo.domain.model.FeeBumpFunctions import io.muun.apollo.domain.model.FeeWindow import io.muun.apollo.domain.model.ForwardingPolicy import io.muun.apollo.domain.model.IncomingSwap @@ -273,9 +274,12 @@ object Gen { * Get a FeeBumpFunctions vector */ fun feeBumpFunctions() = - listOf( - "QsgAAAAAAAAAAAAAf4AAAD+AAABAAAAA", // [[100, 0, 0], [+Inf, 1, 2]] - "f4AAAD+AAAAAAAAA" // [[+Inf, 1, 0]]] + FeeBumpFunctions( + "idTest", + listOf( + "QsgAAAAAAAAAAAAAf4AAAD+AAABAAAAA", // [[100, 0, 0], [+Inf, 1, 2]] + "f4AAAD+AAAAAAAAA" // [[+Inf, 1, 0]]] + ) ) /** * Get a Transaction Hash. diff --git a/android/apollo/src/main/java/io/muun/apollo/data/net/ApiObjectsMapper.java b/android/apollo/src/main/java/io/muun/apollo/data/net/ApiObjectsMapper.java index 544fe9fe..f885c2c4 100644 --- a/android/apollo/src/main/java/io/muun/apollo/data/net/ApiObjectsMapper.java +++ b/android/apollo/src/main/java/io/muun/apollo/data/net/ApiObjectsMapper.java @@ -7,6 +7,7 @@ import io.muun.apollo.data.os.GooglePlayServicesHelper; import io.muun.apollo.data.os.PackageManagerInfoProvider; import io.muun.apollo.data.serialization.dates.ApolloZonedDateTime; +import io.muun.apollo.domain.libwallet.FeeBumpRefreshPolicy; import io.muun.apollo.domain.libwallet.Invoice; import io.muun.apollo.domain.model.BackgroundEvent; import io.muun.apollo.domain.model.BitcoinAmount; @@ -44,9 +45,9 @@ import io.muun.common.api.PhoneNumberJson; import io.muun.common.api.PublicKeyJson; import io.muun.common.api.PublicProfileJson; +import io.muun.common.api.RealTimeFeesRequestJson; import io.muun.common.api.StartEmailSetupJson; import io.muun.common.api.SubmarineSwapRequestJson; -import io.muun.common.api.UnconfirmedOutpointsJson; import io.muun.common.api.UserInvoiceJson; import io.muun.common.api.UserProfileJson; import io.muun.common.crypto.ChallengePublicKey; @@ -62,6 +63,7 @@ import io.muun.common.utils.BitcoinUtils; import io.muun.common.utils.Encodings; import io.muun.common.utils.Pair; +import io.muun.common.utils.Preconditions; import android.os.SystemClock; import androidx.annotation.NonNull; @@ -143,16 +145,25 @@ private BitcoinAmountJson mapBitcoinAmount(@NotNull BitcoinAmount bitcoinAmount) public OperationJson mapOperation( final @NotNull OperationWithMetadata operation, final List outpoints, - final MusigNonces musigNonces + final MusigNonces musigNonces, + final List alternativeTxNonces ) { final Long outputAmountInSatoshis = mapOutputAmountInSatoshis(operation); final List userPublicNoncesHex = new LinkedList<>(); - for (int i = 0; i < outpoints.size(); i++) { + final long noncesCount = musigNonces.length(); + for (int i = 0; i < noncesCount; i++) { userPublicNoncesHex.add(musigNonces.getPubnonceHex(i)); } + for (final MusigNonces nonces : alternativeTxNonces) { + Preconditions.checkState(noncesCount == nonces.length()); + for (int i = 0; i < noncesCount; i++) { + userPublicNoncesHex.add(nonces.getPubnonceHex(i)); + } + } + return new OperationJson( UUID.randomUUID().toString(), operation.getDirection(), @@ -232,7 +243,9 @@ public ClientJson mapClient( final long appSize, final List hardwareAddresses, final String vbMeta, - final String efsCreationTimeInSeconds + final String efsCreationTimeInSeconds, + final Boolean isLowRamDevice, + final Long firstInstallTimeInMs ) { return new ClientJson( ClientTypeJson.APOLLO, @@ -278,7 +291,9 @@ public ClientJson mapClient( appSize, hardwareAddresses, vbMeta, - efsCreationTimeInSeconds + efsCreationTimeInSeconds, + isLowRamDevice, + firstInstallTimeInMs ); } @@ -415,7 +430,9 @@ public CreateFirstSessionJson mapCreateFirstSession( final Long appSize, final List hardwareAddresses, final String vbMeta, - final String efsCreationTimeInSeconds + final String efsCreationTimeInSeconds, + final Boolean isLowRamDevice, + final Long firstInstallTimeInMs ) { return new CreateFirstSessionJson( @@ -442,7 +459,10 @@ public CreateFirstSessionJson mapCreateFirstSession( appSize, hardwareAddresses, vbMeta, - efsCreationTimeInSeconds + efsCreationTimeInSeconds, + isLowRamDevice, + firstInstallTimeInMs + ), gcmToken, primaryCurrency, @@ -479,7 +499,9 @@ public CreateLoginSessionJson mapCreateLoginSession( final Long appSize, final List hardwareAddresses, final String vbMeta, - final String efsCreationTimeInSeconds + final String efsCreationTimeInSeconds, + final Boolean isLowRamDevice, + final Long firstInstallTimeInMs ) { return new CreateLoginSessionJson( @@ -506,7 +528,9 @@ public CreateLoginSessionJson mapCreateLoginSession( appSize, hardwareAddresses, vbMeta, - efsCreationTimeInSeconds + efsCreationTimeInSeconds, + isLowRamDevice, + firstInstallTimeInMs ), gcmToken, email @@ -541,7 +565,9 @@ public CreateRcLoginSessionJson mapCreateRcLoginSession( final Long appSize, final List hardwareAddresses, final String vbMeta, - final String efsCreationTimeInSeconds + final String efsCreationTimeInSeconds, + final Boolean isLowRamDevice, + final Long firstInstallTimeInMs ) { return new CreateRcLoginSessionJson( @@ -568,7 +594,9 @@ public CreateRcLoginSessionJson mapCreateRcLoginSession( appSize, hardwareAddresses, vbMeta, - efsCreationTimeInSeconds + efsCreationTimeInSeconds, + isLowRamDevice, + firstInstallTimeInMs ), gcmToken, new ChallengeKeyJson( @@ -771,11 +799,12 @@ private ExportEmergencyKitJson.Method mapExportMethod( } /** - * Creates a UnconfirmedOutpointsJson. + * Creates a RealTimeFeesRequestJson. */ @NotNull - public UnconfirmedOutpointsJson mapUnconfirmedOutpointsJson( - @NotNull List sizeProgression + public RealTimeFeesRequestJson mapRealTimeFeesRequestJson( + @NotNull List sizeProgression, + @NotNull FeeBumpRefreshPolicy feeBumpRefreshPolicy ) { final List unconfirmedUtxos = new ArrayList<>(); @@ -784,7 +813,28 @@ public UnconfirmedOutpointsJson mapUnconfirmedOutpointsJson( unconfirmedUtxos.add(sizeForAmount.outpoint); } } + ; + return new RealTimeFeesRequestJson( + unconfirmedUtxos, + mapFeeBumpRefreshPolicy(feeBumpRefreshPolicy) + ); + } - return new UnconfirmedOutpointsJson(unconfirmedUtxos); + @NotNull + private RealTimeFeesRequestJson.FeeBumpRefreshPolicy mapFeeBumpRefreshPolicy( + @NotNull FeeBumpRefreshPolicy feeBumpRefreshPolicy + ) { + switch (feeBumpRefreshPolicy) { + case PERIODIC: + return RealTimeFeesRequestJson.FeeBumpRefreshPolicy.PERIODIC; + case FOREGROUND: + return RealTimeFeesRequestJson.FeeBumpRefreshPolicy.FOREGROUND; + case NTS_CHANGED: + return RealTimeFeesRequestJson.FeeBumpRefreshPolicy.CHANGED_NEXT_TRANSACTION_SIZE; + case NEW_OP_BLOCKINGLY: + return RealTimeFeesRequestJson.FeeBumpRefreshPolicy.NEW_OPERATION; + default: + throw new MissingCaseError(feeBumpRefreshPolicy); + } } } diff --git a/android/apollo/src/main/java/io/muun/apollo/data/net/HoustonClient.java b/android/apollo/src/main/java/io/muun/apollo/data/net/HoustonClient.java index 65de72d3..630fa099 100644 --- a/android/apollo/src/main/java/io/muun/apollo/data/net/HoustonClient.java +++ b/android/apollo/src/main/java/io/muun/apollo/data/net/HoustonClient.java @@ -2,6 +2,7 @@ import io.muun.apollo.data.net.base.BaseClient; import io.muun.apollo.data.net.okio.ContentUriRequestBody; +import io.muun.apollo.data.os.ActivityManagerInfoProvider; import io.muun.apollo.data.os.BuildInfoProvider; import io.muun.apollo.data.os.CpuInfoProvider; import io.muun.apollo.data.os.FileInfoProvider; @@ -22,6 +23,7 @@ import io.muun.apollo.domain.errors.newop.NoPaymentRouteException; import io.muun.apollo.domain.errors.newop.SwapFailedException; import io.muun.apollo.domain.errors.newop.UnreachableNodeException; +import io.muun.apollo.domain.libwallet.FeeBumpRefreshPolicy; import io.muun.apollo.domain.libwallet.Invoice; import io.muun.apollo.domain.model.ChallengeKeyUpdateMigration; import io.muun.apollo.domain.model.Contact; @@ -29,6 +31,7 @@ import io.muun.apollo.domain.model.CreateSessionOk; import io.muun.apollo.domain.model.CreateSessionRcOk; import io.muun.apollo.domain.model.EmergencyKitExport; +import io.muun.apollo.domain.model.FulfillmentPushedResult; import io.muun.apollo.domain.model.IncomingSwapFulfillmentData; import io.muun.apollo.domain.model.NextTransactionSize; import io.muun.apollo.domain.model.NotificationReport; @@ -59,11 +62,13 @@ import io.muun.common.api.IntegrityStatus; import io.muun.common.api.KeySet; import io.muun.common.api.LinkActionJson; +import io.muun.common.api.OperationJson; import io.muun.common.api.PasswordSetupJson; import io.muun.common.api.PhoneConfirmation; import io.muun.common.api.PlayIntegrityTokenJson; import io.muun.common.api.PreimageJson; import io.muun.common.api.PublicKeySetJson; +import io.muun.common.api.PushTransactionsJson; import io.muun.common.api.RawTransaction; import io.muun.common.api.SetupChallengeResponse; import io.muun.common.api.UpdateOperationMetadataJson; @@ -97,6 +102,7 @@ import rx.Observable; import rx.Single; +import java.util.ArrayList; import java.util.List; import javax.annotation.Nullable; import javax.inject.Inject; @@ -126,6 +132,8 @@ public class HoustonClient extends BaseClient { private final SystemCapabilitiesProvider systemCapabilitiesProvider; + private final ActivityManagerInfoProvider activityManagerInfoProvider; + /** * Constructor. */ @@ -140,7 +148,8 @@ public HoustonClient( BuildInfoProvider buildInfoProvider, PackageManagerInfoProvider packageManagerInfoProvider, FileInfoProvider fileInfoProvider, - SystemCapabilitiesProvider systemCapabilitiesProvider + SystemCapabilitiesProvider systemCapabilitiesProvider, + ActivityManagerInfoProvider activityManagerInfoProvider ) { super(HoustonService.class); @@ -155,6 +164,7 @@ public HoustonClient( this.packageManagerInfoProvider = packageManagerInfoProvider; this.fileInfoProvider = fileInfoProvider; this.systemCapabilitiesProvider = systemCapabilitiesProvider; + this.activityManagerInfoProvider = activityManagerInfoProvider; } /** @@ -194,7 +204,9 @@ public Observable createFirstSession( fileInfoProvider.getAppSize(), hardwareCapabilitiesProvider.getHardwareAddresses(), systemCapabilitiesProvider.getVbMeta(), - fileInfoProvider.getEfsCreationTimeInSeconds() + fileInfoProvider.getEfsCreationTimeInSeconds(), + activityManagerInfoProvider.isLowRamDevice(), + packageManagerInfoProvider.getFirstInstallTimeInMs() ); return getService().createFirstSession(params) @@ -236,7 +248,9 @@ public Observable createLoginSession( fileInfoProvider.getAppSize(), hardwareCapabilitiesProvider.getHardwareAddresses(), systemCapabilitiesProvider.getVbMeta(), - fileInfoProvider.getEfsCreationTimeInSeconds() + fileInfoProvider.getEfsCreationTimeInSeconds(), + activityManagerInfoProvider.isLowRamDevice(), + packageManagerInfoProvider.getFirstInstallTimeInMs() ); return getService().createLoginSession(params) @@ -278,8 +292,9 @@ public Observable createRcLoginSession( fileInfoProvider.getAppSize(), hardwareCapabilitiesProvider.getHardwareAddresses(), systemCapabilitiesProvider.getVbMeta(), - fileInfoProvider.getEfsCreationTimeInSeconds() - + fileInfoProvider.getEfsCreationTimeInSeconds(), + activityManagerInfoProvider.isLowRamDevice(), + packageManagerInfoProvider.getFirstInstallTimeInMs() ); return getService().createRecoveryCodeLoginSession(session) @@ -591,10 +606,14 @@ public Observable checkIntegrity(IntegrityCheck integrityCheck) public Observable newOperation( final OperationWithMetadata operation, final List outpoints, - final MusigNonces musigNonces + final MusigNonces musigNonces, + final List alternativeTxNonces ) { + final OperationJson operationJson = + apiMapper.mapOperation(operation, outpoints, musigNonces, alternativeTxNonces); + return getService() - .newOperation(apiMapper.mapOperation(operation, outpoints, musigNonces)) + .newOperation(operationJson) .map(modelMapper::mapOperationCreated); } @@ -611,29 +630,44 @@ public Completable updateOperationMetadata(OperationWithMetadata operation) { /** * Pushes a raw transaction to Houston. - * - * @param txHex The bitcoinj's transaction. - * @param operationHid The houston operation id. */ - public Observable pushTransaction( + public Observable pushTransactions( @Nullable String txHex, + @Nullable List alternativeTransactionsHex, long operationHid ) { + final RawTransaction rawTransaction; + final ArrayList alternativeTransactions = new ArrayList(); + if (txHex != null) { - return getService() - .pushTransaction(new RawTransaction(txHex), operationHid) - // This can happen if we determine the payment can't actually be made. If so, we - // fail fast to avoid broadcasting a transaction saving the user on miner fees. - .compose(ObservableFn.replaceHttpException( - ErrorCode.SWAP_FAILED, - SwapFailedException::new - )) - .map(modelMapper::mapTransactionPushed); + Preconditions.checkArgument(alternativeTransactionsHex != null); + + rawTransaction = new RawTransaction(txHex); + + for (var transactionHex : alternativeTransactionsHex) { + alternativeTransactions.add(new RawTransaction(transactionHex)); + } + } else { - return getService() - .pushTransaction(operationHid) // empty body when txHex is not given - .map(modelMapper::mapTransactionPushed); + Preconditions.checkArgument(alternativeTransactionsHex == null); + + rawTransaction = null; } + + final var json = new PushTransactionsJson( + rawTransaction, + alternativeTransactions + ); + + return getService() + .pushTransactions(json, operationHid) + // This can happen if we determine the payment can't actually be made. If so, we + // fail fast to avoid broadcasting a transaction saving the user on miner fees. + .compose(ObservableFn.replaceHttpException( + ErrorCode.SWAP_FAILED, + SwapFailedException::new + )) + .map(modelMapper::mapTransactionPushed); } /** @@ -714,10 +748,13 @@ public Observable fetchRealTimeData() { * @param sizeProgression from NTS */ public Observable fetchRealTimeFees( - List sizeProgression + List sizeProgression, + FeeBumpRefreshPolicy feeBumpRefreshPolicy ) { return getService() - .fetchRealTimeFees(apiMapper.mapUnconfirmedOutpointsJson(sizeProgression)) + .fetchRealTimeFees( + apiMapper.mapRealTimeFeesRequestJson(sizeProgression, feeBumpRefreshPolicy) + ) .map(modelMapper::mapRealTimeFees); } @@ -834,12 +871,13 @@ public Single fetchFulfillmentData(final String inc /** * Push the fulfillment TX for an incoming swap. */ - public Completable pushFulfillmentTransaction( + public Single pushFulfillmentTransaction( final String incomingSwap, final RawTransaction rawTransaction ) { - return getService().pushFulfillmentTransaction(incomingSwap, rawTransaction); + return getService().pushFulfillmentTransaction(incomingSwap, rawTransaction) + .map(modelMapper::mapFulfillmentPushed); } /** diff --git a/android/apollo/src/main/java/io/muun/apollo/data/net/ModelObjectsMapper.java b/android/apollo/src/main/java/io/muun/apollo/data/net/ModelObjectsMapper.java index 6ac7b5ac..19ddcbd7 100644 --- a/android/apollo/src/main/java/io/muun/apollo/data/net/ModelObjectsMapper.java +++ b/android/apollo/src/main/java/io/muun/apollo/data/net/ModelObjectsMapper.java @@ -10,8 +10,10 @@ import io.muun.apollo.domain.model.CreateSessionRcOk; import io.muun.apollo.domain.model.EmergencyKitExport; import io.muun.apollo.domain.model.ExchangeRateWindow; +import io.muun.apollo.domain.model.FeeBumpFunctions; import io.muun.apollo.domain.model.FeeWindow; import io.muun.apollo.domain.model.ForwardingPolicy; +import io.muun.apollo.domain.model.FulfillmentPushedResult; import io.muun.apollo.domain.model.IncomingSwap; import io.muun.apollo.domain.model.IncomingSwapHtlc; import io.muun.apollo.domain.model.MuunFeature; @@ -33,6 +35,7 @@ import io.muun.apollo.domain.model.user.UserPreferences; import io.muun.apollo.domain.model.user.UserProfile; import io.muun.common.Optional; +import io.muun.common.Rules; import io.muun.common.api.BitcoinAmountJson; import io.muun.common.api.ChallengeKeyUpdateMigrationJson; import io.muun.common.api.CommonModelObjectsMapper; @@ -40,14 +43,17 @@ import io.muun.common.api.CreateSessionOkJson; import io.muun.common.api.CreateSessionRcOkJson; import io.muun.common.api.ExportEmergencyKitJson; +import io.muun.common.api.FeeBumpFunctionsJson; import io.muun.common.api.FeeWindowJson; import io.muun.common.api.ForwardingPolicyJson; +import io.muun.common.api.FulfillmentPushedJson; import io.muun.common.api.IncomingSwapHtlcJson; import io.muun.common.api.IncomingSwapJson; import io.muun.common.api.MuunFeatureJson; import io.muun.common.api.NextTransactionSizeJson; import io.muun.common.api.OperationCreatedJson; import io.muun.common.api.OperationJson; +import io.muun.common.api.PartiallySignedTransactionJson; import io.muun.common.api.PendingChallengeUpdateJson; import io.muun.common.api.PhoneNumberJson; import io.muun.common.api.PublicKeySetJson; @@ -76,6 +82,8 @@ import java.util.Collections; import java.util.List; import java.util.Objects; +import java.util.SortedMap; +import java.util.TreeMap; import java.util.TreeSet; import javax.annotation.Nullable; import javax.inject.Inject; @@ -111,7 +119,7 @@ private UserPreferences mapUserPreferences(final io.muun.common.model.UserPrefer } /** - * Create a date time. + * Create a nullable date time. */ @Nullable private ZonedDateTime mapZonedDateTime(@Nullable MuunZonedDateTime dateTime) { @@ -119,6 +127,14 @@ private ZonedDateTime mapZonedDateTime(@Nullable MuunZonedDateTime dateTime) { return null; } + return mapNonNullableZonedDateTime(dateTime); + } + + /** + * Create a date time. + */ + @NotNull + private ZonedDateTime mapNonNullableZonedDateTime(@NotNull MuunZonedDateTime dateTime) { return ((ApolloZonedDateTime) dateTime).dateTime; } @@ -362,10 +378,26 @@ public OperationCreated mapOperationCreated(@NotNull OperationCreatedJson operat networkParameters ), mapNextTransactionSize(operationCreated.nextTransactionSize), - MuunAddress.fromJson(operationCreated.changeAddress) + MuunAddress.fromJson(operationCreated.changeAddress), + mapAlternativeTransactions(operationCreated.alternativeTransactions) ); } + private List mapAlternativeTransactions( + @Nullable final List txs + ) { + if (txs == null) { + return List.of(); + } + + final var result = new ArrayList(); + for (final var tx : txs) { + result.add(PartiallySignedTransaction.fromJson(tx, networkParameters)); + } + + return result; + } + /** * Create a TransactionPushed object. */ @@ -376,7 +408,32 @@ public TransactionPushed mapTransactionPushed(@NotNull TransactionPushedJson txP return new TransactionPushed( txPushed.hex, mapNextTransactionSize(txPushed.nextTransactionSize), - mapOperation(txPushed.updatedOperation) + mapOperation(txPushed.updatedOperation), + mapFeeBumpFunctions(txPushed.feeBumpFunctions) + ); + } + + /** + * Create a FeeBumpFunctions object. + */ + @NotNull + public FeeBumpFunctions mapFeeBumpFunctions( + @NotNull FeeBumpFunctionsJson feeBumpFunctionsJson + ) { + return new FeeBumpFunctions( + feeBumpFunctionsJson.uuid, + feeBumpFunctionsJson.functions + ); + } + + /** + * Map push fulfillment result data. + */ + public FulfillmentPushedResult mapFulfillmentPushed(final FulfillmentPushedJson json) { + + return new FulfillmentPushedResult( + mapNextTransactionSize(json.nextTransactionSize), + mapFeeBumpFunctions(json.feeBumpFunctions) ); } @@ -422,22 +479,46 @@ public RealTimeFees mapRealTimeFees(@NotNull RealTimeFeesJson realTimeFeesJson) // Convert to domain model FeeWindow final FeeWindow feeWindow = new FeeWindow( 1L, // It will be deleted later - mapZonedDateTime(realTimeFeesJson.computedAt), - realTimeFeesJson.targetFeeRates.confTargetToTargetFeeRateInSatPerVbyte, + mapNonNullableZonedDateTime(realTimeFeesJson.computedAt), + mapConfTargetToTargetFeeRateInSatPerVbyte( + realTimeFeesJson.targetFeeRates.confTargetToTargetFeeRateInSatPerVbyte + ), realTimeFeesJson.targetFeeRates.fastConfTarget, realTimeFeesJson.targetFeeRates.mediumConfTarget, realTimeFeesJson.targetFeeRates.slowConfTarget ); + final FeeBumpFunctions feeBumpFunctions = new FeeBumpFunctions( + realTimeFeesJson.feeBumpFunctions.uuid, + realTimeFeesJson.feeBumpFunctions.functions + ); + return new RealTimeFees( - realTimeFeesJson.feeBumpFunctions, + feeBumpFunctions, feeWindow, realTimeFeesJson.minMempoolFeeRateInSatPerVbyte, realTimeFeesJson.minFeeRateIncrementToReplaceByFeeInSatPerVbyte, - mapZonedDateTime(realTimeFeesJson.computedAt) + mapNonNullableZonedDateTime(realTimeFeesJson.computedAt) ); } + private static SortedMap mapConfTargetToTargetFeeRateInSatPerVbyte( + SortedMap confTargetToTargetFeeRateInSatPerVbyte + ) { + final SortedMap targetedFeeRates = new TreeMap<>(); + + for (final var entry: confTargetToTargetFeeRateInSatPerVbyte.entrySet()) { + + final var target = entry.getKey(); + final var feeRateInSatPerVbyte = entry.getValue(); + targetedFeeRates.put( + target, + Rules.toSatsPerWeight(feeRateInSatPerVbyte) + ); + } + return targetedFeeRates; + } + private List mapForwadingPolicies( final List forwardingPolicies ) { diff --git a/android/apollo/src/main/java/io/muun/apollo/data/net/base/interceptor/BackgroundExecutionMetricsInterceptor.kt b/android/apollo/src/main/java/io/muun/apollo/data/net/base/interceptor/BackgroundExecutionMetricsInterceptor.kt index ed8eca1a..f26cb579 100644 --- a/android/apollo/src/main/java/io/muun/apollo/data/net/base/interceptor/BackgroundExecutionMetricsInterceptor.kt +++ b/android/apollo/src/main/java/io/muun/apollo/data/net/base/interceptor/BackgroundExecutionMetricsInterceptor.kt @@ -2,6 +2,7 @@ package io.muun.apollo.data.net.base.interceptor import io.muun.apollo.data.net.base.BaseInterceptor import io.muun.apollo.data.os.BackgroundExecutionMetricsProvider +import io.muun.apollo.data.toSafeAscii import io.muun.apollo.domain.errors.data.MuunSerializationError import io.muun.apollo.domain.model.user.User import io.muun.apollo.domain.selector.UserSelector @@ -40,7 +41,7 @@ class BackgroundExecutionMetricsInterceptor @Inject constructor( private fun safelyEncodeJson(originalRequest: Request): String? { return try { - Json.encodeToString(bemProvider.run()) + Json.encodeToString(bemProvider.run()).toSafeAscii() } catch (e: Throwable) { logError(originalRequest, e) null diff --git a/android/apollo/src/main/java/io/muun/apollo/data/os/PackageManagerInfoProvider.kt b/android/apollo/src/main/java/io/muun/apollo/data/os/PackageManagerInfoProvider.kt index 79c024b4..0111f719 100644 --- a/android/apollo/src/main/java/io/muun/apollo/data/os/PackageManagerInfoProvider.kt +++ b/android/apollo/src/main/java/io/muun/apollo/data/os/PackageManagerInfoProvider.kt @@ -148,4 +148,10 @@ class PackageManagerInfoProvider @Inject constructor(private val context: Contex } return Constants.UNKNOWN } + + val firstInstallTimeInMs: Long + get() { + val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0) + return packageInfo.firstInstallTime + } } \ No newline at end of file diff --git a/android/apollo/src/main/java/io/muun/apollo/domain/action/incoming_swap/FulfillIncomingSwapAction.kt b/android/apollo/src/main/java/io/muun/apollo/domain/action/incoming_swap/FulfillIncomingSwapAction.kt index b02bf48b..8217f1e1 100644 --- a/android/apollo/src/main/java/io/muun/apollo/domain/action/incoming_swap/FulfillIncomingSwapAction.kt +++ b/android/apollo/src/main/java/io/muun/apollo/domain/action/incoming_swap/FulfillIncomingSwapAction.kt @@ -5,7 +5,10 @@ import io.muun.apollo.data.db.incoming_swap.IncomingSwapDao import io.muun.apollo.data.db.operation.OperationDao import io.muun.apollo.data.net.HoustonClient import io.muun.apollo.data.preferences.KeysRepository +import io.muun.apollo.data.preferences.TransactionSizeRepository import io.muun.apollo.domain.action.base.BaseAsyncAction1 +import io.muun.apollo.domain.libwallet.FeeBumpRefreshPolicy +import io.muun.apollo.domain.libwallet.LibwalletService import io.muun.apollo.domain.libwallet.errors.UnfulfillableIncomingSwapError import io.muun.apollo.domain.model.Operation import io.muun.apollo.domain.utils.isInstanceOrIsCausedByError @@ -28,6 +31,8 @@ open class FulfillIncomingSwapAction @Inject constructor( private val keysRepository: KeysRepository, private val network: NetworkParameters, private val incomingSwapDao: IncomingSwapDao, + private val transactionSizeRepository: TransactionSizeRepository, + private val libwalletService: LibwalletService, ) : BaseAsyncAction1() { override fun action(incomingSwapUuid: String): Observable { @@ -105,6 +110,17 @@ open class FulfillIncomingSwapAction @Inject constructor( .map { RawTransaction(Encodings.bytesToHex(it.fullfillmentTx!!)) } .flatMapCompletable { tx -> houstonClient.pushFulfillmentTransaction(op.incomingSwap.houstonUuid, tx) + .flatMap { fulfillmentPushed -> + Single.fromCallable { + transactionSizeRepository.setTransactionSize( + fulfillmentPushed.nextTransactionSize + ) + libwalletService.persistFeeBumpFunctions( + fulfillmentPushed.feeBumpFunctions, + FeeBumpRefreshPolicy.NTS_CHANGED + ) + } + }.toCompletable() } } diff --git a/android/apollo/src/main/java/io/muun/apollo/domain/action/operation/SubmitPaymentAction.java b/android/apollo/src/main/java/io/muun/apollo/domain/action/operation/SubmitPaymentAction.java index 2ff30e27..604c4bbb 100644 --- a/android/apollo/src/main/java/io/muun/apollo/domain/action/operation/SubmitPaymentAction.java +++ b/android/apollo/src/main/java/io/muun/apollo/domain/action/operation/SubmitPaymentAction.java @@ -8,7 +8,10 @@ import io.muun.apollo.domain.action.ContactActions; import io.muun.apollo.domain.action.base.BaseAsyncAction1; import io.muun.apollo.domain.errors.newop.PushTransactionSlowError; +import io.muun.apollo.domain.libwallet.FeeBumpRefreshPolicy; import io.muun.apollo.domain.libwallet.LibwalletBridge; +import io.muun.apollo.domain.libwallet.LibwalletService; +import io.muun.apollo.domain.libwallet.model.SigningExpectations; import io.muun.apollo.domain.model.Contact; import io.muun.apollo.domain.model.Operation; import io.muun.apollo.domain.model.OperationCreated; @@ -16,6 +19,7 @@ import io.muun.apollo.domain.model.PaymentRequest; import io.muun.apollo.domain.model.PreparedPayment; import io.muun.apollo.domain.model.SubmarineSwap; +import io.muun.apollo.domain.model.tx.PartiallySignedTransaction; import io.muun.apollo.domain.utils.ExtensionsKt; import io.muun.common.crypto.hd.MuunAddress; import io.muun.common.crypto.hd.PrivateKey; @@ -23,6 +27,7 @@ import io.muun.common.exception.MissingCaseError; import io.muun.common.model.OperationStatus; import io.muun.common.utils.Encodings; +import io.muun.common.utils.Preconditions; import androidx.annotation.VisibleForTesting; import libwallet.Libwallet; @@ -30,6 +35,7 @@ import libwallet.Transaction; import rx.Observable; +import java.util.ArrayList; import java.util.List; import javax.inject.Inject; import javax.inject.Singleton; @@ -52,6 +58,8 @@ public class SubmitPaymentAction extends BaseAsyncAction1< private final ContactActions contactActions; private final OperationMetadataMapper operationMapper; + private final LibwalletService libwalletService; + /** * Submit an outgoing payment to Houston, and update local data in response. */ @@ -62,7 +70,8 @@ public SubmitPaymentAction(CreateOperationAction createOperation, PublicProfileDao publicProfileDao, HoustonClient houstonClient, ContactActions contactActions, - OperationMetadataMapper operationMetadataMapper) { + OperationMetadataMapper operationMetadataMapper, + LibwalletService libwalletService) { this.createOperation = createOperation; this.userRepository = userRepository; @@ -71,6 +80,7 @@ public SubmitPaymentAction(CreateOperationAction createOperation, this.houstonClient = houstonClient; this.contactActions = contactActions; this.operationMapper = operationMetadataMapper; + this.libwalletService = libwalletService; } @Override @@ -86,58 +96,110 @@ private Observable submitPayment(PreparedPayment prepPayment) { final List outpoints = prepPayment.outpoints; - final int nonceCount = outpoints.size(); - final MusigNonces musigNonces = Libwallet.generateMusigNonces(nonceCount); - - return Observable.defer(keysRepository::getBasePrivateKey) - .flatMap(baseUserPrivKey -> newOperation(opWithMetadata, outpoints, musigNonces) - .flatMap(opCreated -> { - final Operation houstonOp = - operationMapper.mapFromMetadata(opCreated.operation); - - final String transactionHex = op.isLendingSwap() - ? null // money was lent, involves no actual transaction - : signToHex(op, baseUserPrivKey, opCreated, musigNonces); - - // Maybe Houston identified the receiver for us: - final Operation mergedOperation = op.mergeWithUpdate(houstonOp); - - return houstonClient - .pushTransaction(transactionHex, houstonOp.getHid()) - .flatMap(txPushed -> { - // Maybe Houston updated the operation status: - mergedOperation.status = txPushed.operation.getStatus(); - - return createOperation.action( - mergedOperation, txPushed.nextTransactionSize - ); - }) - .onErrorResumeNext(t -> { - if (ExtensionsKt.isInstanceOrIsCausedByTimeoutError(t)) { - - // Most times, a timeout just means that the tx will - // eventually be pushed, albeit slightly delayed. This - // way the app stores the operation/payment and can - // update its state once it receives a notification - mergedOperation.status = OperationStatus.FAILED; - - return createOperation.saveOperation(mergedOperation) - .flatMap(operation -> - Observable.error( - new PushTransactionSlowError(t) - ) - ); - } else { - return Observable.error(t); - } - }); - })); - } + final int maxAlternativeTransactions; + if (prepPayment.swap != null + && !prepPayment.swap.isLend() + && prepPayment.swap.getFundingOutput().getConfirmationsNeeded() == 0 + && prepPayment.swap.getMaxAlternativeTransactions() != null) { + maxAlternativeTransactions = prepPayment.swap.getMaxAlternativeTransactions(); + } else { + maxAlternativeTransactions = 0; + } + + final MusigNonces musigNonces = Libwallet.generateMusigNonces(outpoints.size()); + final List alternativeTxNonces = new ArrayList<>(); + for (var i = 0; i < maxAlternativeTransactions; i++) { + alternativeTxNonces.add(Libwallet.generateMusigNonces(outpoints.size())); + } + + return houstonClient.newOperation( + opWithMetadata, + outpoints, + musigNonces, + alternativeTxNonces + ).flatMap(opCreated -> { + + Preconditions.checkArgument( + opCreated.alternativeTransactions.size() <= maxAlternativeTransactions + ); - private Observable newOperation(OperationWithMetadata operationWithMetadata, - List outpoints, - MusigNonces nonces) { - return houstonClient.newOperation(operationWithMetadata, outpoints, nonces); + final Operation houstonOp = + operationMapper.mapFromMetadata(opCreated.operation); + + final String transactionHex; + final List alternativeTransactionsHex; + + if (op.isLendingSwap()) { + // money was lent, involves no actual transaction + transactionHex = null; + alternativeTransactionsHex = null; + } else { + final var userPrivKey = keysRepository.getBasePrivateKey().toBlocking().first(); + + transactionHex = signToHex( + op, + userPrivKey, + musigNonces, + buildSigningExpectations(op, opCreated, false), + opCreated.partiallySignedTransaction + ); + + alternativeTransactionsHex = new ArrayList<>(); + for (int i = 0; i < opCreated.alternativeTransactions.size(); i++) { + + alternativeTransactionsHex.add( + signToHex( + op, + userPrivKey, + alternativeTxNonces.get(i), + buildSigningExpectations(op, opCreated, true), + opCreated.alternativeTransactions.get(i) + ) + ); + } + } + + // Maybe Houston identified the receiver for us: + final Operation mergedOperation = op.mergeWithUpdate(houstonOp); + + return houstonClient.pushTransactions( + transactionHex, + alternativeTransactionsHex, + houstonOp.getHid() + ) + .flatMap(txPushed -> { + // Maybe Houston updated the operation status: + mergedOperation.status = txPushed.operation.getStatus(); + + libwalletService.persistFeeBumpFunctions( + txPushed.feeBumpFunctions, + FeeBumpRefreshPolicy.NTS_CHANGED + ); + + return createOperation.action( + mergedOperation, txPushed.nextTransactionSize + ); + }) + .onErrorResumeNext(t -> { + if (ExtensionsKt.isInstanceOrIsCausedByTimeoutError(t)) { + + // Most times, a timeout just means that the tx will + // eventually be pushed, albeit slightly delayed. This + // way the app stores the operation/payment and can + // update its state once it receives a notification + mergedOperation.status = OperationStatus.FAILED; + + return createOperation.saveOperation(mergedOperation) + .flatMap(operation -> + Observable.error( + new PushTransactionSlowError(t) + ) + ); + } else { + return Observable.error(t); + } + }); + }); } private OperationWithMetadata buildOperationWithMetadata( @@ -161,27 +223,22 @@ private OperationWithMetadata buildOperationWithMetadata( private String signToHex( final Operation operation, final PrivateKey baseUserPrivateKey, - final OperationCreated operationCreated, - final MusigNonces musigNonces + final MusigNonces musigNonces, + final SigningExpectations signingExpectations, + final PartiallySignedTransaction partiallySignedTransaction ) { final PublicKey baseMuunPublicKey = keysRepository .getBaseMuunPublicKey(); - // Extract data from response: - final MuunAddress changeAddress = operationCreated.changeAddress; - - // Update the operation from Houston's response: - operation.changeAddress = changeAddress; - // Produce the signed Bitcoin transaction: final Transaction txInfo = LibwalletBridge.sign( - operation, baseUserPrivateKey, baseMuunPublicKey, - operationCreated.partiallySignedTransaction, + partiallySignedTransaction, Globals.INSTANCE.getNetwork(), - musigNonces + musigNonces, + signingExpectations ); // Update the Operation after signing: @@ -191,6 +248,24 @@ private String signToHex( return Encodings.bytesToHex(txInfo.getBytes()); } + private static SigningExpectations buildSigningExpectations( + final Operation operation, + final OperationCreated operationCreated, + boolean isAlternativeTx + ) { + final long outputAmount = operation.swap != null + ? operation.swap.getFundingOutput().getOutputAmountInSatoshis() + : operation.amount.inSatoshis; + + return new SigningExpectations( + operation.receiverAddress, + outputAmount, + operationCreated.changeAddress, + operation.fee.inSatoshis, + isAlternativeTx + ); + } + /** * Build an Operation. */ diff --git a/android/apollo/src/main/java/io/muun/apollo/domain/action/realtime/PreloadFeeDataAction.kt b/android/apollo/src/main/java/io/muun/apollo/domain/action/realtime/PreloadFeeDataAction.kt index dbb8b262..f1566e03 100644 --- a/android/apollo/src/main/java/io/muun/apollo/domain/action/realtime/PreloadFeeDataAction.kt +++ b/android/apollo/src/main/java/io/muun/apollo/domain/action/realtime/PreloadFeeDataAction.kt @@ -1,108 +1,45 @@ package io.muun.apollo.domain.action.realtime -import io.muun.apollo.data.net.HoustonClient -import io.muun.apollo.data.preferences.FeeWindowRepository -import io.muun.apollo.data.preferences.MinFeeRateRepository -import io.muun.apollo.data.preferences.TransactionSizeRepository -import io.muun.apollo.domain.action.base.BaseAsyncAction0 +import io.muun.apollo.domain.action.base.BaseAsyncAction1 +import io.muun.apollo.domain.libwallet.FeeBumpRefreshPolicy import io.muun.apollo.domain.libwallet.LibwalletService -import io.muun.apollo.domain.model.MuunFeature -import io.muun.apollo.domain.model.RealTimeFees -import io.muun.apollo.domain.selector.FeatureSelector import io.muun.apollo.domain.utils.toVoid -import io.muun.common.Rules -import io.muun.common.model.UtxoStatus -import io.muun.common.rx.RxHelper import rx.Observable -import timber.log.Timber -import java.util.Date import javax.inject.Inject -import javax.inject.Singleton /** * Update fees data, such as fee window and fee bump functions. */ -@Singleton class PreloadFeeDataAction @Inject constructor( - private val houstonClient: HoustonClient, - private val feeWindowRepository: FeeWindowRepository, - private val minFeeRateRepository: MinFeeRateRepository, - private val transactionSizeRepository: TransactionSizeRepository, - private val featureSelector: FeatureSelector, + private val syncRealTimeFees: SyncRealTimeFees, private val libwalletService: LibwalletService, -) : BaseAsyncAction0() { - - companion object { - private const val throttleIntervalInMilliseconds: Long = 10 * 1000 - } - - private var lastSyncTime: Date = Date(0) // Init with distant past +) : BaseAsyncAction1() { /** * Force re-fetch of Houston's RealTimeFees, bypassing throttling logic. */ - fun runForced(): Observable { - return syncRealTimeFees() + fun runForced(refreshPolicy: FeeBumpRefreshPolicy): Observable { + return syncRealTimeFees.sync(refreshPolicy) } - fun runIfDataIsInvalidated() { + fun runIfDataIsInvalidated(refreshPolicy: FeeBumpRefreshPolicy) { super.run(Observable.defer { if (libwalletService.areFeeBumpFunctionsInvalidated()) { - return@defer this.syncRealTimeFees() + return@defer syncRealTimeFees.sync(refreshPolicy) } else { return@defer Observable.just(null) } }) } - override fun action(): Observable { + override fun action(t: FeeBumpRefreshPolicy): Observable { return Observable.defer { - if (shouldUpdateData()) { - return@defer syncRealTimeFees() + if (syncRealTimeFees.shouldUpdateData()) { + return@defer syncRealTimeFees.sync(t) } else { return@defer Observable.just(null).toVoid() } } } - - private fun syncRealTimeFees(): Observable { - if (!featureSelector.get(MuunFeature.EFFECTIVE_FEES_CALCULATION)) { - return Observable.just(null) - } - - Timber.d("[Sync] Updating realTime fees data") - - transactionSizeRepository.nextTransactionSize?.let { nts -> - if (nts.unconfirmedUtxos.isEmpty()) { - // If there are no unconfirmed UTXOs, it means there are no fee bump functions. - // Remove the fee bump functions by storing an empty list. - libwalletService.persistFeeBumpFunctions(emptyList()) - } - return houstonClient.fetchRealTimeFees(nts.unconfirmedUtxos) - .doOnNext { realTimeFees: RealTimeFees -> - Timber.d("[Sync] Saving updated fees") - storeFeeData(realTimeFees) - libwalletService.persistFeeBumpFunctions(realTimeFees.feeBumpFunctions) - lastSyncTime = Date() - } - .map(RxHelper::toVoid) - } - - Timber.e("syncRealTimeFees was called without a local valid NTS") - return Observable.just(null) - } - - private fun storeFeeData(realTimeFees: RealTimeFees) { - feeWindowRepository.store(realTimeFees.feeWindow) - val minMempoolFeeRateInSatsPerWeightUnit = - Rules.toSatsPerWeight(realTimeFees.minMempoolFeeRateInSatPerVbyte) - minFeeRateRepository.store(minMempoolFeeRateInSatsPerWeightUnit) - } - - private fun shouldUpdateData(): Boolean { - val nowInMilliseconds = Date().time - val secondsElapsedInMilliseconds = nowInMilliseconds - lastSyncTime.time - return secondsElapsedInMilliseconds >= throttleIntervalInMilliseconds - } } \ No newline at end of file diff --git a/android/apollo/src/main/java/io/muun/apollo/domain/action/realtime/SyncRealTimeFees.kt b/android/apollo/src/main/java/io/muun/apollo/domain/action/realtime/SyncRealTimeFees.kt new file mode 100644 index 00000000..74281e7c --- /dev/null +++ b/android/apollo/src/main/java/io/muun/apollo/domain/action/realtime/SyncRealTimeFees.kt @@ -0,0 +1,80 @@ +package io.muun.apollo.domain.action.realtime + +import io.muun.apollo.data.net.HoustonClient +import io.muun.apollo.data.preferences.FeeWindowRepository +import io.muun.apollo.data.preferences.MinFeeRateRepository +import io.muun.apollo.data.preferences.TransactionSizeRepository +import io.muun.apollo.domain.libwallet.FeeBumpRefreshPolicy +import io.muun.apollo.domain.libwallet.LibwalletService +import io.muun.apollo.domain.model.FeeBumpFunctions +import io.muun.apollo.domain.model.MuunFeature +import io.muun.apollo.domain.model.RealTimeFees +import io.muun.apollo.domain.selector.FeatureSelector +import io.muun.common.Rules +import io.muun.common.rx.RxHelper +import rx.Observable +import timber.log.Timber +import java.util.Date +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class SyncRealTimeFees @Inject constructor( + private val houstonClient: HoustonClient, + private val feeWindowRepository: FeeWindowRepository, + private val minFeeRateRepository: MinFeeRateRepository, + private val transactionSizeRepository: TransactionSizeRepository, + private val featureSelector: FeatureSelector, + private val libwalletService: LibwalletService, +) { + + companion object { + private const val THROTTLE_INTERVAL_IN_MILLISECONDS: Long = 10 * 1000 + } + + private var lastSyncTime: Date = Date(0) // Init with distant past + + fun sync(refreshPolicy: FeeBumpRefreshPolicy): Observable { + if (!featureSelector.get(MuunFeature.EFFECTIVE_FEES_CALCULATION)) { + return Observable.just(null) + } + + Timber.d("[Sync] Updating realTime fees data") + + transactionSizeRepository.nextTransactionSize?.let { nts -> + if (nts.unconfirmedUtxos.isEmpty()) { + // If there are no unconfirmed UTXOs, it means there are no fee bump functions. + // Remove the fee bump functions by storing an empty list. + val emptyFeeBumpFunctions = FeeBumpFunctions("", emptyList()) + libwalletService.persistFeeBumpFunctions(emptyFeeBumpFunctions, refreshPolicy) + } + return houstonClient.fetchRealTimeFees(nts.unconfirmedUtxos, refreshPolicy) + .doOnNext { realTimeFees: RealTimeFees -> + Timber.d("[Sync] Saving updated fees") + storeFeeData(realTimeFees) + libwalletService.persistFeeBumpFunctions( + realTimeFees.feeBumpFunctions, + refreshPolicy + ) + lastSyncTime = Date() + } + .map(RxHelper::toVoid) + } + + Timber.e("syncRealTimeFees was called without a local valid NTS") + return Observable.just(null) + } + + fun shouldUpdateData(): Boolean { + val nowInMilliseconds = Date().time + val secondsElapsedInMilliseconds = nowInMilliseconds - lastSyncTime.time + return secondsElapsedInMilliseconds >= THROTTLE_INTERVAL_IN_MILLISECONDS + } + + private fun storeFeeData(realTimeFees: RealTimeFees) { + feeWindowRepository.store(realTimeFees.feeWindow) + val minMempoolFeeRateInSatsPerWeightUnit = + Rules.toSatsPerWeight(realTimeFees.minMempoolFeeRateInSatPerVbyte) + minFeeRateRepository.store(minMempoolFeeRateInSatsPerWeightUnit) + } +} \ No newline at end of file diff --git a/android/apollo/src/main/java/io/muun/apollo/domain/action/session/IsRootedDeviceAction.kt b/android/apollo/src/main/java/io/muun/apollo/domain/action/session/IsRootedDeviceAction.kt index 8c308f31..87a3c7b6 100644 --- a/android/apollo/src/main/java/io/muun/apollo/domain/action/session/IsRootedDeviceAction.kt +++ b/android/apollo/src/main/java/io/muun/apollo/domain/action/session/IsRootedDeviceAction.kt @@ -2,17 +2,42 @@ package io.muun.apollo.domain.action.session import android.content.Context import com.scottyab.rootbeer.RootBeer +import io.muun.apollo.data.os.TorHelper import io.muun.apollo.domain.action.base.BaseAsyncAction0 import rx.Observable +import timber.log.Timber import javax.inject.Inject class IsRootedDeviceAction @Inject constructor( - private val context: Context + private val context: Context, ) : BaseAsyncAction0() { + companion object { + val dangerousBinaries = arrayOf( + TorHelper.process("ncngpu"), + TorHelper.process("xrearyfh"), + TorHelper.process("zntvfxvavg"), + TorHelper.process("fhcrefh") + ) + } + override fun action(): Observable { return Observable.defer { - Observable.just(RootBeer(context).isRooted) + + try { + if (RootBeer(context).isRooted) { + return@defer Observable.just(true) + } + + val hasDangerousNewBinary = dangerousBinaries.any { + RootBeer(context).checkForBinary(it) + } + Observable.just(hasDangerousNewBinary) + } catch (e: Exception) { + // Catching exceptions to prevent potential issues with root checks + Timber.e(e, "Root detection failed") + Observable.just(false) + } } } } \ No newline at end of file diff --git a/android/apollo/src/main/java/io/muun/apollo/domain/action/session/SyncApplicationDataAction.kt b/android/apollo/src/main/java/io/muun/apollo/domain/action/session/SyncApplicationDataAction.kt index 6e00132d..f50b2d30 100644 --- a/android/apollo/src/main/java/io/muun/apollo/domain/action/session/SyncApplicationDataAction.kt +++ b/android/apollo/src/main/java/io/muun/apollo/domain/action/session/SyncApplicationDataAction.kt @@ -13,10 +13,12 @@ import io.muun.apollo.domain.action.integrity.GooglePlayIntegrityCheckAction import io.muun.apollo.domain.action.keys.SyncPublicKeySetAction import io.muun.apollo.domain.action.operation.FetchNextTransactionSizeAction import io.muun.apollo.domain.action.realtime.FetchRealTimeDataAction +import io.muun.apollo.domain.action.realtime.PreloadFeeDataAction import io.muun.apollo.domain.action.session.rc_only.FinishLoginWithRcAction import io.muun.apollo.domain.errors.InitialSyncError import io.muun.apollo.domain.errors.InitialSyncNetworkError import io.muun.apollo.domain.errors.fcm.GooglePlayServicesNotAvailableError +import io.muun.apollo.domain.libwallet.FeeBumpRefreshPolicy import io.muun.apollo.domain.model.LoginWithRc import io.muun.apollo.domain.utils.isInstanceOrIsCausedByNetworkError import io.muun.apollo.domain.utils.toVoid @@ -37,6 +39,7 @@ class SyncApplicationDataAction @Inject constructor( private val syncPublicKeySet: SyncPublicKeySetAction, private val fetchNextTransactionSize: FetchNextTransactionSizeAction, private val fetchRealTimeData: FetchRealTimeDataAction, + private val preloadFeeDataAction: PreloadFeeDataAction, private val createFirstSession: CreateFirstSessionAction, private val finishLoginWithRc: FinishLoginWithRcAction, private val registerInvoices: RegisterInvoicesAction, @@ -80,6 +83,7 @@ class SyncApplicationDataAction @Inject constructor( fetchUserInfo(), fetchNextTransactionSize.action(), fetchRealTimeData.action(), + preloadFeeDataAction.action(FeeBumpRefreshPolicy.FOREGROUND), runOnlyIf(!isFirstSession) { syncContacts(hasContactsPermission) }, Observable.fromCallable(apiMigrationsManager::reset), ) diff --git a/android/apollo/src/main/java/io/muun/apollo/domain/libwallet/GoLibwalletService.kt b/android/apollo/src/main/java/io/muun/apollo/domain/libwallet/GoLibwalletService.kt index 215b1a07..6f62d066 100644 --- a/android/apollo/src/main/java/io/muun/apollo/domain/libwallet/GoLibwalletService.kt +++ b/android/apollo/src/main/java/io/muun/apollo/domain/libwallet/GoLibwalletService.kt @@ -1,19 +1,27 @@ package io.muun.apollo.domain.libwallet import io.muun.apollo.domain.libwallet.errors.FeeBumpFunctionsStoreError +import io.muun.apollo.domain.model.FeeBumpFunctions import io.muun.apollo.domain.utils.toLibwalletModel import newop.Newop import timber.log.Timber class GoLibwalletService: LibwalletService { - override fun persistFeeBumpFunctions(encodedFeeBumpFunctions: List) { - val feeBumpFunctionsStringList = encodedFeeBumpFunctions.toLibwalletModel() + override fun persistFeeBumpFunctions( + feeBumpFunctions: FeeBumpFunctions, + refreshPolicy: FeeBumpRefreshPolicy + ) { + val feeBumpFunctionsStringList = feeBumpFunctions.functions.toLibwalletModel() try { - Newop.persistFeeBumpFunctions(feeBumpFunctionsStringList) + Newop.persistFeeBumpFunctions( + feeBumpFunctionsStringList, + feeBumpFunctions.uuid, + refreshPolicy.value + ) } catch (e: Exception) { Timber.e(e, "Error storing fee bump functions") - throw FeeBumpFunctionsStoreError(encodedFeeBumpFunctions.toString(), e) + throw FeeBumpFunctionsStoreError(feeBumpFunctions.functions.toString(), e) } } diff --git a/android/apollo/src/main/java/io/muun/apollo/domain/libwallet/LibwalletBridge.java b/android/apollo/src/main/java/io/muun/apollo/domain/libwallet/LibwalletBridge.java index f40027e7..d3652b69 100644 --- a/android/apollo/src/main/java/io/muun/apollo/domain/libwallet/LibwalletBridge.java +++ b/android/apollo/src/main/java/io/muun/apollo/domain/libwallet/LibwalletBridge.java @@ -8,10 +8,11 @@ import io.muun.apollo.domain.libwallet.errors.LibwalletVerificationError; import io.muun.apollo.domain.libwallet.errors.PayloadDecryptError; import io.muun.apollo.domain.libwallet.errors.PayloadEncryptError; +import io.muun.apollo.domain.libwallet.model.Address; import io.muun.apollo.domain.libwallet.model.Input; +import io.muun.apollo.domain.libwallet.model.SigningExpectations; import io.muun.apollo.domain.model.BitcoinUriContent; import io.muun.apollo.domain.model.GeneratedEmergencyKit; -import io.muun.apollo.domain.model.Operation; import io.muun.apollo.domain.model.OperationUri; import io.muun.apollo.domain.model.tx.PartiallySignedTransaction; import io.muun.common.Optional; @@ -22,7 +23,6 @@ import io.muun.common.crypto.hd.PublicKeyPair; import io.muun.common.utils.BitcoinUtils; import io.muun.common.utils.Encodings; -import io.muun.common.utils.Preconditions; import libwallet.BackendActivatedFeatureStatusProvider; import libwallet.Config; @@ -34,7 +34,6 @@ import libwallet.MusigNonces; import libwallet.MuunPaymentURI; import libwallet.Network; -import libwallet.SigningExpectations; import libwallet.Transaction; import org.bitcoinj.core.NetworkParameters; import org.javamoney.moneta.Money; @@ -110,14 +109,13 @@ public static byte[] sign(byte[] message, PrivateKey userKey, NetworkParameters * Sign a partially signed transaction. */ public static Transaction sign( - final Operation userCraftedOp, final PrivateKey userPrivateKey, final PublicKey muunPublicKey, final PartiallySignedTransaction pst, final NetworkParameters network, - final MusigNonces musigNonces + final MusigNonces musigNonces, + final SigningExpectations signingExpectations ) { - final byte[] unsignedTx = pst.getTransaction().bitcoinSerialize(); final HDPrivateKey userKey = toLibwalletModel(userPrivateKey, network); @@ -133,7 +131,17 @@ public static Transaction sign( // Attempt client-side verification (log-only for now): // We have some cases that aren't considered in libwallet yet, so keep this advisory - tryLibwalletVerify(userCraftedOp, userKey.publicKey(), muunKey, libwalletPst); + + try { + libwalletPst.verify( + signingExpectations.toLibwalletModel(), + userKey.publicKey(), + muunKey + ); + + } catch (Throwable error) { + Timber.e(new LibwalletVerificationError(error)); + } try { return libwalletPst.sign(userKey, muunKey); @@ -392,19 +400,7 @@ private static HDPrivateKey toLibwalletModel(PrivateKey privKey, NetworkParamete } private static libwallet.MuunAddress toLibwalletModel(MuunAddress address) { - return new libwallet.MuunAddress() { - public String address() { - return address.getAddress(); - } - - public String derivationPath() { - return address.getDerivationPath(); - } - - public long version() { - return address.getVersion(); - } - }; + return new Address(address); } /** @@ -422,31 +418,4 @@ private static Network toLibwalletModel(NetworkParameters networkParameters) { } } - private static void tryLibwalletVerify(Operation userCraftedOp, - HDPublicKey userPublicKey, - HDPublicKey muunPublicKey, - libwallet.PartiallySignedTransaction libwalletPst) { - - Preconditions.checkArgument(!userCraftedOp.isLendingSwap()); // no tx for LEND swaps - - final MuunAddress changeAddress = userCraftedOp.changeAddress; - - final long outputAmount = userCraftedOp.swap != null - ? userCraftedOp.swap.getFundingOutput().getOutputAmountInSatoshis() - : userCraftedOp.amount.inSatoshis; - - try { - final SigningExpectations expectations = new SigningExpectations( - userCraftedOp.receiverAddress, - outputAmount, - changeAddress == null ? null : toLibwalletModel(changeAddress), - userCraftedOp.fee.inSatoshis - ); - - libwalletPst.verify(expectations, userPublicKey, muunPublicKey); - - } catch (Throwable error) { - Timber.e(new LibwalletVerificationError(error)); - } - } } diff --git a/android/apollo/src/main/java/io/muun/apollo/domain/libwallet/LibwalletService.kt b/android/apollo/src/main/java/io/muun/apollo/domain/libwallet/LibwalletService.kt index 877b007b..e156eb13 100644 --- a/android/apollo/src/main/java/io/muun/apollo/domain/libwallet/LibwalletService.kt +++ b/android/apollo/src/main/java/io/muun/apollo/domain/libwallet/LibwalletService.kt @@ -1,8 +1,20 @@ package io.muun.apollo.domain.libwallet +import io.muun.apollo.domain.model.FeeBumpFunctions + +enum class FeeBumpRefreshPolicy(val value: String) { + FOREGROUND("foreground"), + PERIODIC("periodic"), + NEW_OP_BLOCKINGLY("newOpBlockingly"), + NTS_CHANGED("ntsChanged") +} + // LibwalletService is a protocol that abstract interactions with Libwallet library. interface LibwalletService { // Fee Bump Functions section - fun persistFeeBumpFunctions(encodedFeeBumpFunctions: List) + fun persistFeeBumpFunctions( + feeBumpFunctions: FeeBumpFunctions, + refreshPolicy: FeeBumpRefreshPolicy + ) fun areFeeBumpFunctionsInvalidated(): Boolean } \ No newline at end of file diff --git a/android/apollo/src/main/java/io/muun/apollo/domain/libwallet/model/SigningExpectations.kt b/android/apollo/src/main/java/io/muun/apollo/domain/libwallet/model/SigningExpectations.kt new file mode 100644 index 00000000..fd402078 --- /dev/null +++ b/android/apollo/src/main/java/io/muun/apollo/domain/libwallet/model/SigningExpectations.kt @@ -0,0 +1,21 @@ +package io.muun.apollo.domain.libwallet.model + +import io.muun.common.crypto.hd.MuunAddress + +class SigningExpectations( + private val address: String, + private val outputAmountInSats: Long, + private val changeAddress: MuunAddress?, + private val feeInSatoshis: Long, + private val isAlternativeTx: Boolean, +) { + fun toLibwalletModel(): libwallet.SigningExpectations { + return libwallet.SigningExpectations( + address, + outputAmountInSats, + changeAddress?.let { Address(it) }, + feeInSatoshis, + isAlternativeTx + ) + } +} \ No newline at end of file diff --git a/android/apollo/src/main/java/io/muun/apollo/domain/model/FeeBumpFunctions.kt b/android/apollo/src/main/java/io/muun/apollo/domain/model/FeeBumpFunctions.kt new file mode 100644 index 00000000..1d2fdb45 --- /dev/null +++ b/android/apollo/src/main/java/io/muun/apollo/domain/model/FeeBumpFunctions.kt @@ -0,0 +1,7 @@ +package io.muun.apollo.domain.model + +data class FeeBumpFunctions( + val uuid: String, + // Each fee bump functions is codified as a base64 string. + val functions: List +) diff --git a/android/apollo/src/main/java/io/muun/apollo/domain/model/FulfillmentPushedResult.kt b/android/apollo/src/main/java/io/muun/apollo/domain/model/FulfillmentPushedResult.kt new file mode 100644 index 00000000..cb08da39 --- /dev/null +++ b/android/apollo/src/main/java/io/muun/apollo/domain/model/FulfillmentPushedResult.kt @@ -0,0 +1,6 @@ +package io.muun.apollo.domain.model + +class FulfillmentPushedResult( + val nextTransactionSize: NextTransactionSize, + val feeBumpFunctions: FeeBumpFunctions +) \ No newline at end of file diff --git a/android/apollo/src/main/java/io/muun/apollo/domain/model/OperationCreated.java b/android/apollo/src/main/java/io/muun/apollo/domain/model/OperationCreated.java index c5cc8088..0addef16 100644 --- a/android/apollo/src/main/java/io/muun/apollo/domain/model/OperationCreated.java +++ b/android/apollo/src/main/java/io/muun/apollo/domain/model/OperationCreated.java @@ -4,6 +4,7 @@ import io.muun.apollo.domain.model.tx.PartiallySignedTransaction; import io.muun.common.crypto.hd.MuunAddress; +import java.util.List; import javax.annotation.Nullable; import javax.validation.constraints.NotNull; @@ -21,17 +22,22 @@ public class OperationCreated { @Nullable // null if the Operation has no change public final MuunAddress changeAddress; + public final List alternativeTransactions; + /** * Constructor. */ public OperationCreated(OperationWithMetadata operation, PartiallySignedTransaction partiallySignedTransaction, NextTransactionSize nextTransactionSize, - @Nullable MuunAddress changeAddress) { + @Nullable MuunAddress changeAddress, + List alternativeTransactions + ) { this.operation = operation; this.partiallySignedTransaction = partiallySignedTransaction; this.nextTransactionSize = nextTransactionSize; this.changeAddress = changeAddress; + this.alternativeTransactions = alternativeTransactions; } } diff --git a/android/apollo/src/main/java/io/muun/apollo/domain/model/RealTimeFees.kt b/android/apollo/src/main/java/io/muun/apollo/domain/model/RealTimeFees.kt index 70ac6aa1..3b445271 100644 --- a/android/apollo/src/main/java/io/muun/apollo/domain/model/RealTimeFees.kt +++ b/android/apollo/src/main/java/io/muun/apollo/domain/model/RealTimeFees.kt @@ -4,7 +4,7 @@ import org.threeten.bp.ZonedDateTime data class RealTimeFees( // Each fee bump functions is codified as a base64 string. - val feeBumpFunctions: List, + val feeBumpFunctions: FeeBumpFunctions, val feeWindow: FeeWindow, val minMempoolFeeRateInSatPerVbyte: Double, val minFeeRateIncrementToReplaceByFeeInSatPerVbyte: Double, diff --git a/android/apollo/src/main/java/io/muun/apollo/domain/model/SubmarineSwap.kt b/android/apollo/src/main/java/io/muun/apollo/domain/model/SubmarineSwap.kt index 5693d800..34801a01 100644 --- a/android/apollo/src/main/java/io/muun/apollo/domain/model/SubmarineSwap.kt +++ b/android/apollo/src/main/java/io/muun/apollo/domain/model/SubmarineSwap.kt @@ -24,6 +24,7 @@ class SubmarineSwap( private val preimageInHex: String?, private val bestRouteFees: List? = null, // Transient private val fundingOutputPolicies: SubmarineSwapFundingOutputPolicies? = null, // Transient + val maxAlternativeTransactions: Int? = null, // Transient ) : HoustonUuidModel(id, houstonUuid) { companion object { @@ -42,7 +43,8 @@ class SubmarineSwap( ApolloZonedDateTime.fromMuunZonedDateTime(swap.payedAt)?.dateTime, swap.preimageInHex, swap.bestRouteFees?.map(SubmarineSwapBestRouteFees::fromJson), - swap.fundingOutputPolicies?.let(SubmarineSwapFundingOutputPolicies::fromJson) + swap.fundingOutputPolicies?.let(SubmarineSwapFundingOutputPolicies::fromJson), + swap.maxAlternativeTransactionCount ) } } @@ -87,7 +89,8 @@ class SubmarineSwap( SubmarineSwapFees(swapFees.routingFeeInSat, swapFees.outputPaddingInSat), expiresAt, payedAt, - preimageInHex + preimageInHex, + maxAlternativeTransactions = maxAlternativeTransactions ) } diff --git a/android/apollo/src/main/java/io/muun/apollo/domain/model/TransactionPushed.java b/android/apollo/src/main/java/io/muun/apollo/domain/model/TransactionPushed.java index acea1979..4157f54f 100644 --- a/android/apollo/src/main/java/io/muun/apollo/domain/model/TransactionPushed.java +++ b/android/apollo/src/main/java/io/muun/apollo/domain/model/TransactionPushed.java @@ -15,14 +15,19 @@ public class TransactionPushed { @NotNull public final OperationWithMetadata operation; + @NotNull + public final FeeBumpFunctions feeBumpFunctions; + /** * Constructor. */ public TransactionPushed(@Nullable String hex, NextTransactionSize nextTransactionSize, - OperationWithMetadata operation) { + OperationWithMetadata operation, + FeeBumpFunctions feeBumpFunctions) { this.hex = hex; this.nextTransactionSize = nextTransactionSize; this.operation = operation; + this.feeBumpFunctions = feeBumpFunctions; } } diff --git a/android/apollo/src/main/java/io/muun/apollo/domain/sync/FeeDataSyncer.kt b/android/apollo/src/main/java/io/muun/apollo/domain/sync/FeeDataSyncer.kt index 88f3e3e1..77bfc7e5 100644 --- a/android/apollo/src/main/java/io/muun/apollo/domain/sync/FeeDataSyncer.kt +++ b/android/apollo/src/main/java/io/muun/apollo/domain/sync/FeeDataSyncer.kt @@ -6,12 +6,12 @@ import io.muun.apollo.data.preferences.TransactionSizeRepository import io.muun.apollo.domain.action.NotificationActions import io.muun.apollo.domain.action.NotificationProcessingState import io.muun.apollo.domain.action.realtime.PreloadFeeDataAction +import io.muun.apollo.domain.libwallet.FeeBumpRefreshPolicy import io.muun.apollo.domain.model.MuunFeature import io.muun.apollo.domain.model.NextTransactionSize import io.muun.apollo.domain.selector.FeatureSelector -import io.muun.common.model.SizeForAmount -import io.muun.common.model.UtxoStatus import rx.subscriptions.CompositeSubscription +import timber.log.Timber import javax.inject.Inject /** @@ -34,14 +34,18 @@ class FeeDataSyncer @Inject constructor( private var initialNts: NextTransactionSize? = null - private val intervalInMilliseconds: Long = 45000 // 45 seconds + private val intervalInMilliseconds: Long = 60000 // 60 seconds private val compositeSubscription = CompositeSubscription() private val handler = Handler(Looper.getMainLooper()) private val periodicTask = object : Runnable { override fun run() { - preloadFeeData.run() - handler.postDelayed(this, intervalInMilliseconds) + preloadFeeData.run(FeeBumpRefreshPolicy.PERIODIC) + try { + handler.postDelayed(this, intervalInMilliseconds) + } catch (e: Exception) { + Timber.e(e, "Fee data periodic task failed.") + } } } @@ -82,9 +86,10 @@ class FeeDataSyncer @Inject constructor( NotificationProcessingState.COMPLETED -> { if (shouldUpdateFeeBumpFunctions()) { - preloadFeeData.runForced() + preloadFeeData.runForced(FeeBumpRefreshPolicy.NTS_CHANGED) } } + else -> { // ignore } diff --git a/android/apollo/src/test/java/io/muun/apollo/data/os/ExtensionsTest.kt b/android/apollo/src/test/java/io/muun/apollo/data/os/ExtensionsTest.kt new file mode 100644 index 00000000..9c09a701 --- /dev/null +++ b/android/apollo/src/test/java/io/muun/apollo/data/os/ExtensionsTest.kt @@ -0,0 +1,206 @@ +package io.muun.apollo.data.os + +import io.muun.apollo.data.toSafeAscii +import org.junit.Assert +import org.junit.Test + +class ExtensionsTest { + + private val languages = arrayListOf( + "ascii: Hello word!!", + "japanese: ドメイン名例", + "japaneseWithAscii: MajiでKoiする5秒前", + "already encoded japanese: \u30c9\u30e1\u30a4\u30f3\u540d\u4f8b", + "korean: 도메인", + "thai: ยจฆฟคฏข", + "russian: правда", + "emoji: 😉", + "non encoded: \\\\ud83d9", + "non-ascii mixup: 「 Б ü æ α 例 αβγ 」", + ).joinToString("\n") + + private val expectedEncodedLanguages = "ascii: Hello word!!\n" + + "japanese: \\u30c9\\u30e1\\u30a4\\u30f3\\u540d\\u4f8b\n" + + "japaneseWithAscii: Maji\\u3067Koi\\u3059\\u308b5\\u79d2\\u524d\n" + + "already encoded japanese: \\u30c9\\u30e1\\u30a4\\u30f3\\u540d\\u4f8b\n" + + "korean: \\ub3c4\\uba54\\uc778\n" + + "thai: \\u0e22\\u0e08\\u0e06\\u0e1f\\u0e04\\u0e0f\\u0e02\n" + + "russian: \\u043f\\u0440\\u0430\\u0432\\u0434\\u0430\n" + + "emoji: \\ud83d\\ude09\n" + + "non encoded: \\\\ud83d9\n" + + "non-ascii mixup: \\u300c \\u0411 \\u00fc \\u00e6 \\u03b1 \\u4f8b \\u03b1\\u03b2\\u03b3 \\u300d" + + private val actualResponse = "{\n" + + " \"epochInMilliseconds\": 1734567549279,\n" + + " \"batteryLevel\": 92,\n" + + " \"maxBatteryLevel\": 100,\n" + + " \"batteryHealth\": \"GOOD\",\n" + + " \"batteryDischargePrediction\": -1,\n" + + " \"batteryState\": \"UNPLUGGED\",\n" + + " \"totalInternalStorage\": 3087986688,\n" + + " \"freeInternalStorage\": 866512896,\n" + + " \"freeExternalStorage\": [\n" + + " 530092032,\n" + + " 75595776\n" + + " ],\n" + + " \"totalExternalStorage\": [\n" + + " 18224549888,\n" + + " 2438987776\n" + + " ],\n" + + " \"totalRamStorage\": 1922134016,\n" + + " \"freeRamStorage\": 519774208,\n" + + " \"dataState\": \"DATA_DISCONNECTED\",\n" + + " \"simStates\": [\n" + + " \"SIM_STATE_READY\",\n" + + " \"SIM_STATE_READY\"\n" + + " ],\n" + + " \"networkTransport\": \"WIFI\",\n" + + " \"androidUptimeMillis\": 450455711,\n" + + " \"androidElapsedRealtimeMillis\": 787550180,\n" + + " \"androidBootCount\": 980,\n" + + " \"language\": \"es_ES\",\n" + + " \"timeZoneOffsetInSeconds\": -14400,\n" + + " \"telephonyNetworkRegion\": \"VE\",\n" + + " \"simRegion\": \"ve\",\n" + + " \"appDataDir\": \"/data/user/0/io.muun.apollo\",\n" + + " \"vpnState\": 0,\n" + + " \"appImportance\": 230,\n" + + " \"displayMetrics\": {\n" + + " \"density\": 1.75,\n" + + " \"densityDpi\": 280,\n" + + " \"widthPixels\": 720,\n" + + " \"heightPixels\": 1422,\n" + + " \"xdpi\": 281.353,\n" + + " \"ydpi\": 283.028\n" + + " },\n" + + " \"usbConnected\": 0,\n" + + " \"usbPersistConfig\": \"mtp\",\n" + + " \"bridgeEnabled\": 0,\n" + + " \"bridgeDaemonStatus\": \"stopped\",\n" + + " \"developerEnabled\": 1,\n" + + " \"proxyHttp\": \"\",\n" + + " \"proxyHttps\": \"\",\n" + + " \"proxySocks\": \"\",\n" + + " \"autoDateTime\": 1,\n" + + " \"autoTimeZone\": 1,\n" + + " \"timeZoneId\": \"America/Caracas\",\n" + + " \"androidDateFormat\": \"d/M/yy\",\n" + + " \"regionCode\": \"ES\",\n" + + " \"androidCalendarIdentifier\": \"gregory\",\n" + + " \"androidMobileRxTraffic\": 0,\n" + + " \"androidSimOperatorId\": \"73404\",\n" + + " \"androidSimOperatorName\": \"Corporación Digitel\",\n" + + " \"androidMobileOperatorId\": \"73402\",\n" + + " \"mobileOperatorName\": \"DIGITEL\",\n" + + " \"androidMobileRoaming\": false,\n" + + " \"androidMobileDataStatus\": 0,\n" + + " \"androidMobileRadioType\": 1,\n" + + " \"androidMobileDataActivity\": 2,\n" + + " \"androidNetworkLink\": {\n" + + " \"interfaceName\": \"wlan0\",\n" + + " \"routesSize\": 3,\n" + + " \"routesInterfaces\": [\n" + + " \"wlan0\"\n" + + " ],\n" + + " \"hasGatewayRoute\": 1,\n" + + " \"dnsAddresses\": [\n" + + " \"192.168.0.1\"\n" + + " ],\n" + + " \"linkHttpProxyHost\": \"\"\n" + + " }\n" + + "}" + + private val expectedEncodedResponse = "{\n" + + " \"epochInMilliseconds\": 1734567549279,\n" + + " \"batteryLevel\": 92,\n" + + " \"maxBatteryLevel\": 100,\n" + + " \"batteryHealth\": \"GOOD\",\n" + + " \"batteryDischargePrediction\": -1,\n" + + " \"batteryState\": \"UNPLUGGED\",\n" + + " \"totalInternalStorage\": 3087986688,\n" + + " \"freeInternalStorage\": 866512896,\n" + + " \"freeExternalStorage\": [\n" + + " 530092032,\n" + + " 75595776\n" + + " ],\n" + + " \"totalExternalStorage\": [\n" + + " 18224549888,\n" + + " 2438987776\n" + + " ],\n" + + " \"totalRamStorage\": 1922134016,\n" + + " \"freeRamStorage\": 519774208,\n" + + " \"dataState\": \"DATA_DISCONNECTED\",\n" + + " \"simStates\": [\n" + + " \"SIM_STATE_READY\",\n" + + " \"SIM_STATE_READY\"\n" + + " ],\n" + + " \"networkTransport\": \"WIFI\",\n" + + " \"androidUptimeMillis\": 450455711,\n" + + " \"androidElapsedRealtimeMillis\": 787550180,\n" + + " \"androidBootCount\": 980,\n" + + " \"language\": \"es_ES\",\n" + + " \"timeZoneOffsetInSeconds\": -14400,\n" + + " \"telephonyNetworkRegion\": \"VE\",\n" + + " \"simRegion\": \"ve\",\n" + + " \"appDataDir\": \"/data/user/0/io.muun.apollo\",\n" + + " \"vpnState\": 0,\n" + + " \"appImportance\": 230,\n" + + " \"displayMetrics\": {\n" + + " \"density\": 1.75,\n" + + " \"densityDpi\": 280,\n" + + " \"widthPixels\": 720,\n" + + " \"heightPixels\": 1422,\n" + + " \"xdpi\": 281.353,\n" + + " \"ydpi\": 283.028\n" + + " },\n" + + " \"usbConnected\": 0,\n" + + " \"usbPersistConfig\": \"mtp\",\n" + + " \"bridgeEnabled\": 0,\n" + + " \"bridgeDaemonStatus\": \"stopped\",\n" + + " \"developerEnabled\": 1,\n" + + " \"proxyHttp\": \"\",\n" + + " \"proxyHttps\": \"\",\n" + + " \"proxySocks\": \"\",\n" + + " \"autoDateTime\": 1,\n" + + " \"autoTimeZone\": 1,\n" + + " \"timeZoneId\": \"America/Caracas\",\n" + + " \"androidDateFormat\": \"d/M/yy\",\n" + + " \"regionCode\": \"ES\",\n" + + " \"androidCalendarIdentifier\": \"gregory\",\n" + + " \"androidMobileRxTraffic\": 0,\n" + + " \"androidSimOperatorId\": \"73404\",\n" + + " \"androidSimOperatorName\": \"Corporaci\\u00f3n Digitel\",\n" + + " \"androidMobileOperatorId\": \"73402\",\n" + + " \"mobileOperatorName\": \"DIGITEL\",\n" + + " \"androidMobileRoaming\": false,\n" + + " \"androidMobileDataStatus\": 0,\n" + + " \"androidMobileRadioType\": 1,\n" + + " \"androidMobileDataActivity\": 2,\n" + + " \"androidNetworkLink\": {\n" + + " \"interfaceName\": \"wlan0\",\n" + + " \"routesSize\": 3,\n" + + " \"routesInterfaces\": [\n" + + " \"wlan0\"\n" + + " ],\n" + + " \"hasGatewayRoute\": 1,\n" + + " \"dnsAddresses\": [\n" + + " \"192.168.0.1\"\n" + + " ],\n" + + " \"linkHttpProxyHost\": \"\"\n" + + " }\n" + + "}" + + @Test + fun toSafeAsciiExtensionTest() { + val encodedLanguages = languages.toSafeAscii() + + Assert.assertEquals(encodedLanguages, expectedEncodedLanguages) + } + + @Test + fun realResponseToSafeAsciiExtensionTest() { + val encodedResponse = actualResponse.toSafeAscii() + + Assert.assertEquals(encodedResponse, expectedEncodedResponse) + } +} \ No newline at end of file diff --git a/android/apollo/src/test/java/io/muun/apollo/domain/action/PreloadFeeDataActionTest.kt b/android/apollo/src/test/java/io/muun/apollo/domain/action/PreloadFeeDataActionTest.kt index 42b062ed..2c28a01c 100644 --- a/android/apollo/src/test/java/io/muun/apollo/domain/action/PreloadFeeDataActionTest.kt +++ b/android/apollo/src/test/java/io/muun/apollo/domain/action/PreloadFeeDataActionTest.kt @@ -5,84 +5,37 @@ import io.mockk.mockk import io.mockk.verify import io.muun.apollo.BaseTest import io.muun.apollo.TestUtils -import io.muun.apollo.data.external.Gen -import io.muun.apollo.data.net.HoustonClient -import io.muun.apollo.data.preferences.FeeWindowRepository -import io.muun.apollo.data.preferences.MinFeeRateRepository -import io.muun.apollo.data.preferences.TransactionSizeRepository import io.muun.apollo.domain.action.realtime.PreloadFeeDataAction +import io.muun.apollo.domain.action.realtime.SyncRealTimeFees +import io.muun.apollo.domain.libwallet.FeeBumpRefreshPolicy import io.muun.apollo.domain.libwallet.LibwalletService -import io.muun.apollo.domain.model.MuunFeature -import io.muun.apollo.domain.model.RealTimeFees -import io.muun.apollo.domain.selector.FeatureSelector -import io.muun.common.model.SizeForAmount -import io.muun.common.model.UtxoStatus import org.junit.Before import org.junit.Test -import org.threeten.bp.ZonedDateTime import rx.Observable class PreloadFeeDataActionTest: BaseTest() { - private val feeWindowRepository = mockk(relaxed = true) - private val minFeeRateRepository = mockk(relaxed = true) - private val transactionSizeRepository = mockk(relaxed = true) - private val houstonClient = mockk() - private val featureSelector = mockk(relaxed = true) private val libwalletService = mockk(relaxed = true) + private val syncRealTimeFees = mockk(relaxed = true) private lateinit var preloadFeeData: PreloadFeeDataAction - val feeBumpFunctions = Gen.feeBumpFunctions() - val feeWindow = Gen.feeWindow() - val minFeeRateInWeightUnits = 0.25 - val minMempoolFeeRateInSatPerVbyte = minFeeRateInWeightUnits * 4 - val minFeeRateIncrementToReplaceByFeeInSatPerVbyte = 2.5 - - val realTimeFees = RealTimeFees( - feeBumpFunctions, - feeWindow, - minMempoolFeeRateInSatPerVbyte, - minFeeRateIncrementToReplaceByFeeInSatPerVbyte, - ZonedDateTime.now() - ) - - private val sizeProgressionWithUnconfirmedUtxos = SizeForAmount( - 1000L, - 240, - "default:0", - UtxoStatus.UNCONFIRMED, - 240, - "m/schema:1'/recovery:1'", - 1 - ) - @Before fun setUp() { - preloadFeeData = PreloadFeeDataAction( - houstonClient, - feeWindowRepository, - minFeeRateRepository, - transactionSizeRepository, - featureSelector, - libwalletService - ) + preloadFeeData = PreloadFeeDataAction(syncRealTimeFees, libwalletService) } @Test fun testRunTwiceShouldRunOneTimeBecauseOfThrottling() { - every { houstonClient.fetchRealTimeFees(any()) }.returns(Observable.just(realTimeFees)) - every { transactionSizeRepository.nextTransactionSize } - .returns(Gen.nextTransactionSize(sizeProgressionWithUnconfirmedUtxos)) - every { featureSelector.get(MuunFeature.EFFECTIVE_FEES_CALCULATION) }.returns(true) + every { syncRealTimeFees.sync(any()) } returns Observable.just(null) + every { syncRealTimeFees.shouldUpdateData() } returnsMany listOf(true, false) - TestUtils.fetchItemFromObservable(preloadFeeData.action()) + TestUtils.fetchItemFromObservable(preloadFeeData.action(FeeBumpRefreshPolicy.PERIODIC)) - TestUtils.fetchItemFromObservable(preloadFeeData.action()) + TestUtils.fetchItemFromObservable(preloadFeeData.action(FeeBumpRefreshPolicy.PERIODIC)) - verify(exactly = 1) { feeWindowRepository.store(feeWindow) } - verify(exactly = 1) { minFeeRateRepository.store(minFeeRateInWeightUnits) } - verify(exactly = 1) { libwalletService.persistFeeBumpFunctions(feeBumpFunctions) } + verify(exactly = 2) { syncRealTimeFees.shouldUpdateData() } + verify(exactly = 1) { syncRealTimeFees.sync(any()) } // TODO we should test throttling logic (e.g multiple calls in after a threshold should // run action multiple times) @@ -90,16 +43,11 @@ class PreloadFeeDataActionTest: BaseTest() { @Test fun testForceRunTwiceShouldCallServicesTwoTimes() { - every { houstonClient.fetchRealTimeFees(any()) }.returns(Observable.just(realTimeFees)) - every { transactionSizeRepository.nextTransactionSize } - .returns(Gen.nextTransactionSize(sizeProgressionWithUnconfirmedUtxos)) - every { featureSelector.get(MuunFeature.EFFECTIVE_FEES_CALCULATION) }.returns(true) + every { syncRealTimeFees.sync(any()) } returns Observable.just(null) - TestUtils.fetchItemFromObservable(preloadFeeData.runForced()) - TestUtils.fetchItemFromObservable(preloadFeeData.runForced()) + TestUtils.fetchItemFromObservable(preloadFeeData.runForced(FeeBumpRefreshPolicy.NTS_CHANGED)) + TestUtils.fetchItemFromObservable(preloadFeeData.runForced(FeeBumpRefreshPolicy.NTS_CHANGED)) - verify(exactly = 2) { feeWindowRepository.store(feeWindow) } - verify(exactly = 2) { minFeeRateRepository.store(minFeeRateInWeightUnits) } - verify(exactly = 2) { libwalletService.persistFeeBumpFunctions(feeBumpFunctions) } + verify(exactly = 2) { syncRealTimeFees.sync(any()) } } } \ No newline at end of file diff --git a/android/apollo/src/test/java/io/muun/apollo/domain/action/SyncRealTimeFeesTest.kt b/android/apollo/src/test/java/io/muun/apollo/domain/action/SyncRealTimeFeesTest.kt new file mode 100644 index 00000000..73d037d6 --- /dev/null +++ b/android/apollo/src/test/java/io/muun/apollo/domain/action/SyncRealTimeFeesTest.kt @@ -0,0 +1,109 @@ +package io.muun.apollo.domain.action + +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import io.muun.apollo.BaseTest +import io.muun.apollo.TestUtils +import io.muun.apollo.data.external.Gen +import io.muun.apollo.data.net.HoustonClient +import io.muun.apollo.data.preferences.FeeWindowRepository +import io.muun.apollo.data.preferences.MinFeeRateRepository +import io.muun.apollo.data.preferences.TransactionSizeRepository +import io.muun.apollo.domain.action.realtime.SyncRealTimeFees +import io.muun.apollo.domain.libwallet.FeeBumpRefreshPolicy +import io.muun.apollo.domain.libwallet.LibwalletService +import io.muun.apollo.domain.model.MuunFeature +import io.muun.apollo.domain.model.RealTimeFees +import io.muun.apollo.domain.selector.FeatureSelector +import io.muun.common.model.SizeForAmount +import io.muun.common.model.UtxoStatus +import junit.framework.TestCase.assertFalse +import junit.framework.TestCase.assertTrue +import org.junit.Before +import org.junit.Test +import org.threeten.bp.ZonedDateTime +import rx.Observable + +class SyncRealTimeFeesTest: BaseTest() { + + private val feeWindowRepository = mockk(relaxed = true) + private val minFeeRateRepository = mockk(relaxed = true) + private val transactionSizeRepository = mockk(relaxed = true) + private val houstonClient = mockk() + private val featureSelector = mockk(relaxed = true) + private val libwalletService = mockk(relaxed = true) + + private lateinit var syncRealTimeFees: SyncRealTimeFees + + val feeBumpFunctions = Gen.feeBumpFunctions() + val feeWindow = Gen.feeWindow() + val minFeeRateInWeightUnits = 0.25 + val minMempoolFeeRateInSatPerVbyte = minFeeRateInWeightUnits * 4 + val minFeeRateIncrementToReplaceByFeeInSatPerVbyte = 2.5 + + val realTimeFees = RealTimeFees( + feeBumpFunctions, + feeWindow, + minMempoolFeeRateInSatPerVbyte, + minFeeRateIncrementToReplaceByFeeInSatPerVbyte, + ZonedDateTime.now() + ) + + private val sizeProgressionWithUnconfirmedUtxos = SizeForAmount( + 1000L, + 240, + "default:0", + UtxoStatus.UNCONFIRMED, + 240, + "m/schema:1'/recovery:1'", + 1 + ) + + @Before + fun setUp() { + syncRealTimeFees = SyncRealTimeFees( + houstonClient, + feeWindowRepository, + minFeeRateRepository, + transactionSizeRepository, + featureSelector, + libwalletService + ) + } + + @Test + fun testSyncShouldUpdateRepositories() { + every { houstonClient.fetchRealTimeFees(any(), any()) } + .returns(Observable.just(realTimeFees)) + every { transactionSizeRepository.nextTransactionSize } + .returns(Gen.nextTransactionSize(sizeProgressionWithUnconfirmedUtxos)) + every { featureSelector.get(MuunFeature.EFFECTIVE_FEES_CALCULATION) } + .returns(true) + + TestUtils.fetchItemFromObservable(syncRealTimeFees.sync(FeeBumpRefreshPolicy.PERIODIC)) + + verify(exactly = 1) { feeWindowRepository.store(feeWindow) } + verify(exactly = 1) { minFeeRateRepository.store(minFeeRateInWeightUnits) } + verify(exactly = 1) { libwalletService.persistFeeBumpFunctions( + feeBumpFunctions, + FeeBumpRefreshPolicy.PERIODIC + ) } + } + + @Test + fun testShouldUpdateDataShouldReturnsFalseAfterASync() { + every { houstonClient.fetchRealTimeFees(any(), any()) } + .returns(Observable.just(realTimeFees)) + every { transactionSizeRepository.nextTransactionSize } + .returns(Gen.nextTransactionSize(sizeProgressionWithUnconfirmedUtxos)) + every { featureSelector.get(MuunFeature.EFFECTIVE_FEES_CALCULATION) } + .returns(true) + + assertTrue(syncRealTimeFees.shouldUpdateData()) + + TestUtils.fetchItemFromObservable(syncRealTimeFees.sync(FeeBumpRefreshPolicy.PERIODIC)) + + assertFalse(syncRealTimeFees.shouldUpdateData()) + } +} \ No newline at end of file diff --git a/android/apollo/src/test/java/io/muun/apollo/domain/action/incoming_swap/FulfillIncomingSwapActionTest.kt b/android/apollo/src/test/java/io/muun/apollo/domain/action/incoming_swap/FulfillIncomingSwapActionTest.kt index a5cf06fb..8b5c8f98 100644 --- a/android/apollo/src/test/java/io/muun/apollo/domain/action/incoming_swap/FulfillIncomingSwapActionTest.kt +++ b/android/apollo/src/test/java/io/muun/apollo/domain/action/incoming_swap/FulfillIncomingSwapActionTest.kt @@ -13,7 +13,10 @@ import io.muun.apollo.data.external.Gen import io.muun.apollo.data.external.Globals import io.muun.apollo.data.net.HoustonClient import io.muun.apollo.data.preferences.KeysRepository +import io.muun.apollo.data.preferences.TransactionSizeRepository +import io.muun.apollo.domain.libwallet.LibwalletService import io.muun.apollo.domain.libwallet.errors.UnfulfillableIncomingSwapError +import io.muun.apollo.domain.model.FulfillmentPushedResult import io.muun.apollo.domain.model.IncomingSwap import io.muun.apollo.domain.model.IncomingSwapFulfillmentData import io.muun.apollo.domain.model.Preimage @@ -39,6 +42,10 @@ class FulfillIncomingSwapActionTest : BaseTest() { private val incomingSwapDao = mockk(relaxed = true) + private val transactionSizeRepository = mockk(relaxed = true) + + private val libwalletService = mockk(relaxed = true) + private lateinit var action: FulfillIncomingSwapAction private val params = RegTestParams.get() @@ -70,7 +77,9 @@ class FulfillIncomingSwapActionTest : BaseTest() { operationDao, keysRepository, params, - incomingSwapDao + incomingSwapDao, + transactionSizeRepository, + libwalletService ) } @@ -126,6 +135,10 @@ class FulfillIncomingSwapActionTest : BaseTest() { "", 1 ) + val fulfillmentPushedResult = FulfillmentPushedResult( + Gen.nextTransactionSize(), + Gen.feeBumpFunctions() + ) every { operationDao.fetchByIncomingSwapUuid(swap.houstonUuid) @@ -147,12 +160,14 @@ class FulfillIncomingSwapActionTest : BaseTest() { every { houstonClient.pushFulfillmentTransaction(swap.houstonUuid, any()) } returns - Completable.complete() + Single.just(fulfillmentPushedResult) action.action(swap.houstonUuid).toBlocking().subscribe() verify(exactly = 0) { houstonClient.expireInvoice(swap.getPaymentHash()) } verify(exactly = 1) { houstonClient.pushFulfillmentTransaction(any(), any()) } + verify(exactly = 1) { transactionSizeRepository.setTransactionSize(any())} + verify(exactly = 1) { libwalletService.persistFeeBumpFunctions(any(), any())} } @Test diff --git a/android/apollo/src/test/java/io/muun/apollo/domain/sync/FeeDataSyncerTest.kt b/android/apollo/src/test/java/io/muun/apollo/domain/sync/FeeDataSyncerTest.kt index 14cb0ff1..b4a1c912 100644 --- a/android/apollo/src/test/java/io/muun/apollo/domain/sync/FeeDataSyncerTest.kt +++ b/android/apollo/src/test/java/io/muun/apollo/domain/sync/FeeDataSyncerTest.kt @@ -14,6 +14,7 @@ import io.muun.apollo.data.preferences.TransactionSizeRepository import io.muun.apollo.domain.action.NotificationActions import io.muun.apollo.domain.action.NotificationProcessingState import io.muun.apollo.domain.action.realtime.PreloadFeeDataAction +import io.muun.apollo.domain.libwallet.FeeBumpRefreshPolicy import io.muun.apollo.domain.model.MuunFeature import io.muun.apollo.domain.selector.FeatureSelector import io.muun.common.model.SizeForAmount @@ -94,11 +95,11 @@ class FeeDataSyncerTest: BaseTest() { feeDataSyncer.enterForeground() processingSubject.onNext(NotificationProcessingState.STARTED) - verify(exactly = 0) { preloadFeeData.runForced() } + verify(exactly = 0) { preloadFeeData.runForced(FeeBumpRefreshPolicy.NTS_CHANGED) } processingSubject.onNext(NotificationProcessingState.COMPLETED) Thread.sleep(200) - verify(exactly = 0) { preloadFeeData.runForced() } + verify(exactly = 0) { preloadFeeData.runForced(FeeBumpRefreshPolicy.NTS_CHANGED) } } @Test @@ -108,11 +109,11 @@ class FeeDataSyncerTest: BaseTest() { feeDataSyncer.enterForeground() processingSubject.onNext(NotificationProcessingState.STARTED) - verify(exactly = 0) { preloadFeeData.runForced() } + verify(exactly = 0) { preloadFeeData.runForced(FeeBumpRefreshPolicy.NTS_CHANGED) } processingSubject.onNext(NotificationProcessingState.COMPLETED) Thread.sleep(200) - verify(exactly = 0) { preloadFeeData.runForced() } + verify(exactly = 0) { preloadFeeData.runForced(FeeBumpRefreshPolicy.NTS_CHANGED) } } @Test @@ -122,13 +123,13 @@ class FeeDataSyncerTest: BaseTest() { feeDataSyncer.enterForeground() processingSubject.onNext(NotificationProcessingState.STARTED) - verify(exactly = 0) { preloadFeeData.runForced() } + verify(exactly = 0) { preloadFeeData.runForced(FeeBumpRefreshPolicy.NTS_CHANGED) } every { transactionSizeRepository.nextTransactionSize } .returns(Gen.nextTransactionSize(finalSizeProgressionWithUnconfirmedUtxos)) processingSubject.onNext(NotificationProcessingState.COMPLETED) Thread.sleep(200) - verify(exactly = 1) { preloadFeeData.runForced() } + verify(exactly = 1) { preloadFeeData.runForced(FeeBumpRefreshPolicy.NTS_CHANGED) } } @Test @@ -138,12 +139,12 @@ class FeeDataSyncerTest: BaseTest() { feeDataSyncer.enterForeground() processingSubject.onNext(NotificationProcessingState.STARTED) - verify(exactly = 0) { preloadFeeData.runForced() } + verify(exactly = 0) { preloadFeeData.runForced(FeeBumpRefreshPolicy.NTS_CHANGED) } every { transactionSizeRepository.nextTransactionSize } .returns(Gen.nextTransactionSize(initialSizeProgressionWithConfirmedUtxos)) processingSubject.onNext(NotificationProcessingState.COMPLETED) Thread.sleep(200) - verify(exactly = 0) { preloadFeeData.runForced() } + verify(exactly = 0) { preloadFeeData.runForced(FeeBumpRefreshPolicy.NTS_CHANGED) } } } \ No newline at end of file diff --git a/android/apolloui/build.gradle b/android/apolloui/build.gradle index 65fee3c4..c4bf5489 100644 --- a/android/apolloui/build.gradle +++ b/android/apolloui/build.gradle @@ -96,8 +96,8 @@ android { applicationId "io.muun.apollo" minSdk 19 targetSdk 34 - versionCode 1207 - versionName "52.7" + versionCode 1304 + versionName "53.4" // Needed to make sure these classes are available in the main DEX file for API 19 // See: https://spin.atomicobject.com/2018/07/16/support-kitkat-multidex/ @@ -146,6 +146,15 @@ android { shrinkResources true buildConfigField("boolean", "RELEASE", "true") + + splits { + abi { + enable true + reset() + include "x86", "x86_64", "armeabi-v7a", "arm64-v8a" + universalApk false + } + } } } @@ -433,10 +442,9 @@ dependencies { implementation "androidx.navigation:navigation-fragment:$nav_version" implementation "androidx.navigation:navigation-ui:$nav_version" - // 2. Feature module Support for Fragments - implementation "androidx.navigation:navigation-dynamic-features-fragment:$nav_version" + // (We don't use Feature module Support for Fragments) - // 3. Testing Navigation + // 2. Testing Navigation androidTestImplementation "androidx.navigation:navigation-testing:$nav_version" // Fancy animations @@ -451,6 +459,31 @@ task startStaging(type: Exec, dependsOn: 'assembleStaging') { commandLine "${rootProject.rootDir}/tools/run-apollo.sh", "staging" } +android.applicationVariants.configureEach { variant -> + variant.outputs.configureEach { output -> + def baseVersionCode = versionCode as int + def buildVersionSuffix = project.hasProperty('buildSuffix') ? project.buildSuffix : "000" + + if (!buildVersionSuffix.matches("\\d{3}")) { + throw new GradleException("buildSuffix must be a number with 3 digits: '$buildVersionSuffix'") + } + + def abiVersionCodeMap = ["x86": 2, "x86_64": 3, "armeabi-v7a": 4, "arm64-v8a": 5] + def abi = output.getFilter(com.android.build.OutputFile.ABI) + def abiCode = 0 + + if (abi != null) { + abiCode = abiVersionCodeMap.get(abi) ?: 0 + } + + output.versionCodeOverride = (baseVersionCode.toString() + buildVersionSuffix + abiCode.toString()) as int + + if (System.getenv("CI")) { + println "ABI: ${abi ?: 'universal'}, Final versionCode: ${output.versionCodeOverride}" + } + } +} + apply plugin: 'com.google.gms.google-services' // Google Services plugin // We should delete this as soon as we can. google services plugin introduces a dependency strict // checking that checks against other projects deps too. There's a conflict with diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/app/ApolloApplication.java b/android/apolloui/src/main/java/io/muun/apollo/presentation/app/ApolloApplication.java index 423e373b..3e3df150 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/app/ApolloApplication.java +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/app/ApolloApplication.java @@ -13,7 +13,6 @@ import io.muun.apollo.data.logging.LoggingContext; import io.muun.apollo.data.logging.MuunTree; import io.muun.apollo.data.preferences.FeaturesRepository; -import io.muun.apollo.data.preferences.UserRepository; import io.muun.apollo.data.preferences.migration.PreferencesMigrationManager; import io.muun.apollo.domain.ApplicationLockManager; import io.muun.apollo.domain.BackgroundTimesService; @@ -23,6 +22,7 @@ import io.muun.apollo.domain.action.session.DetectAppUpdateAction; import io.muun.apollo.domain.analytics.Analytics; import io.muun.apollo.domain.analytics.AnalyticsEvent; +import io.muun.apollo.domain.libwallet.FeeBumpRefreshPolicy; import io.muun.apollo.domain.libwallet.LibwalletBridge; import io.muun.apollo.domain.model.NightMode; import io.muun.apollo.domain.selector.BitcoinUnitSelector; @@ -311,10 +311,10 @@ public DataComponent getDataComponent() { if (dataComponent == null) { final DataModule dataModule = new DataModule( this, - (appContext, transformerFactory, repoRegistry) -> new NotificationServiceImpl( + (appContext, transformerFactory, userRepository) -> new NotificationServiceImpl( appContext, transformerFactory, - new BitcoinUnitSelector(new UserRepository(this, repoRegistry)) + new BitcoinUnitSelector(userRepository) ), AppStandbyBucketProviderImpl::new, new HoustonConfigImpl() @@ -354,7 +354,7 @@ public void onStart(@NonNull LifecycleOwner owner) { // app moved to foreground backgroundTimesService.enterForeground(); if (userActions.isLoggedIn()) { - preloadFeeData.run(); + preloadFeeData.run(FeeBumpRefreshPolicy.FOREGROUND); feeDataSyncer.enterForeground(); } } 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 b947ef3d..7cfbcfe5 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 @@ -30,6 +30,7 @@ import io.muun.apollo.domain.errors.newop.InvoiceMissingAmountException import io.muun.apollo.domain.errors.newop.NoPaymentRouteException import io.muun.apollo.domain.errors.newop.SwapFailedException import io.muun.apollo.domain.errors.newop.UnreachableNodeException +import io.muun.apollo.domain.libwallet.FeeBumpRefreshPolicy import io.muun.apollo.domain.libwallet.Invoice.parseInvoice import io.muun.apollo.domain.libwallet.toLibwallet import io.muun.apollo.domain.model.BitcoinAmount @@ -184,28 +185,34 @@ class NewOperationPresenter @Inject constructor( resolveOperationUri.state ) - CombineLatestAsyncAction(basicActions.getState(), preloadFeeData.state) - .getState() - .compose(handleStates(view::setLoading, this::handleError)) - .doOnNext { (basicActionsPair, _) -> - // Once we've updated exchange and fee rates, we can proceed with our preparations. - // This is especially important if we landed here after the user clicked an external - // link, since she skipped the home screen and didn't automatically fetch RTD. - - // Both realTimeData and realtimeFees we just call the actions for its side-effects - // (aka refreshing the data in local storage). - val paymentRequest = basicActionsPair!!.second - - // Note that RTD fetching is instantaneous if it was already up to date. - paymentContextSel.watch() - .first() - .doOnNext { newPayCtx: PaymentContext -> - onPaymentContextChanged(newPayCtx, paymentRequest) - } - .let(this::subscribeTo) + // This data is required to set the initial context (in Resolve State). + // To prevent bugs when going and coming back from background, + // subscribe only when necessary. + val isInResolveState = stateMachine.value() is ResolveState + if (isInResolveState) { + CombineLatestAsyncAction(basicActions.getState(), preloadFeeData.state) + .getState() + .compose(handleStates(view::setLoading, this::handleError)) + .doOnNext { (basicActionsPair, _) -> + // Once we've updated exchange and fee rates, we can proceed with our preparations. + // This is especially important if we landed here after the user clicked an external + // link, since she skipped the home screen and didn't automatically fetch RTD. + + // Both realTimeData and realtimeFees we just call the actions for its side-effects + // (aka refreshing the data in local storage). + val paymentRequest = basicActionsPair!!.second + + // Note that RTD fetching is instantaneous if it was already up to date. + paymentContextSel.watch() + .first() + .doOnNext { newPayCtx: PaymentContext -> + onPaymentContextChanged(newPayCtx, paymentRequest) + } + .let(this::subscribeTo) - } - .let(this::subscribeTo) + } + .let(this::subscribeTo) + } subscribeTo(stateMachine.asObservable()) { state -> onStateChanged(state) @@ -493,7 +500,7 @@ class NewOperationPresenter @Inject constructor( // (invalidated). // If the data is invalidated: make an API call to refresh it. // If the data is fresh: simply returns null to complete loading on this screen. - preloadFeeData.runIfDataIsInvalidated() + preloadFeeData.runIfDataIsInvalidated(FeeBumpRefreshPolicy.NEW_OP_BLOCKINGLY) // This is still needed because we need to: // - resolveLnInvoice for submarine swaps TODO mv this to libwallet @@ -719,6 +726,10 @@ class NewOperationPresenter @Inject constructor( val debtAmountInSat = stateVm.validated.swapInfo?.swapFees?.debtAmountInSat val outputAmountInSat = stateVm.validated.swapInfo?.swapFees?.outputAmountInSat val outputPaddingInSat = stateVm.validated.swapInfo?.swapFees?.outputPaddingInSat + val feeBumpSetUUID = stateVm.validated.feeBumpInfo?.setUUID + val feeBumpAmountInSat = stateVm.validated.feeBumpInfo?.amountInSat + val feeBumpPolicy = stateVm.validated.feeBumpInfo?.refreshPolicy + val feeBumpSecondsSinceLastUpdate = stateVm.validated.feeBumpInfo?.secondsSinceLastUpdate objects.add(("fee_type" to type.name.lowercase(Locale.getDefault()))) objects.add(("sats_per_virtual_byte" to feeRateInSatsPerVbyte)) @@ -734,6 +745,12 @@ class NewOperationPresenter @Inject constructor( 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() + ) // Also add previously known metadata objects.addAll(opStartedMetadata(paymentType)) diff --git a/common/src/main/java/io/muun/common/Supports.java b/common/src/main/java/io/muun/common/Supports.java index 6e810c24..cb8194ef 100644 --- a/common/src/main/java/io/muun/common/Supports.java +++ b/common/src/main/java/io/muun/common/Supports.java @@ -158,4 +158,14 @@ public interface OperationUpdatesNotificationsForFalcon { int APOLLO = 1; int FALCON = NOT_SUPPORTED; } + + public interface EffectiveFeesCalculation { + int APOLLO = 1300; + int FALCON = 1200; + } + + public interface SigningAlternativeTransactions { + int APOLLO = NOT_SUPPORTED; + int FALCON = NOT_SUPPORTED; + } } 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 8e1cf0ec..2b9c20ef 100644 --- a/common/src/main/java/io/muun/common/api/ClientJson.java +++ b/common/src/main/java/io/muun/common/api/ClientJson.java @@ -216,6 +216,12 @@ public class ClientJson { @Nullable public String androidEfsCreationTimeInSeconds; + @Nullable + public Boolean androidIsLowRamDevice; + + @Nullable + public Long androidFirstInstallTimeInMs; + /** * Json constructor. */ @@ -269,7 +275,9 @@ public ClientJson( @Nullable final Long androidAppSize, @Nullable final List androidHardwareAddresses, @Nullable final String androidVbMeta, - @Nullable final String androidEfsCreationTimeInSeconds + @Nullable final String androidEfsCreationTimeInSeconds, + @Nullable final Boolean androidIsLowRamDevice, + @Nullable final Long androidFirstInstallTimeInMs ) { this.type = type; this.buildType = buildType; @@ -316,5 +324,7 @@ public ClientJson( this.androidHardwareAddresses = androidHardwareAddresses; this.androidVbMeta = androidVbMeta; this.androidEfsCreationTimeInSeconds = androidEfsCreationTimeInSeconds; + this.androidIsLowRamDevice = androidIsLowRamDevice; + this.androidFirstInstallTimeInMs = androidFirstInstallTimeInMs; } } \ No newline at end of file diff --git a/common/src/main/java/io/muun/common/api/FeeBumpFunctionsJson.java b/common/src/main/java/io/muun/common/api/FeeBumpFunctionsJson.java new file mode 100644 index 00000000..69698384 --- /dev/null +++ b/common/src/main/java/io/muun/common/api/FeeBumpFunctionsJson.java @@ -0,0 +1,30 @@ +package io.muun.common.api; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import org.hibernate.validator.constraints.NotEmpty; + +import java.util.List; + +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public class FeeBumpFunctionsJson { + + @NotEmpty + public String uuid; + + @NotEmpty + public List functions; + + /** + * Json constructor. + */ + public FeeBumpFunctionsJson() { + } + + public FeeBumpFunctionsJson(String uuid, List functions) { + + this.uuid = uuid; + this.functions = functions; + } +} diff --git a/common/src/main/java/io/muun/common/api/FulfillmentPushedJson.java b/common/src/main/java/io/muun/common/api/FulfillmentPushedJson.java new file mode 100644 index 00000000..74ebaf59 --- /dev/null +++ b/common/src/main/java/io/muun/common/api/FulfillmentPushedJson.java @@ -0,0 +1,31 @@ +package io.muun.common.api; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; + +import javax.validation.constraints.NotNull; + +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public class FulfillmentPushedJson { + + @NotNull + public NextTransactionSizeJson nextTransactionSize; + + @NotNull + public FeeBumpFunctionsJson feeBumpFunctions; + + /** + * Json constructor. + */ + public FulfillmentPushedJson() { + } + + public FulfillmentPushedJson( + NextTransactionSizeJson nextTransactionSize, + FeeBumpFunctionsJson feeBumpFunctions + ) { + this.nextTransactionSize = nextTransactionSize; + this.feeBumpFunctions = feeBumpFunctions; + } +} diff --git a/common/src/main/java/io/muun/common/api/OperationCreatedJson.java b/common/src/main/java/io/muun/common/api/OperationCreatedJson.java index 798f1ebb..1d29382d 100644 --- a/common/src/main/java/io/muun/common/api/OperationCreatedJson.java +++ b/common/src/main/java/io/muun/common/api/OperationCreatedJson.java @@ -1,8 +1,12 @@ package io.muun.common.api; +import io.muun.common.Supports; +import io.muun.common.utils.Since; + import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; +import java.util.List; import javax.annotation.Nullable; import javax.validation.constraints.NotNull; @@ -17,6 +21,11 @@ public class OperationCreatedJson { @Nullable // null if the Operation was already fully signed public PartiallySignedTransactionJson partiallySignedTransaction; + @Since( + apolloVersion = Supports.SigningAlternativeTransactions.APOLLO, + falconVersion = Supports.SigningAlternativeTransactions.FALCON + ) + public List alternativeTransactions; /** * Deprecated. This remains from the time when utxos were "locked" after 'newOperation'. Since @@ -43,11 +52,13 @@ public OperationCreatedJson() { */ public OperationCreatedJson(OperationJson operation, @Nullable PartiallySignedTransactionJson partiallySignedTransaction, + List alternativeTransactions, NextTransactionSizeJson nextTransactionSize, @Nullable MuunAddressJson changeAddress) { this.operation = operation; this.partiallySignedTransaction = partiallySignedTransaction; + this.alternativeTransactions = alternativeTransactions; this.nextTransactionSize = nextTransactionSize; this.changeAddress = changeAddress; } diff --git a/common/src/main/java/io/muun/common/api/PushTransactionsJson.java b/common/src/main/java/io/muun/common/api/PushTransactionsJson.java new file mode 100644 index 00000000..9d9d0dfe --- /dev/null +++ b/common/src/main/java/io/muun/common/api/PushTransactionsJson.java @@ -0,0 +1,34 @@ +package io.muun.common.api; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; + +import java.util.List; +import javax.annotation.Nullable; + +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public class PushTransactionsJson { + + @Nullable + public RawTransaction rawTransaction; + + public List alternativeTransactions; + + /** + * Json constructor. + */ + public PushTransactionsJson() { + } + + /** + * Apollo constructor. + */ + public PushTransactionsJson( + @Nullable RawTransaction rawTransaction, + List alternativeTransactions + ) { + this.rawTransaction = rawTransaction; + this.alternativeTransactions = alternativeTransactions; + } +} diff --git a/common/src/main/java/io/muun/common/api/RealTimeFeesJson.java b/common/src/main/java/io/muun/common/api/RealTimeFeesJson.java index f6ec2f3f..a64bc2aa 100644 --- a/common/src/main/java/io/muun/common/api/RealTimeFeesJson.java +++ b/common/src/main/java/io/muun/common/api/RealTimeFeesJson.java @@ -5,7 +5,6 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; -import java.util.List; import javax.validation.constraints.NotNull; @JsonInclude(JsonInclude.Include.NON_NULL) @@ -15,10 +14,10 @@ public class RealTimeFeesJson { /** * Each fee bump function is codified as a base64 string. * The order of these functions is linked to the list of utxos' outpoints in realtime/fees API. - * TODO we should be able to link to UnconfirmedOutpointsJson (lives in clients module). + * (see {@link RealTimeFeesRequestJson}). */ @NotNull - public List feeBumpFunctions; + public FeeBumpFunctionsJson feeBumpFunctions; @NotNull public TargetFeeRatesJson targetFeeRates; @@ -42,7 +41,7 @@ public RealTimeFeesJson() { * All args constructor. */ public RealTimeFeesJson( - List feeBumpFunctions, + FeeBumpFunctionsJson feeBumpFunctions, TargetFeeRatesJson targetFeeRates, double minMempoolFeeRateInSatPerVbyte, double minFeeRateIncrementToReplaceByFeeInSatPerVbyte, diff --git a/common/src/main/java/io/muun/common/api/RealTimeFeesRequestJson.java b/common/src/main/java/io/muun/common/api/RealTimeFeesRequestJson.java new file mode 100644 index 00000000..5667ab0b --- /dev/null +++ b/common/src/main/java/io/muun/common/api/RealTimeFeesRequestJson.java @@ -0,0 +1,65 @@ +package io.muun.common.api; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; + +import java.util.List; +import javax.annotation.Nullable; +import javax.validation.constraints.NotNull; + +/** + * It contains the unconfirmed UTXOs that will be used to obtain the + * corresponding fee bump functions from realtime/fees API. The order + * passed here matches the order of the returned functions. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public class RealTimeFeesRequestJson { + + @NotNull + public List unconfirmedOutpoints; + + // Indicates the call point from mobile for which the fee bump functions should be updated. + @Nullable + public FeeBumpRefreshPolicy feeBumpRefreshPolicy; + + public RealTimeFeesRequestJson( + List unconfirmedOutpoints, + FeeBumpRefreshPolicy refreshPolicy + ) { + this.unconfirmedOutpoints = unconfirmedOutpoints; + this.feeBumpRefreshPolicy = refreshPolicy; + } + + /** + * JSON constructor. + */ + public RealTimeFeesRequestJson() { + } + + public enum FeeBumpRefreshPolicy { + FOREGROUND("foreground"), + PERIODIC("periodic"), + NEW_OPERATION("newOpBlockingly"), + CHANGED_NEXT_TRANSACTION_SIZE("changedNts"), + UNKNOWN("unknown"); + + private final String mobileName; + + FeeBumpRefreshPolicy(String mobileName) { + this.mobileName = mobileName; + } + + @JsonCreator + public static FeeBumpRefreshPolicy fromValue(String value) { + for (FeeBumpRefreshPolicy refreshPolicy : values()) { + if (refreshPolicy.mobileName.equals(value)) { + return refreshPolicy; + } + } + return UNKNOWN; + + } + } +} diff --git a/common/src/main/java/io/muun/common/api/ReplaceByFeeJson.java b/common/src/main/java/io/muun/common/api/ReplaceByFeeJson.java index e4074b4d..b27bf275 100644 --- a/common/src/main/java/io/muun/common/api/ReplaceByFeeJson.java +++ b/common/src/main/java/io/muun/common/api/ReplaceByFeeJson.java @@ -35,9 +35,13 @@ public ReplaceByFeeJson() { /** * Constructor. */ - public ReplaceByFeeJson(BitcoinAmountJson fee, - List outpoints) { + public ReplaceByFeeJson( + BitcoinAmountJson fee, + List outpoints, + List userPublicNoncesHex + ) { this.fee = fee; this.outpoints = outpoints; + this.userPublicNoncesHex = userPublicNoncesHex; } } diff --git a/common/src/main/java/io/muun/common/api/SubmarineSwapJson.java b/common/src/main/java/io/muun/common/api/SubmarineSwapJson.java index cd7a17b0..14338117 100644 --- a/common/src/main/java/io/muun/common/api/SubmarineSwapJson.java +++ b/common/src/main/java/io/muun/common/api/SubmarineSwapJson.java @@ -3,6 +3,7 @@ import io.muun.common.Supports; import io.muun.common.dates.MuunZonedDateTime; import io.muun.common.utils.Deprecated; +import io.muun.common.utils.Since; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; @@ -59,6 +60,14 @@ public class SubmarineSwapJson { @Nullable // Present if the invoice didn't have an amount public SubmarineSwapFundingOutputPoliciesJson fundingOutputPolicies; + // Only present when creating a swap + @Since( + apolloVersion = Supports.SigningAlternativeTransactions.APOLLO, + falconVersion = Supports.SigningAlternativeTransactions.FALCON + ) + @Nullable + public Integer maxAlternativeTransactionCount; + /** * Json constructor. */ @@ -87,7 +96,8 @@ public SubmarineSwapJson(String swapUuid, payedAt, preimageInHex, null, - null + null, + 0 ); } @@ -105,7 +115,9 @@ public SubmarineSwapJson( @Nullable MuunZonedDateTime payedAt, @Nullable String preimageInHex, @Nullable List bestRouteFees, - @Nullable SubmarineSwapFundingOutputPoliciesJson fundingOutputPolicies) { + @Nullable SubmarineSwapFundingOutputPoliciesJson fundingOutputPolicies, + int maxAlternativeTransactionCount + ) { this.swapUuid = swapUuid; this.invoice = invoice; @@ -130,5 +142,6 @@ public SubmarineSwapJson( this.bestRouteFees = bestRouteFees; this.fundingOutputPolicies = fundingOutputPolicies; + this.maxAlternativeTransactionCount = maxAlternativeTransactionCount; } } diff --git a/common/src/main/java/io/muun/common/api/TransactionPushedJson.java b/common/src/main/java/io/muun/common/api/TransactionPushedJson.java index c81f8364..272dedee 100644 --- a/common/src/main/java/io/muun/common/api/TransactionPushedJson.java +++ b/common/src/main/java/io/muun/common/api/TransactionPushedJson.java @@ -24,6 +24,9 @@ public class TransactionPushedJson { @Nullable // Null if the broadcast didn't failed. public BroadcastErrorJson broadcastErrorCode; + @NotNull + public FeeBumpFunctionsJson feeBumpFunctions; + /** * Json constructor. */ @@ -37,11 +40,14 @@ public TransactionPushedJson( @Nullable String hex, NextTransactionSizeJson nextTransactionSize, OperationJson operation, - @Nullable BroadcastErrorJson broadcastErrorCode) { + @Nullable BroadcastErrorJson broadcastErrorCode, + FeeBumpFunctionsJson feeBumpFunctions + ) { this.hex = hex; this.nextTransactionSize = nextTransactionSize; this.updatedOperation = operation; this.broadcastErrorCode = broadcastErrorCode; + this.feeBumpFunctions = feeBumpFunctions; } } diff --git a/common/src/main/java/io/muun/common/api/UnconfirmedOutpointsJson.java b/common/src/main/java/io/muun/common/api/UnconfirmedOutpointsJson.java deleted file mode 100644 index 70d2b6b2..00000000 --- a/common/src/main/java/io/muun/common/api/UnconfirmedOutpointsJson.java +++ /dev/null @@ -1,33 +0,0 @@ -package io.muun.common.api; - -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import com.fasterxml.jackson.annotation.JsonInclude; - -import java.util.List; -import javax.validation.constraints.NotNull; - -/** - * It contains the unconfirmed UTXOs that will be used to obtain the - * corresponding fee bump functions from realtime/fees API. The order - * passed here matches the order of the returned functions. - */ -@JsonInclude(JsonInclude.Include.NON_NULL) -@JsonIgnoreProperties(ignoreUnknown = true) -public class UnconfirmedOutpointsJson { - - @NotNull - public List unconfirmedOutpoints; - - public UnconfirmedOutpointsJson(List unconfirmedOutpoints) { - this.unconfirmedOutpoints = unconfirmedOutpoints; - } - - /** - * Return the list of unconfirmed outpoints separated by ','. - */ - @Override - public String toString() { - return String.join(", ", unconfirmedOutpoints); - } - -} diff --git a/common/src/main/java/io/muun/common/api/houston/HoustonService.java b/common/src/main/java/io/muun/common/api/houston/HoustonService.java index 2671703f..f724d7c3 100644 --- a/common/src/main/java/io/muun/common/api/houston/HoustonService.java +++ b/common/src/main/java/io/muun/common/api/houston/HoustonService.java @@ -18,6 +18,7 @@ import io.muun.common.api.ExportEmergencyKitJson; import io.muun.common.api.ExternalAddressesRecord; import io.muun.common.api.FeedbackJson; +import io.muun.common.api.FulfillmentPushedJson; import io.muun.common.api.IncomingSwapFulfillmentDataJson; import io.muun.common.api.IntegrityCheck; import io.muun.common.api.IntegrityStatus; @@ -36,15 +37,16 @@ import io.muun.common.api.PreimageJson; import io.muun.common.api.PublicKeySetJson; import io.muun.common.api.PublicProfileJson; +import io.muun.common.api.PushTransactionsJson; import io.muun.common.api.RawTransaction; import io.muun.common.api.RealTimeData; import io.muun.common.api.RealTimeFeesJson; +import io.muun.common.api.RealTimeFeesRequestJson; import io.muun.common.api.SetupChallengeResponse; import io.muun.common.api.StartEmailSetupJson; import io.muun.common.api.SubmarineSwapJson; import io.muun.common.api.SubmarineSwapRequestJson; import io.muun.common.api.TransactionPushedJson; -import io.muun.common.api.UnconfirmedOutpointsJson; import io.muun.common.api.UpdateOperationMetadataJson; import io.muun.common.api.UserInvoiceJson; import io.muun.common.api.UserJson; @@ -244,7 +246,7 @@ Observable finishPasswordChange( @POST("realtime/fees") Observable fetchRealTimeFees( - @Body UnconfirmedOutpointsJson unconfirmedOutpoints + @Body RealTimeFeesRequestJson unconfirmedOutpoints ); @GET("operations") @@ -259,14 +261,11 @@ Observable fetchRealTimeFees( Completable updateOperationMetadata(@Path("operationId") Long operationId, @Body UpdateOperationMetadataJson data); - @PUT("operations/{operationId}/raw-transaction") - Observable pushTransaction(@Path("operationId") Long operationId); - @NetworkRetry(count = 0) // No retries. Avoid Musig nonces reuse! @ServerRetry(count = 0) // No retries. Avoid Musig nonces reuse! - @PUT("operations/{operationId}/raw-transaction") - Observable pushTransaction(@Body RawTransaction rawTransaction, - @Path("operationId") Long operationId); + @PUT("operations/{operationId}/raw-transactions") + Observable pushTransactions(@Body PushTransactionsJson pushTransactions, + @Path("operationId") Long operationId); @GET("operations/next-transaction-size") Observable fetchNextTransactionSize(); @@ -285,7 +284,7 @@ Single fetchFulfillmentData( @Path("incomingSwapUuid") String incomingSwapUuid); @PUT("incoming-swaps/{incomingSwapUuid}/fulfillment") - Completable pushFulfillmentTransaction( + Single pushFulfillmentTransaction( @Path("incomingSwapUuid") String incomingSwapUuid, @Body RawTransaction tx); diff --git a/common/src/main/java/io/muun/common/crypto/hd/PrivateKey.java b/common/src/main/java/io/muun/common/crypto/hd/PrivateKey.java index a46fa945..b744c345 100644 --- a/common/src/main/java/io/muun/common/crypto/hd/PrivateKey.java +++ b/common/src/main/java/io/muun/common/crypto/hd/PrivateKey.java @@ -75,6 +75,14 @@ public static PrivateKey getNewRootPrivateKey(@NotNull Context bitcoinContext) { return new PrivateKey("m", deterministicKey, bitcoinContext.getParams()); } + public static PrivateKey fromMasterPrivateKey( + @NotNull DeterministicKey masterKey, + @NotNull NetworkParameters parameters + ) { + + return new PrivateKey("m", masterKey, parameters); + } + /** * Deserialize a base58-encoded extended private key. */ diff --git a/libwallet/encodings.go b/libwallet/encodings.go deleted file mode 100644 index 06a1f791..00000000 --- a/libwallet/encodings.go +++ /dev/null @@ -1,16 +0,0 @@ -package libwallet - -import ( - "math/big" -) - -func paddedSerializeBigInt(size uint, x *big.Int) []byte { - src := x.Bytes() - dst := make([]byte, 0, size) - - for i := 0; i < int(size)-len(src); i++ { - dst = append(dst, 0) - } - - return append(dst, src...) -} diff --git a/libwallet/encrypt.go b/libwallet/encrypt.go index 2a4ccbc4..da7a4f01 100644 --- a/libwallet/encrypt.go +++ b/libwallet/encrypt.go @@ -1,37 +1,16 @@ package libwallet import ( - "bytes" "crypto/aes" - "crypto/cipher" "crypto/rand" - "crypto/sha256" - "encoding/binary" - "errors" "fmt" - "io" - "log/slog" - "math" - "math/big" - - "github.com/muun/libwallet/aescbc" "github.com/btcsuite/btcd/btcec/v2" - "github.com/btcsuite/btcd/btcec/v2/ecdsa" - "github.com/btcsuite/btcd/btcutil/base58" + "github.com/muun/libwallet/aescbc" + "github.com/muun/libwallet/encryption" ) const serializedPublicKeyLength = btcec.PubKeyBytesLenCompressed -const PKEncryptionVersion = 1 - -// maxDerivationPathLen is a safety limit to avoid stupid size allocations -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. -const minNonceLen = 12 type Encrypter interface { // Encrypt the payload and return a string with the necesary information for decryption @@ -43,298 +22,24 @@ type Decrypter interface { Decrypt(payload string) ([]byte, error) } -type hdPubKeyEncrypter struct { - receiverKey *HDPublicKey - senderKey *HDPrivateKey -} - -func addVariableBytes(writer io.Writer, data []byte) error { - if len(data) > math.MaxUint16 { - return fmt.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) - } - - n, err := writer.Write(data) - if err != nil || n != len(data) { - return errors.New("failed to write var bytes") - } - - return nil -} - -func (e *hdPubKeyEncrypter) Encrypt(payload []byte) (string, error) { - // 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. - // - // Conceptually, what we do is: - // 1. Sign the payload using the senders private key so the receiver can check it's authentic - // The signature also covers the receivers public key to avoid payload reuse by the intermediary - // 2. Establish an encryption key via ECDH given the receivers pub key - // 3. Encrypt the payload and signature using AES with a new random nonce - // 4. Add the metadata the receiver will need to decode the message: - // * 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 - // 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. - - signingKey, err := e.senderKey.key.ECPrivKey() - if err != nil { - return "", fmt.Errorf("Encrypt: failed to extract signing key: %w", err) - } - - encryptionKey, err := e.receiverKey.key.ECPubKey() - if err != nil { - return "", fmt.Errorf("Encrypt: failed to extract pub key: %w", err) - } - - // Sign "payload || encryptionKey" to protect against payload reuse by 3rd parties - signaturePayload := make([]byte, 0, len(payload)+serializedPublicKeyLength) - signaturePayload = append(signaturePayload, payload...) - signaturePayload = append(signaturePayload, encryptionKey.SerializeCompressed()...) - hash := sha256.Sum256(signaturePayload) - senderSignature, err := ecdsa.SignCompact(signingKey, hash[:], false) - if err != nil { - return "", fmt.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("Encrypter: failed to add senderSignature: %w", err) - } - - err = addVariableBytes(plaintext, payload) - if err != nil { - return "", fmt.Errorf("Encrypter: 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) - } - - blockCipher, err := aes.NewCipher(sharedSecret) - if err != nil { - return "", fmt.Errorf("Encrypt: new aes failed: %w", err) - } - - gcm, err := cipher.NewGCM(blockCipher) - if err != nil { - return "", fmt.Errorf("Encrypt: new gcm failed: %w", err) - } - - nonce := randomBytes(gcm.NonceSize()) - - // additionalData is "version || pubEph || receiverKeyPath || nonceLen" - additionalDataLen := 1 + serializedPublicKeyLength + 2 + len(e.receiverKey.Path) + 2 - result := bytes.NewBuffer(make([]byte, 0, additionalDataLen)) - result.WriteByte(PKEncryptionVersion) - result.Write(pubEph.SerializeCompressed()) - - err = addVariableBytes(result, []byte(e.receiverKey.Path)) - if err != nil { - return "", fmt.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) - } - - ciphertext := gcm.Seal(nil, nonce, plaintext.Bytes(), result.Bytes()) - - // result is "additionalData || nonce || ciphertext" - n, err := result.Write(nonce) - if err != nil || n != len(nonce) { - return "", errors.New("Encrypt: failed to add nonce") - } - - n, err = result.Write(ciphertext) - if err != nil || n != len(ciphertext) { - return "", errors.New("Encrypt: failed to add ciphertext") - } - - return base58.Encode(result.Bytes()), nil -} - -// hdPrivKeyDecrypter holds the keys for validation and decryption of messages using Muun's scheme -type hdPrivKeyDecrypter struct { - receiverKey *HDPrivateKey - - // 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. - senderKey *PublicKey - - // fromSelf is true if this message is from yourself - fromSelf bool -} - -func extractVariableBytes(reader *bytes.Reader, limit int) ([]byte, error) { - var len uint16 - err := binary.Read(reader, binary.BigEndian, &len) - if err != nil || int(len) > limit || int(len) > reader.Len() { - return nil, errors.New("failed to read byte array len") - } - - result := make([]byte, len) - n, err := reader.Read(result) - if err != nil || n != int(len) { - return nil, errors.New("failed to extract byte array") - } - - return result, nil -} - -func extractVariableString(reader *bytes.Reader, limit int) (string, error) { - bytes, err := extractVariableBytes(reader, limit) - return string(bytes), err -} - -func (d *hdPrivKeyDecrypter) Decrypt(payload string) ([]byte, error) { - // 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) - - decoded := base58.Decode(payload) - reader := bytes.NewReader(decoded) - version, err := reader.ReadByte() - if err != nil { - return nil, fmt.Errorf("Decrypt: failed to read version byte: %w", err) - } - if version != PKEncryptionVersion { - return nil, fmt.Errorf("Decrypt: found key version %v, expected %v", - version, PKEncryptionVersion) - } - - rawPubEph := make([]byte, serializedPublicKeyLength) - n, err := reader.Read(rawPubEph) - if err != nil || n != serializedPublicKeyLength { - return nil, errors.New("Decrypt: failed to read pubeph") - } - - receiverPath, err := extractVariableString(reader, maxDerivationPathLen) - if err != nil { - return nil, fmt.Errorf("Decrypt: failed to extract receiver path: %w", err) - } - - // additionalDataSize is Whatever I've read so far plus two bytes for the nonce len - additionalDataSize := len(decoded) - reader.Len() + 2 - - minCiphertextLen := 2 // an empty sig with no plaintext - nonce, err := extractVariableBytes(reader, reader.Len()-minCiphertextLen) - if err != nil || len(nonce) < minNonceLen { - return nil, errors.New("Decrypt: failed to read nonce") - } - - // What's left is the ciphertext - ciphertext := make([]byte, reader.Len()) - _, err = reader.Read(ciphertext) - if err != nil { - return nil, fmt.Errorf("Decrypt: failed to read ciphertext: %w", err) - } - - receiverKey, err := d.receiverKey.DeriveTo(receiverPath) - if err != nil { - return nil, fmt.Errorf("Decrypt: failed to derive receiver key to path %v: %w", receiverPath, err) - } - - encryptionKey, err := receiverKey.key.ECPrivKey() - if err != nil { - return nil, fmt.Errorf("Decrypt: failed to extract encryption key: %w", err) - } - - var verificationKey *btcec.PublicKey - if d.fromSelf { - // Use the derived receiver key if the sender key is not provided - verificationKey, err = receiverKey.PublicKey().key.ECPubKey() - if err != nil { - return nil, fmt.Errorf("Decrypt: failed to extract verification key: %w", err) - } - } else if d.senderKey != nil { - verificationKey = d.senderKey.key - } - - sharedSecret, err := recoverSharedEncryptionSecretForAES(encryptionKey, rawPubEph) - if err != nil { - return nil, fmt.Errorf("Decrypt: failed to recover shared secret: %w", err) - } - - blockCipher, err := aes.NewCipher(sharedSecret) - if err != nil { - return nil, fmt.Errorf("Decrypt: new aes failed: %w", err) - } - - gcm, err := cipher.NewGCMWithNonceSize(blockCipher, len(nonce)) - if err != nil { - return nil, fmt.Errorf("Decrypt: new gcm failed: %w", err) - } - - plaintext, err := gcm.Open(nil, nonce, ciphertext, decoded[:additionalDataSize]) - if err != nil { - return nil, fmt.Errorf("Decrypt: AEAD failed: %w", err) - } - - plaintextReader := bytes.NewReader(plaintext) - - sig, err := extractVariableBytes(plaintextReader, maxSignatureLen) - if err != nil { - return nil, fmt.Errorf("Decrypt: failed to read sig: %w", err) - } - - data, err := extractVariableBytes(plaintextReader, plaintextReader.Len()) - if err != nil { - return nil, fmt.Errorf("Decrypt: failed to extract user data: %w", err) - } - - signatureData := make([]byte, 0, len(sig)+serializedPublicKeyLength) - signatureData = append(signatureData, data...) - signatureData = append(signatureData, encryptionKey.PubKey().SerializeCompressed()...) - hash := sha256.Sum256(signatureData) - signatureKey, _, err := ecdsa.RecoverCompact(sig, hash[:]) - if err != nil { - return nil, fmt.Errorf("Decrypt: failed to verify signature: %w", err) - } - if verificationKey != nil && !signatureKey.IsEqual(verificationKey) { - return nil, errors.New("Decrypt: signing key mismatch") - } - - return data, nil -} - // Assert hdPubKeyEncrypter fulfills Encrypter interface -var _ Encrypter = (*hdPubKeyEncrypter)(nil) +var _ Encrypter = (*encryption.HdPubKeyEncrypter)(nil) // Assert hdPrivKeyDecrypter fulfills Decrypter interface -var _ Decrypter = (*hdPrivKeyDecrypter)(nil) +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) { // Use deprecated ECDH for compat - pubEph, sharedSecret, err := generateSharedEncryptionSecret(pubKey) + pubEph, sharedSecret, err := encryption.GenerateSharedEncryptionSecret(pubKey) if err != nil { return nil, nil, err } serializedPubkey := pubEph.SerializeCompressed() iv := serializedPubkey[len(serializedPubkey)-aes.BlockSize:] - ciphertext, err := aescbc.EncryptNoPadding(paddedSerializeBigInt(aescbc.KeySize, sharedSecret), iv, plaintext) + ciphertext, err := aescbc.EncryptNoPadding(sharedSecret, iv, plaintext) if err != nil { return nil, nil, fmt.Errorf("encryptWithPubKey: encrypt failed: %w", err) } @@ -342,42 +47,18 @@ func encryptWithPubKey(pubKey *btcec.PublicKey, plaintext []byte) (*btcec.Public return pubEph, ciphertext, nil } -// generateSharedEncryptionSecret performs a ECDH with pubKey -// Deprecated: this function is unsafe and generateSharedEncryptionSecretForAES should be used -func generateSharedEncryptionSecret(pubKey *btcec.PublicKey) (*btcec.PublicKey, *big.Int, error) { - privEph, err := btcec.NewPrivateKey() - if err != nil { - return nil, nil, fmt.Errorf("generateSharedEncryptionSecretForAES: failed to generate key: %w", err) - } - - sharedSecret, _ := btcec.S256().ScalarMult(pubKey.X(), pubKey.Y(), privEph.ToECDSA().D.Bytes()) - - return privEph.PubKey(), sharedSecret, nil -} - -// generateSharedEncryptionSecret performs a ECDH with pubKey and produces a secret usable with AES -func generateSharedEncryptionSecretForAES(pubKey *btcec.PublicKey) (*btcec.PublicKey, []byte, error) { - privEph, sharedSecret, err := generateSharedEncryptionSecret(pubKey) - if err != nil { - return nil, nil, err - } - - hash := sha256.Sum256(paddedSerializeBigInt(aescbc.KeySize, sharedSecret)) - return privEph, hash[:], nil -} - // 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) { // Use deprecated ECDH for compat - sharedSecret, err := recoverSharedEncryptionSecret(privKey, rawPubEph) + sharedSecret, err := encryption.RecoverSharedEncryptionSecret(privKey, rawPubEph) if err != nil { return nil, err } iv := rawPubEph[len(rawPubEph)-aes.BlockSize:] - plaintext, err := aescbc.DecryptNoPadding(paddedSerializeBigInt(aescbc.KeySize, sharedSecret), iv, ciphertext) + plaintext, err := aescbc.DecryptNoPadding(sharedSecret, iv, ciphertext) if err != nil { return nil, fmt.Errorf("decryptWithPrivKey: failed to decrypt: %w", err) } @@ -385,28 +66,6 @@ func decryptWithPrivKey(privKey *btcec.PrivateKey, rawPubEph []byte, ciphertext return plaintext, 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 -func recoverSharedEncryptionSecret(privKey *btcec.PrivateKey, rawPubEph []byte) (*big.Int, error) { - pubEph, err := btcec.ParsePubKey(rawPubEph) - if err != nil { - return nil, fmt.Errorf("recoverSharedEncryptionSecretForAES: failed to parse pub eph: %w", err) - } - - sharedSecret, _ := btcec.S256().ScalarMult(pubEph.X(), pubEph.Y(), privKey.ToECDSA().D.Bytes()) - return sharedSecret, nil -} - -func recoverSharedEncryptionSecretForAES(privKey *btcec.PrivateKey, rawPubEph []byte) ([]byte, error) { - sharedSecret, err := recoverSharedEncryptionSecret(privKey, rawPubEph) - if err != nil { - return nil, err - } - - hash := sha256.Sum256(paddedSerializeBigInt(aescbc.KeySize, sharedSecret)) - return hash[:], nil -} - func randomBytes(count int) []byte { buf := make([]byte, count) _, err := rand.Read(buf) diff --git a/libwallet/encrypt_test.go b/libwallet/encrypt_test.go index 56f437d3..6bff5bd2 100644 --- a/libwallet/encrypt_test.go +++ b/libwallet/encrypt_test.go @@ -2,15 +2,9 @@ package libwallet import ( "bytes" - "crypto/aes" - "crypto/cipher" - "crypto/sha256" + "encoding/base64" "encoding/hex" - "strings" "testing" - - "github.com/btcsuite/btcd/btcec/v2/ecdsa" - "github.com/btcsuite/btcd/btcutil/base58" ) func TestPublicKeyEncryption(t *testing.T) { @@ -77,36 +71,7 @@ func TestPublicKeyEncryption(t *testing.T) { } } -func TestPublicKeyEncryptionV1(t *testing.T) { - const ( - priv = "xprv9s21ZrQH143K2DAjx7FiAo2GQAQ5g7GrPYkTB2RaCd2Ei5ZH7f9cbREHiZTCc1FPn9HKuviUHk8sf5cW3dhYjz6W6XPjXNHu5mLpT5oRH1j" - ciphertext = "AMWm2L3YjA7myBTQQgiZi9F5g1NzaaupkPq1y7csUkf7WLXwnPYjkmy5KjVkyTKjaSXPwjx2zmX9Augzwwh89AsWYTv7KfJTXTj3Lx2mNZgmxJ7eezaJyRHv4koQaEmRykSoVE4esjWK779Sac28kCstkqDMPDYeNud5H4ApetF4BvhvPJyMaVn4RHYSAGzBzMcBV7WxYoRveKHqU9LbAfhCndPtRSVZyTVXY8iE3EvQJFeZVyYdovPK67aHsXWRdi8QCinMQSG21TMmhs7GQAh6iB26X2ABcVFJRGeEKE2coAsfuAHzcAMZ3CdzGgVAm7rrQw13W3XpxwwjWVatH9Jm9H4TrnnnLxRCsBoSKDvA1hmH8a2UG9iMxkhsBVMPzNRMy4Bg4MHk8WyRo3bwCLSVJUFFEciQ3mUneHprezzbVZio" - plaintextHex = "ca4dabb05a47d3ab306c1fad895d97b06dc30564191e610f9b254b1a1d0a536b6eca2b83d0d17d67aaad2a958fe6a6557ad5b26f44e12e7662f47a4e4fd6f482b68a83cd140ad4ded43b90a2c2cf349af84d828b1f961901616b4c4cb01f761bd277ad0d3d90506065aef76b930a962fcb90f2f009898c0d55cd07b5e01c355a9067937185fa9237d03e5ed4243e1bf0f8a959c72a83cbb1729b679cbd660052dd2dd3096b0f19e9275ac459b94d02a95642" - ) - - privKey, _ := NewHDPrivateKeyFromString(priv, "m", Mainnet()) - plaintext, _ := hex.DecodeString(plaintextHex) - - decrypted, err := privKey.Decrypter().Decrypt(ciphertext) - if err != nil { - t.Fatal(err) - } - - if !bytes.Equal(plaintext, decrypted) { - t.Fatalf("decrypted payload differed from original\ndecrypted %v\noriginal %v", - hex.EncodeToString(decrypted), - hex.EncodeToString(plaintext)) - } - - _, err = privKey.Encrypter().Encrypt(decrypted) - if err != nil { - t.Fatal(err) - } - - // Since we use a random key every time, comparing cipher texts makes no sense -} - -func TestPublicKeyDecryptV1(t *testing.T) { +func TestEncDecOps(t *testing.T) { const ( privHex = "xprv9s21ZrQH143K36uECEJcmTnxSXfHjT9jdb7FpMoUJpENDxeRgpscDF3g2w4ySH6G9uVsGKK7e6WgGp7Vc9VVnwC2oWdrr7a3taWiKW8jKnD" @@ -116,137 +81,37 @@ func TestPublicKeyDecryptV1(t *testing.T) { payload := []byte("Asado Viernes") privKey, _ := NewHDPrivateKeyFromString(privHex, path, Mainnet()) - encrypted, _ := privKey.Encrypter().Encrypt(payload) + encrypted, _ := NewEncryptOperation(privKey, payload).Encrypt() - decrypted, err := privKey.Decrypter().Decrypt(encrypted) + decrypted, err := NewDecryptOperation(privKey, encrypted).Decrypt() if err != nil { t.Fatal(err) } if !bytes.Equal(payload, decrypted) { - t.Fatalf("decrypted payload differed from original\ndecrypted %v\noriginal %v", - string(decrypted), - string(payload)) - } - - alterAndCheck := func(msg string, alter func(data []byte)) { - t.Run(msg, func(t *testing.T) { - encryptedData := base58.Decode(encrypted) - alter(encryptedData) - - _, err = privKey.Decrypter().Decrypt(base58.Encode(encryptedData)) - if err == nil { - t.Fatalf("Got nil error for altered payload: %v", msg) - } - - t.Logf("Got error for altered payload %v: %v", msg, err) - }) + t.Fatal("decrypt is bad") } - - alterAndCheck("big nonce size", func(data []byte) { - // Override the nonce size - data[1+serializedPublicKeyLength+2+pathLen] = 255 - }) - - alterAndCheck("bigger nonce size", func(data []byte) { - // Override the nonce size - data[1+serializedPublicKeyLength+2+pathLen+1] = 14 - }) - - alterAndCheck("smaller nonce size", func(data []byte) { - // Override the nonce size - data[1+serializedPublicKeyLength+2+pathLen+1] = 1 - }) - - alterAndCheck("big derivation path len", func(data []byte) { - // Override derivation path length - data[1+serializedPublicKeyLength] = 255 - }) - - alterAndCheck("bigger derivation path len", func(data []byte) { - // Override derivation path length - data[1+serializedPublicKeyLength+1] = 4 - }) - - alterAndCheck("smaller derivation path len", func(data []byte) { - // Override derivation path length - data[1+serializedPublicKeyLength+1] = 0 - }) - - alterAndCheck("nonce", func(data []byte) { - // Invert last byte of the nonce - data[1+serializedPublicKeyLength+2+pathLen+2+11] = - ^data[1+serializedPublicKeyLength+2+pathLen+2+11] - }) - - alterAndCheck("tamper ciphertext", func(data []byte) { - // Invert last byte of the ciphertext - data[len(data)-1] = ^data[len(data)-1] - }) - - t.Run("tamperCiphertextWithAEAD", func(t *testing.T) { - data := base58.Decode(encrypted) - - additionalData := data[0 : 1+serializedPublicKeyLength+2+pathLen+2] - nonce := data[len(data)-12:] - encryptionKey, _ := privKey.key.ECPrivKey() - secret, _ := recoverSharedEncryptionSecretForAES(encryptionKey, data[1:serializedPublicKeyLength+1]) - - block, _ := aes.NewCipher(secret) - gcm, _ := cipher.NewGCM(block) - - fakeHdPrivKey, _ := NewHDPrivateKey(randomBytes(32), Mainnet()) - - fakePayload := []byte(strings.ToLower(string(payload))) - fakePrivKey, _ := fakeHdPrivKey.key.ECPrivKey() - - hash := sha256.Sum256(fakePayload) - fakeSig, _ := ecdsa.SignCompact(fakePrivKey, hash[:], false) - - plaintext := bytes.NewBuffer(nil) - addVariableBytes(plaintext, fakeSig) - plaintext.Write(fakePayload) - - ciphertext := gcm.Seal(nil, nonce, plaintext.Bytes(), additionalData) - - offset := len(additionalData) - for _, b := range ciphertext { - data[offset] = b - offset++ - } - - for _, b := range nonce { - data[offset] = b - offset++ - } - - _, err = privKey.Decrypter().Decrypt(base58.Encode(data)) - if err == nil { - t.Errorf("Got nil error for altered payload: tamper chiphertex recalculating AEAD") - } - - t.Logf("Got error for altered payload tamper chiphertex recalculating AEAD: %v", err) - }) } -func TestEncDecOps(t *testing.T) { - +func TestEncryptedMetadataImplicitHardenedDerivationBug(t *testing.T) { const ( - privHex = "xprv9s21ZrQH143K36uECEJcmTnxSXfHjT9jdb7FpMoUJpENDxeRgpscDF3g2w4ySH6G9uVsGKK7e6WgGp7Vc9VVnwC2oWdrr7a3taWiKW8jKnD" - path = "m" - pathLen = 1 + 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=" ) - payload := []byte("Asado Viernes") - - privKey, _ := NewHDPrivateKeyFromString(privHex, path, Mainnet()) - encrypted, _ := NewEncryptOperation(privKey, payload).Encrypt() + expectedPlaintext, err := base64.StdEncoding.DecodeString(expectedPlaintextBase64) + if err != nil { + t.Fatal(err) + } - decrypted, err := NewDecryptOperation(privKey, encrypted).Decrypt() + privKey, _ := NewHDPrivateKeyFromString(encodedXpriv, "m", Mainnet()) + plaintext, err := privKey.Decrypter().Decrypt(encryptedMetadata) if err != nil { t.Fatal(err) } - if !bytes.Equal(payload, decrypted) { - t.Fatal("decrypt is bad") + if !bytes.Equal(plaintext, expectedPlaintext) { + t.Fatalf("expected:\n%s\ngot:\n%s", expectedPlaintextBase64, plaintext) } + } diff --git a/libwallet/encryption/cypher.go b/libwallet/encryption/cypher.go new file mode 100644 index 00000000..fd9fbaeb --- /dev/null +++ b/libwallet/encryption/cypher.go @@ -0,0 +1,367 @@ +package encryption + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "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/muun/libwallet/hdpath" +) + +const serializedPublicKeyLength = btcec.PubKeyBytesLenCompressed + +const ( + PKEncryptionVersionV1 = 1 + // PKEncryptionVersionV2 is functionally equivalent to V1, except we guarantee it doesn't + // have buggy hardened derivation keys. + PKEncryptionVersionV2 = 2 +) + +// maxDerivationPathLen is a safety limit to avoid stupid size allocations +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. +const minNonceLen = 12 + +type HdPubKeyEncrypter struct { + ReceiverKey *btcec.PublicKey + ReceiverKeyPath string + SenderKey *btcec.PrivateKey +} + +func (e *HdPubKeyEncrypter) Encrypt(payload []byte) (string, error) { + // 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. + // + // Conceptually, what we do is: + // 1. Sign the payload using the senders private key so the receiver can check it's authentic + // The signature also covers the receivers public key to avoid payload reuse by the intermediary + // 2. Establish an encryption key via ECDH given the receivers pub key + // 3. Encrypt the payload and signature using AES with a new random nonce + // 4. Add the metadata the receiver will need to decode the message: + // * 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 + // 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. + + signingKey := e.SenderKey + encryptionKey := e.ReceiverKey + + // Sign "payload || encryptionKey" to protect against payload reuse by 3rd parties + signaturePayload := make([]byte, 0, len(payload)+serializedPublicKeyLength) + signaturePayload = append(signaturePayload, payload...) + signaturePayload = append(signaturePayload, encryptionKey.SerializeCompressed()...) + hash := sha256.Sum256(signaturePayload) + senderSignature, err := ecdsa.SignCompact(signingKey, hash[:], false) + if err != nil { + return "", fmt.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) + } + + err = addVariableBytes(plaintext, payload) + if err != nil { + return "", fmt.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) + } + + blockCipher, err := aes.NewCipher(sharedSecret) + if err != nil { + return "", fmt.Errorf("Encrypt: new aes failed: %w", err) + } + + gcm, err := cipher.NewGCM(blockCipher) + if err != nil { + return "", fmt.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) + } + + // additionalData is "version || pubEph || ReceiverKeyPath || nonceLen" + additionalDataLen := 1 + serializedPublicKeyLength + 2 + len(e.ReceiverKeyPath) + 2 + result := bytes.NewBuffer(make([]byte, 0, additionalDataLen)) + result.WriteByte(PKEncryptionVersionV2) + result.Write(pubEph.SerializeCompressed()) + + err = addVariableBytes(result, []byte(e.ReceiverKeyPath)) + if err != nil { + return "", fmt.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) + } + + ciphertext := gcm.Seal(nil, nonce, plaintext.Bytes(), result.Bytes()) + + // result is "additionalData || nonce || ciphertext" + n, err := result.Write(nonce) + if err != nil || n != len(nonce) { + return "", errors.New("Encrypt: failed to add nonce") + } + + n, err = result.Write(ciphertext) + if err != nil || n != len(ciphertext) { + return "", errors.New("Encrypt: failed to add ciphertext") + } + + return base58.Encode(result.Bytes()), nil +} + +type KeyProvider interface { + WithPath(path string) (*btcec.PrivateKey, error) + WithPathUsingHardenedBug(path string) (*btcec.PrivateKey, error) + Path() string +} + +// hdPrivKeyDecrypter holds the keys for validation and decryption of messages using Muun's scheme +type HdPrivKeyDecrypter struct { + KeyProvider KeyProvider + + // 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. + SenderKey *btcec.PublicKey + + // FromSelf is true if this message is from yourself + FromSelf bool +} + +func (d *HdPrivKeyDecrypter) Decrypt(payload string) ([]byte, error) { + // 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) + } + + encryptionKey, verificationKey, err := d.computeKeys(parsed, false) + if err != nil { + return nil, fmt.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. + originalError := err + + shouldTryDerivationWithBug, err := d.isPotentiallyAffectedByImplicitHardenedDerivationBug(parsed) + if err != nil { + return nil, fmt.Errorf( + "Decrypt: failed to check if affected by derivation bug: %w"+ + " -- originally failed due to %w", err, originalError, + ) + } + + if !shouldTryDerivationWithBug { + return nil, originalError + } + + encryptionKey, verificationKey, err = d.computeKeys(parsed, true) + if err != nil { + return nil, fmt.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 data, nil + } + + return data, nil +} + +func (d *HdPrivKeyDecrypter) computeKeys(parsed decodedPayload, useHardenedDerivationWithBug bool) (*btcec.PrivateKey, *btcec.PublicKey, error) { + var err error + var verificationKey *btcec.PublicKey + + var receiverKey *btcec.PrivateKey + if useHardenedDerivationWithBug { + receiverKey, err = d.KeyProvider.WithPathUsingHardenedBug(parsed.receiverPath) + } else { + 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) + } + + if d.FromSelf { + // Use the derived receiver key if the sender key is not provided + verificationKey = receiverKey.PubKey() + } else if d.SenderKey != nil { + verificationKey = d.SenderKey + } + + return receiverKey, verificationKey, nil +} + +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) + } + + pathToDeriveTo, err := hdpath.Parse(parsed.receiverPath) + if err != nil { + return false, fmt.Errorf("isPotentiallyAffectedByImplicitHardenedDerivationBug: error parsing receiverPath %w", err) + } + + for _, index := range pathToDeriveTo.IndexesFrom(receiverKeyPath) { + if index.Hardened { + return true, nil + } + } + + return false, nil +} + +type decodedPayload struct { + version uint8 + rawPubEph []byte + receiverPath string + nonce []byte + ciphertext []byte + additionalData []byte +} + +func parseEncodedPayload(payload string) (decodedPayload, error) { + decoded := base58.Decode(payload) + reader := bytes.NewReader(decoded) + version, err := reader.ReadByte() + if err != nil { + return decodedPayload{}, fmt.Errorf("parseEncodedPayload: failed to read version byte: %w", err) + } + if version != PKEncryptionVersionV1 && version != PKEncryptionVersionV2 { + return decodedPayload{}, fmt.Errorf("parseEncodedPayload: found key version %v", version) + } + + rawPubEph := make([]byte, serializedPublicKeyLength) + n, err := reader.Read(rawPubEph) + if err != nil || n != serializedPublicKeyLength { + return decodedPayload{}, errors.New("parseEncodedPayload: failed to read pubeph") + } + + receiverPath, err := extractVariableString(reader, maxDerivationPathLen) + if err != nil { + return decodedPayload{}, fmt.Errorf("parseEncodedPayload: failed to extract receiver path: %w", err) + } + + // additionalDataSize is Whatever I've read so far plus two bytes for the nonce len + additionalDataSize := len(decoded) - reader.Len() + 2 + + minCiphertextLen := 2 // an empty sig with no plaintext + nonce, err := extractVariableBytes(reader, reader.Len()-minCiphertextLen) + if err != nil || len(nonce) < minNonceLen { + return decodedPayload{}, errors.New("parseEncodedPayload: failed to read nonce") + } + + // What's left is the ciphertext + ciphertext := make([]byte, reader.Len()) + _, err = reader.Read(ciphertext) + if err != nil { + return decodedPayload{}, fmt.Errorf("parseEncodedPayload: failed to read ciphertext: %w", err) + } + + additionalData := decoded[:additionalDataSize] + + return decodedPayload{ + version, + rawPubEph, + receiverPath, + nonce, + ciphertext, + additionalData, + }, nil +} + +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) + } + + blockCipher, err := aes.NewCipher(sharedSecret) + if err != nil { + return nil, fmt.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) + } + + plaintext, err := gcm.Open(nil, p.nonce, p.ciphertext, p.additionalData) + if err != nil { + return nil, fmt.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) + } + + data, err := extractVariableBytes(plaintextReader, plaintextReader.Len()) + if err != nil { + return nil, fmt.Errorf("decryptAndVerify: failed to extract user data: %w", err) + } + + signatureData := make([]byte, 0, len(sig)+serializedPublicKeyLength) + signatureData = append(signatureData, data...) + signatureData = append(signatureData, encryptionKey.PubKey().SerializeCompressed()...) + hash := sha256.Sum256(signatureData) + signatureKey, _, err := ecdsa.RecoverCompact(sig, hash[:]) + if err != nil { + return nil, fmt.Errorf("decryptAndVerify: failed to verify signature: %w", err) + } + if verificationKey != nil && !signatureKey.IsEqual(verificationKey) { + return nil, errors.New("decryptAndVerify: signing key mismatch") + } + + return data, nil +} diff --git a/libwallet/encryption/cypher_test.go b/libwallet/encryption/cypher_test.go new file mode 100644 index 00000000..dc2828b9 --- /dev/null +++ b/libwallet/encryption/cypher_test.go @@ -0,0 +1,243 @@ +package encryption + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "strings" + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/ecdsa" + "github.com/btcsuite/btcd/btcutil/base58" + "github.com/btcsuite/btcd/btcutil/hdkeychain" + "github.com/btcsuite/btcd/chaincfg" + "github.com/muun/libwallet/hdpath" +) + +func cryptersFromKey(key *hdkeychain.ExtendedKey) (HdPubKeyEncrypter, HdPrivKeyDecrypter) { + + decrypter := HdPrivKeyDecrypter{ + KeyProvider: keyProvider{key, "m"}, + SenderKey: nil, + FromSelf: true, + } + ecPrivKey, _ := key.ECPrivKey() + + encrypter := HdPubKeyEncrypter{ + ReceiverKey: ecPrivKey.PubKey(), + ReceiverKeyPath: "m", + SenderKey: ecPrivKey, + } + + return encrypter, decrypter +} + +type keyProvider struct { + key *hdkeychain.ExtendedKey + path string +} + +func (k keyProvider) WithPath(path string) (*btcec.PrivateKey, error) { + indexes := hdpath.MustParse(path).IndexesFrom(hdpath.MustParse(k.path)) + + var err error + key := k.key + for _, index := range indexes { + var modifier uint32 = 0 + if index.Hardened { + modifier = hdkeychain.HardenedKeyStart + } + key, err = key.Derive(index.Index | modifier) + if err != nil { + return nil, err + } + } + + return key.ECPrivKey() +} + +func (k keyProvider) WithPathUsingHardenedBug(path string) (*btcec.PrivateKey, error) { + indexes := hdpath.MustParse(path).IndexesFrom(hdpath.MustParse(k.path)) + + var err error + key := k.key + for _, index := range indexes { + var modifier uint32 = 0 + if index.Hardened { + modifier = hdkeychain.HardenedKeyStart + } + //lint:ignore SA1019 using deprecated method for backwards compat with the bug + key, err = key.DeriveNonStandard(index.Index | modifier) + if err != nil { + return nil, err + } + } + + return key.ECPrivKey() +} + +func (k keyProvider) Path() string { + return k.path +} + +func TestPublicKeyEncryptionV1(t *testing.T) { + const ( + priv = "xprv9s21ZrQH143K2DAjx7FiAo2GQAQ5g7GrPYkTB2RaCd2Ei5ZH7f9cbREHiZTCc1FPn9HKuviUHk8sf5cW3dhYjz6W6XPjXNHu5mLpT5oRH1j" + ciphertext = "AMWm2L3YjA7myBTQQgiZi9F5g1NzaaupkPq1y7csUkf7WLXwnPYjkmy5KjVkyTKjaSXPwjx2zmX9Augzwwh89AsWYTv7KfJTXTj3Lx2mNZgmxJ7eezaJyRHv4koQaEmRykSoVE4esjWK779Sac28kCstkqDMPDYeNud5H4ApetF4BvhvPJyMaVn4RHYSAGzBzMcBV7WxYoRveKHqU9LbAfhCndPtRSVZyTVXY8iE3EvQJFeZVyYdovPK67aHsXWRdi8QCinMQSG21TMmhs7GQAh6iB26X2ABcVFJRGeEKE2coAsfuAHzcAMZ3CdzGgVAm7rrQw13W3XpxwwjWVatH9Jm9H4TrnnnLxRCsBoSKDvA1hmH8a2UG9iMxkhsBVMPzNRMy4Bg4MHk8WyRo3bwCLSVJUFFEciQ3mUneHprezzbVZio" + plaintextHex = "ca4dabb05a47d3ab306c1fad895d97b06dc30564191e610f9b254b1a1d0a536b6eca2b83d0d17d67aaad2a958fe6a6557ad5b26f44e12e7662f47a4e4fd6f482b68a83cd140ad4ded43b90a2c2cf349af84d828b1f961901616b4c4cb01f761bd277ad0d3d90506065aef76b930a962fcb90f2f009898c0d55cd07b5e01c355a9067937185fa9237d03e5ed4243e1bf0f8a959c72a83cbb1729b679cbd660052dd2dd3096b0f19e9275ac459b94d02a95642" + ) + + privKey, _ := hdkeychain.NewKeyFromString(priv) + + encrypter, decrypter := cryptersFromKey(privKey) + plaintext, _ := hex.DecodeString(plaintextHex) + + decrypted, err := decrypter.Decrypt(ciphertext) + if err != nil { + t.Fatal(err) + } + + if !bytes.Equal(plaintext, decrypted) { + t.Fatalf("decrypted payload differed from original\ndecrypted %v\noriginal %v", + hex.EncodeToString(decrypted), + hex.EncodeToString(plaintext)) + } + + _, err = encrypter.Encrypt(decrypted) + if err != nil { + t.Fatal(err) + } + + // Since we use a random key every time, comparing cipher texts makes no sense +} + +func TestPublicKeyDecryptV1(t *testing.T) { + + const ( + privHex = "xprv9s21ZrQH143K36uECEJcmTnxSXfHjT9jdb7FpMoUJpENDxeRgpscDF3g2w4ySH6G9uVsGKK7e6WgGp7Vc9VVnwC2oWdrr7a3taWiKW8jKnD" + path = "m" + pathLen = 1 + ) + payload := []byte("Asado Viernes") + + privKey, _ := hdkeychain.NewKeyFromString(privHex) + encrypter, decrypter := cryptersFromKey(privKey) + encrypted, _ := encrypter.Encrypt(payload) + + decrypted, err := decrypter.Decrypt(encrypted) + if err != nil { + t.Fatal(err) + } + + if !bytes.Equal(payload, decrypted) { + t.Fatalf("decrypted payload differed from original\ndecrypted %v\noriginal %v", + string(decrypted), + string(payload)) + } + + alterAndCheck := func(msg string, alter func(data []byte)) { + t.Run(msg, func(t *testing.T) { + encryptedData := base58.Decode(encrypted) + alter(encryptedData) + + _, err = decrypter.Decrypt(base58.Encode(encryptedData)) + if err == nil { + t.Fatalf("Got nil error for altered payload: %v", msg) + } + + t.Logf("Got error for altered payload %v: %v", msg, err) + }) + } + + alterAndCheck("big nonce size", func(data []byte) { + // Override the nonce size + data[1+serializedPublicKeyLength+2+pathLen] = 255 + }) + + alterAndCheck("bigger nonce size", func(data []byte) { + // Override the nonce size + data[1+serializedPublicKeyLength+2+pathLen+1] = 14 + }) + + alterAndCheck("smaller nonce size", func(data []byte) { + // Override the nonce size + data[1+serializedPublicKeyLength+2+pathLen+1] = 1 + }) + + alterAndCheck("big derivation path len", func(data []byte) { + // Override derivation path length + data[1+serializedPublicKeyLength] = 255 + }) + + alterAndCheck("bigger derivation path len", func(data []byte) { + // Override derivation path length + data[1+serializedPublicKeyLength+1] = 4 + }) + + alterAndCheck("smaller derivation path len", func(data []byte) { + // Override derivation path length + data[1+serializedPublicKeyLength+1] = 0 + }) + + alterAndCheck("nonce", func(data []byte) { + // Invert last byte of the nonce + data[1+serializedPublicKeyLength+2+pathLen+2+11] = + ^data[1+serializedPublicKeyLength+2+pathLen+2+11] + }) + + alterAndCheck("tamper ciphertext", func(data []byte) { + // Invert last byte of the ciphertext + data[len(data)-1] = ^data[len(data)-1] + }) + + t.Run("tamperCiphertextWithAEAD", func(t *testing.T) { + data := base58.Decode(encrypted) + + additionalData := data[0 : 1+serializedPublicKeyLength+2+pathLen+2] + nonce := data[len(data)-12:] + encryptionKey, _ := hdkeychain.NewKeyFromString(privHex) + ecEncryptionKey, _ := encryptionKey.ECPrivKey() + secret, _ := RecoverSharedEncryptionSecretForAES(ecEncryptionKey, data[1:serializedPublicKeyLength+1]) + + block, _ := aes.NewCipher(secret) + gcm, _ := cipher.NewGCM(block) + + randomKeyBytes := make([]byte, 32) + _, _ = rand.Read(randomKeyBytes) + fakeHdPrivKey, _ := hdkeychain.NewMaster(randomKeyBytes, &chaincfg.MainNetParams) + + fakePayload := []byte(strings.ToLower(string(payload))) + fakePrivKey, _ := fakeHdPrivKey.ECPrivKey() + + hash := sha256.Sum256(fakePayload) + fakeSig, _ := ecdsa.SignCompact(fakePrivKey, hash[:], false) + + plaintext := bytes.NewBuffer(nil) + addVariableBytes(plaintext, fakeSig) + plaintext.Write(fakePayload) + + ciphertext := gcm.Seal(nil, nonce, plaintext.Bytes(), additionalData) + + offset := len(additionalData) + for _, b := range ciphertext { + data[offset] = b + offset++ + } + + for _, b := range nonce { + data[offset] = b + offset++ + } + + _, err = decrypter.Decrypt(base58.Encode(data)) + if err == nil { + t.Errorf("Got nil error for altered payload: tamper chiphertex recalculating AEAD") + } + + t.Logf("Got error for altered payload tamper chiphertex recalculating AEAD: %v", err) + }) +} diff --git a/libwallet/encryption/encodings.go b/libwallet/encryption/encodings.go new file mode 100755 index 00000000..83caa56d --- /dev/null +++ b/libwallet/encryption/encodings.go @@ -0,0 +1,62 @@ +package encryption + +import ( + "bytes" + "encoding/binary" + "errors" + "fmt" + "io" + "math" + "math/big" +) + +func paddedSerializeBigInt(size uint, x *big.Int) []byte { + src := x.Bytes() + dst := make([]byte, 0, size) + + for i := 0; i < int(size)-len(src); i++ { + dst = append(dst, 0) + } + + return append(dst, src...) +} + +func addVariableBytes(writer io.Writer, data []byte) error { + if len(data) > math.MaxUint16 { + return fmt.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) + } + + n, err := writer.Write(data) + if err != nil || n != len(data) { + return errors.New("failed to write var bytes") + } + + return nil +} + +func extractVariableBytes(reader *bytes.Reader, limit int) ([]byte, error) { + var len uint16 + err := binary.Read(reader, binary.BigEndian, &len) + if err != nil || int(len) > limit || int(len) > reader.Len() { + return nil, errors.New("failed to read byte array len") + } + + result := make([]byte, len) + n, err := reader.Read(result) + if err != nil || n != int(len) { + return nil, errors.New("failed to extract byte array") + } + + return result, nil +} + +func extractVariableString(reader *bytes.Reader, limit int) (string, error) { + bytes, err := extractVariableBytes(reader, limit) + return string(bytes), err +} diff --git a/libwallet/encodings_test.go b/libwallet/encryption/encodings_test.go old mode 100644 new mode 100755 similarity index 98% rename from libwallet/encodings_test.go rename to libwallet/encryption/encodings_test.go index 46d4c1d2..b1e1f2ac --- a/libwallet/encodings_test.go +++ b/libwallet/encryption/encodings_test.go @@ -1,4 +1,4 @@ -package libwallet +package encryption import ( "encoding/hex" diff --git a/libwallet/encryption/secret.go b/libwallet/encryption/secret.go new file mode 100644 index 00000000..269b079e --- /dev/null +++ b/libwallet/encryption/secret.go @@ -0,0 +1,55 @@ +package encryption + +import ( + "crypto/sha256" + "fmt" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/muun/libwallet/aescbc" +) + +// GenerateSharedEncryptionSecret performs a ECDH with pubKey +// Deprecated: this function is unsafe and GenerateSharedEncryptionSecretForAES should be used +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) + } + + sharedSecret, _ := btcec.S256().ScalarMult(pubKey.X(), pubKey.Y(), privEph.ToECDSA().D.Bytes()) + + 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 +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) + } + + sharedSecret, _ := btcec.S256().ScalarMult(pubEph.X(), pubEph.Y(), privKey.ToECDSA().D.Bytes()) + return paddedSerializeBigInt(aescbc.KeySize, sharedSecret), nil +} + +// GenerateSharedEncryptionSecret performs a ECDH with pubKey and produces a secret usable with AES +func GenerateSharedEncryptionSecretForAES(pubKey *btcec.PublicKey) (*btcec.PublicKey, []byte, error) { + privEph, sharedSecret, err := GenerateSharedEncryptionSecret(pubKey) + if err != nil { + return nil, nil, err + } + + hash := sha256.Sum256(sharedSecret) + return privEph, hash[:], nil +} + +func RecoverSharedEncryptionSecretForAES(privKey *btcec.PrivateKey, rawPubEph []byte) ([]byte, error) { + sharedSecret, err := RecoverSharedEncryptionSecret(privKey, rawPubEph) + if err != nil { + return nil, err + } + + hash := sha256.Sum256(sharedSecret) + return hash[:], nil +} diff --git a/libwallet/hdpath/hdpath.go b/libwallet/hdpath/hdpath.go index c7c66e02..ed80877d 100644 --- a/libwallet/hdpath/hdpath.go +++ b/libwallet/hdpath/hdpath.go @@ -107,7 +107,7 @@ func (p Path) Indexes() []PathIndex { name = parts[0] } - index, err := strconv.ParseUint(parts[len(parts)-1], 10, 32) + index, err := strconv.ParseUint(parts[len(parts)-1], 10, 31) if err != nil { panic("path is malformed: " + err.Error()) } diff --git a/libwallet/hdpath/hdpath_test.go b/libwallet/hdpath/hdpath_test.go index 5463cabd..5480b3f6 100644 --- a/libwallet/hdpath/hdpath_test.go +++ b/libwallet/hdpath/hdpath_test.go @@ -80,3 +80,15 @@ func TestParsingAndValidation(t *testing.T) { }) } } + +func TestHDPathRejectsImplicitlyHardenedPath(t *testing.T) { + // This test is built weird cause the call we want to test doesn't return an error but instead + // panics. We _can_ "catch" a panic via this defer-recover construction. If the call doesn't + // panic, `t.Fatalf()` fails the test + defer func() { + recover() + }() + + indexes := MustParse("m/2147483648").Indexes() + t.Fatalf("expected panic, got value %v", indexes) +} diff --git a/libwallet/hdprivatekey.go b/libwallet/hdprivatekey.go index 9f89d716..48e66352 100644 --- a/libwallet/hdprivatekey.go +++ b/libwallet/hdprivatekey.go @@ -6,6 +6,8 @@ import ( "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" @@ -75,6 +77,13 @@ func (p *HDPrivateKey) String() string { // DerivedAt derives a new child priv key, which may be hardened // 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) + } + if index < 0 || index > int64(hdkeychain.HardenedKeyStart) { + return nil, fmt.Errorf("index is out of bounds (index %v)", index) + } + var modifier uint32 if hardened { modifier = hdkeychain.HardenedKeyStart @@ -124,6 +133,52 @@ func (p *HDPrivateKey) DeriveTo(path string) (*HDPrivateKey, error) { return derivedKey, nil } +// Deprecated: deriveToPathWithHardenedBug derives the key up to the provided path, using a buggy +// derivation algorithm that causes hardened private key derivation to differ from the HD wallet +// BIP. We updated our logic to use the new version, but we still need this buggy algorithm for +// any keys that we derived before the fix. +// For information on the bug see ExtendedKey#DeriveNonStandard. +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) + } + + firstPath, err := hdpath.Parse(p.Path) + if err != nil { + return nil, fmt.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) + } + + derivedKey := &p.key + derivedKeyPath := firstPath + + for depth, level := range secondPath.IndexesFrom(firstPath) { + + var modifier uint32 + if level.Hardened { + modifier = hdkeychain.HardenedKeyStart + } + + index := level.Index | modifier + 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 &HDPrivateKey{ + key: *derivedKey, + Network: p.Network, + Path: path, + }, nil +} + // Sign a payload using the backing EC key func (p *HDPrivateKey) Sign(data []byte) ([]byte, error) { @@ -138,20 +193,86 @@ func (p *HDPrivateKey) Sign(data []byte) ([]byte, error) { return sig.Serialize(), nil } +type keyProvider struct { + key *HDPrivateKey +} + +func (k keyProvider) WithPath(path string) (*btcec.PrivateKey, error) { + derivedKey, err := k.key.DeriveTo(path) + if err != nil { + return nil, err + } + + return derivedKey.key.ECPrivKey() +} + +func (k keyProvider) WithPathUsingHardenedBug(path string) (*btcec.PrivateKey, error) { + derivedKey, err := k.key.deriveToPathWithHardenedBug(path) + if err != nil { + return nil, err + } + + return derivedKey.key.ECPrivKey() +} + +func (k keyProvider) Path() string { + return k.key.Path +} + func (p *HDPrivateKey) Decrypter() Decrypter { - return &hdPrivKeyDecrypter{p, nil, true} + return &encryption.HdPrivKeyDecrypter{ + KeyProvider: keyProvider{key: p}, + SenderKey: nil, + FromSelf: true, + } } -func (p *HDPrivateKey) DecrypterFrom(senderKey *PublicKey) Decrypter { - return &hdPrivKeyDecrypter{p, senderKey, false} +func (p *HDPrivateKey) DecrypterFrom(sender *PublicKey) Decrypter { + + var senderKey *btcec.PublicKey + if sender != nil { + senderKey = sender.key + } + + return &encryption.HdPrivKeyDecrypter{ + KeyProvider: keyProvider{key: p}, + SenderKey: senderKey, + FromSelf: false, + } } func (p *HDPrivateKey) Encrypter() Encrypter { - return &hdPubKeyEncrypter{p.PublicKey(), p} + key, err := p.key.ECPrivKey() + if err != nil { + panic(err) + } + + return &encryption.HdPubKeyEncrypter{ + ReceiverKey: key.PubKey(), + ReceiverKeyPath: p.Path, + SenderKey: key, + } } func (p *HDPrivateKey) EncrypterTo(receiver *HDPublicKey) Encrypter { - return &hdPubKeyEncrypter{receiver, p} + key, err := p.key.ECPrivKey() + if err != nil { + panic(err) + } + + var receiverKey *btcec.PublicKey + if receiver != nil { + receiverKey, err = receiver.key.ECPubKey() + if err != nil { + panic(err) + } + } + + return &encryption.HdPubKeyEncrypter{ + ReceiverKey: receiverKey, + ReceiverKeyPath: p.Path, + SenderKey: key, + } } // What follows is a workaround for https://github.com/golang/go/issues/46893 diff --git a/libwallet/hdprivatekey_test.go b/libwallet/hdprivatekey_test.go index 836350f1..8fce5b63 100644 --- a/libwallet/hdprivatekey_test.go +++ b/libwallet/hdprivatekey_test.go @@ -2,9 +2,11 @@ package libwallet import ( "crypto/sha256" + "math" "testing" "github.com/btcsuite/btcd/btcec/v2/ecdsa" + "github.com/btcsuite/btcd/btcutil/hdkeychain" "github.com/btcsuite/btcd/chaincfg" ) @@ -292,3 +294,29 @@ func TestHDPrivateKeySign(t *testing.T) { t.Fatal("sig.Verify failed") } } + +func TestHDPrivateKey_DeriveAt(t *testing.T) { + seed := randomBytes(16) + priv, err := NewHDPrivateKey(seed, network) + + t.Run("derive negative index fails", func(t *testing.T) { + _, err = priv.DerivedAt(-1, false) + if err == nil { + t.Errorf("derived a priv key with negative index") + } + }) + + t.Run("derive bigger than uint31 index fails", func(t *testing.T) { + _, err = priv.DerivedAt(math.MaxInt64, false) + if err == nil { + t.Errorf("derived a priv key with int64 index") + } + }) + + t.Run("derive with implicit hardened index fails", func(t *testing.T) { + _, err = priv.DerivedAt(hdkeychain.HardenedKeyStart, false) + if err == nil { + t.Errorf("derived a priv key with implicit hardened index") + } + }) +} diff --git a/libwallet/hdpublickey.go b/libwallet/hdpublickey.go index a2994255..9f07254e 100644 --- a/libwallet/hdpublickey.go +++ b/libwallet/hdpublickey.go @@ -46,6 +46,9 @@ 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) } + if index < 0 || index > int64(hdkeychain.HardenedKeyStart) { + return nil, fmt.Errorf("index is out of bounds (index %v)", index) + } child, err := p.key.Derive(uint32(index)) if err != nil { diff --git a/libwallet/hdpublickey_test.go b/libwallet/hdpublickey_test.go index c7fa237d..2986115a 100644 --- a/libwallet/hdpublickey_test.go +++ b/libwallet/hdpublickey_test.go @@ -20,6 +20,16 @@ func TestHDPublicKey_DerivedAt(t *testing.T) { if err != nil { t.Errorf("failed to derive unhardened pub key due to %v", err) } + + _, err = priv.PublicKey().DerivedAt(-1) + if err == nil { + t.Errorf("derived a pub key with negative index") + } + + _, err = priv.PublicKey().DerivedAt(math.MaxInt64) + if err == nil { + t.Errorf("derived a pub key with int64 index") + } } func TestHDPublicKey_Fingerprint(t *testing.T) { diff --git a/libwallet/invoices.go b/libwallet/invoices.go index 149d3e13..e8c53213 100644 --- a/libwallet/invoices.go +++ b/libwallet/invoices.go @@ -395,11 +395,11 @@ func deriveMetadataEncryptionKey(key *HDPrivateKey) (*HDPrivateKey, error) { if err != nil { return nil, err } - key, err = key.DerivedAt(int64(rand.Int()), false) + key, err = key.DerivedAt(int64(rand.Int31()), false) if err != nil { return nil, err } - return key.DerivedAt(int64(rand.Int()), false) + return key.DerivedAt(int64(rand.Int31()), false) } func GetInvoiceMetadata(paymentHash []byte) (string, error) { diff --git a/libwallet/newop/bridge_persistence_fee_bump_functions.go b/libwallet/newop/bridge_persistence_fee_bump_functions.go index 9d2b2dfe..76c7f5d9 100644 --- a/libwallet/newop/bridge_persistence_fee_bump_functions.go +++ b/libwallet/newop/bridge_persistence_fee_bump_functions.go @@ -14,11 +14,11 @@ import ( "github.com/muun/libwallet/walletdb" ) -const invalidationTimeInSeconds = 60.0 +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) error { +func PersistFeeBumpFunctions(encodedBase64Functions *libwallet.StringList, uuid string, refreshPolicy string) error { if encodedBase64Functions == nil { return errors.New("encoded base 64 function list is null") @@ -29,7 +29,7 @@ func PersistFeeBumpFunctions(encodedBase64Functions *libwallet.StringList) error return err } - feeBumpFunctions := convertToLibwalletFeeBumpFunctions(decodedFunctions) + feeBumpFunctions := convertToLibwalletFeeBumpFunctions(decodedFunctions, uuid, refreshPolicy) db, err := walletdb.Open(path.Join(libwallet.Cfg.DataDir, "wallet.db")) if err != nil { @@ -114,7 +114,11 @@ func decodeFromBase64(base64Function string) ([][]float64, error) { return result, nil } -func convertToLibwalletFeeBumpFunctions(decodedFunctions [][][]float64) []*operation.FeeBumpFunction { +func convertToLibwalletFeeBumpFunctions( + decodedFunctions [][][]float64, + uuid string, + refreshPolicy string, +) *operation.FeeBumpFunctionSet { // Convert to libwallet data types var feeBumpFunctions []*operation.FeeBumpFunction const rightOpenEndpointPosition = 0 @@ -138,8 +142,14 @@ func convertToLibwalletFeeBumpFunctions(decodedFunctions [][][]float64) []*opera } feeBumpFunctions = append( feeBumpFunctions, - &operation.FeeBumpFunction{PartialLinearFunctions: partialLinearFunctions}, + &operation.FeeBumpFunction{ + PartialLinearFunctions: partialLinearFunctions, + }, ) } - return feeBumpFunctions + return &operation.FeeBumpFunctionSet{ + UUID: uuid, + RefreshPolicy: refreshPolicy, + FeeBumpFunctions: feeBumpFunctions, + } } diff --git a/libwallet/newop/bridge_persistence_fee_bump_functions_test.go b/libwallet/newop/bridge_persistence_fee_bump_functions_test.go index 008cfe1d..acaee98f 100644 --- a/libwallet/newop/bridge_persistence_fee_bump_functions_test.go +++ b/libwallet/newop/bridge_persistence_fee_bump_functions_test.go @@ -8,7 +8,6 @@ import ( "path" "reflect" "testing" - "time" ) func TestDecodeFeeBumpFunctions(t *testing.T) { @@ -72,7 +71,6 @@ func TestPersistFeeBumpFunctions(t *testing.T) { encodedFunctionList: []string{"QsgAAAAAAAAAAAAAf4AAAD+AAABAAAAA"}, // [[100, 0, 0], [+Inf, 1, 2]] expectedFunctions: []*operation.FeeBumpFunction{ { - CreatedAt: time.Now(), PartialLinearFunctions: []*operation.PartialLinearFunction{ { LeftClosedEndpoint: 0, @@ -99,7 +97,6 @@ func TestPersistFeeBumpFunctions(t *testing.T) { }, expectedFunctions: []*operation.FeeBumpFunction{ { - CreatedAt: time.Now(), PartialLinearFunctions: []*operation.PartialLinearFunction{ { LeftClosedEndpoint: 0, @@ -139,9 +136,12 @@ func TestPersistFeeBumpFunctions(t *testing.T) { } defer db.Close() + uuid := "uuid" + refreshPolicy := "foreground" + for _, tC := range testCases { functionList := libwallet.NewStringListWithElements(tC.encodedFunctionList) - err := PersistFeeBumpFunctions(functionList) + err := PersistFeeBumpFunctions(functionList, uuid, refreshPolicy) if err != nil && tC.err { t.Fatal(err) @@ -149,18 +149,20 @@ func TestPersistFeeBumpFunctions(t *testing.T) { repository := db.NewFeeBumpRepository() - feeBumpFunctions, err := repository.GetAll() + feeBumpFunctionSet, err := repository.GetAll() if err != nil { t.Fatalf("error getting bump functions") } - if len(feeBumpFunctions) != len(tC.expectedFunctions) { + if len(feeBumpFunctionSet.FeeBumpFunctions) != len(tC.expectedFunctions) || + feeBumpFunctionSet.RefreshPolicy != refreshPolicy || + feeBumpFunctionSet.UUID != uuid { t.Fatalf("fee bump functions were not saved properly") } for i, expectedFunction := range tC.expectedFunctions { - if !reflect.DeepEqual(expectedFunction.PartialLinearFunctions, 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 76358c51..b3dbaf9c 100644 --- a/libwallet/newop/context.go +++ b/libwallet/newop/context.go @@ -27,7 +27,7 @@ type PaymentContext struct { SubmarineSwap *SubmarineSwap //*********************************** - feeBumpFunctions []*operation.FeeBumpFunction + feeBumpFunctionSet *operation.FeeBumpFunctionSet } func (c *PaymentContext) totalBalance() int64 { @@ -46,15 +46,22 @@ func (c *PaymentContext) toBitcoinAmount(sats int64, inputCurrency string) *Bitc } } +func (c *PaymentContext) getFeeBumpFunctions() []*operation.FeeBumpFunction { + if c.feeBumpFunctionSet == nil { + return nil + } + return c.feeBumpFunctionSet.FeeBumpFunctions +} + func newPaymentAnalyzer(context *PaymentContext) *operation.PaymentAnalyzer { return operation.NewPaymentAnalyzer( context.FeeWindow.toInternalType(), context.NextTransactionSize.toInternalType(), - context.feeBumpFunctions, + context.getFeeBumpFunctions(), ) } -func (ipc *InitialPaymentContext) newPaymentContext(feeBumpFunctions []*operation.FeeBumpFunction) *PaymentContext { +func (ipc *InitialPaymentContext) newPaymentContext(feeBumpFunctionSet *operation.FeeBumpFunctionSet) *PaymentContext { return &PaymentContext{ FeeWindow: ipc.FeeWindow, NextTransactionSize: ipc.NextTransactionSize, @@ -62,6 +69,6 @@ func (ipc *InitialPaymentContext) newPaymentContext(feeBumpFunctions []*operatio PrimaryCurrency: ipc.PrimaryCurrency, MinFeeRateInSatsPerVByte: ipc.MinFeeRateInSatsPerVByte, SubmarineSwap: ipc.SubmarineSwap, - feeBumpFunctions: feeBumpFunctions, + feeBumpFunctionSet: feeBumpFunctionSet, } } diff --git a/libwallet/newop/fee_state.go b/libwallet/newop/fee_state.go index 013dd0b5..aaf81957 100644 --- a/libwallet/newop/fee_state.go +++ b/libwallet/newop/fee_state.go @@ -10,7 +10,8 @@ type FeeState struct { State string Amount *BitcoinAmount RateInSatsPerVByte float64 - TargetBlocks int64 // 0 if target not found + TargetBlocks int64 // 0 if target not found + FeeBumpInfo *FeeBumpInfo // info for effective fee tracking/troubleshooting } func (f *FeeState) IsFinal() bool { diff --git a/libwallet/newop/state.go b/libwallet/newop/state.go index ac64028e..055754b4 100644 --- a/libwallet/newop/state.go +++ b/libwallet/newop/state.go @@ -130,12 +130,32 @@ func (a *AmountInfo) mutating(f func(*AmountInfo)) *AmountInfo { return &mutated } +type FeeBumpInfo struct { + SetUUID string + AmountInSat int64 + RefreshPolicy string + SecondsSinceLastUpdate int64 +} + +func NewFeeBumpInfo(feeBumpFunctionSet *operation.FeeBumpFunctionSet, bumpAmount int64) *FeeBumpInfo { + if feeBumpFunctionSet == nil { + return nil + } + return &FeeBumpInfo{ + SetUUID: feeBumpFunctionSet.UUID, + AmountInSat: bumpAmount, + RefreshPolicy: feeBumpFunctionSet.RefreshPolicy, + SecondsSinceLastUpdate: feeBumpFunctionSet.GetSecondsSinceLastUpdate(), + } +} + type Validated struct { analysis *operation.PaymentAnalysis Fee *BitcoinAmount FeeNeedsChange bool Total *BitcoinAmount SwapInfo *SwapInfo + FeeBumpInfo *FeeBumpInfo // info for effective fee tracking/troubleshooting } type SwapInfo struct { @@ -268,7 +288,7 @@ func (s *ResolveState) SetContext(initialContext *InitialPaymentContext) error { return s.setContextWithTime(initialContext, time.Now()) } -func loadFeeBumpFunctions() ([]*operation.FeeBumpFunction, error) { +func loadFeeBumpFunctions() (*operation.FeeBumpFunctionSet, error) { db, err := walletdb.Open(path.Join(libwallet.Cfg.DataDir, "wallet.db")) if err != nil { return nil, err @@ -276,30 +296,30 @@ func loadFeeBumpFunctions() ([]*operation.FeeBumpFunction, error) { defer db.Close() repository := db.NewFeeBumpRepository() - feeBumpFunctions, err := repository.GetAll() + feeBumpFunctionSet, err := repository.GetAll() if err != nil { return nil, err } - return feeBumpFunctions, nil + 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 { - var feeBumpFunctions []*operation.FeeBumpFunction + var feeBumpFunctionSet *operation.FeeBumpFunctionSet if libwallet.DetermineBackendActivatedFeatureStatus(libwallet.BackendFeatureEffectiveFeesCalculation) { // Load fee bump functions from local DB var err error - feeBumpFunctions, err = loadFeeBumpFunctions() + feeBumpFunctionSet, err = loadFeeBumpFunctions() if err != nil { slog.Error("error loading fee bump functions.", slog.Any("error", err)) } } - context := initialContext.newPaymentContext(feeBumpFunctions) + context := initialContext.newPaymentContext(feeBumpFunctionSet) // TODO(newop): add type to PaymentIntent to clarify lightning/onchain distinction invoice := s.PaymentIntent.URI.Invoice @@ -719,14 +739,18 @@ func (s *ValidateState) emitAnalysisOk(analysis *operation.PaymentAnalysis, feeN ) } fee := s.PaymentContext.toBitcoinAmount( - analysis.FeeInSat, + analysis.FeeTotalInSat, s.Amount.InInputCurrency.Currency, ) + + feeBumpInfo := NewFeeBumpInfo(s.PaymentContext.feeBumpFunctionSet, analysis.FeeBumpInSat) + validated := &Validated{ analysis: analysis, Fee: fee, FeeNeedsChange: feeNeedsChange, Total: amount.add(fee), + FeeBumpInfo: feeBumpInfo, } amountInfo := s.AmountInfo.mutating(func(info *AmountInfo) { @@ -839,7 +863,7 @@ func (s *ValidateLightningState) emitAnalysisOk(analysis *operation.PaymentAnaly } onchainFee := s.PaymentContext.toBitcoinAmount( - analysis.FeeInSat, + analysis.FeeTotalInSat, s.Amount.InInputCurrency.Currency, ) @@ -863,6 +887,8 @@ func (s *ValidateLightningState) emitAnalysisOk(analysis *operation.PaymentAnaly isOneConf := analysis.SwapFees.ConfirmationsNeeded > 0 + feeBumpInfo := NewFeeBumpInfo(s.PaymentContext.feeBumpFunctionSet, totalFee.InSat) + validated := &Validated{ Fee: totalFee, Total: amount.add(totalFee), @@ -872,6 +898,7 @@ func (s *ValidateLightningState) emitAnalysisOk(analysis *operation.PaymentAnaly SwapFees: swapFees, IsOneConf: isOneConf, }, + FeeBumpInfo: feeBumpInfo, } amountInfo := s.AmountInfo.mutating(func(info *AmountInfo) { @@ -1018,6 +1045,8 @@ func (s *EditFeeState) CalculateFee(rateInSatsPerVByte float64) (*FeeState, erro return nil, err } + feeBumpInfo := NewFeeBumpInfo(s.PaymentContext.feeBumpFunctionSet, analysis.FeeTotalInSat) + // TODO(newop): add targetblock to analysis result switch analysis.Status { // TODO: this should never happen, right? It should have been detected earlier @@ -1028,16 +1057,18 @@ func (s *EditFeeState) CalculateFee(rateInSatsPerVByte float64) (*FeeState, erro case operation.AnalysisStatusUnpayable: return &FeeState{ State: FeeStateNeedsChange, - Amount: s.toBitcoinAmount(analysis.FeeInSat), + Amount: s.toBitcoinAmount(analysis.FeeTotalInSat), RateInSatsPerVByte: rateInSatsPerVByte, TargetBlocks: s.PaymentContext.FeeWindow.nextHighestBlock(rateInSatsPerVByte), + FeeBumpInfo: feeBumpInfo, }, nil case operation.AnalysisStatusOk: return &FeeState{ State: FeeStateFinalFee, - Amount: s.toBitcoinAmount(analysis.FeeInSat), + Amount: s.toBitcoinAmount(analysis.FeeTotalInSat), RateInSatsPerVByte: rateInSatsPerVByte, TargetBlocks: s.PaymentContext.FeeWindow.nextHighestBlock(rateInSatsPerVByte), + FeeBumpInfo: feeBumpInfo, }, nil default: return nil, fmt.Errorf("unrecognized analysis status: %v", analysis.Status) diff --git a/libwallet/newop/state_test.go b/libwallet/newop/state_test.go index b354455e..22db5a49 100644 --- a/libwallet/newop/state_test.go +++ b/libwallet/newop/state_test.go @@ -245,15 +245,20 @@ func TestOnChainFixedAmountChangeFeeWithFeeBump(t *testing.T) { defer db.Close() repository := db.NewFeeBumpRepository() - repository.Store([]*operation.FeeBumpFunction{ - &operation.FeeBumpFunction{PartialLinearFunctions: []*operation.PartialLinearFunction{ - &operation.PartialLinearFunction{ - LeftClosedEndpoint: 0, - RightOpenEndpoint: math.Inf(1), - Slope: 2, - Intercept: 300, + repository.Store(&operation.FeeBumpFunctionSet{ + UUID: "uuid", + FeeBumpFunctions: []*operation.FeeBumpFunction{ + { + PartialLinearFunctions: []*operation.PartialLinearFunction{ + { + LeftClosedEndpoint: 0, + RightOpenEndpoint: math.Inf(1), + Slope: 2, + Intercept: 300, + }, + }, }, - }}, + }, }) resolveState.SetContext(&context) @@ -799,6 +804,7 @@ func TestOnChainChangeCurrency(t *testing.T) { //goland:noinspection GoUnhandledErrorResult func TestLightningSendZeroFunds(t *testing.T) { + setupStateTests(t) listener := newTestListener() startState := NewOperationFlow(listener) @@ -846,6 +852,7 @@ func TestLightningSendZeroFunds(t *testing.T) { //goland:noinspection GoUnhandledErrorResult func TestLightningSendZeroFundsTFFA(t *testing.T) { + setupStateTests(t) listener := newTestListener() startState := NewOperationFlow(listener) @@ -893,6 +900,7 @@ func TestLightningSendZeroFundsTFFA(t *testing.T) { //goland:noinspection GoUnhandledErrorResult func TestLightningSendNegativeFunds(t *testing.T) { + setupStateTests(t) listener := newTestListener() startState := NewOperationFlow(listener) @@ -940,6 +948,7 @@ func TestLightningSendNegativeFunds(t *testing.T) { //goland:noinspection GoUnhandledErrorResult func TestLightningSendNegativeFundsWithTFFA(t *testing.T) { + setupStateTests(t) listener := newTestListener() startState := NewOperationFlow(listener) @@ -1011,6 +1020,7 @@ func TestLightningExpiredInvoice(t *testing.T) { //goland:noinspection GoUnhandledErrorResult func TestLightningInvoiceWithAmount(t *testing.T) { + setupStateTests(t) listener := newTestListener() startState := NewOperationFlow(listener) @@ -1062,6 +1072,7 @@ func TestLightningInvoiceWithAmount(t *testing.T) { //goland:noinspection GoUnhandledErrorResult func TestLightningWithAmountBack(t *testing.T) { + setupStateTests(t) listener := newTestListener() startState := NewOperationFlow(listener) @@ -1122,6 +1133,7 @@ func TestLightningWithAmountBack(t *testing.T) { //goland:noinspection GoUnhandledErrorResult func TestLightningInvoiceWithAmountAndDescription(t *testing.T) { + setupStateTests(t) listener := newTestListener() startState := NewOperationFlow(listener) @@ -1170,6 +1182,7 @@ func TestLightningInvoiceWithAmountAndDescription(t *testing.T) { //goland:noinspection GoUnhandledErrorResult func TestLightningAmountlessInvoice(t *testing.T) { + setupStateTests(t) listener := newTestListener() startState := NewOperationFlow(listener) @@ -1238,6 +1251,7 @@ func TestLightningAmountlessInvoice(t *testing.T) { //goland:noinspection GoUnhandledErrorResult func TestInvoiceOneConf(t *testing.T) { + setupStateTests(t) listener := newTestListener() startState := NewOperationFlow(listener) @@ -1308,6 +1322,7 @@ func TestAmountConversion(t *testing.T) { // Then, for amount/total the amount in sat and in primary currency differed // in 1 sat. + setupStateTests(t) listener := newTestListener() startState := NewOperationFlow(listener) @@ -1365,6 +1380,7 @@ func TestAmountConversion(t *testing.T) { //goland:noinspection GoUnhandledErrorResult func TestInvoiceUnpayable(t *testing.T) { + setupStateTests(t) listener := newTestListener() startState := NewOperationFlow(listener) @@ -1417,6 +1433,7 @@ func TestInvoiceUnpayable(t *testing.T) { //goland:noinspection GoUnhandledErrorResult func TestInvoiceLend(t *testing.T) { + setupStateTests(t) listener := newTestListener() startState := NewOperationFlow(listener) diff --git a/libwallet/nonces.go b/libwallet/nonces.go index 7f3724b7..a79ea5bd 100644 --- a/libwallet/nonces.go +++ b/libwallet/nonces.go @@ -17,6 +17,10 @@ func (m *MusigNonces) GetPubnonceHex(index int) string { return hex.EncodeToString(m.publicNonces[index][:]) } +func (m *MusigNonces) Length() int { + return len(m.publicNonces) +} + // 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 { diff --git a/libwallet/operation/fee_bump.go b/libwallet/operation/fee_bump.go index 3239f4b7..5484e71d 100644 --- a/libwallet/operation/fee_bump.go +++ b/libwallet/operation/fee_bump.go @@ -5,8 +5,18 @@ import ( "time" ) +type FeeBumpFunctionSet struct { + CreatedAt time.Time + UUID string + RefreshPolicy string + FeeBumpFunctions []*FeeBumpFunction +} + +func (fbs *FeeBumpFunctionSet) GetSecondsSinceLastUpdate() int64 { + return int64(time.Since(fbs.CreatedAt).Seconds()) +} + type FeeBumpFunction struct { - CreatedAt time.Time // it is provided in order by the backend PartialLinearFunctions []*PartialLinearFunction } diff --git a/libwallet/operation/fees.go b/libwallet/operation/fees.go index 59c46dc7..17d21b6e 100644 --- a/libwallet/operation/fees.go +++ b/libwallet/operation/fees.go @@ -18,27 +18,36 @@ type feeCalculator struct { // Consequences of this: // - we don't check balance whatsoever // - fee for COLLECT swap is exactly the same as normal case -func (f *feeCalculator) Fee(amountInSat int64, feeRateInSatsPerVByte float64, takeFeeFromAmount bool) int64 { +func (f *feeCalculator) Fee( + amountInSat int64, + feeRateInSatsPerVByte float64, + takeFeeFromAmount bool, +) (totalFee int64, feeBump int64) { if amountInSat == 0 { - return 0 + return 0, 0 } return f.calculateFee(amountInSat, feeRateInSatsPerVByte, takeFeeFromAmount) } -func (f *feeCalculator) calculateFee(amountInSat int64, feeRateInSatsPerVByte float64, takeFeeFromAmount bool) int64 { +func (f *feeCalculator) calculateFee( + amountInSat int64, + feeRateInSatsPerVByte float64, + takeFeeFromAmount bool, +) (totalFee int64, feeBump int64) { if f.NextTransactionSize == nil { - return 0 + return 0, 0 } var fee int64 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. if sizeForAmount.UtxoStatus == UtxosStatusUnconfirmed { lastUnconfirmedUtxoUsedIndex++ } - var feeBumpAmount int64 = 0 + feeBumpAmount = 0 if lastUnconfirmedUtxoUsedIndex >= 0 { var err error feeBumpAmount, err = f.calculateFeeBumpAmount(lastUnconfirmedUtxoUsedIndex, feeRateInSatsPerVByte) @@ -58,7 +67,7 @@ func (f *feeCalculator) calculateFee(amountInSat int64, feeRateInSatsPerVByte fl } } } - return fee + return fee, feeBumpAmount } func computeFee(sizeInVByte int64, feeRate float64, feeBumpAmount int64) int64 { diff --git a/libwallet/operation/fees_test.go b/libwallet/operation/fees_test.go index 2b6761cd..e3dbd138 100644 --- a/libwallet/operation/fees_test.go +++ b/libwallet/operation/fees_test.go @@ -121,18 +121,21 @@ func TestFeeCalculatorForAmountZero(t *testing.T) { feeRateInSatsPerVbyte float64 takeFeeFromAmount bool expectedFeeInSat int64 + expectedFeeBumpInSat int64 }{ { desc: "calculate for amount zero", feeRateInSatsPerVbyte: 1, takeFeeFromAmount: false, expectedFeeInSat: 0, + expectedFeeBumpInSat: 0, }, { desc: "calculate for amount zero with TFFA", feeRateInSatsPerVbyte: 1, takeFeeFromAmount: true, expectedFeeInSat: 0, + expectedFeeBumpInSat: 0, }, } @@ -149,19 +152,27 @@ func TestFeeCalculatorForAmountZero(t *testing.T) { for _, nts := range allNts { calculator := feeCalculator{&nts, nil} - feeInSat := 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) + } } calculator := feeCalculator{} - feeInSat := 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) + } }) } } @@ -175,6 +186,7 @@ func TestFeeCalculator(t *testing.T) { feeRateInSatsPerVbyte float64 takeFeeFromAmount bool expectedFeeInSat int64 + expectedFeeBumpInSat int64 }{ { desc: "empty fee calculator", @@ -183,6 +195,7 @@ func TestFeeCalculator(t *testing.T) { feeRateInSatsPerVbyte: 10, takeFeeFromAmount: false, expectedFeeInSat: 0, + expectedFeeBumpInSat: 0, }, { desc: "empty fee calculator with TFFA", @@ -191,6 +204,7 @@ func TestFeeCalculator(t *testing.T) { feeRateInSatsPerVbyte: 10, takeFeeFromAmount: false, expectedFeeInSat: 0, + expectedFeeBumpInSat: 0, }, { desc: "non empty fee calculator", @@ -209,6 +223,7 @@ func TestFeeCalculator(t *testing.T) { feeRateInSatsPerVbyte: 10, takeFeeFromAmount: false, expectedFeeInSat: 2400, + expectedFeeBumpInSat: 0, }, { desc: "fails when balance is zero", @@ -217,6 +232,7 @@ func TestFeeCalculator(t *testing.T) { feeRateInSatsPerVbyte: 10, takeFeeFromAmount: false, expectedFeeInSat: 0, + expectedFeeBumpInSat: 0, }, { desc: "fails when balance is zero with TFFA", @@ -225,6 +241,7 @@ func TestFeeCalculator(t *testing.T) { feeRateInSatsPerVbyte: 10, takeFeeFromAmount: true, expectedFeeInSat: 0, + expectedFeeBumpInSat: 0, }, { desc: "fails when amount greater than balance", @@ -233,6 +250,7 @@ func TestFeeCalculator(t *testing.T) { feeRateInSatsPerVbyte: 10, takeFeeFromAmount: false, expectedFeeInSat: 5800, + expectedFeeBumpInSat: 0, }, { desc: "fails when amount greater than balance with TFFA", @@ -241,6 +259,7 @@ func TestFeeCalculator(t *testing.T) { feeRateInSatsPerVbyte: 10, takeFeeFromAmount: true, expectedFeeInSat: 5800, + expectedFeeBumpInSat: 0, }, { desc: "calculates when amount plus fee is greater than balance", @@ -249,6 +268,7 @@ func TestFeeCalculator(t *testing.T) { feeRateInSatsPerVbyte: 10, takeFeeFromAmount: false, expectedFeeInSat: 5800, + expectedFeeBumpInSat: 0, }, { desc: "calculates reduced amount and fee with TFFA", @@ -257,6 +277,7 @@ func TestFeeCalculator(t *testing.T) { feeRateInSatsPerVbyte: 10, takeFeeFromAmount: true, expectedFeeInSat: 2300, + expectedFeeBumpInSat: 0, }, { // This case can't really happen since our PaymentAnalyzer enforces amount == totalBalance for TFFA @@ -267,6 +288,7 @@ func TestFeeCalculator(t *testing.T) { feeRateInSatsPerVbyte: 10, takeFeeFromAmount: true, expectedFeeInSat: 1100, + expectedFeeBumpInSat: 0, }, { desc: "calculates use-all-funds fee with TFFA", @@ -275,6 +297,7 @@ func TestFeeCalculator(t *testing.T) { feeRateInSatsPerVbyte: 10, takeFeeFromAmount: true, expectedFeeInSat: 2300, + expectedFeeBumpInSat: 0, }, { desc: "calculates when paying fee does not require an additional UTXO (1)", @@ -283,6 +306,7 @@ func TestFeeCalculator(t *testing.T) { feeRateInSatsPerVbyte: 10, takeFeeFromAmount: false, expectedFeeInSat: defaultNts.SizeProgression[0].SizeInVByte * 10, + expectedFeeBumpInSat: 0, }, { desc: "calculates when paying fee does not require an additional UTXO (2)", @@ -291,6 +315,7 @@ func TestFeeCalculator(t *testing.T) { feeRateInSatsPerVbyte: 10, takeFeeFromAmount: false, expectedFeeInSat: defaultNts.SizeProgression[1].SizeInVByte * 10, + expectedFeeBumpInSat: 0, }, { desc: "calculates when paying fee does not require an additional UTXO (3)", @@ -299,6 +324,7 @@ func TestFeeCalculator(t *testing.T) { feeRateInSatsPerVbyte: 10, takeFeeFromAmount: false, expectedFeeInSat: defaultNts.SizeProgression[2].SizeInVByte * 10, + expectedFeeBumpInSat: 0, }, { desc: "calculates when paying fee does not require an additional UTXO (4)", @@ -307,6 +333,7 @@ func TestFeeCalculator(t *testing.T) { feeRateInSatsPerVbyte: 10, takeFeeFromAmount: false, expectedFeeInSat: defaultNts.SizeProgression[3].SizeInVByte * 10, + expectedFeeBumpInSat: 0, }, { desc: "calculates when paying fee requires an additional UTXO (1)", @@ -315,6 +342,7 @@ func TestFeeCalculator(t *testing.T) { feeRateInSatsPerVbyte: 10, takeFeeFromAmount: false, expectedFeeInSat: defaultNts.SizeProgression[1].SizeInVByte * 10, + expectedFeeBumpInSat: 0, }, { desc: "calculates when paying fee requires an additional UTXO (2)", @@ -323,6 +351,7 @@ func TestFeeCalculator(t *testing.T) { feeRateInSatsPerVbyte: 10, takeFeeFromAmount: false, expectedFeeInSat: defaultNts.SizeProgression[2].SizeInVByte * 10, + expectedFeeBumpInSat: 0, }, { desc: "calculates when paying fee requires an additional UTXO (3)", @@ -331,6 +360,7 @@ func TestFeeCalculator(t *testing.T) { feeRateInSatsPerVbyte: 10, takeFeeFromAmount: false, expectedFeeInSat: defaultNts.SizeProgression[3].SizeInVByte * 10, + expectedFeeBumpInSat: 0, }, { desc: "calculates when negative UTXOs are larger than positive UTXOs", @@ -339,6 +369,7 @@ func TestFeeCalculator(t *testing.T) { feeRateInSatsPerVbyte: 10, takeFeeFromAmount: false, expectedFeeInSat: 8400, // which is > 64, aka singleNegativeUtxoNts.TotalBalance() + expectedFeeBumpInSat: 0, }, { desc: "calculates when feeBumpFunctions is loaded using 1 utxo unconfirmed", @@ -353,6 +384,7 @@ func TestFeeCalculator(t *testing.T) { feeRateInSatsPerVbyte: 10, takeFeeFromAmount: false, expectedFeeInSat: 2420, + expectedFeeBumpInSat: 120, }, { desc: "calculates when feeBumpFunctions is loaded using 2 unconfirmed utxos", @@ -367,6 +399,7 @@ func TestFeeCalculator(t *testing.T) { feeRateInSatsPerVbyte: 10, takeFeeFromAmount: false, expectedFeeInSat: 3550, + expectedFeeBumpInSat: 150, }, { desc: "calculates when we have less feeBumpFunctions than unconfirmed utxos (use the last function)", @@ -380,6 +413,7 @@ func TestFeeCalculator(t *testing.T) { feeRateInSatsPerVbyte: 10, takeFeeFromAmount: true, expectedFeeInSat: 3520, + expectedFeeBumpInSat: 120, }, { desc: "calculates when it does not have unconfirmed utxos", @@ -394,6 +428,7 @@ func TestFeeCalculator(t *testing.T) { feeRateInSatsPerVbyte: 10, takeFeeFromAmount: false, expectedFeeInSat: 4000, + expectedFeeBumpInSat: 0, }, { desc: "calculates when feeRate is exactly on left/right endpoint", @@ -408,16 +443,25 @@ func TestFeeCalculator(t *testing.T) { feeRateInSatsPerVbyte: 100, takeFeeFromAmount: false, expectedFeeInSat: 11700, + expectedFeeBumpInSat: 700, }, } for _, tC := range testCases { t.Run(tC.desc, func(t *testing.T) { - feeInSat := tC.feeCalculator.Fee(tC.amountInSat, tC.feeRateInSatsPerVbyte, tC.takeFeeFromAmount) + feeInSat, feeBumpInSat := tC.feeCalculator.Fee( + tC.amountInSat, + 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) + } }) } } diff --git a/libwallet/operation/payment_analyzer.go b/libwallet/operation/payment_analyzer.go index bee5425f..fe8dd1b5 100644 --- a/libwallet/operation/payment_analyzer.go +++ b/libwallet/operation/payment_analyzer.go @@ -118,11 +118,12 @@ const ( // 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) - FeeInSat int64 // encodes the onchain fee (other fees may apply, e.g routing/lightning fee) - SwapFees *fees.SwapFees // metadata related to the swap (if one exists for payment) - TotalInSat int64 // AmountInSat + fees (may include other than FeeInSat). May provide extra information in case of error status (e.g payment can't be made). + 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) + 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). } func NewPaymentAnalyzer( @@ -168,7 +169,7 @@ func (a *PaymentAnalyzer) ToAddress(payment *PaymentToAddress) (*PaymentAnalysis } func (a *PaymentAnalyzer) analyzeFeeFromAmount(payment *PaymentToAddress) (*PaymentAnalysis, error) { - fee := a.feeCalculator.Fee(payment.AmountInSat, payment.FeeRateInSatsPerVByte, true) + fee, feeBump := a.feeCalculator.Fee(payment.AmountInSat, payment.FeeRateInSatsPerVByte, true) total := payment.AmountInSat amount := total - fee @@ -179,39 +180,43 @@ func (a *PaymentAnalyzer) analyzeFeeFromAmount(payment *PaymentToAddress) (*Paym amount = 0 } return &PaymentAnalysis{ - Status: AnalysisStatusUnpayable, - AmountInSat: amount, - TotalInSat: payment.AmountInSat, - FeeInSat: fee, + Status: AnalysisStatusUnpayable, + AmountInSat: amount, + TotalInSat: payment.AmountInSat, + FeeTotalInSat: fee, + FeeBumpInSat: feeBump, }, nil } return &PaymentAnalysis{ - Status: AnalysisStatusOk, - AmountInSat: amount, - TotalInSat: payment.AmountInSat, - FeeInSat: fee, + Status: AnalysisStatusOk, + AmountInSat: amount, + TotalInSat: payment.AmountInSat, + FeeTotalInSat: fee, + FeeBumpInSat: feeBump, }, nil } func (a *PaymentAnalyzer) analyzeFeeFromRemainingBalance(payment *PaymentToAddress) (*PaymentAnalysis, error) { - fee := a.feeCalculator.Fee(payment.AmountInSat, payment.FeeRateInSatsPerVByte, false) + fee, feeBump := a.feeCalculator.Fee(payment.AmountInSat, payment.FeeRateInSatsPerVByte, false) total := payment.AmountInSat + fee if total > a.totalBalance() { return &PaymentAnalysis{ - Status: AnalysisStatusUnpayable, - AmountInSat: payment.AmountInSat, - FeeInSat: fee, - TotalInSat: total, + Status: AnalysisStatusUnpayable, + AmountInSat: payment.AmountInSat, + FeeTotalInSat: fee, + FeeBumpInSat: feeBump, + TotalInSat: total, }, nil } return &PaymentAnalysis{ - Status: AnalysisStatusOk, - AmountInSat: payment.AmountInSat, - TotalInSat: total, - FeeInSat: fee, + Status: AnalysisStatusOk, + AmountInSat: payment.AmountInSat, + TotalInSat: total, + FeeTotalInSat: fee, + FeeBumpInSat: feeBump, }, nil } @@ -280,20 +285,20 @@ func (a *PaymentAnalyzer) analyzeLendSwap(payment *PaymentToInvoice, swapFees *f if total > a.totalBalance() { return &PaymentAnalysis{ - Status: AnalysisStatusUnpayable, - AmountInSat: amount, - TotalInSat: total, - FeeInSat: 0, - SwapFees: swapFees, + Status: AnalysisStatusUnpayable, + AmountInSat: amount, + TotalInSat: total, + FeeTotalInSat: 0, + SwapFees: swapFees, }, nil } return &PaymentAnalysis{ - Status: AnalysisStatusOk, - AmountInSat: amount, - TotalInSat: total, - FeeInSat: 0, - SwapFees: swapFees, + Status: AnalysisStatusOk, + AmountInSat: amount, + TotalInSat: total, + FeeTotalInSat: 0, + SwapFees: swapFees, }, nil } @@ -326,26 +331,28 @@ func (a *PaymentAnalyzer) analyzeCollectSwap(payment *PaymentToInvoice, swapFees return nil, err } - fee := a.feeCalculator.Fee(outputAmount, feeRate, false) + fee, feeBump := a.feeCalculator.Fee(outputAmount, feeRate, false) total := outputAmount + fee totalForUser := total - collectAmount if total > a.utxoBalance() || totalForUser > a.totalBalance() { return &PaymentAnalysis{ - Status: AnalysisStatusUnpayable, - AmountInSat: payment.AmountInSat, - FeeInSat: fee, - TotalInSat: totalForUser, - SwapFees: swapFees, + Status: AnalysisStatusUnpayable, + AmountInSat: payment.AmountInSat, + FeeTotalInSat: fee, + FeeBumpInSat: feeBump, + TotalInSat: totalForUser, + SwapFees: swapFees, }, nil } return &PaymentAnalysis{ - Status: AnalysisStatusOk, - AmountInSat: payment.AmountInSat, - FeeInSat: fee, - TotalInSat: totalForUser, - SwapFees: swapFees, + Status: AnalysisStatusOk, + AmountInSat: payment.AmountInSat, + FeeTotalInSat: fee, + FeeBumpInSat: feeBump, + TotalInSat: totalForUser, + SwapFees: swapFees, }, nil } @@ -376,13 +383,14 @@ func (a *PaymentAnalyzer) analyzeTFFAAmountlessInvoiceSwap(payment *PaymentToInv return nil, err } - zeroConfFeeInSat := a.computeFeeForTFFASwap(payment, zeroConfFeeRate) + zeroConfFeeInSat, zeroConfFeeBumpInSat := a.computeFeeForTFFASwap(payment, zeroConfFeeRate) if zeroConfFeeInSat > a.totalBalance() { // We can't even pay the onchain fee return &PaymentAnalysis{ - Status: AnalysisStatusUnpayable, - FeeInSat: zeroConfFeeInSat, - TotalInSat: a.totalBalance() + int64(zeroConfFeeRate), + Status: AnalysisStatusUnpayable, + FeeTotalInSat: zeroConfFeeInSat, + FeeBumpInSat: zeroConfFeeBumpInSat, + TotalInSat: a.totalBalance() + int64(zeroConfFeeRate), }, nil } @@ -392,9 +400,10 @@ func (a *PaymentAnalyzer) analyzeTFFAAmountlessInvoiceSwap(payment *PaymentToInv // - negative conf target (we're using 0) // - no route for amount (should be guaranteed by BestRouteFees struct) return &PaymentAnalysis{ - Status: AnalysisStatusUnpayable, - FeeInSat: zeroConfFeeInSat, - TotalInSat: a.totalBalance() + zeroConfFeeInSat, + Status: AnalysisStatusUnpayable, + FeeTotalInSat: zeroConfFeeInSat, + FeeBumpInSat: zeroConfFeeBumpInSat, + TotalInSat: a.totalBalance() + zeroConfFeeInSat, }, nil } @@ -406,9 +415,10 @@ func (a *PaymentAnalyzer) analyzeTFFAAmountlessInvoiceSwap(payment *PaymentToInv // - negative conf target (we're using 1) // - no route for amount (should be guaranteed by BestRouteFees struct) return &PaymentAnalysis{ - Status: AnalysisStatusUnpayable, - FeeInSat: zeroConfFeeInSat, - TotalInSat: a.totalBalance() + zeroConfFeeInSat, + Status: AnalysisStatusUnpayable, + FeeTotalInSat: zeroConfFeeInSat, + FeeBumpInSat: zeroConfFeeBumpInSat, + TotalInSat: a.totalBalance() + zeroConfFeeInSat, }, nil } } @@ -416,15 +426,17 @@ func (a *PaymentAnalyzer) analyzeTFFAAmountlessInvoiceSwap(payment *PaymentToInv amount := params.Amount lightningFee := params.RoutingFee onChainFee := params.OnChainFee + onChainFeeBump := params.OnChainFeeBump if amount <= 0 { // We can't pay the combined fee // This can be either cause we can't pay both fees summed or we had to bump to // 1-conf and we can't pay that. return &PaymentAnalysis{ - Status: AnalysisStatusUnpayable, - FeeInSat: zeroConfFeeInSat, - TotalInSat: a.totalBalance() + zeroConfFeeInSat, + Status: AnalysisStatusUnpayable, + FeeTotalInSat: zeroConfFeeInSat, + FeeBumpInSat: zeroConfFeeBumpInSat, + TotalInSat: a.totalBalance() + zeroConfFeeInSat, }, nil } @@ -458,27 +470,30 @@ func (a *PaymentAnalyzer) analyzeTFFAAmountlessInvoiceSwap(payment *PaymentToInv if !canPay { return &PaymentAnalysis{ - Status: AnalysisStatusUnpayable, - AmountInSat: int64(amount), - FeeInSat: int64(onChainFee), - TotalInSat: payment.AmountInSat, - SwapFees: swapFees, + Status: AnalysisStatusUnpayable, + AmountInSat: int64(amount), + FeeTotalInSat: int64(onChainFee), + FeeBumpInSat: int64(onChainFeeBump), + TotalInSat: payment.AmountInSat, + SwapFees: swapFees, }, nil } return &PaymentAnalysis{ - Status: AnalysisStatusOk, - AmountInSat: int64(amount), - FeeInSat: int64(onChainFee), - TotalInSat: payment.AmountInSat, - SwapFees: swapFees, + Status: AnalysisStatusOk, + AmountInSat: int64(amount), + FeeTotalInSat: int64(onChainFee), + FeeBumpInSat: int64(onChainFeeBump), + TotalInSat: payment.AmountInSat, + SwapFees: swapFees, }, nil } type swapParams struct { - Amount btcutil.Amount - OnChainFee btcutil.Amount - RoutingFee btcutil.Amount + Amount btcutil.Amount + OnChainFee btcutil.Amount + OnChainFeeBump btcutil.Amount + RoutingFee btcutil.Amount } // computeParamsForTFFASwap takes care of the VERY COMPLEX task of calculating @@ -538,7 +553,7 @@ func (a *PaymentAnalyzer) computeParamsForTFFASwap(payment *PaymentToInvoice, co return nil, err } - onChainFeeInSat := a.computeFeeForTFFASwap(payment, feeRate) + onChainFeeInSat, onChainFeeBumpInSat := a.computeFeeForTFFASwap(payment, feeRate) for _, bestRouteFees := range payment.BestRouteFees { amount := btcutil.Amount( @@ -566,9 +581,10 @@ func (a *PaymentAnalyzer) computeParamsForTFFASwap(payment *PaymentToInvoice, co // it will be burn as on-chain fee. return &swapParams{ - Amount: amount, - RoutingFee: lightningFee, - OnChainFee: btcutil.Amount(onChainFeeInSat), + Amount: amount, + RoutingFee: lightningFee, + OnChainFee: btcutil.Amount(onChainFeeInSat), + OnChainFeeBump: btcutil.Amount(onChainFeeBumpInSat), }, nil } } @@ -577,7 +593,10 @@ func (a *PaymentAnalyzer) computeParamsForTFFASwap(payment *PaymentToInvoice, co return nil, errors.New("none of the best route fees have enough capacity") } -func (a *PaymentAnalyzer) computeFeeForTFFASwap(payment *PaymentToInvoice, feeRate float64) int64 { +func (a *PaymentAnalyzer) computeFeeForTFFASwap( + payment *PaymentToInvoice, + feeRate float64, +) (totalFee int64, feeBump int64) { // Compute tha on-chain fee. As its TFFA, we want to calculate the fee for the total balance // including any sats we want to collect. onChainAmount := a.totalBalance() + int64(payment.FundingOutputPolicies.PotentialCollect) diff --git a/libwallet/operation/payment_analyzer_test.go b/libwallet/operation/payment_analyzer_test.go index 51822f7b..e7f26b11 100644 --- a/libwallet/operation/payment_analyzer_test.go +++ b/libwallet/operation/payment_analyzer_test.go @@ -4,7 +4,6 @@ import ( "math" "reflect" "testing" - "time" "github.com/muun/libwallet/fees" ) @@ -63,10 +62,11 @@ func TestAnalyzeOnChain(t *testing.T) { FeeRateInSatsPerVByte: 1, }, expected: &PaymentAnalysis{ - Status: AnalysisStatusOk, - AmountInSat: 10000, - FeeInSat: 100, - TotalInSat: 10100, + Status: AnalysisStatusOk, + AmountInSat: 10000, + FeeTotalInSat: 100, + FeeBumpInSat: 0, + TotalInSat: 10100, }, }, { @@ -77,10 +77,11 @@ func TestAnalyzeOnChain(t *testing.T) { FeeRateInSatsPerVByte: 1, }, expected: &PaymentAnalysis{ - Status: AnalysisStatusOk, - AmountInSat: 999_900, - FeeInSat: 100, - TotalInSat: 1_000_000, + Status: AnalysisStatusOk, + AmountInSat: 999_900, + FeeTotalInSat: 100, + FeeBumpInSat: 0, + TotalInSat: 1_000_000, }, }, { @@ -156,10 +157,11 @@ func TestAnalyzeOnChain(t *testing.T) { FeeRateInSatsPerVByte: 10, }, expected: &PaymentAnalysis{ - Status: AnalysisStatusOk, - AmountInSat: 5000, - FeeInSat: 2400, - TotalInSat: 7400, + Status: AnalysisStatusOk, + AmountInSat: 5000, + FeeTotalInSat: 2400, + FeeBumpInSat: 0, + TotalInSat: 7400, }, }, { @@ -179,10 +181,11 @@ func TestAnalyzeOnChain(t *testing.T) { FeeRateInSatsPerVByte: 10, }, expected: &PaymentAnalysis{ - Status: AnalysisStatusUnpayable, - AmountInSat: 9000, - FeeInSat: 2400, - TotalInSat: 11400, + Status: AnalysisStatusUnpayable, + AmountInSat: 9000, + FeeTotalInSat: 2400, + FeeBumpInSat: 0, + TotalInSat: 11400, }, }, { @@ -202,10 +205,11 @@ func TestAnalyzeOnChain(t *testing.T) { FeeRateInSatsPerVByte: 10, }, expected: &PaymentAnalysis{ - Status: AnalysisStatusUnpayable, - AmountInSat: 9999, - FeeInSat: 2400, - TotalInSat: 12399, + Status: AnalysisStatusUnpayable, + AmountInSat: 9999, + FeeTotalInSat: 2400, + FeeBumpInSat: 0, + TotalInSat: 12399, }, }, { @@ -223,17 +227,14 @@ func TestAnalyzeOnChain(t *testing.T) { }, feeBump: []*FeeBumpFunction{ &FeeBumpFunction{ - time.Now(), []*PartialLinearFunction{ partialLinearFunction, }}, &FeeBumpFunction{ - time.Now(), []*PartialLinearFunction{ partialLinearFunction, }}, &FeeBumpFunction{ - time.Now(), []*PartialLinearFunction{ partialLinearFunction, }}, @@ -244,10 +245,11 @@ func TestAnalyzeOnChain(t *testing.T) { FeeRateInSatsPerVByte: 10, }, expected: &PaymentAnalysis{ - Status: AnalysisStatusOk, - AmountInSat: 7480, - FeeInSat: 2520, - TotalInSat: 10000, + Status: AnalysisStatusOk, + AmountInSat: 7480, + FeeTotalInSat: 2520, + FeeBumpInSat: 120, + TotalInSat: 10000, }, }, { @@ -276,8 +278,8 @@ func TestAnalyzeOnChain(t *testing.T) { ExpectedDebtInSat: 0, }, feeBump: []*FeeBumpFunction{ - &FeeBumpFunction{time.Now(), firstFeeBumpFunction}, - &FeeBumpFunction{time.Now(), secondFeeBumpFunction}, + &FeeBumpFunction{firstFeeBumpFunction}, + &FeeBumpFunction{secondFeeBumpFunction}, }, payment: &PaymentToAddress{ TakeFeeFromAmount: false, @@ -285,10 +287,11 @@ func TestAnalyzeOnChain(t *testing.T) { FeeRateInSatsPerVByte: 10, }, expected: &PaymentAnalysis{ - Status: AnalysisStatusOk, - AmountInSat: 12000, - FeeInSat: 4520, - TotalInSat: 16520, + Status: AnalysisStatusOk, + AmountInSat: 12000, + FeeTotalInSat: 4520, + FeeBumpInSat: 120, + TotalInSat: 16520, }, }, { @@ -317,8 +320,8 @@ func TestAnalyzeOnChain(t *testing.T) { ExpectedDebtInSat: 0, }, feeBump: []*FeeBumpFunction{ - &FeeBumpFunction{time.Now(), firstFeeBumpFunction}, - &FeeBumpFunction{time.Now(), secondFeeBumpFunction}, + &FeeBumpFunction{firstFeeBumpFunction}, + &FeeBumpFunction{secondFeeBumpFunction}, }, payment: &PaymentToAddress{ TakeFeeFromAmount: true, @@ -326,10 +329,11 @@ func TestAnalyzeOnChain(t *testing.T) { FeeRateInSatsPerVByte: 10, }, expected: &PaymentAnalysis{ - Status: AnalysisStatusOk, - AmountInSat: 22050, - FeeInSat: 7950, - TotalInSat: 30000, + Status: AnalysisStatusOk, + AmountInSat: 22050, + FeeTotalInSat: 7950, + FeeBumpInSat: 150, + TotalInSat: 30000, }, }, { @@ -347,7 +351,6 @@ func TestAnalyzeOnChain(t *testing.T) { }, feeBump: []*FeeBumpFunction{ &FeeBumpFunction{ - time.Now(), []*PartialLinearFunction{ partialLinearFunction, }}, @@ -358,10 +361,11 @@ func TestAnalyzeOnChain(t *testing.T) { FeeRateInSatsPerVByte: 10, }, expected: &PaymentAnalysis{ - Status: AnalysisStatusUnpayable, - AmountInSat: 7500, - FeeInSat: 2520, - TotalInSat: 10020, + Status: AnalysisStatusUnpayable, + AmountInSat: 7500, + FeeTotalInSat: 2520, + FeeBumpInSat: 120, + TotalInSat: 10020, }, }, { @@ -381,10 +385,11 @@ func TestAnalyzeOnChain(t *testing.T) { FeeRateInSatsPerVByte: 10, }, expected: &PaymentAnalysis{ - Status: AnalysisStatusOk, - AmountInSat: 7600, - FeeInSat: 2400, - TotalInSat: 10_000, + Status: AnalysisStatusOk, + AmountInSat: 7600, + FeeTotalInSat: 2400, + FeeBumpInSat: 0, + TotalInSat: 10_000, }, }, { @@ -422,10 +427,11 @@ func TestAnalyzeOnChain(t *testing.T) { FeeRateInSatsPerVByte: 10, }, expected: &PaymentAnalysis{ - Status: AnalysisStatusUnpayable, - AmountInSat: 0, - FeeInSat: 2400, - TotalInSat: 1000, + Status: AnalysisStatusUnpayable, + AmountInSat: 0, + FeeTotalInSat: 2400, + FeeBumpInSat: 0, + TotalInSat: 1000, }, }, { @@ -445,10 +451,11 @@ func TestAnalyzeOnChain(t *testing.T) { FeeRateInSatsPerVByte: 10, }, expected: &PaymentAnalysis{ - Status: AnalysisStatusUnpayable, - AmountInSat: 0, - FeeInSat: 2400, - TotalInSat: 600, + Status: AnalysisStatusUnpayable, + AmountInSat: 0, + FeeTotalInSat: 2400, + FeeBumpInSat: 0, + TotalInSat: 600, }, }, { @@ -463,10 +470,11 @@ func TestAnalyzeOnChain(t *testing.T) { FeeRateInSatsPerVByte: 1, }, expected: &PaymentAnalysis{ - Status: AnalysisStatusOk, - AmountInSat: 10000, - FeeInSat: 100, - TotalInSat: 10100, + Status: AnalysisStatusOk, + AmountInSat: 10000, + FeeTotalInSat: 100, + FeeBumpInSat: 0, + TotalInSat: 10100, }, }, { @@ -481,10 +489,11 @@ func TestAnalyzeOnChain(t *testing.T) { FeeRateInSatsPerVByte: 1, }, expected: &PaymentAnalysis{ - Status: AnalysisStatusOk, - AmountInSat: 989_900, - FeeInSat: 100, - TotalInSat: 990_000, + Status: AnalysisStatusOk, + AmountInSat: 989_900, + FeeTotalInSat: 100, + FeeBumpInSat: 0, + TotalInSat: 990_000, }, }, { @@ -515,10 +524,11 @@ func TestAnalyzeOnChain(t *testing.T) { FeeRateInSatsPerVByte: 10, }, expected: &PaymentAnalysis{ - Status: AnalysisStatusUnpayable, - AmountInSat: 989_500, - FeeInSat: 1000, - TotalInSat: 990_500, + Status: AnalysisStatusUnpayable, + AmountInSat: 989_500, + FeeTotalInSat: 1000, + FeeBumpInSat: 0, + TotalInSat: 990_500, }, }, { @@ -554,10 +564,11 @@ func TestAnalyzeOnChain(t *testing.T) { FeeRateInSatsPerVByte: 100, }, expected: &PaymentAnalysis{ - Status: AnalysisStatusUnpayable, - AmountInSat: 0, - FeeInSat: 24000, - TotalInSat: 2000, + Status: AnalysisStatusUnpayable, + AmountInSat: 0, + FeeTotalInSat: 24000, + FeeBumpInSat: 0, + TotalInSat: 2000, }, }, { @@ -577,10 +588,11 @@ func TestAnalyzeOnChain(t *testing.T) { FeeRateInSatsPerVByte: 10, }, expected: &PaymentAnalysis{ - Status: AnalysisStatusUnpayable, - AmountInSat: 200, - FeeInSat: 2400, - TotalInSat: 2600, + Status: AnalysisStatusUnpayable, + AmountInSat: 200, + FeeTotalInSat: 2400, + FeeBumpInSat: 0, + TotalInSat: 2600, }, }, } @@ -634,8 +646,11 @@ func TestAnalyzeOnChainValidAmountButUnpayableWithAnyFee(t *testing.T) { if analysis.Status != AnalysisStatusUnpayable { t.Fatal("expected analysis to be unpayable") } - if analysis.FeeInSat != 2400 { - t.Fatalf("expected fee to be %v, but got %v", 2400, analysis.FeeInSat) + if analysis.FeeTotalInSat != 2400 { + t.Fatalf("expected fee to be %v, but got %v", 2400, analysis.FeeTotalInSat) + } + if analysis.FeeBumpInSat != 0 { + t.Fatalf("expected fee bump to be %v, but got %v", 0, analysis.FeeBumpInSat) } if analysis.TotalInSat != 12399 { t.Fatalf("expected total to be %v, but got %v", 12399, analysis.TotalInSat) @@ -653,8 +668,11 @@ func TestAnalyzeOnChainValidAmountButUnpayableWithAnyFee(t *testing.T) { if analysis.Status != AnalysisStatusUnpayable { t.Fatal("expected analysis to be unpayable") } - if analysis.FeeInSat != 60 { - t.Fatalf("expected fee to be %v, but got %v", 60, analysis.FeeInSat) + if analysis.FeeTotalInSat != 60 { + t.Fatalf("expected fee to be %v, but got %v", 60, analysis.FeeTotalInSat) + } + if analysis.FeeBumpInSat != 0 { + t.Fatalf("expected fee bump to be %v, but got %v", 0, analysis.FeeBumpInSat) } if analysis.TotalInSat != 10059 { t.Fatalf("expected total to be %v, but got %v", 10059, analysis.TotalInSat) @@ -685,8 +703,11 @@ func TestAnalyzeOnChainValidAmountButUnpayableWithAnyFeeUsingTFFA(t *testing.T) if analysis.Status != AnalysisStatusUnpayable { t.Fatal("expected analysis to be unpayable") } - if analysis.FeeInSat != 2400 { - t.Fatalf("expected fee to be %v, but got %v", 2400, analysis.FeeInSat) + if analysis.FeeTotalInSat != 2400 { + t.Fatalf("expected fee to be %v, but got %v", 2400, analysis.FeeTotalInSat) + } + if analysis.FeeBumpInSat != 0 { + t.Fatalf("expected fee bump to be %v, but got %v", 0, analysis.FeeBumpInSat) } if analysis.TotalInSat != 600 { t.Fatalf("expected total to be %v, but got %v", 600, analysis.TotalInSat) @@ -704,8 +725,11 @@ func TestAnalyzeOnChainValidAmountButUnpayableWithAnyFeeUsingTFFA(t *testing.T) if analysis.Status != AnalysisStatusUnpayable { t.Fatal("expected analysis to be unpayable") } - if analysis.FeeInSat != 60 { - t.Fatalf("expected fee to be %v, but got %v", 60, analysis.FeeInSat) + if analysis.FeeTotalInSat != 60 { + t.Fatalf("expected fee to be %v, but got %v", 60, analysis.FeeTotalInSat) + } + if analysis.FeeBumpInSat != 0 { + t.Fatalf("expected fee bump to be %v, but got %v", 0, analysis.FeeBumpInSat) } if analysis.AmountInSat != 540 { t.Fatalf("expected amount to be %v, but got %v", 540, analysis.TotalInSat) @@ -720,6 +744,7 @@ func TestAnalyzeOffChain(t *testing.T) { desc string feeWindow *FeeWindow nts *NextTransactionSize + feeBump []*FeeBumpFunction payment *PaymentToInvoice expected *PaymentAnalysis err bool @@ -844,10 +869,10 @@ func TestAnalyzeOffChain(t *testing.T) { }, }, expected: &PaymentAnalysis{ - Status: AnalysisStatusOk, - AmountInSat: 100, - FeeInSat: 240, - TotalInSat: 1340, + Status: AnalysisStatusOk, + AmountInSat: 100, + FeeTotalInSat: 240, + TotalInSat: 1340, SwapFees: &fees.SwapFees{ OutputAmount: 1100, DebtType: fees.DebtTypeNone, @@ -882,10 +907,57 @@ func TestAnalyzeOffChain(t *testing.T) { }, }, expected: &PaymentAnalysis{ - Status: AnalysisStatusOk, - AmountInSat: 100, - FeeInSat: 240, - TotalInSat: 1341, + Status: AnalysisStatusOk, + AmountInSat: 100, + FeeTotalInSat: 240, + TotalInSat: 1341, + SwapFees: &fees.SwapFees{ + OutputAmount: 1101, + DebtType: fees.DebtTypeNone, + DebtAmount: 0, + RoutingFee: 1, + OutputPadding: 1000, + ConfirmationsNeeded: 0, + }, + }, + }, + { + desc: "swap with valid amount and fee bump", + nts: &NextTransactionSize{ + SizeProgression: []SizeForAmount{ + { + AmountInSat: 10_000, + SizeInVByte: 240, + Outpoint: "0437cd7f8525ceed2324359c2d0ba26006d92d856a9c20fa0241106ee5a597c4:0", + UtxoStatus: UtxosStatusUnconfirmed, + }, + }, + ExpectedDebtInSat: 0, + }, + feeBump: []*FeeBumpFunction{ + &FeeBumpFunction{ + []*PartialLinearFunction{ + partialLinearFunction, + }}, + }, + payment: &PaymentToInvoice{ + TakeFeeFromAmount: false, + AmountInSat: 100, + SwapFees: &fees.SwapFees{ + OutputAmount: 1101, + DebtType: fees.DebtTypeNone, + DebtAmount: 0, + RoutingFee: 1, + OutputPadding: 1000, + ConfirmationsNeeded: 0, + }, + }, + expected: &PaymentAnalysis{ + Status: AnalysisStatusOk, + AmountInSat: 100, + FeeTotalInSat: 342, + FeeBumpInSat: 102, + TotalInSat: 1443, SwapFees: &fees.SwapFees{ OutputAmount: 1101, DebtType: fees.DebtTypeNone, @@ -920,10 +992,57 @@ func TestAnalyzeOffChain(t *testing.T) { }, }, expected: &PaymentAnalysis{ - Status: AnalysisStatusOk, - AmountInSat: 100, - FeeInSat: 2400, - TotalInSat: 3501, + Status: AnalysisStatusOk, + AmountInSat: 100, + FeeTotalInSat: 2400, + TotalInSat: 3501, + SwapFees: &fees.SwapFees{ + OutputAmount: 1101, + DebtType: fees.DebtTypeNone, + DebtAmount: 0, + RoutingFee: 1, + OutputPadding: 1000, + ConfirmationsNeeded: 1, + }, + }, + }, + { + desc: "swap with valid amount with 1-conf and fee bump", + nts: &NextTransactionSize{ + SizeProgression: []SizeForAmount{ + { + AmountInSat: 10_000, + SizeInVByte: 240, + Outpoint: "0437cd7f8525ceed2324359c2d0ba26006d92d856a9c20fa0241106ee5a597c4:0", + UtxoStatus: UtxosStatusUnconfirmed, + }, + }, + ExpectedDebtInSat: 0, + }, + feeBump: []*FeeBumpFunction{ + &FeeBumpFunction{ + []*PartialLinearFunction{ + partialLinearFunction, + }}, + }, + payment: &PaymentToInvoice{ + TakeFeeFromAmount: false, + AmountInSat: 100, + SwapFees: &fees.SwapFees{ + OutputAmount: 1101, + DebtType: fees.DebtTypeNone, + DebtAmount: 0, + RoutingFee: 1, + OutputPadding: 1000, + ConfirmationsNeeded: 1, + }, + }, + expected: &PaymentAnalysis{ + Status: AnalysisStatusOk, + AmountInSat: 100, + FeeTotalInSat: 2520, + FeeBumpInSat: 120, + TotalInSat: 3621, SwapFees: &fees.SwapFees{ OutputAmount: 1101, DebtType: fees.DebtTypeNone, @@ -983,10 +1102,10 @@ func TestAnalyzeOffChain(t *testing.T) { }, }, expected: &PaymentAnalysis{ - Status: AnalysisStatusUnpayable, - AmountInSat: 500, - FeeInSat: 240, - TotalInSat: 10240, + Status: AnalysisStatusUnpayable, + AmountInSat: 500, + FeeTotalInSat: 240, + TotalInSat: 10240, SwapFees: &fees.SwapFees{ OutputAmount: 10000, DebtType: fees.DebtTypeNone, @@ -1021,10 +1140,10 @@ func TestAnalyzeOffChain(t *testing.T) { }, }, expected: &PaymentAnalysis{ - Status: AnalysisStatusUnpayable, - AmountInSat: 500, - FeeInSat: 240, - TotalInSat: 11240, + Status: AnalysisStatusUnpayable, + AmountInSat: 500, + FeeTotalInSat: 240, + TotalInSat: 11240, SwapFees: &fees.SwapFees{ OutputAmount: 11000, DebtType: fees.DebtTypeNone, @@ -1050,10 +1169,10 @@ func TestAnalyzeOffChain(t *testing.T) { }, }, expected: &PaymentAnalysis{ - Status: AnalysisStatusOk, - AmountInSat: 100, - FeeInSat: 0, - TotalInSat: 101, + Status: AnalysisStatusOk, + AmountInSat: 100, + FeeTotalInSat: 0, + TotalInSat: 101, SwapFees: &fees.SwapFees{ OutputAmount: 0, DebtType: fees.DebtTypeLend, @@ -1079,10 +1198,10 @@ func TestAnalyzeOffChain(t *testing.T) { }, }, expected: &PaymentAnalysis{ - Status: AnalysisStatusOk, - AmountInSat: 100, - FeeInSat: 0, - TotalInSat: 100, + Status: AnalysisStatusOk, + AmountInSat: 100, + FeeTotalInSat: 0, + TotalInSat: 100, SwapFees: &fees.SwapFees{ OutputAmount: 0, DebtType: fees.DebtTypeLend, @@ -1145,10 +1264,10 @@ func TestAnalyzeOffChain(t *testing.T) { }, }, expected: &PaymentAnalysis{ - Status: AnalysisStatusUnpayable, - AmountInSat: 100, - FeeInSat: 0, - TotalInSat: 110, + Status: AnalysisStatusUnpayable, + AmountInSat: 100, + FeeTotalInSat: 0, + TotalInSat: 110, SwapFees: &fees.SwapFees{ OutputAmount: 0, DebtType: fees.DebtTypeLend, @@ -1183,10 +1302,10 @@ func TestAnalyzeOffChain(t *testing.T) { }, }, expected: &PaymentAnalysis{ - Status: AnalysisStatusOk, - AmountInSat: 5000, - FeeInSat: 240, - TotalInSat: 5240, + Status: AnalysisStatusOk, + AmountInSat: 5000, + FeeTotalInSat: 240, + TotalInSat: 5240, SwapFees: &fees.SwapFees{ OutputAmount: 8000, DebtType: fees.DebtTypeCollect, @@ -1221,10 +1340,10 @@ func TestAnalyzeOffChain(t *testing.T) { }, }, expected: &PaymentAnalysis{ - Status: AnalysisStatusOk, - AmountInSat: 5000, - FeeInSat: 240, - TotalInSat: 5241, + Status: AnalysisStatusOk, + AmountInSat: 5000, + FeeTotalInSat: 240, + TotalInSat: 5241, SwapFees: &fees.SwapFees{ OutputAmount: 8001, DebtType: fees.DebtTypeCollect, @@ -1259,10 +1378,10 @@ func TestAnalyzeOffChain(t *testing.T) { }, }, expected: &PaymentAnalysis{ - Status: AnalysisStatusOk, - AmountInSat: 5000, - FeeInSat: 2400, - TotalInSat: 7401, + Status: AnalysisStatusOk, + AmountInSat: 5000, + FeeTotalInSat: 2400, + TotalInSat: 7401, SwapFees: &fees.SwapFees{ OutputAmount: 8001, DebtType: fees.DebtTypeCollect, @@ -1297,10 +1416,10 @@ func TestAnalyzeOffChain(t *testing.T) { }, }, expected: &PaymentAnalysis{ - Status: AnalysisStatusUnpayable, - AmountInSat: 7400, - FeeInSat: 240, - TotalInSat: 7740, + Status: AnalysisStatusUnpayable, + AmountInSat: 7400, + FeeTotalInSat: 240, + TotalInSat: 7740, SwapFees: &fees.SwapFees{ OutputAmount: 10500, DebtType: fees.DebtTypeCollect, @@ -1335,10 +1454,10 @@ func TestAnalyzeOffChain(t *testing.T) { }, }, expected: &PaymentAnalysis{ - Status: AnalysisStatusUnpayable, - AmountInSat: 7400, - FeeInSat: 240, - TotalInSat: 7740, + Status: AnalysisStatusUnpayable, + AmountInSat: 7400, + FeeTotalInSat: 240, + TotalInSat: 7740, SwapFees: &fees.SwapFees{ OutputAmount: 10500, DebtType: fees.DebtTypeCollect, @@ -1373,10 +1492,10 @@ func TestAnalyzeOffChain(t *testing.T) { }, }, expected: &PaymentAnalysis{ - Status: AnalysisStatusOk, - AmountInSat: 12160, - FeeInSat: 240, - TotalInSat: 12400, + Status: AnalysisStatusOk, + AmountInSat: 12160, + FeeTotalInSat: 240, + TotalInSat: 12400, SwapFees: &fees.SwapFees{ OutputAmount: 14160, DebtType: fees.DebtTypeCollect, @@ -1411,10 +1530,10 @@ func TestAnalyzeOffChain(t *testing.T) { }, }, expected: &PaymentAnalysis{ - Status: AnalysisStatusOk, - AmountInSat: 12160, - FeeInSat: 240, - TotalInSat: 12401, + Status: AnalysisStatusOk, + AmountInSat: 12160, + FeeTotalInSat: 240, + TotalInSat: 12401, SwapFees: &fees.SwapFees{ OutputAmount: 14161, DebtType: fees.DebtTypeCollect, @@ -1449,10 +1568,10 @@ func TestAnalyzeOffChain(t *testing.T) { }, }, expected: &PaymentAnalysis{ - Status: AnalysisStatusUnpayable, - AmountInSat: 12401, - FeeInSat: 240, - TotalInSat: 12641, + Status: AnalysisStatusUnpayable, + AmountInSat: 12401, + FeeTotalInSat: 240, + TotalInSat: 12641, SwapFees: &fees.SwapFees{ OutputAmount: 14401, DebtType: fees.DebtTypeCollect, @@ -1491,10 +1610,10 @@ func TestAnalyzeOffChain(t *testing.T) { }, }, expected: &PaymentAnalysis{ - Status: AnalysisStatusOk, - AmountInSat: 12401, - FeeInSat: 0, - TotalInSat: 12411, + Status: AnalysisStatusOk, + AmountInSat: 12401, + FeeTotalInSat: 0, + TotalInSat: 12411, SwapFees: &fees.SwapFees{ OutputAmount: 0, DebtType: fees.DebtTypeLend, @@ -1533,10 +1652,10 @@ func TestAnalyzeOffChain(t *testing.T) { }, }, expected: &PaymentAnalysis{ - Status: AnalysisStatusOk, - AmountInSat: 12401, - FeeInSat: 240, - TotalInSat: 12651, + Status: AnalysisStatusOk, + AmountInSat: 12401, + FeeTotalInSat: 240, + TotalInSat: 12651, SwapFees: &fees.SwapFees{ OutputAmount: 13421, DebtType: fees.DebtTypeCollect, @@ -1575,10 +1694,10 @@ func TestAnalyzeOffChain(t *testing.T) { }, }, expected: &PaymentAnalysis{ - Status: AnalysisStatusOk, - AmountInSat: 12401, - FeeInSat: 240, - TotalInSat: 12651, + Status: AnalysisStatusOk, + AmountInSat: 12401, + FeeTotalInSat: 240, + TotalInSat: 12651, SwapFees: &fees.SwapFees{ OutputAmount: 12411, DebtType: fees.DebtTypeNone, @@ -1617,10 +1736,10 @@ func TestAnalyzeOffChain(t *testing.T) { }, }, expected: &PaymentAnalysis{ - Status: AnalysisStatusOk, - AmountInSat: 12401, - FeeInSat: 2400, - TotalInSat: 14811, + Status: AnalysisStatusOk, + AmountInSat: 12401, + FeeTotalInSat: 2400, + TotalInSat: 14811, SwapFees: &fees.SwapFees{ OutputAmount: 12411, DebtType: fees.DebtTypeNone, @@ -1660,10 +1779,10 @@ func TestAnalyzeOffChain(t *testing.T) { }, }, expected: &PaymentAnalysis{ - Status: AnalysisStatusOk, - AmountInSat: 316798, - FeeInSat: 25300, - TotalInSat: 342434, + Status: AnalysisStatusOk, + AmountInSat: 316798, + FeeTotalInSat: 25300, + TotalInSat: 342434, SwapFees: &fees.SwapFees{ OutputAmount: 337820, DebtType: fees.DebtTypeCollect, @@ -1741,10 +1860,10 @@ func TestAnalyzeOffChain(t *testing.T) { }, }, expected: &PaymentAnalysis{ - Status: AnalysisStatusUnpayable, - AmountInSat: 0, - FeeInSat: 785, - TotalInSat: 1585, + Status: AnalysisStatusUnpayable, + AmountInSat: 0, + FeeTotalInSat: 785, + TotalInSat: 1585, }, }, { @@ -1776,10 +1895,10 @@ func TestAnalyzeOffChain(t *testing.T) { }, }, expected: &PaymentAnalysis{ - Status: AnalysisStatusOk, - AmountInSat: 9977, - FeeInSat: 64, - TotalInSat: 10055, + Status: AnalysisStatusOk, + AmountInSat: 9977, + FeeTotalInSat: 64, + TotalInSat: 10055, SwapFees: &fees.SwapFees{ OutputAmount: 30677, DebtType: fees.DebtTypeCollect, @@ -1819,10 +1938,10 @@ func TestAnalyzeOffChain(t *testing.T) { }, }, expected: &PaymentAnalysis{ - Status: AnalysisStatusOk, - AmountInSat: 10159, - FeeInSat: 124, - TotalInSat: 10290, + Status: AnalysisStatusOk, + AmountInSat: 10159, + FeeTotalInSat: 124, + TotalInSat: 10290, SwapFees: &fees.SwapFees{ OutputAmount: 43458, DebtType: fees.DebtTypeCollect, @@ -1862,9 +1981,9 @@ func TestAnalyzeOffChain(t *testing.T) { }, }, expected: &PaymentAnalysis{ - Status: AnalysisStatusUnpayable, - FeeInSat: 98, - TotalInSat: 86, + Status: AnalysisStatusUnpayable, + FeeTotalInSat: 98, + TotalInSat: 86, }, }, { @@ -1896,10 +2015,10 @@ func TestAnalyzeOffChain(t *testing.T) { }, }, expected: &PaymentAnalysis{ - Status: AnalysisStatusUnpayable, - AmountInSat: 133, - FeeInSat: 103, - TotalInSat: 240, + Status: AnalysisStatusUnpayable, + AmountInSat: 133, + FeeTotalInSat: 103, + TotalInSat: 240, SwapFees: &fees.SwapFees{ RoutingFee: 4, OutputPadding: 163, @@ -1938,10 +2057,10 @@ func TestAnalyzeOffChain(t *testing.T) { }, }, expected: &PaymentAnalysis{ - Status: AnalysisStatusOk, - AmountInSat: 1263, - FeeInSat: 119, - TotalInSat: 1385, + Status: AnalysisStatusOk, + AmountInSat: 1263, + FeeTotalInSat: 119, + TotalInSat: 1385, SwapFees: &fees.SwapFees{ RoutingFee: 3, OutputPadding: 0, @@ -1980,10 +2099,10 @@ func TestAnalyzeOffChain(t *testing.T) { }, }, expected: &PaymentAnalysis{ - Status: AnalysisStatusOk, - AmountInSat: 52, - FeeInSat: 66, - TotalInSat: 119, + Status: AnalysisStatusOk, + AmountInSat: 52, + FeeTotalInSat: 66, + TotalInSat: 119, SwapFees: &fees.SwapFees{ RoutingFee: 1, OutputPadding: 0, @@ -2022,10 +2141,10 @@ func TestAnalyzeOffChain(t *testing.T) { }, }, expected: &PaymentAnalysis{ - Status: AnalysisStatusOk, - AmountInSat: 2470, - FeeInSat: 76, - TotalInSat: 2550, + Status: AnalysisStatusOk, + AmountInSat: 2470, + FeeTotalInSat: 76, + TotalInSat: 2550, SwapFees: &fees.SwapFees{ RoutingFee: 4, OutputPadding: 0, @@ -2068,10 +2187,10 @@ func TestAnalyzeOffChain(t *testing.T) { }, }, expected: &PaymentAnalysis{ - Status: AnalysisStatusOk, - AmountInSat: 59223, - FeeInSat: 215, - TotalInSat: 59477, + Status: AnalysisStatusOk, + AmountInSat: 59223, + FeeTotalInSat: 215, + TotalInSat: 59477, SwapFees: &fees.SwapFees{ RoutingFee: 39, OutputPadding: 0, @@ -2114,10 +2233,10 @@ func TestAnalyzeOffChain(t *testing.T) { }, }, expected: &PaymentAnalysis{ - Status: AnalysisStatusOk, - AmountInSat: 72369, - FeeInSat: 213, - TotalInSat: 72667, + Status: AnalysisStatusOk, + AmountInSat: 72369, + FeeTotalInSat: 213, + TotalInSat: 72667, SwapFees: &fees.SwapFees{ RoutingFee: 85, OutputPadding: 0, @@ -2156,10 +2275,10 @@ func TestAnalyzeOffChain(t *testing.T) { }, }, expected: &PaymentAnalysis{ - Status: AnalysisStatusOk, - AmountInSat: 98800, - FeeInSat: 84, - TotalInSat: 98972, + Status: AnalysisStatusOk, + AmountInSat: 98800, + FeeTotalInSat: 84, + TotalInSat: 98972, SwapFees: &fees.SwapFees{ RoutingFee: 88, OutputPadding: 0, @@ -2198,10 +2317,10 @@ func TestAnalyzeOffChain(t *testing.T) { }, }, expected: &PaymentAnalysis{ - Status: AnalysisStatusUnpayable, - AmountInSat: 249, - FeeInSat: 101, - TotalInSat: 353, + Status: AnalysisStatusUnpayable, + AmountInSat: 249, + FeeTotalInSat: 101, + TotalInSat: 353, SwapFees: &fees.SwapFees{ RoutingFee: 3, OutputPadding: 72, @@ -2240,10 +2359,10 @@ func TestAnalyzeOffChain(t *testing.T) { }, }, expected: &PaymentAnalysis{ - Status: AnalysisStatusOk, - AmountInSat: 321, - FeeInSat: 101, - TotalInSat: 425, + Status: AnalysisStatusOk, + AmountInSat: 321, + FeeTotalInSat: 101, + TotalInSat: 425, SwapFees: &fees.SwapFees{ RoutingFee: 3, OutputPadding: 0, @@ -2283,10 +2402,10 @@ func TestAnalyzeOffChain(t *testing.T) { }, // In this scenario there's 1 sat that is lost and will be burn in fees to the miner expected: &PaymentAnalysis{ - Status: AnalysisStatusOk, - AmountInSat: 53534, - FeeInSat: 90, - TotalInSat: 53704, + Status: AnalysisStatusOk, + AmountInSat: 53534, + FeeTotalInSat: 90, + TotalInSat: 53704, SwapFees: &fees.SwapFees{ RoutingFee: 79, OutputPadding: 0, @@ -2325,10 +2444,10 @@ func TestAnalyzeOffChain(t *testing.T) { }, }, expected: &PaymentAnalysis{ - Status: AnalysisStatusUnpayable, - AmountInSat: 446, - FeeInSat: 53, - TotalInSat: 500, + Status: AnalysisStatusUnpayable, + AmountInSat: 446, + FeeTotalInSat: 53, + TotalInSat: 500, SwapFees: &fees.SwapFees{ RoutingFee: 1, OutputPadding: 99, @@ -2367,9 +2486,9 @@ func TestAnalyzeOffChain(t *testing.T) { }, }, expected: &PaymentAnalysis{ - Status: AnalysisStatusUnpayable, - FeeInSat: 84, - TotalInSat: 99056, + Status: AnalysisStatusUnpayable, + FeeTotalInSat: 84, + TotalInSat: 99056, }, }, } @@ -2387,7 +2506,7 @@ func TestAnalyzeOffChain(t *testing.T) { feeWindow = tC.feeWindow } - analyzer := NewPaymentAnalyzer(feeWindow, nts, nil) + analyzer := NewPaymentAnalyzer(feeWindow, nts, tC.feeBump) analysis, err := analyzer.ToInvoice(tC.payment) if err == nil && tC.err { diff --git a/libwallet/partiallysignedtransaction.go b/libwallet/partiallysignedtransaction.go index 80752fc6..d2bee8ae 100644 --- a/libwallet/partiallysignedtransaction.go +++ b/libwallet/partiallysignedtransaction.go @@ -21,14 +21,26 @@ type SigningExpectations struct { amount int64 change MuunAddress fee int64 + alternative bool } -func NewSigningExpectations(destination string, amount int64, change MuunAddress, fee int64) *SigningExpectations { +func NewSigningExpectations(destination string, amount int64, change MuunAddress, fee int64, alternative bool) *SigningExpectations { return &SigningExpectations{ destination, amount, change, fee, + alternative, + } +} + +func (e *SigningExpectations) ForAlternativeTransaction() *SigningExpectations { + return &SigningExpectations{ + e.destination, + e.amount, + e.change, + e.fee, + true, } } @@ -212,16 +224,24 @@ func (p *PartiallySignedTransaction) Verify(expectations *SigningExpectations, u network := userPublicKey.Network - // We expect TX to be frugal in their ouputs: one to the destination and an optional change. + // We expect TX to be frugal in their outputs: one to the destination and an optional change. // If we were to receive more than that, we consider it invalid. if expectations.change != nil { - if len(p.tx.TxOut) != 2 { + + // 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)) + } + + } else if len(p.tx.TxOut) != 2 { return fmt.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)) - } + + } else if len(p.tx.TxOut) != 1 { + return fmt.Errorf("expected destination output only but found %v", len(p.tx.TxOut)) } // Build output script corresponding to the destination address. @@ -253,14 +273,40 @@ func (p *PartiallySignedTransaction) Verify(expectations *SigningExpectations, u } } - // Fail if not destination output was found in the TX. - if toOutput == nil { - return errors.New("destination output is not present") - } + 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") + } - // 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) + if toOutput != nil && toOutput.Value >= expectedAmount { + return fmt.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)) + } + + // Re-adjust our expectations by moving the reduced destination amount + // to fee. + if toOutput == nil { + expectedFee += expectedAmount + expectedAmount = 0 + } else { + expectedFee += expectedAmount - toOutput.Value + expectedAmount = toOutput.Value + } + + } else { + // Fail if not destination output was found in the TX. + if toOutput == nil { + return errors.New("destination output is not present") + } + + // 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) + } } /* diff --git a/libwallet/partiallysignedtransaction_test.go b/libwallet/partiallysignedtransaction_test.go index 8951575f..28657e0c 100644 --- a/libwallet/partiallysignedtransaction_test.go +++ b/libwallet/partiallysignedtransaction_test.go @@ -892,6 +892,45 @@ func TestPartiallySignedTransaction_Verify(t *testing.T) { changePath2 = "m/schema:1'/recovery:1'/change:0/1" changeVersion2 = addresses.V4 + hexTx5 = "0100000001a51cc04ab631dee48c989a7cd55c4abc451aa958b09d4579cc9852c52baa57ae0100000000ffffffff02a086010000000000220020452f4ae303ec79acd2bce8f7ddb6469f1060d9146003ea34887e5bbdf021c787302dfa02000000002200202ccf0ca2c9b5077ce8345785af26a39277003886fb358877e4083a3fcc5cd66700000000" + + txIndex5 = 1 + txAmount5 = 100000000 + txIdHex5 = "907e3c0c82b36b11b8543c9e058fe6e23d5ad35881f776e1ca9049e622f2cf80" + txAddressPath5 = "m/schema:1'/recovery:1'/external:1/0" + txAddress5 = "bcrt1q9n8segkfk5rhe6p527z67f4rjfmsqwyxlv6csalypqarlnzu6ens8cm8ye" + txAddressVersion5 = addresses.V4 + + changeAddress5 = "bcrt1qg5h54ccra3u6e54uarmamdjxnugxpkg5vqp75dyg0edmmuppc7rsdfcvcp" + changePath5 = "m/schema:1'/recovery:1'/change:0/1" + changeVersion5 = addresses.V4 + + hexTx6 = "0100000001a51cc04ab631dee48c989a7cd55c4abc451aa958b09d4579cc9852c52baa57ae0100000000ffffffff02a086010000000000220020452f4ae303ec79acd2bce8f7ddb6469f1060d9146003ea34887e5bbdf021c787e8030000000000002200202ccf0ca2c9b5077ce8345785af26a39277003886fb358877e4083a3fcc5cd66700000000" + + txIndex6 = 1 + txAmount6 = 100000000 + txIdHex6 = "69391b987ec374f8e61a5fabb94899cd5efe802ee0f4d890bbbdbd18b05cac0f" + txAddressPath6 = "m/schema:1'/recovery:1'/external:1/0" + txAddress6 = "bcrt1q9n8segkfk5rhe6p527z67f4rjfmsqwyxlv6csalypqarlnzu6ens8cm8ye" + txAddressVersion6 = addresses.V4 + + changeAddress6 = "bcrt1qg5h54ccra3u6e54uarmamdjxnugxpkg5vqp75dyg0edmmuppc7rsdfcvcp" + changePath6 = "m/schema:1'/recovery:1'/change:0/1" + changeVersion6 = addresses.V4 + + hexTx7 = "0100000001a51cc04ab631dee48c989a7cd55c4abc451aa958b09d4579cc9852c52baa57ae0100000000ffffffff01a086010000000000220020452f4ae303ec79acd2bce8f7ddb6469f1060d9146003ea34887e5bbdf021c78700000000" + + txIndex7 = 1 + txAmount7 = 100000000 + txIdHex7 = "383136ae84ddd35059a087fb56571f4809d97f09c04eac779ba31f6121818461" + txAddressPath7 = "m/schema:1'/recovery:1'/external:1/0" + txAddress7 = "bcrt1q9n8segkfk5rhe6p527z67f4rjfmsqwyxlv6csalypqarlnzu6ens8cm8ye" + txAddressVersion7 = addresses.V4 + + changeAddress7 = "bcrt1qg5h54ccra3u6e54uarmamdjxnugxpkg5vqp75dyg0edmmuppc7rsdfcvcp" + changePath7 = "m/schema:1'/recovery:1'/change:0/1" + changeVersion7 = addresses.V4 + encodedUserKey = "tpubDAKxNPypXDF3GNCpXFUh6sCdxz7DY9eKMgFxYBgyRSiYWXrBLgdtkPuMbQQzrsYLVyPPSHmNcduLRRd9TSMaYrGLryp8KNkkYBm6eka1Bem" encodedMuunKey = "tpubDBZaivUL3Hv8r25JDupShPuWVkGcwM7NgbMBwkhQLfWu18iBbyQCbRdyg1wRMjoWdZN7Afg3F25zs4c8E6Q4VJrGqAw51DJeqacTFABV9u8" @@ -901,6 +940,9 @@ func TestPartiallySignedTransaction_Verify(t *testing.T) { 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, @@ -912,6 +954,24 @@ func TestPartiallySignedTransaction_Verify(t *testing.T) { basePath, Regtest()) + tx := wire.NewMsgTx(1) + txBytes, _ := hex.DecodeString(hexTx1) + _ = 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 + tx.TxOut[1].Value = 1000 + // change + tx.TxOut[0].Value = 100_000 + tx.TxOut = []*wire.TxOut{tx.TxOut[0]} + buffer := new(bytes.Buffer) + _ = tx.Serialize(buffer) + + println(tx.TxHash().String()) + println(hex.EncodeToString(buffer.Bytes())) + type fields struct { tx string inputs []Input @@ -937,6 +997,18 @@ func TestPartiallySignedTransaction_Verify(t *testing.T) { outpoint: outpoint{index: txIndex3, amount: txAmount3, txId: txId3}, address: addresses.New(txAddressVersion3, txAddressPath3, txAddress3), } + inputForFifthTx := input{ + outpoint: outpoint{index: txIndex5, amount: txAmount5, txId: txId5}, + address: addresses.New(txAddressVersion5, txAddressPath5, txAddress5), + } + inputForSixthTx := input{ + outpoint: outpoint{index: txIndex6, amount: txAmount6, txId: txId6}, + address: addresses.New(txAddressVersion6, txAddressPath6, txAddress6), + } + inputForSeventhTx := input{ + outpoint: outpoint{index: txIndex7, amount: txAmount7, txId: txId7}, + address: addresses.New(txAddressVersion7, txAddressPath7, txAddress7), + } tests := []struct { name string fields fields @@ -1086,6 +1158,155 @@ func TestPartiallySignedTransaction_Verify(t *testing.T) { }, wantErr: false, }, + { + name: "alternative with half the destination amount with change", + fields: fields{ + tx: hexTx5, + inputs: []Input{&inputForFifthTx}, + }, + args: args{ + expectations: &SigningExpectations{ + destination: "bcrt1q9n8segkfk5rhe6p527z67f4rjfmsqwyxlv6csalypqarlnzu6ens8cm8ye", + amount: txAmount5 - 100_000 - 100, + change: addresses.New(changeVersion5, changePath5, changeAddress5), + fee: 100, + alternative: true, + }, + userPublicKey: userPublicKey, + muunPublickKey: muunPublicKey, + }, + }, + { + name: "alternative with more change than expected", + fields: fields{ + tx: hexTx5, + inputs: []Input{&inputForFifthTx}, + }, + args: args{ + expectations: &SigningExpectations{ + destination: "bcrt1q9n8segkfk5rhe6p527z67f4rjfmsqwyxlv6csalypqarlnzu6ens8cm8ye", + amount: txAmount5 - 10_000 - 100, + change: addresses.New(changeVersion5, changePath5, changeAddress5), + fee: 100, + alternative: true, + }, + userPublicKey: userPublicKey, + muunPublickKey: muunPublicKey, + }, + wantErr: true, + }, + { + name: "alternative with a higher amount than expected", + fields: fields{ + tx: hexTx5, + inputs: []Input{&inputForFifthTx}, + }, + args: args{ + expectations: &SigningExpectations{ + destination: "bcrt1q9n8segkfk5rhe6p527z67f4rjfmsqwyxlv6csalypqarlnzu6ens8cm8ye", + amount: 1000, + change: addresses.New(changeVersion5, changePath5, changeAddress5), + fee: 100, + alternative: true, + }, + userPublicKey: userPublicKey, + muunPublickKey: muunPublicKey, + }, + wantErr: true, + }, + { + name: "alternative with mismatched destination address", + fields: fields{ + tx: hexTx5, + inputs: []Input{&inputForFifthTx}, + }, + args: args{ + expectations: &SigningExpectations{ + destination: "bcrt1q9yzsghvmmn7wv3esylrvn3c469s4ce4thk7qmxdly4tzk4f8vvjsqv0crh", + amount: txAmount5 - 100_000 - 100, + change: addresses.New(changeVersion5, changePath5, changeAddress5), + fee: 100, + alternative: true, + }, + userPublicKey: userPublicKey, + muunPublickKey: muunPublicKey, + }, + wantErr: true, + }, + { + name: "alternative with mismatched change address", + fields: fields{ + tx: hexTx5, + inputs: []Input{&inputForFifthTx}, + }, + args: args{ + expectations: &SigningExpectations{ + destination: "bcrt1q9n8segkfk5rhe6p527z67f4rjfmsqwyxlv6csalypqarlnzu6ens8cm8ye", + amount: txAmount5 - 100_000 - 100, + change: addresses.New(changeVersion5, changePath5, txAddress1), + fee: 100, + alternative: true, + }, + userPublicKey: userPublicKey, + muunPublickKey: muunPublicKey, + }, + wantErr: true, + }, + { + name: "alternative with near-dust destination amount with change", + fields: fields{ + tx: hexTx6, + inputs: []Input{&inputForSixthTx}, + }, + args: args{ + expectations: &SigningExpectations{ + destination: "bcrt1q9n8segkfk5rhe6p527z67f4rjfmsqwyxlv6csalypqarlnzu6ens8cm8ye", + amount: txAmount6 - 100_000 - 100, + change: addresses.New(changeVersion6, changePath6, changeAddress6), + fee: 100, + alternative: true, + }, + userPublicKey: userPublicKey, + muunPublickKey: muunPublicKey, + }, + }, + { + name: "alternative with no destination change", + fields: fields{ + tx: hexTx7, + inputs: []Input{&inputForSeventhTx}, + }, + args: args{ + expectations: &SigningExpectations{ + destination: "bcrt1q9n8segkfk5rhe6p527z67f4rjfmsqwyxlv6csalypqarlnzu6ens8cm8ye", + amount: txAmount7 - 100_000 - 100, + change: addresses.New(changeVersion7, changePath7, changeAddress7), + fee: 100, + alternative: true, + }, + userPublicKey: userPublicKey, + muunPublickKey: muunPublicKey, + }, + }, + { + name: "alternative with no destination and more change", + fields: fields{ + tx: hexTx7, + inputs: []Input{&inputForSeventhTx}, + }, + args: args{ + expectations: &SigningExpectations{ + destination: "bcrt1q9n8segkfk5rhe6p527z67f4rjfmsqwyxlv6csalypqarlnzu6ens8cm8ye", + amount: txAmount7 - 9_000 - 100, + change: addresses.New(changeVersion7, changePath7, changeAddress7), + fee: 100, + alternative: true, + }, + userPublicKey: userPublicKey, + muunPublickKey: muunPublicKey, + }, + wantErr: true, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -1096,11 +1317,43 @@ func TestPartiallySignedTransaction_Verify(t *testing.T) { if err != nil { panic(err) } - err = p.Verify(tt.args.expectations, tt.args.userPublicKey, tt.args.muunPublickKey) - t.Logf("test %v returned %v", tt.name, err) - if (err != nil) != tt.wantErr { - t.Errorf("Verify() error = %v, wantErr %v", err, tt.wantErr) + + // We do something a bit particular here. We always run alternative and + // non-alternative verifications. This allows us to test the invariant + // that valid alternative are not valid non-alternative and vice-versa. + + nonAlternativeExpectations := *tt.args.expectations + nonAlternativeExpectations.alternative = false + + 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) + + t.Logf("test %v non-alternative returned %v", tt.name, errNonAlternative) + t.Logf("test %v alternative returned %v", tt.name, errAlternative) + + if tt.args.expectations.alternative { + + if (errAlternative != nil) != tt.wantErr { + t.Errorf("Verify() error = %v, wantErr %v", errAlternative, tt.wantErr) + } + + if errNonAlternative == nil && errAlternative == nil { + t.Errorf("Verify() alternative TX should not verify as non-alternative") + } + + } else { + + if (errNonAlternative != nil) != tt.wantErr { + t.Errorf("Verify() error = %v, wantErr %v", errNonAlternative, tt.wantErr) + } + + if errNonAlternative == nil && errAlternative == nil { + t.Errorf("Verify() non-alternative TX should not verify as alternative") + } } + }) } } diff --git a/libwallet/utils_test.go b/libwallet/utils_test.go new file mode 100644 index 00000000..9f348460 --- /dev/null +++ b/libwallet/utils_test.go @@ -0,0 +1,12 @@ +package libwallet + +// Utils for tests + +import ( + "encoding/hex" +) + +func hexToBytes(value string) []byte { + bytes, _ := hex.DecodeString(value) + return bytes +} diff --git a/libwallet/walletdb/fee_bump_repository.go b/libwallet/walletdb/fee_bump_repository.go index 5e94ac10..9ede2e94 100644 --- a/libwallet/walletdb/fee_bump_repository.go +++ b/libwallet/walletdb/fee_bump_repository.go @@ -10,18 +10,26 @@ import ( ) type FeeBumpRepository interface { - Store(feeBumpFunctions []*operation.FeeBumpFunction) error - GetAll() ([]*operation.FeeBumpFunction, error) + Store(feeBumpFunctionSet *operation.FeeBumpFunctionSet) error + GetAll() (*operation.FeeBumpFunctionSet, error) GetCreationDate() (*time.Time, error) RemoveAll() error } +type FeeBumpFunctionSet struct { + gorm.Model + UUID string + RefreshPolicy string + FeeBumpFunctions []FeeBumpFunction `gorm:"foreignKey:SetID"` +} + 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"` } type PartialLinearFunction struct { @@ -37,8 +45,8 @@ type GORMFeeBumpRepository struct { db *gorm.DB } -func (r *GORMFeeBumpRepository) Store(feeBumpFunctions []*operation.FeeBumpFunction) error { - dbFeeBumpFunctions := mapToDBFeeBumpFunctions(feeBumpFunctions) +func (r *GORMFeeBumpRepository) Store(feeBumpFunctionSet *operation.FeeBumpFunctionSet) error { + dbFeeBumpFunctionSet := mapToDBFeeBumpFunctions(feeBumpFunctionSet) tx := r.db.Begin() @@ -48,11 +56,9 @@ func (r *GORMFeeBumpRepository) Store(feeBumpFunctions []*operation.FeeBumpFunct return fmt.Errorf("error when trying to remove old fee bump functions: %w", err) } - for _, feeBumpFunction := range dbFeeBumpFunctions { - if err := tx.Create(&feeBumpFunction).Error; err != nil { - tx.Rollback() - return err - } + if err := tx.Create(&dbFeeBumpFunctionSet).Error; err != nil { + tx.Rollback() + return err } if err := tx.Commit().Error; err != nil { @@ -61,29 +67,29 @@ func (r *GORMFeeBumpRepository) Store(feeBumpFunctions []*operation.FeeBumpFunct return nil } -func (r *GORMFeeBumpRepository) GetAll() ([]*operation.FeeBumpFunction, error) { - var dbFeeBumpFunctions []FeeBumpFunction +func (r *GORMFeeBumpRepository) GetAll() (*operation.FeeBumpFunctionSet, error) { + var dbFeeBumpFunctionSet FeeBumpFunctionSet - result := r.db.Preload("PartialLinearFunctions").Order("position asc").Find(&dbFeeBumpFunctions) + result := r.db.Preload("FeeBumpFunctions.PartialLinearFunctions").Find(&dbFeeBumpFunctionSet) - if result.Error != nil { + if result.Error != nil && !gorm.IsRecordNotFoundError(result.Error) { return nil, result.Error } - feeBumpFunctions := mapToOperationFeeBumpFunctions(dbFeeBumpFunctions) + feeBumpFunctionSet := mapToOperationFeeBumpFunctions(dbFeeBumpFunctionSet) - return feeBumpFunctions, nil + return feeBumpFunctionSet, nil } func (r *GORMFeeBumpRepository) GetCreationDate() (*time.Time, error) { - var dbFeeBumpFunction FeeBumpFunction - result := r.db.First(&dbFeeBumpFunction) + var dbFeeBumpFunctionSet FeeBumpFunctionSet + result := r.db.First(&dbFeeBumpFunctionSet) if result.Error != nil { return nil, result.Error } - return &dbFeeBumpFunction.CreatedAt, nil + return &dbFeeBumpFunctionSet.CreatedAt, nil } func (r *GORMFeeBumpRepository) RemoveAll() error { @@ -97,13 +103,19 @@ func (r *GORMFeeBumpRepository) RemoveAll() error { } func removeAllInTransaction(tx *gorm.DB) error { - result := tx.Delete(FeeBumpFunction{}) + result := tx.Unscoped().Delete(FeeBumpFunctionSet{}) if result.Error != nil { tx.Rollback() return result.Error } - result = tx.Delete(PartialLinearFunction{}) + result = tx.Unscoped().Delete(FeeBumpFunction{}) + if result.Error != nil { + tx.Rollback() + return result.Error + } + + result = tx.Unscoped().Delete(PartialLinearFunction{}) if result.Error != nil { tx.Rollback() return result.Error @@ -111,9 +123,9 @@ func removeAllInTransaction(tx *gorm.DB) error { return nil } -func mapToDBFeeBumpFunctions(feeBumpFunctions []*operation.FeeBumpFunction) []FeeBumpFunction { +func mapToDBFeeBumpFunctions(feeBumpFunctionSet *operation.FeeBumpFunctionSet) FeeBumpFunctionSet { var dbFeeBumpFunctions []FeeBumpFunction - for i, feeBumpFunction := range feeBumpFunctions { + for i, feeBumpFunction := range feeBumpFunctionSet.FeeBumpFunctions { var dbPartialLinearFunctions []PartialLinearFunction for _, partialLinearFunction := range feeBumpFunction.PartialLinearFunctions { dbPartialLinearFunctions = append(dbPartialLinearFunctions, PartialLinearFunction{ @@ -130,10 +142,15 @@ func mapToDBFeeBumpFunctions(feeBumpFunctions []*operation.FeeBumpFunction) []Fe }) } - return dbFeeBumpFunctions + return FeeBumpFunctionSet{ + UUID: feeBumpFunctionSet.UUID, + RefreshPolicy: feeBumpFunctionSet.RefreshPolicy, + FeeBumpFunctions: dbFeeBumpFunctions, + } } -func mapToOperationFeeBumpFunctions(dbFeeBumpFunctions []FeeBumpFunction) []*operation.FeeBumpFunction { +func mapToOperationFeeBumpFunctions(dbFeeBumpFunctionSet FeeBumpFunctionSet) *operation.FeeBumpFunctionSet { + dbFeeBumpFunctions := dbFeeBumpFunctionSet.FeeBumpFunctions var feeBumpFunctions []*operation.FeeBumpFunction for _, dbFeeBumpFunction := range dbFeeBumpFunctions { var partialLinearFunctions []*operation.PartialLinearFunction @@ -149,10 +166,15 @@ func mapToOperationFeeBumpFunctions(dbFeeBumpFunctions []FeeBumpFunction) []*ope feeBumpFunctions = append( feeBumpFunctions, &operation.FeeBumpFunction{ - CreatedAt: dbFeeBumpFunction.CreatedAt, PartialLinearFunctions: partialLinearFunctions, }, ) } - return feeBumpFunctions + + return &operation.FeeBumpFunctionSet{ + CreatedAt: dbFeeBumpFunctionSet.CreatedAt, + UUID: dbFeeBumpFunctionSet.UUID, + RefreshPolicy: dbFeeBumpFunctionSet.RefreshPolicy, + FeeBumpFunctions: feeBumpFunctions, + } } diff --git a/libwallet/walletdb/fee_bump_repository_test.go b/libwallet/walletdb/fee_bump_repository_test.go index 82d765c6..e04af1ac 100644 --- a/libwallet/walletdb/fee_bump_repository_test.go +++ b/libwallet/walletdb/fee_bump_repository_test.go @@ -5,6 +5,7 @@ import ( "path" "reflect" "testing" + "time" "github.com/muun/libwallet/operation" ) @@ -18,72 +19,93 @@ func TestCreateFeeBumpFunctions(t *testing.T) { repository := db.NewFeeBumpRepository() - expectedFeeBumpFunctions := []*operation.FeeBumpFunction{ - { - PartialLinearFunctions: []*operation.PartialLinearFunction{ - { - LeftClosedEndpoint: 0, - RightOpenEndpoint: 300, - Slope: 2, - Intercept: 300, - }, - { - LeftClosedEndpoint: 300, - RightOpenEndpoint: math.Inf(1), - Slope: 3, - Intercept: 200, + expectedFeeBumpFunctionSet := &operation.FeeBumpFunctionSet{ + CreatedAt: time.Now(), + UUID: "uuid1", + RefreshPolicy: "foreground", + FeeBumpFunctions: []*operation.FeeBumpFunction{ + { + PartialLinearFunctions: []*operation.PartialLinearFunction{ + { + LeftClosedEndpoint: 0, + RightOpenEndpoint: 300, + Slope: 2, + Intercept: 300, + }, + { + LeftClosedEndpoint: 300, + RightOpenEndpoint: math.Inf(1), + Slope: 3, + Intercept: 200, + }, }, }, - }, - { - PartialLinearFunctions: []*operation.PartialLinearFunction{ - { - LeftClosedEndpoint: 0, - RightOpenEndpoint: 100, - Slope: 2, - Intercept: 100, - }, - { - LeftClosedEndpoint: 100, - RightOpenEndpoint: math.Inf(1), - Slope: 3, - Intercept: 500, + { + PartialLinearFunctions: []*operation.PartialLinearFunction{ + { + LeftClosedEndpoint: 0, + RightOpenEndpoint: 100, + Slope: 2, + Intercept: 100, + }, + { + LeftClosedEndpoint: 100, + RightOpenEndpoint: math.Inf(1), + Slope: 3, + Intercept: 500, + }, }, }, - }, - { - PartialLinearFunctions: []*operation.PartialLinearFunction{ - { - LeftClosedEndpoint: 0, - RightOpenEndpoint: 1000, - Slope: 2, - Intercept: 1000, - }, - { - LeftClosedEndpoint: 1000, - RightOpenEndpoint: math.Inf(1), - Slope: 3, - Intercept: 1500, + { + PartialLinearFunctions: []*operation.PartialLinearFunction{ + { + LeftClosedEndpoint: 0, + RightOpenEndpoint: 1000, + Slope: 2, + Intercept: 1000, + }, + { + LeftClosedEndpoint: 1000, + RightOpenEndpoint: math.Inf(1), + Slope: 3, + Intercept: 1500, + }, }, }, }, } - err = repository.Store(expectedFeeBumpFunctions) + err = repository.Store(expectedFeeBumpFunctionSet) if err != nil { t.Fatalf("failed to save fee bump functions: %v", err) } - loadedFeeBumpFunctions, err := repository.GetAll() + loadedFeeBumpFunctionSet, err := repository.GetAll() if err != nil { t.Fatalf("failed to load fee bump functions: %v", err) } - if len(loadedFeeBumpFunctions) != len(expectedFeeBumpFunctions) { - t.Errorf("expected %d fee bump functions, got %d", len(expectedFeeBumpFunctions), len(loadedFeeBumpFunctions)) + if loadedFeeBumpFunctionSet.UUID != expectedFeeBumpFunctionSet.UUID { + t.Errorf("expected %v UUID, got %v", expectedFeeBumpFunctionSet.UUID, loadedFeeBumpFunctionSet.UUID) + } + + if loadedFeeBumpFunctionSet.RefreshPolicy != expectedFeeBumpFunctionSet.RefreshPolicy { + t.Errorf( + "expected %v refresh policy, got %v", + expectedFeeBumpFunctionSet.RefreshPolicy, + loadedFeeBumpFunctionSet.RefreshPolicy, + ) + } + + if len(loadedFeeBumpFunctionSet.FeeBumpFunctions) != len(expectedFeeBumpFunctionSet.FeeBumpFunctions) { + t.Errorf( + "expected %d fee bump functions, got %d", + len(expectedFeeBumpFunctionSet.FeeBumpFunctions), + len(loadedFeeBumpFunctionSet.FeeBumpFunctions)) } - for i, loadedFeeBumpFunction := range loadedFeeBumpFunctions { + expectedFeeBumpFunctions := expectedFeeBumpFunctionSet.FeeBumpFunctions + for i, loadedFeeBumpFunction := range loadedFeeBumpFunctionSet.FeeBumpFunctions { if len(loadedFeeBumpFunction.PartialLinearFunctions) != len(expectedFeeBumpFunctions[i].PartialLinearFunctions) { t.Errorf( "expected %d intervals, got %d", @@ -107,8 +129,8 @@ func TestCreateFeeBumpFunctions(t *testing.T) { t.Fatalf("failed getting creation date: %v", err) } - if loadedFeeBumpFunctions[0].CreatedAt != *creationDate { - t.Fatalf("date mismatch: got: %v, expected: %v", *creationDate, loadedFeeBumpFunctions[0].CreatedAt) + if loadedFeeBumpFunctionSet.CreatedAt != *creationDate { + t.Fatalf("date mismatch: got: %v, expected: %v", *creationDate, loadedFeeBumpFunctionSet.CreatedAt) } err = repository.RemoveAll() @@ -116,12 +138,12 @@ func TestCreateFeeBumpFunctions(t *testing.T) { t.Fatalf("failed removing all fee bump functions: %v", err) } - loadedFeeBumpFunctions, err = repository.GetAll() + loadedFeeBumpFunctionSet, err = repository.GetAll() if err != nil { t.Fatalf("failed to load fee bump functions: %v", err) } - if len(loadedFeeBumpFunctions) != 0 { + if len(loadedFeeBumpFunctionSet.FeeBumpFunctions) != 0 { t.Fatalf("fee bump functions were not removed") } diff --git a/libwallet/walletdb/walletdb.go b/libwallet/walletdb/walletdb.go index 959e8a84..e37601a7 100644 --- a/libwallet/walletdb/walletdb.go +++ b/libwallet/walletdb/walletdb.go @@ -148,6 +148,39 @@ func migrate(db *gorm.DB) error { return tx.DropTable(&FeeBumpFunction{}, &PartialLinearFunction{}).Error }, }, + { + ID: "Add top level FeeBumpFunctionSet table and SetID field", + Migrate: func(tx *gorm.DB) error { + + type FeeBumpFunctionSet struct { + gorm.Model + UUID string + RefreshPolicy string + FeeBumpFunctions []FeeBumpFunction `gorm:"foreignKey:SetID"` + } + + type FeeBumpFunction struct { + gorm.Model + Position uint + FeeBumpIntervals []PartialLinearFunction `gorm:"foreignKey:FunctionPosition;references:Position;"` + SetID uint `gorm:"default:0;not null"` + } + // Crea table FeeBumpFunctionSet and migrate FeeBumpFunction + return tx.AutoMigrate(&FeeBumpFunctionSet{}, &FeeBumpFunction{}).Error + }, + Rollback: func(tx *gorm.DB) error { + + if err := tx.DropTable(&FeeBumpFunctionSet{}).Error; err != nil { + return err + } + + if err := tx.Table("fee_bump_functions").DropColumn(gorm.ToColumnName("SetID")).Error; err != nil { + return err + } + + return nil + }, + }, }) return m.Migrate() } diff --git a/tools/verify-apollo.sh b/tools/verify-apollo.sh index 6c94d7c6..3dabe0b7 100755 --- a/tools/verify-apollo.sh +++ b/tools/verify-apollo.sh @@ -19,22 +19,76 @@ cd $(git rev-parse --show-toplevel) tmp=$(mktemp -d) +# Ensure temp directory is deleted when script exits +trap 'rm -rf "$tmp"' EXIT + # Prepare paths to extract APKs mkdir -p "$tmp/to_verify" "$tmp/baseline" -echo "Building the APK from source. This might take a while (10-20 minutes)..." - +echo "Building the APKs from source. This might take a while (10-20 minutes)..." mkdir -p apk DOCKER_BUILDKIT=1 docker build -f android/Dockerfile -o apk . -unzip -q -d "$tmp/to_verify" "$apk_to_verify" -unzip -q -d "$tmp/baseline" "apk/apolloui-prod-release-unsigned.apk" +# Clean to_verify directory before unzipping +rm -rf "$tmp/to_verify" + +# Unzip the APK to verify +mkdir -p "$tmp/to_verify" +unzip -q -o "$apk_to_verify" -d "$tmp/to_verify" # TODO: verify the signature # Remove the signature since OSS users won't have Muuns private signing key -rm -r "$tmp"/{to_verify,baseline}/{META-INF,resources.arsc} +rm -r "$tmp"/to_verify/{META-INF,resources.arsc} + +# Compare /lib first +lib_dirs_in_verify=($(find "$tmp/to_verify/lib" -mindepth 1 -maxdepth 1 -type d 2>/dev/null || true)) + +echo "Found lib directory in APK to verify: ${lib_dirs_in_verify[*]}" + +if [ ${#lib_dirs_in_verify[@]} -ne 1 ]; then + echo "Unexpected lib directory structure in APK to verify." + exit 3 +fi -diff -r "$tmp/to_verify" "$tmp/baseline" && echo "Verification success!" || echo "Verification failed :(" +lib_dir_name=$(basename "${lib_dirs_in_verify[0]}") +echo "Using lib architecture: $lib_dir_name" +baseline_apk_dir="" + +# Pick baseline based on lib architecture +case "$lib_dir_name" in + arm64-v8a) + baseline_apk_dir="apk/apolloui-prod-arm64-v8a-release-unsigned.apk" + ;; + armeabi-v7a) + baseline_apk_dir="apk/apolloui-prod-armeabi-v7a-release-unsigned.apk" + ;; + x86) + baseline_apk_dir="apk/apolloui-prod-x86-release-unsigned.apk" + ;; + x86_64) + baseline_apk_dir="apk/apolloui-prod-x86_64-release-unsigned.apk" + ;; + *) + echo "Unknown architecture: $lib_dir_name" + exit 4 + ;; +esac + +# Clean baseline directory before unzipping +rm -rf "$tmp/baseline"/* + +unzip -q -o "$baseline_apk_dir" -d "$tmp/baseline" +rm -r "$tmp"/baseline/{META-INF,resources.arsc} + +echo "Comparing files..." + +diff_non_lib=$(diff -r "$tmp/to_verify" "$tmp/baseline" || true) + +if [ -n "$diff_non_lib" ]; then + echo "Verification failed :(" + exit 5 +fi +echo "Verification success!"