spb/os-vault Public
Self-custody, multi-chain crypto wallet for macOS. One recovery phrase, six chain families, zero API keys — nothing leaves your Mac.
Swift 96%
Shell 3.4%
Makefile 0.6%
1//2// TransactionService.swift3// OS Vault4//5// Author: Simon-Pierre Boucher6// Mail: contact@spboucher.ai7//89import Foundation10import BigInt11import Web3Core1213/// Builds, signs and broadcasts transfers, then tracks the receipt.14/// Two shapes: native coin (value transfer) and ERC-20 `transfer` (calldata15/// to the token contract). Fee logic follows the chain's FeeModel — the16/// research finding is that "EIP-1559" hides four realities (BSC's zero base17/// fee, OP-stack/Scroll L1 data fees, Arbitrum's inclusive estimates, Linea's18/// pinned base). Gas is ALWAYS estimated, never hardcoded — even for native19/// sends (Arbitrum folds L1 costs into the gas limit).20public enum TransactionService {2122 public struct PreparedTransfer {23 public let asset: Asset24 public let network: Network25 public let from: String26 public let recipient: String // checksummed27 public let amountUnits: BigUInt28 /// Transaction `to`: the recipient (native) or the token contract (ERC-20).29 public let txDestination: String30 public let txValue: BigUInt // amount (native) or 0 (ERC-20)31 public let calldata: Data32 public let nonce: BigUInt33 public let gasLimit: BigUInt34 public let maxFeePerGas: BigUInt35 public let maxPriorityFeePerGas: BigUInt36 /// Rollup L1 data fee (OP-stack/Scroll), deducted silently on-chain —37 /// shown to the user and included in the balance check.38 public let l1DataFee: BigUInt3940 /// Worst-case cost in native wei on top of any native amount sent.41 public var maxGasCostWei: BigUInt { gasLimit * maxFeePerGas + l1DataFee }42 }4344 // MARK: - Prepare (estimate everything, verify funds)4546 public static func prepare(asset: Asset,47 network: Network,48 from: String,49 recipient: String,50 amountUnits: BigUInt,51 rpc: RPCService,52 balances: Balances) async throws -> PreparedTransfer {53 guard AddressValidator.validate(recipient) != .invalid else {54 throw WalletError.invalidAddress55 }5657 let txDestination: String58 let txValue: BigUInt59 let calldata: Data60 switch asset {61 case .native:62 txDestination = recipient63 txValue = amountUnits64 calldata = Data()65 if amountUnits > balances.ethWei {66 throw WalletError.insufficientTokenBalance67 }68 case .token(let token):69 guard let contract = token.address(on: network) else {70 throw WalletError.internalError("\(token.symbol) is not deployed on \(network.config.displayName).")71 }72 guard let data = Hex.erc20TransferData(to: recipient, amount: amountUnits) else {73 throw WalletError.invalidAddress74 }75 txDestination = contract76 txValue = 077 calldata = data78 if let held = balances.tokenUnits[token.symbol], amountUnits > held {79 throw WalletError.insufficientTokenBalance80 }81 }8283 let nonce = try await rpc.transactionCount(of: from)84 let fees = try await feeParameters(for: network, rpc: rpc)8586 let estimated = try await rpc.estimateGas(87 from: from, to: txDestination, valueWei: txValue, data: calldata88 )89 // Headroom on the estimate — except Arbitrum, whose estimate already90 // embeds the L1 buffer and is meant to be used verbatim.91 let gasLimit = network.config.feeModel == .arbitrumInclusive92 ? estimated93 : estimated * 12 / 109495 // L1 data fee via the rollup's oracle, on an unsigned serialization96 // of the tx. Best effort: a failing oracle call must not block sends.97 var l1Fee: BigUInt = 098 if let oracle = network.l1FeeOracle,99 let destination = EthereumAddress(txDestination, ignoreChecksum: true) {100 var draft = CodableTransaction(101 type: .eip1559, to: destination, nonce: nonce,102 chainID: network.config.chainID, value: txValue, data: calldata,103 gasLimit: gasLimit, maxFeePerGas: fees.maxFee, maxPriorityFeePerGas: fees.tip104 )105 if let serialized = draft.encode(for: .signature) {106 var call = Data([0x49, 0x94, 0x8e, 0x0e]) // getL1Fee(bytes)107 call.append(Hex.abiWord(BigUInt(32))) // offset108 call.append(Hex.abiWord(BigUInt(serialized.count))) // length109 var padded = serialized110 if padded.count % 32 != 0 {111 padded.append(Data(repeating: 0, count: 32 - padded.count % 32))112 }113 call.append(padded)114 if let result = try? await rpc.call(to: oracle, data: call),115 let fee = Hex.toBigUInt(result) {116 l1Fee = fee117 }118 }119 }120121 let prepared = PreparedTransfer(122 asset: asset, network: network, from: from, recipient: recipient,123 amountUnits: amountUnits, txDestination: txDestination,124 txValue: txValue, calldata: calldata,125 nonce: nonce, gasLimit: gasLimit,126 maxFeePerGas: fees.maxFee, maxPriorityFeePerGas: fees.tip,127 l1DataFee: l1Fee128 )129130 // Native sends must cover amount + gas; token sends just the gas.131 let required = txValue + prepared.maxGasCostWei132 if balances.ethWei < required {133 throw WalletError.insufficientETHForGas(134 needWei: TokenAmount.formatWei(required),135 haveWei: TokenAmount.formatWei(balances.ethWei)136 )137 }138 return prepared139 }140141 /// Per-FeeModel (maxFeePerGas, maxPriorityFeePerGas).142 static func feeParameters(for network: Network,143 rpc: RPCService) async throws -> (maxFee: BigUInt, tip: BigUInt) {144 switch network.config.feeModel {145 case .zeroBaseFee:146 // BSC: baseFee is 0 (BEP-226); price entirely via gasPrice.147 let gasPrice = try await rpc.gasPrice()148 return (max(gasPrice, 1), max(gasPrice, 1))149 case .lineaPinnedBase:150 // Base fee pinned at 7 wei; the tip is the real price.151 let gasPrice = try await rpc.gasPrice()152 let tip = (try? await rpc.maxPriorityFeePerGas()) ?? gasPrice153 return (max(gasPrice * 12 / 10, tip + 7), max(tip, 1))154 case .arbitrumInclusive:155 // Suggested tip is 0; cost = gasLimit × (2×base).156 let baseFee = try await rpc.latestBaseFee()157 let tip = (try? await rpc.maxPriorityFeePerGas()) ?? 0158 return (baseFee * 2 + tip, tip)159 case .eip1559, .opStackL1Fee, .scrollL1Fee:160 let baseFee = try await rpc.latestBaseFee()161 let tip: BigUInt162 if let suggested = try? await rpc.maxPriorityFeePerGas() {163 tip = max(suggested, 1)164 } else {165 tip = 1_000_000 // 0.001 gwei — plenty on modern L2s166 }167 return (baseFee * 2 + tip, tip)168 }169 }170171 // MARK: - Sign + broadcast172173 public static func send(_ prepared: PreparedTransfer, privateKey: Data, rpc: RPCService) async throws -> String {174 guard let destination = EthereumAddress(prepared.txDestination, ignoreChecksum: true) else {175 throw WalletError.internalError("Bad destination address.")176 }177 var tx = CodableTransaction(178 type: .eip1559,179 to: destination,180 nonce: prepared.nonce,181 chainID: prepared.network.config.chainID,182 value: prepared.txValue,183 data: prepared.calldata,184 gasLimit: prepared.gasLimit,185 maxFeePerGas: prepared.maxFeePerGas,186 maxPriorityFeePerGas: prepared.maxPriorityFeePerGas187 )188 do {189 try tx.sign(privateKey: privateKey)190 } catch {191 throw WalletError.signingFailed192 }193 guard let raw = tx.encode(for: .transaction) else {194 throw WalletError.signingFailed195 }196 return try await rpc.sendRawTransaction(Hex.string(raw))197 }198199 // MARK: - Receipt tracking200201 public enum Confirmation {202 case confirmed203 case failed204 case timedOut205 }206207 public static func waitForReceipt(hash: String,208 rpc: RPCService,209 pollEvery seconds: Double = 3,210 timeout: Double = 300) async -> Confirmation {211 let deadline = Date().addingTimeInterval(timeout)212 while Date() < deadline {213 if let receipt = try? await rpc.transactionReceipt(hash) {214 return receipt.succeeded ? .confirmed : .failed215 }216 try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))217 if Task.isCancelled { return .timedOut }218 }219 return .timedOut220 }221}222