Skip to content

Commit 3f96634

Browse files
Merge pull request #32 from tkhq/taylor/txn-sign-delegated-access
Taylor/txn sign delegated access
2 parents eb3c583 + 6ae8ff6 commit 3f96634

File tree

3 files changed

+312
-0
lines changed

3 files changed

+312
-0
lines changed
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import Foundation
2+
import TurnkeyTypes
3+
4+
public struct CreateP256ApiKeyUserParams: Sendable {
5+
public var userName: String
6+
public var apiKeyName: String
7+
public var publicKey: String
8+
9+
public init(userName: String, apiKeyName: String, publicKey: String) throws {
10+
let trimmedPublicKey = publicKey.trimmingCharacters(in: .whitespacesAndNewlines)
11+
guard !trimmedPublicKey.isEmpty else {
12+
throw TurnkeySwiftError.invalidConfiguration(
13+
"'publicKey' is required and cannot be empty."
14+
)
15+
}
16+
self.userName = userName.trimmingCharacters(in: .whitespacesAndNewlines)
17+
self.apiKeyName = apiKeyName.trimmingCharacters(in: .whitespacesAndNewlines)
18+
self.publicKey = trimmedPublicKey
19+
}
20+
}
21+
22+
public struct Policy: Codable, Sendable {
23+
public let policyId: String
24+
public let policyName: String
25+
public let effect: v1Effect
26+
public let condition: String?
27+
public let consensus: String?
28+
public let notes: String?
29+
30+
public init(
31+
policyId: String,
32+
policyName: String,
33+
effect: v1Effect,
34+
condition: String? = nil,
35+
consensus: String? = nil,
36+
notes: String? = nil
37+
) {
38+
self.policyId = policyId
39+
self.policyName = policyName
40+
self.effect = effect
41+
self.condition = condition
42+
self.consensus = consensus
43+
self.notes = notes
44+
}
45+
}
46+
47+
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
import Foundation
2+
import TurnkeyTypes
3+
import TurnkeyHttp
4+
5+
extension TurnkeyContext {
6+
7+
/// Fetches an existing user by P-256 API key public key, or creates a new one if none exists.
8+
///
9+
/// - Parameters:
10+
/// - params: Params containing the P-256 public key and desired user/api key names.
11+
/// - organizationId: Optional organization override. Defaults to active session org.
12+
/// - Returns: The existing or newly created `v1User`.
13+
public func fetchOrCreateP256ApiKeyUser(
14+
params: CreateP256ApiKeyUserParams,
15+
organizationId: String? = nil
16+
) async throws -> v1User {
17+
guard
18+
authState == .authenticated,
19+
let client = client
20+
else {
21+
throw TurnkeySwiftError.invalidSession
22+
}
23+
24+
let orgId = organizationId ?? self.session?.organizationId
25+
guard let orgId else {
26+
throw TurnkeySwiftError.invalidSession
27+
}
28+
29+
// attempt to find an existing user with this P-256 API key
30+
let usersResp = try await client.getUsers(TGetUsersBody(organizationId: orgId))
31+
if let existing = usersResp.users.first(where: { user in
32+
user.apiKeys.contains(where: { apiKey in
33+
apiKey.credential.publicKey == params.publicKey &&
34+
apiKey.credential.type == .credential_type_api_key_p256
35+
})
36+
}) {
37+
return existing
38+
}
39+
40+
// not found, create a new user with this API key
41+
let userName = params.userName.isEmpty ? "Public Key User" : params.userName
42+
let apiKeyName = params.apiKeyName.isEmpty ? "public-key-user-\(params.publicKey)" : params.apiKeyName
43+
44+
let apiKeyParams = v1ApiKeyParamsV2(
45+
apiKeyName: apiKeyName,
46+
curveType: .api_key_curve_p256,
47+
expirationSeconds: nil,
48+
publicKey: params.publicKey
49+
)
50+
51+
let createParamsV3 = v1UserParamsV3(
52+
apiKeys: [apiKeyParams],
53+
authenticators: [],
54+
oauthProviders: [],
55+
userEmail: nil,
56+
userName: userName,
57+
userPhoneNumber: nil,
58+
userTags: []
59+
)
60+
61+
let createResp = try await client.createUsers(TCreateUsersBody(
62+
organizationId: orgId,
63+
users: [createParamsV3]
64+
))
65+
66+
guard let newUserId = createResp.userIds.first, !newUserId.isEmpty else {
67+
throw TurnkeySwiftError.invalidResponse
68+
}
69+
70+
let userResp = try await client.getUser(TGetUserBody(
71+
organizationId: orgId,
72+
userId: newUserId
73+
))
74+
return userResp.user
75+
}
76+
77+
/// Fetches each requested policy if it exists, or creates it if it does not.
78+
///
79+
/// This function is idempotent:
80+
/// - Multiple calls with the same `policies` will not create duplicates.
81+
/// - For every policy in the request:
82+
/// - If it already exists, it is returned with its `policyId`.
83+
/// - If it does not exist, it is created and returned with its new `policyId`.
84+
///
85+
/// - Parameters:
86+
/// - policies: The list of policies to fetch or create.
87+
/// - organizationId: Optional organization override. Defaults to the current session's `organizationId`.
88+
/// - Returns: An array of items where each contains:
89+
/// - `policyId`: The unique identifier of the policy.
90+
/// - `policyName`: Human-readable name of the policy.
91+
/// - `effect`: The instruction to DENY or ALLOW an activity.
92+
/// - `condition`: Optional condition expression that triggers the effect.
93+
/// - `consensus`: Optional consensus expression that triggers the effect.
94+
/// - `notes`: Optional developer notes or description for the policy.
95+
/// - Throws: If there is no active session, if the input is invalid,
96+
/// if fetching existing policies fails, or if creating policies fails.
97+
public func fetchOrCreatePolicies(
98+
policies: [v1CreatePolicyIntentV3],
99+
organizationId: String? = nil
100+
) async throws -> [Policy] {
101+
guard
102+
authState == .authenticated,
103+
let client = client
104+
else {
105+
throw TurnkeySwiftError.invalidSession
106+
}
107+
108+
guard !policies.isEmpty else {
109+
throw TurnkeySwiftError.invalidConfiguration("'policies' must be a non-empty array of policy definitions.")
110+
}
111+
112+
let orgId = organizationId ?? self.session?.organizationId
113+
guard let orgId else {
114+
throw TurnkeySwiftError.invalidSession
115+
}
116+
117+
let existingResp = try await client.getPolicies(TGetPoliciesBody(organizationId: orgId))
118+
let existing = existingResp.policies
119+
120+
var existingBySignature: [String: String] = [:] // signature -> policyId
121+
for p in existing {
122+
existingBySignature[policySignature(p)] = p.policyId
123+
}
124+
125+
var alreadyExisting: [Policy] = []
126+
var missing: [v1CreatePolicyIntentV3] = []
127+
128+
for intent in policies {
129+
let sig = policySignature(intent)
130+
if let policyId = existingBySignature[sig] {
131+
alreadyExisting.append(
132+
.init(
133+
policyId: policyId,
134+
policyName: intent.policyName,
135+
effect: intent.effect,
136+
condition: intent.condition,
137+
consensus: intent.consensus,
138+
notes: intent.notes
139+
)
140+
)
141+
} else {
142+
missing.append(intent)
143+
}
144+
}
145+
146+
if missing.isEmpty {
147+
return alreadyExisting
148+
}
149+
150+
let createResp = try await client.createPolicies(TCreatePoliciesBody(
151+
organizationId: orgId,
152+
policies: missing
153+
))
154+
155+
let createdIds = createResp.policyIds
156+
guard createdIds.count == missing.count else {
157+
throw TurnkeySwiftError.invalidResponse
158+
}
159+
160+
let newlyCreated: [Policy] = zip(missing, createdIds).map { (intent, policyId) in
161+
.init(
162+
policyId: policyId,
163+
policyName: intent.policyName,
164+
effect: intent.effect,
165+
condition: intent.condition,
166+
consensus: intent.consensus,
167+
notes: intent.notes
168+
)
169+
}
170+
171+
return alreadyExisting + newlyCreated
172+
}
173+
174+
// MARK: - Helpers
175+
private func policySignature(_ policy: v1Policy) -> String {
176+
[
177+
policy.policyName,
178+
policy.effect.rawValue,
179+
policy.condition,
180+
policy.consensus,
181+
policy.notes
182+
].joined(separator: "|")
183+
}
184+
185+
private func policySignature(_ intent: v1CreatePolicyIntentV3) -> String {
186+
[
187+
intent.policyName,
188+
intent.effect.rawValue,
189+
intent.condition ?? "",
190+
intent.consensus ?? "",
191+
intent.notes ?? ""
192+
].joined(separator: "|")
193+
}
194+
}
195+
196+

Sources/TurnkeySwift/Public/TurnkeyContext+Signing.swift

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,4 +156,73 @@ extension TurnkeyContext {
156156
throw TurnkeySwiftError.failedToSignPayload(underlying: error)
157157
}
158158
}
159+
160+
/// Signs a transaction using the specified wallet account.
161+
///
162+
/// Delegates to the string-based overload using `account.address`.
163+
///
164+
/// - Parameters:
165+
/// - signWith: The wallet account to use for signing.
166+
/// - unsignedTransaction: The raw unsigned transaction payload (chain-canonical encoding).
167+
/// - type: The transaction type (e.g. `.transaction_type_ethereum`, `.transaction_type_solana`).
168+
///
169+
/// - Returns: The signed transaction string.
170+
///
171+
/// - Throws:
172+
/// - `TurnkeySwiftError.invalidSession` if no active session is found.
173+
/// - `TurnkeySwiftError.failedToSignPayload` if signing fails.
174+
public func signTransaction(
175+
signWith account: WalletAccount,
176+
unsignedTransaction: String,
177+
type: v1TransactionType
178+
) async throws -> String {
179+
return try await signTransaction(
180+
signWith: account.address,
181+
unsignedTransaction: unsignedTransaction,
182+
type: type
183+
)
184+
}
185+
186+
/// Signs a transaction using the specified address or key identifier.
187+
///
188+
/// Uses the active session to request a signed transaction from the Turnkey API.
189+
/// Note: For Ethereum, the returned signed transaction string may or may not include a `0x` prefix.
190+
/// Consumers should prepend `0x` when required by their transport if absent.
191+
///
192+
/// - Parameters:
193+
/// - signWith: Wallet account address, private key address, or key identifier to sign with.
194+
/// - unsignedTransaction: The raw unsigned transaction payload (chain-canonical encoding).
195+
/// - type: The transaction type (e.g. `.transaction_type_ethereum`, `.transaction_type_solana`).
196+
///
197+
/// - Returns: The signed transaction string.
198+
///
199+
/// - Throws:
200+
/// - `TurnkeySwiftError.invalidSession` if no active session is found.
201+
/// - `TurnkeySwiftError.failedToSignPayload` if signing fails.
202+
public func signTransaction(
203+
signWith: String,
204+
unsignedTransaction: String,
205+
type: v1TransactionType
206+
) async throws -> String {
207+
guard
208+
authState == .authenticated,
209+
let client = client,
210+
let sessionKey = selectedSessionKey,
211+
let stored = try JwtSessionStore.load(key: sessionKey)
212+
else {
213+
throw TurnkeySwiftError.invalidSession
214+
}
215+
216+
do {
217+
let resp = try await client.signTransaction(TSignTransactionBody(
218+
organizationId: stored.decoded.organizationId,
219+
signWith: signWith,
220+
type: type,
221+
unsignedTransaction: unsignedTransaction
222+
))
223+
return resp.signedTransaction
224+
} catch {
225+
throw TurnkeySwiftError.failedToSignPayload(underlying: error)
226+
}
227+
}
159228
}

0 commit comments

Comments
 (0)