diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceAccountManager.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceAccountManager.java index d3818e7781..5e7b44a2bd 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceAccountManager.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceAccountManager.java @@ -21,7 +21,6 @@ import org.whispersystems.libsignal.state.SignedPreKeyRecord; import org.whispersystems.libsignal.util.guava.Optional; import org.whispersystems.signalservice.api.crypto.InvalidCiphertextException; import org.whispersystems.signalservice.api.crypto.ProfileCipher; -import org.whispersystems.signalservice.api.crypto.ProfileCipherInputStream; import org.whispersystems.signalservice.api.crypto.ProfileCipherOutputStream; import org.whispersystems.signalservice.api.groupsv2.ClientZkOperations; import org.whispersystems.signalservice.api.groupsv2.GroupsV2Api; @@ -56,10 +55,13 @@ import org.whispersystems.signalservice.internal.contacts.entities.DiscoveryRequ import org.whispersystems.signalservice.internal.contacts.entities.DiscoveryResponse; import org.whispersystems.signalservice.internal.crypto.ProvisioningCipher; import org.whispersystems.signalservice.api.account.AccountAttributes; +import org.whispersystems.signalservice.api.payments.CurrencyConversions; +import org.whispersystems.signalservice.internal.push.AuthCredentials; import org.whispersystems.signalservice.internal.push.ProfileAvatarData; import org.whispersystems.signalservice.internal.push.PushServiceSocket; import org.whispersystems.signalservice.internal.push.RemoteAttestationUtil; import org.whispersystems.signalservice.internal.push.RemoteConfigResponse; +import org.whispersystems.signalservice.internal.push.SignalServiceProtos; import org.whispersystems.signalservice.internal.push.VerifyAccountResponse; import org.whispersystems.signalservice.internal.push.http.ProfileCipherOutputStreamFactory; import org.whispersystems.signalservice.internal.storage.protos.ManifestRecord; @@ -75,7 +77,6 @@ import org.whispersystems.util.Base64; import java.io.ByteArrayInputStream; import java.io.DataInputStream; import java.io.IOException; -import java.nio.charset.StandardCharsets; import java.security.KeyStore; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; @@ -636,19 +637,31 @@ public class SignalServiceAccountManager { this.pushServiceSocket.pingStorageService(); } + public CurrencyConversions getCurrencyConversions() throws IOException { + return this.pushServiceSocket.getCurrencyConversions(); + } + /** * @return The avatar URL path, if one was written. */ - public Optional setVersionedProfile(UUID uuid, ProfileKey profileKey, String name, String about, String aboutEmoji, StreamDetails avatar) + public Optional setVersionedProfile(UUID uuid, + ProfileKey profileKey, + String name, + String about, + String aboutEmoji, + Optional paymentsAddress, + StreamDetails avatar) throws IOException { if (name == null) name = ""; - byte[] ciphertextName = new ProfileCipher(profileKey).encryptName(name.getBytes(StandardCharsets.UTF_8), ProfileCipher.getTargetNameLength(name)); - byte[] ciphertextAbout = new ProfileCipher(profileKey).encryptName(about.getBytes(StandardCharsets.UTF_8), ProfileCipher.getTargetAboutLength(about)); - byte[] ciphertextEmoji = new ProfileCipher(profileKey).encryptName(aboutEmoji.getBytes(StandardCharsets.UTF_8), ProfileCipher.EMOJI_PADDED_LENGTH); - boolean hasAvatar = avatar != null; - ProfileAvatarData profileAvatarData = null; + ProfileCipher profileCipher = new ProfileCipher(profileKey); + byte[] ciphertextName = profileCipher.encryptString(name, ProfileCipher.getTargetNameLength(name)); + byte[] ciphertextAbout = profileCipher.encryptString(about, ProfileCipher.getTargetAboutLength(about)); + byte[] ciphertextEmoji = profileCipher.encryptString(aboutEmoji, ProfileCipher.EMOJI_PADDED_LENGTH); + byte[] ciphertextMobileCoinAddress = paymentsAddress.transform(address -> profileCipher.encryptWithLength(address.toByteArray(), ProfileCipher.PAYMENTS_ADDRESS_CONTENT_SIZE)).orNull(); + boolean hasAvatar = avatar != null; + ProfileAvatarData profileAvatarData = null; if (hasAvatar) { profileAvatarData = new ProfileAvatarData(avatar.getStream(), @@ -661,6 +674,7 @@ public class SignalServiceAccountManager { ciphertextName, ciphertextAbout, ciphertextEmoji, + ciphertextMobileCoinAddress, hasAvatar, profileKey.getCommitment(uuid).serialize()), profileAvatarData); @@ -731,4 +745,9 @@ public class SignalServiceAccountManager { public GroupsV2Api getGroupsV2Api() { return new GroupsV2Api(pushServiceSocket, groupsV2Operations); } + + public AuthCredentials getPaymentsAuthorization() throws IOException { + return pushServiceSocket.getPaymentsAuthorization(); + } + } diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java index f695d6005d..6c5bbb1ed6 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java @@ -42,6 +42,7 @@ import org.whispersystems.signalservice.api.messages.multidevice.BlockedListMess import org.whispersystems.signalservice.api.messages.multidevice.ConfigurationMessage; import org.whispersystems.signalservice.api.messages.multidevice.KeysMessage; import org.whispersystems.signalservice.api.messages.multidevice.MessageRequestResponseMessage; +import org.whispersystems.signalservice.api.messages.multidevice.OutgoingPaymentMessage; import org.whispersystems.signalservice.api.messages.multidevice.ReadMessage; import org.whispersystems.signalservice.api.messages.multidevice.SentTranscriptMessage; import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage; @@ -57,6 +58,8 @@ import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException import org.whispersystems.signalservice.api.push.exceptions.ServerRejectedException; import org.whispersystems.signalservice.api.push.exceptions.UnregisteredUserException; import org.whispersystems.signalservice.api.util.CredentialsProvider; +import org.whispersystems.signalservice.api.util.Uint64RangeException; +import org.whispersystems.signalservice.api.util.Uint64Util; import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration; import org.whispersystems.signalservice.internal.crypto.PaddingInputStream; import org.whispersystems.signalservice.internal.push.AttachmentV2UploadAttributes; @@ -364,6 +367,8 @@ public class SignalServiceMessageSender { content = createMultiDeviceFetchTypeContent(message.getFetchType().get()); } else if (message.getMessageRequestResponse().isPresent()) { content = createMultiDeviceMessageRequestResponseContent(message.getMessageRequestResponse().get()); + } else if (message.getOutgoingPaymentMessage().isPresent()) { + content = createMultiDeviceOutgoingPaymentContent(message.getOutgoingPaymentMessage().get()); } else if (message.getKeys().isPresent()) { content = createMultiDeviceSyncKeysContent(message.getKeys().get()); } else if (message.getVerified().isPresent()) { @@ -748,6 +753,21 @@ public class SignalServiceMessageSender { builder.setGroupCallUpdate(DataMessage.GroupCallUpdate.newBuilder().setEraId(message.getGroupCallUpdate().get().getEraId())); } + if (message.getPayment().isPresent()) { + SignalServiceDataMessage.Payment payment = message.getPayment().get(); + + if (payment.getPaymentNotification().isPresent()) { + SignalServiceDataMessage.PaymentNotification paymentNotification = payment.getPaymentNotification().get(); + DataMessage.Payment.Notification.MobileCoin.Builder mobileCoinPayment = DataMessage.Payment.Notification.MobileCoin.newBuilder().setReceipt(ByteString.copyFrom(paymentNotification.getReceipt())); + DataMessage.Payment.Notification.Builder paymentBuilder = DataMessage.Payment.Notification.newBuilder() + .setNote(paymentNotification.getNote()) + .setMobileCoin(mobileCoinPayment); + + builder.setPayment(DataMessage.Payment.newBuilder().setNotification(paymentBuilder)); + builder.setRequiredProtocolVersion(Math.max(DataMessage.ProtocolVersion.PAYMENTS_VALUE, builder.getRequiredProtocolVersion())); + } + } + builder.setTimestamp(message.getTimestamp()); return enforceMaxContentSize(container.setDataMessage(builder).build().toByteArray()); @@ -1095,6 +1115,43 @@ public class SignalServiceMessageSender { return container.setSyncMessage(syncMessage).build().toByteArray(); } + private byte[] createMultiDeviceOutgoingPaymentContent(OutgoingPaymentMessage message) { + Content.Builder container = Content.newBuilder(); + SyncMessage.Builder syncMessage = createSyncMessageBuilder(); + SyncMessage.OutgoingPayment.Builder paymentMessage = SyncMessage.OutgoingPayment.newBuilder(); + + if (message.getRecipient().isPresent()) { + paymentMessage.setRecipientUuid(message.getRecipient().get().toString()); + } + + if (message.getNote().isPresent()) { + paymentMessage.setNote(message.getNote().get()); + } + + try { + SyncMessage.OutgoingPayment.MobileCoin.Builder mobileCoinBuilder = SyncMessage.OutgoingPayment.MobileCoin.newBuilder(); + + if (message.getAddress().isPresent()) { + mobileCoinBuilder.setRecipientAddress(ByteString.copyFrom(message.getAddress().get())); + } + mobileCoinBuilder.setAmountPicoMob(Uint64Util.bigIntegerToUInt64(message.getAmount().toPicoMobBigInteger())) + .setFeePicoMob(Uint64Util.bigIntegerToUInt64(message.getFee().toPicoMobBigInteger())) + .setReceipt(message.getReceipt()) + .setLedgerBlockTimestamp(message.getBlockTimestamp()) + .setLedgerBlockIndex(message.getBlockIndex()) + .addAllOutputPublicKeys(message.getPublicKeys()) + .addAllSpentKeyImages(message.getKeyImages()); + + paymentMessage.setMobileCoin(mobileCoinBuilder); + } catch (Uint64RangeException e) { + throw new AssertionError(e); + } + + syncMessage.setOutgoingPayment(paymentMessage); + + return container.setSyncMessage(syncMessage).build().toByteArray(); + } + private byte[] createMultiDeviceSyncKeysContent(KeysMessage keysMessage) { Content.Builder container = Content.newBuilder(); SyncMessage.Builder syncMessage = createSyncMessageBuilder(); diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/crypto/ProfileCipher.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/crypto/ProfileCipher.java index 8bd75d0c6e..a41e58d18d 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/crypto/ProfileCipher.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/crypto/ProfileCipher.java @@ -5,11 +5,15 @@ import org.signal.zkgroup.profiles.ProfileKey; import org.whispersystems.libsignal.util.ByteUtil; import org.whispersystems.signalservice.internal.util.Util; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; import java.nio.charset.StandardCharsets; import java.security.InvalidAlgorithmParameterException; import java.security.InvalidKeyException; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; +import java.util.Locale; import javax.crypto.BadPaddingException; import javax.crypto.Cipher; @@ -30,6 +34,10 @@ public class ProfileCipher { public static final int MAX_POSSIBLE_NAME_LENGTH = NAME_PADDED_LENGTH_2; public static final int MAX_POSSIBLE_ABOUT_LENGTH = ABOUT_PADDED_LENGTH_3; public static final int EMOJI_PADDED_LENGTH = 32; + public static final int ENCRYPTION_OVERHEAD = 28; + + public static final int PAYMENTS_ADDRESS_BASE64_FIELD_SIZE = 776; + public static final int PAYMENTS_ADDRESS_CONTENT_SIZE = PAYMENTS_ADDRESS_BASE64_FIELD_SIZE * 6 / 8 - ProfileCipher.ENCRYPTION_OVERHEAD; private final ProfileKey key; @@ -37,7 +45,12 @@ public class ProfileCipher { this.key = key; } - public byte[] encryptName(byte[] input, int paddedLength) { + /** + * Encrypts an input and ensures padded length. + *

+ * Padded length does not include {@link #ENCRYPTION_OVERHEAD}. + */ + public byte[] encrypt(byte[] input, int paddedLength) { try { byte[] inputPadded = new byte[paddedLength]; @@ -52,13 +65,22 @@ public class ProfileCipher { Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(key.serialize(), "AES"), new GCMParameterSpec(128, nonce)); - return ByteUtil.combine(nonce, cipher.doFinal(inputPadded)); + byte[] encryptedPadded = ByteUtil.combine(nonce, cipher.doFinal(inputPadded)); + + if (encryptedPadded.length != (paddedLength + ENCRYPTION_OVERHEAD)) { + throw new AssertionError(String.format(Locale.US, "Wrong output length %d != padded length %d + %d", encryptedPadded.length, paddedLength, ENCRYPTION_OVERHEAD)); + } + + return encryptedPadded; } catch (NoSuchAlgorithmException | InvalidAlgorithmParameterException | BadPaddingException | NoSuchPaddingException | IllegalBlockSizeException | InvalidKeyException e) { throw new AssertionError(e); } } - public byte[] decryptName(byte[] input) throws InvalidCiphertextException { + /** + * Returns original data with padding still intact. + */ + public byte[] decrypt(byte[] input) throws InvalidCiphertextException { try { if (input.length < 12 + 16 + 1) { throw new InvalidCiphertextException("Too short: " + input.length); @@ -70,20 +92,7 @@ public class ProfileCipher { Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key.serialize(), "AES"), new GCMParameterSpec(128, nonce)); - byte[] paddedPlaintext = cipher.doFinal(input, nonce.length, input.length - nonce.length); - int plaintextLength = 0; - - for (int i=paddedPlaintext.length-1;i>=0;i--) { - if (paddedPlaintext[i] != (byte)0x00) { - plaintextLength = i + 1; - break; - } - } - - byte[] plaintext = new byte[plaintextLength]; - System.arraycopy(paddedPlaintext, 0, plaintext, 0, plaintextLength); - - return plaintext; + return cipher.doFinal(input, nonce.length, input.length - nonce.length); } catch (NoSuchAlgorithmException | InvalidAlgorithmParameterException | NoSuchPaddingException | IllegalBlockSizeException e) { throw new AssertionError(e); } catch (InvalidKeyException | BadPaddingException e) { @@ -91,6 +100,70 @@ public class ProfileCipher { } } + /** + * Encrypts a string's UTF bytes representation. + */ + public byte[] encryptString(String input, int paddedLength) { + return encrypt(input.getBytes(StandardCharsets.UTF_8), paddedLength); + } + + /** + * Strips 0 char padding from decrypt result. + */ + public String decryptString(byte[] input) throws InvalidCiphertextException { + byte[] paddedPlaintext = decrypt(input); + int plaintextLength = 0; + + for (int i = paddedPlaintext.length - 1; i >= 0; i--) { + if (paddedPlaintext[i] != (byte) 0x00) { + plaintextLength = i + 1; + break; + } + } + + byte[] plaintext = new byte[plaintextLength]; + System.arraycopy(paddedPlaintext, 0, plaintext, 0, plaintextLength); + + return new String(plaintext); + } + + /** + * Encodes the length, and adds padding. + *

+ * encrypt(input.length | input | padding) + *

+ * Padded length does not include 28 bytes encryption overhead. + */ + public byte[] encryptWithLength(byte[] input, int paddedLength) { + ByteBuffer content = ByteBuffer.wrap(new byte[input.length + 4]); + content.order(ByteOrder.LITTLE_ENDIAN); + content.putInt(input.length); + content.put(input); + return encrypt(content.array(), paddedLength); + } + + /** + * Extracts result from: + *

+ * decrypt(encrypt(result.length | result | padding)) + */ + public byte[] decryptWithLength(byte[] input) throws InvalidCiphertextException, IOException { + byte[] decrypted = decrypt(input); + int maxLength = decrypted.length - 4; + ByteBuffer content = ByteBuffer.wrap(decrypted); + content.order(ByteOrder.LITTLE_ENDIAN); + int contentLength = content.getInt(); + if (contentLength > maxLength) { + throw new IOException("Encoded length exceeds content length"); + } + if (contentLength < 0) { + throw new IOException("Encoded length is less than 0"); + } + byte[] result = new byte[contentLength]; + content.get(result); + return result; + } + public boolean verifyUnidentifiedAccess(byte[] theirUnidentifiedAccessVerifier) { try { if (theirUnidentifiedAccessVerifier == null || theirUnidentifiedAccessVerifier.length == 0) return false; diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceContent.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceContent.java index b4b418873a..782cd566c9 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceContent.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceContent.java @@ -28,6 +28,7 @@ import org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMess import org.whispersystems.signalservice.api.messages.multidevice.BlockedListMessage; import org.whispersystems.signalservice.api.messages.multidevice.ConfigurationMessage; import org.whispersystems.signalservice.api.messages.multidevice.MessageRequestResponseMessage; +import org.whispersystems.signalservice.api.messages.multidevice.OutgoingPaymentMessage; import org.whispersystems.signalservice.api.messages.multidevice.ReadMessage; import org.whispersystems.signalservice.api.messages.multidevice.RequestMessage; import org.whispersystems.signalservice.api.messages.multidevice.SentTranscriptMessage; @@ -36,6 +37,7 @@ import org.whispersystems.signalservice.api.messages.multidevice.StickerPackOper import org.whispersystems.signalservice.api.messages.multidevice.VerifiedMessage; import org.whispersystems.signalservice.api.messages.multidevice.ViewOnceOpenMessage; import org.whispersystems.signalservice.api.messages.shared.SharedContact; +import org.whispersystems.signalservice.api.payments.Money; import org.whispersystems.signalservice.api.push.SignalServiceAddress; import org.whispersystems.signalservice.api.util.UuidUtil; import org.whispersystems.signalservice.internal.push.SignalServiceProtos; @@ -364,6 +366,21 @@ public final class SignalServiceContent { groupContext); } + SignalServiceDataMessage.Payment payment; + try { + payment = createPayment(content); + } catch (InvalidMessageException e) { + throw new ProtocolInvalidMessageException(e, metadata.getSender().getIdentifier(), metadata.getSenderDevice()); + } + + if (content.getRequiredProtocolVersion() > SignalServiceProtos.DataMessage.ProtocolVersion.CURRENT.getNumber()) { + throw new UnsupportedDataMessageProtocolVersionException(SignalServiceProtos.DataMessage.ProtocolVersion.CURRENT.getNumber(), + content.getRequiredProtocolVersion(), + metadata.getSender().getIdentifier(), + metadata.getSenderDevice(), + groupContext); + } + for (SignalServiceProtos.AttachmentPointer pointer : content.getAttachmentsList()) { attachments.add(createAttachmentPointer(pointer)); } @@ -391,7 +408,8 @@ public final class SignalServiceContent { content.getIsViewOnce(), reaction, remoteDelete, - groupCallUpdate); + groupCallUpdate, + payment); } private static SignalServiceSyncMessage createSynchronizeMessage(SignalServiceMetadata metadata, @@ -589,6 +607,32 @@ public final class SignalServiceContent { return SignalServiceSyncMessage.forMessageRequestResponse(responseMessage); } + if (content.hasOutgoingPayment()) { + SignalServiceProtos.SyncMessage.OutgoingPayment outgoingPayment = content.getOutgoingPayment(); + switch (outgoingPayment.getPaymentDetailCase()) { + case MOBILECOIN: { + SignalServiceProtos.SyncMessage.OutgoingPayment.MobileCoin mobileCoin = outgoingPayment.getMobileCoin(); + Money.MobileCoin amount = Money.picoMobileCoin(mobileCoin.getAmountPicoMob()); + Money.MobileCoin fee = Money.picoMobileCoin(mobileCoin.getFeePicoMob()); + ByteString address = mobileCoin.getRecipientAddress(); + Optional recipient = Optional.fromNullable(UuidUtil.parseOrNull(outgoingPayment.getRecipientUuid())); + + return SignalServiceSyncMessage.forOutgoingPayment(new OutgoingPaymentMessage(recipient, + amount, + fee, + mobileCoin.getReceipt(), + mobileCoin.getLedgerBlockIndex(), + mobileCoin.getLedgerBlockTimestamp(), + address.isEmpty() ? Optional.absent() : Optional.of(address.toByteArray()), + Optional.of(outgoingPayment.getNote()), + mobileCoin.getOutputPublicKeysList(), + mobileCoin.getSpentKeyImagesList())); + } + default: + return SignalServiceSyncMessage.empty(); + } + } + return SignalServiceSyncMessage.empty(); } @@ -800,6 +844,33 @@ public final class SignalServiceContent { return new SignalServiceDataMessage.GroupCallUpdate(groupCallUpdate.getEraId()); } + private static SignalServiceDataMessage.Payment createPayment(SignalServiceProtos.DataMessage content) throws InvalidMessageException { + if (!content.hasPayment()) { + return null; + } + + SignalServiceProtos.DataMessage.Payment payment = content.getPayment(); + + switch (payment.getItemCase()) { + case NOTIFICATION: return new SignalServiceDataMessage.Payment(createPaymentNotification(payment)); + default : throw new InvalidMessageException("Unknown payment item"); + } + } + + private static SignalServiceDataMessage.PaymentNotification createPaymentNotification(SignalServiceProtos.DataMessage.Payment content) + throws InvalidMessageException + { + if (!content.hasNotification() || + content.getNotification().getTransactionCase() != SignalServiceProtos.DataMessage.Payment.Notification.TransactionCase.MOBILECOIN) + { + throw new InvalidMessageException(); + } + + SignalServiceProtos.DataMessage.Payment.Notification payment = content.getNotification(); + + return new SignalServiceDataMessage.PaymentNotification(payment.getMobileCoin().getReceipt().toByteArray(), payment.getNote()); + } + private static List createSharedContacts(SignalServiceProtos.DataMessage content) throws ProtocolInvalidMessageException { if (content.getContactCount() <= 0) return null; diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceDataMessage.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceDataMessage.java index 493ed0e7dd..6a1cdcc5cc 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceDataMessage.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceDataMessage.java @@ -39,6 +39,7 @@ public class SignalServiceDataMessage { private final Optional reaction; private final Optional remoteDelete; private final Optional groupCallUpdate; + private final Optional payment; /** * Construct a SignalServiceDataMessage. @@ -58,7 +59,8 @@ public class SignalServiceDataMessage { boolean expirationUpdate, byte[] profileKey, boolean profileKeyUpdate, Quote quote, List sharedContacts, List previews, List mentions, Sticker sticker, boolean viewOnce, Reaction reaction, RemoteDelete remoteDelete, - GroupCallUpdate groupCallUpdate) + GroupCallUpdate groupCallUpdate, + Payment payment) { try { this.group = SignalServiceGroupContext.createOptional(group, groupV2); @@ -79,6 +81,7 @@ public class SignalServiceDataMessage { this.reaction = Optional.fromNullable(reaction); this.remoteDelete = Optional.fromNullable(remoteDelete); this.groupCallUpdate = Optional.fromNullable(groupCallUpdate); + this.payment = Optional.fromNullable(payment); if (attachments != null && !attachments.isEmpty()) { this.attachments = Optional.of(attachments); @@ -227,6 +230,10 @@ public class SignalServiceDataMessage { return groupCallUpdate; } + public Optional getPayment() { + return payment; + } + public static class Builder { private List attachments = new LinkedList<>(); @@ -249,6 +256,7 @@ public class SignalServiceDataMessage { private Reaction reaction; private RemoteDelete remoteDelete; private GroupCallUpdate groupCallUpdate; + private Payment payment; private Builder() {} @@ -371,13 +379,19 @@ public class SignalServiceDataMessage { return this; } + public Builder withPayment(Payment payment) { + this.payment = payment; + return this; + } + public SignalServiceDataMessage build() { if (timestamp == 0) timestamp = System.currentTimeMillis(); return new SignalServiceDataMessage(timestamp, group, groupV2, attachments, body, endSession, expiresInSeconds, expirationUpdate, profileKey, profileKeyUpdate, quote, sharedContacts, previews, mentions, sticker, viewOnce, reaction, remoteDelete, - groupCallUpdate); + groupCallUpdate, + payment); } } @@ -590,4 +604,35 @@ public class SignalServiceDataMessage { return eraId; } } + + public static class PaymentNotification { + + private final byte[] receipt; + private final String note; + + public PaymentNotification(byte[] receipt, String note) { + this.receipt = receipt; + this.note = note; + } + + public byte[] getReceipt() { + return receipt; + } + + public String getNote() { + return note; + } + } + + public static class Payment { + private final Optional paymentNotification; + + public Payment(PaymentNotification paymentNotification) { + this.paymentNotification = Optional.of(paymentNotification); + } + + public Optional getPaymentNotification() { + return paymentNotification; + } + } } diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/OutgoingPaymentMessage.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/OutgoingPaymentMessage.java new file mode 100644 index 0000000000..7b3110dd8b --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/OutgoingPaymentMessage.java @@ -0,0 +1,87 @@ +package org.whispersystems.signalservice.api.messages.multidevice; + +import com.google.protobuf.ByteString; + +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.payments.Money; + +import java.util.List; +import java.util.UUID; + +public final class OutgoingPaymentMessage { + + private final Optional recipient; + private final Money.MobileCoin amount; + private final Money.MobileCoin fee; + private final ByteString receipt; + private final long blockIndex; + private final long blockTimestamp; + private final Optional address; + private final Optional note; + private final List publicKeys; + private final List keyImages; + + public OutgoingPaymentMessage(Optional recipient, + Money.MobileCoin amount, + Money.MobileCoin fee, + ByteString receipt, + long blockIndex, + long blockTimestamp, + Optional address, + Optional note, + List publicKeys, + List keyImages) + { + this.recipient = recipient; + this.amount = amount; + this.fee = fee; + this.receipt = receipt; + this.blockIndex = blockIndex; + this.blockTimestamp = blockTimestamp; + this.address = address; + this.note = note; + this.publicKeys = publicKeys; + this.keyImages = keyImages; + } + + public Optional getRecipient() { + return recipient; + } + + public Money.MobileCoin getAmount() { + return amount; + } + + public ByteString getReceipt() { + return receipt; + } + + public Money.MobileCoin getFee() { + return fee; + } + + public long getBlockIndex() { + return blockIndex; + } + + public long getBlockTimestamp() { + return blockTimestamp; + } + + public Optional getAddress() { + return address; + } + + public Optional getNote() { + return note; + } + + public List getPublicKeys() { + return publicKeys; + } + + public List getKeyImages() { + return keyImages; + } +} + diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/SignalServiceSyncMessage.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/SignalServiceSyncMessage.java index 5d2aa3dae3..18b245adc6 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/SignalServiceSyncMessage.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/SignalServiceSyncMessage.java @@ -1,4 +1,4 @@ -/** +/* * Copyright (C) 2014-2016 Open Whisper Systems * * Licensed according to the LICENSE file in this repository. @@ -27,6 +27,7 @@ public class SignalServiceSyncMessage { private final Optional fetchType; private final Optional keys; private final Optional messageRequestResponse; + private final Optional outgoingPaymentMessage; private SignalServiceSyncMessage(Optional sent, Optional contacts, @@ -40,7 +41,8 @@ public class SignalServiceSyncMessage { Optional> stickerPackOperations, Optional fetchType, Optional keys, - Optional messageRequestResponse) + Optional messageRequestResponse, + Optional outgoingPaymentMessage) { this.sent = sent; this.contacts = contacts; @@ -55,249 +57,282 @@ public class SignalServiceSyncMessage { this.fetchType = fetchType; this.keys = keys; this.messageRequestResponse = messageRequestResponse; + this.outgoingPaymentMessage = outgoingPaymentMessage; } public static SignalServiceSyncMessage forSentTranscript(SentTranscriptMessage sent) { return new SignalServiceSyncMessage(Optional.of(sent), - Optional.absent(), - Optional.absent(), - Optional.absent(), - Optional.absent(), - Optional.>absent(), - Optional.absent(), - Optional.absent(), - Optional.absent(), - Optional.>absent(), - Optional.absent(), - Optional.absent(), - Optional.absent()); + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent()); } public static SignalServiceSyncMessage forContacts(ContactsMessage contacts) { - return new SignalServiceSyncMessage(Optional.absent(), + return new SignalServiceSyncMessage(Optional.absent(), Optional.of(contacts), - Optional.absent(), - Optional.absent(), - Optional.absent(), - Optional.>absent(), - Optional.absent(), - Optional.absent(), - Optional.absent(), - Optional.>absent(), - Optional.absent(), - Optional.absent(), - Optional.absent()); + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent()); } public static SignalServiceSyncMessage forGroups(SignalServiceAttachment groups) { - return new SignalServiceSyncMessage(Optional.absent(), - Optional.absent(), + return new SignalServiceSyncMessage(Optional.absent(), + Optional.absent(), Optional.of(groups), - Optional.absent(), - Optional.absent(), - Optional.>absent(), - Optional.absent(), - Optional.absent(), - Optional.absent(), - Optional.>absent(), - Optional.absent(), - Optional.absent(), - Optional.absent()); + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent()); } public static SignalServiceSyncMessage forRequest(RequestMessage request) { - return new SignalServiceSyncMessage(Optional.absent(), - Optional.absent(), - Optional.absent(), - Optional.absent(), + return new SignalServiceSyncMessage(Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), Optional.of(request), - Optional.>absent(), - Optional.absent(), - Optional.absent(), - Optional.absent(), - Optional.>absent(), - Optional.absent(), - Optional.absent(), - Optional.absent()); + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent()); } public static SignalServiceSyncMessage forRead(List reads) { - return new SignalServiceSyncMessage(Optional.absent(), - Optional.absent(), - Optional.absent(), - Optional.absent(), - Optional.absent(), + return new SignalServiceSyncMessage(Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), Optional.of(reads), - Optional.absent(), - Optional.absent(), - Optional.absent(), - Optional.>absent(), - Optional.absent(), - Optional.absent(), - Optional.absent()); + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent()); } public static SignalServiceSyncMessage forViewOnceOpen(ViewOnceOpenMessage timerRead) { - return new SignalServiceSyncMessage(Optional.absent(), - Optional.absent(), - Optional.absent(), - Optional.absent(), - Optional.absent(), - Optional.>absent(), + return new SignalServiceSyncMessage(Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), Optional.of(timerRead), - Optional.absent(), - Optional.absent(), - Optional.>absent(), - Optional.absent(), - Optional.absent(), - Optional.absent()); + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent()); } public static SignalServiceSyncMessage forRead(ReadMessage read) { List reads = new LinkedList<>(); reads.add(read); - return new SignalServiceSyncMessage(Optional.absent(), - Optional.absent(), - Optional.absent(), - Optional.absent(), - Optional.absent(), + return new SignalServiceSyncMessage(Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), Optional.of(reads), - Optional.absent(), - Optional.absent(), - Optional.absent(), - Optional.>absent(), - Optional.absent(), - Optional.absent(), - Optional.absent()); + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent()); } public static SignalServiceSyncMessage forVerified(VerifiedMessage verifiedMessage) { - return new SignalServiceSyncMessage(Optional.absent(), - Optional.absent(), - Optional.absent(), - Optional.absent(), - Optional.absent(), - Optional.>absent(), - Optional.absent(), + return new SignalServiceSyncMessage(Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), Optional.of(verifiedMessage), - Optional.absent(), - Optional.>absent(), - Optional.absent(), - Optional.absent(), - Optional.absent()); + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent()); } public static SignalServiceSyncMessage forBlocked(BlockedListMessage blocked) { - return new SignalServiceSyncMessage(Optional.absent(), - Optional.absent(), - Optional.absent(), + return new SignalServiceSyncMessage(Optional.absent(), + Optional.absent(), + Optional.absent(), Optional.of(blocked), - Optional.absent(), - Optional.>absent(), - Optional.absent(), - Optional.absent(), - Optional.absent(), - Optional.>absent(), - Optional.absent(), - Optional.absent(), - Optional.absent()); + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent()); } public static SignalServiceSyncMessage forConfiguration(ConfigurationMessage configuration) { - return new SignalServiceSyncMessage(Optional.absent(), - Optional.absent(), - Optional.absent(), - Optional.absent(), - Optional.absent(), - Optional.>absent(), - Optional.absent(), - Optional.absent(), + return new SignalServiceSyncMessage(Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), Optional.of(configuration), - Optional.>absent(), - Optional.absent(), - Optional.absent(), - Optional.absent()); + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent()); } public static SignalServiceSyncMessage forStickerPackOperations(List stickerPackOperations) { - return new SignalServiceSyncMessage(Optional.absent(), - Optional.absent(), - Optional.absent(), - Optional.absent(), - Optional.absent(), - Optional.>absent(), - Optional.absent(), - Optional.absent(), - Optional.absent(), + return new SignalServiceSyncMessage(Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), Optional.of(stickerPackOperations), - Optional.absent(), - Optional.absent(), - Optional.absent()); + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent()); } public static SignalServiceSyncMessage forFetchLatest(FetchType fetchType) { - return new SignalServiceSyncMessage(Optional.absent(), - Optional.absent(), - Optional.absent(), - Optional.absent(), - Optional.absent(), - Optional.>absent(), - Optional.absent(), - Optional.absent(), - Optional.absent(), - Optional.>absent(), + return new SignalServiceSyncMessage(Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), Optional.of(fetchType), - Optional.absent(), - Optional.absent()); + Optional.absent(), + Optional.absent(), + Optional.absent()); } public static SignalServiceSyncMessage forKeys(KeysMessage keys) { - return new SignalServiceSyncMessage(Optional.absent(), - Optional.absent(), - Optional.absent(), - Optional.absent(), - Optional.absent(), - Optional.>absent(), - Optional.absent(), - Optional.absent(), - Optional.absent(), - Optional.>absent(), - Optional.absent(), + return new SignalServiceSyncMessage(Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), Optional.of(keys), - Optional.absent()); + Optional.absent(), + Optional.absent()); } public static SignalServiceSyncMessage forMessageRequestResponse(MessageRequestResponseMessage messageRequestResponse) { - return new SignalServiceSyncMessage(Optional.absent(), - Optional.absent(), - Optional.absent(), - Optional.absent(), - Optional.absent(), - Optional.>absent(), - Optional.absent(), - Optional.absent(), - Optional.absent(), - Optional.>absent(), - Optional.absent(), - Optional.absent(), - Optional.of(messageRequestResponse)); + return new SignalServiceSyncMessage(Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.of(messageRequestResponse), + Optional.absent()); + } + + public static SignalServiceSyncMessage forOutgoingPayment(OutgoingPaymentMessage outgoingPaymentMessage) { + return new SignalServiceSyncMessage(Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.of(outgoingPaymentMessage)); } public static SignalServiceSyncMessage empty() { - return new SignalServiceSyncMessage(Optional.absent(), - Optional.absent(), - Optional.absent(), - Optional.absent(), - Optional.absent(), - Optional.>absent(), - Optional.absent(), - Optional.absent(), - Optional.absent(), - Optional.>absent(), - Optional.absent(), - Optional.absent(), - Optional.absent()); + return new SignalServiceSyncMessage(Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent()); } public Optional getSent() { @@ -352,6 +387,10 @@ public class SignalServiceSyncMessage { return messageRequestResponse; } + public Optional getOutgoingPaymentMessage() { + return outgoingPaymentMessage; + } + public enum FetchType { LOCAL_PROFILE, STORAGE_MANIFEST diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/payments/Currency.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/payments/Currency.java new file mode 100644 index 0000000000..0a28904a19 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/payments/Currency.java @@ -0,0 +1,66 @@ +package org.whispersystems.signalservice.api.payments; + +public abstract class Currency { + + private Currency() {} + + public abstract String getCurrencyCode(); + public abstract int getDecimalPrecision(); + public abstract Formatter getFormatter(FormatterOptions formatterOptions); + + private static class CryptoCurrency extends Currency { + + private final String currencyCode; + private final int decimalPrecision; + + CryptoCurrency(String currencyCode, int decimalPrecision) { + this.currencyCode = currencyCode; + this.decimalPrecision = decimalPrecision; + } + + public String getCurrencyCode() { + return currencyCode; + } + + public int getDecimalPrecision() { + return decimalPrecision; + } + + @Override + public Formatter getFormatter(FormatterOptions formatterOptions) { + return Formatter.forMoney(this, formatterOptions); + } + } + + private static class FiatCurrency extends Currency { + + private final java.util.Currency javaCurrency; + + private FiatCurrency(java.util.Currency javaCurrency) { + this.javaCurrency = javaCurrency; + } + + @Override + public String getCurrencyCode() { + return javaCurrency.getCurrencyCode(); + } + + @Override + public int getDecimalPrecision() { + return javaCurrency.getDefaultFractionDigits(); + } + + @Override + public Formatter getFormatter(FormatterOptions formatterOptions) { + return Formatter.forFiat(javaCurrency, formatterOptions); + } + } + + public static Currency fromJavaCurrency(java.util.Currency javaCurrency) { + return new FiatCurrency(javaCurrency); + } + + public static Currency fromCodeAndPrecision(String code, int decimalPrecision) { + return new CryptoCurrency(code, decimalPrecision); + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/payments/CurrencyConversion.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/payments/CurrencyConversion.java new file mode 100644 index 0000000000..a8389310a6 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/payments/CurrencyConversion.java @@ -0,0 +1,21 @@ +package org.whispersystems.signalservice.api.payments; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.Map; + +public final class CurrencyConversion { + @JsonProperty + private String base; + + @JsonProperty + private Map conversions; + + public String getBase() { + return base; + } + + public Map getConversions() { + return conversions; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/payments/CurrencyConversions.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/payments/CurrencyConversions.java new file mode 100644 index 0000000000..1f2d0abcb4 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/payments/CurrencyConversions.java @@ -0,0 +1,21 @@ +package org.whispersystems.signalservice.api.payments; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +public final class CurrencyConversions { + @JsonProperty + private List currencies; + + @JsonProperty + private long timestamp; + + public List getCurrencies() { + return currencies; + } + + public long getTimestamp() { + return timestamp; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/payments/Formatter.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/payments/Formatter.java new file mode 100644 index 0000000000..e4aba57558 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/payments/Formatter.java @@ -0,0 +1,95 @@ +package org.whispersystems.signalservice.api.payments; + +import java.math.BigDecimal; +import java.text.NumberFormat; + +/** + * Formats the given amount to look like a given currency, utilizing the given formatter options. + */ +public abstract class Formatter { + + protected final FormatterOptions formatterOptions; + + private Formatter(FormatterOptions formatterOptions) { + this.formatterOptions = formatterOptions; + } + + public abstract String format(BigDecimal amount); + + static Formatter forMoney(Currency currency, FormatterOptions formatterOptions) { + return new CryptoFormatter(currency, formatterOptions); + } + + static Formatter forFiat(java.util.Currency currency, FormatterOptions formatterOptions) { + return new FiatFormatter(currency, formatterOptions); + } + + private static final class FiatFormatter extends Formatter { + + private final java.util.Currency javaCurrency; + + private FiatFormatter(java.util.Currency javaCurrency, FormatterOptions formatterOptions) { + super(formatterOptions); + + this.javaCurrency = javaCurrency; + } + + @Override + public String format(BigDecimal amount) { + BigDecimal toFormat = formatterOptions.alwaysPositive ? amount.abs() : amount; + StringBuilder builder = new StringBuilder(); + NumberFormat numberFormat; + + if (formatterOptions.withUnit) { + numberFormat = NumberFormat.getCurrencyInstance(formatterOptions.locale); + numberFormat.setCurrency(javaCurrency); + } else { + numberFormat = NumberFormat.getNumberInstance(formatterOptions.locale); + numberFormat.setMinimumFractionDigits(javaCurrency.getDefaultFractionDigits()); + } + + numberFormat.setMaximumFractionDigits(Math.min(javaCurrency.getDefaultFractionDigits(), + formatterOptions.maximumFractionDigits)); + + builder.append(numberFormat.format(toFormat)); + + return builder.toString(); + } + } + + private static final class CryptoFormatter extends Formatter { + + private final Currency currency; + + private CryptoFormatter(Currency currency, FormatterOptions formatterOptions) { + super(formatterOptions); + + this.currency = currency; + } + + @Override + public String format(BigDecimal amount) { + NumberFormat format = NumberFormat.getNumberInstance(formatterOptions.locale); + BigDecimal toFormat = formatterOptions.alwaysPositive ? amount.abs() : amount; + StringBuilder builder = new StringBuilder(); + + format.setMaximumFractionDigits(Math.min(currency.getDecimalPrecision(), formatterOptions.maximumFractionDigits)); + + if (toFormat.signum() == 1 && formatterOptions.alwaysPrefixWithSign) { + builder.append("+"); + } + + builder.append(format.format(toFormat)); + + if (formatterOptions.withSpaceBeforeUnit && formatterOptions.withUnit) { + builder.append(" "); + } + + if (formatterOptions.withUnit) { + builder.append(currency.getCurrencyCode()); + } + + return builder.toString(); + } + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/payments/FormatterOptions.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/payments/FormatterOptions.java new file mode 100644 index 0000000000..1f035665a2 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/payments/FormatterOptions.java @@ -0,0 +1,82 @@ +package org.whispersystems.signalservice.api.payments; + +import java.util.Locale; + +public final class FormatterOptions { + + public final Locale locale; + public final boolean alwaysPositive; + public final boolean alwaysPrefixWithSign; + public final boolean withSpaceBeforeUnit; + public final boolean withUnit; + public final int maximumFractionDigits; + + FormatterOptions(Builder builder) { + this.locale = builder.locale; + this.alwaysPositive = builder.alwaysPositive; + this.alwaysPrefixWithSign = builder.alwaysPrefixWithSign; + this.withSpaceBeforeUnit = builder.withSpaceBeforeUnit; + this.withUnit = builder.withUnit; + this.maximumFractionDigits = builder.maximumFractionDigits; + } + + public static FormatterOptions defaults() { + return builder().build(); + } + + public static FormatterOptions defaults(Locale locale) { + return builder(locale).build(); + } + + public static FormatterOptions.Builder builder() { + return builder(Locale.getDefault()); + } + + public static FormatterOptions.Builder builder(Locale locale) { + return new Builder(locale); + } + + public static final class Builder { + + private final Locale locale; + + private boolean alwaysPositive = false; + private boolean alwaysPrefixWithSign = false; + private boolean withSpaceBeforeUnit = true; + private boolean withUnit = true; + private int maximumFractionDigits = Integer.MAX_VALUE; + + private Builder(Locale locale) { + this.locale = locale; + } + + public Builder alwaysPositive() { + alwaysPositive = true; + return this; + } + + public Builder alwaysPrefixWithSign() { + alwaysPrefixWithSign = true; + return this; + } + + public Builder withoutSpaceBeforeUnit() { + withSpaceBeforeUnit = false; + return this; + } + + public Builder withoutUnit() { + withUnit = false; + return this; + } + + public Builder withMaximumFractionDigits(int maximumFractionDigits) { + this.maximumFractionDigits = Math.max(maximumFractionDigits, 0); + return this; + } + + public FormatterOptions build() { + return new FormatterOptions(this); + } + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/payments/Money.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/payments/Money.java new file mode 100644 index 0000000000..aae7db6219 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/payments/Money.java @@ -0,0 +1,254 @@ +package org.whispersystems.signalservice.api.payments; + +import org.whispersystems.signalservice.api.util.Uint64RangeException; +import org.whispersystems.signalservice.api.util.Uint64Util; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.util.Collection; +import java.util.Comparator; + +public abstract class Money { + + /** + * @param amount Can be positive or negative. Can exceed 64 bits. + * Must not have a decimal scale beyond that which mobile coin allows. + */ + public static MobileCoin mobileCoin(BigDecimal amount) { + return picoMobileCoin(amount.movePointRight(MobileCoin.PRECISION).toBigIntegerExact()); + } + + /** + * @param picoMobileCoin Can be positive or negative. Can exceed 64 bits. + */ + public static MobileCoin picoMobileCoin(BigInteger picoMobileCoin) { + return picoMobileCoin.signum() == 0 ? MobileCoin.ZERO + : new MobileCoin(picoMobileCoin); + } + + /** + * @param picoMobileCoinUint64 Treated as unsigned. + */ + public static MobileCoin picoMobileCoin(long picoMobileCoinUint64) { + return picoMobileCoin(Uint64Util.uint64ToBigInteger(picoMobileCoinUint64)); + } + + /** + * Parses the output of {@link #serialize()}. + * + * @throws ParseException iff the format is incorrect. + */ + public static Money parse(String serialized) throws ParseException { + if (serialized == null) { + throw new ParseException(); + } + String[] split = serialized.split(":"); + if (split.length != 2) { + throw new ParseException(); + } + if (Money.MobileCoin.CURRENCY.getCurrencyCode().equals(split[0])) { + return picoMobileCoin(new BigInteger(split[1])); + } + throw new ParseException(); + } + + /** + * Parses the output of {@link #serialize()}. Asserts that there is no parsing exception. + */ + public static Money parseOrThrow(String serialized) { + try { + return parse(serialized); + } catch (ParseException e) { + throw new AssertionError(e); + } + } + + public abstract boolean isPositive(); + + public abstract boolean isNegative(); + + public abstract Money negate(); + + public abstract Money abs(); + + public abstract Money add(Money other); + + public abstract Money subtract(Money other); + + public abstract Currency getCurrency(); + + public abstract String serializeAmountString(); + + public MobileCoin requireMobileCoin() { + throw new AssertionError(); + } + + public final String serialize() { + return getCurrency().getCurrencyCode() + ":" + serializeAmountString(); + } + + /** + * Given instance of one money type, this will give you the corresponding zero value. + */ + public abstract Money toZero(); + + public static final class MobileCoin extends Money { + public static final Comparator ASCENDING = (x, y) -> x.amount.compareTo(y.amount); + public static final Comparator DESCENDING = (x, y) -> y.amount.compareTo(x.amount); + + public static final MobileCoin ZERO = new MobileCoin(BigInteger.ZERO); + + private static final int PRECISION = 12; + + public static final Currency CURRENCY = Currency.fromCodeAndPrecision("MOB", PRECISION); + + private final BigInteger amount; + private final BigDecimal amountDecimal; + + private MobileCoin(BigInteger amount) { + this.amount = amount; + this.amountDecimal = new BigDecimal(amount).movePointLeft(PRECISION).stripTrailingZeros(); + } + + public static MobileCoin sum(Collection values) { + switch (values.size()) { + case 0: + return ZERO; + case 1: + return values.iterator().next(); + default: { + BigInteger result = ZERO.amount; + + for (MobileCoin value : values) { + result = result.add(value.amount); + } + + return Money.picoMobileCoin(result); + } + } + } + + @Override + public boolean isPositive() { + return amount.signum() == 1; + } + + @Override + public boolean isNegative() { + return amount.signum() == -1; + } + + @Override + public MobileCoin negate() { + return new MobileCoin(amount.negate()); + } + + @Override + public MobileCoin abs() { + if (amount.signum() == -1) { + return negate(); + } + return this; + } + + @Override + public Money add(Money other) { + return new MobileCoin(amount.add(other.requireMobileCoin().amount)); + } + + @Override + public Money subtract(Money other) { + return new MobileCoin(amount.subtract(other.requireMobileCoin().amount)); + } + + @Override + public Currency getCurrency() { + return CURRENCY; + } + + @Override + public MobileCoin requireMobileCoin() { + return this; + } + + @Override + public Money toZero() { + return ZERO; + } + + @Override + public boolean equals(Object o) { + return o instanceof MobileCoin && amount.equals(((MobileCoin) o).amount); + } + + @Override + public int hashCode() { + return amount.hashCode(); + } + + @Override + public String serializeAmountString() { + return toPicoMobBigInteger().toString(); + } + + /** + * The value expressed in Mobile coin. + */ + public String getAmountDecimalString() { + return amountDecimal.toString(); + } + + public boolean greaterThan(MobileCoin other) { + return amount.compareTo(other.amount) > 0; + } + + public boolean lessThan(MobileCoin other) { + return amount.compareTo(other.amount) < 0; + } + + @Deprecated + public double toDouble() { + return amountDecimal.doubleValue(); + } + + public BigDecimal toBigDecimal() { + return amountDecimal; + } + + public BigInteger toPicoMobBigInteger() { + return amount; + } + + public long toPicoMobUint64() throws Uint64RangeException { + return Uint64Util.bigIntegerToUInt64(amount); + } + + @Override + public String toString(Formatter formatter) { + return formatter.format(amountDecimal); + } + } + + @Override + public abstract boolean equals(Object o); + + @Override + public abstract int hashCode(); + + @Override + public String toString() { + return serialize(); + } + + public abstract String toString(Formatter formatter); + + public final String toString(FormatterOptions formatterOptions){ + Formatter formatter = getCurrency().getFormatter(formatterOptions); + return toString(formatter); + } + + public static final class ParseException extends Exception { + private ParseException() { + } + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/payments/MoneyProtobufUtil.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/payments/MoneyProtobufUtil.java new file mode 100644 index 0000000000..88e9eec7a3 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/payments/MoneyProtobufUtil.java @@ -0,0 +1,35 @@ +package org.whispersystems.signalservice.api.payments; + +import org.whispersystems.signalservice.api.util.Uint64RangeException; +import org.whispersystems.signalservice.internal.push.SignalServiceProtos; + +public final class MoneyProtobufUtil { + + public static SignalServiceProtos.DataMessage.Payment.Amount moneyToPaymentAmount(Money money) { + SignalServiceProtos.DataMessage.Payment.Amount.Builder builder = SignalServiceProtos.DataMessage.Payment.Amount.newBuilder(); + + if (money instanceof Money.MobileCoin) { + try { + builder.setMobileCoin(SignalServiceProtos.DataMessage.Payment.Amount.MobileCoin.newBuilder() + .setPicoMob(money.requireMobileCoin() + .toPicoMobUint64())); + } catch (Uint64RangeException e) { + throw new AssertionError(e); + } + } else { + throw new AssertionError(); + } + + return builder.build(); + } + + public static Money paymentAmountToMoney(SignalServiceProtos.DataMessage.Payment.Amount amount) throws UnsupportedCurrencyException { + switch (amount.getAmountCase()) { + case MOBILECOIN: + return Money.picoMobileCoin(amount.getMobileCoin().getPicoMob()); + case AMOUNT_NOT_SET: + default: + throw new UnsupportedCurrencyException(); + } + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/payments/PaymentsConstants.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/payments/PaymentsConstants.java new file mode 100644 index 0000000000..3630c98811 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/payments/PaymentsConstants.java @@ -0,0 +1,11 @@ +package org.whispersystems.signalservice.api.payments; + +public final class PaymentsConstants { + + private PaymentsConstants() {} + + public static final int PAYMENTS_ENTROPY_LENGTH = 32; + public static final int MNEMONIC_LENGTH = Math.round(PAYMENTS_ENTROPY_LENGTH * 0.75f); + public static final int SHORT_FRACTION_LENGTH = 4; + +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/payments/UnsupportedCurrencyException.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/payments/UnsupportedCurrencyException.java new file mode 100644 index 0000000000..c336054b88 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/payments/UnsupportedCurrencyException.java @@ -0,0 +1,4 @@ +package org.whispersystems.signalservice.api.payments; + +public final class UnsupportedCurrencyException extends Exception { +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/profiles/SignalServiceProfile.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/profiles/SignalServiceProfile.java index e58d75d162..cc677079de 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/profiles/SignalServiceProfile.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/profiles/SignalServiceProfile.java @@ -35,6 +35,9 @@ public class SignalServiceProfile { @JsonProperty private String aboutEmoji; + @JsonProperty + private byte[] paymentAddress; + @JsonProperty private String avatar; @@ -76,6 +79,10 @@ public class SignalServiceProfile { return aboutEmoji; } + public byte[] getPaymentAddress() { + return paymentAddress; + } + public String getAvatar() { return avatar; } diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/profiles/SignalServiceProfileWrite.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/profiles/SignalServiceProfileWrite.java index 243e7c668b..1af1a09b13 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/profiles/SignalServiceProfileWrite.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/profiles/SignalServiceProfileWrite.java @@ -17,6 +17,9 @@ public class SignalServiceProfileWrite { @JsonProperty private byte[] aboutEmoji; + @JsonProperty + private byte[] paymentAddress; + @JsonProperty private boolean avatar; @@ -27,13 +30,14 @@ public class SignalServiceProfileWrite { public SignalServiceProfileWrite(){ } - public SignalServiceProfileWrite(String version, byte[] name, byte[] about, byte[] aboutEmoji, boolean avatar, byte[] commitment) { - this.version = version; - this.name = name; - this.about = about; - this.aboutEmoji = aboutEmoji; - this.avatar = avatar; - this.commitment = commitment; + public SignalServiceProfileWrite(String version, byte[] name, byte[] about, byte[] aboutEmoji, byte[] paymentAddress, boolean avatar, byte[] commitment) { + this.version = version; + this.name = name; + this.about = about; + this.aboutEmoji = aboutEmoji; + this.paymentAddress = paymentAddress; + this.avatar = avatar; + this.commitment = commitment; } public boolean hasAvatar() { diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/SignalAccountRecord.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/SignalAccountRecord.java index 7dfbbf9e51..6939d57851 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/SignalAccountRecord.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/SignalAccountRecord.java @@ -2,7 +2,9 @@ package org.whispersystems.signalservice.api.storage; import com.google.protobuf.ByteString; +import org.whispersystems.libsignal.logging.Log; import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.payments.PaymentsConstants; import org.whispersystems.signalservice.api.push.SignalServiceAddress; import org.whispersystems.signalservice.api.util.OptionalUtil; import org.whispersystems.signalservice.api.util.ProtoUtil; @@ -25,6 +27,7 @@ public final class SignalAccountRecord implements SignalRecord { private final Optional profileKey; private final List pinnedConversations; private final boolean preferContactAvatars; + private final Payments payments; public SignalAccountRecord(StorageId id, AccountRecord proto) { this.id = id; @@ -37,6 +40,7 @@ public final class SignalAccountRecord implements SignalRecord { this.avatarUrlPath = OptionalUtil.absentIfEmpty(proto.getAvatarUrlPath()); this.pinnedConversations = new ArrayList<>(proto.getPinnedConversationsCount()); this.preferContactAvatars = proto.getPreferContactAvatars(); + this.payments = new Payments(proto.getPayments().getEnabled(), OptionalUtil.absentIfEmpty(proto.getPayments().getEntropy())); for (AccountRecord.PinnedConversation conversation : proto.getPinnedConversationsList()) { pinnedConversations.add(PinnedConversation.fromRemote(conversation)); @@ -112,6 +116,10 @@ public final class SignalAccountRecord implements SignalRecord { return preferContactAvatars; } + public Payments getPayments() { + return payments; + } + AccountRecord toProto() { return proto; } @@ -220,6 +228,31 @@ public final class SignalAccountRecord implements SignalRecord { } } + public static class Payments { + private static final String TAG = Payments.class.getSimpleName(); + + private final boolean enabled; + private final Optional entropy; + + public Payments(boolean enabled, Optional entropy) { + byte[] entropyBytes = entropy.orNull(); + if (entropyBytes != null && entropyBytes.length != PaymentsConstants.PAYMENTS_ENTROPY_LENGTH) { + Log.w(TAG, "Blocked entropy of length " + entropyBytes.length); + entropyBytes = null; + } + this.entropy = Optional.fromNullable(entropyBytes); + this.enabled = enabled && this.entropy.isPresent(); + } + + public boolean isEnabled() { + return enabled; + } + + public Optional getEntropy() { + return entropy; + } + } + public static final class Builder { private final StorageId id; private final AccountRecord.Builder builder; @@ -312,6 +345,22 @@ public final class SignalAccountRecord implements SignalRecord { return this; } + public Builder setPayments(boolean enabled, byte[] entropy) { + org.whispersystems.signalservice.internal.storage.protos.Payments.Builder paymentsBuilder = org.whispersystems.signalservice.internal.storage.protos.Payments.newBuilder(); + + boolean entropyPresent = entropy != null && entropy.length == PaymentsConstants.PAYMENTS_ENTROPY_LENGTH; + + paymentsBuilder.setEnabled(enabled && entropyPresent); + + if (entropyPresent) { + paymentsBuilder.setEntropy(ByteString.copyFrom(entropy)); + } + + builder.setPayments(paymentsBuilder); + + return this; + } + public SignalAccountRecord build() { AccountRecord proto = builder.build(); diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/util/OptionalUtil.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/util/OptionalUtil.java index db976d4086..06629d8782 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/util/OptionalUtil.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/util/OptionalUtil.java @@ -2,6 +2,7 @@ package org.whispersystems.signalservice.api.util; import com.google.protobuf.ByteString; +import org.whispersystems.libsignal.util.guava.Function; import org.whispersystems.libsignal.util.guava.Optional; import java.util.Arrays; @@ -11,6 +12,16 @@ public final class OptionalUtil { private OptionalUtil() { } + public static Optional flatMap(Optional input, Function> flatMapFunction) { + Optional> output = input.transform(flatMapFunction); + + if (output.isPresent()) { + return output.get(); + } else { + return Optional.absent(); + } + } + public static boolean byteArrayEquals(Optional a, Optional b) { if (a.isPresent() != b.isPresent()) { return false; diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/util/ProtoUtil.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/util/ProtoUtil.java index 9a5ce93257..306b8088e9 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/util/ProtoUtil.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/util/ProtoUtil.java @@ -120,8 +120,10 @@ public final class ProtoUtil { field.setAccessible(true); GeneratedMessageLite inner = (GeneratedMessageLite) field.get(proto); - innerProtos.add(inner); - innerProtos.addAll(getInnerProtos(inner)); + if (inner != null) { + innerProtos.add(inner); + innerProtos.addAll(getInnerProtos(inner)); + } } } diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/util/Uint64RangeException.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/util/Uint64RangeException.java new file mode 100644 index 0000000000..9876373bdd --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/util/Uint64RangeException.java @@ -0,0 +1,7 @@ +package org.whispersystems.signalservice.api.util; + +public final class Uint64RangeException extends Exception { + Uint64RangeException(String message) { + super(message); + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/util/Uint64Util.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/util/Uint64Util.java new file mode 100644 index 0000000000..7660c4a38e --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/util/Uint64Util.java @@ -0,0 +1,42 @@ +package org.whispersystems.signalservice.api.util; + +import java.math.BigInteger; + +import static org.whispersystems.libsignal.util.ByteUtil.longToByteArray; + +public final class Uint64Util { + + private final static BigInteger MAX_UINT64 = uint64ToBigInteger(0xffffffffffffffffL); + + private Uint64Util() { + } + + /** + * Creates a {@link BigInteger} of the supplied value, treating it as unsigned. + */ + public static BigInteger uint64ToBigInteger(long uint64) { + if (uint64 < 0) { + return new BigInteger(1, longToByteArray(uint64)); + } else { + return BigInteger.valueOf(uint64); + } + } + + /** + * Creates a long to be treated as unsigned of the supplied {@link BigInteger}. + * + * @param value Must be in the range [0..2^64) + * @throws Uint64RangeException iff value is outside of range. + */ + public static long bigIntegerToUInt64(BigInteger value) throws Uint64RangeException { + if (value.signum() < 0) { + throw new Uint64RangeException("BigInteger out of uint64 range (negative)"); + } + + if (value.compareTo(MAX_UINT64) > 0) { + throw new Uint64RangeException("BigInteger out of uint64 range (> MAX)"); + } + + return value.longValue(); + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/AuthCredentials.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/AuthCredentials.java index a6dec30bb6..883945f1f7 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/AuthCredentials.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/AuthCredentials.java @@ -12,7 +12,18 @@ public class AuthCredentials { @JsonProperty private String password; + public static AuthCredentials create(String username, String password) { + AuthCredentials authCredentials = new AuthCredentials(); + authCredentials.username = username; + authCredentials.password = password; + return authCredentials; + } + public String asBasic() { return Credentials.basic(username, password); } + + public String username() { return username; } + + public String password() { return password; } } diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java index 341636ec3e..42984fef2c 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java @@ -42,6 +42,7 @@ import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemo import org.whispersystems.signalservice.api.messages.calls.CallingResponse; import org.whispersystems.signalservice.api.messages.calls.TurnServerInfo; import org.whispersystems.signalservice.api.messages.multidevice.DeviceInfo; +import org.whispersystems.signalservice.api.payments.CurrencyConversions; import org.whispersystems.signalservice.api.profiles.ProfileAndCredential; import org.whispersystems.signalservice.api.profiles.SignalServiceProfile; import org.whispersystems.signalservice.api.profiles.SignalServiceProfileWrite; @@ -88,6 +89,7 @@ import org.whispersystems.signalservice.internal.push.exceptions.GroupNotFoundEx import org.whispersystems.signalservice.internal.push.exceptions.GroupPatchNotAcceptedException; import org.whispersystems.signalservice.internal.push.exceptions.MismatchedDevicesException; import org.whispersystems.signalservice.internal.push.exceptions.NotInGroupException; +import org.whispersystems.signalservice.internal.push.exceptions.PaymentsRegionException; import org.whispersystems.signalservice.internal.push.exceptions.StaleDevicesException; import org.whispersystems.signalservice.internal.push.http.CancelationSignal; import org.whispersystems.signalservice.internal.push.http.DigestingRequestBody; @@ -131,7 +133,6 @@ import java.util.UUID; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; -import javax.net.SocketFactory; import javax.net.ssl.SSLContext; import javax.net.ssl.TrustManager; import javax.net.ssl.X509TrustManager; @@ -192,6 +193,8 @@ public class PushServiceSocket { private static final String ATTACHMENT_V2_PATH = "/v2/attachments/form/upload"; private static final String ATTACHMENT_V3_PATH = "/v3/attachments/form/upload"; + private static final String PAYMENTS_AUTH_PATH = "/v1/payments/auth"; + private static final String PROFILE_PATH = "/v1/profile/%s"; private static final String PROFILE_USERNAME_PATH = "/v1/profile/username/%s"; @@ -216,6 +219,8 @@ public class PushServiceSocket { private static final String GROUPSV2_GROUP_JOIN = "/v1/groups/join/%s"; private static final String GROUPSV2_TOKEN = "/v1/groups/token"; + private static final String PAYMENTS_CONVERSIONS = "/v1/payments/conversions"; + private static final String SERVER_DELIVERED_TIMESTAMP_HEADER = "X-Signal-Timestamp"; private static final Map NO_HEADERS = Collections.emptyMap(); @@ -717,7 +722,12 @@ public class PushServiceSocket { String requestBody = JsonUtil.toJson(signalServiceProfileWrite); ProfileAvatarUploadAttributes formAttributes; - String response = makeServiceRequest(String.format(PROFILE_PATH, ""), "PUT", requestBody); + String response = makeServiceRequest(String.format(PROFILE_PATH, ""), + "PUT", + requestBody, + NO_HEADERS, + PaymentsRegionException::responseCodeHandler, + Optional.absent()); if (signalServiceProfileWrite.hasAvatar() && profileAvatar != null) { try { @@ -784,10 +794,14 @@ public class PushServiceSocket { } } - private String getCredentials(String authPath) throws IOException { + private AuthCredentials getAuthCredentials(String authPath) throws IOException { String response = makeServiceRequest(authPath, "GET", null, NO_HEADERS); AuthCredentials token = JsonUtil.fromJson(response, AuthCredentials.class); - return token.asBasic(); + return token; + } + + private String getCredentials(String authPath) throws IOException { + return getAuthCredentials(authPath).asBasic(); } public String getContactDiscoveryAuthorization() throws IOException { @@ -798,6 +812,10 @@ public class PushServiceSocket { return getCredentials(KBS_AUTH_PATH); } + public AuthCredentials getPaymentsAuthorization() throws IOException { + return getAuthCredentials(PAYMENTS_AUTH_PATH); + } + public TokenResponse getKeyBackupServiceToken(String authorizationToken, String enclaveName) throws IOException { @@ -2133,6 +2151,18 @@ public class PushServiceSocket { return GroupExternalCredential.parseFrom(readBodyBytes(response)); } + public CurrencyConversions getCurrencyConversions() + throws NonSuccessfulResponseCodeException, PushNetworkException, MalformedResponseException + { + String response = makeServiceRequest(PAYMENTS_CONVERSIONS, "GET", null); + try { + return JsonUtil.fromJson(response, CurrencyConversions.class); + } catch (IOException e) { + Log.w(TAG, e); + throw new MalformedResponseException("Unable to parse entity", e); + } + } + public static final class GroupHistory { private final GroupChanges groupChanges; private final Optional contentRange; diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/exceptions/PaymentsRegionException.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/exceptions/PaymentsRegionException.java new file mode 100644 index 0000000000..9ea9c75886 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/exceptions/PaymentsRegionException.java @@ -0,0 +1,18 @@ +package org.whispersystems.signalservice.internal.push.exceptions; + +import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException; + +public final class PaymentsRegionException extends NonSuccessfulResponseCodeException { + public PaymentsRegionException(int code) { + super(code); + } + + /** + * Promotes a 403 to this exception type. + */ + public static void responseCodeHandler(int responseCode) throws PaymentsRegionException { + if (responseCode == 403) { + throw new PaymentsRegionException(responseCode); + } + } +} diff --git a/libsignal/service/src/main/proto/SignalService.proto b/libsignal/service/src/main/proto/SignalService.proto index ba9238277e..ef461cf3bb 100644 --- a/libsignal/service/src/main/proto/SignalService.proto +++ b/libsignal/service/src/main/proto/SignalService.proto @@ -238,6 +238,45 @@ message DataMessage { optional string eraId = 1; } + message Payment { + + message Address { + message MobileCoin { + optional bytes address = 1; + } + + oneof Address { + MobileCoin mobileCoin = 1; + } + } + + message Amount { + message MobileCoin { + optional uint64 picoMob = 1; + } + + oneof Amount { + MobileCoin mobileCoin = 1; + } + } + + message Notification { + message MobileCoin { + optional bytes receipt = 1; + } + + oneof Transaction { + MobileCoin mobileCoin = 1; + } + + optional string note = 2; + } + + oneof Item { + Notification notification = 1; + } + } + enum ProtocolVersion { option allow_alias = true; @@ -248,7 +287,8 @@ message DataMessage { REACTIONS = 4; CDN_SELECTOR_ATTACHMENTS = 5; MENTIONS = 6; - CURRENT = 6; + PAYMENTS = 7; + CURRENT = 7; } optional string body = 1; @@ -269,7 +309,7 @@ message DataMessage { optional Delete delete = 17; repeated BodyRange bodyRanges = 18; optional GroupCallUpdate groupCallUpdate = 19; - reserved /* future use */ 20; + optional Payment payment = 20; } message NullMessage { @@ -418,6 +458,28 @@ message SyncMessage { optional Type type = 4; } + message OutgoingPayment { + message MobileCoin { + optional bytes recipientAddress = 1; + // @required + optional uint64 amountPicoMob = 2; + // @required + optional uint64 feePicoMob = 3; + optional bytes receipt = 4; + optional uint64 ledgerBlockTimestamp = 5; + // @required + optional uint64 ledgerBlockIndex = 6; + repeated bytes spentKeyImages = 7; + repeated bytes outputPublicKeys = 8; + } + optional string recipientUuid = 1; + optional string note = 2; + + oneof paymentDetail { + MobileCoin mobileCoin = 3; + } + } + optional Sent sent = 1; optional Contacts contacts = 2; optional Groups groups = 3; @@ -432,6 +494,7 @@ message SyncMessage { optional FetchLatest fetchLatest = 12; optional Keys keys = 13; optional MessageRequestResponse messageRequestResponse = 14; + optional OutgoingPayment outgoingPayment = 15; } message AttachmentPointer { @@ -530,3 +593,14 @@ message GroupDetails { optional uint32 inboxPosition = 10; optional bool archived = 11; } + +message PaymentAddress { + oneof Address { + MobileCoinAddress mobileCoinAddress = 1; + } + + message MobileCoinAddress { + optional bytes address = 1; + optional bytes signature = 2; + } +} diff --git a/libsignal/service/src/main/proto/SignalStorage.proto b/libsignal/service/src/main/proto/SignalStorage.proto index d18b8c40b2..a9854c655f 100644 --- a/libsignal/service/src/main/proto/SignalStorage.proto +++ b/libsignal/service/src/main/proto/SignalStorage.proto @@ -99,6 +99,11 @@ message GroupV2Record { bool markedUnread = 5; } +message Payments { + bool enabled = 1; + bytes entropy = 2; +} + message AccountRecord { enum PhoneNumberSharingMode { @@ -135,4 +140,5 @@ message AccountRecord { bool unlistedPhoneNumber = 13; repeated PinnedConversation pinnedConversations = 14; bool preferContactAvatars = 15; + Payments payments = 16; } diff --git a/libsignal/service/src/test/java/org/whispersystems/signalservice/api/crypto/ProfileCipherTest.java b/libsignal/service/src/test/java/org/whispersystems/signalservice/api/crypto/ProfileCipherTest.java index c03ac68b1c..6ab55cac9d 100644 --- a/libsignal/service/src/test/java/org/whispersystems/signalservice/api/crypto/ProfileCipherTest.java +++ b/libsignal/service/src/test/java/org/whispersystems/signalservice/api/crypto/ProfileCipherTest.java @@ -11,7 +11,6 @@ import org.whispersystems.util.Base64; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; -import java.nio.charset.StandardCharsets; import java.security.Security; public class ProfileCipherTest extends TestCase { @@ -23,18 +22,18 @@ public class ProfileCipherTest extends TestCase { public void testEncryptDecrypt() throws InvalidCiphertextException, InvalidInputException { ProfileKey key = new ProfileKey(Util.getSecretBytes(32)); ProfileCipher cipher = new ProfileCipher(key); - byte[] name = cipher.encryptName("Clement\0Duval".getBytes(), 53); - byte[] plaintext = cipher.decryptName(name); - assertEquals(new String(plaintext), "Clement\0Duval"); + byte[] name = cipher.encrypt("Clement\0Duval".getBytes(), 53); + String plaintext = cipher.decryptString(name); + assertEquals(plaintext, "Clement\0Duval"); } public void testEmpty() throws Exception { ProfileKey key = new ProfileKey(Util.getSecretBytes(32)); ProfileCipher cipher = new ProfileCipher(key); - byte[] name = cipher.encryptName("".getBytes(), 26); - byte[] plaintext = cipher.decryptName(name); + byte[] name = cipher.encrypt("".getBytes(), 26); + String plaintext = cipher.decryptString(name); - assertEquals(plaintext.length, 0); + assertEquals(plaintext.length(), 0); } public void testStreams() throws Exception { @@ -64,7 +63,7 @@ public class ProfileCipherTest extends TestCase { public void testEncryptLengthBucket1() throws InvalidInputException { ProfileKey key = new ProfileKey(Util.getSecretBytes(32)); ProfileCipher cipher = new ProfileCipher(key); - byte[] name = cipher.encryptName("Peter\0Parker".getBytes(), 53); + byte[] name = cipher.encrypt("Peter\0Parker".getBytes(), 53); String encoded = Base64.encodeBytes(name); @@ -74,7 +73,7 @@ public class ProfileCipherTest extends TestCase { public void testEncryptLengthBucket2() throws InvalidInputException { ProfileKey key = new ProfileKey(Util.getSecretBytes(32)); ProfileCipher cipher = new ProfileCipher(key); - byte[] name = cipher.encryptName("Peter\0Parker".getBytes(), 257); + byte[] name = cipher.encrypt("Peter\0Parker".getBytes(), 257); String encoded = Base64.encodeBytes(name); diff --git a/libsignal/service/src/test/java/org/whispersystems/signalservice/api/payments/FiatFormatterTest.java b/libsignal/service/src/test/java/org/whispersystems/signalservice/api/payments/FiatFormatterTest.java new file mode 100644 index 0000000000..e49577edda --- /dev/null +++ b/libsignal/service/src/test/java/org/whispersystems/signalservice/api/payments/FiatFormatterTest.java @@ -0,0 +1,64 @@ +package org.whispersystems.signalservice.api.payments; + +import org.junit.Test; + +import java.math.BigDecimal; +import java.util.Currency; +import java.util.Locale; + +import static org.junit.Assert.assertEquals; + +public class FiatFormatterTest { + + private static final Currency javaCurrency = Currency.getInstance("USD"); + + @Test + public void givenAFiatCurrency_whenIFormatWithDefaultOptions_thenIExpectADefaultFormattedString() { + // GIVEN + Formatter formatter = Formatter.forFiat(javaCurrency, FormatterOptions.defaults(Locale.US)); + + // WHEN + String result = formatter.format(BigDecimal.valueOf(100)); + + // THEN + assertEquals("$100.00", result); + } + + @Test + public void givenANegative_whenIFormatWithAlwaysPositive_thenIExpectPositive() { + // GIVEN + FormatterOptions options = FormatterOptions.builder(Locale.US).alwaysPositive().build(); + Formatter formatter = Formatter.forFiat(javaCurrency, options); + + // WHEN + String result = formatter.format(BigDecimal.valueOf(-100L)); + + // THEN + assertEquals("$100.00", result); + } + + @Test + public void givenALargeFiatCurrency_whenIFormatWithDefaultOptions_thenIExpectGrouping() { + // GIVEN + Formatter formatter = Formatter.forFiat(javaCurrency, FormatterOptions.defaults(Locale.US)); + + // WHEN + String result = formatter.format(BigDecimal.valueOf(1000L)); + + // THEN + assertEquals("$1,000.00", result); + } + + @Test + public void givenAFiatCurrency_whenIFormatWithoutUnit_thenIExpectAStringWithoutUnit() { + // GIVEN + Formatter formatter = Formatter.forFiat(javaCurrency, FormatterOptions.builder(Locale.US).withoutUnit().build()); + + // WHEN + String result = formatter.format(BigDecimal.ONE); + + // THEN + assertEquals("1.00", result); + } + +} \ No newline at end of file diff --git a/libsignal/service/src/test/java/org/whispersystems/signalservice/api/payments/MobileCoinFormatterTest.java b/libsignal/service/src/test/java/org/whispersystems/signalservice/api/payments/MobileCoinFormatterTest.java new file mode 100644 index 0000000000..7ace7a4d4f --- /dev/null +++ b/libsignal/service/src/test/java/org/whispersystems/signalservice/api/payments/MobileCoinFormatterTest.java @@ -0,0 +1,149 @@ +package org.whispersystems.signalservice.api.payments; + +import org.junit.Test; + +import java.math.BigDecimal; +import java.util.Locale; + +import static org.junit.Assert.assertEquals; + +public class MobileCoinFormatterTest { + + private static final Currency currency = Money.MobileCoin.CURRENCY; + + @Test + public void givenAMoneyCurrency_whenIFormatWithDefaultOptions_thenIExpectADefaultFormattedString() { + // GIVEN + Formatter formatter = Formatter.forMoney(currency, FormatterOptions.defaults(Locale.US)); + + // WHEN + String result = formatter.format(BigDecimal.ONE); + + // THEN + assertEquals("1 MOB", result); + } + + @Test + public void givenALargeMoneyCurrency_whenIFormatWithDefaultOptions_thenIExpectGrouping() { + // GIVEN + Formatter formatter = Formatter.forMoney(currency, FormatterOptions.defaults(Locale.US)); + + // WHEN + String result = formatter.format(BigDecimal.valueOf(-1000L)); + + // THEN + assertEquals("-1,000 MOB", result); + } + + @Test + public void givenANegative_whenIFormatWithAlwaysPositive_thenIExpectPositive() { + // GIVEN + FormatterOptions options = FormatterOptions.builder(Locale.US).alwaysPositive().build(); + Formatter formatter = Formatter.forMoney(currency, options); + + // WHEN + String result = formatter.format(BigDecimal.valueOf(-100L)); + + // THEN + assertEquals("100 MOB", result); + } + + @Test + public void givenAnAmount_whenIFormatWithoutSpaceBeforeUnit_thenIExpectNoSpaceBeforeUnit() { + // GIVEN + FormatterOptions options = FormatterOptions.builder(Locale.US).withoutSpaceBeforeUnit().build(); + Formatter formatter = Formatter.forMoney(currency, options); + + // WHEN + String result = formatter.format(BigDecimal.valueOf(100L)); + + // THEN + assertEquals("100MOB", result); + } + + @Test + public void givenAnAmount_whenIFormatWithoutUnit_thenIExpectNoSpaceBeforeUnit() { + // GIVEN + FormatterOptions options = FormatterOptions.builder(Locale.US).withoutUnit().build(); + Formatter formatter = Formatter.forMoney(currency, options); + + // WHEN + String result = formatter.format(BigDecimal.valueOf(100L)); + + // THEN + assertEquals("100", result); + } + + @Test + public void givenAnAmount_whenIFormatWithAlwaysPrefixSign_thenIExpectSignOnPositiveValues() { + // GIVEN + FormatterOptions options = FormatterOptions.builder(Locale.US).alwaysPrefixWithSign().build(); + Formatter formatter = Formatter.forMoney(currency, options); + + // WHEN + String result = formatter.format(BigDecimal.valueOf(100L)); + + // THEN + assertEquals("+100 MOB", result); + } + + @Test + public void givenAMoneyCurrency_whenIToStringWithDefaultOptions_thenIExpectADefaultFormattedString() { + // GIVEN + FormatterOptions options = FormatterOptions.defaults(Locale.US); + + // WHEN + String result = Money.mobileCoin(BigDecimal.ONE).toString(options); + + // THEN + assertEquals("1 MOB", result); + } + + @Test + public void givenAnAmount_whenIToStringWithAlwaysPrefixSign_thenIExpectSignOnPositiveValues() { + // GIVEN + FormatterOptions options = FormatterOptions.builder(Locale.US).alwaysPrefixWithSign().build(); + + // WHEN + String result = Money.mobileCoin(BigDecimal.ONE).toString(options); + + // THEN + assertEquals("+1 MOB", result); + } + + @Test + public void givenAnAmount_whenIToStringWithMaximumFractionalDigitsOf4_thenIExpectRoundingAndTruncating() { + // GIVEN + FormatterOptions options = FormatterOptions.builder(Locale.US).withMaximumFractionDigits(4).build(); + + // WHEN + String result = Money.mobileCoin(BigDecimal.valueOf(1.1234567)).toString(options); + + // THEN + assertEquals("1.1235 MOB", result); + } + + @Test + public void givenAnAmount_whenIToStringWithMaximumFractionalDigitsOf5_thenIExpectToSee5Places() { + // GIVEN + FormatterOptions options = FormatterOptions.builder(Locale.US).withMaximumFractionDigits(5).build(); + + // WHEN + String result = Money.mobileCoin(BigDecimal.valueOf(1.1234507)).toString(options); + + // THEN + assertEquals("1.12345 MOB", result); + } + + @Test + public void givenAnAmount_whenIToStringWithMaximumFractionalDigitsOf5ButFewerActual_thenIExpectToSeeFewerPlaces() { + // GIVEN + FormatterOptions options = FormatterOptions.builder(Locale.US).withMaximumFractionDigits(5).build(); + + // WHEN + String result = Money.mobileCoin(BigDecimal.valueOf(1.120003)).toString(options); + + // THEN + assertEquals("1.12 MOB", result); + } +} \ No newline at end of file diff --git a/libsignal/service/src/test/java/org/whispersystems/signalservice/api/payments/MoneyTest_MobileCoin.java b/libsignal/service/src/test/java/org/whispersystems/signalservice/api/payments/MoneyTest_MobileCoin.java new file mode 100644 index 0000000000..52074364e6 --- /dev/null +++ b/libsignal/service/src/test/java/org/whispersystems/signalservice/api/payments/MoneyTest_MobileCoin.java @@ -0,0 +1,342 @@ +package org.whispersystems.signalservice.api.payments; + +import org.junit.Test; +import org.whispersystems.signalservice.api.util.Uint64RangeException; + +import java.math.BigDecimal; +import java.math.BigInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +public final class MoneyTest_MobileCoin { + + @Test + public void create_zero() { + Money mobileCoin = Money.mobileCoin(BigDecimal.ZERO); + + assertFalse(mobileCoin.isPositive()); + assertFalse(mobileCoin.isNegative()); + } + + @Test + public void create_positive() { + Money mobileCoin = Money.mobileCoin(BigDecimal.ONE); + + assertTrue(mobileCoin.isPositive()); + assertFalse(mobileCoin.isNegative()); + } + + @Test + public void create_negative() { + Money mobileCoin = Money.mobileCoin(BigDecimal.ONE.negate()); + + assertFalse(mobileCoin.isPositive()); + assertTrue(mobileCoin.isNegative()); + } + + @Test + public void toString_format() { + Money.MobileCoin mobileCoin = Money.mobileCoin(BigDecimal.valueOf(-1000.32456)); + + assertEquals("MOB:-1000324560000000", mobileCoin.toString()); + } + + @Test + public void toAmountString_format() { + Money.MobileCoin mobileCoin = Money.mobileCoin(BigDecimal.valueOf(-1000.32456)); + + assertEquals("-1000.32456", mobileCoin.getAmountDecimalString()); + } + + @Test + public void currency() { + Money mobileCoin = Money.mobileCoin(BigDecimal.valueOf(-1000.32456)); + + assertEquals("MOB", mobileCoin.getCurrency().getCurrencyCode()); + assertEquals(12, mobileCoin.getCurrency().getDecimalPrecision()); + } + + @Test + public void equality() { + Money mobileCoin1 = Money.mobileCoin(BigDecimal.ONE); + Money mobileCoin10 = Money.mobileCoin(BigDecimal.ONE); + + assertEquals(mobileCoin1, mobileCoin10); + assertEquals(mobileCoin1.hashCode(), mobileCoin10.hashCode()); + } + + @Test + public void inequality() { + Money mobileCoin1 = Money.mobileCoin(BigDecimal.ONE); + Money mobileCoin10 = Money.mobileCoin(BigDecimal.TEN); + + assertNotEquals(mobileCoin1, mobileCoin10); + assertNotEquals(mobileCoin1.hashCode(), mobileCoin10.hashCode()); + } + + @Test + public void negate() { + Money money1 = Money.mobileCoin(BigDecimal.ONE); + Money moneyNegative1 = Money.mobileCoin(BigDecimal.ONE.negate()); + Money negated = money1.negate(); + + assertEquals(moneyNegative1, negated); + } + + @Test + public void abs() { + Money money0 = Money.mobileCoin(BigDecimal.ZERO); + Money money1 = Money.mobileCoin(BigDecimal.ONE); + Money moneyNegative1 = Money.mobileCoin(BigDecimal.ONE.negate()); + Money absOfZero = money0.abs(); + Money absOfPositive = money1.abs(); + Money absOfNegative = moneyNegative1.abs(); + + assertSame(money0, absOfZero); + assertSame(money1, absOfPositive); + assertEquals(money1, absOfNegative); + } + + @Test + public void require_cast() { + Money money = Money.mobileCoin(BigDecimal.ONE.negate()); + Money.MobileCoin mobileCoin = money.requireMobileCoin(); + + assertSame(money, mobileCoin); + } + + @Test + public void serialize_negative() { + Money.MobileCoin mobileCoin = Money.mobileCoin(BigDecimal.valueOf(-1000.32456)); + + assertEquals("MOB:-1000324560000000", mobileCoin.serialize()); + } + + @Test + public void parse_negative() throws Money.ParseException { + Money original = Money.mobileCoin(BigDecimal.valueOf(-1000.32456)); + String serialized = original.serialize(); + + Money deserialized = Money.parse(serialized); + + assertEquals(original, deserialized); + } + + @Test + public void parseOrThrow() { + Money original = Money.mobileCoin(BigDecimal.valueOf(-123.6323)); + String serialized = original.serialize(); + + Money deserialized = Money.parseOrThrow(serialized); + + assertEquals(original, deserialized); + } + + @Test + public void parse_zero() { + Money value = Money.parseOrThrow("MOB:0000000000000000"); + + assertSame(Money.MobileCoin.ZERO, value); + } + + @Test(expected = Money.ParseException.class) + public void parse_fail_empty() throws Money.ParseException { + Money.parse(""); + } + + @Test(expected = Money.ParseException.class) + public void parse_fail_null() throws Money.ParseException { + Money.parse(null); + } + + @Test(expected = Money.ParseException.class) + public void parse_fail_unknown_currency() throws Money.ParseException { + Money.parse("XYZ:123"); + } + + @Test(expected = Money.ParseException.class) + public void parse_fail_no_value() throws Money.ParseException { + Money.parse("MOB"); + } + + @Test(expected = Money.ParseException.class) + public void parse_fail_too_many_parts() throws Money.ParseException { + Money.parse("MOB:1:2"); + } + + @Test(expected = AssertionError.class) + public void parseOrThrowOrThrow_fail_empty() { + Money.parseOrThrow(""); + } + + @Test(expected = AssertionError.class) + public void parseOrThrowOrThrow_fail_null() { + Money.parseOrThrow(null); + } + + @Test(expected = AssertionError.class) + public void parseOrThrowOrThrow_fail_unknown_currency() { + Money.parseOrThrow("XYZ:123"); + } + + @Test(expected = AssertionError.class) + public void parseOrThrowOrThrow_fail_no_value() { + Money.parseOrThrow("MOB"); + } + + @Test(expected = AssertionError.class) + public void parseOrThrowOrThrow_fail_too_many_parts() { + Money.parseOrThrow("MOB:1:2"); + } + + @Test + public void from_big_integer_picoMobileCoin() { + Money.MobileCoin mobileCoin1 = Money.mobileCoin(new BigDecimal("352324.325232123456")); + Money.MobileCoin mobileCoin2 = Money.picoMobileCoin(BigInteger.valueOf(352324325232123456L)); + + assertEquals(mobileCoin1, mobileCoin2); + } + + @Test + public void from_big_integer_picoMobileCoin_zero() { + Money.MobileCoin mobileCoin = Money.picoMobileCoin(BigInteger.ZERO); + + assertSame(Money.MobileCoin.ZERO, mobileCoin); + } + + @Test + public void from_very_large_big_integer_picoMobileCoin() { + Money.MobileCoin mobileCoin1 = Money.mobileCoin(new BigDecimal("352324.325232123456")); + Money.MobileCoin mobileCoin2 = Money.picoMobileCoin(BigInteger.valueOf(352324325232123456L)); + + assertEquals(mobileCoin1, mobileCoin2); + } + + @Test + public void to_picoMob_bigInteger() { + Money.MobileCoin mobileCoin1 = Money.mobileCoin(BigDecimal.valueOf(21324.325232)); + + BigInteger bigInteger = mobileCoin1.toPicoMobBigInteger(); + + assertEquals(BigInteger.valueOf(21324325232000000L), bigInteger); + } + + @Test(expected = ArithmeticException.class) + public void precision_loss_on_creation() { + Money.mobileCoin(new BigDecimal("10376293.0000000000001")); + } + + @Test(expected = ArithmeticException.class) + public void precision_loss_on_creation_negative() { + Money.mobileCoin(new BigDecimal("-10376293.0000000000001")); + } + + @Test + public void from_picoMob() { + Money.MobileCoin mobileCoin1 = Money.picoMobileCoin(1234567890987654321L); + + assertEquals("1234567.890987654321", mobileCoin1.getAmountDecimalString()); + } + + @Test + public void to_picoMob() throws Uint64RangeException { + Money.MobileCoin mobileCoin1 = Money.picoMobileCoin(1234567890987654321L); + + assertEquals(1234567890987654321L, mobileCoin1.toPicoMobUint64()); + } + + @Test + public void from_large_picoMob() { + Money.MobileCoin mobileCoin1 = Money.picoMobileCoin(0x9000000000000000L); + + assertEquals("10376293.541461622784", mobileCoin1.getAmountDecimalString()); + } + + @Test + public void from_large_negative() { + Money.MobileCoin mobileCoin1 = Money.mobileCoin(new BigDecimal("-1234567.890987654321")); + + assertEquals("-1234567.890987654321", mobileCoin1.getAmountDecimalString()); + } + + @Test + public void to_large_picoMob() throws Uint64RangeException { + Money.MobileCoin mobileCoin1 = Money.picoMobileCoin(0x9000000000000000L); + + assertEquals(0x9000000000000000L, mobileCoin1.toPicoMobUint64()); + } + + @Test + public void from_maximum_picoMob() { + Money.MobileCoin mobileCoin = Money.picoMobileCoin(0xffffffffffffffffL); + + assertEquals("18446744073709551615", mobileCoin.serializeAmountString()); + } + + @Test + public void from_maximum_picoMob_and_back() throws Uint64RangeException { + Money.MobileCoin mobileCoin = Money.picoMobileCoin(0xffffffffffffffffL); + + assertEquals(0xffffffffffffffffL, mobileCoin.toPicoMobUint64()); + } + + @Test + public void large_mobile_coin_value_exceeding_64_bits() { + Money.MobileCoin mobileCoin = Money.mobileCoin(new BigDecimal("18446744.073709551616")); + + assertEquals("18446744073709551616", mobileCoin.serializeAmountString()); + } + + @Test(expected = Uint64RangeException.class) + public void large_mobile_coin_value_exceeding_64_bits_toPicoMobUint64_failure() throws Uint64RangeException { + Money.MobileCoin mobileCoin = Money.mobileCoin(new BigDecimal("18446744.073709551616")); + + mobileCoin.toPicoMobUint64(); + } + + @Test(expected = Uint64RangeException.class) + public void negative_to_pico_mob_uint64() throws Uint64RangeException { + Money.MobileCoin mobileCoin1 = Money.mobileCoin(new BigDecimal("-1")); + + mobileCoin1.toPicoMobUint64(); + } + + @Test + public void greater_than() { + assertTrue(mobileCoin2(2).greaterThan(mobileCoin2(1))); + assertTrue(mobileCoin2(-1).greaterThan(mobileCoin2(-2))); + assertFalse(mobileCoin2(2).greaterThan(mobileCoin2(2))); + assertFalse(mobileCoin2(1).greaterThan(mobileCoin2(2))); + } + + @Test + public void less_than() { + assertTrue(mobileCoin2(1).lessThan(mobileCoin2(2))); + assertTrue(mobileCoin2(-2).lessThan(mobileCoin2(-1))); + assertFalse(mobileCoin2(2).lessThan(mobileCoin2(2))); + assertFalse(mobileCoin2(2).lessThan(mobileCoin2(1))); + } + + @Test + public void zero_constant() { + Money mobileCoin1 = Money.mobileCoin(BigDecimal.ZERO); + Money mobileCoin2 = Money.MobileCoin.ZERO; + + assertEquals(mobileCoin1, mobileCoin2); + } + + @Test + public void to_zero() { + Money mobileCoin = Money.mobileCoin(BigDecimal.ONE); + + assertSame(Money.MobileCoin.ZERO, mobileCoin.toZero()); + } + + private static Money.MobileCoin mobileCoin2(double value) { + return Money.mobileCoin(BigDecimal.valueOf(value)); + } +} diff --git a/libsignal/service/src/test/java/org/whispersystems/signalservice/api/payments/MoneyTest_MobileCoin_add.java b/libsignal/service/src/test/java/org/whispersystems/signalservice/api/payments/MoneyTest_MobileCoin_add.java new file mode 100644 index 0000000000..62b146031c --- /dev/null +++ b/libsignal/service/src/test/java/org/whispersystems/signalservice/api/payments/MoneyTest_MobileCoin_add.java @@ -0,0 +1,76 @@ +package org.whispersystems.signalservice.api.payments; + +import org.junit.Test; +import org.whispersystems.signalservice.api.util.Uint64RangeException; + +import java.math.BigDecimal; +import java.math.BigInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +public final class MoneyTest_MobileCoin_add { + + @Test + public void add_0() { + Money mobileCoin1 = Money.mobileCoin(BigDecimal.ZERO); + Money mobileCoin2 = Money.mobileCoin(BigDecimal.ZERO); + + Money sum = mobileCoin1.add(mobileCoin2); + + assertEquals(Money.mobileCoin(BigDecimal.ZERO), sum); + } + + @Test + public void add_1_rhs() { + Money mobileCoin1 = Money.mobileCoin(BigDecimal.ZERO); + Money mobileCoin2 = Money.mobileCoin(BigDecimal.ONE); + + Money sum = mobileCoin1.add(mobileCoin2); + + assertEquals(Money.mobileCoin(BigDecimal.ONE), sum); + } + + @Test + public void add_1_lhs() { + Money mobileCoin1 = Money.mobileCoin(BigDecimal.ONE); + Money mobileCoin2 = Money.mobileCoin(BigDecimal.ZERO); + + Money sum = mobileCoin1.add(mobileCoin2); + + assertEquals(Money.mobileCoin(BigDecimal.ONE), sum); + } + + @Test + public void add_2() { + Money mobileCoin1 = Money.mobileCoin(BigDecimal.ONE); + Money mobileCoin2 = Money.mobileCoin(BigDecimal.ONE); + + Money sum = mobileCoin1.add(mobileCoin2); + + assertEquals(Money.mobileCoin(BigDecimal.valueOf(2)), sum); + } + + @Test + public void add_fraction() { + Money mobileCoin1 = Money.mobileCoin(BigDecimal.ONE); + Money mobileCoin2 = Money.mobileCoin(BigDecimal.valueOf(2.2)); + + Money sum = mobileCoin1.add(mobileCoin2); + + assertEquals(Money.mobileCoin(BigDecimal.valueOf(3.2)), sum); + } + + @Test + public void add_negative_fraction() { + Money mobileCoin1 = Money.mobileCoin(BigDecimal.valueOf(-5.2)); + Money mobileCoin2 = Money.mobileCoin(BigDecimal.ONE); + + Money sum = mobileCoin1.add(mobileCoin2); + + assertEquals(Money.mobileCoin(BigDecimal.valueOf(-4.2)), sum); + } +} diff --git a/libsignal/service/src/test/java/org/whispersystems/signalservice/api/payments/MoneyTest_MobileCoin_comparators.java b/libsignal/service/src/test/java/org/whispersystems/signalservice/api/payments/MoneyTest_MobileCoin_comparators.java new file mode 100644 index 0000000000..5fe5d796a5 --- /dev/null +++ b/libsignal/service/src/test/java/org/whispersystems/signalservice/api/payments/MoneyTest_MobileCoin_comparators.java @@ -0,0 +1,32 @@ +package org.whispersystems.signalservice.api.payments; + +import org.junit.Test; + +import java.math.BigDecimal; +import java.util.List; + +import static java.util.Arrays.asList; +import static org.junit.Assert.assertEquals; + +public final class MoneyTest_MobileCoin_comparators { + + @Test + public void sort_ascending() { + Money.MobileCoin mobileCoin1 = Money.mobileCoin(BigDecimal.ONE); + Money.MobileCoin mobileCoin2 = Money.mobileCoin(BigDecimal.valueOf(2)); + List list = asList(mobileCoin2, mobileCoin1); + list.sort(Money.MobileCoin.ASCENDING); + + assertEquals(asList(mobileCoin1, mobileCoin2), list); + } + + @Test + public void sort_descending() { + Money.MobileCoin mobileCoin1 = Money.mobileCoin(BigDecimal.ONE); + Money.MobileCoin mobileCoin2 = Money.mobileCoin(BigDecimal.valueOf(2)); + List list = asList(mobileCoin1, mobileCoin2); + list.sort(Money.MobileCoin.DESCENDING); + + assertEquals(asList(mobileCoin2, mobileCoin1), list); + } +} diff --git a/libsignal/service/src/test/java/org/whispersystems/signalservice/api/payments/MoneyTest_MobileCoin_subtract.java b/libsignal/service/src/test/java/org/whispersystems/signalservice/api/payments/MoneyTest_MobileCoin_subtract.java new file mode 100644 index 0000000000..3995bfe899 --- /dev/null +++ b/libsignal/service/src/test/java/org/whispersystems/signalservice/api/payments/MoneyTest_MobileCoin_subtract.java @@ -0,0 +1,70 @@ +package org.whispersystems.signalservice.api.payments; + +import org.junit.Test; + +import java.math.BigDecimal; + +import static org.junit.Assert.assertEquals; + +public final class MoneyTest_MobileCoin_subtract { + + @Test + public void subtract_0() { + Money mobileCoin1 = Money.mobileCoin(BigDecimal.ZERO); + Money mobileCoin2 = Money.mobileCoin(BigDecimal.ZERO); + + Money sum = mobileCoin1.subtract(mobileCoin2); + + assertEquals(Money.mobileCoin(BigDecimal.ZERO), sum); + } + + @Test + public void subtract_1_rhs() { + Money mobileCoin1 = Money.mobileCoin(BigDecimal.ZERO); + Money mobileCoin2 = Money.mobileCoin(BigDecimal.ONE); + + Money sum = mobileCoin1.subtract(mobileCoin2); + + assertEquals(Money.mobileCoin(BigDecimal.ONE.negate()), sum); + } + + @Test + public void subtract_1_lhs() { + Money mobileCoin1 = Money.mobileCoin(BigDecimal.ONE); + Money mobileCoin2 = Money.mobileCoin(BigDecimal.ZERO); + + Money sum = mobileCoin1.subtract(mobileCoin2); + + assertEquals(Money.mobileCoin(BigDecimal.ONE), sum); + } + + @Test + public void subtract_2() { + Money mobileCoin1 = Money.mobileCoin(BigDecimal.ONE); + Money mobileCoin2 = Money.mobileCoin(BigDecimal.ONE); + + Money sum = mobileCoin1.subtract(mobileCoin2); + + assertEquals(Money.mobileCoin(BigDecimal.ZERO), sum); + } + + @Test + public void subtract_fraction() { + Money mobileCoin1 = Money.mobileCoin(BigDecimal.valueOf(2.2)); + Money mobileCoin2 = Money.mobileCoin(BigDecimal.ONE); + + Money sum = mobileCoin1.subtract(mobileCoin2); + + assertEquals(Money.mobileCoin(BigDecimal.valueOf(1.2)), sum); + } + + @Test + public void subtract_negative_fraction() { + Money mobileCoin1 = Money.mobileCoin(BigDecimal.ONE); + Money mobileCoin2 = Money.mobileCoin(BigDecimal.valueOf(-5.2)); + + Money sum = mobileCoin1.subtract(mobileCoin2); + + assertEquals(Money.mobileCoin(BigDecimal.valueOf(6.2)), sum); + } +} diff --git a/libsignal/service/src/test/java/org/whispersystems/signalservice/api/payments/MoneyTest_MobileCoin_sum.java b/libsignal/service/src/test/java/org/whispersystems/signalservice/api/payments/MoneyTest_MobileCoin_sum.java new file mode 100644 index 0000000000..a591617eea --- /dev/null +++ b/libsignal/service/src/test/java/org/whispersystems/signalservice/api/payments/MoneyTest_MobileCoin_sum.java @@ -0,0 +1,50 @@ +package org.whispersystems.signalservice.api.payments; + +import org.junit.Test; + +import java.math.BigDecimal; + +import static java.util.Arrays.asList; +import static java.util.Collections.emptyList; +import static java.util.Collections.singletonList; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; + +public final class MoneyTest_MobileCoin_sum { + + @Test + public void sum_empty_list() { + Money sum = Money.MobileCoin.sum(emptyList()); + + assertSame(Money.MobileCoin.ZERO, sum); + } + + @Test + public void sum_1() { + Money.MobileCoin mobileCoin1 = Money.mobileCoin(BigDecimal.ONE); + + Money sum = Money.MobileCoin.sum(singletonList(mobileCoin1)); + + assertSame(mobileCoin1, sum); + } + + @Test + public void sum_2() { + Money.MobileCoin mobileCoin1 = Money.mobileCoin(BigDecimal.ONE); + Money.MobileCoin mobileCoin2 = Money.mobileCoin(BigDecimal.valueOf(2)); + + Money sum = Money.MobileCoin.sum(asList(mobileCoin1, mobileCoin2)); + + assertEquals(Money.mobileCoin(BigDecimal.valueOf(3)), sum); + } + + @Test + public void sum_negatives() { + Money.MobileCoin mobileCoin1 = Money.mobileCoin(BigDecimal.ONE); + Money.MobileCoin mobileCoin2 = Money.mobileCoin(BigDecimal.valueOf(-2)); + + Money sum = Money.MobileCoin.sum(asList(mobileCoin1, mobileCoin2)); + + assertEquals(Money.mobileCoin(BigDecimal.valueOf(-1)), sum); + } +} diff --git a/libsignal/service/src/test/java/org/whispersystems/signalservice/api/util/ProtoUtilTest.java b/libsignal/service/src/test/java/org/whispersystems/signalservice/api/util/ProtoUtilTest.java index fd3f2718d9..6480b72881 100644 --- a/libsignal/service/src/test/java/org/whispersystems/signalservice/api/util/ProtoUtilTest.java +++ b/libsignal/service/src/test/java/org/whispersystems/signalservice/api/util/ProtoUtilTest.java @@ -90,6 +90,18 @@ public class ProtoUtilTest { assertTrue(ProtoUtil.hasUnknownFields(personWithUnknowns)); } + @Test + public void hasUnknownFields_nullInnerMessage() throws InvalidProtocolBufferException { + TestPersonWithNewMessage person = TestPersonWithNewMessage.newBuilder() + .setName("Peter Parker") + .setAge(23) + .build(); + + TestPerson personWithUnknowns = TestPerson.parseFrom(person.toByteArray()); + + assertFalse(ProtoUtil.hasUnknownFields(personWithUnknowns)); + } + @Test public void combineWithUnknownFields_noUnknowns() throws InvalidProtocolBufferException { TestPerson personWithUnknowns = TestPerson.newBuilder() diff --git a/libsignal/service/src/test/java/org/whispersystems/signalservice/api/util/Uint64UtilTest.java b/libsignal/service/src/test/java/org/whispersystems/signalservice/api/util/Uint64UtilTest.java new file mode 100644 index 0000000000..80af4b2c0f --- /dev/null +++ b/libsignal/service/src/test/java/org/whispersystems/signalservice/api/util/Uint64UtilTest.java @@ -0,0 +1,95 @@ +package org.whispersystems.signalservice.api.util; + +import org.junit.Test; + +import java.math.BigInteger; + +import static org.junit.Assert.assertEquals; +import static org.whispersystems.signalservice.api.util.Uint64Util.uint64ToBigInteger; +import static org.whispersystems.signalservice.api.util.Uint64Util.bigIntegerToUInt64; + +public final class Uint64UtilTest { + + @Test + public void long_zero_to_bigInteger() { + BigInteger bigInteger = uint64ToBigInteger(0); + + assertEquals("0", bigInteger.toString()); + } + + @Test + public void long_to_bigInteger() { + BigInteger bigInteger = uint64ToBigInteger(12345L); + + assertEquals("12345", bigInteger.toString()); + } + + @Test + public void bigInteger_zero_to_long() throws Uint64RangeException { + long uint64 = bigIntegerToUInt64(BigInteger.ZERO); + + assertEquals(0, uint64); + } + + @Test + public void first_uint64_value_to_bigInteger() { + BigInteger bigInteger = uint64ToBigInteger(0x8000000000000000L); + + assertEquals("9223372036854775808", bigInteger.toString()); + } + + @Test + public void bigInteger_to_first_uint64_value() throws Uint64RangeException { + long uint64 = bigIntegerToUInt64(new BigInteger("9223372036854775808")); + + assertEquals(0x8000000000000000L, uint64); + } + + @Test + public void large_uint64_value_to_bigInteger() { + BigInteger bigInteger = uint64ToBigInteger(0xa523f21e412c14d2L); + + assertEquals("11899620852199331026", bigInteger.toString()); + } + + @Test + public void bigInteger_to_large_uint64_value() throws Uint64RangeException { + long uint64 = bigIntegerToUInt64(new BigInteger("11899620852199331026")); + + assertEquals(0xa523f21e412c14d2L, uint64); + } + + @Test + public void largest_uint64_value_to_bigInteger() { + BigInteger bigInteger = uint64ToBigInteger(0xffffffffffffffffL); + + assertEquals("18446744073709551615", bigInteger.toString()); + } + + @Test + public void bigInteger_to_largest_uint64_value() throws Uint64RangeException { + long uint64 = bigIntegerToUInt64(new BigInteger("18446744073709551615")); + + assertEquals(0xffffffffffffffffL, uint64); + } + + @Test(expected = Uint64RangeException.class) + public void too_big_by_one() throws Uint64RangeException { + bigIntegerToUInt64(new BigInteger("18446744073709551616")); + } + + @Test(expected = Uint64RangeException.class) + public void too_small_by_one() throws Uint64RangeException { + bigIntegerToUInt64(new BigInteger("-1")); + } + + @Test(expected = Uint64RangeException.class) + public void too_big_by_a_lot() throws Uint64RangeException { + bigIntegerToUInt64(new BigInteger("1844674407370955161623")); + } + + @Test(expected = Uint64RangeException.class) + public void too_small_by_a_lot() throws Uint64RangeException { + bigIntegerToUInt64(new BigInteger("-1844674407370955161623")); + } +}