diff --git a/Package.resolved b/Package.resolved index 61972371..7bbda514 100644 --- a/Package.resolved +++ b/Package.resolved @@ -41,8 +41,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/xmtp/libxmtp-swift", "state" : { - "revision" : "f8c4be0d591671067c8e7772b7c402931f33ed54", - "version" : "0.4.3-beta1" + "revision" : "b129e203957b690cbab91c165af90ddb5fa8bdb5", + "version" : "0.4.3-beta3" } }, { diff --git a/Sources/XMTPiOS/Frames/FramesClient.swift b/Sources/XMTPiOS/Frames/FramesClient.swift new file mode 100644 index 00000000..e234e53b --- /dev/null +++ b/Sources/XMTPiOS/Frames/FramesClient.swift @@ -0,0 +1,108 @@ +// +// FramesClient.swift +// +// +// Created by Alex Risch on 3/28/24. +// + +import Foundation +import LibXMTP + +public typealias FrameActionBody = Xmtp_MessageContents_FrameActionBody +public typealias FrameAction = Xmtp_MessageContents_FrameAction + +enum FramesClientError: Error { + case missingConversationTopic + case missingTarget + case readMetadataFailed(message: String, code: Int) + case postFrameFailed(message: String, code: Int) +} + +public class FramesClient { + var xmtpClient: Client + public var proxy: OpenFramesProxy + + public init(xmtpClient: Client, proxy: OpenFramesProxy? = nil) { + self.xmtpClient = xmtpClient + self.proxy = proxy ?? OpenFramesProxy() + } + + public func signFrameAction(inputs: FrameActionInputs) async throws -> FramePostPayload { + let opaqueConversationIdentifier = try self.buildOpaqueIdentifier(inputs: inputs) + let frameUrl = inputs.frameUrl + let buttonIndex = inputs.buttonIndex + let inputText = inputs.inputText ?? "" + let state = inputs.state ?? "" + let now = Date().timeIntervalSince1970 + let timestamp = now + + var toSign = FrameActionBody() + toSign.frameURL = frameUrl + toSign.buttonIndex = buttonIndex + toSign.opaqueConversationIdentifier = opaqueConversationIdentifier + toSign.timestamp = UInt64(timestamp) + toSign.inputText = inputText + toSign.unixTimestamp = UInt32(now) + toSign.state = state + + let signedAction = try await self.buildSignedFrameAction(actionBodyInputs: toSign) + + let untrustedData = FramePostUntrustedData( + url: frameUrl, timestamp: UInt64(now), buttonIndex: buttonIndex, inputText: inputText, state: state, walletAddress: self.xmtpClient.address, opaqueConversationIdentifier: opaqueConversationIdentifier, unixTimestamp: UInt32(now) + ) + + + let trustedData = FramePostTrustedData(messageBytes: signedAction.base64EncodedString()) + + let payload = FramePostPayload( + clientProtocol: "xmtp@\(PROTOCOL_VERSION)", untrustedData: untrustedData, trustedData: trustedData + ) + + return payload + } + + private func signDigest(digest: Data) async throws -> Signature { + let key = self.xmtpClient.keys.identityKey + let privateKey = try PrivateKey(key) + let signature = try await privateKey.sign(Data(digest)) + return signature + } + + private func getPublicKeyBundle() async throws -> PublicKeyBundle { + let bundleBytes = self.xmtpClient.publicKeyBundle; + return try PublicKeyBundle(bundleBytes); + } + + private func buildSignedFrameAction(actionBodyInputs: FrameActionBody) async throws -> Data { + + let digest = sha256(input: try actionBodyInputs.serializedData()) + let signature = try await self.signDigest(digest: digest) + + let publicKeyBundle = try await self.getPublicKeyBundle() + var frameAction = FrameAction() + frameAction.actionBody = try actionBodyInputs.serializedData() + frameAction.signature = signature + frameAction.signedPublicKeyBundle = try SignedPublicKeyBundle(publicKeyBundle) + + return try frameAction.serializedData() + } + + private func buildOpaqueIdentifier(inputs: FrameActionInputs) throws -> String { + switch inputs.conversationInputs { + case .group(let groupInputs): + let combined = groupInputs.groupId + groupInputs.groupSecret + let digest = sha256(input: combined) + return digest.base64EncodedString() + case .dm(let dmInputs): + guard let conversationTopic = dmInputs.conversationTopic else { + throw FramesClientError.missingConversationTopic + } + guard let combined = (conversationTopic.lowercased() + dmInputs.participantAccountAddresses.map { $0.lowercased() }.sorted().joined()).data(using: .utf8) else { + throw FramesClientError.missingConversationTopic + } + let digest = sha256(input: combined) + return digest.base64EncodedString() + } + } + +} diff --git a/Sources/XMTPiOS/Frames/FramesConstants.swift b/Sources/XMTPiOS/Frames/FramesConstants.swift new file mode 100644 index 00000000..976b8746 --- /dev/null +++ b/Sources/XMTPiOS/Frames/FramesConstants.swift @@ -0,0 +1,12 @@ +// +// File.swift +// +// +// Created by Alex Risch on 3/28/24. +// + +import Foundation + +let OPEN_FRAMES_PROXY_URL = "https://frames.xmtp.chat/" + +let PROTOCOL_VERSION = "2024-02-09" diff --git a/Sources/XMTPiOS/Frames/FramesTypes.swift b/Sources/XMTPiOS/Frames/FramesTypes.swift new file mode 100644 index 00000000..1f0819dc --- /dev/null +++ b/Sources/XMTPiOS/Frames/FramesTypes.swift @@ -0,0 +1,166 @@ +// +// File.swift +// +// +// Created by Alex Risch on 3/28/24. +// + +import Foundation + +typealias AcceptedFrameClients = [String: String] + +enum OpenFrameButton: Codable { + case link(target: String, label: String) + case mint(target: String, label: String) + case post(target: String?, label: String) + case postRedirect(target: String?, label: String) + + enum CodingKeys: CodingKey { + case action, target, label + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let action = try container.decode(String.self, forKey: .action) + guard let target = try container.decodeIfPresent(String.self, forKey: .target) else { + throw FramesClientError.missingTarget + } + let label = try container.decode(String.self, forKey: .label) + + switch action { + case "link": + self = .link(target: target, label: label) + case "mint": + self = .mint(target: target, label: label) + case "post": + self = .post(target: target, label: label) + case "post_redirect": + self = .postRedirect(target: target, label: label) + default: + throw DecodingError.dataCorruptedError(forKey: .action, in: container, debugDescription: "Invalid action value") + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .link(let target, let label): + try container.encode("link", forKey: .action) + try container.encode(target, forKey: .target) + try container.encode(label, forKey: .label) + case .mint(let target, let label): + try container.encode("mint", forKey: .action) + try container.encode(target, forKey: .target) + try container.encode(label, forKey: .label) + case .post(let target, let label): + try container.encode("post", forKey: .action) + try container.encode(target, forKey: .target) + try container.encode(label, forKey: .label) + case .postRedirect(let target, let label): + try container.encode("post_redirect", forKey: .action) + try container.encode(target, forKey: .target) + try container.encode(label, forKey: .label) + } + } +} + +public struct OpenFrameImage: Codable { + let content: String + let aspectRatio: AspectRatio? + let alt: String? +} + +public enum AspectRatio: String, Codable { + case ratio_1_91_1 = "1.91.1" + case ratio_1_1 = "1:1" +} + +public struct TextInput: Codable { + let content: String +} + +struct OpenFrameResult: Codable { + let acceptedClients: AcceptedFrameClients + let image: OpenFrameImage + let postUrl: String? + let textInput: TextInput? + let buttons: [String: OpenFrameButton]? + let ogImage: String + let state: String? +}; + +public struct GetMetadataResponse: Codable { + let url: String + public let extractedTags: [String: String] +} + +public struct PostRedirectResponse: Codable { + let originalUrl: String + let redirectedTo: String +}; + +public struct OpenFramesUntrustedData: Codable { + let url: String + let timestamp: Int + let buttonIndex: Int + let inputText: String? + let state: String? +} + +public typealias FramesApiRedirectResponse = PostRedirectResponse; + +public struct FramePostUntrustedData: Codable { + let url: String + let timestamp: UInt64 + let buttonIndex: Int32 + let inputText: String? + let state: String? + let walletAddress: String + let opaqueConversationIdentifier: String + let unixTimestamp: UInt32 +} + +public struct FramePostTrustedData: Codable { + let messageBytes: String +} + +public struct FramePostPayload: Codable { + let clientProtocol: String + let untrustedData: FramePostUntrustedData + let trustedData: FramePostTrustedData +} + +public struct DmActionInputs: Codable { + public let conversationTopic: String? + public let participantAccountAddresses: [String] + public init(conversationTopic: String? = nil, participantAccountAddresses: [String]) { + self.conversationTopic = conversationTopic + self.participantAccountAddresses = participantAccountAddresses + } +} + +public struct GroupActionInputs: Codable { + let groupId: Data + let groupSecret: Data +} + +public enum ConversationActionInputs: Codable { + case dm(DmActionInputs) + case group(GroupActionInputs) +} + +public struct FrameActionInputs: Codable { + let frameUrl: String + let buttonIndex: Int32 + let inputText: String? + let state: String? + let conversationInputs: ConversationActionInputs + public init(frameUrl: String, buttonIndex: Int32, inputText: String?, state: String?, conversationInputs: ConversationActionInputs) { + self.frameUrl = frameUrl + self.buttonIndex = buttonIndex + self.inputText = inputText + self.state = state + self.conversationInputs = conversationInputs + } +} + diff --git a/Sources/XMTPiOS/Frames/OpenFramesProxy.swift b/Sources/XMTPiOS/Frames/OpenFramesProxy.swift new file mode 100644 index 00000000..7f00c07b --- /dev/null +++ b/Sources/XMTPiOS/Frames/OpenFramesProxy.swift @@ -0,0 +1,38 @@ +// +// File.swift +// +// +// Created by Alex Risch on 3/28/24. +// + +import Foundation + +public class OpenFramesProxy { + let inner: ProxyClient + + init(baseUrl: String = OPEN_FRAMES_PROXY_URL) { + self.inner = ProxyClient(baseUrl: baseUrl); + } + + public func readMetadata(url: String) async throws -> GetMetadataResponse { + return try await self.inner.readMetadata(url: url); + } + + public func post(url: String, payload: FramePostPayload) async throws -> GetMetadataResponse { + return try await self.inner.post(url: url, payload: payload); + } + + public func postRedirect( + url: String, + payload: FramePostPayload + ) async throws -> FramesApiRedirectResponse { + return try await self.inner.postRedirect(url: url, payload: payload); + } + + public func mediaUrl(url: String) async throws -> String { + if url.hasPrefix("data:") { + return url + } + return self.inner.mediaUrl(url: url); + } +} diff --git a/Sources/XMTPiOS/Frames/ProxyClient.swift b/Sources/XMTPiOS/Frames/ProxyClient.swift new file mode 100644 index 00000000..f260c457 --- /dev/null +++ b/Sources/XMTPiOS/Frames/ProxyClient.swift @@ -0,0 +1,101 @@ +// +// File.swift +// +// +// Created by Alex Risch on 3/28/24. +// + +import Foundation + +struct Metadata: Codable { + let title: String + let description: String + let imageUrl: String +} + + +class ProxyClient { + var baseUrl: String + + init(baseUrl: String) { + self.baseUrl = baseUrl + } + + func readMetadata(url: String) async throws -> GetMetadataResponse { + let encodedUrl = url.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" + let fullUrl = "\(self.baseUrl)?url=\(encodedUrl)" + guard let url = URL(string: fullUrl) else { + throw URLError(.badURL) + } + + let (data, response) = try await URLSession.shared.data(from: url) + guard let httpResponse = response as? HTTPURLResponse else { + throw URLError(.badServerResponse) + } + + guard httpResponse.statusCode == 200 else { + throw FramesClientError.readMetadataFailed(message: "Failed to read metadata for \(url)", code: httpResponse.statusCode) + } + + let decoder = JSONDecoder() + let metadataResponse: GetMetadataResponse = try decoder.decode(GetMetadataResponse.self, from: data) + return metadataResponse + } + + func post(url: String, payload: Codable) async throws -> GetMetadataResponse { + + let encodedUrl = url.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" + let fullUrl = "\(self.baseUrl)?url=\(encodedUrl)" + guard let url = URL(string: fullUrl) else { + throw URLError(.badURL) + } + let encoder = JSONEncoder() + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try encoder.encode(payload) + + let (data, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { + throw URLError(.badServerResponse) + } + + let decoder = JSONDecoder() + let metadataResponse = try decoder.decode(GetMetadataResponse.self, from: data) + return metadataResponse + } + + func postRedirect(url: String, payload: Codable) async throws -> PostRedirectResponse { + let encodedUrl = url.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" + let fullUrl = "\(self.baseUrl)redirect?url=\(encodedUrl)" + guard let url = URL(string: fullUrl) else { + throw URLError(.badURL) + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONSerialization.data(withJSONObject: payload) + + let (data, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + throw URLError(.badServerResponse) + } + + guard httpResponse.statusCode == 200 else { + throw FramesClientError.postFrameFailed(message: "Failed to post to frame \(url)", code: httpResponse.statusCode) + } + + let decoder = JSONDecoder() + let postRedirectResponse = try decoder.decode(PostRedirectResponse.self, from: data) + return postRedirectResponse + } + + func mediaUrl(url: String) -> String { + let encodedUrl = url.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" + let result = "\(self.baseUrl)media?url=\(encodedUrl)" + return result; + } +} + + diff --git a/Tests/XMTPTests/FramesTests.swift b/Tests/XMTPTests/FramesTests.swift new file mode 100644 index 00000000..f0bf567f --- /dev/null +++ b/Tests/XMTPTests/FramesTests.swift @@ -0,0 +1,63 @@ +// +// FramesTests.swift +// +// +// Created by Alex Risch on 4/1/24. +// + +import Foundation +import secp256k1 +import XCTest +@testable import XMTPiOS + +final class FramesTests: XCTestCase { + func testInstantiateFramesClient() async throws { + let frameUrl = "https://fc-polls-five.vercel.app/polls/01032f47-e976-42ee-9e3d-3aac1324f4b8" + + let key = try Crypto.secureRandomBytes(count: 32) + let bo = try PrivateKey.generate() + let client = try await Client.create( + account: bo, + options: .init( + api: .init(env: .local, isSecure: false), + mlsAlpha: true, + mlsEncryptionKey: key + ) + ) + + let framesClient = FramesClient(xmtpClient: client) + let metadata = try await framesClient.proxy.readMetadata(url: frameUrl) + let conversationTopic = "foo" + let participantAccountAddresses = ["amal", "bola"] + let dmInputs = DmActionInputs( + conversationTopic: conversationTopic, participantAccountAddresses: participantAccountAddresses) + let conversationInputs = ConversationActionInputs.dm(dmInputs) + let frameInputs = FrameActionInputs(frameUrl: frameUrl, buttonIndex: 1, inputText: nil, state: nil, conversationInputs: conversationInputs) + let signedPayload = try await framesClient.signFrameAction(inputs: frameInputs) + + guard let postUrl = metadata.extractedTags["fc:frame:post_url"] else { + throw NSError(domain: "", code: 0, userInfo: [NSLocalizedDescriptionKey: "postUrl should exist"]) + } + let response = try await framesClient.proxy.post(url: postUrl, payload: signedPayload) + + guard response.extractedTags["fc:frame"] == "vNext" else { + throw NSError(domain: "", code: 0, userInfo: [NSLocalizedDescriptionKey: "response should have expected extractedTags"]) + } + + guard let imageUrl = response.extractedTags["fc:frame:image"] else { + throw NSError(domain: "", code: 0, userInfo: [NSLocalizedDescriptionKey: "imageUrl should exist"]) + } + + let mediaUrl = try await framesClient.proxy.mediaUrl(url: imageUrl) + + let (_, mediaResponse) = try await URLSession.shared.data(from: URL(string: mediaUrl)!) + + guard (mediaResponse as? HTTPURLResponse)?.statusCode == 200 else { + throw NSError(domain: "", code: 0, userInfo: [NSLocalizedDescriptionKey: "downloadedMedia should be ok"]) + } + + guard (mediaResponse as? HTTPURLResponse)?.mimeType == "image/png" else { + throw NSError(domain: "", code: 0, userInfo: [NSLocalizedDescriptionKey: "downloadedMedia should be image/png"]) + } + } +}