From 49c5c3b02bc1dda1fdb8c3a74b779ef55d9834e6 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 16 Oct 2026 17:17:35 -0400 Subject: [PATCH 1/5] chore(contract): scaffold ocp 0.8.0 and flipcash2 0.20.0 contract changes Build against local client protocol checkouts; pins unchanged. - ocp: add IntentMultiTransfer for SendPublicMultiPayment (one no-privacy transfer per payment) and Client.transferMultiple; handle the new Metadata case in IntentMetadata. - flipcash2: add CommunityService and FlipClient.fetchCommunity for GetCommunity. - Map ReactionUpdate.Action.BOOSTED and RemoveReactionResponse.Result.CANNOT_REMOVE_BOOSTED. - Carry boost_usd on Reactor, EmojiReaction and ReactionUpdate, the chat holdings summary, the activity BoostedReaction and the ChatMetadata.reaction_boost case. --- .../Flip API/FlipClient+Community.swift | 17 +++ .../Clients/Flip API/FlipClient.swift | 2 + .../Services/ChatMessagingService.swift | 7 +- .../Flip API/Services/CommunityService.swift | 81 ++++++++++++ .../Payments API/Client+Transaction.swift | 19 +++ .../Intents/IntentMultiTransfer.swift | 120 ++++++++++++++++++ .../Services/TransactionService.swift | 31 +++++ .../Models/Activity+Metadata.swift | 41 ++++++ .../FlipcashCore/Models/Activity.swift | 10 +- .../Models/Chat/EmojiReaction.swift | 17 ++- .../FlipcashCore/Models/Chat/Reaction.swift | 4 +- .../FlipcashCore/Models/Community.swift | 38 ++++++ .../Conversation/ChatPaymentMetadata.swift | 13 +- .../Models/Conversation/Conversation.swift | 11 +- .../ConversationStreamEvent.swift | 25 +++- .../Models/Conversation/HoldingsSummary.swift | 40 ++++++ .../FlipcashCore/Models/IntentMetadata.swift | 5 + .../IntentMultiTransferTests.swift | 84 ++++++++++++ .../IntentWithdrawTests.swift | 2 +- .../TransportClassificationTests.swift | 1 + 20 files changed, 551 insertions(+), 17 deletions(-) create mode 100644 FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Community.swift create mode 100644 FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/CommunityService.swift create mode 100644 FlipcashCore/Sources/FlipcashCore/Clients/Payments API/Intents/IntentMultiTransfer.swift create mode 100644 FlipcashCore/Sources/FlipcashCore/Models/Community.swift create mode 100644 FlipcashCore/Sources/FlipcashCore/Models/Conversation/HoldingsSummary.swift create mode 100644 FlipcashCore/Tests/FlipcashCoreTests/IntentMultiTransferTests.swift diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Community.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Community.swift new file mode 100644 index 000000000..867110e73 --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Community.swift @@ -0,0 +1,17 @@ +// +// FlipClient+Community.swift +// FlipcashCore +// + +import Foundation + +extension FlipClient { + + /// Fetches the community of the currency launched as `mint`. Throws + /// `ErrorGetCommunity.notFound` when the mint is not a Flipcash currency. + public func fetchCommunity(mint: PublicKey, owner: KeyPair) async throws -> Community { + try await withCheckedThrowingContinuation { c in + communityService.getCommunity(mint: mint, owner: owner) { c.resume(with: $0) } + } + } +} diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient.swift index b0cd2ef36..81c14a418 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient.swift @@ -40,6 +40,7 @@ public class FlipClient: ObservableObject { internal let blocklistService: BlocklistService internal let reportingService: ReportingService internal let pollService: PollService + internal let communityService: CommunityService /// The single per-user event stream. Started on login, stopped on logout. public let eventStreamer: EventStreamer @@ -89,6 +90,7 @@ public class FlipClient: ObservableObject { self.blocklistService = BlocklistService(client: client) self.reportingService = ReportingService(client: client) self.pollService = PollService(client: client) + self.communityService = CommunityService(client: client) self.eventStreamer = EventStreamer(service: EventStreamingService(client: client)) } diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatMessagingService.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatMessagingService.swift index c8d921d22..020000587 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatMessagingService.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatMessagingService.swift @@ -499,7 +499,7 @@ final class ChatMessagingService: Sendable { switch error { case .ok: return response.hasReaction ? .success(EmojiReaction(response.reaction)) : .failure(.unknown) - case .denied, .messageNotFound, .unknown, .transportFailure, .cancelled, .rejected: + case .denied, .messageNotFound, .cannotRemoveBoosted, .unknown, .transportFailure, .cancelled, .rejected: return .failure(error) } } @@ -752,6 +752,8 @@ public enum ErrorRemoveReaction: Int, Error { case ok case denied case messageNotFound + /// The caller paid to boost the emoji, and a boosted reaction is permanent. + case cannotRemoveBoosted case unknown = -1 case transportFailure = -2 case cancelled = -3 @@ -795,7 +797,7 @@ extension ErrorRemoveReaction: ServerError, TransportClassifiableError { switch self { case .ok, .transportFailure: .suppressed case .cancelled: .info - case .denied, .messageNotFound: .info + case .denied, .messageNotFound, .cannotRemoveBoosted: .info case .unknown, .rejected: .error } } @@ -804,6 +806,7 @@ extension ErrorRemoveReaction: ServerError, TransportClassifiableError { public var reactionFailure: ReactionFailure { switch self { case .messageNotFound: .messageNotFound + case .cannotRemoveBoosted: .cannotRemoveBoosted case .denied: .denied case .ok, .unknown, .transportFailure, .cancelled, .rejected: .network } diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/CommunityService.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/CommunityService.swift new file mode 100644 index 000000000..be1149c17 --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/CommunityService.swift @@ -0,0 +1,81 @@ +// +// CommunityService.swift +// FlipcashCore +// + +import Foundation +import FlipcashAPI +import GRPCCore + +private let logger = Logger(label: "flipcash.community-service") + +final class CommunityService: Sendable { + + private let service: Flipcash_Community_V1_Community.Client + + init(client: GRPCClient) { + self.service = Flipcash_Community_V1_Community.Client(wrapping: client) + } + + /// Fetches the community of the currency launched as `mint`. + func getCommunity(mint: PublicKey, owner: KeyPair, completion: @Sendable @escaping (Result) -> Void) { + logger.info("Fetching community", metadata: ["mint": "\(mint.base58)"]) + + let request = Flipcash_Community_V1_GetCommunityRequest.with { + $0.mint = mint.proto + $0.auth = owner.authFor(message: $0) + } + + Task { + do { + let response = try await service.getCommunity(request, options: .unaryDefault) + let result = Self.communityResult(response) + if case .failure(let error) = result { + logger.error("Failed to fetch community", metadata: ["error": "\(error)"]) + } + await MainActor.run { completion(result) } + } catch let error as RPCError { + await MainActor.run { completion(.failure(.from(transportError: error))) } + } catch { + await MainActor.run { completion(.failure(.unknown)) } + } + } + } + + /// The community a response carried, or the failure it reported. + static func communityResult(_ response: Flipcash_Community_V1_GetCommunityResponse) -> Result { + let error = ErrorGetCommunity(rawValue: response.result.rawValue) ?? .unknown + switch error { + case .ok: + guard response.hasCommunity, let community = try? Community(response.community) else { + return .failure(.unknown) + } + return .success(community) + case .notFound, .unknown, .transportFailure, .cancelled, .rejected: + return .failure(error) + } + } +} + +// MARK: - Errors - + +public enum ErrorGetCommunity: Int, Error { + case ok + /// The mint is not a currency launched on Flipcash, or is not yet available. + case notFound + case unknown = -1 + case transportFailure = -2 + case cancelled = -3 + case rejected = -4 +} + +extension ErrorGetCommunity: ServerError, TransportClassifiableError { + public var reportingLevel: ErrorReportingLevel { + switch self { + case .ok, .transportFailure: .suppressed + case .cancelled: .info + case .notFound: .info + case .unknown, .rejected: .error + } + } +} diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Payments API/Client+Transaction.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Payments API/Client+Transaction.swift index b8d8bd8a2..d4c0f3374 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Payments API/Client+Transaction.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Payments API/Client+Transaction.swift @@ -61,6 +61,25 @@ extension Client { } } + /// Pays two to ten recipients from `owner` in one intent. Not yet called by the app; the + /// reaction-boost flow is its first consumer. + public func transferMultiple(payments: [(destination: PublicKey, destinationOwner: PublicKey?, exchangedFiat: ExchangedFiat)], verifiedState: VerifiedState, owner: AccountCluster, chatMetadata: ChatPaymentMetadata? = nil, rendezvous: PublicKey) async throws { + let appMetadata = try chatMetadata?.serializedAppMetadata() + let payments = payments.map { + IntentMultiTransfer.Payment(destination: $0.destination, destinationOwner: $0.destinationOwner, exchangedFiat: $0.exchangedFiat) + } + _ = try await withCheckedThrowingContinuation { c in + transactionService.transferMultiple( + payments: payments, + verifiedState: verifiedState, + sourceCluster: owner, + appMetadata: appMetadata, + owner: owner.authority.keyPair, + rendezvous: rendezvous + ) { c.resume(with: $0) } + } + } + public func withdraw(exchangedFiat: ExchangedFiat, verifiedState: VerifiedState, fee: TokenAmount, owner: AccountCluster, destinationMetadata: DestinationMetadata) async throws { _ = try await withCheckedThrowingContinuation { c in transactionService.withdraw( diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Payments API/Intents/IntentMultiTransfer.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Payments API/Intents/IntentMultiTransfer.swift new file mode 100644 index 000000000..cca2a70e2 --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Payments API/Intents/IntentMultiTransfer.swift @@ -0,0 +1,120 @@ +// +// IntentMultiTransfer.swift +// FlipcashCore +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import FlipcashAPI +import SwiftProtobuf + +/// Pays two to ten recipients from one source account in a single `SendPublicMultiPayment` intent. +/// +/// Emits one `NoPrivacyTransferAction` per payment, in payment order, from the source PRIMARY to +/// the payment's destination. Mirrors `IntentTransfer`, which emits one for `SendPublicPayment`; +/// Android must emit the same actions in the same order. +final class IntentMultiTransfer: IntentType { + + /// One recipient's share of the intent. + struct Payment { + /// The recipient's PRIMARY account for the intent's mint. + let destination: PublicKey + /// The recipient's owner authority. The server requires it only when `destination` is + /// auto-opened by this intent; sending it always is harmless. + let destinationOwner: PublicKey? + let exchangedFiat: ExchangedFiat + } + + enum Error: Swift.Error { + /// The server accepts two to ten payments. + case invalidPaymentCount + /// Every payment must pay the same mint. + case mintMismatch + /// Destinations, and their owners, must be unique and must differ from the source. + case duplicateDestination + } + + static let paymentCountRange = 2...10 + + let id: PublicKey + let sourceCluster: AccountCluster + let payments: [Payment] + let mint: PublicKey + /// The verified rate (and reserve state) every payment is priced against. The server requires + /// all payments to share currency, rate, and reserve state. + let verifiedState: VerifiedState + /// Serialized `flipcash.intent.v1.AppMetadata`, e.g. a chat `reaction_boost`. + let appMetadata: Data? + + var actionGroup: ActionGroup + + init(rendezvous: PublicKey, sourceCluster: AccountCluster, payments: [Payment], verifiedState: VerifiedState, appMetadata: Data? = nil) throws { + guard Self.paymentCountRange.contains(payments.count) else { + throw Error.invalidPaymentCount + } + let mint = payments[0].exchangedFiat.mint + guard payments.allSatisfy({ $0.exchangedFiat.mint == mint }) else { + throw Error.mintMismatch + } + let destinations = payments.map(\.destination) + [sourceCluster.vaultPublicKey] + guard Set(destinations).count == destinations.count else { + throw Error.duplicateDestination + } + let owners = payments.compactMap(\.destinationOwner) + guard Set(owners).count == owners.count else { + throw Error.duplicateDestination + } + + self.id = rendezvous + self.sourceCluster = sourceCluster + self.payments = payments + self.mint = mint + self.verifiedState = verifiedState + self.appMetadata = appMetadata + + self.actionGroup = ActionGroup(actions: payments.map { + ActionTransfer( + amount: $0.exchangedFiat.onChainAmount, + sourceCluster: sourceCluster, + destination: $0.destination, + mint: mint + ) + }) + } +} + +// MARK: - Proto - + +extension IntentMultiTransfer { + func metadata() -> Ocp_Transaction_V1_Metadata { + .with { + $0.sendPublicMultiPayment = .with { + $0.source = sourceCluster.vaultPublicKey.solanaAccountID + $0.mint = mint.solanaAccountID + + $0.payments = payments.map { payment in + .with { + $0.destination = payment.destination.solanaAccountID + if let destinationOwner = payment.destinationOwner { + $0.destinationOwner = destinationOwner.solanaAccountID + } + $0.clientExchangeData = .with { + $0.mint = payment.exchangedFiat.mint.solanaAccountID + $0.quarks = payment.exchangedFiat.onChainAmount.quarks + $0.nativeAmount = payment.exchangedFiat.nativeAmount.doubleValue + $0.coreMintFiatExchangeRate = verifiedState.rateProto + if let reserveProto = verifiedState.reserveProto { + $0.launchpadCurrencyReserveState = reserveProto + } + } + } + } + } + + if let appMetadata { + $0.appMetadata = .with { $0.value = appMetadata } + } + } + } +} diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Payments API/Services/TransactionService.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Payments API/Services/TransactionService.swift index 5dd130df4..a81e7d0ca 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Payments API/Services/TransactionService.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Payments API/Services/TransactionService.swift @@ -82,6 +82,37 @@ final class TransactionService: Sendable { } } + func transferMultiple(payments: [IntentMultiTransfer.Payment], verifiedState: VerifiedState, sourceCluster: AccountCluster, appMetadata: Data? = nil, owner: KeyPair, rendezvous: PublicKey, completion: @Sendable @escaping (Result<(), Error>) -> Void) { + logger.info("Sending multi-payment transfer", metadata: ["payments": "\(payments.count)"]) + + let intent: IntentMultiTransfer + do { + intent = try IntentMultiTransfer( + rendezvous: rendezvous, + sourceCluster: sourceCluster, + payments: payments, + verifiedState: verifiedState, + appMetadata: appMetadata + ) + } catch { + logger.error("Invalid multi-payment transfer", metadata: ["error": "\(error)"]) + completion(.failure(error)) + return + } + + submit(intent: intent, owner: owner) { result in + switch result { + case .success(_): + logger.info("Multi-payment transfer succeeded") + completion(.success(())) + + case .failure(let error): + logger.error("Multi-payment transfer failed", metadata: ["error": "\(error)"]) + completion(.failure(error)) + } + } + } + func withdraw(exchangedFiat: ExchangedFiat, verifiedState: VerifiedState, fee: TokenAmount, sourceCluster: AccountCluster, destinationMetadata: DestinationMetadata, owner: KeyPair, completion: @Sendable @escaping (Result<(), Error>) -> Void) { logger.info("Sending withdrawal") diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Activity+Metadata.swift b/FlipcashCore/Sources/FlipcashCore/Models/Activity+Metadata.swift index 3165037e8..81724a8be 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Activity+Metadata.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Activity+Metadata.swift @@ -138,3 +138,44 @@ extension Activity.SwapMetadata { ) } } + +extension Activity { + /// The reaction a payment boosted: the payer's reaction with `emoji` on a message in a group + /// chat. Describes what the payment was for when it was made; the message may since have been + /// deleted and the viewer may no longer be a member of the chat. + public struct BoostedReaction: Sendable, Equatable, Hashable { + public let conversationID: ConversationID + public let messageID: MessageID + public let emoji: String + + public init(conversationID: ConversationID, messageID: MessageID, emoji: String) { + self.conversationID = conversationID + self.messageID = messageID + self.emoji = emoji + } + } +} + +extension Activity.BoostedReaction { + init(_ proto: Flipcash_Activity_V1_BoostedReaction) { + self.init( + conversationID: ConversationID(proto.chatID), + messageID: MessageID(proto.messageID), + emoji: proto.emoji.value + ) + } + + /// The boosted reaction a send or receive notification carries; `nil` for other kinds and for + /// payments that boosted nothing. + init?(_ proto: Flipcash_Activity_V1_Notification.OneOf_AdditionalMetadata?) { + switch proto { + case .directlySentCrypto(let metadata) where metadata.hasBoostedReaction: + self.init(metadata.boostedReaction) + case .receivedCrypto(let metadata) where metadata.hasBoostedReaction: + self.init(metadata.boostedReaction) + case .directlySentCrypto, .receivedCrypto, .withdrewCrypto, .indirectlySentCrypto, + .depositedCrypto, .boughtCrypto, .soldCrypto, .swappedCrypto, .none: + return nil + } + } +} diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Activity.swift b/FlipcashCore/Sources/FlipcashCore/Models/Activity.swift index 2f6fe0315..aff1035e1 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Activity.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Activity.swift @@ -28,6 +28,10 @@ public struct Activity: Identifiable, Sendable, Equatable, Hashable { /// withdrawals). Used to resolve an avatar and a richer name in feed rows. public let counterparty: Counterparty? + /// The reaction this payment boosted, when it was a boost. Not persisted: an activity restored + /// from the local database reports `nil` until the server's copy refreshes it. + public let boostedReaction: BoostedReaction? + public var cancellableCashLinkMetadata: CashLinkMetadata? { switch state { case .pending: @@ -48,7 +52,7 @@ public struct Activity: Identifiable, Sendable, Equatable, Hashable { return nil } - public init(id: PublicKey, state: State, kind: Kind, title: String, exchangedFiat: ExchangedFiat, date: Date, metadata: Metadata?, textSubstitutions: [TextSubstitution] = [], counterparty: Counterparty? = nil) { + public init(id: PublicKey, state: State, kind: Kind, title: String, exchangedFiat: ExchangedFiat, date: Date, metadata: Metadata?, textSubstitutions: [TextSubstitution] = [], counterparty: Counterparty? = nil, boostedReaction: BoostedReaction? = nil) { self.id = id self.state = state self.kind = kind @@ -58,6 +62,7 @@ public struct Activity: Identifiable, Sendable, Equatable, Hashable { self.metadata = metadata self.textSubstitutions = textSubstitutions self.counterparty = counterparty + self.boostedReaction = boostedReaction } } @@ -217,7 +222,8 @@ extension Activity { date: proto.ts.date, metadata: .init(proto.additionalMetadata), textSubstitutions: substitutions, - counterparty: .init(proto.additionalMetadata) + counterparty: .init(proto.additionalMetadata), + boostedReaction: .init(proto.additionalMetadata) ) } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Chat/EmojiReaction.swift b/FlipcashCore/Sources/FlipcashCore/Models/Chat/EmojiReaction.swift index fa71550e1..8296969ad 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Chat/EmojiReaction.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Chat/EmojiReaction.swift @@ -14,11 +14,15 @@ public struct Reactor: Hashable, Sendable { public let reactedAt: Date? /// The emoji aggregate's version when this reactor last toggled; orders a reactor list. public let version: UInt64 + /// The total this user has paid to boost the emoji, in USD at each payment's market value; 0 + /// when they have only reacted. The primary order of a reactor list, ahead of `version`. + public let boostUSD: Double - public init(userID: UserID, reactedAt: Date?, version: UInt64) { + public init(userID: UserID, reactedAt: Date?, version: UInt64, boostUSD: Double = 0) { self.userID = userID self.reactedAt = reactedAt self.version = version + self.boostUSD = boostUSD } } @@ -30,13 +34,17 @@ public struct EmojiReaction: Hashable, Sendable { public let selfReactor: Reactor? public let sampleReactors: [Reactor] public let version: UInt64 + /// The total paid to boost this emoji on the message, in USD; 0 when no one has boosted it. The + /// primary sort key of a message's reaction summary, ahead of `count`. + public let boostUSD: Double - public init(emoji: String, count: UInt64, selfReactor: Reactor?, sampleReactors: [Reactor], version: UInt64) { + public init(emoji: String, count: UInt64, selfReactor: Reactor?, sampleReactors: [Reactor], version: UInt64, boostUSD: Double = 0) { self.emoji = emoji self.count = count self.selfReactor = selfReactor self.sampleReactors = sampleReactors self.version = version + self.boostUSD = boostUSD } /// The summary entry `ReactionState` accepts. @@ -76,7 +84,7 @@ extension Reactor { /// Builds a reactor from its proto, or nil when the user id does not parse. public init?(_ proto: Flipcash_Messaging_V1_Reactor) { guard let userID = try? UUID(data: proto.userID.value) else { return nil } - self.init(userID: userID, reactedAt: proto.hasReactedTs ? proto.reactedTs.date : nil, version: proto.version) + self.init(userID: userID, reactedAt: proto.hasReactedTs ? proto.reactedTs.date : nil, version: proto.version, boostUSD: proto.boostUsd) } } @@ -88,7 +96,8 @@ extension EmojiReaction { count: proto.count, selfReactor: proto.hasSelfReactor ? Reactor(proto.selfReactor) : nil, sampleReactors: proto.sampleReactors.compactMap(Reactor.init), - version: proto.version + version: proto.version, + boostUSD: proto.boostUsd ) } } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Chat/Reaction.swift b/FlipcashCore/Sources/FlipcashCore/Models/Chat/Reaction.swift index b5a31b08f..a09c15994 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Chat/Reaction.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Chat/Reaction.swift @@ -62,6 +62,8 @@ public enum ReactionFailure: Hashable, Sendable { case messageNotFound case cannotReact case tooManyReactionTypes + /// A remove was refused because the caller boosted the reaction, which is permanent. + case cannotRemoveBoosted /// The error to show the user for this failure, or nil when the rollback is silent. public var userError: ReactionError? { @@ -70,7 +72,7 @@ public enum ReactionFailure: Hashable, Sendable { return .reactionFailed case .tooManyReactionTypes: return .tooManyReactionTypes - case .messageNotFound, .cannotReact: + case .messageNotFound, .cannotReact, .cannotRemoveBoosted: return nil } } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Community.swift b/FlipcashCore/Sources/FlipcashCore/Models/Community.swift new file mode 100644 index 000000000..4fd89df38 --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Models/Community.swift @@ -0,0 +1,38 @@ +// +// Community.swift +// FlipcashCore +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import FlipcashAPI + +/// The community around a currency launched on Flipcash. +public struct Community: Sendable { + public let mint: PublicKey + public let name: String + /// The community's leader, when the server names one and the profile parses. + public let leader: Profile? + /// The currency's top-holder group chats, in the server's order. + public let topHolderChats: [Conversation] + + public init(mint: PublicKey, name: String, leader: Profile?, topHolderChats: [Conversation]) { + self.mint = mint + self.name = name + self.leader = leader + self.topHolderChats = topHolderChats + } +} + +extension Community { + /// Builds a community from its proto, or throws when the mint does not parse. + init(_ proto: Flipcash_Community_V1_CommunityMetadata) throws { + self.init( + mint: try PublicKey(proto.mint.value), + name: proto.name, + leader: proto.hasLeader && proto.leader.hasProfile ? try? Profile(proto.leader.profile) : nil, + topHolderChats: proto.topHolderChats.map(Conversation.init) + ) + } +} diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatPaymentMetadata.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatPaymentMetadata.swift index 9dc817579..7cf5eafa0 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatPaymentMetadata.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatPaymentMetadata.swift @@ -20,6 +20,10 @@ public enum ChatPaymentMetadata: Sendable { /// A Send Cash payment to a member of a group chat. The destination owner account names the /// recipient; the server denies a tip, so the action is always `SEND`. case groupPayment(chatID: ConversationID) + /// A payment that boosts the payer's own reaction with `emoji` on a message in a group chat. + /// The payer must already have reacted; the server splits the payment between the message's + /// sender and the group's creator and posts no message into the chat. + case reactionBoost(chatID: ConversationID, messageID: MessageID, emoji: String) /// The chat this payment posts into. public var chatID: ConversationID { @@ -30,6 +34,8 @@ public enum ChatPaymentMetadata: Sendable { return chatID case .groupPayment(let chatID): return chatID + case .reactionBoost(let chatID, _, _): + return chatID } } @@ -38,7 +44,7 @@ public enum ChatPaymentMetadata: Sendable { /// carries instead of re-deriving it. public var isTip: Bool { switch self { - case .contactDm, .groupPayment: + case .contactDm, .groupPayment, .reactionBoost: return false case .tipDm(_, _, let action): return action == .tip @@ -64,6 +70,11 @@ public enum ChatPaymentMetadata: Sendable { } case .groupPayment: $0.groupPayment = .with { $0.action = .send } + case .reactionBoost(_, let messageID, let emoji): + $0.reactionBoost = .with { + $0.messageID = messageID.proto + $0.emoji = .with { $0.value = emoji } + } } } }.serializedData() diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/Conversation.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/Conversation.swift index 3196a0c18..fb17b8cef 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/Conversation.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/Conversation.swift @@ -77,7 +77,14 @@ public struct Conversation: Identifiable, Hashable, Sendable { /// server's copy refreshes it. public var inLobby: Bool - public init(id: ConversationID, members: [ConversationMember], lastMessage: ConversationMessage?, lastActivity: Date, type: ConversationType = .contactDm, isHidden: Bool = false, title: String? = nil, latestEventSequence: UInt64 = 0, picture: ProfilePicture? = nil, rosterSummary: ConversationRosterSummary = ConversationRosterSummary(memberCount: 0, version: 0), rules: ConversationRules? = nil, viewerState: ConversationViewerState? = nil, creator: UserID? = nil, useE2Ee: Bool = false, isPrivate: Bool = false, inLobby: Bool = false, description: String? = nil, coverPicture: ProfilePicture? = nil) { + /// What the group's members hold of the currency it is gated on. Only set for a public group + /// gated on a single Flipcash-launched currency, and only above the server's minimum holder + /// count. A periodic snapshot with no change events, so it refreshes by refetching the chat. + /// Not persisted: a conversation restored from the local database reports `nil` until the + /// server's copy refreshes it. + public var holdingsSummary: HoldingsSummary? + + public init(id: ConversationID, members: [ConversationMember], lastMessage: ConversationMessage?, lastActivity: Date, type: ConversationType = .contactDm, isHidden: Bool = false, title: String? = nil, latestEventSequence: UInt64 = 0, picture: ProfilePicture? = nil, rosterSummary: ConversationRosterSummary = ConversationRosterSummary(memberCount: 0, version: 0), rules: ConversationRules? = nil, viewerState: ConversationViewerState? = nil, creator: UserID? = nil, useE2Ee: Bool = false, isPrivate: Bool = false, inLobby: Bool = false, description: String? = nil, coverPicture: ProfilePicture? = nil, holdingsSummary: HoldingsSummary? = nil) { self.id = id self.members = members self.lastMessage = lastMessage @@ -96,6 +103,7 @@ public struct Conversation: Identifiable, Hashable, Sendable { self.useE2Ee = useE2Ee self.isPrivate = isPrivate self.inLobby = inLobby + self.holdingsSummary = holdingsSummary } } @@ -185,6 +193,7 @@ extension Conversation { self.useE2Ee = proto.useE2Ee self.isPrivate = proto.isPrivate self.inLobby = proto.inLobby + self.holdingsSummary = proto.hasHoldingsSummary ? HoldingsSummary(proto.holdingsSummary) : nil } /// The member that isn't the signed-in user, used to title the conversation. diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStreamEvent.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStreamEvent.swift index b66c12084..d9d41cdc1 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStreamEvent.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStreamEvent.swift @@ -76,21 +76,31 @@ public struct DecodedReactionUpdate: Hashable, Sendable { public let messageID: MessageID public let emoji: String public let actor: UserID - /// True for an add, false for a remove. + /// True for an add or a boost, false for a remove. A boost adds the actor's reaction when it is + /// absent, so it applies to reaction state as an add. public let added: Bool + /// True when the actor paid to boost the emoji. Such a reaction cannot be removed afterwards. + public let boosted: Bool /// The emoji's total reactor count after the change. public let count: UInt64 public let version: UInt64 public let reactedAt: Date? + /// The emoji's total boost after the change, in USD. + public let boostUSD: Double + /// The actor's own total boost on the emoji after the change, in USD; 0 unless `boosted`. + public let actorBoostUSD: Double - public init(messageID: MessageID, emoji: String, actor: UserID, added: Bool, count: UInt64, version: UInt64, reactedAt: Date?) { + public init(messageID: MessageID, emoji: String, actor: UserID, added: Bool, boosted: Bool = false, count: UInt64, version: UInt64, reactedAt: Date?, boostUSD: Double = 0, actorBoostUSD: Double = 0) { self.messageID = messageID self.emoji = emoji self.actor = actor self.added = added + self.boosted = boosted self.count = count self.version = version self.reactedAt = reactedAt + self.boostUSD = boostUSD + self.actorBoostUSD = actorBoostUSD } } @@ -98,9 +108,11 @@ extension DecodedReactionUpdate { /// Nil for an update whose action this client does not know or whose actor does not parse. init?(_ proto: Flipcash_Messaging_V1_ReactionUpdate) { let added: Bool + let boosted: Bool switch proto.action { - case .added: added = true - case .removed: added = false + case .added: (added, boosted) = (true, false) + case .removed: (added, boosted) = (false, false) + case .boosted: (added, boosted) = (true, true) case .unknown, .UNRECOGNIZED: return nil } guard let actor = try? UUID(data: proto.actor.value) else { return nil } @@ -109,9 +121,12 @@ extension DecodedReactionUpdate { emoji: proto.emoji.value, actor: actor, added: added, + boosted: boosted, count: proto.count, version: proto.version, - reactedAt: proto.hasReactedTs ? proto.reactedTs.date : nil + reactedAt: proto.hasReactedTs ? proto.reactedTs.date : nil, + boostUSD: proto.boostUsd, + actorBoostUSD: proto.actorBoostUsd ) } } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/HoldingsSummary.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/HoldingsSummary.swift new file mode 100644 index 000000000..99af66ef8 --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/HoldingsSummary.swift @@ -0,0 +1,40 @@ +// +// HoldingsSummary.swift +// FlipcashCore +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import FlipcashAPI + +/// What a group's members held of the currency the group is gated on, as of one moment. +public struct HoldingsSummary: Hashable, Sendable { + public let mint: PublicKey + /// The total the members held, in the mint's quarks. + public let quarks: UInt64 + /// How many members held any of the currency. + public let holderCount: UInt64 + /// When the server computed `quarks` and `holderCount`. + public let asOf: Date + + public init(mint: PublicKey, quarks: UInt64, holderCount: UInt64, asOf: Date) { + self.mint = mint + self.quarks = quarks + self.holderCount = holderCount + self.asOf = asOf + } +} + +extension HoldingsSummary { + /// Builds a summary from its proto, or nil when the mint does not parse. + init?(_ proto: Flipcash_Chat_V1_HoldingsSummary) { + guard let mint = try? PublicKey(proto.mint.value) else { return nil } + self.init( + mint: mint, + quarks: proto.quarks, + holderCount: proto.holderCount, + asOf: proto.hasAsOf ? proto.asOf.date : .distantPast + ) + } +} diff --git a/FlipcashCore/Sources/FlipcashCore/Models/IntentMetadata.swift b/FlipcashCore/Sources/FlipcashCore/Models/IntentMetadata.swift index 7982360a2..b301c593e 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/IntentMetadata.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/IntentMetadata.swift @@ -52,6 +52,11 @@ extension IntentMetadata { throw Error.unsupportedMetadataType } + case .sendPublicMultiPayment: + // Multi-payment intents are only issued for reaction boosts, which the app does not + // surface as a payment through the intent metadata poll. + throw Error.unsupportedMetadataType + case .publicDistribution: throw Error.unsupportedMetadataType } diff --git a/FlipcashCore/Tests/FlipcashCoreTests/IntentMultiTransferTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/IntentMultiTransferTests.swift new file mode 100644 index 000000000..6e6fac353 --- /dev/null +++ b/FlipcashCore/Tests/FlipcashCoreTests/IntentMultiTransferTests.swift @@ -0,0 +1,84 @@ +// +// IntentMultiTransferTests.swift +// FlipcashCoreTests +// + +import Foundation +import Testing +import FlipcashAPI +@testable import FlipcashCore + +@Suite("IntentMultiTransfer") +struct IntentMultiTransferTests { + + @Test("Emits one no-privacy transfer per payment, in order, from the source vault") + func emitsOneTransferPerPayment() throws { + let source = AccountCluster.mock + let payments = [makePayment(quarks: 800_000), makePayment(quarks: 200_000)] + let intent = try makeIntent(source: source, payments: payments) + + let actions = intent.actions.map { $0.action() } + #expect(actions.map(\.id) == [0, 1]) + #expect(actions.map(\.noPrivacyTransfer.amount) == [800_000, 200_000]) + #expect(actions.map(\.noPrivacyTransfer.source) == Array(repeating: source.vaultPublicKey.solanaAccountID, count: 2)) + #expect(actions.map(\.noPrivacyTransfer.destination) == payments.map { $0.destination.solanaAccountID }) + } + + @Test("Metadata carries source, mint, and each payment's client exchange data") + func metadataShape() throws { + let source = AccountCluster.mock + let payments = [makePayment(quarks: 800_000), makePayment(quarks: 200_000, owner: .generate()!)] + let intent = try makeIntent(source: source, payments: payments) + + let metadata = intent.metadata().sendPublicMultiPayment + #expect(metadata.source == source.vaultPublicKey.solanaAccountID) + #expect(metadata.mint == PublicKey.usdf.solanaAccountID) + #expect(metadata.payments.count == 2) + #expect(metadata.payments.map(\.clientExchangeData.quarks) == [800_000, 200_000]) + #expect(metadata.payments[0].hasDestinationOwner == false) + #expect(metadata.payments[1].hasDestinationOwner) + } + + @Test("Rejects fewer than two and more than ten payments") + func rejectsInvalidCounts() { + #expect(throws: IntentMultiTransfer.Error.invalidPaymentCount) { + try makeIntent(payments: [makePayment(quarks: 1)]) + } + #expect(throws: IntentMultiTransfer.Error.invalidPaymentCount) { + try makeIntent(payments: (0..<11).map { _ in makePayment(quarks: 1) }) + } + } + + @Test("Rejects a repeated destination") + func rejectsDuplicateDestination() { + let first = makePayment(quarks: 1) + #expect(throws: IntentMultiTransfer.Error.duplicateDestination) { + try makeIntent(payments: [first, first]) + } + } + + // MARK: - Helpers + + private func makePayment(quarks: UInt64, owner: PublicKey? = nil) -> IntentMultiTransfer.Payment { + IntentMultiTransfer.Payment( + destination: .generate()!, + destinationOwner: owner, + exchangedFiat: ExchangedFiat( + nativeAmount: FiatAmount.usd(Decimal(quarks) / 1_000_000), + rate: .oneToOne + ) + ) + } + + private func makeIntent(source: AccountCluster = .mock, payments: [IntentMultiTransfer.Payment]) throws -> IntentMultiTransfer { + var rate = Ocp_Currency_V1_VerifiedCoreMintFiatExchangeRate() + rate.exchangeRate.currencyCode = "usd" + rate.exchangeRate.exchangeRate = 1.0 + return try IntentMultiTransfer( + rendezvous: .generate()!, + sourceCluster: source, + payments: payments, + verifiedState: VerifiedState(rateProto: rate) + ) + } +} diff --git a/FlipcashCore/Tests/FlipcashCoreTests/IntentWithdrawTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/IntentWithdrawTests.swift index dfe5ac71c..51b901bb8 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/IntentWithdrawTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/IntentWithdrawTests.swift @@ -103,7 +103,7 @@ struct IntentWithdrawTests { switch metadata.type { case .sendPublicPayment: break - case .openAccounts, .receivePaymentsPublicly, .publicDistribution, .none: + case .openAccounts, .receivePaymentsPublicly, .publicDistribution, .sendPublicMultiPayment, .none: Issue.record("Expected sendPublicPayment metadata") return } diff --git a/FlipcashCore/Tests/FlipcashCoreTests/TransportClassificationTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/TransportClassificationTests.swift index 66ad132b8..14c2b4cda 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/TransportClassificationTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/TransportClassificationTests.swift @@ -55,6 +55,7 @@ struct TransportClassificationTests { @Test func errorSendEmailCode() { assertClassifies(ErrorSendEmailCode.self) } @Test func errorCheckEmailCode() { assertClassifies(ErrorCheckEmailCode.self) } @Test func errorUnlinkEmail() { assertClassifies(ErrorUnlinkEmail.self) } + @Test func errorGetCommunity() { assertClassifies(ErrorGetCommunity.self) } @Test func errorFetchProfile() { assertClassifies(ErrorFetchProfile.self) } @Test func errorUpdateFlipcard() { assertClassifies(ErrorUpdateFlipcard.self) } @Test func errorSetMinDmChatInitFee() { assertClassifies(ErrorSetMinDmChatInitFee.self) } From e3c4224c75f6274d696e7a194cf26cad70c498f7 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 16 Oct 2026 17:28:08 -0400 Subject: [PATCH 2/5] fix(payments): require destination owner on multi-payment intents IntentMultiTransfer.Payment.destinationOwner is now non-optional and is always written to the SendPublicMultiPayment metadata, since the server requires it when a destination PRIMARY is auto-opened. Client.transferMultiple takes a non-optional owner too. The intent now also rejects a destination owner equal to the source authority (destinationIsSource) and any payment whose native currency differs from the verified rate's currency (currencyMismatch, compared case-insensitively). Tests cover owner presence, appMetadata set/absent, duplicate owner, owner equal to source authority, destination equal to source vault, mint mismatch, and currency mismatch. --- .../Payments API/Client+Transaction.swift | 2 +- .../Intents/IntentMultiTransfer.swift | 26 +++-- .../IntentMultiTransferTests.swift | 101 ++++++++++++++++-- 3 files changed, 112 insertions(+), 17 deletions(-) diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Payments API/Client+Transaction.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Payments API/Client+Transaction.swift index d4c0f3374..91ee37078 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Payments API/Client+Transaction.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Payments API/Client+Transaction.swift @@ -63,7 +63,7 @@ extension Client { /// Pays two to ten recipients from `owner` in one intent. Not yet called by the app; the /// reaction-boost flow is its first consumer. - public func transferMultiple(payments: [(destination: PublicKey, destinationOwner: PublicKey?, exchangedFiat: ExchangedFiat)], verifiedState: VerifiedState, owner: AccountCluster, chatMetadata: ChatPaymentMetadata? = nil, rendezvous: PublicKey) async throws { + public func transferMultiple(payments: [(destination: PublicKey, destinationOwner: PublicKey, exchangedFiat: ExchangedFiat)], verifiedState: VerifiedState, owner: AccountCluster, chatMetadata: ChatPaymentMetadata? = nil, rendezvous: PublicKey) async throws { let appMetadata = try chatMetadata?.serializedAppMetadata() let payments = payments.map { IntentMultiTransfer.Payment(destination: $0.destination, destinationOwner: $0.destinationOwner, exchangedFiat: $0.exchangedFiat) diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Payments API/Intents/IntentMultiTransfer.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Payments API/Intents/IntentMultiTransfer.swift index cca2a70e2..edcfc7e74 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Payments API/Intents/IntentMultiTransfer.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Payments API/Intents/IntentMultiTransfer.swift @@ -20,9 +20,9 @@ final class IntentMultiTransfer: IntentType { struct Payment { /// The recipient's PRIMARY account for the intent's mint. let destination: PublicKey - /// The recipient's owner authority. The server requires it only when `destination` is - /// auto-opened by this intent; sending it always is harmless. - let destinationOwner: PublicKey? + /// The recipient's owner authority. The server requires it when `destination` (a PRIMARY + /// account) is auto-opened by this intent; we always send it. + let destinationOwner: PublicKey let exchangedFiat: ExchangedFiat } @@ -31,8 +31,13 @@ final class IntentMultiTransfer: IntentType { case invalidPaymentCount /// Every payment must pay the same mint. case mintMismatch - /// Destinations, and their owners, must be unique and must differ from the source. + /// Destinations, and their owners, must each be unique, and no destination may be the + /// source vault. case duplicateDestination + /// A destination owner must differ from the source's owner authority. + case destinationIsSource + /// Every payment must use one currency, the verified rate's. + case currencyMismatch } static let paymentCountRange = 2...10 @@ -61,10 +66,17 @@ final class IntentMultiTransfer: IntentType { guard Set(destinations).count == destinations.count else { throw Error.duplicateDestination } - let owners = payments.compactMap(\.destinationOwner) + let owners = payments.map(\.destinationOwner) guard Set(owners).count == owners.count else { throw Error.duplicateDestination } + guard !owners.contains(sourceCluster.authorityPublicKey) else { + throw Error.destinationIsSource + } + let verifiedCurrency = verifiedState.rateProto.exchangeRate.currencyCode.lowercased() + guard payments.allSatisfy({ $0.exchangedFiat.nativeAmount.currency.rawValue.lowercased() == verifiedCurrency }) else { + throw Error.currencyMismatch + } self.id = rendezvous self.sourceCluster = sourceCluster @@ -96,9 +108,7 @@ extension IntentMultiTransfer { $0.payments = payments.map { payment in .with { $0.destination = payment.destination.solanaAccountID - if let destinationOwner = payment.destinationOwner { - $0.destinationOwner = destinationOwner.solanaAccountID - } + $0.destinationOwner = payment.destinationOwner.solanaAccountID $0.clientExchangeData = .with { $0.mint = payment.exchangedFiat.mint.solanaAccountID $0.quarks = payment.exchangedFiat.onChainAmount.quarks diff --git a/FlipcashCore/Tests/FlipcashCoreTests/IntentMultiTransferTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/IntentMultiTransferTests.swift index 6e6fac353..524bf29e4 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/IntentMultiTransferTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/IntentMultiTransferTests.swift @@ -27,7 +27,7 @@ struct IntentMultiTransferTests { @Test("Metadata carries source, mint, and each payment's client exchange data") func metadataShape() throws { let source = AccountCluster.mock - let payments = [makePayment(quarks: 800_000), makePayment(quarks: 200_000, owner: .generate()!)] + let payments = [makePayment(quarks: 800_000), makePayment(quarks: 200_000)] let intent = try makeIntent(source: source, payments: payments) let metadata = intent.metadata().sendPublicMultiPayment @@ -35,8 +35,29 @@ struct IntentMultiTransferTests { #expect(metadata.mint == PublicKey.usdf.solanaAccountID) #expect(metadata.payments.count == 2) #expect(metadata.payments.map(\.clientExchangeData.quarks) == [800_000, 200_000]) - #expect(metadata.payments[0].hasDestinationOwner == false) - #expect(metadata.payments[1].hasDestinationOwner) + } + + @Test("Every payment carries its destination owner") + func carriesDestinationOwners() throws { + let payments = [makePayment(quarks: 800_000), makePayment(quarks: 200_000)] + let intent = try makeIntent(payments: payments) + + let metadata = intent.metadata().sendPublicMultiPayment + #expect(metadata.payments.allSatisfy { $0.hasDestinationOwner }) + #expect(metadata.payments.map(\.destinationOwner) == payments.map { $0.destinationOwner.solanaAccountID }) + } + + @Test("appMetadata is set when given and absent when not") + func appMetadata() throws { + let payments = [makePayment(quarks: 1), makePayment(quarks: 1)] + let bytes = Data([1, 2, 3]) + + let with = try makeIntent(payments: payments, appMetadata: bytes) + #expect(with.metadata().hasAppMetadata) + #expect(with.metadata().appMetadata.value == bytes) + + let without = try makeIntent(payments: payments) + #expect(without.metadata().hasAppMetadata == false) } @Test("Rejects fewer than two and more than ten payments") @@ -57,20 +78,83 @@ struct IntentMultiTransferTests { } } + @Test("Rejects a repeated destination owner") + func rejectsDuplicateOwner() { + let owner = PublicKey.generate()! + #expect(throws: IntentMultiTransfer.Error.duplicateDestination) { + try makeIntent(payments: [makePayment(quarks: 1, owner: owner), makePayment(quarks: 1, owner: owner)]) + } + } + + @Test("Rejects a destination owner equal to the source authority") + func rejectsOwnerEqualToSource() { + let source = AccountCluster.mock + #expect(throws: IntentMultiTransfer.Error.destinationIsSource) { + try makeIntent(source: source, payments: [ + makePayment(quarks: 1, owner: source.authorityPublicKey), + makePayment(quarks: 1), + ]) + } + } + + @Test("Rejects a destination equal to the source vault") + func rejectsDestinationEqualToSourceVault() { + let source = AccountCluster.mock + #expect(throws: IntentMultiTransfer.Error.duplicateDestination) { + try makeIntent(source: source, payments: [ + makePayment(quarks: 1, destination: source.vaultPublicKey), + makePayment(quarks: 1), + ]) + } + } + + @Test("Rejects payments in different mints") + func rejectsMintMismatch() { + let other = makePayment(quarks: 1, fiat: ExchangedFiat( + onChainAmount: TokenAmount(wholeTokens: 1, mint: .generate()!), + nativeAmount: FiatAmount.usd(1), + currencyRate: .oneToOne + )) + #expect(throws: IntentMultiTransfer.Error.mintMismatch) { + try makeIntent(payments: [makePayment(quarks: 1), other]) + } + } + + @Test("Rejects payments whose currency differs from the other payments or the verified rate") + func rejectsCurrencyMismatch() { + let cadFiat = ExchangedFiat( + nativeAmount: FiatAmount(value: 1, currency: .cad), + rate: Rate(fx: 1, currency: .cad) + ) + let cad = makePayment(quarks: 1, fiat: cadFiat) + #expect(throws: IntentMultiTransfer.Error.currencyMismatch) { + try makeIntent(payments: [makePayment(quarks: 1), cad]) + } + // Both payments agree with each other but not with the verified (usd) rate. + #expect(throws: IntentMultiTransfer.Error.currencyMismatch) { + try makeIntent(payments: [cad, makePayment(quarks: 1, fiat: cadFiat)]) + } + } + // MARK: - Helpers - private func makePayment(quarks: UInt64, owner: PublicKey? = nil) -> IntentMultiTransfer.Payment { + private func makePayment( + quarks: UInt64, + destination: PublicKey = .generate()!, + owner: PublicKey = .generate()!, + fiat: ExchangedFiat? = nil + ) -> IntentMultiTransfer.Payment { IntentMultiTransfer.Payment( - destination: .generate()!, + destination: destination, destinationOwner: owner, - exchangedFiat: ExchangedFiat( + exchangedFiat: fiat ?? ExchangedFiat( nativeAmount: FiatAmount.usd(Decimal(quarks) / 1_000_000), rate: .oneToOne ) ) } - private func makeIntent(source: AccountCluster = .mock, payments: [IntentMultiTransfer.Payment]) throws -> IntentMultiTransfer { + private func makeIntent(source: AccountCluster = .mock, payments: [IntentMultiTransfer.Payment], appMetadata: Data? = nil) throws -> IntentMultiTransfer { var rate = Ocp_Currency_V1_VerifiedCoreMintFiatExchangeRate() rate.exchangeRate.currencyCode = "usd" rate.exchangeRate.exchangeRate = 1.0 @@ -78,7 +162,8 @@ struct IntentMultiTransferTests { rendezvous: .generate()!, sourceCluster: source, payments: payments, - verifiedState: VerifiedState(rateProto: rate) + verifiedState: VerifiedState(rateProto: rate), + appMetadata: appMetadata ) } } From ee1b1d1a40b910d7d1df3c14821365eb8f4d3e87 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 16 Oct 2026 17:51:51 -0400 Subject: [PATCH 3/5] chore(deps): bump ocp-client-protocol to 0.8.0 --- .../xcshareddata/swiftpm/Package.resolved | 6 +++--- FlipcashAPI/Package.swift | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 1f2701f99..e86dee4b7 100644 --- a/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "d61d3d0a8bd1078b5f85476541742804facb4c56b3b33fa2f04e283ed039e8be", + "originHash" : "44c64715516fa049f4ec78036ea21f0acae42d360ae655fde887e6a92f4c7d48", "pins" : [ { "identity" : "abseil-cpp-binary", @@ -213,8 +213,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/code-payments/ocp-client-protocol", "state" : { - "revision" : "756e3724c217f556d9cb8799037f5ff8a216ac06", - "version" : "0.7.0" + "revision" : "01b4f913f076e03732d7aed0e19a689cce14ab07", + "version" : "0.8.0" } }, { diff --git a/FlipcashAPI/Package.swift b/FlipcashAPI/Package.swift index 9435e29ee..271c24be6 100644 --- a/FlipcashAPI/Package.swift +++ b/FlipcashAPI/Package.swift @@ -55,7 +55,7 @@ enum ContractPackage: String, CaseIterable { /// The pinned version consumed when this package isn't building against a local checkout. var version: Version { switch self { - case .ocp: return "0.7.0" + case .ocp: return "0.8.0" case .flipcash2: return "0.19.0" } } From 949756ad537f47dab49bc856489cbdfa9861e396 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 16 Oct 2026 17:52:08 -0400 Subject: [PATCH 4/5] chore(deps): bump flipcash2-client-protocol to 0.20.0 --- .../xcshareddata/swiftpm/Package.resolved | 6 +++--- FlipcashAPI/Package.swift | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index e86dee4b7..bce729fed 100644 --- a/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "44c64715516fa049f4ec78036ea21f0acae42d360ae655fde887e6a92f4c7d48", + "originHash" : "967b87f053de503ec7757ad7d9e6ebc7bb6a3b87ae116109d6b2630591c572f0", "pins" : [ { "identity" : "abseil-cpp-binary", @@ -87,8 +87,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/code-payments/flipcash2-client-protocol", "state" : { - "revision" : "3f3e29b416a50047854984a5155c136c6489d455", - "version" : "0.19.0" + "revision" : "f0bc47eefd9c8d2074f229c91d3a950f4bed1b97", + "version" : "0.20.0" } }, { diff --git a/FlipcashAPI/Package.swift b/FlipcashAPI/Package.swift index 271c24be6..7912cf3b8 100644 --- a/FlipcashAPI/Package.swift +++ b/FlipcashAPI/Package.swift @@ -56,7 +56,7 @@ enum ContractPackage: String, CaseIterable { var version: Version { switch self { case .ocp: return "0.8.0" - case .flipcash2: return "0.19.0" + case .flipcash2: return "0.20.0" } } From 2c8a5b33b0e7ffab79f83bbba454d4059766bc35 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 16 Oct 2026 19:28:01 -0400 Subject: [PATCH 5/5] test(payments): gate multi-payment intent against shared vectors Add MultiPaymentIntentVectorTests, which builds IntentMultiTransfer from multi_payment_intent.json and asserts actions, compact messages, SHA-256, ed25519 signatures, proto metadata, and the eight rejection cases. The vectors expect destination == source vault and a repeated destination owner to be distinct rules from the others, so IntentMultiTransfer.Error gains duplicateDestinationOwner and a destination equal to the source vault now throws destinationIsSource instead of duplicateDestination. Add a repo-root .swift-version containing "xcode" so swiftly resolves to the Xcode toolchain; a swiftly 6.2.1 toolchain fails to build FlipcashCore/Package.swift against this Xcode's SDK. --- .swift-version | 1 + .../Intents/IntentMultiTransfer.swift | 15 +- .../IntentMultiTransferTests.swift | 4 +- .../Fixtures/multi_payment_intent.json | 767 ++++++++++++++++++ .../MultiPaymentIntentVectorTests.swift | 279 +++++++ 5 files changed, 1059 insertions(+), 7 deletions(-) create mode 100644 .swift-version create mode 100644 FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/Fixtures/multi_payment_intent.json create mode 100644 FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/MultiPaymentIntentVectorTests.swift diff --git a/.swift-version b/.swift-version new file mode 100644 index 000000000..7e58507ed --- /dev/null +++ b/.swift-version @@ -0,0 +1 @@ +xcode diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Payments API/Intents/IntentMultiTransfer.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Payments API/Intents/IntentMultiTransfer.swift index edcfc7e74..e91729809 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Payments API/Intents/IntentMultiTransfer.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Payments API/Intents/IntentMultiTransfer.swift @@ -31,10 +31,12 @@ final class IntentMultiTransfer: IntentType { case invalidPaymentCount /// Every payment must pay the same mint. case mintMismatch - /// Destinations, and their owners, must each be unique, and no destination may be the - /// source vault. + /// Destinations must be unique. case duplicateDestination - /// A destination owner must differ from the source's owner authority. + /// Destination owners must be unique. + case duplicateDestinationOwner + /// No destination may be the source vault, and no destination owner may be the source's + /// owner authority. case destinationIsSource /// Every payment must use one currency, the verified rate's. case currencyMismatch @@ -62,13 +64,16 @@ final class IntentMultiTransfer: IntentType { guard payments.allSatisfy({ $0.exchangedFiat.mint == mint }) else { throw Error.mintMismatch } - let destinations = payments.map(\.destination) + [sourceCluster.vaultPublicKey] + let destinations = payments.map(\.destination) guard Set(destinations).count == destinations.count else { throw Error.duplicateDestination } + guard !destinations.contains(sourceCluster.vaultPublicKey) else { + throw Error.destinationIsSource + } let owners = payments.map(\.destinationOwner) guard Set(owners).count == owners.count else { - throw Error.duplicateDestination + throw Error.duplicateDestinationOwner } guard !owners.contains(sourceCluster.authorityPublicKey) else { throw Error.destinationIsSource diff --git a/FlipcashCore/Tests/FlipcashCoreTests/IntentMultiTransferTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/IntentMultiTransferTests.swift index 524bf29e4..b084461e2 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/IntentMultiTransferTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/IntentMultiTransferTests.swift @@ -81,7 +81,7 @@ struct IntentMultiTransferTests { @Test("Rejects a repeated destination owner") func rejectsDuplicateOwner() { let owner = PublicKey.generate()! - #expect(throws: IntentMultiTransfer.Error.duplicateDestination) { + #expect(throws: IntentMultiTransfer.Error.duplicateDestinationOwner) { try makeIntent(payments: [makePayment(quarks: 1, owner: owner), makePayment(quarks: 1, owner: owner)]) } } @@ -100,7 +100,7 @@ struct IntentMultiTransferTests { @Test("Rejects a destination equal to the source vault") func rejectsDestinationEqualToSourceVault() { let source = AccountCluster.mock - #expect(throws: IntentMultiTransfer.Error.duplicateDestination) { + #expect(throws: IntentMultiTransfer.Error.destinationIsSource) { try makeIntent(source: source, payments: [ makePayment(quarks: 1, destination: source.vaultPublicKey), makePayment(quarks: 1), diff --git a/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/Fixtures/multi_payment_intent.json b/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/Fixtures/multi_payment_intent.json new file mode 100644 index 000000000..174506e2e --- /dev/null +++ b/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/Fixtures/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/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/MultiPaymentIntentVectorTests.swift b/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/MultiPaymentIntentVectorTests.swift new file mode 100644 index 000000000..a944a8a65 --- /dev/null +++ b/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/MultiPaymentIntentVectorTests.swift @@ -0,0 +1,279 @@ +import Foundation +import Testing +import CryptoKit +import FlipcashAPI +@testable import FlipcashCore + +/// `test-vectors/multi_payment_intent.json`: `IntentMultiTransfer` must emit one no-privacy +/// transfer per payment, in order, and each action must sign the same compact message the +/// Android builder signs. The canonical copy lives in the orchestrator repo; this one is synced. +/// Never edit it locally. +/// +/// The intent is built through the production type. The fixture's source vault is 32 copies of a +/// seed byte, which no timelock derivation produces, so the cluster is the real one for +/// `signerSeed` with its vault replaced through its `Codable` form. +@Suite struct MultiPaymentIntentVectorTests { + + struct VerifiedStateFixture: Decodable, Sendable { + let currency: String + let rate: String + } + + /// `destinationOwnerSeed` is an integer, or the string "sourceAuthority". + enum OwnerSeed: Decodable, Sendable { + case seed(Int) + case sourceAuthority + + init(from decoder: Decoder) throws { + let c = try decoder.singleValueContainer() + if let seed = try? c.decode(Int.self) { self = .seed(seed) } else { + let name = try c.decode(String.self) + guard name == "sourceAuthority" else { + throw DecodingError.dataCorruptedError(in: c, debugDescription: "unknown owner \(name)") + } + self = .sourceAuthority + } + } + } + + struct Payment: Decodable, Sendable { + let destinationSeed: Int + let destinationOwnerSeed: OwnerSeed + let quarks: String + let mintSeed: Int + let currency: String + let nativeAmount: String + } + + struct Action: Decodable, Sendable { + let id: Int + let sourceSeed: Int + let authority: String + let destinationSeed: Int + let mintSeed: Int + let amount: String + let nonceSeed: Int + let nonceValueSeed: Int + let message: String + let sha256: String + let signature: String + } + + struct ExpectedMetadata: Decodable, Sendable { + struct Payment: Decodable, Sendable { + let destinationSeed: Int + let destinationOwnerSeed: Int + let quarks: String + let nativeAmount: String + let currency: String + } + let sourceSeed: Int + let mintSeed: Int + let payments: [Payment] + } + + struct Vector: Decodable, Sendable, CustomTestStringConvertible { + let name: String + let sourceVaultSeed: Int + let sourceAuthority: String + let signerSeed: String + let mintSeed: Int + let verifiedState: VerifiedStateFixture + let payments: [Payment] + let expectedActions: [Action] + let expectedMetadata: ExpectedMetadata + var testDescription: String { name } + } + + struct Rejection: Decodable, Sendable, CustomTestStringConvertible { + let name: String + let sourceVaultSeed: Int + let sourceAuthority: String + let signerSeed: String + let mintSeed: Int + let verifiedState: VerifiedStateFixture + let payments: [Payment] + let expectedError: String + var testDescription: String { name } + } + + struct Fixture: Decodable { + let vectors: [Vector] + let rejections: [Rejection] + } + + static let vectors: [Vector] = (try? loadFixture().vectors) ?? [] + static let rejections: [Rejection] = (try? loadFixture().rejections) ?? [] + + private static func loadFixture() throws -> Fixture { + let url = try #require( + Bundle.module.url(forResource: "multi_payment_intent", withExtension: "json", subdirectory: "Fixtures") + ) + return try JSONDecoder().decode(Fixture.self, from: Data(contentsOf: url)) + } + + @Test func fixtureLoads() throws { + let fixture = try Self.loadFixture() + #expect(!fixture.vectors.isEmpty && !fixture.rejections.isEmpty) + } + + @Test(arguments: vectors) + func intentMatchesCanonicalVector(_ v: Vector) throws { + let (source, intent) = try Self.makeIntent( + signerSeed: v.signerSeed, sourceAuthority: v.sourceAuthority, sourceVaultSeed: v.sourceVaultSeed, + mintSeed: v.mintSeed, verifiedState: v.verifiedState, payments: v.payments + ) + + // The server returns one nonce and blockhash per action. + try intent.apply(parameters: v.expectedActions.map { + ServerParameter( + actionID: $0.id, + parameter: nil, + configs: [.init(nonce: try Self.key($0.nonceSeed), blockhash: try Self.key($0.nonceValueSeed))] + ) + }) + + // Actions: count, then id, source, authority, mint, destination and amount in order. + let actions = intent.actions.map { $0.action() } + #expect(actions.count == v.expectedActions.count) + for (action, expected) in zip(actions, v.expectedActions) { + let transfer = action.noPrivacyTransfer + #expect(Int(action.id) == expected.id) + #expect(transfer.source == (try Self.key(expected.sourceSeed)).solanaAccountID) + #expect(transfer.source == source.vaultPublicKey.solanaAccountID) + #expect(transfer.authority.value.hexString == expected.authority) + #expect(transfer.mint == (try Self.key(expected.mintSeed)).solanaAccountID) + #expect(transfer.destination == (try Self.key(expected.destinationSeed)).solanaAccountID) + #expect(String(transfer.amount) == expected.amount) + } + + // Signing: message bytes, hash and signature per action. + let signatures = try intent.signatures() + #expect(signatures.count == v.expectedActions.count) + for (index, expected) in v.expectedActions.enumerated() { + let messages = try intent.actions[index].compactMessages() + #expect(messages.count == 1) + let message = try #require(messages.first).data + #expect(message.hexString == expected.message, "message bytes mismatch for action \(expected.id)") + #expect(Data(CryptoKit.SHA256.hash(data: message)).hexString == expected.sha256, "sha256 mismatch for action \(expected.id)") + #expect(signatures[index].data.hexString == expected.signature, "signature mismatch for action \(expected.id)") + } + + // Metadata. + let expectedMetadata = v.expectedMetadata + let metadata = intent.metadata().sendPublicMultiPayment + #expect(metadata.source == (try Self.key(expectedMetadata.sourceSeed)).solanaAccountID) + #expect(metadata.mint == (try Self.key(expectedMetadata.mintSeed)).solanaAccountID) + #expect(metadata.payments.count == expectedMetadata.payments.count) + for (proto, expected) in zip(metadata.payments, expectedMetadata.payments) { + #expect(proto.destination == (try Self.key(expected.destinationSeed)).solanaAccountID) + #expect(proto.hasDestinationOwner) + #expect(proto.destinationOwner == (try Self.key(expected.destinationOwnerSeed)).solanaAccountID) + #expect(String(proto.clientExchangeData.quarks) == expected.quarks) + // `native_amount` is a double on the wire; `Decimal.doubleValue` divides by ten per + // digit, so it can land an ulp or so from the correctly rounded value. + let native = try #require(Double(expected.nativeAmount)) + #expect(abs(proto.clientExchangeData.nativeAmount - native) <= native * 1e-12, + "native \(proto.clientExchangeData.nativeAmount) vs \(expected.nativeAmount)") + #expect(proto.clientExchangeData.coreMintFiatExchangeRate.exchangeRate.currencyCode == expected.currency) + } + } + + @Test(arguments: rejections) + func builderRejectsInvalidIntent(_ r: Rejection) throws { + let thrown = #expect(throws: IntentMultiTransfer.Error.self) { + try Self.makeIntent( + signerSeed: r.signerSeed, sourceAuthority: r.sourceAuthority, sourceVaultSeed: r.sourceVaultSeed, + mintSeed: r.mintSeed, verifiedState: r.verifiedState, payments: r.payments + ) + } + let rule: String? = switch thrown { + case .invalidPaymentCount: "count" + case .mintMismatch: "mintMismatch" + case .duplicateDestination: "duplicateDestination" + case .duplicateDestinationOwner: "duplicateDestinationOwner" + case .destinationIsSource: "destinationIsSource" + case .currencyMismatch: "currencyMismatch" + case nil: nil + } + #expect(rule == r.expectedError) + } + + // MARK: - Helpers + + private static func makeIntent( + signerSeed: String, + sourceAuthority: String, + sourceVaultSeed: Int, + mintSeed: Int, + verifiedState: VerifiedStateFixture, + payments fixturePayments: [Payment] + ) throws -> (AccountCluster, IntentMultiTransfer) { + let signer = KeyPair(seed: try Seed32(Self.bytes(hex: signerSeed))) + #expect(signer.publicKey.data.hexString == sourceAuthority) + + let source = try Self.cluster(signer: signer, mint: try Self.key(mintSeed), vault: try Self.key(sourceVaultSeed)) + #expect(source.authorityPublicKey.data.hexString == sourceAuthority) + + let payments = try fixturePayments.map { p -> IntentMultiTransfer.Payment in + let currency = try CurrencyCode(currencyCode: p.currency) + let owner: PublicKey = switch p.destinationOwnerSeed { + case .seed(let seed): try Self.key(seed) + case .sourceAuthority: source.authorityPublicKey + } + return IntentMultiTransfer.Payment( + destination: try Self.key(p.destinationSeed), + destinationOwner: owner, + exchangedFiat: ExchangedFiat( + onChainAmount: TokenAmount(quarks: try #require(UInt64(p.quarks)), mint: try Self.key(p.mintSeed)), + nativeAmount: FiatAmount(value: try #require(Decimal(string: p.nativeAmount)), currency: currency), + currencyRate: Rate(fx: 1, currency: currency) + ) + ) + } + + var rate = Ocp_Currency_V1_VerifiedCoreMintFiatExchangeRate() + rate.exchangeRate.currencyCode = verifiedState.currency + rate.exchangeRate.exchangeRate = try #require(Double(verifiedState.rate)) + let intent = try IntentMultiTransfer( + rendezvous: .generate()!, + sourceCluster: source, + payments: payments, + verifiedState: VerifiedState(rateProto: rate) + ) + return (source, intent) + } + + private static func key(_ seed: Int) throws -> PublicKey { + try PublicKey([UInt8](repeating: UInt8(seed), count: 32)) + } + + private static func bytes(hex: String) -> [UInt8] { + stride(from: 0, to: hex.count, by: 2).map { + let start = hex.index(hex.startIndex, offsetBy: $0) + return UInt8(hex[start.. AccountCluster { + let derived = AccountCluster( + authority: DerivedKey(path: .primary(), keyPair: signer), + mint: mint, + timeAuthority: .usdcAuthority + ) + let encoder = JSONEncoder() + var json = try #require(JSONSerialization.jsonObject(with: encoder.encode(derived)) as? [String: Any]) + var timelock = try #require(json["timelock"] as? [String: Any]) + var vaultJSON = try #require(timelock["vault"] as? [String: Any]) + let wrapped = try #require(JSONSerialization.jsonObject(with: encoder.encode([vault])) as? [Any]) + vaultJSON["publicKey"] = try #require(wrapped.first) + timelock["vault"] = vaultJSON + json["timelock"] = timelock + return try JSONDecoder().decode(AccountCluster.self, from: JSONSerialization.data(withJSONObject: json)) + } +} + +private extension Data { + var hexString: String { map { String(format: "%02x", $0) }.joined() } +}