Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .swift-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
xcode

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions FlipcashAPI/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,8 @@ 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 .flipcash2: return "0.19.0"
case .ocp: return "0.8.0"
case .flipcash2: return "0.20.0"
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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) }
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
}
Expand All @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<AppTransport>

init(client: GRPCClient<AppTransport>) {
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<Community, ErrorGetCommunity>) -> 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<Community, ErrorGetCommunity> {
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
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
//
// 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 when `destination` (a PRIMARY
/// account) is auto-opened by this intent; we always send it.
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 must be unique.
case duplicateDestination
/// 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
}

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)
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.duplicateDestinationOwner
}
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
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
$0.destinationOwner = payment.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 }
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
Loading
Loading