diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt index acecaaacaa..4ab758cf54 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt @@ -469,7 +469,8 @@ class EventStreamDelegate @Inject constructor( reactionState(chatId, reactionUpdate.messageId).applyUpdate( emoji = reactionUpdate.emoji.value, actorIsSelf = reactionUpdate.actor == userManager.accountId, - added = reactionUpdate.action == ReactionUpdate.Action.ADDED, + // BOOSTED means the reaction still stands (boosts are permanent), so only REMOVED clears it. + added = reactionUpdate.action != ReactionUpdate.Action.REMOVED, count = reactionUpdate.count, version = reactionUpdate.version, reactedAt = reactionUpdate.reactedAt, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3a4987a9ee..fef8a2bd1a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -68,8 +68,8 @@ protovalidate-kt = "0.1.3" # cadence and there is nothing to keep aligned. # 0.3.0 is the first release of either package to ship R8 keep rules for its generated # messages, which is what lets proguard-rules.pro drop its own. -ocp-client-protocol = "0.7.0" -flipcash2-client-protocol = "0.19.0" +ocp-client-protocol = "0.8.0" +flipcash2-client-protocol = "0.20.0" # The Android port is the ONLY libphonenumber this app depends on, deliberately. Google's # `com.googlecode` artifact used to sit alongside it; the two ship separate copies of the metadata, diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/CommunityController.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/CommunityController.kt new file mode 100644 index 0000000000..4d4ac08eaa --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/CommunityController.kt @@ -0,0 +1,21 @@ +package com.flipcash.services.controllers + +import com.flipcash.services.models.Community +import com.flipcash.services.repository.CommunityRepository +import com.flipcash.services.user.UserManager +import com.getcode.solana.keys.Mint +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class CommunityController @Inject constructor( + private val repository: CommunityRepository, + private val userManager: UserManager, +) { + /** Fetches the community built around the currency [mint]. */ + suspend fun getCommunity(mint: Mint): Result { + val owner = userManager.accountCluster?.authority?.keyPair + ?: return Result.failure(Throwable("No account cluster in UserManager")) + return repository.getCommunity(owner, mint) + } +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/inject/FlipcashModule.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/inject/FlipcashModule.kt index 301f7f1110..1daeefddf9 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/inject/FlipcashModule.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/inject/FlipcashModule.kt @@ -34,6 +34,7 @@ import com.flipcash.services.internal.network.services.PhoneVerificationService import com.flipcash.services.internal.network.services.ProfileService import com.flipcash.services.internal.network.services.PurchaseService import com.flipcash.services.internal.network.services.PushService +import com.flipcash.services.internal.network.services.CommunityService import com.flipcash.services.internal.network.services.ResolverService import com.flipcash.services.internal.network.services.ReportingService import com.flipcash.services.internal.network.services.SettingsService @@ -51,6 +52,7 @@ import com.flipcash.services.internal.repositories.InternalModerationRepository import com.flipcash.services.internal.repositories.InternalProfileRepository import com.flipcash.services.internal.repositories.InternalPurchaseRepository import com.flipcash.services.internal.repositories.InternalPushRepository +import com.flipcash.services.internal.repositories.InternalCommunityRepository import com.flipcash.services.internal.repositories.InternalResolverRepository import com.flipcash.services.internal.repositories.InternalReportingRepository import com.flipcash.services.internal.repositories.InternalSettingsRepository @@ -68,6 +70,7 @@ import com.flipcash.services.repository.ProfileRepository import com.flipcash.services.repository.PurchaseRepository import com.flipcash.services.repository.PushRepository import com.flipcash.services.repository.BlobStorageRepository +import com.flipcash.services.repository.CommunityRepository import com.flipcash.services.repository.ResolverRepository import com.flipcash.services.repository.ReportingRepository import com.flipcash.services.repository.SettingsRepository @@ -185,6 +188,13 @@ internal object FlipcashModule { contactMapper: ContactMapper, ): ContactListRepository = InternalContactListRepository(service, contactMapper) + @Provides + internal fun providesCommunityRepository( + service: CommunityService, + chatMapper: ChatMetadataMapper, + userProfileMapper: UserProfileMapper, + ): CommunityRepository = InternalCommunityRepository(service, chatMapper, userProfileMapper) + @Provides internal fun providesResolverRepository( service: ResolverService, diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/ActivityFeedMessageMapper.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/ActivityFeedMessageMapper.kt index e609ab0436..3ffbdc8167 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/ActivityFeedMessageMapper.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/ActivityFeedMessageMapper.kt @@ -13,7 +13,10 @@ import com.flipcash.services.internal.network.extensions.toId import com.flipcash.services.internal.network.extensions.toMint import com.flipcash.services.internal.network.extensions.toPublicKey import com.flipcash.services.models.ActivityFeedNotification +import com.flipcash.services.models.BoostedReaction import com.flipcash.services.models.NotificationMetadata +import com.flipcash.services.models.chat.Emoji +import com.flipcash.services.internal.network.extensions.toChatId import com.flipcash.services.models.NotificationState import com.flipcash.services.models.SwapState import com.flipcash.services.models.SwappedCryptoMetadata @@ -51,6 +54,7 @@ internal class ActivityFeedMessageMapper @Inject constructor( userId = if (meta.destinationIdentifierCase == Model.DirectlySentCryptoNotificationMetadata.DestinationIdentifierCase.USER_ID ) meta.userId.value.toByteArray().toList() else null, + boostedReaction = if (meta.hasBoostedReaction()) meta.boostedReaction.toDomain() else null, ) } Model.Notification.AdditionalMetadataCase.RECEIVED_CRYPTO -> { @@ -62,6 +66,7 @@ internal class ActivityFeedMessageMapper @Inject constructor( userId = if (meta.sourceIdentifierCase == Model.ReceivedCryptoNotificationMetadata.SourceIdentifierCase.USER_ID ) meta.userId.value.toByteArray().toList() else null, + boostedReaction = if (meta.hasBoostedReaction()) meta.boostedReaction.toDomain() else null, ) } Model.Notification.AdditionalMetadataCase.WITHDREW_CRYPTO -> NotificationMetadata.WithdrewCrypto( @@ -161,4 +166,9 @@ private fun Model.SwapState.toDomain(): SwapState = when (this) { Model.SwapState.SWAP_STATE_UNKNOWN, Model.SwapState.UNRECOGNIZED, null -> SwapState.UNKNOWN -} \ No newline at end of file +} +private fun Model.BoostedReaction.toDomain(): BoostedReaction = BoostedReaction( + chatId = chatId.toChatId(), + messageId = messageId.value, + emoji = Emoji(emoji.value), +) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/ChatMetadataMapper.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/ChatMetadataMapper.kt index 47076b2cfe..f0def1afe6 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/ChatMetadataMapper.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/ChatMetadataMapper.kt @@ -6,6 +6,7 @@ import com.flipcash.services.internal.network.extensions.toChatId import com.flipcash.services.internal.network.extensions.toChatMessage import com.flipcash.services.internal.network.extensions.toChatRules import com.flipcash.services.internal.network.extensions.toChatType +import com.flipcash.services.internal.network.extensions.toHoldingsSummary import com.flipcash.services.internal.network.extensions.toId import com.flipcash.services.internal.network.extensions.toMediaItem import com.flipcash.services.internal.network.extensions.toPointer @@ -57,6 +58,7 @@ class ChatMetadataMapper @Inject constructor( useE2ee = from.useE2Ee, isPrivate = from.isPrivate, inLobby = from.inLobby, + holdingsSummary = if (from.hasHoldingsSummary()) from.holdingsSummary.toHoldingsSummary() else null, ) } } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/CommunityApi.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/CommunityApi.kt new file mode 100644 index 0000000000..837140bdce --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/CommunityApi.kt @@ -0,0 +1,44 @@ +package com.flipcash.services.internal.network.api + +import com.codeinc.flipcash.gen.common.v1.Common +import com.codeinc.flipcash.gen.community.v1.CommunityGrpcKt +import com.codeinc.flipcash.gen.community.v1.validate +import com.flipcash.services.internal.annotations.FlipcashManagedChannel +import com.flipcash.services.internal.network.extensions.authenticate +import com.getcode.ed25519.Ed25519.KeyPair +import com.getcode.opencode.internal.network.core.GrpcApi +import com.getcode.solana.keys.Mint +import com.getcode.utils.toByteString +import dev.bmcreations.protovalidate.orThrow +import io.grpc.ManagedChannel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import javax.inject.Inject +import javax.inject.Singleton +import com.codeinc.flipcash.gen.community.v1.CommunityService as RpcCommunityService + +@Singleton +internal class CommunityApi @Inject constructor( + @FlipcashManagedChannel + managedChannel: ManagedChannel, +) : GrpcApi(managedChannel) { + + private val api = CommunityGrpcKt.CommunityCoroutineStub(managedChannel) + .withWaitForReady() + + suspend fun getCommunity( + owner: KeyPair, + mint: Mint, + ): RpcCommunityService.GetCommunityResponse { + val request = RpcCommunityService.GetCommunityRequest.newBuilder() + .setMint(Common.PublicKey.newBuilder().setValue(mint.bytes.toByteString())) + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.getCommunity(request) + } + } +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt index 246f049f45..90cbcc44bb 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt @@ -25,6 +25,7 @@ import com.flipcash.services.models.chat.ChatMetadata import com.flipcash.services.models.chat.ChatMutation import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.ChatUpdate +import com.flipcash.services.models.chat.HoldingsSummary import com.flipcash.services.models.chat.Emoji import com.flipcash.services.models.chat.KeyEnvelope import com.flipcash.services.models.chat.LobbyMember @@ -312,6 +313,7 @@ internal fun MessagingModel.EmojiReaction.toEmojiReaction(): EmojiReaction { selfReactor = if (hasSelfReactor()) selfReactor.toReactor() else null, sampleReactors = sampleReactorsList.map { it.toReactor() }, version = version, + boostUsd = boostUsd, ) } @@ -320,6 +322,7 @@ internal fun MessagingModel.Reactor.toReactor(): Reactor { userId = userId.toId(), reactedAt = Instant.fromEpochSeconds(reactedTs.seconds, reactedTs.nanos), version = version, + boostUsd = boostUsd, ) } @@ -331,11 +334,14 @@ internal fun MessagingModel.ReactionUpdate.toReactionUpdate(): ReactionUpdate { action = when (action) { MessagingModel.ReactionUpdate.Action.ADDED -> ReactionUpdate.Action.ADDED MessagingModel.ReactionUpdate.Action.REMOVED -> ReactionUpdate.Action.REMOVED + MessagingModel.ReactionUpdate.Action.BOOSTED -> ReactionUpdate.Action.BOOSTED else -> ReactionUpdate.Action.UNKNOWN }, count = count, version = version, reactedAt = Instant.fromEpochSeconds(reactedTs.seconds, reactedTs.nanos), + boostUsd = boostUsd, + actorBoostUsd = actorBoostUsd, ) } @@ -455,6 +461,16 @@ internal fun ChatModel.Metadata.toChatMetadata(): ChatMetadata { useE2ee = useE2Ee, isPrivate = isPrivate, inLobby = inLobby, + holdingsSummary = if (hasHoldingsSummary()) holdingsSummary.toHoldingsSummary() else null, + ) +} + +internal fun ChatModel.HoldingsSummary.toHoldingsSummary(): HoldingsSummary { + return HoldingsSummary( + mint = mint.toMint(), + holdings = Fiat(quarks = quarks), + holderCount = holderCount, + asOf = Instant.fromEpochSeconds(asOf.seconds, asOf.nanos), ) } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ChatMessagingService.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ChatMessagingService.kt index 71e543e2f0..4b331bd4f1 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ChatMessagingService.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ChatMessagingService.kt @@ -305,6 +305,7 @@ internal class ChatMessagingService @Inject constructor( RpcMessagingService.RemoveReactionResponse.Result.OK -> Result.success(response.reaction.toEmojiReaction()) RpcMessagingService.RemoveReactionResponse.Result.DENIED -> Result.failure(RemoveReactionError.Denied()) RpcMessagingService.RemoveReactionResponse.Result.MESSAGE_NOT_FOUND -> Result.failure(RemoveReactionError.MessageNotFound()) + RpcMessagingService.RemoveReactionResponse.Result.CANNOT_REMOVE_BOOSTED -> Result.failure(RemoveReactionError.CannotRemoveBoosted()) RpcMessagingService.RemoveReactionResponse.Result.UNRECOGNIZED -> Result.failure(RemoveReactionError.Unrecognized()) else -> Result.failure(RemoveReactionError.Other()) } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/CommunityService.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/CommunityService.kt new file mode 100644 index 0000000000..734835b67b --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/CommunityService.kt @@ -0,0 +1,36 @@ +package com.flipcash.services.internal.network.services + +import com.flipcash.services.internal.network.api.CommunityApi +import com.flipcash.services.models.GetCommunityError +import com.getcode.ed25519.Ed25519.KeyPair +import com.getcode.opencode.internal.network.extensions.foldWithSuppression +import com.getcode.opencode.utils.toValidationOrElse +import com.getcode.solana.keys.Mint +import javax.inject.Inject +import com.codeinc.flipcash.gen.community.v1.Model as CommunityModel +import com.codeinc.flipcash.gen.community.v1.CommunityService as RpcCommunityService + +internal class CommunityService @Inject constructor( + private val api: CommunityApi, +) { + suspend fun getCommunity( + owner: KeyPair, + mint: Mint, + ): Result { + return runCatching { + api.getCommunity(owner, mint) + }.foldWithSuppression( + onSuccess = { response -> + when (response.result) { + RpcCommunityService.GetCommunityResponse.Result.OK -> Result.success(response.community) + RpcCommunityService.GetCommunityResponse.Result.NOT_FOUND -> Result.failure(GetCommunityError.NotFound()) + RpcCommunityService.GetCommunityResponse.Result.UNRECOGNIZED -> Result.failure(GetCommunityError.Unrecognized()) + else -> Result.failure(GetCommunityError.Other()) + } + }, + onFailure = { cause -> + Result.failure(cause.toValidationOrElse { GetCommunityError.Other(cause = it) }) + } + ) + } +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalCommunityRepository.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalCommunityRepository.kt new file mode 100644 index 0000000000..f036295c6f --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalCommunityRepository.kt @@ -0,0 +1,35 @@ +package com.flipcash.services.internal.repositories + +import com.flipcash.services.internal.domain.ChatMetadataMapper +import com.flipcash.services.internal.domain.UserProfileMapper +import com.flipcash.services.internal.network.extensions.toMint +import com.flipcash.services.internal.network.services.CommunityService +import com.flipcash.services.models.Community +import com.flipcash.services.models.CommunityLeader +import com.flipcash.services.repository.CommunityRepository +import com.getcode.ed25519.Ed25519.KeyPair +import com.getcode.solana.keys.Mint +import com.getcode.utils.ErrorUtils + +internal class InternalCommunityRepository( + private val service: CommunityService, + private val chatMapper: ChatMetadataMapper, + private val userProfileMapper: UserProfileMapper, +) : CommunityRepository { + override suspend fun getCommunity(owner: KeyPair, mint: Mint): Result { + return service.getCommunity(owner, mint) + .map { metadata -> + Community( + mint = metadata.mint.toMint(), + name = metadata.name, + leader = if (metadata.hasLeader()) { + CommunityLeader(profile = userProfileMapper.map(metadata.leader.profile)) + } else { + null + }, + topHolderChats = metadata.topHolderChatsList.map { chatMapper.map(it) }, + ) + } + .onFailure { ErrorUtils.handleError(it) } + } +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/ActivityFeedNotification.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/ActivityFeedNotification.kt index b7b1cbfd3f..c1eaad6f41 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/ActivityFeedNotification.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/ActivityFeedNotification.kt @@ -1,5 +1,7 @@ package com.flipcash.services.models +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.Emoji import com.getcode.opencode.model.core.ID import com.getcode.opencode.model.financial.Fiat import com.getcode.opencode.model.financial.LocalFiat @@ -58,6 +60,7 @@ sealed interface NotificationMetadata { data class DirectlySentCrypto( val phoneNumber: String? = null, val userId: ID? = null, + val boostedReaction: BoostedReaction? = null, ) : NotificationMetadata /** @@ -77,6 +80,7 @@ sealed interface NotificationMetadata { data class ReceivedCrypto( val phoneNumber: String? = null, val userId: ID? = null, + val boostedReaction: BoostedReaction? = null, ) : NotificationMetadata /** @@ -108,6 +112,19 @@ sealed interface NotificationMetadata { /** * The state of a swap as a whole. */ +/** + * Set on a sent/received payment notification when the payment boosted a reaction. A boost that + * paid one person names them as the notification's counterparty; one that paid both the + * message's sender and the group's creator names neither, and the notification amount is the + * whole boost. + */ +@Serializable +data class BoostedReaction( + val chatId: ChatId, + val messageId: Long, + val emoji: Emoji, +) + enum class SwapState { UNKNOWN, PENDING, diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/Community.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/Community.kt new file mode 100644 index 0000000000..5875a42e4e --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/Community.kt @@ -0,0 +1,23 @@ +package com.flipcash.services.models + +import com.flipcash.services.models.chat.ChatMetadata +import com.getcode.solana.keys.Mint + +/** + * A community built around a Flipcash-launched currency. + * + * @param mint The currency the community is built around + * @param name The community's display name + * @param leader The community's leader, if one has been established + * @param topHolderChats Chats among the currency's top holders, as returned by the server + */ +data class Community( + val mint: Mint, + val name: String, + val leader: CommunityLeader?, + val topHolderChats: List, +) + +data class CommunityLeader( + val profile: UserProfile, +) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt index b2ee61d296..c8a8512c64 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt @@ -587,6 +587,15 @@ sealed class StreamEventsError( data class Other(override val cause: Throwable? = null) : StreamEventsError(message = cause?.message, cause = cause), NotifiableError } +sealed class GetCommunityError( + override val message: String? = null, + override val cause: Throwable? = null +): CodeServerError(message, cause) { + class NotFound : GetCommunityError("Not found") + class Unrecognized : GetCommunityError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : GetCommunityError(message = cause?.message, cause = cause), NotifiableError +} + sealed class ResolveContactError( override val message: String? = null, override val cause: Throwable? = null @@ -654,6 +663,8 @@ sealed class RemoveReactionError( ): CodeServerError(message, cause) { class Denied : RemoveReactionError("Denied") class MessageNotFound : RemoveReactionError("Message not found") + // Boosted reactions are permanent. + class CannotRemoveBoosted : RemoveReactionError("Cannot remove boosted reaction") class Unrecognized : RemoveReactionError("Unrecognized"), NotifiableError data class Other(override val cause: Throwable? = null) : RemoveReactionError(message = cause?.message, cause = cause), NotifiableError } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/ReactionBoostMetadata.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/ReactionBoostMetadata.kt new file mode 100644 index 0000000000..2d7f7c5278 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/ReactionBoostMetadata.kt @@ -0,0 +1,35 @@ +package com.flipcash.services.models + +import com.codeinc.flipcash.gen.common.v1.Common +import com.codeinc.flipcash.gen.intent.v1.Model as FlipcashIntentModel +import com.flipcash.services.internal.network.extensions.asEmoji +import com.flipcash.services.internal.network.extensions.asMessageId +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.Emoji +import com.getcode.utils.toByteString + +/** + * Builds the serialized Flipcash `AppMetadata` proto bytes for a payment that boosts a reaction + * on [messageId] in [chatId]. The intent that carries this is built by the payment flow; the + * server resolves the boost's payees and amounts from the intent itself. + * + * Returns `null` when [chatId] or [messageId] is missing so the caller can pass the result + * through unconditionally. + */ +fun buildReactionBoostMetadata( + chatId: ChatId?, + messageId: Long?, + emoji: Emoji, +): ByteArray? { + if (chatId == null || messageId == null) return null + return FlipcashIntentModel.AppMetadata.newBuilder() + .setChat( + FlipcashIntentModel.ChatMetadata.newBuilder() + .setChatId(Common.ChatId.newBuilder().setValue(chatId.bytes.toByteString())) + .setReactionBoost( + FlipcashIntentModel.ChatMetadata.ReactionBoost.newBuilder() + .setMessageId(messageId.asMessageId()) + .setEmoji(emoji.asEmoji()) + ) + ).build().toByteArray() +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatMetadata.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatMetadata.kt index 7b884d3600..541e4e9e4c 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatMetadata.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatMetadata.kt @@ -38,4 +38,8 @@ data class ChatMetadata( val isPrivate: Boolean = false, // Per-viewer: true while the viewer is waiting in this private group's lobby. val inLobby: Boolean = false, + // Periodic snapshot of what the group's members hold of its gating currency. Only set for + // public groups gated on a single Flipcash-launched currency above a server-side minimum + // holder count. No change events: refresh by refetching the chat. + val holdingsSummary: HoldingsSummary? = null, ) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/Emoji.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/Emoji.kt index 21c40d1e1b..b6e667ea8b 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/Emoji.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/Emoji.kt @@ -1,4 +1,7 @@ package com.flipcash.services.models.chat +import kotlinx.serialization.Serializable + +@Serializable @JvmInline value class Emoji(val value: String) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/EmojiReaction.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/EmojiReaction.kt index e8a4e39ab9..a8d66f158d 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/EmojiReaction.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/EmojiReaction.kt @@ -6,4 +6,6 @@ data class EmojiReaction( val selfReactor: Reactor?, val sampleReactors: List, val version: Long, + // Total USD value of boosts on this emoji. The primary sort key of a ReactionSummary. + val boostUsd: Double = 0.0, ) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/HoldingsSummary.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/HoldingsSummary.kt new file mode 100644 index 0000000000..bd18981d61 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/HoldingsSummary.kt @@ -0,0 +1,20 @@ +package com.flipcash.services.models.chat + +import com.getcode.opencode.model.financial.Fiat +import com.getcode.solana.keys.Mint +import kotlin.time.Instant + +/** + * What a group's members held of the currency the group is gated on, as of [asOf]. + * + * @param mint The currency the group is gated on + * @param holdings Total held by members, in the mint's quarks + * @param holderCount Number of members who held any of the currency + * @param asOf When the snapshot was computed + */ +data class HoldingsSummary( + val mint: Mint, + val holdings: Fiat, + val holderCount: Long, + val asOf: Instant, +) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ReactionUpdate.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ReactionUpdate.kt index f81524b896..95d42d24f0 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ReactionUpdate.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ReactionUpdate.kt @@ -11,10 +11,16 @@ data class ReactionUpdate( val count: Long, val version: Long, val reactedAt: Instant, + // Total USD boost on the emoji after this update. + val boostUsd: Double = 0.0, + // USD boost attributable to the actor after this update. + val actorBoostUsd: Double = 0.0, ) { enum class Action { UNKNOWN, ADDED, REMOVED, + // The actor paid to boost an existing reaction. Boosted reactions are permanent. + BOOSTED, } } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/Reactor.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/Reactor.kt index 740cd0dac7..39aa91e268 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/Reactor.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/Reactor.kt @@ -7,4 +7,6 @@ data class Reactor( val userId: ID, val reactedAt: Instant, val version: Long = 0, + // USD value this reactor has paid to boost the reaction; 0 when it was not boosted. + val boostUsd: Double = 0.0, ) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/repository/CommunityRepository.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/repository/CommunityRepository.kt new file mode 100644 index 0000000000..1a542b9d01 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/repository/CommunityRepository.kt @@ -0,0 +1,9 @@ +package com.flipcash.services.repository + +import com.flipcash.services.models.Community +import com.getcode.ed25519.Ed25519.KeyPair +import com.getcode.solana.keys.Mint + +interface CommunityRepository { + suspend fun getCommunity(owner: KeyPair, mint: Mint): Result +} diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/controllers/TransactionController.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/controllers/TransactionController.kt index 011d45831f..ce500e641e 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/controllers/TransactionController.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/controllers/TransactionController.kt @@ -7,6 +7,7 @@ import com.getcode.opencode.internal.manager.VerifiedState import com.getcode.opencode.internal.network.api.intents.IntentDistribution import com.getcode.opencode.internal.network.api.intents.IntentRemoteReceive import com.getcode.opencode.internal.network.api.intents.IntentRemoteSend +import com.getcode.opencode.internal.network.api.intents.IntentMultiTransfer import com.getcode.opencode.internal.network.api.intents.IntentTransfer import com.getcode.opencode.internal.extensions.exchangeDataFor import com.getcode.opencode.internal.solana.extensions.newInstance @@ -157,6 +158,38 @@ class TransactionController @Inject constructor( return submitIntent(scope, intent, source.authority.keyPair) } + /** + * Sends funds to several users' vaults in a single SendPublicMultiPayment intent. Each + * payment carries its own amount; all must share one verified state (rate and reserve). + */ + suspend fun directMultiTransfer( + payments: List>, + verifiedState: VerifiedState, + token: Token, + source: AccountCluster, + appMetadata: ByteArray? = null, + scope: CoroutineScope = this.scope, + ): Result { + val intent = runCatching { + IntentMultiTransfer.create( + shares = payments.map { (destinationOwner, amount) -> + val vault = TimelockDerivedAccounts.newInstance(owner = destinationOwner, token = token).vault.publicKey + IntentMultiTransfer.Share( + destination = vault, + destinationOwner = destinationOwner, + amount = amount, + ) + }, + mint = token.address, + sourceCluster = source, + verifiedState = verifiedState, + appMetadata = appMetadata, + ) + }.getOrElse { return Result.failure(it) } + + return submitIntent(scope, intent, source.authority.keyPair) + } + override suspend fun withdraw( amount: VerifiedFiat, mint: Mint, diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/api/intents/IntentMultiTransfer.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/api/intents/IntentMultiTransfer.kt new file mode 100644 index 0000000000..a66072a6cc --- /dev/null +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/api/intents/IntentMultiTransfer.kt @@ -0,0 +1,128 @@ +package com.getcode.opencode.internal.network.api.intents + +import com.codeinc.opencode.gen.transaction.v1.OcpTransactionService +import com.getcode.opencode.internal.manager.VerifiedState +import com.getcode.opencode.internal.network.api.intents.actions.ActionPublicTransfer +import com.getcode.opencode.internal.network.extensions.asProtobufMetadata +import com.getcode.opencode.model.accounts.AccountCluster +import com.getcode.opencode.model.financial.LocalFiat +import com.getcode.opencode.model.transactions.ExchangeData +import com.getcode.opencode.model.transactions.TransactionMetadata +import com.getcode.opencode.solana.intents.ActionGroup +import com.getcode.opencode.solana.intents.IntentType +import com.getcode.opencode.solana.intents.buildActionGroup +import com.getcode.opencode.utils.generate +import com.getcode.solana.keys.Mint +import com.getcode.solana.keys.PublicKey + +/** + * A SendPublicMultiPayment intent: one public transfer from the source PRIMARY account to each of + * 2..10 OCP-managed PRIMARY destinations. Mirrors [IntentTransfer]'s direct path. + * + * Action list emitted, in payment order (ids are assigned 0..n-1 by [ActionGroup]): + * ``` + * for payment in payments + * NoPrivacyTransferAction(source = vault of source cluster, destination = payment.destination, + * authority = source authority, amount = payment quarks, mint) + * ``` + * Each share's quarks come from that share's own [LocalFiat]; they are not split from a total. + * All payments share one [VerifiedState] (currency, core mint rate, reserve state). + */ +internal class IntentMultiTransfer( + override val id: PublicKey, + override val metadata: TransactionMetadata, + override val actionGroup: ActionGroup, +) : IntentType() { + override fun metadata(): OcpTransactionService.Metadata { + return metadata.asProtobufMetadata() + } + + /** + * @param destination the destination's derived PRIMARY vault for the intent mint + * @param destinationOwner the destination's owner; always sent so a missing PRIMARY can be auto-opened + * @param amount this share's amount + */ + data class Share( + val destination: PublicKey, + val destinationOwner: PublicKey, + val amount: LocalFiat, + ) + + companion object { + const val MIN_PAYMENTS = 2 + const val MAX_PAYMENTS = 10 + + fun create( + shares: List, + mint: Mint, + sourceCluster: AccountCluster, + verifiedState: VerifiedState, + appMetadata: ByteArray? = null, + ): IntentMultiTransfer { + require(shares.size in MIN_PAYMENTS..MAX_PAYMENTS) { + "SendPublicMultiPayment requires $MIN_PAYMENTS..$MAX_PAYMENTS payments, got ${shares.size}" + } + require(shares.map { it.destination }.distinct().size == shares.size) { + "Destinations must be unique" + } + require(shares.map { it.destinationOwner }.distinct().size == shares.size) { + "Destination owners must be unique" + } + require(shares.none { it.destination == sourceCluster.vaultPublicKey }) { + "A destination cannot be the source" + } + require(shares.none { it.destinationOwner == sourceCluster.authorityPublicKey }) { + "A destination owner cannot be the source owner" + } + + require(shares.all { it.amount.mint == mint }) { + "Every payment must be denominated in the intent mint" + } + val shareCurrencies = shares.map { it.amount.nativeAmount.currencyCode }.distinct() + require(shareCurrencies.size == 1) { + "Every payment must use the same currency, got $shareCurrencies" + } + val verifiedCurrency = verifiedState.rateProto.exchangeRate.currencyCode + require(shareCurrencies.single().name.equals(verifiedCurrency, ignoreCase = true)) { + "Payment currency ${shareCurrencies.single().name} must match the verified rate currency $verifiedCurrency" + } + + val actionGroup = buildActionGroup { + shares.forEach { share -> + add( + ActionPublicTransfer.newInstance( + owner = sourceCluster.authority.keyPair, + source = sourceCluster.vaultPublicKey, + destination = share.destination, + amount = share.amount.underlyingTokenAmount, + mint = mint, + ) + ) + } + } + + return IntentMultiTransfer( + id = PublicKey.generate(), + metadata = TransactionMetadata.SendPublicMultiPayment( + source = sourceCluster.vaultPublicKey, + mint = mint, + payments = shares.map { share -> + val verified = ExchangeData.Verified( + mint = mint, + quarks = share.amount.underlyingTokenAmount.quarks, + nativeAmount = share.amount.nativeAmount.decimalValue, + verifiedState = verifiedState, + ) + TransactionMetadata.SendPublicMultiPayment.Payment( + destination = share.destination, + destinationOwner = share.destinationOwner, + verifiedExchangeData = verified, + ) + }, + appMetadata = appMetadata, + ), + actionGroup = actionGroup, + ) + } + } +} diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/extensions/LocalToProtobuf.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/extensions/LocalToProtobuf.kt index 186e7ee458..e737a1a9e5 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/extensions/LocalToProtobuf.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/extensions/LocalToProtobuf.kt @@ -173,6 +173,36 @@ internal fun TransactionMetadata.asProtobufMetadata(): OcpTransactionService.Met } } + is TransactionMetadata.SendPublicMultiPayment -> { + builder.setSendPublicMultiPayment( + OcpTransactionService.SendPublicMultiPaymentMetadata.newBuilder() + .setSource(source.asSolanaAccountId()) + .setMint(mint.asSolanaAccountId()) + .apply { + payments.forEach { payment -> + addPayments( + OcpTransactionService.SendPublicMultiPaymentMetadata.Payment.newBuilder() + .setDestination(payment.destination.asSolanaAccountId()) + .apply { + payment.destinationOwner?.let { setDestinationOwner(it.asSolanaAccountId()) } + payment.verifiedExchangeData?.let { + setClientExchangeData(it.asProtobufExchangeData()) + } + } + .build() + ) + } + } + .build() + ) + if (appMetadata != null) { + builder.setAppMetadata( + OcpTransactionService.AppMetadata.newBuilder() + .setValue(appMetadata.toByteString()) + ) + } + } + is TransactionMetadata.PublicDistribution -> { builder.setPublicDistribution( OcpTransactionService.PublicDistributionMetadata.newBuilder() diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/extensions/ProtobufToLocal.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/extensions/ProtobufToLocal.kt index f45832b989..651cf5b7b4 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/extensions/ProtobufToLocal.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/extensions/ProtobufToLocal.kt @@ -118,6 +118,19 @@ internal fun OcpTransactionService.Metadata.toMetadata(): TransactionMetadata { mint = receivePaymentsPublicly.mint.toMint(), ) + OcpTransactionService.Metadata.TypeCase.SEND_PUBLIC_MULTI_PAYMENT -> TransactionMetadata.SendPublicMultiPayment( + source = sendPublicMultiPayment.source.toPublicKey(), + payments = sendPublicMultiPayment.paymentsList.map { payment -> + TransactionMetadata.SendPublicMultiPayment.Payment( + destination = payment.destination.toPublicKey(), + destinationOwner = if (payment.hasDestinationOwner()) payment.destinationOwner.toPublicKey() else null, + exchangeData = if (payment.hasServerExchangeData()) payment.serverExchangeData.toModel() else null, + verifiedExchangeData = if (payment.hasClientExchangeData()) payment.clientExchangeData.toModel() else null, + ) + }, + mint = sendPublicMultiPayment.mint.toMint(), + ) + OcpTransactionService.Metadata.TypeCase.TYPE_NOT_SET -> TransactionMetadata.Unknown OcpTransactionService.Metadata.TypeCase.PUBLIC_DISTRIBUTION -> TransactionMetadata.PublicDistribution( source = publicDistribution.source.toPublicKey(), diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/model/core/errors/Errors.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/model/core/errors/Errors.kt index bd3440bbd6..c0c7686b39 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/model/core/errors/Errors.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/model/core/errors/Errors.kt @@ -240,6 +240,7 @@ private fun KClass.label(): String = when (this) { TransactionMetadata.OpenAccount::class -> "OpenAccount" TransactionMetadata.PublicPayment::class -> "PublicPayment" TransactionMetadata.SendPublicPayment::class -> "SendPublicPayment" + TransactionMetadata.SendPublicMultiPayment::class -> "SendPublicMultiPayment" TransactionMetadata.ReceivePublicPayment::class -> "ReceivePublicPayment" TransactionMetadata.PublicDistribution::class -> "PublicDistribution" TransactionMetadata.Unknown::class -> "Unknown" diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/model/transactions/TransactionMetadata.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/model/transactions/TransactionMetadata.kt index 4fa1c3a903..666ebd8b0a 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/model/transactions/TransactionMetadata.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/model/transactions/TransactionMetadata.kt @@ -187,6 +187,50 @@ sealed interface TransactionMetadata { ) } + /** + * Send payments publicly from the user's primary account to multiple OCP primary accounts. + * + * ``` + * Action Specification: + * + * for payment in payments + * actions.push_back(NoPrivacyTransferAction(PRIMARY, payment.destination, payment.ExchangeData.Quarks)) + * ``` + * + * Notes: + * - Every destination must be an OCP-managed PRIMARY account for [mint]. Withdrawals, indirect + * sends and fee payments are not supported. + * - Destinations, and the owners of destinations, must be unique and cannot be the source or + * its owner. + * - Every payment must share the same currency, verified core mint rate and, for launchpad + * currencies, verified reserve state. + * - 2 to 10 payments. + * + * @param source The primary account where funds will be sent from + * @param payments The set of payments, each carrying its own exchange data + * @param mint The mint this intent operates against + * @param appMetadata Optional opaque app-defined metadata attached to the intent + */ + data class SendPublicMultiPayment( + val source: PublicKey, + val payments: List, + val mint: Mint, + val appMetadata: ByteArray? = null, + ): TransactionMetadata { + /** + * @param destination The destination PRIMARY account + * @param destinationOwner Required when the destination PRIMARY account will be auto-opened + * @param exchangeData The server-provided exchange data for this share + * @param verifiedExchangeData The client-provided exchange data for this share + */ + data class Payment( + val destination: PublicKey, + val destinationOwner: PublicKey? = null, + val exchangeData: ExchangeData.WithRate? = null, + val verifiedExchangeData: ExchangeData.Verified? = null, + ) + } + /** * Distribute funds from a pool account publicly to one or more user-owned accounts. * diff --git a/services/opencode/src/test/kotlin/com/getcode/opencode/internal/network/api/intents/IntentMultiTransferTest.kt b/services/opencode/src/test/kotlin/com/getcode/opencode/internal/network/api/intents/IntentMultiTransferTest.kt new file mode 100644 index 0000000000..b29ea9ccc0 --- /dev/null +++ b/services/opencode/src/test/kotlin/com/getcode/opencode/internal/network/api/intents/IntentMultiTransferTest.kt @@ -0,0 +1,196 @@ +package com.getcode.opencode.internal.network.api.intents + +import com.codeinc.opencode.gen.currency.v1.coreMintFiatExchangeRate +import com.codeinc.opencode.gen.currency.v1.verifiedCoreMintFiatExchangeRate +import com.getcode.crypt.DerivedKey +import com.getcode.ed25519.Ed25519 +import com.getcode.opencode.internal.manager.VerifiedState +import com.getcode.opencode.internal.network.api.intents.actions.ActionPublicTransfer +import com.getcode.opencode.model.accounts.AccountCluster +import com.getcode.opencode.model.financial.CurrencyCode +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.LocalFiat +import com.getcode.opencode.model.financial.Rate +import com.getcode.solana.keys.Mint +import com.getcode.solana.keys.PublicKey +import io.mockk.every +import io.mockk.mockk +import org.junit.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class IntentMultiTransferTest { + + private fun key(seed: Int) = PublicKey(List(32) { seed.toByte() }) + + private val mint = Mint.usdf + private val sourceVault = key(1) + private val sourceAuthority = key(2) + + private val source: AccountCluster = mockk { + val pair = mockk { + every { publicKeyBytes } returns sourceAuthority.byteArray + } + val derived = mockk { every { keyPair } returns pair } + every { authority } returns derived + every { vaultPublicKey } returns sourceVault + every { authorityPublicKey } returns sourceAuthority + } + + private fun verifiedState(currency: String = "cad") = VerifiedState( + rateProto = verifiedCoreMintFiatExchangeRate { + exchangeRate = coreMintFiatExchangeRate { + currencyCode = currency + exchangeRate = 1.5 + } + }, + reserveProto = null, + ) + + private fun fiat( + quarks: Long, + currency: CurrencyCode = CurrencyCode.CAD, + mint: Mint = this.mint, + ) = LocalFiat( + underlyingTokenAmount = Fiat(quarks = quarks, currencyCode = CurrencyCode.USD), + nativeAmount = Fiat(fiat = quarks * 1.5 / 1_000_000, currencyCode = currency), + rate = Rate(fx = 1.5, currency = currency), + mint = mint, + ) + + private fun share( + n: Int, + quarks: Long = 1_000_000L * n, + destination: PublicKey = key(10 + n), + owner: PublicKey = key(100 + n), + amount: LocalFiat = fiat(quarks), + ) = IntentMultiTransfer.Share(destination, owner, amount) + + private fun shares(count: Int) = (1..count).map { share(it) } + + private fun create( + shares: List = shares(3), + appMetadata: ByteArray? = null, + state: VerifiedState = verifiedState(), + ) = IntentMultiTransfer.create( + shares = shares, + mint = mint, + sourceCluster = source, + verifiedState = state, + appMetadata = appMetadata, + ) + + @Test + fun `emits one no privacy transfer per share in input order`() { + val input = shares(3) + val intent = create(input) + + val actions = intent.actionGroup.actions + assertEquals(3, actions.size) + actions.forEachIndexed { index, action -> + assertEquals(index, action.id) + val proto = action.action() + assertTrue(proto.hasNoPrivacyTransfer()) + val transfer = proto.noPrivacyTransfer + assertContentEquals(sourceVault.byteArray, transfer.source.value.toByteArray()) + assertContentEquals(sourceAuthority.byteArray, transfer.authority.value.toByteArray()) + assertContentEquals(input[index].destination.byteArray, transfer.destination.value.toByteArray()) + assertEquals(input[index].amount.underlyingTokenAmount.quarks, transfer.amount) + assertContentEquals(mint.byteArray, transfer.mint.value.toByteArray()) + assertIs(action) + } + } + + @Test + fun `metadata carries source mint and per payment owner and exchange data`() { + val input = shares(3) + val metadata = create(input).metadata().sendPublicMultiPayment + + assertContentEquals(sourceVault.byteArray, metadata.source.value.toByteArray()) + assertContentEquals(mint.byteArray, metadata.mint.value.toByteArray()) + assertEquals(3, metadata.paymentsCount) + metadata.paymentsList.forEachIndexed { index, payment -> + assertContentEquals(input[index].destination.byteArray, payment.destination.value.toByteArray()) + assertTrue(payment.hasDestinationOwner()) + assertContentEquals(input[index].destinationOwner.byteArray, payment.destinationOwner.value.toByteArray()) + assertTrue(payment.hasClientExchangeData()) + assertEquals(input[index].amount.underlyingTokenAmount.quarks, payment.clientExchangeData.quarks) + } + } + + @Test + fun `app metadata is set when given`() { + val bytes = byteArrayOf(1, 2, 3) + val proto = create(appMetadata = bytes).metadata() + + assertTrue(proto.hasAppMetadata()) + assertContentEquals(bytes, proto.appMetadata.value.toByteArray()) + } + + @Test + fun `app metadata is absent when not given`() { + assertFalse(create().metadata().hasAppMetadata()) + } + + @Test + fun `rejects fewer than the minimum payments`() { + assertFailsWith { create(shares(1)) } + } + + @Test + fun `accepts the maximum payments and rejects more`() { + create(shares(IntentMultiTransfer.MAX_PAYMENTS)) + assertFailsWith { create(shares(IntentMultiTransfer.MAX_PAYMENTS + 1)) } + } + + @Test + fun `rejects duplicate destinations`() { + assertFailsWith { + create(listOf(share(1), share(2, destination = key(11)))) + } + } + + @Test + fun `rejects duplicate owners`() { + assertFailsWith { + create(listOf(share(1), share(2, owner = key(101)))) + } + } + + @Test + fun `rejects destination equal to source vault`() { + assertFailsWith { + create(listOf(share(1), share(2, destination = sourceVault))) + } + } + + @Test + fun `rejects owner equal to source authority`() { + assertFailsWith { + create(listOf(share(1), share(2, owner = sourceAuthority))) + } + } + + @Test + fun `rejects a share in a different mint`() { + assertFailsWith { + create(listOf(share(1), share(2, amount = fiat(2_000_000, mint = Mint.usdc)))) + } + } + + @Test + fun `rejects shares in different currencies`() { + assertFailsWith { + create(listOf(share(1), share(2, amount = fiat(2_000_000, currency = CurrencyCode.USD)))) + } + } + + @Test + fun `rejects shares whose currency differs from the verified rate`() { + assertFailsWith { create(state = verifiedState("usd")) } + } +} diff --git a/services/opencode/src/test/kotlin/com/getcode/opencode/solana/MultiPaymentIntentVectorTest.kt b/services/opencode/src/test/kotlin/com/getcode/opencode/solana/MultiPaymentIntentVectorTest.kt new file mode 100644 index 0000000000..65d18b2fa0 --- /dev/null +++ b/services/opencode/src/test/kotlin/com/getcode/opencode/solana/MultiPaymentIntentVectorTest.kt @@ -0,0 +1,230 @@ +package com.getcode.opencode.solana + +import com.codeinc.opencode.gen.currency.v1.coreMintFiatExchangeRate +import com.codeinc.opencode.gen.currency.v1.verifiedCoreMintFiatExchangeRate +import com.getcode.crypt.DerivedKey +import com.getcode.ed25519.Ed25519 +import com.getcode.opencode.internal.manager.VerifiedState +import com.getcode.opencode.internal.network.api.intents.IntentMultiTransfer +import com.getcode.opencode.model.accounts.AccountCluster +import com.getcode.opencode.model.financial.CurrencyCode +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.LocalFiat +import com.getcode.opencode.model.financial.Rate +import com.getcode.opencode.solana.intents.CompactMessageArgs +import com.getcode.opencode.solana.intents.ServerParameter +import com.getcode.solana.keys.Mint +import com.getcode.solana.keys.PublicKey +import io.mockk.every +import io.mockk.mockk +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.math.BigDecimal +import java.security.MessageDigest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertTrue + +/** + * GATE: [IntentMultiTransfer] (SendPublicMultiPayment) must match the canonical cross-platform + * vectors in `multi_payment_intent.json` (the iOS repo asserts the same file): action count, order, + * ids, source/destination/amount, the compact message each action signs (and its SHA-256), and the + * proto metadata. + * + * The compact message is produced by the real action: server nonce parameters are applied with + * `IntentType.apply` (as the SubmitIntent flow does), then `ActionType.compactMessageArgs()` is + * serialized with the same layout as `CompactMessageVectorTest` and `ActionType.compactMessages()` + * (the SHA-256 the action hands to ed25519) is compared with the fixture hash. + * + * `rejections` lists inputs the builder must refuse; each must make `create` throw. + * + * The ed25519 key pair is mocked (the JNI does not load on the host JVM; ed25519 is gated by + * ed25519.json), so the signature itself is not asserted. `Mint` accepts arbitrary key bytes, so the + * fixture's `mintSeed` key is used directly. + */ +class MultiPaymentIntentVectorTest { + + private fun key(seed: Int) = PublicKey(List(32) { seed.toByte() }) + + private fun String.hexToBytes(): ByteArray = + ByteArray(length / 2) { substring(it * 2, it * 2 + 2).toInt(16).toByte() } + + private fun ByteArray.toHex() = joinToString("") { "%02x".format(it) } + + private fun amountLe8(value: Long): ByteArray = ByteArray(8) { ((value ushr (8 * it)) and 0xFF).toByte() } + + private fun mintOf(seed: Int) = Mint(List(32) { seed.toByte() }) + + private fun JsonElement.seedKey(authority: PublicKey): PublicKey { + val content = jsonPrimitive.content + return if (content == "sourceAuthority") authority else key(content.toInt()) + } + + private class Case( + val cluster: AccountCluster, + val sourceVault: PublicKey, + val authority: PublicKey, + val mint: Mint, + val state: VerifiedState, + val shares: List, + ) { + fun create() = IntentMultiTransfer.create( + shares = shares, + mint = mint, + sourceCluster = cluster, + verifiedState = state, + ) + } + + private fun load(v: JsonObject): Case { + val sourceVault = key(v["sourceVaultSeed"]!!.jsonPrimitive.int) + val authority = PublicKey(v["sourceAuthority"]!!.jsonPrimitive.content.hexToBytes().toList()) + val mint = mintOf(v["mintSeed"]!!.jsonPrimitive.int) + + val cluster: AccountCluster = mockk { + val pair = mockk { every { publicKeyBytes } returns authority.byteArray } + val derived = mockk { every { keyPair } returns pair } + every { this@mockk.authority } returns derived + every { vaultPublicKey } returns sourceVault + every { authorityPublicKey } returns authority + } + + val vs = v["verifiedState"]!!.jsonObject + val state = VerifiedState( + rateProto = verifiedCoreMintFiatExchangeRate { + exchangeRate = coreMintFiatExchangeRate { + currencyCode = vs["currency"]!!.jsonPrimitive.content + exchangeRate = vs["rate"]!!.jsonPrimitive.content.toDouble() + } + }, + reserveProto = null, + ) + val fx = vs["rate"]!!.jsonPrimitive.content.toDouble() + + val shares = v["payments"]!!.jsonArray.map { it.jsonObject }.map { p -> + val currency = CurrencyCode.valueOf(p["currency"]!!.jsonPrimitive.content.uppercase()) + IntentMultiTransfer.Share( + destination = key(p["destinationSeed"]!!.jsonPrimitive.int), + destinationOwner = p["destinationOwnerSeed"]!!.seedKey(authority), + amount = LocalFiat( + underlyingTokenAmount = Fiat(quarks = p["quarks"]!!.jsonPrimitive.content.toLong(), currencyCode = CurrencyCode.USD), + nativeAmount = Fiat( + quarks = BigDecimal(p["nativeAmount"]!!.jsonPrimitive.content).movePointRight(6).toLong(), + currencyCode = currency, + ), + rate = Rate(fx = fx, currency = currency), + mint = mintOf(p["mintSeed"]!!.jsonPrimitive.int), + ), + ) + } + return Case(cluster, sourceVault, authority, mint, state, shares) + } + + private fun resource(): JsonObject { + val text = javaClass.getResourceAsStream("/multi_payment_intent.json")!!.bufferedReader().use { it.readText() } + return Json.parseToJsonElement(text).jsonObject + } + + @Test + fun multi_payment_intent_matches_canonical_vectors() { + val vectors = resource()["vectors"]!!.jsonArray + assertTrue(vectors.isNotEmpty(), "no vectors loaded") + + for (el in vectors) { + val v = el.jsonObject + val name = v["name"]!!.jsonPrimitive.content + val c = load(v) + val intent = c.create() + + val expected = v["expectedActions"]!!.jsonArray.map { it.jsonObject } + assertEquals(expected.size, intent.actions.size, "action count mismatch for $name") + + // Apply the server's nonce/blockhash per action, as the SubmitIntent flow does. + intent.apply( + expected.map { e -> + ServerParameter( + actionId = e["id"]!!.jsonPrimitive.int, + parameter = null, + configs = listOf( + ServerParameter.Config( + nonce = key(e["nonceSeed"]!!.jsonPrimitive.int), + blockhash = key(e["nonceValueSeed"]!!.jsonPrimitive.int), + ) + ), + ) + } + ) + + expected.forEachIndexed { i, e -> + val label = "$name action $i" + val action = intent.actions[i] + val quarks = e["amount"]!!.jsonPrimitive.content.toLong() + + assertEquals(e["id"]!!.jsonPrimitive.int, action.id, "id mismatch for $label") + + val transfer = action.action().noPrivacyTransfer + assertEquals(key(e["sourceSeed"]!!.jsonPrimitive.int).byteArray.toHex(), transfer.source.value.toByteArray().toHex(), "source mismatch for $label") + assertEquals(e["authority"]!!.jsonPrimitive.content, transfer.authority.value.toByteArray().toHex(), "authority mismatch for $label") + assertEquals(key(e["destinationSeed"]!!.jsonPrimitive.int).byteArray.toHex(), transfer.destination.value.toByteArray().toHex(), "destination mismatch for $label") + assertEquals(mintOf(e["mintSeed"]!!.jsonPrimitive.int).byteArray.toHex(), transfer.mint.value.toByteArray().toHex(), "mint mismatch for $label") + assertEquals(quarks, transfer.amount, "amount mismatch for $label") + + val args = action.compactMessageArgs().single() + assertIs(args) + val message = "transfer".toByteArray(Charsets.UTF_8) + + args.source.byteArray + args.destination.byteArray + amountLe8(args.amountInQuarks) + + args.nonce.byteArray + args.nonceValue.byteArray + assertEquals(e["message"]!!.jsonPrimitive.content, message.toHex(), "message bytes mismatch for $label") + + // What the action hands to ed25519.sign. + val signed = action.compactMessages().single() + assertEquals(e["sha256"]!!.jsonPrimitive.content, signed.toHex(), "signed sha256 mismatch for $label") + assertEquals( + e["sha256"]!!.jsonPrimitive.content, + MessageDigest.getInstance("SHA-256").digest(message).toHex(), + "sha256 of message mismatch for $label", + ) + } + + val em = v["expectedMetadata"]!!.jsonObject + val metadata = intent.metadata().sendPublicMultiPayment + assertEquals(key(em["sourceSeed"]!!.jsonPrimitive.int).byteArray.toHex(), metadata.source.value.toByteArray().toHex(), "metadata source mismatch for $name") + assertEquals(mintOf(em["mintSeed"]!!.jsonPrimitive.int).byteArray.toHex(), metadata.mint.value.toByteArray().toHex(), "metadata mint mismatch for $name") + val payments = em["payments"]!!.jsonArray.map { it.jsonObject } + assertEquals(payments.size, metadata.paymentsCount, "metadata payment count mismatch for $name") + metadata.paymentsList.forEachIndexed { i, payment -> + val p = payments[i] + val label = "$name payment $i" + assertEquals(key(p["destinationSeed"]!!.jsonPrimitive.int).byteArray.toHex(), payment.destination.value.toByteArray().toHex(), "metadata destination mismatch for $label") + assertTrue(payment.hasDestinationOwner(), "destination_owner missing for $label") + assertEquals(key(p["destinationOwnerSeed"]!!.jsonPrimitive.int).byteArray.toHex(), payment.destinationOwner.value.toByteArray().toHex(), "metadata destination_owner mismatch for $label") + val exchange = payment.clientExchangeData + assertEquals(p["quarks"]!!.jsonPrimitive.content.toLong(), exchange.quarks, "metadata quarks mismatch for $label") + assertEquals(p["nativeAmount"]!!.jsonPrimitive.content.toDouble(), exchange.nativeAmount, 0.0, "metadata native amount mismatch for $label") + assertEquals(p["currency"]!!.jsonPrimitive.content, exchange.coreMintFiatExchangeRate.exchangeRate.currencyCode, "metadata currency mismatch for $label") + } + } + } + + @Test + fun multi_payment_intent_rejects_canonical_invalid_cases() { + val rejections = resource()["rejections"]!!.jsonArray + assertTrue(rejections.isNotEmpty(), "no rejections loaded") + + for (el in rejections) { + val v = el.jsonObject + val name = v["name"]!!.jsonPrimitive.content + val c = load(v) + assertFailsWith("expected rejection (${v["expectedError"]!!.jsonPrimitive.content}) for $name") { + c.create() + } + } + } +} diff --git a/services/opencode/src/test/resources/multi_payment_intent.json b/services/opencode/src/test/resources/multi_payment_intent.json new file mode 100644 index 0000000000..174506e2e8 --- /dev/null +++ b/services/opencode/src/test/resources/multi_payment_intent.json @@ -0,0 +1,767 @@ +{ + "algorithm": "ocp-send-public-multi-payment", + "note": "IntentMultiTransfer: one NoPrivacyTransfer per payment in input order, ids 0..n-1, per-action transfer compact message + SHA256 + ed25519 signature by the source authority. Every payment carries destination_owner. `rejections` must each make the builder throw; expectedError names the rule, not a platform error type. A destinationOwnerSeed of \"sourceAuthority\" means the sourceAuthority key itself.", + "vectors": [ + { + "name": "boost 80/20", + "sourceVaultSeed": 16, + "sourceAuthority": "8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c", + "signerSeed": "0101010101010101010101010101010101010101010101010101010101010101", + "mintSeed": 15, + "verifiedState": { + "currency": "usd", + "rate": "1.0" + }, + "payments": [ + { + "destinationSeed": 33, + "destinationOwnerSeed": 49, + "quarks": "800000", + "mintSeed": 15, + "currency": "usd", + "nativeAmount": "0.800000" + }, + { + "destinationSeed": 34, + "destinationOwnerSeed": 50, + "quarks": "200000", + "mintSeed": 15, + "currency": "usd", + "nativeAmount": "0.200000" + } + ], + "expectedActions": [ + { + "id": 0, + "sourceSeed": 16, + "authority": "8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c", + "destinationSeed": 33, + "mintSeed": 15, + "amount": "800000", + "nonceSeed": 160, + "nonceValueSeed": 192, + "message": "7472616e736665721010101010101010101010101010101010101010101010101010101010101010212121212121212121212121212121212121212121212121212121212121212100350c0000000000a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0", + "sha256": "156a00c82cb69e115181b1def043ba761bae4e975577b069816a3e9264c19eaf", + "signature": "31b436fe31c538740c53831287a950c4f607e253003769f9bcfeae429ff8662b2c0033a1616c3ea2b6830cfdf3bdd621284567a51870541fca7d8d27b74fe70a" + }, + { + "id": 1, + "sourceSeed": 16, + "authority": "8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c", + "destinationSeed": 34, + "mintSeed": 15, + "amount": "200000", + "nonceSeed": 161, + "nonceValueSeed": 193, + "message": "7472616e7366657210101010101010101010101010101010101010101010101010101010101010102222222222222222222222222222222222222222222222222222222222222222400d030000000000a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1", + "sha256": "450cf9885637e4232be482e041807938ee0b5e464be21942417ce2ff5d96db83", + "signature": "5275e17aaee5f08f847db11d3a70444de511798d4a9ed179c08b11167ab879e16180de0d1d044874d599d061e40ce708ff92b0e00ffc7f68c4582d97d11a860f" + } + ], + "expectedMetadata": { + "sourceSeed": 16, + "mintSeed": 15, + "payments": [ + { + "destinationSeed": 33, + "destinationOwnerSeed": 49, + "quarks": "800000", + "nativeAmount": "0.800000", + "currency": "usd" + }, + { + "destinationSeed": 34, + "destinationOwnerSeed": 50, + "quarks": "200000", + "nativeAmount": "0.200000", + "currency": "usd" + } + ] + } + }, + { + "name": "two uneven, large amount", + "sourceVaultSeed": 16, + "sourceAuthority": "8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c", + "signerSeed": "0101010101010101010101010101010101010101010101010101010101010101", + "mintSeed": 15, + "verifiedState": { + "currency": "usd", + "rate": "1.0" + }, + "payments": [ + { + "destinationSeed": 33, + "destinationOwnerSeed": 49, + "quarks": "9223372036854775807", + "mintSeed": 15, + "currency": "usd", + "nativeAmount": "9223372036854.775807" + }, + { + "destinationSeed": 34, + "destinationOwnerSeed": 50, + "quarks": "1", + "mintSeed": 15, + "currency": "usd", + "nativeAmount": "0.000001" + } + ], + "expectedActions": [ + { + "id": 0, + "sourceSeed": 16, + "authority": "8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c", + "destinationSeed": 33, + "mintSeed": 15, + "amount": "9223372036854775807", + "nonceSeed": 160, + "nonceValueSeed": 192, + "message": "7472616e7366657210101010101010101010101010101010101010101010101010101010101010102121212121212121212121212121212121212121212121212121212121212121ffffffffffffff7fa0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0", + "sha256": "5db9333e9b97df29a5b3f9ee2dd0c1fbcd10b1c1eb7eed3913f877c27d294cba", + "signature": "39600fe7fd0f82e9d9eff2e9f67d86b2c2a16b799e801f0e05b637f60c35cddfaf7a34962881c708cc0c7125a1a8956dff76d68ee02786eda20006f0a630fe0c" + }, + { + "id": 1, + "sourceSeed": 16, + "authority": "8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c", + "destinationSeed": 34, + "mintSeed": 15, + "amount": "1", + "nonceSeed": 161, + "nonceValueSeed": 193, + "message": "7472616e73666572101010101010101010101010101010101010101010101010101010101010101022222222222222222222222222222222222222222222222222222222222222220100000000000000a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1", + "sha256": "862abc7fe1f9a4a70f1253ac9faf8c4fb59cb66ace3a267b67ab179190008fcd", + "signature": "7e4bb6ba00e1274c2cf6381d8fa48cb58ec2c9972f4fac700056c005948857bb3c92dff15561edb30754ce24ed7125a537c8bcfafeb1ea4af03ec5ce0475b700" + } + ], + "expectedMetadata": { + "sourceSeed": 16, + "mintSeed": 15, + "payments": [ + { + "destinationSeed": 33, + "destinationOwnerSeed": 49, + "quarks": "9223372036854775807", + "nativeAmount": "9223372036854.775807", + "currency": "usd" + }, + { + "destinationSeed": 34, + "destinationOwnerSeed": 50, + "quarks": "1", + "nativeAmount": "0.000001", + "currency": "usd" + } + ] + } + }, + { + "name": "ten payments (max)", + "sourceVaultSeed": 16, + "sourceAuthority": "8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c", + "signerSeed": "0101010101010101010101010101010101010101010101010101010101010101", + "mintSeed": 15, + "verifiedState": { + "currency": "usd", + "rate": "1.0" + }, + "payments": [ + { + "destinationSeed": 64, + "destinationOwnerSeed": 96, + "quarks": "1000", + "mintSeed": 15, + "currency": "usd", + "nativeAmount": "0.001000" + }, + { + "destinationSeed": 65, + "destinationOwnerSeed": 97, + "quarks": "2000", + "mintSeed": 15, + "currency": "usd", + "nativeAmount": "0.002000" + }, + { + "destinationSeed": 66, + "destinationOwnerSeed": 98, + "quarks": "3000", + "mintSeed": 15, + "currency": "usd", + "nativeAmount": "0.003000" + }, + { + "destinationSeed": 67, + "destinationOwnerSeed": 99, + "quarks": "4000", + "mintSeed": 15, + "currency": "usd", + "nativeAmount": "0.004000" + }, + { + "destinationSeed": 68, + "destinationOwnerSeed": 100, + "quarks": "5000", + "mintSeed": 15, + "currency": "usd", + "nativeAmount": "0.005000" + }, + { + "destinationSeed": 69, + "destinationOwnerSeed": 101, + "quarks": "6000", + "mintSeed": 15, + "currency": "usd", + "nativeAmount": "0.006000" + }, + { + "destinationSeed": 70, + "destinationOwnerSeed": 102, + "quarks": "7000", + "mintSeed": 15, + "currency": "usd", + "nativeAmount": "0.007000" + }, + { + "destinationSeed": 71, + "destinationOwnerSeed": 103, + "quarks": "8000", + "mintSeed": 15, + "currency": "usd", + "nativeAmount": "0.008000" + }, + { + "destinationSeed": 72, + "destinationOwnerSeed": 104, + "quarks": "9000", + "mintSeed": 15, + "currency": "usd", + "nativeAmount": "0.009000" + }, + { + "destinationSeed": 73, + "destinationOwnerSeed": 105, + "quarks": "10000", + "mintSeed": 15, + "currency": "usd", + "nativeAmount": "0.010000" + } + ], + "expectedActions": [ + { + "id": 0, + "sourceSeed": 16, + "authority": "8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c", + "destinationSeed": 64, + "mintSeed": 15, + "amount": "1000", + "nonceSeed": 160, + "nonceValueSeed": 192, + "message": "7472616e7366657210101010101010101010101010101010101010101010101010101010101010104040404040404040404040404040404040404040404040404040404040404040e803000000000000a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0", + "sha256": "554aa96015b18f275d52ddca389a2e4b020b0d243db5bd2eccf82a9c3007331f", + "signature": "e0bdf877b02cf0fd0f3f96341943dbe045ffa03646ed8219cbff15c391e36c77dcab520ce31247da462c507da36f7f00451e09666aeaf1a215ead4bd27aef609" + }, + { + "id": 1, + "sourceSeed": 16, + "authority": "8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c", + "destinationSeed": 65, + "mintSeed": 15, + "amount": "2000", + "nonceSeed": 161, + "nonceValueSeed": 193, + "message": "7472616e7366657210101010101010101010101010101010101010101010101010101010101010104141414141414141414141414141414141414141414141414141414141414141d007000000000000a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1", + "sha256": "4709579b5c6332ee4df28d529b48e6bfac95bf2afe0f622bb2603151df419af5", + "signature": "97bdae2ae8d37ad6ef4405db5d9160089ffebed060678727ff7ed26e0fa09f6940239bea506bc319cc5a7e109f92a737d832183beea99e8423fdc40a623be30a" + }, + { + "id": 2, + "sourceSeed": 16, + "authority": "8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c", + "destinationSeed": 66, + "mintSeed": 15, + "amount": "3000", + "nonceSeed": 162, + "nonceValueSeed": 194, + "message": "7472616e7366657210101010101010101010101010101010101010101010101010101010101010104242424242424242424242424242424242424242424242424242424242424242b80b000000000000a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2", + "sha256": "d5d058613d75eed724b6068da891c2ba337429d33cdb6b07275490db7406950b", + "signature": "f96d2d65e704babbe306404b1ea7faf5ced9d07d5052d206d97d34b7256e16aaa10acc799f5b9d7b024693d82b29e8bc356f077f07a6c96ed09d79934ba5d60d" + }, + { + "id": 3, + "sourceSeed": 16, + "authority": "8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c", + "destinationSeed": 67, + "mintSeed": 15, + "amount": "4000", + "nonceSeed": 163, + "nonceValueSeed": 195, + "message": "7472616e7366657210101010101010101010101010101010101010101010101010101010101010104343434343434343434343434343434343434343434343434343434343434343a00f000000000000a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3", + "sha256": "4179a86cc44c53bee5f5ba11d36dbb5ab46e7fcf80dbf5c15a0c1ffc8fd5259d", + "signature": "0be3596cc71f48a86e304d0a41ce64a2b36f4c21b6e0d1ab1d004b8eaa43893d2bc6a1bc147cd001a9cd8ccf9be76ddfcab9d2e300a2ffb2345197659347a60d" + }, + { + "id": 4, + "sourceSeed": 16, + "authority": "8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c", + "destinationSeed": 68, + "mintSeed": 15, + "amount": "5000", + "nonceSeed": 164, + "nonceValueSeed": 196, + "message": "7472616e73666572101010101010101010101010101010101010101010101010101010101010101044444444444444444444444444444444444444444444444444444444444444448813000000000000a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4", + "sha256": "d6f72ded8294a5ade7ddb036e5d6369f8a8c8153022c1977815952ed1855b798", + "signature": "d976d7df516eafe031556353681987c695a93b1c967c7e7c8a90029302ee5cb9ab823d13984c7d317eeca76256c67da207aa0bf8873d28f91f50ee1edadc4504" + }, + { + "id": 5, + "sourceSeed": 16, + "authority": "8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c", + "destinationSeed": 69, + "mintSeed": 15, + "amount": "6000", + "nonceSeed": 165, + "nonceValueSeed": 197, + "message": "7472616e73666572101010101010101010101010101010101010101010101010101010101010101045454545454545454545454545454545454545454545454545454545454545457017000000000000a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5", + "sha256": "584f4dfa872b2c2eef7b51cc91ef2b5de86b95f313e38ea0ba60ce7c558dfe26", + "signature": "94beacefed5d7079f9ebdd78e5b801c57d081df43a522bd363a181c31ade587647a2082ab32e0aaa929578eaeec8149bb2d50f78618bbbc63f5ed4bc54916903" + }, + { + "id": 6, + "sourceSeed": 16, + "authority": "8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c", + "destinationSeed": 70, + "mintSeed": 15, + "amount": "7000", + "nonceSeed": 166, + "nonceValueSeed": 198, + "message": "7472616e7366657210101010101010101010101010101010101010101010101010101010101010104646464646464646464646464646464646464646464646464646464646464646581b000000000000a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6", + "sha256": "156cd8dda1e314e19910146810f53732229c8b40efcb7c5b411112c514fd4260", + "signature": "db9462c1d7c050d9df4f2f9e744bcf63f87af7581751770eb1322602df246cf7c641cecb21a4f62257343c18cf7b7eb72c75b384080ae2558846f0935b3c5a08" + }, + { + "id": 7, + "sourceSeed": 16, + "authority": "8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c", + "destinationSeed": 71, + "mintSeed": 15, + "amount": "8000", + "nonceSeed": 167, + "nonceValueSeed": 199, + "message": "7472616e7366657210101010101010101010101010101010101010101010101010101010101010104747474747474747474747474747474747474747474747474747474747474747401f000000000000a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7", + "sha256": "ca3dcdc3bac5059282424ffb53d8d72f9e11cf426208bcb6b2d5594f41b1b400", + "signature": "99ec6f60700886389aff2eaf46ba258b4f296345144073d791b92d167961fab710c74351437028d5fed4aebe610a87df9fe40d25fdb9af0e2e3f2bdbcb7e0807" + }, + { + "id": 8, + "sourceSeed": 16, + "authority": "8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c", + "destinationSeed": 72, + "mintSeed": 15, + "amount": "9000", + "nonceSeed": 168, + "nonceValueSeed": 200, + "message": "7472616e73666572101010101010101010101010101010101010101010101010101010101010101048484848484848484848484848484848484848484848484848484848484848482823000000000000a8a8a8a8a8a8a8a8a8a8a8a8a8a8a8a8a8a8a8a8a8a8a8a8a8a8a8a8a8a8a8a8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8", + "sha256": "ff021a571ab510fd5c2ac7f33d61ec9b65ba8b5edf4ea60ff5b28c09f8ef7c75", + "signature": "8192b6b7090119598f796a7ac9be1ecfd71c901db2d392e42128d3566022ec7e2eeee93f21afd9f6ccfa86a3d2f1abc8a58efe466d0e3b83cd358801d134990f" + }, + { + "id": 9, + "sourceSeed": 16, + "authority": "8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c", + "destinationSeed": 73, + "mintSeed": 15, + "amount": "10000", + "nonceSeed": 169, + "nonceValueSeed": 201, + "message": "7472616e73666572101010101010101010101010101010101010101010101010101010101010101049494949494949494949494949494949494949494949494949494949494949491027000000000000a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9", + "sha256": "926fe8924d46602353ec919d83284fe3fd1a471a688fd56626079207a8ff41cf", + "signature": "c0b477d29f75a16257f65882582d4e8d3f7af48527f713bf268d8ab4154f112418b8ae0417edbfab9a8e7f683410a9de31419ba02cce08c4709b23f066bef104" + } + ], + "expectedMetadata": { + "sourceSeed": 16, + "mintSeed": 15, + "payments": [ + { + "destinationSeed": 64, + "destinationOwnerSeed": 96, + "quarks": "1000", + "nativeAmount": "0.001000", + "currency": "usd" + }, + { + "destinationSeed": 65, + "destinationOwnerSeed": 97, + "quarks": "2000", + "nativeAmount": "0.002000", + "currency": "usd" + }, + { + "destinationSeed": 66, + "destinationOwnerSeed": 98, + "quarks": "3000", + "nativeAmount": "0.003000", + "currency": "usd" + }, + { + "destinationSeed": 67, + "destinationOwnerSeed": 99, + "quarks": "4000", + "nativeAmount": "0.004000", + "currency": "usd" + }, + { + "destinationSeed": 68, + "destinationOwnerSeed": 100, + "quarks": "5000", + "nativeAmount": "0.005000", + "currency": "usd" + }, + { + "destinationSeed": 69, + "destinationOwnerSeed": 101, + "quarks": "6000", + "nativeAmount": "0.006000", + "currency": "usd" + }, + { + "destinationSeed": 70, + "destinationOwnerSeed": 102, + "quarks": "7000", + "nativeAmount": "0.007000", + "currency": "usd" + }, + { + "destinationSeed": 71, + "destinationOwnerSeed": 103, + "quarks": "8000", + "nativeAmount": "0.008000", + "currency": "usd" + }, + { + "destinationSeed": 72, + "destinationOwnerSeed": 104, + "quarks": "9000", + "nativeAmount": "0.009000", + "currency": "usd" + }, + { + "destinationSeed": 73, + "destinationOwnerSeed": 105, + "quarks": "10000", + "nativeAmount": "0.010000", + "currency": "usd" + } + ] + } + } + ], + "rejections": [ + { + "name": "one payment", + "sourceVaultSeed": 16, + "sourceAuthority": "8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c", + "signerSeed": "0101010101010101010101010101010101010101010101010101010101010101", + "mintSeed": 15, + "verifiedState": { + "currency": "usd", + "rate": "1.0" + }, + "payments": [ + { + "destinationSeed": 33, + "destinationOwnerSeed": 49, + "quarks": "800000", + "mintSeed": 15, + "currency": "usd", + "nativeAmount": "0.800000" + } + ], + "expectedError": "count" + }, + { + "name": "eleven payments", + "sourceVaultSeed": 16, + "sourceAuthority": "8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c", + "signerSeed": "0101010101010101010101010101010101010101010101010101010101010101", + "mintSeed": 15, + "verifiedState": { + "currency": "usd", + "rate": "1.0" + }, + "payments": [ + { + "destinationSeed": 64, + "destinationOwnerSeed": 96, + "quarks": "1000", + "mintSeed": 15, + "currency": "usd", + "nativeAmount": "0.001000" + }, + { + "destinationSeed": 65, + "destinationOwnerSeed": 97, + "quarks": "1000", + "mintSeed": 15, + "currency": "usd", + "nativeAmount": "0.001000" + }, + { + "destinationSeed": 66, + "destinationOwnerSeed": 98, + "quarks": "1000", + "mintSeed": 15, + "currency": "usd", + "nativeAmount": "0.001000" + }, + { + "destinationSeed": 67, + "destinationOwnerSeed": 99, + "quarks": "1000", + "mintSeed": 15, + "currency": "usd", + "nativeAmount": "0.001000" + }, + { + "destinationSeed": 68, + "destinationOwnerSeed": 100, + "quarks": "1000", + "mintSeed": 15, + "currency": "usd", + "nativeAmount": "0.001000" + }, + { + "destinationSeed": 69, + "destinationOwnerSeed": 101, + "quarks": "1000", + "mintSeed": 15, + "currency": "usd", + "nativeAmount": "0.001000" + }, + { + "destinationSeed": 70, + "destinationOwnerSeed": 102, + "quarks": "1000", + "mintSeed": 15, + "currency": "usd", + "nativeAmount": "0.001000" + }, + { + "destinationSeed": 71, + "destinationOwnerSeed": 103, + "quarks": "1000", + "mintSeed": 15, + "currency": "usd", + "nativeAmount": "0.001000" + }, + { + "destinationSeed": 72, + "destinationOwnerSeed": 104, + "quarks": "1000", + "mintSeed": 15, + "currency": "usd", + "nativeAmount": "0.001000" + }, + { + "destinationSeed": 73, + "destinationOwnerSeed": 105, + "quarks": "1000", + "mintSeed": 15, + "currency": "usd", + "nativeAmount": "0.001000" + }, + { + "destinationSeed": 74, + "destinationOwnerSeed": 106, + "quarks": "1000", + "mintSeed": 15, + "currency": "usd", + "nativeAmount": "0.001000" + } + ], + "expectedError": "count" + }, + { + "name": "duplicate destination", + "sourceVaultSeed": 16, + "sourceAuthority": "8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c", + "signerSeed": "0101010101010101010101010101010101010101010101010101010101010101", + "mintSeed": 15, + "verifiedState": { + "currency": "usd", + "rate": "1.0" + }, + "payments": [ + { + "destinationSeed": 33, + "destinationOwnerSeed": 49, + "quarks": "800000", + "mintSeed": 15, + "currency": "usd", + "nativeAmount": "0.800000" + }, + { + "destinationSeed": 33, + "destinationOwnerSeed": 50, + "quarks": "200000", + "mintSeed": 15, + "currency": "usd", + "nativeAmount": "0.200000" + } + ], + "expectedError": "duplicateDestination" + }, + { + "name": "duplicate destination owner", + "sourceVaultSeed": 16, + "sourceAuthority": "8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c", + "signerSeed": "0101010101010101010101010101010101010101010101010101010101010101", + "mintSeed": 15, + "verifiedState": { + "currency": "usd", + "rate": "1.0" + }, + "payments": [ + { + "destinationSeed": 33, + "destinationOwnerSeed": 49, + "quarks": "800000", + "mintSeed": 15, + "currency": "usd", + "nativeAmount": "0.800000" + }, + { + "destinationSeed": 34, + "destinationOwnerSeed": 49, + "quarks": "200000", + "mintSeed": 15, + "currency": "usd", + "nativeAmount": "0.200000" + } + ], + "expectedError": "duplicateDestinationOwner" + }, + { + "name": "destination is source vault", + "sourceVaultSeed": 16, + "sourceAuthority": "8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c", + "signerSeed": "0101010101010101010101010101010101010101010101010101010101010101", + "mintSeed": 15, + "verifiedState": { + "currency": "usd", + "rate": "1.0" + }, + "payments": [ + { + "destinationSeed": 33, + "destinationOwnerSeed": 49, + "quarks": "800000", + "mintSeed": 15, + "currency": "usd", + "nativeAmount": "0.800000" + }, + { + "destinationSeed": 16, + "destinationOwnerSeed": 50, + "quarks": "200000", + "mintSeed": 15, + "currency": "usd", + "nativeAmount": "0.200000" + } + ], + "expectedError": "destinationIsSource" + }, + { + "name": "destination owner is source authority", + "sourceVaultSeed": 16, + "sourceAuthority": "8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c", + "signerSeed": "0101010101010101010101010101010101010101010101010101010101010101", + "mintSeed": 15, + "verifiedState": { + "currency": "usd", + "rate": "1.0" + }, + "payments": [ + { + "destinationSeed": 33, + "destinationOwnerSeed": 49, + "quarks": "800000", + "mintSeed": 15, + "currency": "usd", + "nativeAmount": "0.800000" + }, + { + "destinationSeed": 34, + "destinationOwnerSeed": "sourceAuthority", + "quarks": "200000", + "mintSeed": 15, + "currency": "usd", + "nativeAmount": "0.200000" + } + ], + "expectedError": "destinationIsSource" + }, + { + "name": "mint mismatch", + "sourceVaultSeed": 16, + "sourceAuthority": "8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c", + "signerSeed": "0101010101010101010101010101010101010101010101010101010101010101", + "mintSeed": 15, + "verifiedState": { + "currency": "usd", + "rate": "1.0" + }, + "payments": [ + { + "destinationSeed": 33, + "destinationOwnerSeed": 49, + "quarks": "800000", + "mintSeed": 15, + "currency": "usd", + "nativeAmount": "0.800000" + }, + { + "destinationSeed": 34, + "destinationOwnerSeed": 50, + "quarks": "200000", + "mintSeed": 14, + "currency": "usd", + "nativeAmount": "0.200000" + } + ], + "expectedError": "mintMismatch" + }, + { + "name": "currency mismatch", + "sourceVaultSeed": 16, + "sourceAuthority": "8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c", + "signerSeed": "0101010101010101010101010101010101010101010101010101010101010101", + "mintSeed": 15, + "verifiedState": { + "currency": "usd", + "rate": "1.0" + }, + "payments": [ + { + "destinationSeed": 33, + "destinationOwnerSeed": 49, + "quarks": "800000", + "mintSeed": 15, + "currency": "usd", + "nativeAmount": "0.800000" + }, + { + "destinationSeed": 34, + "destinationOwnerSeed": 50, + "quarks": "200000", + "mintSeed": 15, + "currency": "cad", + "nativeAmount": "0.200000" + } + ], + "expectedError": "currencyMismatch" + } + ] +} diff --git a/test-vectors/README.md b/test-vectors/README.md index 74d0f6f945..9b1bec84a4 100644 --- a/test-vectors/README.md +++ b/test-vectors/README.md @@ -36,6 +36,7 @@ impl must reproduce the fixtures before the native duplicates are deleted. | `curve.json` | `:libs:currency-math` androidTest → `connectedAndroidTest` (device, loads .bin tables) — **green** | `FlipcashCoreVectors` → xcodebuild on iOS Simulator — **green** | | `solana_message.json` | `:services:opencode` → `testDebugUnitTest` (host JVM) — **green** | `FlipcashCoreVectors` → xcodebuild on iOS Simulator — **green** | | `compact_message.json` | `:services:opencode` → `testDebugUnitTest` (host JVM) — **green** | `FlipcashCoreVectors` → xcodebuild on iOS Simulator — **green** | +| `multi_payment_intent.json` | `:services:opencode` → `testDebugUnitTest` (host JVM) — **green** | `FlipcashCoreVectors` (iOS) | | `kikcode.json` + `kikcode_golden.svg` | `:libs:codes:kikcode` → `testAndroidHostTest` (host JVM) — **green** | *same Kotlin source* → `iosSimulatorArm64Test` (Kotlin/Native) — **green** | | `reactions.json` | `:libs:emojis` + `:apps:flipcash:shared:chat` → `testDebugUnitTest` (host JVM); `drawability` via `:libs:emojis` androidTest → `connectedAndroidTest`. Read in place, no per-module copy. Canonical in the orchestrator (#17), not regenerated here | `FlipcashCoreVectors` → xcodebuild on iOS Simulator (copy of the orchestrator fixture) | diff --git a/test-vectors/gen_multi_payment_intent.py b/test-vectors/gen_multi_payment_intent.py new file mode 100644 index 0000000000..fdb9cbfa29 --- /dev/null +++ b/test-vectors/gen_multi_payment_intent.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""SendPublicMultiPayment intent vectors (ocp 0.8.0, IntentMultiTransfer on both apps). + +Given a source cluster and N payments, both builders must emit: + + actions[i] = NoPrivacyTransfer(id = i, source = sourceVault, authority = sourceAuthority, + destination = payments[i].destination, amount = payments[i].quarks) + +in input order, and each action signs the same compact message a single transfer signs +(see compact_message.json), with the nonce and blockhash the server returns for action i: + + "transfer" || source(32) || destination(32) || amount(LE8) || nonce(32) || nonceValue(32) + signature = ed25519_sign( SHA256(message), sourceAuthority ) + +The metadata block lists the field values each app asserts through its own proto accessors +(source, mint, and every payment's destination, destination_owner and quarks). The serialized +proto bytes are not compared: protobuf-java and swift-protobuf are each tested against their own +generated types. + +Public keys are 32 copies of a seed byte, as in compact_message.json, except sourceAuthority, +which is the real ed25519 public key of signerSeed so the signatures verify against it. +""" +import json, hashlib +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +def pk(seed: int) -> bytes: + return bytes([seed]) * 32 + +SIGNER_SEED = bytes([0x01]) * 32 +_authority = Ed25519PrivateKey.from_private_bytes(SIGNER_SEED) +AUTHORITY = _authority.public_key().public_bytes( + serialization.Encoding.Raw, serialization.PublicFormat.Raw) + +SOURCE_VAULT_SEED = 0x10 +MINT_SEED = 0x0F + +def message(source: bytes, dest: bytes, amount: int, nonce: bytes, nonce_value: bytes) -> bytes: + return b"transfer" + source + dest + int(amount).to_bytes(8, "little") + nonce + nonce_value + +VERIFIED_CURRENCY = "usd" +VERIFIED_RATE = "1.0" + +def pay(d, o, quarks, currency=VERIFIED_CURRENCY, mint=MINT_SEED): + # 6-decimal token at rate 1.0: native amount = quarks / 10^6, as a decimal string. + native = f"{quarks // 1_000_000}.{quarks % 1_000_000:06d}" + return {"destinationSeed": d, "destinationOwnerSeed": o, "quarks": str(quarks), + "mintSeed": mint, "currency": currency, "nativeAmount": native} + +VALID = [ + # Reaction boost: 80% to the message sender, 20% to the group creator. + ("boost 80/20", [pay(0x21, 0x31, 800_000), pay(0x22, 0x32, 200_000)]), + ("two uneven, large amount", [pay(0x21, 0x31, (1 << 63) - 1), pay(0x22, 0x32, 1)]), + ("ten payments (max)", [pay(0x40 + i, 0x60 + i, 1_000 * (i + 1)) for i in range(10)]), +] + +two = lambda: [pay(0x21, 0x31, 800_000), pay(0x22, 0x32, 200_000)] +def with_(i, **kw): + ps = two(); ps[i].update(kw); return ps + +AUTHORITY_SEED_MARKER = "sourceAuthority" # destinationOwner == the real source authority key + +REJECT = [ + ("one payment", "count", [pay(0x21, 0x31, 800_000)]), + ("eleven payments", "count", [pay(0x40 + i, 0x60 + i, 1_000) for i in range(11)]), + ("duplicate destination", "duplicateDestination", with_(1, destinationSeed=0x21)), + ("duplicate destination owner", "duplicateDestinationOwner", with_(1, destinationOwnerSeed=0x31)), + ("destination is source vault", "destinationIsSource", with_(1, destinationSeed=SOURCE_VAULT_SEED)), + ("destination owner is source authority", "destinationIsSource", + with_(1, destinationOwnerSeed=AUTHORITY_SEED_MARKER)), + ("mint mismatch", "mintMismatch", with_(1, mintSeed=0x0E)), + ("currency mismatch", "currencyMismatch", with_(1, currency="cad")), +] + +def common(): + return { + "sourceVaultSeed": SOURCE_VAULT_SEED, + "sourceAuthority": AUTHORITY.hex(), + "signerSeed": SIGNER_SEED.hex(), + "mintSeed": MINT_SEED, + "verifiedState": {"currency": VERIFIED_CURRENCY, "rate": VERIFIED_RATE}, + } + +vectors = [] +for name, payments in VALID: + source = pk(SOURCE_VAULT_SEED) + actions = [] + for i, p in enumerate(payments): + d, quarks = p["destinationSeed"], int(p["quarks"]) + nonce_seed, nonce_value_seed = 0xA0 + i, 0xC0 + i + msg = message(source, pk(d), quarks, pk(nonce_seed), pk(nonce_value_seed)) + digest = hashlib.sha256(msg).digest() + actions.append({ + "id": i, + "sourceSeed": SOURCE_VAULT_SEED, + "authority": AUTHORITY.hex(), + "destinationSeed": d, + "mintSeed": MINT_SEED, + "amount": str(quarks), + "nonceSeed": nonce_seed, + "nonceValueSeed": nonce_value_seed, + "message": msg.hex(), + "sha256": digest.hex(), + "signature": _authority.sign(digest).hex(), + }) + vectors.append({"name": name, **common(), "payments": payments, + "expectedActions": actions, + "expectedMetadata": { + "sourceSeed": SOURCE_VAULT_SEED, "mintSeed": MINT_SEED, + "payments": [{k: p[k] for k in ("destinationSeed", "destinationOwnerSeed", "quarks", + "nativeAmount", "currency")} for p in payments], + }}) + +rejections = [{"name": n, **common(), "payments": ps, "expectedError": e} for n, e, ps in REJECT] + +print(json.dumps({ + "algorithm": "ocp-send-public-multi-payment", + "note": "IntentMultiTransfer: one NoPrivacyTransfer per payment in input order, ids 0..n-1, " + "per-action transfer compact message + SHA256 + ed25519 signature by the source authority. " + "Every payment carries destination_owner. `rejections` must each make the builder throw; " + "expectedError names the rule, not a platform error type. A destinationOwnerSeed of " + "\"sourceAuthority\" means the sourceAuthority key itself.", + "vectors": vectors, + "rejections": rejections, +}, indent=2)) diff --git a/test-vectors/multi_payment_intent.json b/test-vectors/multi_payment_intent.json new file mode 100644 index 0000000000..174506e2e8 --- /dev/null +++ b/test-vectors/multi_payment_intent.json @@ -0,0 +1,767 @@ +{ + "algorithm": "ocp-send-public-multi-payment", + "note": "IntentMultiTransfer: one NoPrivacyTransfer per payment in input order, ids 0..n-1, per-action transfer compact message + SHA256 + ed25519 signature by the source authority. Every payment carries destination_owner. `rejections` must each make the builder throw; expectedError names the rule, not a platform error type. A destinationOwnerSeed of \"sourceAuthority\" means the sourceAuthority key itself.", + "vectors": [ + { + "name": "boost 80/20", + "sourceVaultSeed": 16, + "sourceAuthority": "8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c", + "signerSeed": "0101010101010101010101010101010101010101010101010101010101010101", + "mintSeed": 15, + "verifiedState": { + "currency": "usd", + "rate": "1.0" + }, + "payments": [ + { + "destinationSeed": 33, + "destinationOwnerSeed": 49, + "quarks": "800000", + "mintSeed": 15, + "currency": "usd", + "nativeAmount": "0.800000" + }, + { + "destinationSeed": 34, + "destinationOwnerSeed": 50, + "quarks": "200000", + "mintSeed": 15, + "currency": "usd", + "nativeAmount": "0.200000" + } + ], + "expectedActions": [ + { + "id": 0, + "sourceSeed": 16, + "authority": "8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c", + "destinationSeed": 33, + "mintSeed": 15, + "amount": "800000", + "nonceSeed": 160, + "nonceValueSeed": 192, + "message": "7472616e736665721010101010101010101010101010101010101010101010101010101010101010212121212121212121212121212121212121212121212121212121212121212100350c0000000000a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0", + "sha256": "156a00c82cb69e115181b1def043ba761bae4e975577b069816a3e9264c19eaf", + "signature": "31b436fe31c538740c53831287a950c4f607e253003769f9bcfeae429ff8662b2c0033a1616c3ea2b6830cfdf3bdd621284567a51870541fca7d8d27b74fe70a" + }, + { + "id": 1, + "sourceSeed": 16, + "authority": "8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c", + "destinationSeed": 34, + "mintSeed": 15, + "amount": "200000", + "nonceSeed": 161, + "nonceValueSeed": 193, + "message": "7472616e7366657210101010101010101010101010101010101010101010101010101010101010102222222222222222222222222222222222222222222222222222222222222222400d030000000000a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1", + "sha256": "450cf9885637e4232be482e041807938ee0b5e464be21942417ce2ff5d96db83", + "signature": "5275e17aaee5f08f847db11d3a70444de511798d4a9ed179c08b11167ab879e16180de0d1d044874d599d061e40ce708ff92b0e00ffc7f68c4582d97d11a860f" + } + ], + "expectedMetadata": { + "sourceSeed": 16, + "mintSeed": 15, + "payments": [ + { + "destinationSeed": 33, + "destinationOwnerSeed": 49, + "quarks": "800000", + "nativeAmount": "0.800000", + "currency": "usd" + }, + { + "destinationSeed": 34, + "destinationOwnerSeed": 50, + "quarks": "200000", + "nativeAmount": "0.200000", + "currency": "usd" + } + ] + } + }, + { + "name": "two uneven, large amount", + "sourceVaultSeed": 16, + "sourceAuthority": "8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c", + "signerSeed": "0101010101010101010101010101010101010101010101010101010101010101", + "mintSeed": 15, + "verifiedState": { + "currency": "usd", + "rate": "1.0" + }, + "payments": [ + { + "destinationSeed": 33, + "destinationOwnerSeed": 49, + "quarks": "9223372036854775807", + "mintSeed": 15, + "currency": "usd", + "nativeAmount": "9223372036854.775807" + }, + { + "destinationSeed": 34, + "destinationOwnerSeed": 50, + "quarks": "1", + "mintSeed": 15, + "currency": "usd", + "nativeAmount": "0.000001" + } + ], + "expectedActions": [ + { + "id": 0, + "sourceSeed": 16, + "authority": "8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c", + "destinationSeed": 33, + "mintSeed": 15, + "amount": "9223372036854775807", + "nonceSeed": 160, + "nonceValueSeed": 192, + "message": "7472616e7366657210101010101010101010101010101010101010101010101010101010101010102121212121212121212121212121212121212121212121212121212121212121ffffffffffffff7fa0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0", + "sha256": "5db9333e9b97df29a5b3f9ee2dd0c1fbcd10b1c1eb7eed3913f877c27d294cba", + "signature": "39600fe7fd0f82e9d9eff2e9f67d86b2c2a16b799e801f0e05b637f60c35cddfaf7a34962881c708cc0c7125a1a8956dff76d68ee02786eda20006f0a630fe0c" + }, + { + "id": 1, + "sourceSeed": 16, + "authority": "8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c", + "destinationSeed": 34, + "mintSeed": 15, + "amount": "1", + "nonceSeed": 161, + "nonceValueSeed": 193, + "message": "7472616e73666572101010101010101010101010101010101010101010101010101010101010101022222222222222222222222222222222222222222222222222222222222222220100000000000000a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1", + "sha256": "862abc7fe1f9a4a70f1253ac9faf8c4fb59cb66ace3a267b67ab179190008fcd", + "signature": "7e4bb6ba00e1274c2cf6381d8fa48cb58ec2c9972f4fac700056c005948857bb3c92dff15561edb30754ce24ed7125a537c8bcfafeb1ea4af03ec5ce0475b700" + } + ], + "expectedMetadata": { + "sourceSeed": 16, + "mintSeed": 15, + "payments": [ + { + "destinationSeed": 33, + "destinationOwnerSeed": 49, + "quarks": "9223372036854775807", + "nativeAmount": "9223372036854.775807", + "currency": "usd" + }, + { + "destinationSeed": 34, + "destinationOwnerSeed": 50, + "quarks": "1", + "nativeAmount": "0.000001", + "currency": "usd" + } + ] + } + }, + { + "name": "ten payments (max)", + "sourceVaultSeed": 16, + "sourceAuthority": "8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c", + "signerSeed": "0101010101010101010101010101010101010101010101010101010101010101", + "mintSeed": 15, + "verifiedState": { + "currency": "usd", + "rate": "1.0" + }, + "payments": [ + { + "destinationSeed": 64, + "destinationOwnerSeed": 96, + "quarks": "1000", + "mintSeed": 15, + "currency": "usd", + "nativeAmount": "0.001000" + }, + { + "destinationSeed": 65, + "destinationOwnerSeed": 97, + "quarks": "2000", + "mintSeed": 15, + "currency": "usd", + "nativeAmount": "0.002000" + }, + { + "destinationSeed": 66, + "destinationOwnerSeed": 98, + "quarks": "3000", + "mintSeed": 15, + "currency": "usd", + "nativeAmount": "0.003000" + }, + { + "destinationSeed": 67, + "destinationOwnerSeed": 99, + "quarks": "4000", + "mintSeed": 15, + "currency": "usd", + "nativeAmount": "0.004000" + }, + { + "destinationSeed": 68, + "destinationOwnerSeed": 100, + "quarks": "5000", + "mintSeed": 15, + "currency": "usd", + "nativeAmount": "0.005000" + }, + { + "destinationSeed": 69, + "destinationOwnerSeed": 101, + "quarks": "6000", + "mintSeed": 15, + "currency": "usd", + "nativeAmount": "0.006000" + }, + { + "destinationSeed": 70, + "destinationOwnerSeed": 102, + "quarks": "7000", + "mintSeed": 15, + "currency": "usd", + "nativeAmount": "0.007000" + }, + { + "destinationSeed": 71, + "destinationOwnerSeed": 103, + "quarks": "8000", + "mintSeed": 15, + "currency": "usd", + "nativeAmount": "0.008000" + }, + { + "destinationSeed": 72, + "destinationOwnerSeed": 104, + "quarks": "9000", + "mintSeed": 15, + "currency": "usd", + "nativeAmount": "0.009000" + }, + { + "destinationSeed": 73, + "destinationOwnerSeed": 105, + "quarks": "10000", + "mintSeed": 15, + "currency": "usd", + "nativeAmount": "0.010000" + } + ], + "expectedActions": [ + { + "id": 0, + "sourceSeed": 16, + "authority": "8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c", + "destinationSeed": 64, + "mintSeed": 15, + "amount": "1000", + "nonceSeed": 160, + "nonceValueSeed": 192, + "message": "7472616e7366657210101010101010101010101010101010101010101010101010101010101010104040404040404040404040404040404040404040404040404040404040404040e803000000000000a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0", + "sha256": "554aa96015b18f275d52ddca389a2e4b020b0d243db5bd2eccf82a9c3007331f", + "signature": "e0bdf877b02cf0fd0f3f96341943dbe045ffa03646ed8219cbff15c391e36c77dcab520ce31247da462c507da36f7f00451e09666aeaf1a215ead4bd27aef609" + }, + { + "id": 1, + "sourceSeed": 16, + "authority": "8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c", + "destinationSeed": 65, + "mintSeed": 15, + "amount": "2000", + "nonceSeed": 161, + "nonceValueSeed": 193, + "message": "7472616e7366657210101010101010101010101010101010101010101010101010101010101010104141414141414141414141414141414141414141414141414141414141414141d007000000000000a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1", + "sha256": "4709579b5c6332ee4df28d529b48e6bfac95bf2afe0f622bb2603151df419af5", + "signature": "97bdae2ae8d37ad6ef4405db5d9160089ffebed060678727ff7ed26e0fa09f6940239bea506bc319cc5a7e109f92a737d832183beea99e8423fdc40a623be30a" + }, + { + "id": 2, + "sourceSeed": 16, + "authority": "8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c", + "destinationSeed": 66, + "mintSeed": 15, + "amount": "3000", + "nonceSeed": 162, + "nonceValueSeed": 194, + "message": "7472616e7366657210101010101010101010101010101010101010101010101010101010101010104242424242424242424242424242424242424242424242424242424242424242b80b000000000000a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2", + "sha256": "d5d058613d75eed724b6068da891c2ba337429d33cdb6b07275490db7406950b", + "signature": "f96d2d65e704babbe306404b1ea7faf5ced9d07d5052d206d97d34b7256e16aaa10acc799f5b9d7b024693d82b29e8bc356f077f07a6c96ed09d79934ba5d60d" + }, + { + "id": 3, + "sourceSeed": 16, + "authority": "8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c", + "destinationSeed": 67, + "mintSeed": 15, + "amount": "4000", + "nonceSeed": 163, + "nonceValueSeed": 195, + "message": "7472616e7366657210101010101010101010101010101010101010101010101010101010101010104343434343434343434343434343434343434343434343434343434343434343a00f000000000000a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3", + "sha256": "4179a86cc44c53bee5f5ba11d36dbb5ab46e7fcf80dbf5c15a0c1ffc8fd5259d", + "signature": "0be3596cc71f48a86e304d0a41ce64a2b36f4c21b6e0d1ab1d004b8eaa43893d2bc6a1bc147cd001a9cd8ccf9be76ddfcab9d2e300a2ffb2345197659347a60d" + }, + { + "id": 4, + "sourceSeed": 16, + "authority": "8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c", + "destinationSeed": 68, + "mintSeed": 15, + "amount": "5000", + "nonceSeed": 164, + "nonceValueSeed": 196, + "message": "7472616e73666572101010101010101010101010101010101010101010101010101010101010101044444444444444444444444444444444444444444444444444444444444444448813000000000000a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4", + "sha256": "d6f72ded8294a5ade7ddb036e5d6369f8a8c8153022c1977815952ed1855b798", + "signature": "d976d7df516eafe031556353681987c695a93b1c967c7e7c8a90029302ee5cb9ab823d13984c7d317eeca76256c67da207aa0bf8873d28f91f50ee1edadc4504" + }, + { + "id": 5, + "sourceSeed": 16, + "authority": "8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c", + "destinationSeed": 69, + "mintSeed": 15, + "amount": "6000", + "nonceSeed": 165, + "nonceValueSeed": 197, + "message": "7472616e73666572101010101010101010101010101010101010101010101010101010101010101045454545454545454545454545454545454545454545454545454545454545457017000000000000a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5", + "sha256": "584f4dfa872b2c2eef7b51cc91ef2b5de86b95f313e38ea0ba60ce7c558dfe26", + "signature": "94beacefed5d7079f9ebdd78e5b801c57d081df43a522bd363a181c31ade587647a2082ab32e0aaa929578eaeec8149bb2d50f78618bbbc63f5ed4bc54916903" + }, + { + "id": 6, + "sourceSeed": 16, + "authority": "8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c", + "destinationSeed": 70, + "mintSeed": 15, + "amount": "7000", + "nonceSeed": 166, + "nonceValueSeed": 198, + "message": "7472616e7366657210101010101010101010101010101010101010101010101010101010101010104646464646464646464646464646464646464646464646464646464646464646581b000000000000a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6", + "sha256": "156cd8dda1e314e19910146810f53732229c8b40efcb7c5b411112c514fd4260", + "signature": "db9462c1d7c050d9df4f2f9e744bcf63f87af7581751770eb1322602df246cf7c641cecb21a4f62257343c18cf7b7eb72c75b384080ae2558846f0935b3c5a08" + }, + { + "id": 7, + "sourceSeed": 16, + "authority": "8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c", + "destinationSeed": 71, + "mintSeed": 15, + "amount": "8000", + "nonceSeed": 167, + "nonceValueSeed": 199, + "message": "7472616e7366657210101010101010101010101010101010101010101010101010101010101010104747474747474747474747474747474747474747474747474747474747474747401f000000000000a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7", + "sha256": "ca3dcdc3bac5059282424ffb53d8d72f9e11cf426208bcb6b2d5594f41b1b400", + "signature": "99ec6f60700886389aff2eaf46ba258b4f296345144073d791b92d167961fab710c74351437028d5fed4aebe610a87df9fe40d25fdb9af0e2e3f2bdbcb7e0807" + }, + { + "id": 8, + "sourceSeed": 16, + "authority": "8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c", + "destinationSeed": 72, + "mintSeed": 15, + "amount": "9000", + "nonceSeed": 168, + "nonceValueSeed": 200, + "message": "7472616e73666572101010101010101010101010101010101010101010101010101010101010101048484848484848484848484848484848484848484848484848484848484848482823000000000000a8a8a8a8a8a8a8a8a8a8a8a8a8a8a8a8a8a8a8a8a8a8a8a8a8a8a8a8a8a8a8a8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8", + "sha256": "ff021a571ab510fd5c2ac7f33d61ec9b65ba8b5edf4ea60ff5b28c09f8ef7c75", + "signature": "8192b6b7090119598f796a7ac9be1ecfd71c901db2d392e42128d3566022ec7e2eeee93f21afd9f6ccfa86a3d2f1abc8a58efe466d0e3b83cd358801d134990f" + }, + { + "id": 9, + "sourceSeed": 16, + "authority": "8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c", + "destinationSeed": 73, + "mintSeed": 15, + "amount": "10000", + "nonceSeed": 169, + "nonceValueSeed": 201, + "message": "7472616e73666572101010101010101010101010101010101010101010101010101010101010101049494949494949494949494949494949494949494949494949494949494949491027000000000000a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9", + "sha256": "926fe8924d46602353ec919d83284fe3fd1a471a688fd56626079207a8ff41cf", + "signature": "c0b477d29f75a16257f65882582d4e8d3f7af48527f713bf268d8ab4154f112418b8ae0417edbfab9a8e7f683410a9de31419ba02cce08c4709b23f066bef104" + } + ], + "expectedMetadata": { + "sourceSeed": 16, + "mintSeed": 15, + "payments": [ + { + "destinationSeed": 64, + "destinationOwnerSeed": 96, + "quarks": "1000", + "nativeAmount": "0.001000", + "currency": "usd" + }, + { + "destinationSeed": 65, + "destinationOwnerSeed": 97, + "quarks": "2000", + "nativeAmount": "0.002000", + "currency": "usd" + }, + { + "destinationSeed": 66, + "destinationOwnerSeed": 98, + "quarks": "3000", + "nativeAmount": "0.003000", + "currency": "usd" + }, + { + "destinationSeed": 67, + "destinationOwnerSeed": 99, + "quarks": "4000", + "nativeAmount": "0.004000", + "currency": "usd" + }, + { + "destinationSeed": 68, + "destinationOwnerSeed": 100, + "quarks": "5000", + "nativeAmount": "0.005000", + "currency": "usd" + }, + { + "destinationSeed": 69, + "destinationOwnerSeed": 101, + "quarks": "6000", + "nativeAmount": "0.006000", + "currency": "usd" + }, + { + "destinationSeed": 70, + "destinationOwnerSeed": 102, + "quarks": "7000", + "nativeAmount": "0.007000", + "currency": "usd" + }, + { + "destinationSeed": 71, + "destinationOwnerSeed": 103, + "quarks": "8000", + "nativeAmount": "0.008000", + "currency": "usd" + }, + { + "destinationSeed": 72, + "destinationOwnerSeed": 104, + "quarks": "9000", + "nativeAmount": "0.009000", + "currency": "usd" + }, + { + "destinationSeed": 73, + "destinationOwnerSeed": 105, + "quarks": "10000", + "nativeAmount": "0.010000", + "currency": "usd" + } + ] + } + } + ], + "rejections": [ + { + "name": "one payment", + "sourceVaultSeed": 16, + "sourceAuthority": "8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c", + "signerSeed": "0101010101010101010101010101010101010101010101010101010101010101", + "mintSeed": 15, + "verifiedState": { + "currency": "usd", + "rate": "1.0" + }, + "payments": [ + { + "destinationSeed": 33, + "destinationOwnerSeed": 49, + "quarks": "800000", + "mintSeed": 15, + "currency": "usd", + "nativeAmount": "0.800000" + } + ], + "expectedError": "count" + }, + { + "name": "eleven payments", + "sourceVaultSeed": 16, + "sourceAuthority": "8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c", + "signerSeed": "0101010101010101010101010101010101010101010101010101010101010101", + "mintSeed": 15, + "verifiedState": { + "currency": "usd", + "rate": "1.0" + }, + "payments": [ + { + "destinationSeed": 64, + "destinationOwnerSeed": 96, + "quarks": "1000", + "mintSeed": 15, + "currency": "usd", + "nativeAmount": "0.001000" + }, + { + "destinationSeed": 65, + "destinationOwnerSeed": 97, + "quarks": "1000", + "mintSeed": 15, + "currency": "usd", + "nativeAmount": "0.001000" + }, + { + "destinationSeed": 66, + "destinationOwnerSeed": 98, + "quarks": "1000", + "mintSeed": 15, + "currency": "usd", + "nativeAmount": "0.001000" + }, + { + "destinationSeed": 67, + "destinationOwnerSeed": 99, + "quarks": "1000", + "mintSeed": 15, + "currency": "usd", + "nativeAmount": "0.001000" + }, + { + "destinationSeed": 68, + "destinationOwnerSeed": 100, + "quarks": "1000", + "mintSeed": 15, + "currency": "usd", + "nativeAmount": "0.001000" + }, + { + "destinationSeed": 69, + "destinationOwnerSeed": 101, + "quarks": "1000", + "mintSeed": 15, + "currency": "usd", + "nativeAmount": "0.001000" + }, + { + "destinationSeed": 70, + "destinationOwnerSeed": 102, + "quarks": "1000", + "mintSeed": 15, + "currency": "usd", + "nativeAmount": "0.001000" + }, + { + "destinationSeed": 71, + "destinationOwnerSeed": 103, + "quarks": "1000", + "mintSeed": 15, + "currency": "usd", + "nativeAmount": "0.001000" + }, + { + "destinationSeed": 72, + "destinationOwnerSeed": 104, + "quarks": "1000", + "mintSeed": 15, + "currency": "usd", + "nativeAmount": "0.001000" + }, + { + "destinationSeed": 73, + "destinationOwnerSeed": 105, + "quarks": "1000", + "mintSeed": 15, + "currency": "usd", + "nativeAmount": "0.001000" + }, + { + "destinationSeed": 74, + "destinationOwnerSeed": 106, + "quarks": "1000", + "mintSeed": 15, + "currency": "usd", + "nativeAmount": "0.001000" + } + ], + "expectedError": "count" + }, + { + "name": "duplicate destination", + "sourceVaultSeed": 16, + "sourceAuthority": "8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c", + "signerSeed": "0101010101010101010101010101010101010101010101010101010101010101", + "mintSeed": 15, + "verifiedState": { + "currency": "usd", + "rate": "1.0" + }, + "payments": [ + { + "destinationSeed": 33, + "destinationOwnerSeed": 49, + "quarks": "800000", + "mintSeed": 15, + "currency": "usd", + "nativeAmount": "0.800000" + }, + { + "destinationSeed": 33, + "destinationOwnerSeed": 50, + "quarks": "200000", + "mintSeed": 15, + "currency": "usd", + "nativeAmount": "0.200000" + } + ], + "expectedError": "duplicateDestination" + }, + { + "name": "duplicate destination owner", + "sourceVaultSeed": 16, + "sourceAuthority": "8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c", + "signerSeed": "0101010101010101010101010101010101010101010101010101010101010101", + "mintSeed": 15, + "verifiedState": { + "currency": "usd", + "rate": "1.0" + }, + "payments": [ + { + "destinationSeed": 33, + "destinationOwnerSeed": 49, + "quarks": "800000", + "mintSeed": 15, + "currency": "usd", + "nativeAmount": "0.800000" + }, + { + "destinationSeed": 34, + "destinationOwnerSeed": 49, + "quarks": "200000", + "mintSeed": 15, + "currency": "usd", + "nativeAmount": "0.200000" + } + ], + "expectedError": "duplicateDestinationOwner" + }, + { + "name": "destination is source vault", + "sourceVaultSeed": 16, + "sourceAuthority": "8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c", + "signerSeed": "0101010101010101010101010101010101010101010101010101010101010101", + "mintSeed": 15, + "verifiedState": { + "currency": "usd", + "rate": "1.0" + }, + "payments": [ + { + "destinationSeed": 33, + "destinationOwnerSeed": 49, + "quarks": "800000", + "mintSeed": 15, + "currency": "usd", + "nativeAmount": "0.800000" + }, + { + "destinationSeed": 16, + "destinationOwnerSeed": 50, + "quarks": "200000", + "mintSeed": 15, + "currency": "usd", + "nativeAmount": "0.200000" + } + ], + "expectedError": "destinationIsSource" + }, + { + "name": "destination owner is source authority", + "sourceVaultSeed": 16, + "sourceAuthority": "8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c", + "signerSeed": "0101010101010101010101010101010101010101010101010101010101010101", + "mintSeed": 15, + "verifiedState": { + "currency": "usd", + "rate": "1.0" + }, + "payments": [ + { + "destinationSeed": 33, + "destinationOwnerSeed": 49, + "quarks": "800000", + "mintSeed": 15, + "currency": "usd", + "nativeAmount": "0.800000" + }, + { + "destinationSeed": 34, + "destinationOwnerSeed": "sourceAuthority", + "quarks": "200000", + "mintSeed": 15, + "currency": "usd", + "nativeAmount": "0.200000" + } + ], + "expectedError": "destinationIsSource" + }, + { + "name": "mint mismatch", + "sourceVaultSeed": 16, + "sourceAuthority": "8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c", + "signerSeed": "0101010101010101010101010101010101010101010101010101010101010101", + "mintSeed": 15, + "verifiedState": { + "currency": "usd", + "rate": "1.0" + }, + "payments": [ + { + "destinationSeed": 33, + "destinationOwnerSeed": 49, + "quarks": "800000", + "mintSeed": 15, + "currency": "usd", + "nativeAmount": "0.800000" + }, + { + "destinationSeed": 34, + "destinationOwnerSeed": 50, + "quarks": "200000", + "mintSeed": 14, + "currency": "usd", + "nativeAmount": "0.200000" + } + ], + "expectedError": "mintMismatch" + }, + { + "name": "currency mismatch", + "sourceVaultSeed": 16, + "sourceAuthority": "8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c", + "signerSeed": "0101010101010101010101010101010101010101010101010101010101010101", + "mintSeed": 15, + "verifiedState": { + "currency": "usd", + "rate": "1.0" + }, + "payments": [ + { + "destinationSeed": 33, + "destinationOwnerSeed": 49, + "quarks": "800000", + "mintSeed": 15, + "currency": "usd", + "nativeAmount": "0.800000" + }, + { + "destinationSeed": 34, + "destinationOwnerSeed": 50, + "quarks": "200000", + "mintSeed": 15, + "currency": "cad", + "nativeAmount": "0.200000" + } + ], + "expectedError": "currencyMismatch" + } + ] +}