Auditoria
Endereço parecido
Kit/Sources/EscaliburChains/AddressPoisoning.swift
Detecção de envenenamento de endereço.
| 1 | import Foundation |
| 2 | |
| 3 | /// Deteccao de envenenamento de endereco. |
| 4 | /// |
| 5 | /// O golpe: o atacante gera um endereco com o comeco e o fim iguais aos de um que o |
| 6 | /// dono ja usou e manda um valor minusculo, para o endereco falso aparecer no |
| 7 | /// historico. Quem copia de la, conferindo so as pontas, paga o atacante. |
| 8 | /// |
| 9 | /// A comparacao ignora o prefixo fixo de cada rede (o atacante nao precisa gerar |
| 10 | /// "0x" nem "bc1q", entao eles nao contam como coincidencia) e olha as pontas do |
| 11 | /// resto. Duas formas da mesma conta (caixa do EIP-55, amigavel e crua da TON) nao |
| 12 | /// sao parecidas: sao a mesma. |
| 13 | public enum AddressPoisoning { |
| 14 | /// Coincidencias de ponta que um atacante consegue gerar em minutos. Acima disso |
| 15 | /// o custo cresce 16 a 58 vezes por caractere. |
| 16 | static let minimumPrefix = 3 |
| 17 | static let minimumSuffix = 3 |
| 18 | static let minimumTotal = 7 |
| 19 | /// Uma ponta so, longa: quem confere so o comeco ou so o fim cai em 2+6, 2+8 ou 0+10, |
| 20 | /// que as duas pontas juntas deixavam passar (auditoria 4, C7-07). Ao acaso, cinco |
| 21 | /// caracteres iguais numa ponta saem uma vez em um milhao de pares, em hexadecimal |
| 22 | /// (16^5 = 2^20). |
| 23 | static let singleEnd = 5 |
| 24 | /// A mesma ponta so, com 32 simbolos ou mais (bech32, base58, base32 da Stellar, |
| 25 | /// base64 da TON): quatro caracteres ja sao os mesmos 20 bits (32^4 = 2^20). O sosia que |
| 26 | /// casa so os 4 do fim, os que a tela mostra, custa o mesmo milhao de tentativas que |
| 27 | /// os 5 do hexadecimal, e passava sem aviso (revisao dos enderecos parecidos, B-5). |
| 28 | static let singleEndWide = 4 |
| 29 | /// Na Cardano, o comeco do corpo e a credencial de pagamento inteira (28 bytes, 44 |
| 30 | /// caracteres de bech32): com ela igual, os dois enderecos sao da mesma chave de |
| 31 | /// pagamento, com outra parte de stake, e nao um sosia gerado. |
| 32 | static let cardanoPaymentCharacters = 44 |
| 33 | /// Na Cardano, a credencial de stake de um endereco e publica: o atacante poe a de um |
| 34 | /// endereco que o dono usou no seu, com o pagamento dele, e a segunda metade dos dois |
| 35 | /// fica igual de graca. Com o stake igual e o pagamento diferente, tres caracteres |
| 36 | /// iguais no fim (o checksum) ja marcam (B-5). |
| 37 | static let cardanoSameStakeSuffix = 3 |
| 38 | |
| 39 | /// Menos que isso depois do prefixo da rede nao e endereco gerado (nome curto da NEAR). |
| 40 | /// O tamanho conta o registro do nome da NEAR: tirar o ".near" do corpo nao deixa de |
| 41 | /// comparar um nome que ja era comparado. |
| 42 | public static let minimumLength = 16 |
| 43 | |
| 44 | /// O primeiro endereco de `known` parecido com `address`, se houver. |
| 45 | public static func lookalike(_ address: String, among known: [String], chain: Chain) -> String? { |
| 46 | guard let target = Compared(address, chain: chain) else { return nil } |
| 47 | let targetAccount = Address.canonicalRecipient(address, chain: chain) |
| 48 | return known.first { other in |
| 49 | guard let compared = Compared(other, chain: chain), compared.text != target.text else { return false } |
| 50 | if let targetAccount, Address.canonicalRecipient(other, chain: chain) == targetAccount { return false } |
| 51 | // Na Stellar, o endereco M e a conta base G dele (ou dois M da mesma base) sao |
| 52 | // a mesma conta com ids diferentes, nao um sosia. O ID trocado entre dois M e |
| 53 | // outra conferencia (`sameAccountOtherID`). |
| 54 | if chain.family == .stellar, let a = StellarMuxedAccount(address: other.trimmingCharacters(in: .whitespacesAndNewlines))?.account, |
| 55 | let b = StellarMuxedAccount(address: address.trimmingCharacters(in: .whitespacesAndNewlines))?.account, a == b { |
| 56 | return false |
| 57 | } |
| 58 | let candidate = compared.body |
| 59 | let head = commonPrefix(candidate, target.body) |
| 60 | let tail = commonPrefix(String(candidate.reversed()), String(target.body.reversed())) |
| 61 | if chain.family == .cardano { |
| 62 | if head >= cardanoPaymentCharacters { return false } |
| 63 | if tail >= cardanoSameStakeSuffix, cardanoSameStakeOtherPayment(address, other) { return true } |
| 64 | } |
| 65 | if head >= minimumPrefix && tail >= minimumSuffix && head + tail >= minimumTotal { return true } |
| 66 | if chain.family == .near, !isHex64(candidate), !isHex64(target.body), similarNames(candidate, target.body) { return true } |
| 67 | guard let single = singleEndLength(chain, candidate, target.body) else { return false } |
| 68 | return max(head, tail) >= single |
| 69 | } |
| 70 | } |
| 71 | |
| 72 | /// Dois nomes da NEAR parecidos entre si: ate 2 edicoes (letra trocada, posta ou |
| 73 | /// tirada) com corpo de 8 ou mais, ou um comeco comum que cobre o nome menor menos 2. |
| 74 | /// Registrar um nome sob ".near" custa centavos e nao pede nenhum caractere gerado: |
| 75 | /// "coinbase-hot-wa11et", "coinbase-hot-wallets" e "binance-deposit-02" imitam |
| 76 | /// "coinbase-hot-wallet" e "binance-deposit-01" so com o fim mudado, e o fim do corpo, |
| 77 | /// sem o ".near", ja nao contava como ponta (revisao do B-9). |
| 78 | /// |
| 79 | /// As duas regras valem tambem sobre o nome como o olho le (`visualFold`): com tres |
| 80 | /// trocas ou mais, "coinbase-h0t-wa11et" e "binanse-dep0sit-07" passavam. |
| 81 | static func similarNames(_ a: String, _ b: String) -> Bool { |
| 82 | similarSpelling(a, b) || similarSpelling(visualFold(a), visualFold(b)) |
| 83 | } |
| 84 | |
| 85 | static func similarSpelling(_ a: String, _ b: String) -> Bool { |
| 86 | let shorter = min(a.count, b.count) |
| 87 | if commonPrefix(a, b) >= max(nameSharedPrefix, shorter - 2) { return true } |
| 88 | return shorter >= nameMinimumForEdits && editDistance(a, b, limit: nameEdits) <= nameEdits |
| 89 | } |
| 90 | |
| 91 | static let nameSharedPrefix = 6 |
| 92 | static let nameMinimumForEdits = 8 |
| 93 | static let nameEdits = 2 |
| 94 | |
| 95 | /// Pares de letras que se leem como uma so, e os caracteres que se passam por outros |
| 96 | /// num nome de conta (so minusculas, algarismos, "-", "_" e "."): "rn" por "m", "vv" por |
| 97 | /// "w", "cl" por "d", "0" por "o", "5" por "s", e "1", "i" e "l" juntos. Ficam de fora o |
| 98 | /// "_" no lugar do "-" (ja e uma edicao, e dobrado somava nomes de numeros diferentes, |
| 99 | /// "camila-5948" e "camila_9998") e os algarismos que so lembram letras (2, 6, 8, 9), |
| 100 | /// que transformavam numeros em palavras ("andrew-9016" e "andrew-gomes") sem ganhar |
| 101 | /// sosia nenhum nas amostras (teste `nearVisualMimics`). |
| 102 | static let visualPairs: [(String, String)] = [("rn", "m"), ("vv", "w"), ("cl", "d")] |
| 103 | static let visualSingles: [Character: Character] = ["0": "o", "1": "l", "i": "l", "5": "s"] |
| 104 | |
| 105 | /// O nome como o olho le: dois nomes que so diferem por um sosia visual dobram no mesmo. |
| 106 | static func visualFold(_ name: String) -> String { |
| 107 | var text = name |
| 108 | for (pair, single) in visualPairs { text = text.replacingOccurrences(of: pair, with: single) } |
| 109 | return String(text.map { visualSingles[$0] ?? $0 }) |
| 110 | } |
| 111 | |
| 112 | /// Distancia de edicao (Levenshtein) entre `a` e `b`, parando em `limit + 1`. |
| 113 | static func editDistance(_ a: String, _ b: String, limit: Int) -> Int { |
| 114 | let x = Array(a.utf8), y = Array(b.utf8) |
| 115 | guard abs(x.count - y.count) <= limit else { return limit + 1 } |
| 116 | guard !x.isEmpty, !y.isEmpty else { return max(x.count, y.count) } |
| 117 | var previous = Array(0...y.count) |
| 118 | for i in 1...x.count { |
| 119 | var current = [i] + [Int](repeating: 0, count: y.count) |
| 120 | for j in 1...y.count { |
| 121 | current[j] = min(previous[j] + 1, current[j - 1] + 1, previous[j - 1] + (x[i - 1] == y[j - 1] ? 0 : 1)) |
| 122 | } |
| 123 | if current.min()! > limit { return limit + 1 } |
| 124 | previous = current |
| 125 | } |
| 126 | return min(previous[y.count], limit + 1) |
| 127 | } |
| 128 | |
| 129 | /// Um endereco como a comparacao le, em minusculas: `text`, tudo depois do prefixo da |
| 130 | /// rede, diz se dois sao o mesmo; `body`, tambem sem o sufixo de nome da NEAR, e de |
| 131 | /// onde saem as pontas. O mesmo nome sob ".near" e sob ".tg" tem o corpo igual e o |
| 132 | /// texto diferente: sao contas diferentes, e as duas pontas iguais o marcam. |
| 133 | private struct Compared { |
| 134 | let text: String |
| 135 | let body: String |
| 136 | |
| 137 | init?(_ address: String, chain: Chain) { |
| 138 | text = AddressPoisoning.afterPrefix(address, chain: chain) |
| 139 | guard text.count >= AddressPoisoning.minimumLength else { return nil } |
| 140 | body = AddressPoisoning.body(address, chain: chain) |
| 141 | } |
| 142 | } |
| 143 | |
| 144 | /// Quantos caracteres iguais numa ponta so ja fazem o par parecido, ou `nil` quando a |
| 145 | /// regra da ponta so nao vale. Quatro com 32 simbolos ou mais, cinco em hexadecimal: |
| 146 | /// os dois sao 20 bits, e ao acaso saem perto de duas vezes em um milhao de pares |
| 147 | /// (as taxas medidas por rede estao no teste). |
| 148 | static func singleEndLength(_ chain: Chain, _ a: String, _ b: String) -> Int? { |
| 149 | switch chain.family { |
| 150 | case .evm, .sui, .aptos: |
| 151 | return singleEnd |
| 152 | case .near: |
| 153 | // Conta com nome e escolhida letra a letra, e nomes com o mesmo comeco ou o |
| 154 | // mesmo fim sao comuns ("alex...", "...-wallet"): entre dois nomes a ponta so nao |
| 155 | // vale, e a regra e a de `similarNames`. Com uma conta implicita (64 caracteres |
| 156 | // hexadecimais) de um lado, vale: um nome feito dos 59 primeiros hex de uma |
| 157 | // implicita, mais ".near", e um sosia dela. Pelo tamanho so, um nome de 64 |
| 158 | // letras tambem entrava (revisao dos enderecos parecidos, B-9). |
| 159 | return isHex64(a) || isHex64(b) ? singleEnd : nil |
| 160 | case .ton: |
| 161 | // A grafia crua ("0:" e 64 hex) e hexadecimal; a amigavel, base64. |
| 162 | return isHex64(a) && isHex64(b) ? singleEnd : singleEndWide |
| 163 | case .utxo, .tron, .xrpl, .stellar, .solana, .cardano, .polkadot: |
| 164 | return singleEndWide |
| 165 | } |
| 166 | } |
| 167 | |
| 168 | /// 64 caracteres hexadecimais (o corpo ja vem em minusculas). |
| 169 | static func isHex64(_ text: String) -> Bool { |
| 170 | text.utf8.count == 64 && text.utf8.allSatisfy { (0x30...0x39).contains($0) || (0x61...0x66).contains($0) } |
| 171 | } |
| 172 | |
| 173 | /// Dois enderecos base da Cardano com a mesma credencial de stake e pagamentos |
| 174 | /// diferentes. |
| 175 | static func cardanoSameStakeOtherPayment(_ a: String, _ b: String) -> Bool { |
| 176 | guard case .success(let first) = CardanoAddress.parse(a), case .success(let second) = CardanoAddress.parse(b), |
| 177 | let stake = first.stakeHash, stake == second.stakeHash |
| 178 | else { return false } |
| 179 | return first.paymentHash != second.paymentHash |
| 180 | } |
| 181 | |
| 182 | // MARK: Mesma conta, outro ID (Stellar) |
| 183 | |
| 184 | /// O conhecido com a mesma conta base de `address` e outro ID, quando nenhum conhecido |
| 185 | /// tem a mesma conta com o mesmo ID (revisao dos enderecos parecidos, B-6). |
| 186 | /// |
| 187 | /// `lookalike` deixa de fora, de proposito, dois M da mesma base: sao a mesma conta, e |
| 188 | /// nao um sosia gerado. So que numa corretora o ID e o que diz de qual cliente e o |
| 189 | /// deposito. Um programa que troca o endereco copiado pode decodificar o M, trocar so |
| 190 | /// o ID pelo de uma conta do atacante na mesma corretora e codificar de novo: o |
| 191 | /// endereco continua valido, da mesma conta, e passava por todas as conferencias. |
| 192 | /// |
| 193 | /// O que se compara e o par (conta base, ID): o M traz os dois, e um G traz o ID no memo |
| 194 | /// que vai junto. Por isso `tag` e o memo de um destino G, e `tagged` sao os contatos G |
| 195 | /// salvos com o memo (revisao do B-6). Um G sem memo (o historico da rede so da a conta |
| 196 | /// base) nao diz qual ID o dono usou, e nao tem o que comparar. |
| 197 | public static func sameAccountOtherID(_ address: String, tag: String? = nil, among known: [String], |
| 198 | tagged: [(address: String, tag: String)] = [], chain: Chain) -> SameAccount? { |
| 199 | guard chain.family == .stellar, let target = muxedPair(address, tag: tag) else { return nil } |
| 200 | let pairs = known.compactMap { text in muxedPair(text, tag: nil).map { (text: text, pair: $0) } } |
| 201 | + tagged.compactMap { entry in muxedPair(entry.address, tag: entry.tag).map { (text: entry.address, pair: $0) } } |
| 202 | // O mesmo par ja usado: e o destino de sempre. |
| 203 | if pairs.contains(where: { $0.pair == target }) { return nil } |
| 204 | return pairs.first { $0.pair.account == target.account }.map { SameAccount(known: $0.text, knownID: $0.pair.id) } |
| 205 | } |
| 206 | |
| 207 | /// Um conhecido com a mesma conta base e outro ID. |
| 208 | public struct SameAccount: Equatable, Sendable { |
| 209 | /// O endereco como o dono o tem: um M, ou o G de um contato salvo com o ID no memo. |
| 210 | public let known: String |
| 211 | /// O ID desse conhecido. |
| 212 | public let knownID: UInt64 |
| 213 | } |
| 214 | |
| 215 | /// Conta base e ID: do M, ou do G com um memo que e numero. |
| 216 | static func muxedPair(_ address: String, tag: String?) -> (account: StellarAccountID, id: UInt64)? { |
| 217 | guard let account = muxedAccount(address) else { return nil } |
| 218 | if let id = account.id { return (account.account, id) } |
| 219 | guard let tag, let id = UInt64(tag.trimmingCharacters(in: .whitespacesAndNewlines)) else { return nil } |
| 220 | return (account.account, id) |
| 221 | } |
| 222 | |
| 223 | /// O dono confirmou o ID de um M com a mesma conta de um conhecido: digitou o numero |
| 224 | /// do ID que quem vai receber mostra, ou os 6 ultimos caracteres do endereco, lidos onde |
| 225 | /// ele foi copiado (revisao do B-6). |
| 226 | /// |
| 227 | /// Os 6 ultimos so valem como resposta porque nenhum dos dois esta na tela enquanto o |
| 228 | /// desafio esta: o trecho do ID do colado (que inclui os 6 ultimos) vem escondido, e o |
| 229 | /// campo do endereco colado some ate o dono apertar "Editar", que desfaz o desafio. |
| 230 | public static func muxedIDConfirmed(_ typed: String, address: String) -> Bool { |
| 231 | let text = address.trimmingCharacters(in: .whitespacesAndNewlines) |
| 232 | guard let id = muxedAccount(text)?.id else { return false } |
| 233 | if idConfirmed(typed, id: id) { return true } |
| 234 | let answer = typed.filter { !$0.isWhitespace } |
| 235 | return answer.count == muxedTailLength && answer.uppercased() == String(text.suffix(muxedTailLength)).uppercased() |
| 236 | } |
| 237 | |
| 238 | static let muxedTailLength = 6 |
| 239 | |
| 240 | /// O numero digitado e `id`. Vale com espacos e com os algarismos do teclado do idioma |
| 241 | /// (arabicos orientais, persas, devanagari), que viram 0 a 9. |
| 242 | public static func idConfirmed(_ typed: String, id: UInt64) -> Bool { |
| 243 | guard let digits = asciiDigits(typed), let number = UInt64(digits) else { return false } |
| 244 | return number == id |
| 245 | } |
| 246 | |
| 247 | /// Os algarismos de `text` em 0 a 9, sem os espacos; `nil` se houver qualquer outra coisa. |
| 248 | static func asciiDigits(_ text: String) -> String? { |
| 249 | var out = "" |
| 250 | for scalar in text.unicodeScalars where !scalar.properties.isWhitespace { |
| 251 | guard scalar.properties.numericType == .decimal, let value = scalar.properties.numericValue, |
| 252 | let digit = Int(exactly: value), (0...9).contains(digit) else { return nil } |
| 253 | out += String(digit) |
| 254 | } |
| 255 | return out.isEmpty ? nil : out |
| 256 | } |
| 257 | |
| 258 | /// O trecho que o dono digita lendo na fonte, quando nao ha com o que comparar: os 6 do |
| 259 | /// meio do endereco. Num M da Stellar o meio ainda e a chave, igual em todo M da mesma |
| 260 | /// conta, e a troca so do ID passava; ali o trecho e o fim, o ID e o checksum (revisao |
| 261 | /// do B-6). |
| 262 | public static func blindSegment(_ address: String, chain: Chain, length: Int = 6) -> Segment? { |
| 263 | let text = address.trimmingCharacters(in: .whitespacesAndNewlines) |
| 264 | if chain.family == .stellar, muxedAccount(text)?.isMuxed == true { |
| 265 | return Segment(start: text.count - length, length: length) |
| 266 | } |
| 267 | return middleSegment(address, length: length) |
| 268 | } |
| 269 | |
| 270 | /// O trecho do endereco M depois da chave: o ID e o checksum, o que muda quando so o |
| 271 | /// ID muda. A versao e a chave ocupam 33 bytes, 264 bits; o caractere 52 ainda leva os |
| 272 | /// 4 ultimos bits da chave (e o bit mais alto do ID), e do 53 em diante e so ID e |
| 273 | /// checksum. |
| 274 | public static func muxedIDSegment(_ address: String) -> Segment? { |
| 275 | let text = address.trimmingCharacters(in: .whitespacesAndNewlines) |
| 276 | guard let account = muxedAccount(text), account.isMuxed, text.count > muxedIDStart else { return nil } |
| 277 | return Segment(start: muxedIDStart, length: text.count - muxedIDStart) |
| 278 | } |
| 279 | |
| 280 | static let muxedIDStart = (1 + 32) * 8 / 5 + 1 |
| 281 | |
| 282 | static func muxedAccount(_ address: String) -> StellarMuxedAccount? { |
| 283 | StellarMuxedAccount(address: address.trimmingCharacters(in: .whitespacesAndNewlines)) |
| 284 | } |
| 285 | |
| 286 | /// Um trecho do endereco, em posicoes de caractere do texto como ele e mostrado. |
| 287 | public struct Segment: Equatable, Sendable { |
| 288 | public let start: Int |
| 289 | public let length: Int |
| 290 | |
| 291 | /// O trecho dentro de `address`. |
| 292 | public func text(in address: String) -> String { |
| 293 | String(address.dropFirst(start).prefix(length)) |
| 294 | } |
| 295 | } |
| 296 | |
| 297 | /// Os `length` caracteres do meio do endereco: o que o dono le e digita quando nao ha |
| 298 | /// com o que comparar (historico da rede ilegivel, auditoria 3, B1). |
| 299 | public static func middleSegment(_ address: String, length: Int = 6) -> Segment? { |
| 300 | let count = address.count |
| 301 | guard count >= length + 4 else { return nil } |
| 302 | return Segment(start: (count - length) / 2, length: length) |
| 303 | } |
| 304 | |
| 305 | /// Onde `address` comeca a diferir de `other`: os `length` caracteres a partir da |
| 306 | /// primeira diferenca depois do prefixo da rede. E o trecho que o desafio pede: |
| 307 | /// as pontas o atacante copia, o meio ele nao consegue (auditoria 2, M2). |
| 308 | public static func differingSegment(_ address: String, from other: String, chain: Chain, length: Int = 6) -> Segment? { |
| 309 | let shown = address.trimmingCharacters(in: .whitespacesAndNewlines) |
| 310 | let target = afterPrefix(shown, chain: chain) |
| 311 | let candidate = afterPrefix(other, chain: chain) |
| 312 | guard target.count >= length, target != candidate else { return nil } |
| 313 | let fixed = shown.count - target.count |
| 314 | let first = fixed + commonPrefix(target, candidate) |
| 315 | let start = min(first, shown.count - length) |
| 316 | return Segment(start: max(start, fixed), length: length) |
| 317 | } |
| 318 | |
| 319 | /// O endereco sem o prefixo que a rede impoe (e, na NEAR, sem o registro do nome), em |
| 320 | /// minusculas. |
| 321 | static func body(_ address: String, chain: Chain) -> String { |
| 322 | let text = address.trimmingCharacters(in: .whitespacesAndNewlines) |
| 323 | return bodySegment(text, chain: chain).text(in: text).lowercased() |
| 324 | } |
| 325 | |
| 326 | /// Tudo depois do prefixo que a rede impoe, em minusculas, com o registro do nome da |
| 327 | /// NEAR: e por ele que se sabe se dois enderecos sao o mesmo texto. |
| 328 | static func afterPrefix(_ address: String, chain: Chain) -> String { |
| 329 | let text = address.trimmingCharacters(in: .whitespacesAndNewlines) |
| 330 | return String(text.dropFirst(bodySegment(text, chain: chain).start)).lowercased() |
| 331 | } |
| 332 | |
| 333 | /// Onde fica o corpo do endereco, no texto como ele e mostrado (sem espaco nas pontas): |
| 334 | /// tudo depois do prefixo que a rede impoe e, na NEAR, antes do registro do nome. A |
| 335 | /// tela abrevia por aqui (`Fmt.address`), para os 6 do comeco serem do corpo, e nao o |
| 336 | /// "addr1q" que todo endereco tem. |
| 337 | public static func bodySegment(_ address: String, chain: Chain) -> Segment { |
| 338 | let text = address.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() |
| 339 | let count = text.count |
| 340 | let start: Int |
| 341 | switch chain.family { |
| 342 | case .evm, .sui, .aptos: |
| 343 | start = text.hasPrefix("0x") ? 2 : 0 |
| 344 | case .utxo: |
| 345 | if let hrp = UTXOParams.for(chain).bech32HRP, text.hasPrefix(hrp + "1") { |
| 346 | start = min(hrp.count + 2, count) // hrp, separador e versao (q, p) |
| 347 | } else { |
| 348 | start = min(1, count) // caractere de versao do base58 |
| 349 | } |
| 350 | case .tron, .xrpl, .stellar: |
| 351 | start = min(1, count) |
| 352 | case .ton: |
| 353 | start = text.hasPrefix("0:") || count > 2 ? 2 : 0 |
| 354 | case .solana: |
| 355 | start = 0 |
| 356 | case .cardano: |
| 357 | // "addr1" e o caractere do cabecalho (tipo de endereco), que o atacante copia |
| 358 | // de graca, como o hrp e a versao do bech32 no Bitcoin. |
| 359 | start = text.hasPrefix("addr1") && count > 6 ? 6 : 0 |
| 360 | case .polkadot: |
| 361 | // O prefixo de rede 0 faz todo endereco da Polkadot comecar com 1. |
| 362 | start = min(1, count) |
| 363 | case .near: |
| 364 | // Conta implicita sem prefixo; nome inteiro, que o dono escolhe letra a letra, |
| 365 | // menos o registro do fim (".near", ".tg"): ele e igual por desenho, e contado |
| 366 | // como ponta fazia "alexis-exchange.near" parecido com "alexander-wallet.near" |
| 367 | // (B-9). |
| 368 | if let suffix = NEARAccountID.registrySuffixes.first(where: { text.hasSuffix($0) && count > $0.count }) { |
| 369 | return Segment(start: 0, length: count - suffix.count) |
| 370 | } |
| 371 | start = 0 |
| 372 | } |
| 373 | return Segment(start: start, length: count - start) |
| 374 | } |
| 375 | |
| 376 | static func commonPrefix(_ a: String, _ b: String) -> Int { |
| 377 | zip(a, b).prefix { $0 == $1 }.count |
| 378 | } |
| 379 | } |