Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

V3 Frames Client #432

Draft
wants to merge 22 commits into
base: main
Choose a base branch
from
Draft
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
118 changes: 118 additions & 0 deletions Sources/XMTPiOS/Frames/FramesClient.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
//
// 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(message: String) async throws -> Data {
return try self.xmtpClient.signWithInstallationKey(message: message)
}

private func buildSignedFrameAction(actionBodyInputs: FrameActionBody)
async throws -> Data
{
let digest = sha256(input: try actionBodyInputs.serializedData()).toHex
let signature = try await self.signDigest(message: digest)

var frameAction = FrameAction()
frameAction.actionBody = try actionBodyInputs.serializedData()
frameAction.installationSignature = signature
frameAction.installationID = self.xmtpClient.installationID.hexToData
frameAction.inboxID = self.xmtpClient.inboxID

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()
}
}

}
12 changes: 12 additions & 0 deletions Sources/XMTPiOS/Frames/FramesConstants.swift
Original file line number Diff line number Diff line change
@@ -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"
165 changes: 165 additions & 0 deletions Sources/XMTPiOS/Frames/FramesTypes.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
//
// 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
}
}
38 changes: 38 additions & 0 deletions Sources/XMTPiOS/Frames/OpenFramesProxy.swift
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading
Loading