SPB Git

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%
9.0 KB · 222 lines swift
Raw Blame History
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