From f2db129b04f01ef29f75bde07940a195fdd3c664 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 16 Oct 2026 17:12:47 -0400 Subject: [PATCH 1/4] chore(protos): scaffold ocp 0.8.0 and flipcash2 0.20.0 contract changes Built against local client checkouts; no version pins changed. opencode: SendPublicMultiPaymentMetadata model, proto mapping both ways, IntentMultiTransfer builder and TransactionController.directMultiTransfer. flipcash: Community service/repository/controller (GetCommunity), boost_usd on reactors, emoji reactions and reaction updates, ReactionUpdate.Action.BOOSTED, RemoveReactionError.CannotRemoveBoosted, chat holdings_summary, activity BoostedReaction, and a ReactionBoost intent metadata builder. A BOOSTED stream update no longer clears the viewer's own reaction. --- .../internal/delegates/EventStreamDelegate.kt | 3 +- .../controllers/CommunityController.kt | 21 ++++ .../services/inject/FlipcashModule.kt | 10 ++ .../domain/ActivityFeedMessageMapper.kt | 12 +- .../internal/domain/ChatMetadataMapper.kt | 2 + .../internal/network/api/CommunityApi.kt | 44 +++++++ .../network/extensions/ProtobufToLocal.kt | 16 +++ .../network/services/ChatMessagingService.kt | 1 + .../network/services/CommunityService.kt | 36 ++++++ .../InternalCommunityRepository.kt | 35 ++++++ .../models/ActivityFeedNotification.kt | 17 +++ .../com/flipcash/services/models/Community.kt | 23 ++++ .../com/flipcash/services/models/Errors.kt | 11 ++ .../services/models/ReactionBoostMetadata.kt | 35 ++++++ .../services/models/chat/ChatMetadata.kt | 4 + .../flipcash/services/models/chat/Emoji.kt | 3 + .../services/models/chat/EmojiReaction.kt | 2 + .../services/models/chat/HoldingsSummary.kt | 20 +++ .../services/models/chat/ReactionUpdate.kt | 6 + .../flipcash/services/models/chat/Reactor.kt | 2 + .../repository/CommunityRepository.kt | 9 ++ .../controllers/TransactionController.kt | 31 +++++ .../api/intents/IntentMultiTransfer.kt | 114 ++++++++++++++++++ .../network/extensions/LocalToProtobuf.kt | 24 ++++ .../network/extensions/ProtobufToLocal.kt | 13 ++ .../opencode/model/core/errors/Errors.kt | 1 + .../model/transactions/TransactionMetadata.kt | 42 +++++++ 27 files changed, 535 insertions(+), 2 deletions(-) create mode 100644 services/flipcash/src/main/kotlin/com/flipcash/services/controllers/CommunityController.kt create mode 100644 services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/CommunityApi.kt create mode 100644 services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/CommunityService.kt create mode 100644 services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalCommunityRepository.kt create mode 100644 services/flipcash/src/main/kotlin/com/flipcash/services/models/Community.kt create mode 100644 services/flipcash/src/main/kotlin/com/flipcash/services/models/ReactionBoostMetadata.kt create mode 100644 services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/HoldingsSummary.kt create mode 100644 services/flipcash/src/main/kotlin/com/flipcash/services/repository/CommunityRepository.kt create mode 100644 services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/api/intents/IntentMultiTransfer.kt 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 5859f26b7d..b5b68a3f4e 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 @@ -476,7 +476,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/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 cc00394f93..4e7195b190 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 @@ -297,6 +298,7 @@ internal fun MessagingModel.EmojiReaction.toEmojiReaction(): EmojiReaction { selfReactor = if (hasSelfReactor()) selfReactor.toReactor() else null, sampleReactors = sampleReactorsList.map { it.toReactor() }, version = version, + boostUsd = boostUsd, ) } @@ -305,6 +307,7 @@ internal fun MessagingModel.Reactor.toReactor(): Reactor { userId = userId.toId(), reactedAt = Instant.fromEpochSeconds(reactedTs.seconds, reactedTs.nanos), version = version, + boostUsd = boostUsd, ) } @@ -316,11 +319,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, ) } @@ -440,6 +446,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..d89b5f352c 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,36 @@ 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, + 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, + ) + }.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..b735ae7daa --- /dev/null +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/api/intents/IntentMultiTransfer.kt @@ -0,0 +1,114 @@ +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, + ): 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" + } + + 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, + ) + }, + ), + 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..f187daf395 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,30 @@ 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() + ) + } + 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..fd40ab6a4c 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,48 @@ 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 + */ + data class SendPublicMultiPayment( + val source: PublicKey, + val payments: List, + val mint: Mint, + ): 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. * From 7bbc4066ec3ecf7d58451007fb84444327202175 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 16 Oct 2026 17:28:13 -0400 Subject: [PATCH 2/4] fix(opencode): align multi-payment intent checks and carry app metadata IntentMultiTransfer.create now requires every share to be in the intent mint and in one currency matching the verified rate's currency. It also accepts optional appMetadata, carried on TransactionMetadata.SendPublicMultiPayment and written to the intent's app_metadata, as SendPublicPayment does. TransactionController.directMultiTransfer passes it through. Adds IntentMultiTransferTest covering action shape, metadata and rejections. --- .../controllers/TransactionController.kt | 2 + .../api/intents/IntentMultiTransfer.kt | 14 ++ .../network/extensions/LocalToProtobuf.kt | 6 + .../model/transactions/TransactionMetadata.kt | 2 + .../api/intents/IntentMultiTransferTest.kt | 196 ++++++++++++++++++ 5 files changed, 220 insertions(+) create mode 100644 services/opencode/src/test/kotlin/com/getcode/opencode/internal/network/api/intents/IntentMultiTransferTest.kt 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 d89b5f352c..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 @@ -167,6 +167,7 @@ class TransactionController @Inject constructor( verifiedState: VerifiedState, token: Token, source: AccountCluster, + appMetadata: ByteArray? = null, scope: CoroutineScope = this.scope, ): Result { val intent = runCatching { @@ -182,6 +183,7 @@ class TransactionController @Inject constructor( mint = token.address, sourceCluster = source, verifiedState = verifiedState, + appMetadata = appMetadata, ) }.getOrElse { return Result.failure(it) } 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 index b735ae7daa..a66072a6cc 100644 --- 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 @@ -57,6 +57,7 @@ internal class IntentMultiTransfer( 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}" @@ -74,6 +75,18 @@ internal class IntentMultiTransfer( "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( @@ -106,6 +119,7 @@ internal class IntentMultiTransfer( 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 f187daf395..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 @@ -195,6 +195,12 @@ internal fun TransactionMetadata.asProtobufMetadata(): OcpTransactionService.Met } .build() ) + if (appMetadata != null) { + builder.setAppMetadata( + OcpTransactionService.AppMetadata.newBuilder() + .setValue(appMetadata.toByteString()) + ) + } } is TransactionMetadata.PublicDistribution -> { 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 fd40ab6a4c..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 @@ -209,11 +209,13 @@ sealed interface TransactionMetadata { * @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 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")) } + } +} From 16a9868d632daebf4652595879da1712c53a304d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 16 Oct 2026 17:45:02 -0400 Subject: [PATCH 3/4] chore(deps): bump ocp-client-protocol to 0.8.0 and flipcash2-client-protocol to 0.20.0 --- gradle/libs.versions.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 765338d545..cd445ee408 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, From 1c798aa45fce291139670ff07693095e86fe4eeb Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 16 Oct 2026 19:06:35 -0400 Subject: [PATCH 4/4] test(opencode): gate multi-payment intent against shared vectors Drive IntentMultiTransfer.create with multi_payment_intent.json: action count, ids, source, destination, authority, mint and amount per action; the transfer compact message and SHA-256 each action signs once the server nonce and blockhash are applied; and the SendPublicMultiPayment metadata (source, mint, destination, destination_owner, quarks, native amount, currency). The rejections array must each make create throw. The ed25519 key pair is mocked, so signatures are not asserted. --- .../solana/MultiPaymentIntentVectorTest.kt | 230 ++++++ .../test/resources/multi_payment_intent.json | 767 ++++++++++++++++++ test-vectors/README.md | 1 + test-vectors/gen_multi_payment_intent.py | 125 +++ test-vectors/multi_payment_intent.json | 767 ++++++++++++++++++ 5 files changed, 1890 insertions(+) create mode 100644 services/opencode/src/test/kotlin/com/getcode/opencode/solana/MultiPaymentIntentVectorTest.kt create mode 100644 services/opencode/src/test/resources/multi_payment_intent.json create mode 100644 test-vectors/gen_multi_payment_intent.py create mode 100644 test-vectors/multi_payment_intent.json 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" + } + ] +}