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
Original file line number Diff line number Diff line change
Expand Up @@ -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<Void, Never>?

var body: some View {
VStack(alignment: .leading, spacing: 16) {
Expand Down Expand Up @@ -112,6 +114,37 @@ struct TransferDashboardView: View {
selectedToken = availableTokens.first
}
})
.onAppear {
idempotencyKeyTask?.cancel()
startIdempotencyKeyRotation()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this hook can fire multiple times, might want to change to

idempotencyKeyTask?.cancel()
startIdempotencyKeyRotation()

to prevent multiple key rotation tasks

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch! Fixed - now cancelling any existing task before starting a new one in onAppear to prevent multiple rotation loops.

}
.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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

might want to also trigger changing currentIdempotencyKey any time recipient, token locator, or amount is changed

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great suggestion! Added onChange handlers for amount, recipientWallet, and selectedToken that reset the idempotency key when any of these parameters change. This makes sense semantically since a different recipient/token/amount represents a new transaction that should have its own idempotency key.

}
}
}
}

private func triggerTransaction() async {
Expand All @@ -137,7 +170,8 @@ struct TransferDashboardView: View {
let summary = try await wallet.send(
recipientWallet,
"solana:\(selectedToken)",
amount
amount,
idempotencyKey: currentIdempotencyKey
)

await MainActor.run {
Expand Down
7 changes: 5 additions & 2 deletions Sources/Wallet/Data/DefaultSmartWalletService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
3 changes: 2 additions & 1 deletion Sources/Wallet/Data/SmartWalletService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
19 changes: 15 additions & 4 deletions Sources/Wallet/Model/Wallet/Wallet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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"
Expand Down Expand Up @@ -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)
Expand Down
Loading