// // TransactionService.swift // OS Vault // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // import Foundation import BigInt import Web3Core /// Builds, signs and broadcasts transfers, then tracks the receipt. /// Two shapes: native coin (value transfer) and ERC-20 `transfer` (calldata /// to the token contract). Fee logic follows the chain's FeeModel — the /// research finding is that "EIP-1559" hides four realities (BSC's zero base /// fee, OP-stack/Scroll L1 data fees, Arbitrum's inclusive estimates, Linea's /// pinned base). Gas is ALWAYS estimated, never hardcoded — even for native /// sends (Arbitrum folds L1 costs into the gas limit). public enum TransactionService { public struct PreparedTransfer { public let asset: Asset public let network: Network public let from: String public let recipient: String // checksummed public let amountUnits: BigUInt /// Transaction `to`: the recipient (native) or the token contract (ERC-20). public let txDestination: String public let txValue: BigUInt // amount (native) or 0 (ERC-20) public let calldata: Data public let nonce: BigUInt public let gasLimit: BigUInt public let maxFeePerGas: BigUInt public let maxPriorityFeePerGas: BigUInt /// Rollup L1 data fee (OP-stack/Scroll), deducted silently on-chain — /// shown to the user and included in the balance check. public let l1DataFee: BigUInt /// Worst-case cost in native wei on top of any native amount sent. public var maxGasCostWei: BigUInt { gasLimit * maxFeePerGas + l1DataFee } } // MARK: - Prepare (estimate everything, verify funds) public static func prepare(asset: Asset, network: Network, from: String, recipient: String, amountUnits: BigUInt, rpc: RPCService, balances: Balances) async throws -> PreparedTransfer { guard AddressValidator.validate(recipient) != .invalid else { throw WalletError.invalidAddress } let txDestination: String let txValue: BigUInt let calldata: Data switch asset { case .native: txDestination = recipient txValue = amountUnits calldata = Data() if amountUnits > balances.ethWei { throw WalletError.insufficientTokenBalance } case .token(let token): guard let contract = token.address(on: network) else { throw WalletError.internalError("\(token.symbol) is not deployed on \(network.config.displayName).") } guard let data = Hex.erc20TransferData(to: recipient, amount: amountUnits) else { throw WalletError.invalidAddress } txDestination = contract txValue = 0 calldata = data if let held = balances.tokenUnits[token.symbol], amountUnits > held { throw WalletError.insufficientTokenBalance } } let nonce = try await rpc.transactionCount(of: from) let fees = try await feeParameters(for: network, rpc: rpc) let estimated = try await rpc.estimateGas( from: from, to: txDestination, valueWei: txValue, data: calldata ) // Headroom on the estimate — except Arbitrum, whose estimate already // embeds the L1 buffer and is meant to be used verbatim. let gasLimit = network.config.feeModel == .arbitrumInclusive ? estimated : estimated * 12 / 10 // L1 data fee via the rollup's oracle, on an unsigned serialization // of the tx. Best effort: a failing oracle call must not block sends. var l1Fee: BigUInt = 0 if let oracle = network.l1FeeOracle, let destination = EthereumAddress(txDestination, ignoreChecksum: true) { var draft = CodableTransaction( type: .eip1559, to: destination, nonce: nonce, chainID: network.config.chainID, value: txValue, data: calldata, gasLimit: gasLimit, maxFeePerGas: fees.maxFee, maxPriorityFeePerGas: fees.tip ) if let serialized = draft.encode(for: .signature) { var call = Data([0x49, 0x94, 0x8e, 0x0e]) // getL1Fee(bytes) call.append(Hex.abiWord(BigUInt(32))) // offset call.append(Hex.abiWord(BigUInt(serialized.count))) // length var padded = serialized if padded.count % 32 != 0 { padded.append(Data(repeating: 0, count: 32 - padded.count % 32)) } call.append(padded) if let result = try? await rpc.call(to: oracle, data: call), let fee = Hex.toBigUInt(result) { l1Fee = fee } } } let prepared = PreparedTransfer( asset: asset, network: network, from: from, recipient: recipient, amountUnits: amountUnits, txDestination: txDestination, txValue: txValue, calldata: calldata, nonce: nonce, gasLimit: gasLimit, maxFeePerGas: fees.maxFee, maxPriorityFeePerGas: fees.tip, l1DataFee: l1Fee ) // Native sends must cover amount + gas; token sends just the gas. let required = txValue + prepared.maxGasCostWei if balances.ethWei < required { throw WalletError.insufficientETHForGas( needWei: TokenAmount.formatWei(required), haveWei: TokenAmount.formatWei(balances.ethWei) ) } return prepared } /// Per-FeeModel (maxFeePerGas, maxPriorityFeePerGas). static func feeParameters(for network: Network, rpc: RPCService) async throws -> (maxFee: BigUInt, tip: BigUInt) { switch network.config.feeModel { case .zeroBaseFee: // BSC: baseFee is 0 (BEP-226); price entirely via gasPrice. let gasPrice = try await rpc.gasPrice() return (max(gasPrice, 1), max(gasPrice, 1)) case .lineaPinnedBase: // Base fee pinned at 7 wei; the tip is the real price. let gasPrice = try await rpc.gasPrice() let tip = (try? await rpc.maxPriorityFeePerGas()) ?? gasPrice return (max(gasPrice * 12 / 10, tip + 7), max(tip, 1)) case .arbitrumInclusive: // Suggested tip is 0; cost = gasLimit × (2×base). let baseFee = try await rpc.latestBaseFee() let tip = (try? await rpc.maxPriorityFeePerGas()) ?? 0 return (baseFee * 2 + tip, tip) case .eip1559, .opStackL1Fee, .scrollL1Fee: let baseFee = try await rpc.latestBaseFee() let tip: BigUInt if let suggested = try? await rpc.maxPriorityFeePerGas() { tip = max(suggested, 1) } else { tip = 1_000_000 // 0.001 gwei — plenty on modern L2s } return (baseFee * 2 + tip, tip) } } // MARK: - Sign + broadcast public static func send(_ prepared: PreparedTransfer, privateKey: Data, rpc: RPCService) async throws -> String { guard let destination = EthereumAddress(prepared.txDestination, ignoreChecksum: true) else { throw WalletError.internalError("Bad destination address.") } var tx = CodableTransaction( type: .eip1559, to: destination, nonce: prepared.nonce, chainID: prepared.network.config.chainID, value: prepared.txValue, data: prepared.calldata, gasLimit: prepared.gasLimit, maxFeePerGas: prepared.maxFeePerGas, maxPriorityFeePerGas: prepared.maxPriorityFeePerGas ) do { try tx.sign(privateKey: privateKey) } catch { throw WalletError.signingFailed } guard let raw = tx.encode(for: .transaction) else { throw WalletError.signingFailed } return try await rpc.sendRawTransaction(Hex.string(raw)) } // MARK: - Receipt tracking public enum Confirmation { case confirmed case failed case timedOut } public static func waitForReceipt(hash: String, rpc: RPCService, pollEvery seconds: Double = 3, timeout: Double = 300) async -> Confirmation { let deadline = Date().addingTimeInterval(timeout) while Date() < deadline { if let receipt = try? await rpc.transactionReceipt(hash) { return receipt.succeeded ? .confirmed : .failed } try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) if Task.isCancelled { return .timedOut } } return .timedOut } }