Pular para o conteúdo
Escalibur Wallet
Auditoria

O contrato entre redes e assinador

Kit/Sources/EscaliburChains/Signing.swift

O contrato entre as redes e o assinador.

332 linhasSHA-256 a3ddd2057d5b4776…Abrir no GitHub, no commit 015f5f5
1import EscaliburCore
2import 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.
12public 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.
27public 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.
50public 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.
63public 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.
82public protocol SignableTransaction: Sendable {
83 var chain: Chain { get }
84 var signingRequests: [SigningRequest] { get }
85 func assemble(with signatures: [ProducedSignature]) throws -> SignedTransaction
86}
87
88public 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.
107public 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.
231public 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}