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.
| 1 | import EscaliburCore |
| 2 | import 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 | |
| 21 | public 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. |
| 48 | public 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. |
| 70 | public 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. |
| 83 | public 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. |
| 106 | public 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. |
| 134 | public 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 | |
| 141 | public 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 | } |