Skip to content
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
16 changes: 1 addition & 15 deletions SoraPassport/Common/Configs/ApplicationConfigs.swift
Original file line number Diff line number Diff line change
Expand Up @@ -298,21 +298,7 @@ extension ApplicationConfig: ApplicationConfigProtocol {
}

var defaultChainNodes: Set<ChainNodeModel> {
#if F_RELEASE
return [
ChainNodeModel(url: URL(string: "wss://mof2.sora.org")!, name: "Sora", apikey: nil),
]

#elseif F_STAGING || F_TEST
return [
ChainNodeModel(url: URL(string: "wss://mof2.sora.org")!, name: "Sora", apikey: nil),
]
#else
return [
ChainNodeModel(url: URL(string: "wss://mof2.sora.org")!, name: "Sora", apikey: nil),
]

#endif
Set(SoraNodeConnectionPolicy.bundledMainnetNodes)
}

var polkaswapIndexerURL: URL {
Expand Down
58 changes: 58 additions & 0 deletions SoraPassport/Common/Network/JSONRPC/ConnectionAutobalancing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,61 @@ extension ConnectionRank {
rank = 0
}
}

/// Node choice is connection state, not a change to the user's saved preference.
/// Only the known SORA mainnet identity receives the bundled mainnet fallbacks.
enum SoraNodeConnectionPolicy {
static let mainnetGenesis = "0x7e4e32d0feafd4f9c9414b0be86373f9a1efa904809b683453a9af6856d38ad5"
static let bundledMainnetNodes = [
ChainNodeModel(url: URL(string: "wss://ws.mof.sora.org")!, name: "SORA Ministry of Finance", apikey: nil),
ChainNodeModel(url: URL(string: "wss://mof2.sora.org")!, name: "SORA Ministry of Finance 2", apikey: nil),
]

static func isMainnet(chainId: String, addressPrefix: UInt16) -> Bool {
chainId.lowercased() == mainnetGenesis && addressPrefix == 69
}

static func candidates(for chain: ChainModel) -> [ChainNodeModel] {
let bundled = isMainnet(chainId: chain.chainId, addressPrefix: chain.addressPrefix)
? bundledMainnetNodes : []
let defaults = chain.nodes.sorted { $0.url.absoluteString < $1.url.absoluteString }
let custom = (chain.customNodes ?? []).sorted { $0.url.absoluteString < $1.url.absoluteString }
var seen: Set<URL> = []
return ([chain.selectedNode].compactMap { $0 } + bundled + defaults + custom).filter {
guard ["ws", "wss"].contains($0.url.scheme?.lowercased() ?? ""),
$0.url.host?.isEmpty == false else { return false }
return seen.insert($0.url).inserted
}
}
}

struct NodeConnectionFailover {
struct Decision {
let nextNode: ChainNodeModel?
let shouldPresentUnavailable: Bool
}

private var failedURLs: Set<URL> = []
private var hasPresentedUnavailable = false

mutating func failed(url: URL, candidates: [ChainNodeModel]) -> Decision {
guard let current = candidates.firstIndex(where: { $0.url == url }) else {
return Decision(nextNode: nil, shouldPresentUnavailable: false)
}
failedURLs.insert(url)
let ordered = Array(candidates.dropFirst(current + 1)) + Array(candidates.prefix(current + 1))
if let next = ordered.first(where: { !failedURLs.contains($0.url) }) {
return Decision(nextNode: next, shouldPresentUnavailable: false)
}
let shouldPresent = !hasPresentedUnavailable
hasPresentedUnavailable = true
failedURLs.removeAll()
return Decision(nextNode: ordered.first(where: { $0.url != url }),
shouldPresentUnavailable: shouldPresent)
}

mutating func connected() {
failedURLs.removeAll()
hasPresentedUnavailable = false
}
}
107 changes: 46 additions & 61 deletions SoraPassport/Common/Services/ChainRegistry/ChainRegistry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ final class ChainRegistry {
private let mutex = NSLock()

private let maxAttemptCount = 2
private var nodeFailover: [ChainModel.Id: NodeConnectionFailover] = [:]

init(
snapshotHotBootBuilder: SnapshotHotBootBuilderProtocol,
Expand Down Expand Up @@ -313,7 +314,7 @@ extension ChainRegistry: ChainRegistryProtocol {

let chain = chains.first { $0.chainId == chainId }
let url = connectionPool.getConnection(for: chainId)?.url
return chain?.nodes.first { $0.url == url } ?? chain?.customNodes?.first { $0.url == url }
return chain.flatMap { SoraNodeConnectionPolicy.candidates(for: $0).first { $0.url == url } }
}

func getAssetManager(for chainId: ChainModel.Id) -> AssetManagerProtocol {
Expand Down Expand Up @@ -372,74 +373,58 @@ extension ChainRegistry: ChainRegistryProtocol {
extension ChainRegistry: ConnectionPoolDelegate {

func connectionNeedsReconnect(url: URL, attempt: Int) {
guard let failedChain = chains.first(where: { chain in
return chain.nodes.first { $0.url == url } != nil || chain.customNodes?.first { $0.url == url } != nil
}) else {
return
}

guard attempt > maxAttemptCount else {

let defaultNodes = failedChain.nodes
let customNodes = failedChain.customNodes ?? []

let sortedDefaultNodes = defaultNodes.sorted(by: { $0.url.absoluteString < $1.url.absoluteString })
let sortedCustomNodes = customNodes.sorted(by: { $0.url.absoluteString < $1.url.absoluteString })

let allNodes = sortedDefaultNodes + sortedCustomNodes

let currentNodeIndex = Int(allNodes.firstIndex(where: { $0.url == url } ) ?? 0)
let nextNodeIndex = currentNodeIndex + 1 >= allNodes.count ? 0 : currentNodeIndex + 1

if currentNodeIndex + 1 >= allNodes.count {
DispatchQueue.main.async {
self.networkStatusPresenter?.didDecideUnreachableNodesAllertPresentation()
}
guard attempt >= maxAttemptCount else { return }
// Delegate notifications are asynchronous. Ignore stale failures after
// another connection has already been selected or established.
processingQueue.async { [weak self] in
guard let self = self else { return }
self.mutex.lock()
guard let chain = self.chains.first(where: {
self.connectionPool.getConnection(for: $0.chainId)?.url == url
}), let connection = self.connectionPool.getConnection(for: chain.chainId) else {
self.mutex.unlock()
return
}

let currentNode = allNodes[currentNodeIndex]
let nextNode = allNodes[nextNodeIndex]

let event = FailedNodeConnectionEvent(node: currentNode)
eventCenter.notify(with: event)
changeSelectedNode(from: failedChain, to: nextNode)
return
}

guard failedChain.selectedNode == nil else { return }

let node = failedChain.selectedNode ?? failedChain.nodes.first(where: { $0.url != url })

if let newUrl = node?.url {
if let connection = getConnection(for: failedChain.chainId) {
connection.reconnect(url: newUrl)

let event = ChainsUpdatedEvent(updatedChains: [failedChain])
eventCenter.notify(with: event)
if case .connected = connection.state {
self.mutex.unlock()
return
}
var failover = self.nodeFailover[chain.chainId] ?? NodeConnectionFailover()
let candidates = SoraNodeConnectionPolicy.candidates(for: chain)
let decision = failover.failed(url: url, candidates: candidates)
self.nodeFailover[chain.chainId] = failover
self.mutex.unlock()

if let failedNode = candidates.first(where: { $0.url == url }) {
self.eventCenter.notify(with: FailedNodeConnectionEvent(node: failedNode))
}
if let next = decision.nextNode {
connection.disconnectIfNeeded()
connection.reconnect(url: next.url)
connection.connectIfNeeded()
self.eventCenter.notify(with: ChainsUpdatedEvent(updatedChains: [chain]))
}
if decision.shouldPresentUnavailable {
DispatchQueue.main.async { [weak self] in
self?.networkStatusPresenter?.didDecideUnreachableNodesAllertPresentation()
}
}
}
}

func connectionUpdated(url: URL) {
SettingsManager.shared.lastSuccessfulUrl = url
}

private func changeSelectedNode(from: ChainModel, to: ChainNodeModel) {

let updatedChain = from.replacingSelectedNode(to)

let saveOperation = chainRepository.saveOperation {
return [updatedChain]
} _: {
[]
processingQueue.async { [weak self] in
guard let self = self else { return }
self.mutex.lock()
defer { self.mutex.unlock() }
guard let chain = self.chains.first(where: {
self.connectionPool.getConnection(for: $0.chainId)?.url == url
}) else { return }
self.nodeFailover[chain.chainId]?.connected()
SettingsManager.shared.lastSuccessfulUrl = url
}

saveOperation.completionBlock = { [weak self] in
let event = ChainsUpdatedEvent(updatedChains: [updatedChain])
self?.eventCenter.notify(with: event)
}
operationManager.enqueue(operations: [saveOperation], in: .transient)
}

}

struct FailedNodeConnectionEvent: EventProtocol {
Expand Down
Loading
Loading