Skip to content
1 change: 1 addition & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ let package = Package(
.process("Resources/WalletStellarRecoveryMethods.json"),
.process("Resources/WalletStellarExternalWallet.json"),
.process("Resources/WalletStellarPasskey.json"),
.process("Resources/WalletStellarQuorum.json"),
.process("Resources/WalletSolanaUnsupportedSigners.json"),
.process("Resources/WalletSolanaOnlyUnsupportedSigner.json"),
.process("Resources/WalletSolanaInvalidAdminSigner.json"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ public enum AdminSignerDataType: String, Sendable, Codable {
case externalWallet = "external-wallet"
case phone
case server
case quorum
}

public extension AdminSignerData {
Expand Down Expand Up @@ -107,3 +108,21 @@ public struct ServerSignerData: AdminSignerData {
self.address = address
}
}

/// A signer that approves only when `threshold` of its `members` approve.
public struct QuorumSignerData: AdminSignerData {
/// The identifier that Crossmint gives the quorum when it is added to the wallet.
public let id: String
/// The number of members that must approve.
Comment thread
tomas-martins-crossmint marked this conversation as resolved.
Resolved
public let threshold: Int
public let members: [any AdminSignerData]

public var type: AdminSignerDataType { .quorum }
public var locatorId: String { id }

public init(id: String, threshold: Int, members: [any AdminSignerData]) {
self.id = id
self.threshold = threshold
self.members = members
}
}
4 changes: 4 additions & 0 deletions Sources/Logger/LogEvents.swift
Original file line number Diff line number Diff line change
Expand Up @@ -544,6 +544,10 @@ public enum LogEvents {
/// The SDK uses the first supported recovery signer instead.
public static let walletConfigAdminSignerFallback = "walletConfig.adminSigner.fallback"

/// A quorum of the wallet has a member type that this SDK version does not support.
/// The SDK leaves this member out of the quorum's members.
public static let walletConfigQuorumMemberSkipped = "walletConfig.quorumMember.skipped"

// MARK: - WalletFactory Error Events

public static let walletFactoryInvalidChain = "walletFactory.invalidChain"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ enum SignerFactory {
} else {
nil
}
case .device, .externalWallet, .server, .unknown:
case .device, .externalWallet, .server, .quorum, .unknown:
nil
}
}
Expand Down
62 changes: 62 additions & 0 deletions Sources/Wallet/Data/GetWallet/Response/QuorumSignerApiModel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
//
// QuorumSignerApiModel.swift
// CrossmintSDK
//
// Created by Tomas Martins on 16/10/26.
//

import CrossmintCommonTypes
import Logger

public struct QuorumSignerApiModel: AdminSignerApiModel {
Comment thread
tomas-martins-crossmint marked this conversation as resolved.
Resolved
public let type: AdminSignerDataType
public let locator: String
public let threshold: Int
let members: [any AdminSignerApiModel]

public var toDomain: any AdminSignerData {
QuorumSignerData(
id: String(locator.dropFirst("quorum:".count)),
threshold: threshold,
members: members.map(\.toDomain)
)
}

private enum CodingKeys: String, CodingKey {
case type, locator, threshold, members
}

public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
type = try container.decode(AdminSignerDataType.self, forKey: .type)
locator = try container.decode(String.self, forKey: .locator)
threshold = try container.decode(Int.self, forKey: .threshold)

var list = try container.nestedUnkeyedContainer(forKey: .members)
var members: [any AdminSignerApiModel] = []
while !list.isAtEnd {
let entry = try list.superDecoder()
let rawType = try WalletConfigApiModel.rawSignerType(from: entry)
guard let memberType = AdminSignerDataType(rawValue: rawType), memberType != .quorum else {
Logger.smartWallet.warning(LogEvents.walletConfigQuorumMemberSkipped, attributes: [
"quorum": locator,
"type": rawType
])
continue
}
members.append(try WalletConfigApiModel.decodeSigner(from: entry))
}
self.members = members
}

public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(type, forKey: .type)
try container.encode(locator, forKey: .locator)
try container.encode(threshold, forKey: .threshold)
var list = container.nestedUnkeyedContainer(forKey: .members)
for member in members {
try list.encode(member)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -78,12 +78,12 @@ public struct WalletConfigApiModel: Decodable {
return methods
}

private static func rawSignerType(from decoder: Decoder) throws -> String {
static func rawSignerType(from decoder: Decoder) throws -> String {
let typeContainer = try decoder.container(keyedBy: AdminSignerCodingKeys.self)
return try typeContainer.decode(String.self, forKey: .type)
}

private static func decodeSigner(from decoder: Decoder) throws -> AdminSignerApiModel {
static func decodeSigner(from decoder: Decoder) throws -> AdminSignerApiModel {
let typeContainer = try decoder.container(keyedBy: AdminSignerCodingKeys.self)
let rawType = try rawSignerType(from: decoder)
guard let type = AdminSignerDataType(rawValue: rawType) else {
Expand All @@ -107,6 +107,8 @@ public struct WalletConfigApiModel: Decodable {
return try ExternalWalletSignerApiModel(from: decoder)
case .server:
return try ServerSignerApiModel(from: decoder)
case .quorum:
return try QuorumSignerApiModel(from: decoder)
Comment thread
tomas-martins-crossmint marked this conversation as resolved.
Resolved
}
}

Expand Down
5 changes: 5 additions & 0 deletions Sources/Wallet/Model/Wallet/SignerLocator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ public enum SignerLocator: Codable, Sendable, Hashable {
case passkey(credentialId: String)
case apiKey(address: String? = nil)
case server(address: String)
case quorum(id: String)
case unknown(String)

/// Whether this locator refers to a device signer, regardless of its public key.
Expand Down Expand Up @@ -49,6 +50,8 @@ public enum SignerLocator: Codable, Sendable, Hashable {
address.map { "api-key:\($0)" } ?? "api-key"
case let .server(address):
"server:\(address)"
case let .quorum(id):
"quorum:\(id)"
case let .unknown(raw):
raw
}
Expand Down Expand Up @@ -105,6 +108,8 @@ public enum SignerLocator: Codable, Sendable, Hashable {
self = .apiKey(address: address)
case ("server", .some(let address)):
self = .server(address: address)
case ("quorum", .some(let id)):
self = .quorum(id: id)
default:
throw .signerLocatorError(locator)
}
Expand Down
4 changes: 3 additions & 1 deletion Tests/WalletTests/Model/SignerLocatorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ struct SignerLocatorTests {
(SignerLocator.apiKey(address: "0xdef"), "api-key:0xdef"),
(SignerLocator.apiKey(), "api-key"),
(SignerLocator.server(address: "0x999"), "server:0x999"),
(SignerLocator.quorum(id: "9f2c6a1e"), "quorum:9f2c6a1e"),
(SignerLocator.unknown("carrier-pigeon:0xabc"), "carrier-pigeon:0xabc")
]
)
Expand All @@ -34,7 +35,8 @@ struct SignerLocatorTests {
("passkey:cred-1", SignerLocator.passkey(credentialId: "cred-1")),
("api-key:0xdef", SignerLocator.apiKey(address: "0xdef")),
("api-key", SignerLocator.apiKey()),
("server:0x999", SignerLocator.server(address: "0x999"))
("server:0x999", SignerLocator.server(address: "0x999")),
("quorum:9f2c6a1e", SignerLocator.quorum(id: "9f2c6a1e"))
]
)
func parsesKnownLocatorStrings(stringAndExpected: (String, SignerLocator)) throws {
Expand Down
53 changes: 53 additions & 0 deletions Tests/WalletTests/Model/WalletQuorumSignerTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
//
// WalletQuorumSignerTests.swift
// CrossmintSDK
//
// Created by Tomas Martins on 16/10/26.
//

import CrossmintCommonTypes
import Foundation
import Testing
import TestsUtils

@testable import Wallet

private let QUORUM_ID = "9f2c6a1e-4b7d-4c3a-9e51-2d8f0b6c7a14"

@Suite("Wallet with a quorum signer", .tags(.unit))
struct WalletQuorumSignerTests {
private func makeStellarWallet() throws -> StellarWallet {
let baseModel: WalletApiModel = try GetFromFile.getModelFrom(
fileName: "WalletStellarQuorum",
bundle: Bundle.module
)
return try StellarWallet(
smartWalletService: MockSmartWalletService(),
signer: nil,
baseModel: baseModel,
stellarChain: .stellar
)
}

@Test func loadsAWalletWhoseOnlyRecoveryMethodIsAQuorum() throws {
let wallet = try makeStellarWallet()

let quorum = try #require(wallet.config.recovery as? QuorumSignerData)

#expect(quorum.locator == "quorum:\(QUORUM_ID)")
#expect(quorum.threshold == 2)
#expect(wallet.config.recoveryMethodsWithStatus.map(\.status) == [.active])
}

@Test func keepsTheSupportedMembersInOrder() throws {
let wallet = try makeStellarWallet()

let quorum = try #require(wallet.config.recovery as? QuorumSignerData)

#expect(quorum.members.map(\.locator) == [
"email:alice@example.com",
"email:bob@example.com",
"phone:+14155552671"
])
}
}
44 changes: 44 additions & 0 deletions Tests/WalletTests/Resources/WalletStellarQuorum.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
{
"type": "smart",
"chainType": "stellar",
"config": {
"adminSigner": {
"type": "quorum",
"locator": "quorum:9f2c6a1e-4b7d-4c3a-9e51-2d8f0b6c7a14",
"threshold": 2,
"members": [
{ "type": "email", "email": "alice@example.com", "locator": "email:alice@example.com" },
{ "type": "email", "email": "bob@example.com", "locator": "email:bob@example.com" },
{ "type": "phone", "phone": "+14155552671", "locator": "phone:+14155552671" }
]
},
"recoveryMethods": [
{
"type": "quorum",
"locator": "quorum:9f2c6a1e-4b7d-4c3a-9e51-2d8f0b6c7a14",
"threshold": 2,
"status": "active",
"members": [
{ "type": "email", "email": "alice@example.com", "locator": "email:alice@example.com" },
{ "type": "email", "email": "bob@example.com", "locator": "email:bob@example.com" },
{ "type": "totp", "locator": "totp:x" },
{ "type": "phone", "phone": "+14155552671", "locator": "phone:+14155552671" }
]
}
],
"delegatedSigners": [
{
"type": "quorum",
"locator": "quorum:0b8e7d6c-5a4f-4e3d-8c2b-1a0f9e8d7c6b",
"threshold": 2,
"members": [
{ "type": "email", "email": "carol@example.com", "locator": "email:carol@example.com" },
{ "type": "email", "email": "dave@example.com", "locator": "email:dave@example.com" }
]
}
]
},
"address": "CA3SGE76PWYAM4OVKETY47GASUGYDF3Y3QFFSPIWFCNKQLUOO4SOIT2W",
"linkedUser": "email:alice@example.com",
"createdAt": "2026-10-16T10:00:00.000Z"
}
Loading