Auditoria
O contrato entre redes e assinador
Kit/Sources/EscaliburChains/Signing.swift
O contrato entre as redes e o assinador.
| 1 | import EscaliburCore |
| 2 | import Foundation |
| 3 | |
| 4 | // O contrato entre as redes e o assinador. |
| 5 | // |
| 6 | // EscaliburChains monta a transacao e diz, byte a byte, o que precisa ser assinado. |
| 7 | // EscaliburKeys deriva a chave, assina, confere a assinatura e devolve. Nenhum lado |
| 8 | // faz o trabalho do outro: a rede nunca ve chave privada, e o assinador nunca decide |
| 9 | // o que assinar. |
| 10 | |
| 11 | /// Como a assinatura e feita e em que formato ela volta. |
| 12 | public enum SignatureScheme: String, Sendable, Codable { |
| 13 | /// ECDSA secp256k1, low-S, em DER. Bitcoin, Litecoin, Dogecoin, XRP Ledger. |
| 14 | case ecdsaDER |
| 15 | /// ECDSA secp256k1, low-S, `r || s` (64 bytes) mais o id de recuperacao. EVM, Tron. |
| 16 | case ecdsaRecoverable |
| 17 | /// Ed25519 sobre a mensagem inteira (nao sobre um digesto). Solana, Stellar, TON. |
| 18 | case ed25519 |
| 19 | /// Schnorr BIP-340 com a chave ajustada pelo BIP-86. Taproot. |
| 20 | case schnorrBIP340 |
| 21 | /// Ed25519 com chave estendida BIP32-Ed25519, da chave mestra Icarus (CIP-3), sobre o |
| 22 | /// hash de 32 bytes do corpo da transacao. Cardano. |
| 23 | case ed25519Cardano |
| 24 | } |
| 25 | |
| 26 | /// Uma assinatura que a transacao precisa. |
| 27 | public struct SigningRequest: Sendable, Equatable { |
| 28 | /// Qual chave: o caminho a partir da seed da carteira. |
| 29 | public let path: DerivationPath |
| 30 | public let curve: Curve |
| 31 | public let scheme: SignatureScheme |
| 32 | /// Digesto de 32 bytes (ECDSA, Schnorr) ou a mensagem inteira (Ed25519), |
| 33 | /// **sempre calculado localmente** a partir dos campos da transacao. |
| 34 | public let payload: [UInt8] |
| 35 | /// A chave publica que a chave derivada tem de ter: 33 bytes comprimidos na |
| 36 | /// secp256k1, 32 na Ed25519. O assinador recusa se nao bater, e isso pega caminho |
| 37 | /// errado antes de uma assinatura sair com a chave de outra conta. |
| 38 | public let expectedPublicKey: [UInt8] |
| 39 | |
| 40 | public init(path: DerivationPath, curve: Curve, scheme: SignatureScheme, payload: [UInt8], expectedPublicKey: [UInt8]) { |
| 41 | self.path = path |
| 42 | self.curve = curve |
| 43 | self.scheme = scheme |
| 44 | self.payload = payload |
| 45 | self.expectedPublicKey = expectedPublicKey |
| 46 | } |
| 47 | } |
| 48 | |
| 49 | /// O que o assinador devolve para cada `SigningRequest`, na mesma ordem. |
| 50 | public struct ProducedSignature: Sendable, Equatable { |
| 51 | /// DER (ecdsaDER), `r || s` (ecdsaRecoverable), 64 bytes (ed25519, schnorr). |
| 52 | public let bytes: [UInt8] |
| 53 | /// So em ecdsaRecoverable: 0 ou 1. |
| 54 | public let recoveryID: UInt8? |
| 55 | |
| 56 | public init(bytes: [UInt8], recoveryID: UInt8? = nil) { |
| 57 | self.bytes = bytes |
| 58 | self.recoveryID = recoveryID |
| 59 | } |
| 60 | } |
| 61 | |
| 62 | /// A transacao pronta para transmitir. |
| 63 | public struct SignedTransaction: Sendable, Equatable, Codable { |
| 64 | public let chainID: String |
| 65 | public let raw: [UInt8] |
| 66 | /// A forma que o provedor da rede recebe: hex, base64, JSON. Transmitir sempre |
| 67 | /// estes mesmos bytes, nunca reassinar: na Solana e no XRP Ledger com Ed25519 a |
| 68 | /// reassinatura muda o id da transacao. |
| 69 | public let encoded: String |
| 70 | /// O identificador que o explorador de blocos entende. |
| 71 | public let id: String |
| 72 | |
| 73 | public init(chainID: String, raw: [UInt8], encoded: String, id: String) { |
| 74 | self.chainID = chainID |
| 75 | self.raw = raw |
| 76 | self.encoded = encoded |
| 77 | self.id = id |
| 78 | } |
| 79 | } |
| 80 | |
| 81 | /// Uma transacao que sabe dizer o que precisa ser assinado e se montar depois. |
| 82 | public protocol SignableTransaction: Sendable { |
| 83 | var chain: Chain { get } |
| 84 | var signingRequests: [SigningRequest] { get } |
| 85 | func assemble(with signatures: [ProducedSignature]) throws -> SignedTransaction |
| 86 | } |
| 87 | |
| 88 | public enum SigningError: Error, Equatable, Sendable { |
| 89 | case wrongSignatureCount |
| 90 | case malformedSignature |
| 91 | case expired |
| 92 | } |
| 93 | |
| 94 | // MARK: Plano |
| 95 | |
| 96 | /// Um lote de transacoes que passou pela validacao e pode ser assinado. |
| 97 | /// |
| 98 | /// **O inicializador e interno a EscaliburChains.** Nenhum outro modulo, nem os motores |
| 99 | /// de EscaliburEngines nem o app, constroi um plano: ele so nasce dos planejadores das |
| 100 | /// redes, que conferem a intencao do dono contra o que vai ser assinado. De fora, a |
| 101 | /// unica composicao generica e somar avisos (`addingWarnings`). Encadear planos para |
| 102 | /// assinar de uma vez (`sequence`) tambem e interno: so os compositores nomeados de |
| 103 | /// cada rede (`TradePlanner.combineSplit`, `XRPLPlanner.combineTrustlineAndOffer`) |
| 104 | /// juntam planos, e cada um deriva tipo, titulo, linhas e movimentos dos planos que |
| 105 | /// junta. Nenhum motor escreve a revisao. O assinador de EscaliburKeys so aceita este |
| 106 | /// tipo, entao "assinar sem validar" nao compila. |
| 107 | public struct SigningPlan: Sendable { |
| 108 | public let id: UUID |
| 109 | public let walletID: UUID |
| 110 | public let chain: Chain |
| 111 | /// O que a tela de revisao mostra, ja decodificado. Nunca o JSON do provedor. |
| 112 | public let review: PlanReview |
| 113 | public let transactions: [any SignableTransaction] |
| 114 | public let createdAt: Date |
| 115 | |
| 116 | /// Um plano vale 60 segundos. Passou disso, recota e reconstroi: nonce, taxa e |
| 117 | /// cotacao ficam velhos, e uma assinatura sobre dado velho nao sai. |
| 118 | public static let lifetime: TimeInterval = 60 |
| 119 | |
| 120 | init(walletID: UUID, chain: Chain, review: PlanReview, transactions: [any SignableTransaction], createdAt: Date = .now) { |
| 121 | self.init(id: UUID(), walletID: walletID, chain: chain, review: review, transactions: transactions, createdAt: createdAt) |
| 122 | } |
| 123 | |
| 124 | private init(id: UUID, walletID: UUID, chain: Chain, review: PlanReview, transactions: [any SignableTransaction], createdAt: Date) { |
| 125 | self.id = id |
| 126 | self.walletID = walletID |
| 127 | self.chain = chain |
| 128 | self.review = review |
| 129 | self.transactions = transactions |
| 130 | self.createdAt = createdAt |
| 131 | } |
| 132 | |
| 133 | public func isExpired(now: Date = .now) -> Bool { |
| 134 | now.timeIntervalSince(createdAt) > Self.lifetime |
| 135 | } |
| 136 | |
| 137 | // MARK: Composicao |
| 138 | |
| 139 | public enum CompositionError: Error, Equatable, Sendable { |
| 140 | /// Nenhum plano para encadear. |
| 141 | case empty |
| 142 | /// Planos de carteiras ou de redes diferentes nao se assinam juntos. |
| 143 | case mixedPlans |
| 144 | /// Um envio dentro de uma troca ou ordem: a revisao chamaria de troca uma |
| 145 | /// transferencia para outra pessoa. |
| 146 | case sendInsideTrade |
| 147 | /// As partes nao formam a operacao que o compositor junta (tipo, ativo, conta, |
| 148 | /// sequencia ou valores diferentes do que cada parte validou). |
| 149 | case partsDoNotMatch |
| 150 | } |
| 151 | |
| 152 | /// O mesmo plano com avisos a mais, os que so o motor sabe (endereco parecido com |
| 153 | /// um ja usado, primeiro envio). Transacoes, destino, titulo, linhas, identidade e |
| 154 | /// prazo nao mudam; aviso repetido nao entra. |
| 155 | public func addingWarnings(_ warnings: [PlanReview.Warning]) -> SigningPlan { |
| 156 | let fresh = warnings.reduce(into: [PlanReview.Warning]()) { out, warning in |
| 157 | if !review.warnings.contains(warning), !out.contains(warning) { out.append(warning) } |
| 158 | } |
| 159 | guard !fresh.isEmpty else { return self } |
| 160 | let updated = PlanReview( |
| 161 | kind: review.kind, title: review.title, lines: review.lines, warnings: review.warnings + fresh, |
| 162 | transactionCount: review.transactionCount, recipient: review.recipient, recipientTag: review.recipientTag, |
| 163 | outgoing: review.outgoing, incomingMinimum: review.incomingMinimum, beneficiary: review.beneficiary |
| 164 | ) |
| 165 | return SigningPlan(id: id, walletID: walletID, chain: chain, review: updated, transactions: transactions, createdAt: createdAt) |
| 166 | } |
| 167 | |
| 168 | /// O mesmo plano com uma linha a mais na revisao, logo depois da linha `after` (ou no |
| 169 | /// comeco, se ela nao existe). So os compositores de EscaliburChains chamam: o motor |
| 170 | /// nao escreve linha. Transacoes, titulo, movimentos e prazo nao mudam. |
| 171 | func addingLine(_ line: PlanReview.Line, after label: String) -> SigningPlan { |
| 172 | var lines = review.lines |
| 173 | let index = lines.firstIndex { $0.label == label }.map { $0 + 1 } ?? 0 |
| 174 | lines.insert(line, at: index) |
| 175 | let updated = PlanReview( |
| 176 | kind: review.kind, title: review.title, lines: lines, warnings: review.warnings, |
| 177 | transactionCount: review.transactionCount, recipient: review.recipient, recipientTag: review.recipientTag, |
| 178 | outgoing: review.outgoing, incomingMinimum: review.incomingMinimum, beneficiary: review.beneficiary |
| 179 | ) |
| 180 | return SigningPlan(id: id, walletID: walletID, chain: chain, review: updated, transactions: transactions, createdAt: createdAt) |
| 181 | } |
| 182 | |
| 183 | /// Planos ja validados, assinados de uma vez e transmitidos na ordem dada (a |
| 184 | /// autorizacao antes da troca, as pernas de uma divisao, a linha de confianca antes |
| 185 | /// da oferta). |
| 186 | /// |
| 187 | /// **Interno.** So os compositores nomeados das redes chamam, com tipo, titulo, |
| 188 | /// abertura e movimentos que eles derivaram dos proprios planos. A revisao e a soma |
| 189 | /// das revisoes: as linhas de abertura (`lead`), depois as de cada plano, com |
| 190 | /// "Etapa n ·" na frente quando `stepPrefix`, sem as de rotulo em `omitting` (o que a |
| 191 | /// abertura ja resume). Linha de conferencia caractere a caractere (`verbatim`) nunca |
| 192 | /// sai, mesmo com o rotulo na lista. Troca e ordem nao levam envio dentro. Os avisos |
| 193 | /// de todos entram, sem repeticao. O prazo conta do plano mais antigo. |
| 194 | static func sequence( |
| 195 | _ plans: [SigningPlan], kind: PlanReview.Kind, title: String, lead: [PlanReview.Line], |
| 196 | stepPrefix: Bool = false, omitting labels: Set<String> = [], |
| 197 | outgoing: PlanReview.Movement?, incomingMinimum: PlanReview.Movement?, beneficiary: String? |
| 198 | ) throws -> SigningPlan { |
| 199 | guard let first = plans.first else { throw CompositionError.empty } |
| 200 | guard plans.allSatisfy({ $0.walletID == first.walletID && $0.chain.id == first.chain.id }) else { |
| 201 | throw CompositionError.mixedPlans |
| 202 | } |
| 203 | if kind == .swap || kind == .limitOrder { |
| 204 | guard !plans.contains(where: { $0.review.kind == .send || $0.review.recipient != nil }) else { |
| 205 | throw CompositionError.sendInsideTrade |
| 206 | } |
| 207 | } |
| 208 | var lines = lead |
| 209 | for (index, plan) in plans.enumerated() { |
| 210 | for line in plan.review.lines where line.verbatim || !labels.contains(line.label) { |
| 211 | lines.append(PlanReview.Line( |
| 212 | stepPrefix ? kitText("Etapa \(index + 1) · \(line.label)") : line.label, line.value, verbatim: line.verbatim, role: line.role |
| 213 | )) |
| 214 | } |
| 215 | } |
| 216 | var warnings: [PlanReview.Warning] = [] |
| 217 | for warning in plans.flatMap(\.review.warnings) where !warnings.contains(warning) { warnings.append(warning) } |
| 218 | let transactions = plans.flatMap(\.transactions) |
| 219 | let review = PlanReview( |
| 220 | kind: kind, title: title, lines: lines, warnings: warnings, transactionCount: transactions.count, |
| 221 | outgoing: outgoing, incomingMinimum: incomingMinimum, beneficiary: beneficiary |
| 222 | ) |
| 223 | return SigningPlan( |
| 224 | walletID: first.walletID, chain: first.chain, review: review, transactions: transactions, |
| 225 | createdAt: plans.map(\.createdAt).min() ?? first.createdAt |
| 226 | ) |
| 227 | } |
| 228 | } |
| 229 | |
| 230 | /// O resumo que a tela de revisao mostra, montado pela validacao. |
| 231 | public struct PlanReview: Sendable, Equatable { |
| 232 | public enum Kind: String, Sendable { |
| 233 | case send, swap, approve, limitOrder, cancelOrder, trustline, revoke, bridge |
| 234 | } |
| 235 | |
| 236 | public struct Line: Sendable, Equatable { |
| 237 | /// O que a linha e, para quem decide pela linha e nao pelo texto: o rotulo sai |
| 238 | /// no idioma do aparelho, o papel nao muda. |
| 239 | public enum Role: Sendable, Equatable { |
| 240 | case plain |
| 241 | /// O destino do envio, como entrou na transacao. |
| 242 | case recipient |
| 243 | /// O valor que sai na troca, conferido de novo antes de assinar. |
| 244 | case sold |
| 245 | /// Contrato, mint, emissor ou conta de token: diz qual e o ativo, nao para |
| 246 | /// quem vai. A revisao do envio deixa de fora (pedido do dono, 14/10/2026). |
| 247 | case assetReference |
| 248 | } |
| 249 | |
| 250 | public let label: String |
| 251 | public let value: String |
| 252 | /// Valor que se confere caractere por caractere (endereco, tag): vai para a |
| 253 | /// placa clara, em mono. |
| 254 | public let verbatim: Bool |
| 255 | public let role: Role |
| 256 | |
| 257 | public init(_ label: String, _ value: String, verbatim: Bool = false, role: Role = .plain) { |
| 258 | self.label = label |
| 259 | self.value = value |
| 260 | self.verbatim = verbatim |
| 261 | self.role = role |
| 262 | } |
| 263 | } |
| 264 | |
| 265 | public enum Warning: Sendable, Equatable { |
| 266 | case firstSendToAddress |
| 267 | case lookalikeAddress(known: String) |
| 268 | case noDestinationTag |
| 269 | case destinationIsContract |
| 270 | case highPriceImpact(percent: Double) |
| 271 | case unlimitedApproval |
| 272 | case highFee(percentOfAmount: Double) |
| 273 | case unverifiedToken(symbol: String) |
| 274 | case activatesAccount(minimum: String) |
| 275 | /// A ponte toca a BNB: la o USDC, o USDT e o ETH sao os da Binance (Binance-Peg), |
| 276 | /// nao os da Circle nem os da Tether (revisao da ponte BNB, 14/10/2026, M1). |
| 277 | case binancePeg(symbol: String) |
| 278 | /// A troca vai para a fila publica da rede: um robo de sanduiche pode levar o que |
| 279 | /// ficar entre a estimativa e o minimo garantido (auditoria 4, M2). |
| 280 | case publicMempool |
| 281 | } |
| 282 | |
| 283 | /// Quanto de qual ativo, em unidades da rede. O ativo e o `Asset.id` da lista |
| 284 | /// (`TokenRegistry`), o mesmo que a tela usa. |
| 285 | public struct Movement: Sendable, Equatable { |
| 286 | public let assetID: String |
| 287 | public let amount: BigUInt |
| 288 | |
| 289 | public init(assetID: String, amount: BigUInt) { |
| 290 | self.assetID = assetID |
| 291 | self.amount = amount |
| 292 | } |
| 293 | } |
| 294 | |
| 295 | public let kind: Kind |
| 296 | /// "Enviar 50 XRP", "Trocar 0,5 ETH por USDC". |
| 297 | public let title: String |
| 298 | public let lines: [Line] |
| 299 | public let warnings: [Warning] |
| 300 | /// Quantas transacoes o dono vai assinar neste plano. |
| 301 | public let transactionCount: Int |
| 302 | /// Num envio: o destino exatamente como entrou na transacao, e a tag, o memo ou o |
| 303 | /// comentario que foi junto. O app confere contra o que o dono digitou antes de |
| 304 | /// mostrar a revisao e de novo antes de assinar (`Address.sameRecipient`). |
| 305 | public let recipient: String? |
| 306 | public let recipientTag: String? |
| 307 | /// O que sai da carteira: no envio, o valor enviado; na troca e na ordem, o valor |
| 308 | /// vendido. Preenchido so pelo planejador, das transacoes que ele montou; o app |
| 309 | /// confere contra o ativo e o valor pedidos antes de revisar e antes de assinar. |
| 310 | public let outgoing: Movement? |
| 311 | /// Na troca e na ordem: o minimo que entra, garantido pela transacao. |
| 312 | public let incomingMinimum: Movement? |
| 313 | /// Na troca e na ordem: quem recebe o que entra. Sempre a propria conta do dono. |
| 314 | public let beneficiary: String? |
| 315 | |
| 316 | public init( |
| 317 | kind: Kind, title: String, lines: [Line], warnings: [Warning] = [], transactionCount: Int = 1, |
| 318 | recipient: String? = nil, recipientTag: String? = nil, |
| 319 | outgoing: Movement? = nil, incomingMinimum: Movement? = nil, beneficiary: String? = nil |
| 320 | ) { |
| 321 | self.kind = kind |
| 322 | self.title = title |
| 323 | self.lines = lines |
| 324 | self.warnings = warnings |
| 325 | self.transactionCount = transactionCount |
| 326 | self.recipient = recipient |
| 327 | self.recipientTag = recipientTag |
| 328 | self.outgoing = outgoing |
| 329 | self.incomingMinimum = incomingMinimum |
| 330 | self.beneficiary = beneficiary |
| 331 | } |
| 332 | } |