diff --git a/Examples/SolanaDemo/SolanaDemo/Dashboard/TransferDashboardView.swift b/Examples/SolanaDemo/SolanaDemo/Dashboard/TransferDashboardView.swift index f9fc8a6..c2a80ae 100644 --- a/Examples/SolanaDemo/SolanaDemo/Dashboard/TransferDashboardView.swift +++ b/Examples/SolanaDemo/SolanaDemo/Dashboard/TransferDashboardView.swift @@ -16,6 +16,8 @@ struct TransferDashboardView: View { @State private var availableTokens: [SolanaSupportedToken] = [] @State private var showTokenSelectionMenu: Bool = false @State private var isSendingTransaction: Bool = false + @State private var currentIdempotencyKey: String = UUID().uuidString + @State private var idempotencyKeyTask: Task? var body: some View { VStack(alignment: .leading, spacing: 16) { @@ -112,6 +114,37 @@ struct TransferDashboardView: View { selectedToken = availableTokens.first } }) + .onAppear { + idempotencyKeyTask?.cancel() + startIdempotencyKeyRotation() + } + .onDisappear { + idempotencyKeyTask?.cancel() + } + .onChange(of: amount) { _, _ in + resetIdempotencyKey() + } + .onChange(of: recipientWallet) { _, _ in + resetIdempotencyKey() + } + .onChange(of: selectedToken) { _, _ in + resetIdempotencyKey() + } + } + + private func resetIdempotencyKey() { + currentIdempotencyKey = UUID().uuidString + } + + private func startIdempotencyKeyRotation() { + idempotencyKeyTask = Task { + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 30_000_000_000) + await MainActor.run { + currentIdempotencyKey = UUID().uuidString + } + } + } } private func triggerTransaction() async { @@ -137,7 +170,8 @@ struct TransferDashboardView: View { let summary = try await wallet.send( recipientWallet, "solana:\(selectedToken)", - amount + amount, + idempotencyKey: currentIdempotencyKey ) await MainActor.run { diff --git a/Sources/Wallet/Data/DefaultSmartWalletService.swift b/Sources/Wallet/Data/DefaultSmartWalletService.swift index 6d668fa..434d1e6 100644 --- a/Sources/Wallet/Data/DefaultSmartWalletService.swift +++ b/Sources/Wallet/Data/DefaultSmartWalletService.swift @@ -187,7 +187,8 @@ public final class DefaultSmartWalletService: SmartWalletService { chainType: String, tokenLocator: String, recipient: String, - amount: String + amount: String, + idempotencyKey: String? = nil ) async throws(TransactionError) -> any TransactionApiModel { struct Body: Encodable { let recipient: String @@ -198,10 +199,12 @@ public final class DefaultSmartWalletService: SmartWalletService { recipient: recipient, amount: amount ) + var headers = await authHeaders + headers["x-idempotency-key"] = idempotencyKey ?? UUID().uuidString let endpoint = Endpoint( path: "/2025-06-09/wallets/me:\(chainType)/tokens/\(tokenLocator)/transfers", method: .post, - headers: await authHeaders, + headers: headers, body: try jsonCoder.encodeRequest( body, errorType: TransactionError.self diff --git a/Sources/Wallet/Data/SmartWalletService.swift b/Sources/Wallet/Data/SmartWalletService.swift index b1e7198..dccf0c1 100644 --- a/Sources/Wallet/Data/SmartWalletService.swift +++ b/Sources/Wallet/Data/SmartWalletService.swift @@ -42,7 +42,8 @@ public protocol SmartWalletService: AuthenticatedService, Sendable { chainType: String, tokenLocator: String, recipient: String, - amount: String + amount: String, + idempotencyKey: String? ) async throws(TransactionError) -> any TransactionApiModel func createSignature( diff --git a/Sources/Wallet/Model/Wallet/Wallet.swift b/Sources/Wallet/Model/Wallet/Wallet.swift index f14c5f9..8461991 100644 --- a/Sources/Wallet/Model/Wallet/Wallet.swift +++ b/Sources/Wallet/Model/Wallet/Wallet.swift @@ -169,10 +169,18 @@ open class Wallet: @unchecked Sendable { return transaction } + /// Sends tokens to a recipient. + /// - Parameters: + /// - walletLocator: The recipient wallet address + /// - tokenLocator: Token identifier in format "{chain}:{token}" (e.g., "base-sepolia:eth", "solana:usdc") + /// - amount: The amount to send as a decimal number + /// - idempotencyKey: Optional unique key to prevent duplicate transaction creation. If not provided, a random UUID will be generated. + /// - Returns: A TransactionSummary containing the transaction details public func send( _ walletLocator: String, _ tokenLocator: String, - _ amount: Double + _ amount: Double, + idempotencyKey: String? = nil ) async throws(TransactionError) -> TransactionSummary { Logger.smartWallet.debug(LogEvents.walletSendStart, attributes: [ "recipient": walletLocator, @@ -183,7 +191,8 @@ open class Wallet: @unchecked Sendable { guard let transaction = try await transferTokenAndPollWhilePending( tokenLocator: tokenLocator, recipient: walletLocator, - amount: String(amount) + amount: String(amount), + idempotencyKey: idempotencyKey )?.toCompleted() else { Logger.smartWallet.error(LogEvents.walletSendError, attributes: [ "error": "Unknown error" @@ -252,14 +261,16 @@ Transaction ID: \(createdTransaction?.id ?? "unknown") internal func transferTokenAndPollWhilePending( tokenLocator: String, recipient: String, - amount: String + amount: String, + idempotencyKey: String? = nil ) async throws(TransactionError) -> Transaction? { onTransactionStart?() let createdTransaction = try await smartWalletService.transferToken( chainType: chain.chainType.rawValue, tokenLocator: tokenLocator, recipient: recipient, - amount: amount + amount: amount, + idempotencyKey: idempotencyKey ).toDomain(withService: smartWalletService) let signedTransaction = try await signTransactionIfRequired(createdTransaction)