Pular para o conteúdo
Escalibur Wallet
Auditoria

A guarda de chamada EVM

Kit/Sources/EscaliburChains/EVM/EVMCallGuard.swift

A guarda de chamada: toda transação EVM passa por aqui antes de virar plano.

236 linhasSHA-256 e39bba2e2259264f…Abrir no GitHub, no commit 015f5f5
1import EscaliburCore
2import Foundation
3
4// A guarda de chamada: toda transacao EVM passa por aqui antes de virar plano.
5//
6// Ela decodifica a calldata e so deixa passar o que sabe ler por inteiro. A regra e
7// a de docs/seguranca.md 4.3: **sem decodificador, sem assinatura.** O que a
8// carteira nao entende nao vai para a tela como "dados hex" para o dono aprovar as
9// cegas; e recusado.
10//
11// Recusado sempre, antes de qualquer allowlist:
12// - tipo 4 (EIP-7702): delega a conta inteira a um contrato; com chainId 0 vale em
13// todas as redes de uma vez. Numa amostra, 63% das autorizacoes eram de drainer.
14// - `setApprovalForAll`: entrega todos os NFTs de uma colecao.
15// - `increaseAllowance`/`increaseApproval`: approve disfarcado, fora do fluxo de
16// valor exato.
17// - `permit` (EIP-2612, DAI, Permit2): permissao que o dono nao ve virar gasto.
18// - criacao de contrato (sem destino).
19// - calldata que nenhum decodificador reconhece.
20
21public enum EVMCallRefusal: Error, Equatable, Sendable {
22 case eip7702Authorization
23 case unsupportedTransactionType(UInt8)
24 case contractCreation
25 case malformedCalldata
26 case setApprovalForAll
27 case increaseAllowance
28 case permit
29 case spenderNotAllowed(EVMAddress)
30 /// Seletor sem decodificador conhecido para aquele destino.
31 case unknownSelector([UInt8])
32 /// Valor nativo junto de uma chamada ERC-20 (que nao e payable).
33 case valueWithTokenCall
34 /// `transfer` para o proprio contrato do token: o token fica preso para sempre.
35 case recipientIsTokenContract
36 /// Destino 0x000...000.
37 case burnAddress
38 /// Destino que a politica bloqueia (router, contrato de token conhecido).
39 case blockedRecipient(EVMAddress)
40 /// Os argumentos nao decodificam de forma canonica.
41 case invalidArguments(ABIError)
42 /// A regra da chamada de contrato recusou o conteudo.
43 case ruleRejected(String)
44}
45
46/// A transacao como proposta: o tipo cru, destino, valor e calldata. Pode vir de um
47/// `EVMTransaction` montado aqui ou da resposta de um provedor.
48public struct EVMCallProposal: Sendable, Equatable {
49 public let transactionType: UInt8
50 /// `nil` e criacao de contrato.
51 public let to: EVMAddress?
52 public let value: BigUInt
53 public let data: [UInt8]
54
55 public init(transactionType: UInt8, to: EVMAddress?, value: BigUInt, data: [UInt8]) {
56 self.transactionType = transactionType
57 self.to = to
58 self.value = value
59 self.data = data
60 }
61
62 public init(_ transaction: EVMTransaction) {
63 self.init(transactionType: transaction.transactionType, to: transaction.to, value: transaction.value, data: transaction.data)
64 }
65}
66
67/// Uma chamada de contrato que a camada de cima sabe decodificar e validar (router
68/// de swap, por exemplo). A funcao fixa o seletor e os tipos; `validate` confere os
69/// argumentos decodificados contra a intencao do dono e lanca para recusar.
70public struct EVMContractCallRule: Sendable {
71 public let contract: EVMAddress
72 public let function: ABIFunction
73 public let validate: @Sendable (_ arguments: [ABIValue], _ value: BigUInt) throws -> Void
74
75 public init(contract: EVMAddress, function: ABIFunction, validate: @escaping @Sendable (_ arguments: [ABIValue], _ value: BigUInt) throws -> Void) {
76 self.contract = contract
77 self.function = function
78 self.validate = validate
79 }
80}
81
82/// O que a guarda aceita alem do basico. Tudo vem de quem chama, compilado.
83public struct EVMCallPolicy: Sendable {
84 /// Spenders que podem receber approve de valor diferente de zero.
85 public var approvedSpenders: Set<EVMAddress>
86 /// Destinos que nunca recebem valor nem token: routers, contratos de token.
87 public var blockedRecipients: Set<EVMAddress>
88 public var contractRules: [EVMContractCallRule]
89 /// A unica permissao do Permit2 que passa: a que o plano de troca pela Uniswap monta,
90 /// com token, router, valor e prazo exatos (docs/seguranca.md 4.4). Qualquer outra
91 /// chamada `approve` ao Permit2, e todo permit assinado, continua recusado.
92 public var permit2Approval: EVMPermit2Approval?
93
94 public init(approvedSpenders: Set<EVMAddress> = [], blockedRecipients: Set<EVMAddress> = [], contractRules: [EVMContractCallRule] = [],
95 permit2Approval: EVMPermit2Approval? = nil) {
96 self.approvedSpenders = approvedSpenders
97 self.blockedRecipients = blockedRecipients
98 self.contractRules = contractRules
99 self.permit2Approval = permit2Approval
100 }
101}
102
103/// `Permit2.approve(token, spender, amount, expiration)`, na cadeia: a permissao interna do
104/// Permit2 para um router puxar o token que ja foi aprovado ao Permit2. Transacao visivel,
105/// nao assinatura fora da cadeia; valor exato e prazo curto.
106public struct EVMPermit2Approval: Sendable, Equatable {
107 public static let function = try! ABIFunction("approve(address,address,uint160,uint48)") // 87517c45
108 /// Prazo maximo: 30 minutos (docs/seguranca.md 4.4).
109 public static let maxLifetime: UInt64 = 30 * 60
110
111 public let token: EVMAddress
112 public let spender: EVMAddress
113 public let amount: BigUInt
114 /// Segundos Unix.
115 public let expiration: UInt64
116
117 public init(token: EVMAddress, spender: EVMAddress, amount: BigUInt, expiration: UInt64) {
118 self.token = token
119 self.spender = spender
120 self.amount = amount
121 self.expiration = expiration
122 }
123
124 public var arguments: [ABIValue] {
125 [.address(token), .address(spender), .uint(amount), .uint(BigUInt(expiration))]
126 }
127
128 public func calldata() throws -> [UInt8] {
129 try Self.function.encodeCall(arguments)
130 }
131}
132
133/// O que a transacao faz, decodificado. E isto que vai para a revisao.
134public enum EVMDecodedCall: Sendable, Equatable {
135 case nativeTransfer(to: EVMAddress, amount: BigUInt)
136 case tokenTransfer(token: EVMAddress, to: EVMAddress, amount: BigUInt)
137 case tokenApproval(token: EVMAddress, spender: EVMAddress, amount: BigUInt)
138 case contractCall(contract: EVMAddress, function: String, arguments: [ABIValue], value: BigUInt)
139}
140
141public enum EVMCallGuard {
142 // Assinaturas recusadas. Os seletores saem do keccak da assinatura e os testes
143 // conferem contra os valores publicados (4byte.directory, EIPs 2612 e o Permit2).
144 static let deniedFunctions: [(signature: String, refusal: EVMCallRefusal)] = [
145 ("setApprovalForAll(address,bool)", .setApprovalForAll), // a22cb465
146 ("increaseAllowance(address,uint256)", .increaseAllowance), // 39509351
147 ("increaseApproval(address,uint256)", .increaseAllowance), // d73dd623
148 ("permit(address,address,uint256,uint256,uint8,bytes32,bytes32)", .permit), // d505accf EIP-2612
149 ("permit(address,address,uint256,uint256,bool,uint8,bytes32,bytes32)", .permit), // 8fcbaf0c DAI
150 ("permit(address,((address,uint160,uint48,uint48),address,uint256),bytes)", .permit), // 2b67b570 Permit2
151 ("permit(address,((address,uint160,uint48,uint48)[],address,uint256),bytes)", .permit), // 2a2d80d1 Permit2
152 ("approve(address,address,uint160,uint48)", .permit), // 87517c45 Permit2
153 ]
154
155 static let deniedSelectors: [[UInt8]: EVMCallRefusal] = {
156 var out = [[UInt8]: EVMCallRefusal]()
157 for entry in deniedFunctions {
158 // As assinaturas sao literais deste arquivo; se uma nao ler, o teste pega.
159 if let function = try? ABIFunction(entry.signature) { out[function.selector] = entry.refusal }
160 }
161 return out
162 }()
163
164 /// Decodifica e valida. Devolve o que a transacao faz, ou lanca o motivo da recusa.
165 public static func inspect(_ proposal: EVMCallProposal, policy: EVMCallPolicy) throws -> EVMDecodedCall {
166 switch proposal.transactionType {
167 case 0, 2: break
168 case 4: throw EVMCallRefusal.eip7702Authorization
169 default: throw EVMCallRefusal.unsupportedTransactionType(proposal.transactionType)
170 }
171 guard let to = proposal.to else { throw EVMCallRefusal.contractCreation }
172
173 if proposal.data.isEmpty {
174 try checkRecipient(to, policy: policy)
175 return .nativeTransfer(to: to, amount: proposal.value)
176 }
177 guard proposal.data.count >= 4 else { throw EVMCallRefusal.malformedCalldata }
178 let selector = Array(proposal.data.prefix(4))
179 if let refusal = deniedSelectors[selector] {
180 // So a permissao do Permit2 que o plano declarou, ao proprio Permit2, sem valor.
181 guard let allowed = policy.permit2Approval, selector == EVMPermit2Approval.function.selector,
182 to == EIP712ValidatedMessage.permit2, proposal.value.isZero,
183 let arguments = try? EVMPermit2Approval.function.decodeCall(proposal.data), arguments == allowed.arguments
184 else { throw refusal }
185 return .contractCall(contract: to, function: EVMPermit2Approval.function.signature, arguments: arguments, value: 0)
186 }
187
188 if selector == ERC20.transferFunction.selector {
189 let (recipient, amount) = try addressAndAmount(ERC20.transferFunction, proposal.data)
190 guard proposal.value.isZero else { throw EVMCallRefusal.valueWithTokenCall }
191 guard recipient != to else { throw EVMCallRefusal.recipientIsTokenContract }
192 try checkRecipient(recipient, policy: policy)
193 return .tokenTransfer(token: to, to: recipient, amount: amount)
194 }
195 if selector == ERC20.approveFunction.selector {
196 let (spender, amount) = try addressAndAmount(ERC20.approveFunction, proposal.data)
197 guard proposal.value.isZero else { throw EVMCallRefusal.valueWithTokenCall }
198 // Revogar (approve 0) vale para qualquer spender: e assim que se desfaz
199 // uma aprovacao antiga, inclusive de router que morreu.
200 if !amount.isZero, !policy.approvedSpenders.contains(spender) {
201 throw EVMCallRefusal.spenderNotAllowed(spender)
202 }
203 return .tokenApproval(token: to, spender: spender, amount: amount)
204 }
205 for rule in policy.contractRules where rule.contract == to && rule.function.selector == selector {
206 let arguments: [ABIValue]
207 do {
208 arguments = try rule.function.decodeCall(proposal.data)
209 } catch let error as ABIError {
210 throw EVMCallRefusal.invalidArguments(error)
211 }
212 try rule.validate(arguments, proposal.value)
213 return .contractCall(contract: to, function: rule.function.signature, arguments: arguments, value: proposal.value)
214 }
215 // Calldata desconhecida e recusada mesmo para destino sem codigo: "tem codigo"
216 // e dado de rede, e a guarda nao aposta nele.
217 throw EVMCallRefusal.unknownSelector(selector)
218 }
219
220 static func checkRecipient(_ recipient: EVMAddress, policy: EVMCallPolicy) throws {
221 guard !recipient.isZero else { throw EVMCallRefusal.burnAddress }
222 guard !policy.blockedRecipients.contains(recipient) else { throw EVMCallRefusal.blockedRecipient(recipient) }
223 }
224
225 static func addressAndAmount(_ function: ABIFunction, _ data: [UInt8]) throws -> (EVMAddress, BigUInt) {
226 do {
227 let arguments = try function.decodeCall(data)
228 guard arguments.count == 2, let address = arguments[0].addressValue, let amount = arguments[1].uintValue else {
229 throw EVMCallRefusal.malformedCalldata
230 }
231 return (address, amount)
232 } catch let error as ABIError {
233 throw EVMCallRefusal.invalidArguments(error)
234 }
235 }
236}