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%
8.7 KB · 212 lines swift
Raw Blame History
1//2//  CoreTests.swift3//  OS Vault4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//89import XCTest10import BigInt11@testable import OSVaultKit1213final class DerivationTests: XCTestCase {1415    // Standard BIP-39/BIP-44 test vector (public, not a real wallet):16    // the all-"abandon" mnemonic derives this well-known address at17    // m/44'/60'/0'/0/0 across every major wallet implementation.18    let vectorMnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"19    let vectorAddress = "0x9858EfFD232B4033E47d90003D41EC34EcaEda94"2021    func testKnownVectorDerivesKnownAddress() throws {22        let wallet = try KeyManager.derive(mnemonic: vectorMnemonic)23        XCTAssertEqual(wallet.address, vectorAddress)24        XCTAssertEqual(wallet.privateKey.count, 32)25    }2627    func testGeneratedMnemonicIsValidAndDerives() throws {28        let manager = KeyManager(vaultURL: tempVaultURL())29        let mnemonic = try manager.generateMnemonic()30        XCTAssertEqual(mnemonic.split(separator: " ").count, 12)31        XCTAssertTrue(KeyManager.validate(mnemonic: mnemonic))32        let wallet = try KeyManager.derive(mnemonic: mnemonic)33        XCTAssertTrue(wallet.address.hasPrefix("0x"))34        XCTAssertEqual(wallet.address.count, 42)35    }3637    func testInvalidMnemonicRejected() {38        XCTAssertFalse(KeyManager.validate(mnemonic: "not a real phrase at all"))39        XCTAssertFalse(KeyManager.validate(mnemonic: "abandon abandon abandon"))40        // Right words, wrong checksum word.41        XCTAssertFalse(KeyManager.validate(mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon"))42    }4344    func testNormalizationHandlesCaseAndWhitespace() throws {45        let messy = "  Abandon ABANDON abandon\nabandon abandon abandon abandon abandon abandon abandon abandon   about "46        let wallet = try KeyManager.derive(mnemonic: messy)47        XCTAssertEqual(wallet.address, vectorAddress)48    }49}5051final class VaultCryptoTests: XCTestCase {5253    func testSealOpenRoundtrip() throws {54        let secret = Data("twelve words of nothing sensitive here".utf8)55        // Low iteration count keeps the test fast; production uses 600k.56        let vault = try VaultCrypto.seal(secret: secret, password: "correct horse", iterations: 10_000)57        let opened = try VaultCrypto.open(vault, password: "correct horse")58        XCTAssertEqual(opened, secret)59    }6061    func testWrongPasswordFails() throws {62        let vault = try VaultCrypto.seal(secret: Data("s".utf8), password: "right", iterations: 10_000)63        XCTAssertThrowsError(try VaultCrypto.open(vault, password: "wrong")) { error in64            XCTAssertEqual(error as? WalletError, .wrongPassword)65        }66    }6768    func testTamperedCiphertextFails() throws {69        let vault = try VaultCrypto.seal(secret: Data("s".utf8), password: "pw", iterations: 10_000)70        var combined = Data(base64Encoded: vault.ciphertext)!71        combined[combined.count - 1] ^= 0xFF72        let tampered = VaultCrypto.VaultFile(73            version: vault.version, kdf: vault.kdf,74            cipher: vault.cipher, ciphertext: combined.base64EncodedString()75        )76        XCTAssertThrowsError(try VaultCrypto.open(tampered, password: "pw"))77    }7879    func testKeyManagerVaultLifecycle() throws {80        let url = tempVaultURL()81        let manager = KeyManager(vaultURL: url)82        XCTAssertFalse(manager.hasVault)8384        let mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"85        let saved = try manager.saveWallet(mnemonic: mnemonic, password: "test-password-123")86        XCTAssertTrue(manager.hasVault)8788        let unlocked = try manager.unlock(password: "test-password-123")89        XCTAssertEqual(unlocked.address, saved.address)90        XCTAssertEqual(unlocked.mnemonic, mnemonic)9192        XCTAssertThrowsError(try manager.unlock(password: "nope")) { error in93            XCTAssertEqual(error as? WalletError, .wrongPassword)94        }9596        // Vault file on disk must never contain the mnemonic in clear.97        let raw = try String(contentsOf: url, encoding: .utf8)98        XCTAssertFalse(raw.contains("abandon"))99100        try manager.deleteVault()101        XCTAssertFalse(manager.hasVault)102    }103}104105final class TokenAmountTests: XCTestCase {106107    func testUSDCSixDecimals() {108        XCTAssertEqual(TokenAmount.parse("1", decimals: 6), 1_000_000)109        XCTAssertEqual(TokenAmount.parse("12.5", decimals: 6), 12_500_000)110        XCTAssertEqual(TokenAmount.parse("0.000001", decimals: 6), 1)111        XCTAssertEqual(TokenAmount.parse("1,25", decimals: 6), 1_250_000)112        XCTAssertNil(TokenAmount.parse("0.0000001", decimals: 6))   // too many digits113        XCTAssertNil(TokenAmount.parse("", decimals: 6))114        XCTAssertNil(TokenAmount.parse("1.2.3", decimals: 6))115        XCTAssertNil(TokenAmount.parse("abc", decimals: 6))116        XCTAssertNil(TokenAmount.parse("-1", decimals: 6))117    }118119    func testDAIEighteenDecimals() {120        XCTAssertEqual(TokenAmount.parse("1", decimals: 18), BigUInt(10).power(18))121        XCTAssertEqual(TokenAmount.format(BigUInt(10).power(18), decimals: 18), "1")122        XCTAssertEqual(TokenAmount.format(BigUInt(1_500) * BigUInt(10).power(15), decimals: 18), "1.5")123    }124125    func testFormatting() {126        XCTAssertEqual(TokenAmount.format(1_000_000, decimals: 6), "1")127        XCTAssertEqual(TokenAmount.format(12_500_000, decimals: 6), "12.5")128        XCTAssertEqual(TokenAmount.format(1, decimals: 6), "0.000001")129        XCTAssertEqual(TokenAmount.format(0, decimals: 6), "0")130        // Truncation, never rounding up.131        XCTAssertEqual(TokenAmount.format(1_999_999, decimals: 6, maxFractionDigits: 2), "1.99")132    }133134    func testRoundtrip() {135        let cases = ["0.1", "123456.654321", "1", "0.000001"]136        for c in cases {137            let parsed = TokenAmount.parse(c, decimals: 6)!138            XCTAssertEqual(TokenAmount.format(parsed, decimals: 6), c)139        }140    }141}142143final class AddressValidationTests: XCTestCase {144145    func testChecksummedAddressValid() {146        // EIP-55 reference vectors.147        let valid = [148            "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed",149            "0xfB6916095ca1df60bB79Ce92cE3Ea74c37c5d359",150            "0x52908400098527886E0F7030069857D2E4169EE7"151        ]152        for address in valid {153            guard case .valid(let checksummed) = AddressValidator.validate(address) else {154                return XCTFail("\(address) should be valid")155            }156            XCTAssertEqual(checksummed, address)157        }158    }159160    func testBadChecksumInvalid() {161        // One flipped case letter breaks the checksum.162        XCTAssertEqual(AddressValidator.validate("0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAeD"), .invalid)163    }164165    func testLowercaseWarnsButPasses() {166        let result = AddressValidator.validate("0x5aaeb6053f3e94c9b9a09f33669435e7ef1beaed")167        guard case .validNoChecksum(let checksummed) = result else {168            return XCTFail("lowercase should pass with warning")169        }170        XCTAssertEqual(checksummed, "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed")171    }172173    func testGarbageInvalid() {174        XCTAssertEqual(AddressValidator.validate(""), .invalid)175        XCTAssertEqual(AddressValidator.validate("0x123"), .invalid)176        XCTAssertEqual(AddressValidator.validate("5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed"), .invalid)177        XCTAssertEqual(AddressValidator.validate("0xZZZeb6053F3E94C9b9A09f33669435E7Ef1BeAed"), .invalid)178    }179}180181final class CalldataTests: XCTestCase {182183    func testTransferCalldataShape() {184        let recipient = "0x9858EfFD232B4033E47d90003D41EC34EcaEda94"185        let calldata = Hex.erc20TransferData(to: recipient, amount: 1_000_000)!186        XCTAssertEqual(calldata.count, 4 + 32 + 32)187        XCTAssertEqual(calldata.prefix(4), Data([0xa9, 0x05, 0x9c, 0xbb]))188        XCTAssertEqual(Hex.string(calldata.suffix(32)),189                       "0x00000000000000000000000000000000000000000000000000000000000f4240")190    }191192    func testBalanceOfCalldata() {193        let owner = "0x9858EfFD232B4033E47d90003D41EC34EcaEda94"194        let calldata = Hex.erc20BalanceOfData(owner: owner)!195        XCTAssertEqual(calldata.count, 4 + 32)196        XCTAssertEqual(calldata.prefix(4), Data([0x70, 0xa0, 0x82, 0x31]))197    }198199    func testQuantityHelpers() {200        XCTAssertEqual(Hex.quantity(0), "0x0")201        XCTAssertEqual(Hex.quantity(255), "0xff")202        XCTAssertEqual(Hex.toBigUInt("0xff"), 255)203        XCTAssertEqual(Hex.toBigUInt("0x"), 0)204        XCTAssertNil(Hex.toBigUInt("0xzz"))205    }206}207208func tempVaultURL() -> URL {209    FileManager.default.temporaryDirectory210        .appendingPathComponent("osvault-tests-\(UUID().uuidString)/vault.json")211}212