diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000000..792e389eeb --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,23 @@ +# Owners must have write access to this repository. + +* @soramitsu/fearless + +/.github/ @soramitsu/devops-team +/AGENTS.md @soramitsu/fearless @soramitsu/ios-developers +/CONTRIBUTING.md @soramitsu/fearless @soramitsu/ios-developers +/README.md @soramitsu/fearless @soramitsu/ios-developers +/docs/release-checklist.md @soramitsu/fearless @soramitsu/devops-team +/docs/rollback-checklist.md @soramitsu/fearless @soramitsu/devops-team +/scripts/audit-public-artifacts.sh @soramitsu/fearless @soramitsu/ios-developers @soramitsu/security + +/fearless.xcodeproj/ @soramitsu/fearless @soramitsu/ios-developers +/fearless.xcworkspace/ @soramitsu/fearless @soramitsu/ios-developers +/fearless/ @soramitsu/fearless @soramitsu/ios-developers +/fearless/Common/Model/ @soramitsu/fearless @soramitsu/ios-developers @soramitsu/security +/fearless/Common/Keys/ @soramitsu/fearless @soramitsu/ios-developers @soramitsu/security +/fearless/Common/Network/Wallet/ @soramitsu/fearless @soramitsu/ios-developers @soramitsu/security +/fearless/CoreLayer/CoreComponents/Storage/Keys/ @soramitsu/fearless @soramitsu/ios-developers @soramitsu/security +/**/*Key* @soramitsu/fearless @soramitsu/ios-developers @soramitsu/security +/**/*Keychain* @soramitsu/fearless @soramitsu/ios-developers @soramitsu/security +/**/*Keystore* @soramitsu/fearless @soramitsu/ios-developers @soramitsu/security +/**/*Signer* @soramitsu/fearless @soramitsu/ios-developers @soramitsu/security diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000000..e1c9319168 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,49 @@ +## Summary + +Describe the change and why it is needed. + +## Related Issue + +Closes # (or) Relates to # + +## Target Branch + +- [ ] This PR targets `develop` +- [ ] This PR targets `master` and is a release or hotfix PR + +## Type of Change + +- [ ] feat (new feature) +- [ ] fix (bug fix) +- [ ] refactor (no functional change) +- [ ] chore/build (tooling, CI, deps) +- [ ] docs + +## Screenshots / Videos + +If UI changes, include before/after. + +## Test Plan + +Commands run locally: + +``` +xcodebuild -workspace fearless.xcworkspace -scheme fearless.tests test +``` + +Additional checks and scenarios covered: +- + +## Risks & Rollout + +Potential impact, migrations, or config/secrets required. + +## Checklist + +- [ ] Linked an issue and added a clear description +- [ ] Added/updated tests for changed code where applicable +- [ ] Updated docs when behavior or commands changed +- [ ] Ran simulator build/tests locally or verified CI +- [ ] No secrets or local environment files committed +- [ ] `./scripts/audit-public-artifacts.sh` passes when public artifacts or env defaults change +- [ ] No direct-to-`master` workflow is introduced diff --git a/.github/workflows/branch-flow.yml b/.github/workflows/branch-flow.yml new file mode 100644 index 0000000000..ccfb351e13 --- /dev/null +++ b/.github/workflows/branch-flow.yml @@ -0,0 +1,51 @@ +name: Branch Flow + +on: + pull_request: + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Branch flow audit self-test + run: bash scripts/test-branch-flow-audit.sh + + - name: Branch flow drift audit + run: bash scripts/audit-branch-flow.sh + + - name: Validate PR target + env: + BASE_BRANCH: ${{ github.base_ref }} + HEAD_BRANCH: ${{ github.head_ref }} + run: | + set -euo pipefail + + echo "PR branch flow: ${HEAD_BRANCH} -> ${BASE_BRANCH}" + + if [[ "$BASE_BRANCH" == "develop" ]]; then + if [[ "$HEAD_BRANCH" == "master" ]]; then + echo "::error::Do not open normal work from master to develop. Use a feature/fix/chore/refactor, release, or hotfix branch." + exit 1 + fi + + exit 0 + fi + + if [[ "$BASE_BRANCH" == "master" ]]; then + if [[ "$HEAD_BRANCH" == "develop" || "$HEAD_BRANCH" == release/* || "$HEAD_BRANCH" == hotfix/* ]]; then + exit 0 + fi + + echo "::error::master is releasable. Only develop, release/*, or hotfix/* branches may target master." + exit 1 + fi + + echo "::error::Pull requests must target develop for normal work or master for release/hotfix promotion." + exit 1 diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml index 7783fd9593..859aff68dc 100644 --- a/.github/workflows/codecov.yml +++ b/.github/workflows/codecov.yml @@ -1,9 +1,14 @@ name: Codecov Fearless on: + push: + branches: + - develop + - master pull_request: branches: - develop + - master jobs: build: @@ -12,6 +17,49 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Branch flow audit + run: | + bash ./scripts/audit-branch-flow.sh + bash ./scripts/test-branch-flow-audit.sh + + - name: Audit public artifacts + run: ./scripts/audit-public-artifacts.sh + + - name: TODO debt audit + run: | + bash ./scripts/test-todo-debt-audit.sh + bash ./scripts/audit-todo-debt.sh + + - name: Iroha mobile SDK release asset contract + env: + GH_TOKEN: ${{ github.token }} + IROHA_MOBILE_SDK_RELEASE_TAG: ${{ vars.IROHA_MOBILE_SDK_RELEASE_TAG }} + IROHA_MOBILE_SDK_RELEASE_REPO: ${{ vars.IROHA_MOBILE_SDK_RELEASE_REPO }} + run: | + bash ./scripts/check-iroha-mobile-sdk-release-assets.sh --self-test + if [[ -n "${IROHA_MOBILE_SDK_RELEASE_TAG:-}" ]]; then + bash ./scripts/check-iroha-mobile-sdk-release-assets.sh --download --tag "$IROHA_MOBILE_SDK_RELEASE_TAG" + else + echo "IROHA_MOBILE_SDK_RELEASE_TAG is not configured; real release asset validation skipped." + fi + + - name: Private overlay audit self-test + run: bash ./scripts/test-private-overlay-boundary.sh + + - name: Shared-features delta report + run: | + bash ./scripts/deps/test-shared-features-delta-report.sh + bash ./scripts/deps/audit-shared-features-delta-report.sh "$(pwd)" --write-report build/reports/shared-features-delta-report.json + + - name: Upload shared-features delta report + if: always() + uses: actions/upload-artifact@v4 + with: + name: shared-features-delta-report + path: build/reports/shared-features-delta-report.json + if-no-files-found: ignore + retention-days: 14 + - name: Configure GitHub auth for private pods (optional) env: GH_READ_TOKEN: ${{ secrets.GH_READ_TOKEN }} @@ -22,7 +70,7 @@ jobs: else echo "GH_READ_TOKEN not set; proceeding without GitHub auth" fi - + - name: Bootstrap Dependencies run: | bash scripts/ci/bootstrap.sh @@ -116,3 +164,17 @@ jobs: - name: Upload coverage to Codecov run: bash <(curl -s https://codecov.io/bash) -J 'fearless' + + jenkins-pr-merge: + name: continuous-integration/jenkins/pr-merge + runs-on: ubuntu-latest + needs: build + if: always() + steps: + - name: Require iOS build job success + run: | + if [[ "${{ needs.build.result }}" != "success" ]]; then + echo "Required iOS build job did not pass: ${{ needs.build.result }}" >&2 + exit 1 + fi + echo "Jenkins compatibility context is backed by the GitHub Actions build job." diff --git a/.gitignore b/.gitignore index f6cd6967a2..07ef27c8e7 100644 --- a/.gitignore +++ b/.gitignore @@ -43,6 +43,8 @@ Carthage/Build #fastlane/screenshots #fastlane/test_output fastlane/ +.bundle/ +vendor/bundle/ # Code Injection # # After new code Injection tools there's a generated folder /iOSInjectionProject diff --git a/AGENTS.md b/AGENTS.md index 051db38c81..d36b2263e6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,6 +59,7 @@ CI note - Do not alter seed handling, signing, or cryptography without maintainer approval. - Runtime registries and chain/type sources must be aligned with the current Polkadot SDK release; coordinate updates with maintainers. - Use `*.xcconfig` and environment variables for private values; avoid hardcoding secrets in `Info.plist`. +- Keep partner identifiers and buy-provider keys in environment variables or generated CI keys, not committed Swift source. ## Dependencies & Versioning - Prefer conservative upgrades (patch/minor). Pin major bumps to separate PRs with clear testing notes. @@ -149,6 +150,7 @@ The project mixes CocoaPods and Swift Package Manager. Follow these steps in ord - Environment variables: - `GH_PAT_READ` (optional): GitHub PAT for private pods (`FearlessKeys`). - `DEVELOPER_DIR` (optional): Jenkinsfile auto‑pins to Xcode 15.x if present for SPM stability; otherwise default Xcode is used. + - `MOONPAY_PUBLIC_KEY`: Moonpay partner public key generated into CI keys for trusted builds. - Private keys in PRs: - PR builds do NOT require private keys. The Jenkinsfile detects PR context (`CHANGE_ID`) and temporarily comments out the `pod 'FearlessKeys'` line before `pod install`, then restores the file. This prevents private repo access and allows PRs to build without secrets. - Trusted branches (develop/master/release): Jenkins sets `INCLUDE_FEARLESS_KEYS=1` and, if `GH_PAT_READ` is present, rewrites GitHub URLs to use the token so `pod install` can fetch `FearlessKeys`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c1d6e83fa3..47f116cfe0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -27,7 +27,25 @@ If you think you miss some functionality on our application, please open issue o * (OPTIONAL) If possible, provide complete description of functionality you suggest * (OPTIONAL) Provide your telegram username, and be sure to join [Our Community Channel](https://t.me/fearlesshappiness) on Telegram, so our team can get in touch with you to retrieve more information about your vision -Even if you provide mockups, please understand that our design team will review it and might implement this in very different way. +Even if you provide mockups, please understand that our design team will review it and might implement this in very different way. + +### Git flow + +All normal work starts from `develop` and is submitted back to `develop`. +Use short-lived branches named `feature/-`, `fix/-`, +`chore/`, or `refactor/`. + +`master` is the releasable branch. Do not target `master` except for release +pull requests from `develop` or urgent `hotfix/` branches. +Every commit on `master` must be safe to release, and release tags must point at +commits already merged to `master`. + +Feature PRs are squash-merged after review and green CI. Release PRs to +`master` use merge commits so the release boundary remains visible. + +The `Branch Flow` GitHub Actions workflow validates PR targets automatically: +normal work must target `develop`, while `master` accepts only `develop`, +`release/*`, or `hotfix/*` branches. ### 💻 Opening Pull Requests @@ -36,10 +54,10 @@ If you would like to help us by contributing writing the code, please follow nex * Always create and issue prior to opening Pull Request (hereinafter the PR), or your PR less likely to be reviewed * Remember, that in that case you are required to provide full description for you feature in created issue, otherwise it would be hard for us to understand what you're trying to add to our codebase * Follow the coding standards guidelines (TO BE PROVIDED LATER), or you will be asked to make changes to follow them -* Please avoid huge PRs, and if your contribution really requires lots of files, please make a base branch with series of small PRs on your fork, and then provide link to those PRs in your big one PR in our repository +* Please avoid huge PRs, and if your contribution really requires lots of files, please make a base branch with series of small PRs on your fork, and then provide link to those PRs in your big one PR in our repository * Provide steps for QA engineer to test your functionality (they should cover requirements from your issue) -Even if you provide mockups, please understand that our design team will review it and might implement this in very different way. +Even if you provide mockups, please understand that our design team will review it and might implement this in very different way. ### Telegram links diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000000..f0b075238f --- /dev/null +++ b/Gemfile @@ -0,0 +1,10 @@ +source "https://rubygems.org" + +gem "cocoapods", "1.16.2" + +# System Ruby on current macOS images can still be 2.6. Pin transitive gems +# whose latest releases now require newer Ruby versions so bootstrap remains +# reproducible for public checkouts. +gem "drb", "2.0.6" +gem "ffi", "1.15.5" +gem "securerandom", "0.3.2" diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000000..0f51e8bf4b --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,107 @@ +GEM + remote: https://rubygems.org/ + specs: + CFPropertyList (3.0.9) + activesupport (6.1.7.10) + concurrent-ruby (~> 1.0, >= 1.0.2) + i18n (>= 1.6, < 2) + minitest (>= 5.1) + tzinfo (~> 2.0) + zeitwerk (~> 2.3) + addressable (2.9.0) + public_suffix (>= 2.0.2, < 8.0) + algoliasearch (1.27.5) + httpclient (~> 2.8, >= 2.8.3) + json (>= 1.5.1) + atomos (0.1.3) + claide (1.1.0) + cocoapods (1.16.2) + addressable (~> 2.8) + claide (>= 1.0.2, < 2.0) + cocoapods-core (= 1.16.2) + cocoapods-deintegrate (>= 1.0.3, < 2.0) + cocoapods-downloader (>= 2.1, < 3.0) + cocoapods-plugins (>= 1.0.0, < 2.0) + cocoapods-search (>= 1.0.0, < 2.0) + cocoapods-trunk (>= 1.6.0, < 2.0) + cocoapods-try (>= 1.1.0, < 2.0) + colored2 (~> 3.1) + escape (~> 0.0.4) + fourflusher (>= 2.3.0, < 3.0) + gh_inspector (~> 1.0) + molinillo (~> 0.8.0) + nap (~> 1.0) + ruby-macho (>= 2.3.0, < 3.0) + xcodeproj (>= 1.27.0, < 2.0) + cocoapods-core (1.16.2) + activesupport (>= 5.0, < 8) + addressable (~> 2.8) + algoliasearch (~> 1.0) + concurrent-ruby (~> 1.1) + fuzzy_match (~> 2.0.4) + nap (~> 1.0) + netrc (~> 0.11) + public_suffix (~> 4.0) + typhoeus (~> 1.0) + cocoapods-deintegrate (1.0.5) + cocoapods-downloader (2.1) + cocoapods-plugins (1.0.0) + nap + cocoapods-search (1.0.1) + cocoapods-trunk (1.6.0) + nap (>= 0.8, < 2.0) + netrc (~> 0.11) + cocoapods-try (1.2.0) + colored2 (3.1.2) + concurrent-ruby (1.3.7) + drb (2.0.6) + ruby2_keywords + escape (0.0.4) + ethon (0.18.0) + ffi (>= 1.15.0) + logger + ffi (1.15.5) + fourflusher (2.3.1) + fuzzy_match (2.0.4) + gh_inspector (1.1.3) + httpclient (2.9.0) + mutex_m + i18n (1.14.8) + concurrent-ruby (~> 1.0) + json (2.7.6) + logger (1.7.0) + minitest (5.25.4) + molinillo (0.8.0) + mutex_m (0.3.0) + nanaimo (0.4.0) + nap (1.1.0) + netrc (0.11.0) + public_suffix (4.0.7) + rexml (3.4.4) + ruby-macho (2.5.1) + ruby2_keywords (0.0.5) + securerandom (0.3.2) + typhoeus (1.6.0) + ethon (>= 0.18.0) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + xcodeproj (1.27.0) + CFPropertyList (>= 2.3.3, < 4.0) + atomos (~> 0.1.3) + claide (>= 1.0.2, < 2.0) + colored2 (~> 3.1) + nanaimo (~> 0.4.0) + rexml (>= 3.3.6, < 4.0) + zeitwerk (2.6.18) + +PLATFORMS + ruby + +DEPENDENCIES + cocoapods (= 1.16.2) + drb (= 2.0.6) + ffi (= 1.15.5) + securerandom (= 0.3.2) + +BUNDLED WITH + 1.17.2 diff --git a/Jenkinsfile b/Jenkinsfile index 8cc37dc0c0..2a00a2f508 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -6,7 +6,7 @@ def jobParams = [ string(defaultValue: '', description: 'Additional Jira tasks (comma-separated)', name: 'additionalJiraTasks'), booleanParam(defaultValue: false, description: 'Get all Jira tasks specified in the PR', name: 'getAllJiraTasks'), booleanParam(defaultValue: false, description: 'run sonarqube scan', name: 'sonar'), - booleanParam(defaultValue: false, description: 'Upload builds to nexus(master,develop and staging branches upload always)', name: 'upload_to_nexus'), + booleanParam(defaultValue: false, description: 'Upload builds to nexus(master and develop branches upload always)', name: 'upload_to_nexus'), ] def appPipeline = new org.ios.AppPipeline( @@ -20,7 +20,7 @@ def appPipeline = new org.ios.AppPipeline( sonarProjectKey: 'fearless:fearless-ios', dojoProductType: 'fearless', effectJiraTasks: true, - uploadToNexusFor: ['master','develop','staging'] + uploadToNexusFor: ['master','develop'] ) // Best-effort GitHub status helper; won't fail if plugin isn't installed diff --git a/Packages/FearlessDependencies/Package.resolved b/Packages/FearlessDependencies/Package.resolved index 41542ec0ee..3df2cca4b9 100644 --- a/Packages/FearlessDependencies/Package.resolved +++ b/Packages/FearlessDependencies/Package.resolved @@ -5,8 +5,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/attaswift/BigInt.git", "state" : { - "revision" : "e07e00fa1fd435143a2dcf8b7eec9a7710b2fdfe", - "version" : "5.7.0" + "revision" : "0ed110f7555c34ff468e72e1686e59721f2b0da6", + "version" : "5.3.0" } }, { @@ -252,6 +252,15 @@ "version" : "1.1.1" } }, + { + "identity" : "swift-openapi-runtime", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-openapi-runtime", + "state" : { + "revision" : "a51b3bd6f2151e9a6f792ca6937a7242c4758768", + "version" : "0.3.6" + } + }, { "identity" : "swift-qrcode-generator", "kind" : "remoteSourceControl", @@ -309,19 +318,28 @@ { "identity" : "ton-api-swift", "kind" : "remoteSourceControl", - "location" : "https://github.com/DRadmir/ton-api-swift.git", + "location" : "https://github.com/tonkeeper/ton-api-swift.git", "state" : { - "revision" : "8ddff19a40d3d00503cab7fb9d9eb77459169488", - "version" : "0.5.0" + "revision" : "d57d457cbaea081467ae65648e92fc6094ea342b", + "version" : "0.1.7" } }, { "identity" : "ton-swift", "kind" : "remoteSourceControl", - "location" : "https://github.com/DRadmir/ton-swift.git", + "location" : "https://github.com/tonkeeper/ton-swift.git", + "state" : { + "revision" : "03dbe1f4cef7471b012993ec54e0783882acb3ab", + "version" : "1.0.4" + } + }, + { + "identity" : "tweetnacl-swiftwrap", + "kind" : "remoteSourceControl", + "location" : "https://github.com/bitmark-inc/tweetnacl-swiftwrap", "state" : { - "branch" : "main", - "revision" : "73c9894e2be8d6d16b87853342eb2755d2e4be8a" + "revision" : "f8fd111642bf2336b11ef9ea828510693106e954", + "version" : "1.1.0" } }, { diff --git a/Packages/FearlessDependencies/Package.swift b/Packages/FearlessDependencies/Package.swift index 95c60d489f..c6ef4eb879 100644 --- a/Packages/FearlessDependencies/Package.swift +++ b/Packages/FearlessDependencies/Package.swift @@ -29,8 +29,10 @@ let package = Package( .package(url: "https://github.com/onevcat/Kingfisher", exact: "7.10.2"), .package(url: "https://github.com/SnapKit/SnapKit", exact: "5.0.0"), // TON SDK + remote API - .package(url: "https://github.com/DRadmir/ton-api-swift.git", exact: "0.5.0"), - .package(url: "https://github.com/DRadmir/ton-swift.git", branch: "main") + .package(url: "https://github.com/tonkeeper/ton-api-swift.git", exact: "0.1.7"), + .package(url: "https://github.com/tonkeeper/ton-swift.git", exact: "1.0.4"), + .package(url: "https://github.com/apple/swift-openapi-runtime", exact: "0.3.6"), + .package(url: "https://github.com/apple/swift-http-types", exact: "1.5.1") ], targets: [ .target( @@ -58,7 +60,9 @@ let package = Package( .product(name: "SnapKit", package: "SnapKit"), // TON SDK .product(name: "TonAPI", package: "ton-api-swift"), - .product(name: "TonSwift", package: "ton-swift") + .product(name: "TonSwift", package: "ton-swift"), + .product(name: "OpenAPIRuntime", package: "swift-openapi-runtime"), + .product(name: "HTTPTypes", package: "swift-http-types") ], path: "Sources/FearlessDependencies" ) diff --git a/Packages/FearlessDependencies/Sources/FearlessDependencies/Exports.swift b/Packages/FearlessDependencies/Sources/FearlessDependencies/Exports.swift index d4ab039702..28f10d39d0 100644 --- a/Packages/FearlessDependencies/Sources/FearlessDependencies/Exports.swift +++ b/Packages/FearlessDependencies/Sources/FearlessDependencies/Exports.swift @@ -60,3 +60,11 @@ #if canImport(TonSwift) @_exported import TonSwift #endif + +#if canImport(OpenAPIRuntime) +@_exported import OpenAPIRuntime +#endif + +#if canImport(HTTPTypes) +@_exported import HTTPTypes +#endif diff --git a/Podfile b/Podfile index 17b9df61ac..f22ff965fa 100644 --- a/Podfile +++ b/Podfile @@ -36,7 +36,6 @@ abstract_target 'fearlessAll' do pod 'SoraFoundation', '~> 1.0.0' pod 'R.swift', '6.1.0', :inhibit_warnings => true pod 'FireMock', :inhibit_warnings => true - pod 'SoraKeystore', :git => 'https://github.com/soramitsu/keystore-iOS.git', :tag => '1.0.2' pod 'Sourcery', '~> 1.4' # Ensure UI/framework deps are available to the tests as well pod 'SoraUI', '~> 1.10.3', :inhibit_warnings => true diff --git a/Podfile.lock b/Podfile.lock index 09c9a44ece..1f29aec5a4 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -137,6 +137,6 @@ SPEC CHECKSUMS: SwiftFormat: 73573b89257437c550b03d934889725fbf8f75e5 SwiftLint: 6a9eb020853558a7eaf00f655636b5d6ad15bd02 -PODFILE CHECKSUM: 3604bb1e18de64538855e345c6cefbe5973ab6b2 +PODFILE CHECKSUM: a2eded2a3c4cb4c733c44341c7827ea8b9f21734 COCOAPODS: 1.16.2 diff --git a/README.md b/README.md index 6dbe99c5b1..c53a4d5574 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,16 @@ Track features development: [board link](https://soramitsucoltd.aha.io/shared/34 - Run tests locally for both configurations: - `bash scripts/test-matrix.sh` (uses iPhone 15 simulator by default) +## Configuration +Partner and release identifiers are supplied by environment variables or CI key +generation, not committed source. For public local builds, unset values resolve +to empty strings and the affected buy-provider flows are unavailable. + +Moonpay: +- `MOONPAY_PRODUCTION_SECRET` +- `MOONPAY_TEST_SECRET` +- `MOONPAY_PUBLIC_KEY` + ## License Fearless Wallet iOS is available under the Apache 2.0 license. See the LICENSE file for more info. diff --git a/config/todo-debt-baseline.tsv b/config/todo-debt-baseline.tsv new file mode 100644 index 0000000000..ccd3438865 --- /dev/null +++ b/config/todo-debt-baseline.tsv @@ -0,0 +1,21 @@ +fearless/ApplicationLayer/Services/WalletConnect/eip_mew/SignTypedData.swift // TODO: check with metamask test cases v4 +fearless/Common/Crypto/Data+Ethereum.swift // TODO: Move to library +fearless/Common/Crypto/Data+Keccak.swift // TODO: move to library +fearless/Common/Extension/Foundation/NSPredicate+Filter.swift // TODO: Remove +fearless/Common/Extension/Foundation/NSPredicate+Validation.swift // TODO: Add deriviationPathHardSoftNumeric? +fearless/Common/Extension/Foundation/NSPredicate+Validation.swift // TODO: Add deriviationPathHardSoftNumericPassword? +fearless/Common/Extension/Storage/CDAccountItem+CoreDataDecodable.swift // TODO: Fix logic +fearless/Common/Services/ChainRegistry/RuntimeProviderPool/RuntimeHotBootSnapshotFactory.swift // TODO: think about it +fearless/Common/Services/ChainRegistry/RuntimeProviderPool/RuntimeSnapshotOperationFactory.swift // TODO: think about it +fearless/Common/Services/ExtrinsicService/ExtrinsicOperationFactory.swift method: "author_submitAndWatchExtrinsic", // TODO: add to fearless utils +fearless/Common/Storage/Migration/UserStorage/MultiassetV2MigrationPolicy.swift // TODO: tech debt +fearless/Common/Storage/Migration/UserStorage/StorageMigrator+Sync.swift // TODO: Move this logic to app loading state +fearless/Modules/AddAccount/Interactors/AddAccount+AccountConfirmInteractor.swift // TODO: Check how to convert this to chain account import +fearless/Modules/Export/ExportMnemonic/ExportMnemonicPresenter.swift // TODO: Support custom accounts +fearless/Modules/PolkaswapFlow/PolkaswapAdjustment/PolkaswapAdjustmentPresenter.swift // TODO: - need think about this +fearless/Modules/Profile/ProfileViewFactory.swift // TODO: Eth account info fetching +fearless/Modules/Staking/Analytics/Validators/ViewModel/AnalyticsValidatorsViewModelFactory.swift firstLine: "TODO in next release", +fearless/Modules/Staking/SelectValidatorsFlow/ValidatorInfo/Flow/Parachain/ValidatorInfoParachainViewModelFactory.swift // TODO: Has slashes real value +fearlessTests/Common/Crypto/KeystoreExportWrapperTests.swift // cryptoType: .sr25519) // FIXME: cryptoType: info.cryptoType ?? .sr25519) +fearlessTests/Modules/ControllerAccountConfirmation/ControllerAccountConfirmationTests.swift // TODO +fearlessTests/Modules/StakingUnbondSetup/StakingUnbondSetupTests.swift // TODO diff --git a/docs/SSFNativeCryptoUpstreamDelta.md b/docs/SSFNativeCryptoUpstreamDelta.md index c82d564fd1..fc28b21e64 100644 --- a/docs/SSFNativeCryptoUpstreamDelta.md +++ b/docs/SSFNativeCryptoUpstreamDelta.md @@ -62,10 +62,10 @@ Reason: These repo files are the canonical local source of truth: -- [IrohaCrypto.module.modulemap](/Users/williamrichter/Git/fearless-iOS/scripts/deps/templates/IrohaCrypto.module.modulemap) -- [IrohaCrypto-umbrella.h](/Users/williamrichter/Git/fearless-iOS/scripts/deps/templates/IrohaCrypto-umbrella.h) -- [IrohaCrypto.linker-settings.swiftfrag](/Users/williamrichter/Git/fearless-iOS/scripts/deps/templates/IrohaCrypto.linker-settings.swiftfrag) -- [export-native-crypto-upstream-delta.sh](/Users/williamrichter/Git/fearless-iOS/scripts/deps/export-native-crypto-upstream-delta.sh) +- `scripts/deps/templates/IrohaCrypto.module.modulemap` +- `scripts/deps/templates/IrohaCrypto-umbrella.h` +- `scripts/deps/templates/IrohaCrypto.linker-settings.swiftfrag` +- `scripts/deps/export-native-crypto-upstream-delta.sh` ## Exporting the delta @@ -84,6 +84,17 @@ This writes: That output is intended to be the handoff artifact for upstreaming or vendoring the remaining `shared-features-spm` native crypto delta. +For release review and upstream handoff, generate the full shared-features bundle: + +```bash +bash scripts/deps/export-shared-features-upstream-delta.sh +``` + +This writes `build/shared-features-upstream-delta` with the machine-readable +`shared-features-delta-report.json`, the native crypto export, a deterministic +`handoff-manifest.json`, and a README that lists every carried delta and removal +blocker. + ## Exit condition for Milestone 3 Milestone 3 is complete when the pinned `shared-features-spm` source already contains this delta and the repo no longer needs to mutate the resolved checkout after package resolution. diff --git a/docs/SSFStability.md b/docs/SSFStability.md index 30ec9b6bd9..1c4ea13005 100644 --- a/docs/SSFStability.md +++ b/docs/SSFStability.md @@ -37,7 +37,10 @@ Key fixes in this repo - Native crypto upstream delta - Doc: docs/SSFNativeCryptoUpstreamDelta.md - - What: records the exact `shared-features-spm` source changes still being carried locally for `IrohaCrypto` and provides an export script for upstream handoff. + - Audit/report: scripts/deps/audit-shared-features-delta-report.sh + - Full handoff export: scripts/deps/export-shared-features-upstream-delta.sh + - CI artifact: build/reports/shared-features-delta-report.json + - What: records the exact `shared-features-spm` source changes still being carried locally for `IrohaCrypto`, reports `removalReadiness.status`, lists the blockers and mutation markers that must disappear before checkout mutation can be removed, and provides an export script for upstream handoff. - Why: makes the remaining Milestone 3 work explicit and upstreamable instead of leaving it distributed across repair scripts. - Git LFS for binary targets diff --git a/docs/release-checklist.md b/docs/release-checklist.md new file mode 100644 index 0000000000..e5498c391e --- /dev/null +++ b/docs/release-checklist.md @@ -0,0 +1,75 @@ +# Release Checklist + +Use this checklist for every release PR from `develop` to `master`. + +## Before The Release PR + +- Confirm all release work has landed on `develop`. +- Confirm the app version, build number, changelog, and release notes are final. +- Confirm no private keys, App Store credentials, analytics tokens, provisioning + data, or local environment files are committed. +- Run `bash ./scripts/test-branch-flow-audit.sh && bash ./scripts/audit-branch-flow.sh` + and confirm the release branch flow rules still pass. +- Run `./scripts/audit-public-artifacts.sh` and confirm it passes. +- Run `bash ./scripts/test-todo-debt-audit.sh && bash ./scripts/audit-todo-debt.sh` + and confirm no new TODO/FIXME/STOPSHIP debt was introduced. +- Run `bash ./scripts/deps/check-dependency-contracts.sh "$PWD"` and confirm + the committed SwiftPM state, native crypto contract wiring, and + shared-features patch wiring still fail fast before release. +- Run + `bash ./scripts/deps/test-shared-features-delta-report.sh && bash ./scripts/deps/audit-shared-features-delta-report.sh "$PWD" --write-report build/reports/shared-features-delta-report.json` + and review `build/reports/shared-features-delta-report.json`. Every + `carriedDeltas` entry must either be present in the pinned + `shared-features-spm` source before removing checkout mutation, or remain + intentionally carried for the release. Confirm + `removalReadiness.status` is still `blocked` unless the same report proves all + required absent markers have been removed from CI and release scripts. +- Run + `bash ./scripts/deps/export-shared-features-upstream-delta.sh "$PWD" build/shared-features-upstream-delta` + before upstream handoff or release review of the shared-features patch debt, + then attach/review `build/shared-features-upstream-delta/handoff-manifest.json`. +- Run `bash ./scripts/test-transaction-builder-tests-audit.sh && bash ./scripts/audit-transaction-builder-tests.sh` + and confirm Bitcoin, Solana, Iroha, and TON fail-closed transfer coverage + remains in place. +- Run `bash ./scripts/check-iroha-mobile-sdk-release-assets.sh --self-test`. + If `IROHA_MOBILE_SDK_RELEASE_TAG` is configured for the release, also run + `bash ./scripts/check-iroha-mobile-sdk-release-assets.sh --download --tag "$IROHA_MOBILE_SDK_RELEASE_TAG"`. +- From the workspace root, run + `bash scripts/audit-passkey-backup-prerequisites.sh` and confirm + `config/passkey-backup-production.json` still matches iOS passkey code, + associated domains, CloudKit storage, and the production challenge-service + contract. Before enabling user-facing passkey backup, run the same audit with + `PASSKEY_BACKUP_LIVE_HEALTH=1` and confirm the deployed challenge service + passes. Keep `isPasskeyBackupEnabled=false` unless that live release audit is + green for the release. +- Before enabling user-facing passkey backup, confirm the iOS flow handles + iCloud account availability, CloudKit production schema readiness, + associated-domain provisioning, provisioning profiles, and a recovery path + that offers restore before creating a new backup. +- Confirm public build instructions still work without private overlays. +- When the private iOS overlay checkout is available, run + `PRIVATE_OVERLAY_REPORT=build/reports/private-overlay-boundary.tsv PRIVATE_REPO_DIR=../fearless-iOS-priv ./scripts/audit-private-overlay-boundary.sh` + and confirm it passes. If it fails, use the full TSV report to move every + listed product-code or public-config path back to the public repo before + release. +- Run or confirm green CI for branch-flow audit, public artifact audit, + TODO-debt audit, Iroha mobile SDK release asset contract, private-overlay + audit self-test, simulator build, and tests. +- Confirm Universal Wallet migrations, legacy export-only access, and supported + network registry changes are documented in the PR. +- Confirm rollback owner, monitoring owner, and release communication channel. + +## Release PR To `master` + +- Open the PR from `develop` or `release/` to `master`. +- Include test evidence, migration notes, release notes, and rollback notes. +- Confirm the `Branch Flow` workflow is green for the release PR. +- Require review and green CI before merge. +- Merge with a merge commit so the release boundary is visible. +- Create the release tag only after the merge commit is on `master`. + +## After Release + +- Verify the distributed artifact matches the tagged commit. +- Monitor crash, wallet creation, import, transfer, and indexer error rates. +- Keep the hotfix path ready from `master` until rollout completes. diff --git a/docs/rollback-checklist.md b/docs/rollback-checklist.md new file mode 100644 index 0000000000..d978048ed5 --- /dev/null +++ b/docs/rollback-checklist.md @@ -0,0 +1,32 @@ +# Rollback Checklist + +Use this when a released iOS build causes a production-impacting issue. + +## Trigger + +- Stop or pause phased release when crash, signing, migration, transfer, or + indexer-backed read failures exceed the release threshold. +- Assign one incident owner and one communication owner. + +## Immediate Actions + +- Identify the last known-good `master` tag and current rollout percentage. +- Capture failing version, device/OS distribution, feature flags, and relevant + backend/indexer status. +- Disable remote config or feature gates first when that removes the issue + without a binary rollback. +- If binary rollback is required, halt phased release in App Store Connect and + prepare a hotfix branch from `master`. + +## Hotfix Path + +- Create `hotfix/` from `master`. +- Apply the smallest safe fix or revert. +- Run the release checklist checks that cover the changed surface. +- Open a PR to `master`, tag after merge, then merge or cherry-pick back to + `develop`. + +## After Recovery + +- Document root cause, affected versions, user impact, and prevention work. +- Update release notes and the project tracker with the final disposition. diff --git a/docs/universal-wallet-v2-vectors.json b/docs/universal-wallet-v2-vectors.json new file mode 100644 index 0000000000..99fade5385 --- /dev/null +++ b/docs/universal-wallet-v2-vectors.json @@ -0,0 +1,147 @@ +{ + "version": 1, + "generatedAt": "2026-06-23", + "derivationSpec": "Fearless Universal Wallet V2", + "vectors": [ + { + "id": "import12", + "mnemonic": "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + "wordCount": 12, + "expected": { + "substrate": { + "derivationPath": "", + "cryptoType": "sr25519", + "publicKeyHex": "66933bd1f37070ef87bd1198af3dacceb095237f803f3d32b173e6b425ed7972", + "polkadotAddress": "13KVd4f2a4S5pLp4gTTFezyXdPWx27vQ9vS6xBXJ9yWVd7xo", + "ss58Prefix": 0 + }, + "evm": { + "derivationPath": "m/44'/60'/0'/0/0", + "address": "0x9858EfFD232B4033E47d90003D41EC34EcaEda94" + }, + "bitcoin": { + "mainnet": { + "accountPath": "m/84'/0'/0'", + "firstReceivePath": "m/84'/0'/0'/0/0", + "accountXpub": "xpub6CatWdiZiodmUeTDp8LT5or8nmbKNcuyvz7WyksVFkKB4RHwCD3XyuvPEbvqAQY3rAPshWcMLoP2fMFMKHPJ4ZeZXYVUhLv1VMrjPC7PW6V", + "firstReceiveAddress": "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu" + }, + "testnet": { + "accountPath": "m/84'/1'/0'", + "firstReceivePath": "m/84'/1'/0'/0/0", + "accountXpub": "xpub6Bm9M1SxZdzL3TxdNV8897FgtTLBgehR1wVNnMyJ5VLRK5n3tFqXxrCVnVQj4zooN4eFSkf6Sma84reWc5ZCXMxPbLXQs3BcaBdTd4YQa3B", + "firstReceiveAddress": "tb1q6rz28mcfaxtmd6v789l9rrlrusdprr9pqcpvkl" + } + }, + "solana": { + "derivationPath": "m/44'/501'/0'/0'", + "publicKeyHex": "f036276246a75b9de3349ed42b15e232f6518fc20f5fcd4f1d64e81f9bd258f7", + "address": "HAgk14JpMQLgt6rVgv7cBQFJWFto5Dqxi472uT3DKpqk" + }, + "ton": { + "derivationPath": "m/44'/607'/0'/0'/0'", + "walletVersion": "v4r2", + "workchain": 0, + "publicKeyHex": "3b5dd9de5a5a4f6b17ecec17135772bea6b132ecaa9413d930aa0dcc75d29fa6", + "addressBounceable": "EQDxAUFadQXDd3EXGa3TLF_EF66gMc9h3_aZ0j0zXNoIYR1Z", + "addressNonBounceable": "UQDxAUFadQXDd3EXGa3TLF_EF66gMc9h3_aZ0j0zXNoIYUCc", + "testnetNonBounceable": "0QDxAUFadQXDd3EXGa3TLF_EF66gMc9h3_aZ0j0zXNoIYfsW" + }, + "iroha": { + "derivationPath": "m/44'/617'/0'/0'", + "taira": { + "publicKeyHex": "8dea60946dd00558e61254c7aadeccabd1e39d5b9a12a45b678e22209f19426f", + "canonicalHex": "0x020001208dea60946dd00558e61254c7aadeccabd1e39d5b9a12a45b678e22209f19426f", + "i105": "testu\uff9b1Pc\uff852\uff97t\uff89a\uff98L\uff95\uff7d2M\u30f1\uff90\uff8e\uff73\uff93\u30f1\uff77\uff86\uff72M\uff92S\uff8f\uff71\u30f1\uff77J\u30f1FmJ\uff87Ms6YN687Y", + "chainDiscriminant": 369 + }, + "nexus": { + "publicKeyHex": "8dea60946dd00558e61254c7aadeccabd1e39d5b9a12a45b678e22209f19426f", + "canonicalHex": "0x020001208dea60946dd00558e61254c7aadeccabd1e39d5b9a12a45b678e22209f19426f", + "i105": "sorau\uff9b1Pc\uff852\uff97t\uff89a\uff98L\uff95\uff7d2M\u30f1\uff90\uff8e\uff73\uff93\u30f1\uff77\uff86\uff72M\uff92S\uff8f\uff71\u30f1\uff77J\u30f1FmJ\uff87Ms6YN687Y", + "chainDiscriminant": 753 + } + } + } + }, + { + "id": "default24", + "mnemonic": "abandon amount liar amount expire adjust cage candy arch gather drum bullet absurd math era live bid rhythm alien crouch range attend journey unaware", + "wordCount": 24, + "expected": { + "substrate": { + "derivationPath": "", + "cryptoType": "sr25519", + "publicKeyHex": "4a50a9606f3b0c47e0582f9a2dce9da3ec59819c6e15468f6f03f07e7fcfed23", + "polkadotAddress": "12gSWHjzeFyd2aSLVCVjByvQ8DVXSVWzQV4jAdjpdzCjExyg", + "ss58Prefix": 0 + }, + "evm": { + "derivationPath": "m/44'/60'/0'/0/0", + "address": "0xF9297b542BDb5DA50C364f9AE4Cbe1F3933bA40F" + }, + "bitcoin": { + "mainnet": { + "accountPath": "m/84'/0'/0'", + "firstReceivePath": "m/84'/0'/0'/0/0", + "accountXpub": "xpub6BqBNbtUuW88D24ZcyjziejtCDfiqyXsAoXVAoHEQxmyNBxHqomJcqq8sufg8YbyD25ScJeHcENXxUeLPmW9sxt5xyd7Y1HvvpziHQEopcs", + "firstReceiveAddress": "bc1qslk39wvggqa0vl8nd6jckaz54dw3vk45c5w60m" + }, + "testnet": { + "accountPath": "m/84'/1'/0'", + "firstReceivePath": "m/84'/1'/0'/0/0", + "accountXpub": "xpub6CTZ93vw865e4ZwMboznYa8vnpCho3kAsqjK6WT5mY9c8yePzWKLAtGHV3ikjdLW1Yf72fmHdoT6bAeUeqVG1HZCENtwvNckQJtCxCuuox9", + "firstReceiveAddress": "tb1q2mhcnxyddvq4vxja3mg5t73uknyppfx2u4k54f" + } + }, + "solana": { + "derivationPath": "m/44'/501'/0'/0'", + "publicKeyHex": "41463818479793c75705f6984a7ddd22f83d3e968c5c2c036cec881addfd5cec", + "address": "5Pobwp6d9ihN9Nz38f87gVCEBFMgipFiSM2VtUhVit6w" + }, + "ton": { + "derivationPath": "m/44'/607'/0'/0'/0'", + "walletVersion": "v4r2", + "workchain": 0, + "publicKeyHex": "c1f91bfd0283d9c25f14488d8d252c8ce74fe05edcaf4f5c2f647f92a08af09c", + "addressBounceable": "EQAXzfXU_vaoDQkasgmNRC2gG0cU0bS83sV4bEXVswywW4Lx", + "addressNonBounceable": "UQAXzfXU_vaoDQkasgmNRC2gG0cU0bS83sV4bEXVswywW980", + "testnetNonBounceable": "0QAXzfXU_vaoDQkasgmNRC2gG0cU0bS83sV4bEXVswywW2S-" + }, + "iroha": { + "derivationPath": "m/44'/617'/0'/0'", + "taira": { + "publicKeyHex": "9a676c350154f7b7183c5b2f44a49f926a75967260840f43062afdcf4259b995", + "canonicalHex": "0x020001209a676c350154f7b7183c5b2f44a49f926a75967260840f43062afdcf4259b995", + "i105": "testu\uff9b1Pn4Z3\uff81e\uff78ZzXm\uff86Wdh\uff8cy\uff7e\uff93T\uff90T\uff86aU\uff9aS\uff991\uff76\uff9b\uff79C\uff77e8\uff73\uff8b154C93", + "chainDiscriminant": 369 + }, + "nexus": { + "publicKeyHex": "9a676c350154f7b7183c5b2f44a49f926a75967260840f43062afdcf4259b995", + "canonicalHex": "0x020001209a676c350154f7b7183c5b2f44a49f926a75967260840f43062afdcf4259b995", + "i105": "sorau\uff9b1Pn4Z3\uff81e\uff78ZzXm\uff86Wdh\uff8cy\uff7e\uff93T\uff90T\uff86aU\uff9aS\uff991\uff76\uff9b\uff79C\uff77e8\uff73\uff8b154C93", + "chainDiscriminant": 753 + } + } + } + } + ], + "negativeCases": [ + { + "id": "bitcoin-wrong-purpose", + "reason": "BIP44 m/44 paths must not match BIP84 native SegWit addresses." + }, + { + "id": "solana-wrong-path", + "reason": "m/44'/501'/0' must not match the default m/44'/501'/0'/0' account." + }, + { + "id": "iroha-wrong-discriminant", + "reason": "Taira discriminant 369 and Nexus discriminant 753 must not be accepted interchangeably." + }, + { + "id": "ton-testnet-flag", + "reason": "TON testnet-only address encoding must not be accepted as a mainnet receive address." + } + ] +} diff --git a/docs/universal-wallet-v2.md b/docs/universal-wallet-v2.md new file mode 100644 index 0000000000..e329640a38 --- /dev/null +++ b/docs/universal-wallet-v2.md @@ -0,0 +1,205 @@ +# Universal Wallet V2 Contract + +Status: implementation contract for the Bitcoin, Solana, TON, and SORA Nexus +workstreams. + +## Goals + +- New wallets are one encrypted universal wallet, not one wallet per ecosystem. +- The default backup phrase for newly created wallets is 24 BIP39 words. +- Import remains compatible with 12-word BIP39 phrases. +- One phrase deterministically restores every supported account on Android, iOS, + and the browser extension. +- Legacy accounts remain available only for export and fund-safety recovery after + the migration cutoff. +- Wallet core, chain registry defaults, derivation rules, and indexer clients + stay open source. + +## Ecosystems + +The shared ecosystem enum is lowercase at API, registry, and fixture boundaries: + +- `substrate` +- `evm` +- `ton` +- `bitcoin` +- `solana` +- `iroha` + +Platform UI may map these values to native enum names, but persisted and networked +interfaces must use the lowercase identifiers above. + +## Identity Envelope + +Wallet metadata uses a versioned JSON envelope with these fields: + +| Field | Requirement | +| --- | --- | +| `schemaVersion` | Must be `2`. | +| `walletId` | Stable local id matching `uw2_[A-Za-z0-9_-]{16,64}`. | +| `displayName` | Non-empty user-visible name, at most 64 characters, no control characters. | +| `source` | One of `created-24-word`, `imported-12-word`, `imported-24-word`, `legacy-import`. | +| `status` | One of `active`, `migration-required`, `legacy-export-only`. | +| `publicAccounts` | Public account descriptors only; no mnemonic, seed, private key, or encrypted secret material. | +| `createdAtMillis` | Positive Unix epoch timestamp in milliseconds. | +| `updatedAtMillis` | Optional Unix epoch timestamp in milliseconds, greater than or equal to `createdAtMillis`. | +| `legacyExportOnlyReason` | Required only for `legacy-export-only`; otherwise must be absent or blank. | + +An `active` identity must include at least one public account for every supported +ecosystem: `substrate`, `evm`, `ton`, `bitcoin`, `solana`, and `iroha`. +`migration-required` identities may be partial but must contain at least one +public account. `legacy-export-only` identities must use `source = +legacy-import` and include a short human-readable recovery/export reason. + +Public account descriptors use lowercase ecosystem ids, stable account ids +matching `^[a-z0-9][a-z0-9._:-]{1,63}$`, chain ids matching +`^[A-Za-z0-9._:-]{2,128}$` when present, derivation paths rooted at `m`, and +lowercase hex public keys when present. + +## Signing Contract + +Wallet signing prompts use a shared request/result envelope. Requests contain +`requestId`, `accountId`, `ecosystem`, `chainId`, `origin`, `method`, +chain-specific payload fields, `createdAtMillis`, and optional +`expiresAtMillis`. + +Allowed methods are `sign-message`, `sign-transaction`, +`sign-and-send-transaction`, and `sign-all-transactions`. Message payloads use +`base64`, `hex`, or `utf8` encoding plus a display hint of `raw`, `utf8`, or +`hex`. Single transaction methods carry `transactionBase64`. Batch signing +carries `transactionsBase64` with at most 16 transactions. + +Results contain the request identity fields, method, `status`, signing outputs, +optional `errorCode`, and `signedAtMillis`. Allowed statuses are `approved`, +`rejected`, and `failed`. Approved message results must include a public key and +at least one signature representation. Approved transaction results must include +the signed transaction payload. Approved sign-and-send results must also include +a transaction hash. Rejected and failed results must include an error code and +must not include signatures or signed payloads. + +Clients must reject malformed request ids, account ids, ecosystems, chain ids, +origins, timestamps, invalid base64/hex payloads, oversized transaction batches, +and result payloads that do not match their status and method. + +## Hard-Cutoff Migration + +After the migration cutoff, normal wallet access is allowed only when a valid +Universal Wallet V2 identity exists. A wallet with legacy accounts and no +universal wallet must enter `migrate-before-access`: normal balance, transfer, +staking, dApp, and signing flows stay blocked until the user creates or imports a +universal wallet. A fresh install with no legacy material enters +`create-universal-wallet`. + +Legacy vault descriptors are export-only. They may expose backup/export and +fund-safety recovery metadata, but they must set `canExportSecrets = true`, +`canSignTransactions = false`, and `mode = export-only`. Legacy vaults must not +be used for new signatures, broadcasts, staking actions, dApp approvals, or +normal account selection after the cutoff. + +Migration snapshots contain `schemaVersion`, `platform`, `hasUniversalWallet`, +`legacyVaults`, `cutoffAtMillis`, and `evaluatedAtMillis`. Clients must reject +duplicate legacy vault ids, malformed account ids, unknown ecosystems, unsafe +addresses or display names, missing export-only reasons, disabled export, legacy +signing flags, and invalid timestamps. + +## Secure-Storage Migration Requirements + +Android migration must read old account material only through the existing +encrypted stores, write Universal Wallet V2 material through Android Keystore +backed encryption where available, preserve export-only legacy descriptors, and +never copy mnemonic, seed, or private-key material into public logs, analytics, +plain preferences, or repo fixtures. + +iOS migration must read old account material only through Keychain-backed +storage, write Universal Wallet V2 material to the configured Keychain access +group with passcode or biometric protection where available, preserve export-only +legacy descriptors, and avoid storing secret material in Core Data, user defaults, +logs, or public fixtures. + +Web extension migration must read old account material through the extension +keyring, write Universal Wallet V2 material only through the encrypted keyring or +WebCrypto-protected browser storage, preserve export-only legacy descriptors, and +avoid storing secret material in unencrypted local storage, background messages, +Redux/Pinia state, logs, or test fixtures. + +## Derivation Defaults + +| Ecosystem | Default | +| --- | --- | +| Substrate/DOT | Existing sr25519 root derivation; SS58 prefix is chain-specific. | +| EVM | `m/44'/60'/0'/0/0` | +| Bitcoin mainnet | BIP84 account path `m/84'/0'/0'`; first receive path `m/84'/0'/0'/0/0`. | +| Bitcoin testnet | BIP84 account path `m/84'/1'/0'`; first receive path `m/84'/1'/0'/0/0`. | +| Solana | `m/44'/501'/0'/0'` | +| TON | `m/44'/607'/0'/0'/0'`, Wallet V4 R2, workchain `0`. | +| SORA Nexus/Iroha | `m/44'/617'/0'/0'`, Ed25519 I105 account address. | + +Solana import compatibility may support common existing Solana paths, but new +Universal Wallet V2 accounts must use `m/44'/501'/0'/0'`. + +## Registry Requirements + +Registry files use `schemaVersion = 1` and a top-level `chains` array. Each +chain entry contains `id`, lowercase `ecosystem`, `chainId`, `displayName`, +`enabledByDefault`, optional `nativeAsset`, optional `derivationPath`, optional +`slip44CoinType`, and `endpoints`. + +Enabled-by-default chains must include at least one endpoint. Disabled or gated +chains, including Nexus before its production Torii/TLS endpoint is confirmed, +may omit endpoints. Chain ids and endpoint ids must be unique, display names +must be non-empty human-readable strings without control characters, native asset +symbols must be uppercase, decimals must be in `0...255`, and derivation paths +must be rooted at `m`. + +Endpoint kinds are `indexer`, `rpc`, `torii-mcp`, and `explorer`. Public URLs +must be HTTPS; local development URLs may use `http://localhost` or +`http://127.0.0.1`. Public indexer endpoints are read-only. Broadcasts, +transaction simulation, and other write operations must use RPC or Torii +endpoints, never a public indexer endpoint. + +- TON indexer base URL: `https://ti.soramitsu.io`. +- Solana indexer base URL: `https://si.soramitsu.io`. +- `si.soramitsu.io` is read-only. Transaction simulation and broadcast use the + configured Solana RPC endpoint directly. +- Taira testnet is enabled with I105 chain discriminant `369`, Torii root + `https://taira.sora.org`, and chain id `iroha3-taira`. +- Nexus mainnet uses I105 chain discriminant `753` and chain id + `sora:nexus:global`, but remains registry-gated until the + production Torii/TLS endpoint is confirmed. + +## Normalized Indexer Contract + +Wallet-facing indexer code maps raw chain responses into normalized public +payloads before UI, cache, or migration logic consumes them. + +Normalized asset balances contain `accountId`, `ecosystem`, `chainId`, +`assetId`, raw integer `amount`, `decimals`, `isNative`, optional `symbol`, +optional `name`, optional `uiAmountString`, optional token or contract account +ids, and `syncedAtMillis`. + +Normalized transactions contain `accountId`, `ecosystem`, `chainId`, +`transactionId`, `status`, `direction`, `operationType`, optional timestamp, +optional raw integer amount and fee fields, optional counterparty, optional block +number, optional cursor, optional HTTPS explorer URL, and `syncedAtMillis`. +Allowed transaction statuses are `pending`, `confirmed`, and `failed`. Allowed +directions are `incoming`, `outgoing`, `self`, and `unknown`. Allowed operation +types are `transfer`, `swap`, `stake`, `unstake`, `governance`, +`contract-call`, `mint`, `burn`, `fee`, and `unknown`. + +Normalized token metadata contains `ecosystem`, `chainId`, `assetId`, +`decimals`, optional `symbol`, optional `name`, optional `iconUrl`, optional +`metadataUrl`, `isVerified`, and `syncedAtMillis`. Public asset URLs must be +HTTPS or IPFS URLs. Page metadata contains optional `nextCursor`, `limit`, +optional `total`, and `syncedAtMillis`; page limits are capped at `250`. + +All normalized amounts are decimal strings containing unsigned raw integer +amounts. The clients must reject malformed account ids, unknown ecosystems, +malformed chain ids, unsafe cursors, invalid URLs, non-integer amounts, invalid +decimals, and non-positive sync timestamps before persisting normalized data. + +## Fixture Contract + +Golden vectors live in `docs/universal-wallet-v2-vectors.json`. Every wallet +repo must keep an equivalent fixture and test that validates all ecosystem +sections, derives each expected public address, and rejects wrong network, +wrong path, and wrong I105 discriminant cases. diff --git a/fearless.xcodeproj/project.pbxproj b/fearless.xcodeproj/project.pbxproj index 4735da1977..ba9a2d4864 100644 --- a/fearless.xcodeproj/project.pbxproj +++ b/fearless.xcodeproj/project.pbxproj @@ -285,8 +285,7 @@ 2CF2F93AF862CF54FC46B560 /* PurchaseInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91D44421CCD7AD220A05CD0E /* PurchaseInteractor.swift */; }; 2CFEBF8B7B9C820D1A80B60B /* StakingPoolCreateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A6A52B8A4D734D5BCADE355 /* StakingPoolCreateTests.swift */; }; 2D2FA50F16CD470C878A8336 /* CoreDataAliases.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46F82B507C2844DA90822C42 /* CoreDataAliases.swift */; }; - 2D5B1A6B315FEF23A885A55E /* fearless/Common/Keys/FearlessKeysShim.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1E2F3A4B5C60718293A4B5C /* fearless/Common/Keys/FearlessKeysShim.swift */; }; - A1B2C3D4E5F60718293A4B5E /* TonChainSelectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60718293A4B5D /* TonChainSelectionTests.swift */; }; + 2D5B1A6B315FEF23A885A55E /* FearlessKeysShim.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1E2F3A4B5C60718293A4B5C /* FearlessKeysShim.swift */; }; 2E57C70427E8AB3D00AF075A /* CrowdloanWikiTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E57C70327E8AB3D00AF075A /* CrowdloanWikiTableViewCell.swift */; }; 2E57C70B27E9EC0F00AF075A /* ProfileViewState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E57C70A27E9EC0E00AF075A /* ProfileViewState.swift */; }; 2E57C70D27E9ED5400AF075A /* ProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E57C70C27E9ED5300AF075A /* ProfileViewModel.swift */; }; @@ -591,7 +590,6 @@ 842D1E9324D20CC800C30A7A /* OnboardingMainTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842D1E9224D20CC800C30A7A /* OnboardingMainTests.swift */; }; 842D1E9624D2DD6700C30A7A /* MnemonicDisplayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842D1E9524D2DD6700C30A7A /* MnemonicDisplayView.swift */; }; 84300B2F26C1112300D64514 /* ConnectionPoolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84300B2E26C1112300D64514 /* ConnectionPoolTests.swift */; }; - F1A2B3C4D5E6F708192A3B4C /* NetworkWorkerCompatibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A2B3C4D5E6F708192A3B4D /* NetworkWorkerCompatibilityTests.swift */; }; 8430AACC2602249B005B1066 /* InitialStakingState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8430AACB2602249B005B1066 /* InitialStakingState.swift */; }; 8430AAD42602285B005B1066 /* StakingStateCommonData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8430AAD32602285B005B1066 /* StakingStateCommonData.swift */; }; 8430AADC26022C58005B1066 /* NoStashState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8430AADB26022C58005B1066 /* NoStashState.swift */; }; @@ -708,8 +706,6 @@ 845B822326EFDF3F00D25C72 /* SelectedAccountSettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 845B822226EFDF3F00D25C72 /* SelectedAccountSettingsTests.swift */; }; 845B822526EFE03E00D25C72 /* AccountGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 845B822426EFE03E00D25C72 /* AccountGenerator.swift */; }; 845B822726EFFE0200D25C72 /* MetaAccountMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 845B822626EFFE0200D25C72 /* MetaAccountMapperTests.swift */; }; - A7B8C9D0E1F23456789ABCDF /* AssetModelMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7B8C9D0E1F23456789ABCDE /* AssetModelMapperTests.swift */; }; - F0A1B2C3D4E5F60718293A4C /* AssetRepositoryFactoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0A1B2C3D4E5F60718293A4B /* AssetRepositoryFactoryTests.swift */; }; 845B822926F0BB6700D25C72 /* CrowdloanChainSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 845B822826F0BB6700D25C72 /* CrowdloanChainSettings.swift */; }; 845BB8B825E4465F00E5FCDC /* ExtrinsicService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 845BB8B725E4465F00E5FCDC /* ExtrinsicService.swift */; }; 845BB8C025E4508800E5FCDC /* SS58FactoryExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 845BB8BF25E4508800E5FCDC /* SS58FactoryExtensions.swift */; }; @@ -1309,6 +1305,7 @@ 9F664DA212363F5F2C2B530B /* SelectMarketRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C05A688EA7379572BBCE545 /* SelectMarketRouter.swift */; }; A090FF206B56A0E465C62072 /* CrowdloanListPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86F7A369E31DCB9ABD556EE9 /* CrowdloanListPresenter.swift */; }; A14F444FA808457C16EF826C /* WalletOptionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 193908913A5B241C82672D8A /* WalletOptionViewController.swift */; }; + A1B2C3D4E5F60718293A4B5E /* TonChainSelectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60718293A4B5D /* TonChainSelectionTests.swift */; }; A1C05D0028CD04C16AB6082F /* LiquidityPoolDetailsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67BDE520860A67C800E7F4AB /* LiquidityPoolDetailsViewController.swift */; }; A29F1452BF0AEB885E6460E2 /* SelectMarketPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E07F60661001B2C505D9C8B /* SelectMarketPresenter.swift */; }; A32E1373E3671D518FFC3BC2 /* YourValidatorListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B90CEC70F101AA25A4C00021 /* YourValidatorListViewController.swift */; }; @@ -1324,6 +1321,7 @@ A73B076B0E983DA669C1F215 /* SwapTransactionDetailPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69B00AC48FB7D11855875EB9 /* SwapTransactionDetailPresenter.swift */; }; A73E830F0BAE2FB63F0408DB /* WalletChainAccountDashboardTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22289DD3B70546794C4838CC /* WalletChainAccountDashboardTests.swift */; }; A76B05966CE772DD4B822A3C /* AccountCreateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17717EAA891DDB5A0223718F /* AccountCreateTests.swift */; }; + A7B8C9D0E1F23456789ABCDF /* AssetModelMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7B8C9D0E1F23456789ABCDE /* AssetModelMapperTests.swift */; }; A871B6ABACAE8A811010F792 /* StakingPayoutConfirmationWireframe.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5ACCFD31E14AF4D30955288 /* StakingPayoutConfirmationWireframe.swift */; }; A8F69AC9D7294E7DCBA50470 /* SelectValidatorsStartPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B6244A9538B39AFCD3A6F3A /* SelectValidatorsStartPresenter.swift */; }; A9597D17F54CFF4F3704D868 /* AssetNetworksPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67B1037AEC97DBEAF9FD50C1 /* AssetNetworksPresenter.swift */; }; @@ -1480,7 +1478,7 @@ B893A2515909AB6915196317 /* NetworkInfoViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B0E2EDF787BF82F16663215 /* NetworkInfoViewController.swift */; }; BA7AEE82627CFC0AFD69B299 /* RecommendedValidatorListPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA2580363AC3E4A9CD40256E /* RecommendedValidatorListPresenter.swift */; }; BB96B1EA86DAB3E83B50E4BD /* MultichainAssetSelectionPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26B8EEA0D384CCA5EB1CA052 /* MultichainAssetSelectionPresenter.swift */; }; - BBCCDDEEAABB112233445566 /* fearless/Modules/Send/Validators/XcmMinAmountInspectorShim.swift in Sources */ = {isa = PBXBuildFile; fileRef = AABBCCDDEEFF112233445566 /* fearless/Modules/Send/Validators/XcmMinAmountInspectorShim.swift */; }; + BBCCDDEEAABB112233445566 /* XcmMinAmountInspectorShim.swift in Sources */ = {isa = PBXBuildFile; fileRef = AABBCCDDEEFF112233445566 /* XcmMinAmountInspectorShim.swift */; }; BC2DF589C6623601C39EF8F4 /* LiquidityPoolSupplyPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31F848482B2AD7D6831B0CCE /* LiquidityPoolSupplyPresenter.swift */; }; BD571417BD18C711B76E1D62 /* ExportSeedWireframe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B4C1B5D56DB69BA0AECF731 /* ExportSeedWireframe.swift */; }; BDE80F08EBEE3B0C95598EA8 /* WalletSendConfirmInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE152D16E6C78D297BFFC3C /* WalletSendConfirmInteractor.swift */; }; @@ -1533,7 +1531,6 @@ C6182B412C631AAC0089558D /* SSFTransferService in Frameworks */ = {isa = PBXBuildFile; productRef = C6182B402C631AAC0089558D /* SSFTransferService */; }; C6182B432C631AAC0089558D /* SSFUtils in Frameworks */ = {isa = PBXBuildFile; productRef = C6182B422C631AAC0089558D /* SSFUtils */; }; C6182B452C631AAC0089558D /* SSFXCM in Frameworks */ = {isa = PBXBuildFile; productRef = C6182B442C631AAC0089558D /* SSFXCM */; }; - C6182B472C631AAC0089558D /* SoraKeystore in Frameworks */ = {isa = PBXBuildFile; productRef = C6182B462C631AAC0089558D /* SoraKeystore */; }; C6182B492C631AAC0089558D /* keccak in Frameworks */ = {isa = PBXBuildFile; productRef = C6182B482C631AAC0089558D /* keccak */; }; C6182B4C2C6474F30089558D /* PricesService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6182B4B2C6474F30089558D /* PricesService.swift */; }; C6264C292799A56E00FCA0DB /* WalletDetailsTableCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6264C282799A56E00FCA0DB /* WalletDetailsTableCell.swift */; }; @@ -1716,9 +1713,11 @@ EF02C9661F03C8EF58182997 /* StakingAmountViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB76FEC075A6FE1D246BA5DD /* StakingAmountViewController.swift */; }; EF23CA9AF3889FEAB2D6CBD6 /* NftCollectionPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25B80FDDB5C3032A0BBBD826 /* NftCollectionPresenter.swift */; }; F022F1444E0F75CCA42F4648 /* YourValidatorListProtocols.swift in Sources */ = {isa = PBXBuildFile; fileRef = A31780E84948D7FE632ECB02 /* YourValidatorListProtocols.swift */; }; + F0A1B2C3D4E5F60718293A4C /* AssetRepositoryFactoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0A1B2C3D4E5F60718293A4B /* AssetRepositoryFactoryTests.swift */; }; F0C3DB0CEE1975626B0014A8 /* StakingUnbondConfirmInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C34D496D0F57E685237B3A7 /* StakingUnbondConfirmInteractor.swift */; }; F17C7FA0DB540A803558D1BB /* AnalyticsRewardDetailsPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85211D55E2AF0A697FB3EB84 /* AnalyticsRewardDetailsPresenter.swift */; }; - F1E2D3C4B5A60718293A4B5C /* fearless/Common/Network/GraphQL/GraphQLResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1F2A3B4C5D60718293A4B5C /* fearless/Common/Network/GraphQL/GraphQLResponse.swift */; }; + F1A2B3C4D5E6F708192A3B4C /* NetworkWorkerCompatibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A2B3C4D5E6F708192A3B4D /* NetworkWorkerCompatibilityTests.swift */; }; + F1E2D3C4B5A60718293A4B5C /* GraphQLResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1F2A3B4C5D60718293A4B5C /* GraphQLResponse.swift */; }; F20C8D17ABF18B7104E14394 /* StakingAmountInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 312DE7ADA5ABC3214AD3D4AD /* StakingAmountInteractor.swift */; }; F31469BD18062A4A008FE39E /* LiquidityPoolSupplyViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 497EFA05A2D8076BFE82964D /* LiquidityPoolSupplyViewController.swift */; }; F38273DB7A8B139B9FD8FBE2 /* MainNftContainerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AFC9C09ABBCEB6E581134E84 /* MainNftContainerViewController.swift */; }; @@ -2904,6 +2903,86 @@ FAE39B172AA965940011A9D6 /* URL+IPFS.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE39B162AA965940011A9D6 /* URL+IPFS.swift */; }; FAE41F5B2C8CF4C900123456 /* SceneWindowFinder.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE41F5A2C8CF4C900123456 /* SceneWindowFinder.swift */; }; FAE5858F2B0764ED00240FE1 /* SoraSubsquidHistoryOperationFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE5858E2B0764EC00240FE1 /* SoraSubsquidHistoryOperationFactory.swift */; }; + FAE601022ECA700100000001 /* UniversalWalletV2VectorsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE601012ECA700100000001 /* UniversalWalletV2VectorsTests.swift */; }; + FAE601042ECA700100000001 /* UniversalWalletEcosystem.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE601032ECA700100000001 /* UniversalWalletEcosystem.swift */; }; + FAE601062ECA700100000001 /* UniversalWalletRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE601052ECA700100000001 /* UniversalWalletRegistry.swift */; }; + FAE601082ECA700100000001 /* UniversalWalletDerivationPaths.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE601072ECA700100000001 /* UniversalWalletDerivationPaths.swift */; }; + FAE6010A2ECA700100000001 /* IrohaAddressCodec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE601092ECA700100000001 /* IrohaAddressCodec.swift */; }; + FAE6010C2ECA700100000001 /* IrohaAddressCodecTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE6010B2ECA700100000001 /* IrohaAddressCodecTests.swift */; }; + FAE6010E2ECA700100000001 /* SolanaKeyDerivation.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE6010D2ECA700100000001 /* SolanaKeyDerivation.swift */; }; + FAE601102ECA700100000001 /* SolanaIndexerContract.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE6010F2ECA700100000001 /* SolanaIndexerContract.swift */; }; + FAE601122ECA700100000001 /* SolanaIndexerContractTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE601112ECA700100000001 /* SolanaIndexerContractTests.swift */; }; + FAE601142ECA700100000001 /* BitcoinKeyDerivation.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE601152ECA700100000001 /* BitcoinKeyDerivation.swift */; }; + FAE601162ECA700100000001 /* BitcoinIndexerContract.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE601172ECA700100000001 /* BitcoinIndexerContract.swift */; }; + FAE601182ECA700100000001 /* BitcoinIndexerContractTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE601192ECA700100000001 /* BitcoinIndexerContractTests.swift */; }; + FAE601202ECA700100000001 /* SolanaSigner.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE601212ECA700100000001 /* SolanaSigner.swift */; }; + FAE601222ECA700100000001 /* SolanaTransactionSigner.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE601232ECA700100000001 /* SolanaTransactionSigner.swift */; }; + FAE601242ECA700100000001 /* SolanaTransactionSignerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE601252ECA700100000001 /* SolanaTransactionSignerTests.swift */; }; + FAE601262ECA700100000001 /* IrohaKeyDerivation.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE601272ECA700100000001 /* IrohaKeyDerivation.swift */; }; + FAE601282ECA700100000001 /* TonAddressCodec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE601292ECA700100000001 /* TonAddressCodec.swift */; }; + FAE6012A2ECA700100000001 /* TonKeyDerivation.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE6012B2ECA700100000001 /* TonKeyDerivation.swift */; }; + FAE6012C2ECA700100000001 /* TonIndexerContract.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE6012D2ECA700100000001 /* TonIndexerContract.swift */; }; + FAE6012E2ECA700100000001 /* TonIndexerContractTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE6012F2ECA700100000001 /* TonIndexerContractTests.swift */; }; + FAE601302ECA700100000001 /* IrohaToriiContract.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE601312ECA700100000001 /* IrohaToriiContract.swift */; }; + FAE601322ECA700100000001 /* IrohaToriiContractTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE601332ECA700100000001 /* IrohaToriiContractTests.swift */; }; + FAE601342ECA700100000001 /* IrohaToriiClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE601352ECA700100000001 /* IrohaToriiClient.swift */; }; + FAE601362ECA700100000001 /* IrohaToriiClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE601372ECA700100000001 /* IrohaToriiClientTests.swift */; }; + FAE601382ECA700100000001 /* TonIndexerClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE601392ECA700100000001 /* TonIndexerClient.swift */; }; + FAE6013A2ECA700100000001 /* TonIndexerClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE6013B2ECA700100000001 /* TonIndexerClientTests.swift */; }; + FAE6013C2ECA700100000001 /* SolanaIndexerClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE6013D2ECA700100000001 /* SolanaIndexerClient.swift */; }; + FAE6013E2ECA700100000001 /* SolanaIndexerClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE6013F2ECA700100000001 /* SolanaIndexerClientTests.swift */; }; + FAE601402ECA700100000001 /* BitcoinIndexerClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE601412ECA700100000001 /* BitcoinIndexerClient.swift */; }; + FAE601422ECA700100000001 /* BitcoinIndexerClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE601432ECA700100000001 /* BitcoinIndexerClientTests.swift */; }; + FAE601442ECA700100000001 /* UniversalWalletHTTPTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE601452ECA700100000001 /* UniversalWalletHTTPTransport.swift */; }; + FAE601462ECA700100000001 /* UniversalWalletIndexerClientFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE601472ECA700100000001 /* UniversalWalletIndexerClientFactory.swift */; }; + FAE601482ECA700100000001 /* UniversalWalletIndexerClientFactoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE601492ECA700100000001 /* UniversalWalletIndexerClientFactoryTests.swift */; }; + FAE6014A2ECA700100000001 /* UniversalWalletIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE6014B2ECA700100000001 /* UniversalWalletIdentity.swift */; }; + FAE6014C2ECA700100000001 /* UniversalWalletIdentityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE6014D2ECA700100000001 /* UniversalWalletIdentityTests.swift */; }; + FAE6014E2ECA700100000001 /* UniversalWalletIndexerContract.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE6014F2ECA700100000001 /* UniversalWalletIndexerContract.swift */; }; + FAE601502ECA700100000001 /* UniversalWalletIndexerContractTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE601512ECA700100000001 /* UniversalWalletIndexerContractTests.swift */; }; + FAE601522ECA700100000001 /* UniversalWalletSigningContract.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE601532ECA700100000001 /* UniversalWalletSigningContract.swift */; }; + FAE601542ECA700100000001 /* UniversalWalletSigningContractTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE601552ECA700100000001 /* UniversalWalletSigningContractTests.swift */; }; + FAE601562ECA700100000001 /* UniversalWalletMigrationContract.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE601572ECA700100000001 /* UniversalWalletMigrationContract.swift */; }; + FAE601582ECA700100000001 /* UniversalWalletMigrationContractTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE601592ECA700100000001 /* UniversalWalletMigrationContractTests.swift */; }; + FAE6015A2ECA700100000001 /* UniversalWalletRegistryContract.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE6015B2ECA700100000001 /* UniversalWalletRegistryContract.swift */; }; + FAE6015C2ECA700100000001 /* UniversalWalletRegistryContractTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE6015D2ECA700100000001 /* UniversalWalletRegistryContractTests.swift */; }; + FAE6016A2ECA700100000001 /* BitcoinReceiveDiscovery.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE6016B2ECA700100000001 /* BitcoinReceiveDiscovery.swift */; }; + FAE6016C2ECA700100000001 /* BitcoinReceiveDiscoveryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE6016D2ECA700100000001 /* BitcoinReceiveDiscoveryTests.swift */; }; + FAE6016E2ECA700100000001 /* BitcoinBalanceSync.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE6016F2ECA700100000001 /* BitcoinBalanceSync.swift */; }; + FAE601702ECA700100000001 /* BitcoinBalanceSyncTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE601712ECA700100000001 /* BitcoinBalanceSyncTests.swift */; }; + FAE601722ECA700100000001 /* BitcoinFeeEstimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE601732ECA700100000001 /* BitcoinFeeEstimator.swift */; }; + FAE601742ECA700100000001 /* BitcoinFeeEstimatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE601752ECA700100000001 /* BitcoinFeeEstimatorTests.swift */; }; + FAE601762ECA700100000001 /* BitcoinUtxoSelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE601772ECA700100000001 /* BitcoinUtxoSelector.swift */; }; + FAE601782ECA700100000001 /* BitcoinUtxoSelectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE601792ECA700100000001 /* BitcoinUtxoSelectorTests.swift */; }; + FAE6017A2ECA700100000001 /* BitcoinSendPlanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE6017B2ECA700100000001 /* BitcoinSendPlanner.swift */; }; + FAE6017C2ECA700100000001 /* BitcoinSendPlannerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE6017D2ECA700100000001 /* BitcoinSendPlannerTests.swift */; }; + FAE6017E2ECA700100000001 /* BitcoinTransactionBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE6017F2ECA700100000001 /* BitcoinTransactionBuilder.swift */; }; + FAE601802ECA700100000001 /* BitcoinTransactionBuilderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE601812ECA700100000001 /* BitcoinTransactionBuilderTests.swift */; }; + FAE601822ECA700100000001 /* BitcoinTransactionBroadcaster.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE601832ECA700100000001 /* BitcoinTransactionBroadcaster.swift */; }; + FAE601842ECA700100000001 /* BitcoinTransactionBroadcasterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE601852ECA700100000001 /* BitcoinTransactionBroadcasterTests.swift */; }; + FAE601862ECA700100000001 /* BitcoinTransactionHistorySync.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE601872ECA700100000001 /* BitcoinTransactionHistorySync.swift */; }; + FAE601882ECA700100000001 /* BitcoinTransactionHistorySyncTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE601892ECA700100000001 /* BitcoinTransactionHistorySyncTests.swift */; }; + FAE6018A2ECA700100000001 /* BitcoinSendService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE6018B2ECA700100000001 /* BitcoinSendService.swift */; }; + FAE6018C2ECA700100000001 /* BitcoinSendServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE6018D2ECA700100000001 /* BitcoinSendServiceTests.swift */; }; + FAE601A22ECA700100000001 /* BitcoinHistoryOperationFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE601A32ECA700100000001 /* BitcoinHistoryOperationFactory.swift */; }; + FAE601A42ECA700100000001 /* BitcoinHistoryOperationFactoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE601A52ECA700100000001 /* BitcoinHistoryOperationFactoryTests.swift */; }; + FAE601A62ECA700100000001 /* UniversalWalletAccountAddressResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE601A72ECA700100000001 /* UniversalWalletAccountAddressResolver.swift */; }; + FAE601A82ECA700100000001 /* UniversalWalletAccountAddressResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE601A92ECA700100000001 /* UniversalWalletAccountAddressResolverTests.swift */; }; + FAE601AA2ECA700100000001 /* SolanaHistoryOperationFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE601AB2ECA700100000001 /* SolanaHistoryOperationFactory.swift */; }; + FAE601AC2ECA700100000001 /* SolanaHistoryOperationFactoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE601AD2ECA700100000001 /* SolanaHistoryOperationFactoryTests.swift */; }; + FAE601AE2ECA700100000001 /* IrohaHistoryOperationFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE601AF2ECA700100000001 /* IrohaHistoryOperationFactory.swift */; }; + FAE601B02ECA700100000001 /* IrohaHistoryOperationFactoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE601B12ECA700100000001 /* IrohaHistoryOperationFactoryTests.swift */; }; + FAE601B22ECA700100000001 /* PasskeyBackupContract.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE601B32ECA700100000001 /* PasskeyBackupContract.swift */; }; + FAE6018E2ECA700100000001 /* SolanaBalanceSync.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE6018F2ECA700100000001 /* SolanaBalanceSync.swift */; }; + FAE601902ECA700100000001 /* SolanaBalanceSyncTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE601912ECA700100000001 /* SolanaBalanceSyncTests.swift */; }; + FAE601922ECA700100000001 /* SolanaTransactionHistorySync.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE601932ECA700100000001 /* SolanaTransactionHistorySync.swift */; }; + FAE601942ECA700100000001 /* SolanaTransactionHistorySyncTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE601952ECA700100000001 /* SolanaTransactionHistorySyncTests.swift */; }; + FAE601962ECA700100000001 /* SolanaRpcClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE601972ECA700100000001 /* SolanaRpcClient.swift */; }; + FAE601982ECA700100000001 /* SolanaRpcClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE601992ECA700100000001 /* SolanaRpcClientTests.swift */; }; + FAE6019A2ECA700100000001 /* SolanaTransferTransactionBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE6019B2ECA700100000001 /* SolanaTransferTransactionBuilder.swift */; }; + FAE6019C2ECA700100000001 /* SolanaSendService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE6019D2ECA700100000001 /* SolanaSendService.swift */; }; + FAE6019E2ECA700100000001 /* SolanaTransferTransactionBuilderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE6019F2ECA700100000001 /* SolanaTransferTransactionBuilderTests.swift */; }; + FAE601A02ECA700100000001 /* SolanaSendServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE601A12ECA700100000001 /* SolanaSendServiceTests.swift */; }; FAE9EB9D288AB74D009390B6 /* AnalyticsRewardsFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE9EB9C288AB74D009390B6 /* AnalyticsRewardsFlow.swift */; }; FAE9EB9F288ABBBE009390B6 /* AnalyticRewardsParachainViewModelState.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE9EB9E288ABBBE009390B6 /* AnalyticRewardsParachainViewModelState.swift */; }; FAE9EBA1288ABBC7009390B6 /* AnalyticRewardsParachainStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE9EBA0288ABBC7009390B6 /* AnalyticRewardsParachainStrategy.swift */; }; @@ -2952,7 +3031,7 @@ FAFDB2C529112A0F003971FB /* UsedRuntimeModules.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAFDB2C429112A0F003971FB /* UsedRuntimeModules.swift */; }; FAFE00032DCAF00100E5C0DE /* FearlessUtils in Frameworks */ = {isa = PBXBuildFile; productRef = FAFE00022DCAF00100E5C0DE /* FearlessUtils */; }; FAFE10022DCF000100E5C0DE /* FearlessDependencies in Frameworks */ = {isa = PBXBuildFile; productRef = FAFE10012DCF000100E5C0DE /* FearlessDependencies */; }; - FAFE10072DCAF11200E5C0DE /* fearless/Common/Compatibility/NetworkWorkerCompatibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAFE10032DCAF11100E5C0DE /* fearless/Common/Compatibility/NetworkWorkerCompatibility.swift */; }; + FAFE10072DCAF11200E5C0DE /* NetworkWorkerCompatibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAFE10032DCAF11100E5C0DE /* NetworkWorkerCompatibility.swift */; }; FAFEAA112DCF200100E5C0DE /* ReownWalletKit in Frameworks */ = {isa = PBXBuildFile; productRef = FAFEAA132DCF200100E5C0DE /* ReownWalletKit */; }; FAFFAE3629AC84180074AF1F /* ParachainHistoryOperationFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAFFAE3329AC84180074AF1F /* ParachainHistoryOperationFactory.swift */; }; FAFFAE3729AC84180074AF1F /* ParachainSubqueryHistoryOperationFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAFFAE3429AC84180074AF1F /* ParachainSubqueryHistoryOperationFactory.swift */; }; @@ -3634,7 +3713,6 @@ 842D1E9224D20CC800C30A7A /* OnboardingMainTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingMainTests.swift; sourceTree = ""; }; 842D1E9524D2DD6700C30A7A /* MnemonicDisplayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MnemonicDisplayView.swift; sourceTree = ""; }; 84300B2E26C1112300D64514 /* ConnectionPoolTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionPoolTests.swift; sourceTree = ""; }; - F1A2B3C4D5E6F708192A3B4D /* NetworkWorkerCompatibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkWorkerCompatibilityTests.swift; sourceTree = ""; }; 8430AACB2602249B005B1066 /* InitialStakingState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InitialStakingState.swift; sourceTree = ""; }; 8430AAD32602285B005B1066 /* StakingStateCommonData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StakingStateCommonData.swift; sourceTree = ""; }; 8430AADB26022C58005B1066 /* NoStashState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoStashState.swift; sourceTree = ""; }; @@ -3754,8 +3832,6 @@ 845B822226EFDF3F00D25C72 /* SelectedAccountSettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectedAccountSettingsTests.swift; sourceTree = ""; }; 845B822426EFE03E00D25C72 /* AccountGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountGenerator.swift; sourceTree = ""; }; 845B822626EFFE0200D25C72 /* MetaAccountMapperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetaAccountMapperTests.swift; sourceTree = ""; }; - A7B8C9D0E1F23456789ABCDE /* AssetModelMapperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetModelMapperTests.swift; sourceTree = ""; }; - F0A1B2C3D4E5F60718293A4B /* AssetRepositoryFactoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetRepositoryFactoryTests.swift; sourceTree = ""; }; 845B822826F0BB6700D25C72 /* CrowdloanChainSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrowdloanChainSettings.swift; sourceTree = ""; }; 845BB8B725E4465F00E5FCDC /* ExtrinsicService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtrinsicService.swift; sourceTree = ""; }; 845BB8BF25E4508800E5FCDC /* SS58FactoryExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SS58FactoryExtensions.swift; sourceTree = ""; }; @@ -4357,6 +4433,7 @@ 9C05A688EA7379572BBCE545 /* SelectMarketRouter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SelectMarketRouter.swift; sourceTree = ""; }; 9FED48DE9B681995E6E4A581 /* LiquidityPoolDetailsProtocols.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LiquidityPoolDetailsProtocols.swift; sourceTree = ""; }; A14CA4551FCC2EBD078E2242 /* AccountConfirmViewFactory.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AccountConfirmViewFactory.swift; sourceTree = ""; }; + A1B2C3D4E5F60718293A4B5D /* TonChainSelectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = fearlessTests/ApplicationLayer/Services/FeatureToggle/TonChainSelectionTests.swift; sourceTree = ""; }; A3104ABC4BECF08B0BA836AA /* AccountConfirmViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AccountConfirmViewController.swift; sourceTree = ""; }; A31780E84948D7FE632ECB02 /* YourValidatorListProtocols.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = YourValidatorListProtocols.swift; sourceTree = ""; }; A3BACB7E24BC87F9218DBBC4 /* StakingPayoutConfirmationViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = StakingPayoutConfirmationViewController.swift; sourceTree = ""; }; @@ -4367,6 +4444,7 @@ A7219B81CEA13CD60BD8FAFE /* ClaimCrowdloanRewardsProtocols.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ClaimCrowdloanRewardsProtocols.swift; sourceTree = ""; }; A77DF1CA9610844CF63C4BBC /* Pods-fearlessAll-fearless.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-fearlessAll-fearless.debug.xcconfig"; path = "Target Support Files/Pods-fearlessAll-fearless/Pods-fearlessAll-fearless.debug.xcconfig"; sourceTree = ""; }; A7AD1285797131E836CD994B /* AssetSelectionWireframe.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AssetSelectionWireframe.swift; sourceTree = ""; }; + A7B8C9D0E1F23456789ABCDE /* AssetModelMapperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetModelMapperTests.swift; sourceTree = ""; }; A82E373FFFBF708D7CF0973E /* StakingUnbondSetupViewFactory.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = StakingUnbondSetupViewFactory.swift; sourceTree = ""; }; A84638893DC99974E098719E /* StakingUnbondConfirmWireframe.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = StakingUnbondConfirmWireframe.swift; sourceTree = ""; }; A865455F8FC60413A6CB8A44 /* ExportSeedInteractor.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ExportSeedInteractor.swift; sourceTree = ""; }; @@ -4375,7 +4453,7 @@ AA1111000011223344556675 /* WalletSelectAccountCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = WalletSelectAccountCommand.swift; path = fearless/Modules/Wallet/Commands/WalletSelectAccountCommand.swift; sourceTree = SOURCE_ROOT; }; AA1111000011223344556676 /* WalletSelectAccountCommandFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = WalletSelectAccountCommandFactory.swift; path = fearless/Modules/Wallet/Commands/WalletSelectAccountCommandFactory.swift; sourceTree = SOURCE_ROOT; }; AA2580363AC3E4A9CD40256E /* RecommendedValidatorListPresenter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecommendedValidatorListPresenter.swift; sourceTree = ""; }; - AABBCCDDEEFF112233445566 /* fearless/Modules/Send/Validators/XcmMinAmountInspectorShim.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = fearless/Modules/Send/Validators/XcmMinAmountInspectorShim.swift; sourceTree = ""; }; + AABBCCDDEEFF112233445566 /* XcmMinAmountInspectorShim.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = fearless/Modules/Send/Validators/XcmMinAmountInspectorShim.swift; sourceTree = ""; }; AB2349A5057312BDB6C65804 /* StakingAmountViewController.xib */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = file.xib; path = StakingAmountViewController.xib; sourceTree = ""; }; AB67BB02A5FD525C8ACA5521 /* WalletTransactionDetailsTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WalletTransactionDetailsTests.swift; sourceTree = ""; }; ABFC2AD62212BE16C7B7C429 /* RecommendedValidatorListTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecommendedValidatorListTests.swift; sourceTree = ""; }; @@ -4701,8 +4779,7 @@ CF891BE39D442C2D06DDF3BB /* StakingRewardDetailsProtocols.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = StakingRewardDetailsProtocols.swift; sourceTree = ""; }; D06A0B252CCD6CAE8C5EDC16 /* AddCustomNodeProtocols.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AddCustomNodeProtocols.swift; sourceTree = ""; }; D101339CC1292531CC4DB0AC /* StakingUnbondSetupInteractor.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = StakingUnbondSetupInteractor.swift; sourceTree = ""; }; - D1E2F3A4B5C60718293A4B5C /* fearless/Common/Keys/FearlessKeysShim.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = fearless/Common/Keys/FearlessKeysShim.swift; sourceTree = ""; }; - A1B2C3D4E5F60718293A4B5D /* TonChainSelectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = fearlessTests/ApplicationLayer/Services/FeatureToggle/TonChainSelectionTests.swift; sourceTree = ""; }; + D1E2F3A4B5C60718293A4B5C /* FearlessKeysShim.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = fearless/Common/Keys/FearlessKeysShim.swift; sourceTree = ""; }; D2E749A964E0920F98B62B71 /* MultichainAssetSelectionProtocols.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MultichainAssetSelectionProtocols.swift; sourceTree = ""; }; D39D54DC9992CF9CB6699AA3 /* StakingAmountViewFactory.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = StakingAmountViewFactory.swift; sourceTree = ""; }; D430E8B808864B281A62AB43 /* LiquidityPoolSupplyConfirmViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LiquidityPoolSupplyConfirmViewController.swift; sourceTree = ""; }; @@ -4736,7 +4813,7 @@ E0A0CC21B0CD2A47B9B28841 /* NftSendViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NftSendViewController.swift; sourceTree = ""; }; E11575D8B4F64C2E805372A5 /* AccountExportPasswordViewFactory.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AccountExportPasswordViewFactory.swift; sourceTree = ""; }; E1E60EF37AC0A7646ED8FE64 /* AccountImportViewFactory.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AccountImportViewFactory.swift; sourceTree = ""; }; - E1F2A3B4C5D60718293A4B5C /* fearless/Common/Network/GraphQL/GraphQLResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = fearless/Common/Network/GraphQL/GraphQLResponse.swift; sourceTree = ""; }; + E1F2A3B4C5D60718293A4B5C /* GraphQLResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = fearless/Common/Network/GraphQL/GraphQLResponse.swift; sourceTree = ""; }; E32C2DC4CC106A3509BE651D /* ExportMnemonicTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ExportMnemonicTests.swift; sourceTree = ""; }; E357E95FDDBF8F3938402145 /* Pods-fearlessAll-fearless.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-fearlessAll-fearless.release.xcconfig"; path = "Target Support Files/Pods-fearlessAll-fearless/Pods-fearlessAll-fearless.release.xcconfig"; sourceTree = ""; }; E4E78D69E8EBC3EB4D01F8EF /* CrowdloanListInteractor.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CrowdloanListInteractor.swift; sourceTree = ""; }; @@ -4768,8 +4845,10 @@ EFB278373745C20822442686 /* ExportSeedPresenter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ExportSeedPresenter.swift; sourceTree = ""; }; F02C3AF74DE2F2CDBD165803 /* NetworkIssuesNotificationProtocols.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NetworkIssuesNotificationProtocols.swift; sourceTree = ""; }; F02DBCA4A63A5E52E3739374 /* ControllerAccountConfirmationViewFactory.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ControllerAccountConfirmationViewFactory.swift; sourceTree = ""; }; + F0A1B2C3D4E5F60718293A4B /* AssetRepositoryFactoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetRepositoryFactoryTests.swift; sourceTree = ""; }; F12B2D844B474F810C807451 /* AccountManagementTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AccountManagementTests.swift; sourceTree = ""; }; F15D1C78B2844216802DA000 /* ChainSelectionViewFactory.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ChainSelectionViewFactory.swift; sourceTree = ""; }; + F1A2B3C4D5E6F708192A3B4D /* NetworkWorkerCompatibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkWorkerCompatibilityTests.swift; sourceTree = ""; }; F1E3F963A56923FD036280BD /* WalletOptionAssembly.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WalletOptionAssembly.swift; sourceTree = ""; }; F23E38DCBC74C528D7839B76 /* CrowdloanContributionSetupInteractor.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CrowdloanContributionSetupInteractor.swift; sourceTree = ""; }; F28EDDF9277242505FDDECA1 /* CustomValidatorListProtocols.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CustomValidatorListProtocols.swift; sourceTree = ""; }; @@ -5959,6 +6038,86 @@ FAE39B162AA965940011A9D6 /* URL+IPFS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+IPFS.swift"; sourceTree = ""; }; FAE41F5A2C8CF4C900123456 /* SceneWindowFinder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneWindowFinder.swift; sourceTree = ""; }; FAE5858E2B0764EC00240FE1 /* SoraSubsquidHistoryOperationFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoraSubsquidHistoryOperationFactory.swift; sourceTree = ""; }; + FAE601012ECA700100000001 /* UniversalWalletV2VectorsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UniversalWalletV2VectorsTests.swift; sourceTree = ""; }; + FAE601032ECA700100000001 /* UniversalWalletEcosystem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UniversalWalletEcosystem.swift; sourceTree = ""; }; + FAE601052ECA700100000001 /* UniversalWalletRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UniversalWalletRegistry.swift; sourceTree = ""; }; + FAE601072ECA700100000001 /* UniversalWalletDerivationPaths.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UniversalWalletDerivationPaths.swift; sourceTree = ""; }; + FAE601092ECA700100000001 /* IrohaAddressCodec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IrohaAddressCodec.swift; sourceTree = ""; }; + FAE6010B2ECA700100000001 /* IrohaAddressCodecTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IrohaAddressCodecTests.swift; sourceTree = ""; }; + FAE6010D2ECA700100000001 /* SolanaKeyDerivation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SolanaKeyDerivation.swift; sourceTree = ""; }; + FAE6010F2ECA700100000001 /* SolanaIndexerContract.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SolanaIndexerContract.swift; sourceTree = ""; }; + FAE601112ECA700100000001 /* SolanaIndexerContractTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SolanaIndexerContractTests.swift; sourceTree = ""; }; + FAE601152ECA700100000001 /* BitcoinKeyDerivation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitcoinKeyDerivation.swift; sourceTree = ""; }; + FAE601172ECA700100000001 /* BitcoinIndexerContract.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitcoinIndexerContract.swift; sourceTree = ""; }; + FAE601192ECA700100000001 /* BitcoinIndexerContractTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitcoinIndexerContractTests.swift; sourceTree = ""; }; + FAE601212ECA700100000001 /* SolanaSigner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SolanaSigner.swift; sourceTree = ""; }; + FAE601232ECA700100000001 /* SolanaTransactionSigner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SolanaTransactionSigner.swift; sourceTree = ""; }; + FAE601252ECA700100000001 /* SolanaTransactionSignerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SolanaTransactionSignerTests.swift; sourceTree = ""; }; + FAE601272ECA700100000001 /* IrohaKeyDerivation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IrohaKeyDerivation.swift; sourceTree = ""; }; + FAE601292ECA700100000001 /* TonAddressCodec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TonAddressCodec.swift; sourceTree = ""; }; + FAE6012B2ECA700100000001 /* TonKeyDerivation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TonKeyDerivation.swift; sourceTree = ""; }; + FAE6012D2ECA700100000001 /* TonIndexerContract.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TonIndexerContract.swift; sourceTree = ""; }; + FAE6012F2ECA700100000001 /* TonIndexerContractTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TonIndexerContractTests.swift; sourceTree = ""; }; + FAE601312ECA700100000001 /* IrohaToriiContract.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IrohaToriiContract.swift; sourceTree = ""; }; + FAE601332ECA700100000001 /* IrohaToriiContractTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IrohaToriiContractTests.swift; sourceTree = ""; }; + FAE601352ECA700100000001 /* IrohaToriiClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IrohaToriiClient.swift; sourceTree = ""; }; + FAE601372ECA700100000001 /* IrohaToriiClientTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IrohaToriiClientTests.swift; sourceTree = ""; }; + FAE601392ECA700100000001 /* TonIndexerClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TonIndexerClient.swift; sourceTree = ""; }; + FAE6013B2ECA700100000001 /* TonIndexerClientTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TonIndexerClientTests.swift; sourceTree = ""; }; + FAE6013D2ECA700100000001 /* SolanaIndexerClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SolanaIndexerClient.swift; sourceTree = ""; }; + FAE6013F2ECA700100000001 /* SolanaIndexerClientTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SolanaIndexerClientTests.swift; sourceTree = ""; }; + FAE601412ECA700100000001 /* BitcoinIndexerClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitcoinIndexerClient.swift; sourceTree = ""; }; + FAE601432ECA700100000001 /* BitcoinIndexerClientTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitcoinIndexerClientTests.swift; sourceTree = ""; }; + FAE601452ECA700100000001 /* UniversalWalletHTTPTransport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UniversalWalletHTTPTransport.swift; sourceTree = ""; }; + FAE601472ECA700100000001 /* UniversalWalletIndexerClientFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UniversalWalletIndexerClientFactory.swift; sourceTree = ""; }; + FAE601492ECA700100000001 /* UniversalWalletIndexerClientFactoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UniversalWalletIndexerClientFactoryTests.swift; sourceTree = ""; }; + FAE6014B2ECA700100000001 /* UniversalWalletIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UniversalWalletIdentity.swift; sourceTree = ""; }; + FAE6014D2ECA700100000001 /* UniversalWalletIdentityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UniversalWalletIdentityTests.swift; sourceTree = ""; }; + FAE6014F2ECA700100000001 /* UniversalWalletIndexerContract.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UniversalWalletIndexerContract.swift; sourceTree = ""; }; + FAE601512ECA700100000001 /* UniversalWalletIndexerContractTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UniversalWalletIndexerContractTests.swift; sourceTree = ""; }; + FAE601532ECA700100000001 /* UniversalWalletSigningContract.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UniversalWalletSigningContract.swift; sourceTree = ""; }; + FAE601552ECA700100000001 /* UniversalWalletSigningContractTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UniversalWalletSigningContractTests.swift; sourceTree = ""; }; + FAE601572ECA700100000001 /* UniversalWalletMigrationContract.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UniversalWalletMigrationContract.swift; sourceTree = ""; }; + FAE601592ECA700100000001 /* UniversalWalletMigrationContractTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UniversalWalletMigrationContractTests.swift; sourceTree = ""; }; + FAE6015B2ECA700100000001 /* UniversalWalletRegistryContract.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UniversalWalletRegistryContract.swift; sourceTree = ""; }; + FAE6015D2ECA700100000001 /* UniversalWalletRegistryContractTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UniversalWalletRegistryContractTests.swift; sourceTree = ""; }; + FAE6016B2ECA700100000001 /* BitcoinReceiveDiscovery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitcoinReceiveDiscovery.swift; sourceTree = ""; }; + FAE6016D2ECA700100000001 /* BitcoinReceiveDiscoveryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitcoinReceiveDiscoveryTests.swift; sourceTree = ""; }; + FAE6016F2ECA700100000001 /* BitcoinBalanceSync.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitcoinBalanceSync.swift; sourceTree = ""; }; + FAE601712ECA700100000001 /* BitcoinBalanceSyncTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitcoinBalanceSyncTests.swift; sourceTree = ""; }; + FAE601732ECA700100000001 /* BitcoinFeeEstimator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitcoinFeeEstimator.swift; sourceTree = ""; }; + FAE601752ECA700100000001 /* BitcoinFeeEstimatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitcoinFeeEstimatorTests.swift; sourceTree = ""; }; + FAE601772ECA700100000001 /* BitcoinUtxoSelector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitcoinUtxoSelector.swift; sourceTree = ""; }; + FAE601792ECA700100000001 /* BitcoinUtxoSelectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitcoinUtxoSelectorTests.swift; sourceTree = ""; }; + FAE6017B2ECA700100000001 /* BitcoinSendPlanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitcoinSendPlanner.swift; sourceTree = ""; }; + FAE6017D2ECA700100000001 /* BitcoinSendPlannerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitcoinSendPlannerTests.swift; sourceTree = ""; }; + FAE6017F2ECA700100000001 /* BitcoinTransactionBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitcoinTransactionBuilder.swift; sourceTree = ""; }; + FAE601812ECA700100000001 /* BitcoinTransactionBuilderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitcoinTransactionBuilderTests.swift; sourceTree = ""; }; + FAE601832ECA700100000001 /* BitcoinTransactionBroadcaster.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitcoinTransactionBroadcaster.swift; sourceTree = ""; }; + FAE601852ECA700100000001 /* BitcoinTransactionBroadcasterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitcoinTransactionBroadcasterTests.swift; sourceTree = ""; }; + FAE601872ECA700100000001 /* BitcoinTransactionHistorySync.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitcoinTransactionHistorySync.swift; sourceTree = ""; }; + FAE601892ECA700100000001 /* BitcoinTransactionHistorySyncTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitcoinTransactionHistorySyncTests.swift; sourceTree = ""; }; + FAE6018B2ECA700100000001 /* BitcoinSendService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitcoinSendService.swift; sourceTree = ""; }; + FAE6018D2ECA700100000001 /* BitcoinSendServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitcoinSendServiceTests.swift; sourceTree = ""; }; + FAE601A32ECA700100000001 /* BitcoinHistoryOperationFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitcoinHistoryOperationFactory.swift; sourceTree = ""; }; + FAE601A52ECA700100000001 /* BitcoinHistoryOperationFactoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitcoinHistoryOperationFactoryTests.swift; sourceTree = ""; }; + FAE601A72ECA700100000001 /* UniversalWalletAccountAddressResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UniversalWalletAccountAddressResolver.swift; sourceTree = ""; }; + FAE601A92ECA700100000001 /* UniversalWalletAccountAddressResolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UniversalWalletAccountAddressResolverTests.swift; sourceTree = ""; }; + FAE601AB2ECA700100000001 /* SolanaHistoryOperationFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SolanaHistoryOperationFactory.swift; sourceTree = ""; }; + FAE601AD2ECA700100000001 /* SolanaHistoryOperationFactoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SolanaHistoryOperationFactoryTests.swift; sourceTree = ""; }; + FAE601AF2ECA700100000001 /* IrohaHistoryOperationFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IrohaHistoryOperationFactory.swift; sourceTree = ""; }; + FAE601B12ECA700100000001 /* IrohaHistoryOperationFactoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IrohaHistoryOperationFactoryTests.swift; sourceTree = ""; }; + FAE601B32ECA700100000001 /* PasskeyBackupContract.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasskeyBackupContract.swift; sourceTree = ""; }; + FAE6018F2ECA700100000001 /* SolanaBalanceSync.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SolanaBalanceSync.swift; sourceTree = ""; }; + FAE601912ECA700100000001 /* SolanaBalanceSyncTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SolanaBalanceSyncTests.swift; sourceTree = ""; }; + FAE601932ECA700100000001 /* SolanaTransactionHistorySync.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SolanaTransactionHistorySync.swift; sourceTree = ""; }; + FAE601952ECA700100000001 /* SolanaTransactionHistorySyncTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SolanaTransactionHistorySyncTests.swift; sourceTree = ""; }; + FAE601972ECA700100000001 /* SolanaRpcClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SolanaRpcClient.swift; sourceTree = ""; }; + FAE601992ECA700100000001 /* SolanaRpcClientTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SolanaRpcClientTests.swift; sourceTree = ""; }; + FAE6019B2ECA700100000001 /* SolanaTransferTransactionBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SolanaTransferTransactionBuilder.swift; sourceTree = ""; }; + FAE6019D2ECA700100000001 /* SolanaSendService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SolanaSendService.swift; sourceTree = ""; }; + FAE6019F2ECA700100000001 /* SolanaTransferTransactionBuilderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SolanaTransferTransactionBuilderTests.swift; sourceTree = ""; }; + FAE601A12ECA700100000001 /* SolanaSendServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SolanaSendServiceTests.swift; sourceTree = ""; }; FAE9EB9C288AB74D009390B6 /* AnalyticsRewardsFlow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsRewardsFlow.swift; sourceTree = ""; }; FAE9EB9E288ABBBE009390B6 /* AnalyticRewardsParachainViewModelState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticRewardsParachainViewModelState.swift; sourceTree = ""; }; FAE9EBA0288ABBC7009390B6 /* AnalyticRewardsParachainStrategy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticRewardsParachainStrategy.swift; sourceTree = ""; }; @@ -6011,7 +6170,7 @@ FAFBEE82284621900036D08C /* SelectedValidatorListParachainViewModelFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectedValidatorListParachainViewModelFactory.swift; sourceTree = ""; }; FAFDB2C229112A00003971FB /* SubstrateCallPath.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SubstrateCallPath.swift; sourceTree = ""; }; FAFDB2C429112A0F003971FB /* UsedRuntimeModules.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UsedRuntimeModules.swift; sourceTree = ""; }; - FAFE10032DCAF11100E5C0DE /* fearless/Common/Compatibility/NetworkWorkerCompatibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = fearless/Common/Compatibility/NetworkWorkerCompatibility.swift; sourceTree = ""; }; + FAFE10032DCAF11100E5C0DE /* NetworkWorkerCompatibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = fearless/Common/Compatibility/NetworkWorkerCompatibility.swift; sourceTree = ""; }; FAFFAE3329AC84180074AF1F /* ParachainHistoryOperationFactory.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ParachainHistoryOperationFactory.swift; sourceTree = ""; }; FAFFAE3429AC84180074AF1F /* ParachainSubqueryHistoryOperationFactory.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ParachainSubqueryHistoryOperationFactory.swift; sourceTree = ""; }; FAFFAE3529AC84180074AF1F /* ParachainSubsquidHistoryOperationFactory.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ParachainSubsquidHistoryOperationFactory.swift; sourceTree = ""; }; @@ -6111,7 +6270,6 @@ C6182B3D2C631AAC0089558D /* SSFSingleValueCache in Frameworks */, C6182B1F2C631AAC0089558D /* SSFCloudStorage in Frameworks */, C6182B232C631AAC0089558D /* SSFEraKit in Frameworks */, - C6182B472C631AAC0089558D /* SoraKeystore in Frameworks */, C6182B372C631AAC0089558D /* SSFQRService in Frameworks */, C6182B352C631AAC0089558D /* SSFPoolsStorage in Frameworks */, C6182B1D2C631AAC0089558D /* SSFChainRegistry in Frameworks */, @@ -6826,10 +6984,10 @@ 0A90ED612F7401B700F741B6 /* Recovered References */ = { isa = PBXGroup; children = ( - FAFE10032DCAF11100E5C0DE /* fearless/Common/Compatibility/NetworkWorkerCompatibility.swift */, - E1F2A3B4C5D60718293A4B5C /* fearless/Common/Network/GraphQL/GraphQLResponse.swift */, - AABBCCDDEEFF112233445566 /* fearless/Modules/Send/Validators/XcmMinAmountInspectorShim.swift */, - D1E2F3A4B5C60718293A4B5C /* fearless/Common/Keys/FearlessKeysShim.swift */, + FAFE10032DCAF11100E5C0DE /* NetworkWorkerCompatibility.swift */, + E1F2A3B4C5D60718293A4B5C /* GraphQLResponse.swift */, + AABBCCDDEEFF112233445566 /* XcmMinAmountInspectorShim.swift */, + D1E2F3A4B5C60718293A4B5C /* FearlessKeysShim.swift */, AA1111000011223344556673 /* AccountManagementViewFactory.swift */, AA1111000011223344556675 /* WalletSelectAccountCommand.swift */, AA1111000011223344556676 /* WalletSelectAccountCommandFactory.swift */, @@ -8952,6 +9110,41 @@ 849013C424A80986008F705E /* Info.plist */, 6784511E27C7B7E095FFD4AB /* Modules */, 842D1E8F24D20B1500C30A7A /* Constants.swift */, + FAE601012ECA700100000001 /* UniversalWalletV2VectorsTests.swift */, + FAE6010B2ECA700100000001 /* IrohaAddressCodecTests.swift */, + FAE601112ECA700100000001 /* SolanaIndexerContractTests.swift */, + FAE601192ECA700100000001 /* BitcoinIndexerContractTests.swift */, + FAE601252ECA700100000001 /* SolanaTransactionSignerTests.swift */, + FAE6012F2ECA700100000001 /* TonIndexerContractTests.swift */, + FAE601332ECA700100000001 /* IrohaToriiContractTests.swift */, + FAE601372ECA700100000001 /* IrohaToriiClientTests.swift */, + FAE6013B2ECA700100000001 /* TonIndexerClientTests.swift */, + FAE6013F2ECA700100000001 /* SolanaIndexerClientTests.swift */, + FAE601912ECA700100000001 /* SolanaBalanceSyncTests.swift */, + FAE601952ECA700100000001 /* SolanaTransactionHistorySyncTests.swift */, + FAE601992ECA700100000001 /* SolanaRpcClientTests.swift */, + FAE6019F2ECA700100000001 /* SolanaTransferTransactionBuilderTests.swift */, + FAE601A12ECA700100000001 /* SolanaSendServiceTests.swift */, + FAE601432ECA700100000001 /* BitcoinIndexerClientTests.swift */, + FAE6016D2ECA700100000001 /* BitcoinReceiveDiscoveryTests.swift */, + FAE601712ECA700100000001 /* BitcoinBalanceSyncTests.swift */, + FAE601752ECA700100000001 /* BitcoinFeeEstimatorTests.swift */, + FAE601792ECA700100000001 /* BitcoinUtxoSelectorTests.swift */, + FAE6017D2ECA700100000001 /* BitcoinSendPlannerTests.swift */, + FAE601812ECA700100000001 /* BitcoinTransactionBuilderTests.swift */, + FAE601852ECA700100000001 /* BitcoinTransactionBroadcasterTests.swift */, + FAE601892ECA700100000001 /* BitcoinTransactionHistorySyncTests.swift */, + FAE601A52ECA700100000001 /* BitcoinHistoryOperationFactoryTests.swift */, + FAE601A92ECA700100000001 /* UniversalWalletAccountAddressResolverTests.swift */, + FAE601AD2ECA700100000001 /* SolanaHistoryOperationFactoryTests.swift */, + FAE601B12ECA700100000001 /* IrohaHistoryOperationFactoryTests.swift */, + FAE6018D2ECA700100000001 /* BitcoinSendServiceTests.swift */, + FAE601492ECA700100000001 /* UniversalWalletIndexerClientFactoryTests.swift */, + FAE6014D2ECA700100000001 /* UniversalWalletIdentityTests.swift */, + FAE601512ECA700100000001 /* UniversalWalletIndexerContractTests.swift */, + FAE601552ECA700100000001 /* UniversalWalletSigningContractTests.swift */, + FAE601592ECA700100000001 /* UniversalWalletMigrationContractTests.swift */, + FAE6015D2ECA700100000001 /* UniversalWalletRegistryContractTests.swift */, ); path = fearlessTests; sourceTree = ""; @@ -9200,6 +9393,48 @@ C63468DE28F2C178005CB1F1 /* Contact.swift */, FA8F63B0298273FE004B8CD4 /* DecodedTypes.swift */, C633AD1029B99FA400EA8C29 /* ChainEcosystem.swift */, + FAE601032ECA700100000001 /* UniversalWalletEcosystem.swift */, + FAE601052ECA700100000001 /* UniversalWalletRegistry.swift */, + FAE601072ECA700100000001 /* UniversalWalletDerivationPaths.swift */, + FAE601092ECA700100000001 /* IrohaAddressCodec.swift */, + FAE6010D2ECA700100000001 /* SolanaKeyDerivation.swift */, + FAE6010F2ECA700100000001 /* SolanaIndexerContract.swift */, + FAE601152ECA700100000001 /* BitcoinKeyDerivation.swift */, + FAE601172ECA700100000001 /* BitcoinIndexerContract.swift */, + FAE601212ECA700100000001 /* SolanaSigner.swift */, + FAE601232ECA700100000001 /* SolanaTransactionSigner.swift */, + FAE601272ECA700100000001 /* IrohaKeyDerivation.swift */, + FAE601292ECA700100000001 /* TonAddressCodec.swift */, + FAE6012B2ECA700100000001 /* TonKeyDerivation.swift */, + FAE6012D2ECA700100000001 /* TonIndexerContract.swift */, + FAE601312ECA700100000001 /* IrohaToriiContract.swift */, + FAE601352ECA700100000001 /* IrohaToriiClient.swift */, + FAE601392ECA700100000001 /* TonIndexerClient.swift */, + FAE6013D2ECA700100000001 /* SolanaIndexerClient.swift */, + FAE6018F2ECA700100000001 /* SolanaBalanceSync.swift */, + FAE601932ECA700100000001 /* SolanaTransactionHistorySync.swift */, + FAE601972ECA700100000001 /* SolanaRpcClient.swift */, + FAE6019B2ECA700100000001 /* SolanaTransferTransactionBuilder.swift */, + FAE6019D2ECA700100000001 /* SolanaSendService.swift */, + FAE601412ECA700100000001 /* BitcoinIndexerClient.swift */, + FAE6016B2ECA700100000001 /* BitcoinReceiveDiscovery.swift */, + FAE6016F2ECA700100000001 /* BitcoinBalanceSync.swift */, + FAE601732ECA700100000001 /* BitcoinFeeEstimator.swift */, + FAE601772ECA700100000001 /* BitcoinUtxoSelector.swift */, + FAE6017B2ECA700100000001 /* BitcoinSendPlanner.swift */, + FAE6017F2ECA700100000001 /* BitcoinTransactionBuilder.swift */, + FAE601832ECA700100000001 /* BitcoinTransactionBroadcaster.swift */, + FAE601872ECA700100000001 /* BitcoinTransactionHistorySync.swift */, + FAE6018B2ECA700100000001 /* BitcoinSendService.swift */, + FAE601A72ECA700100000001 /* UniversalWalletAccountAddressResolver.swift */, + FAE601B32ECA700100000001 /* PasskeyBackupContract.swift */, + FAE601452ECA700100000001 /* UniversalWalletHTTPTransport.swift */, + FAE601472ECA700100000001 /* UniversalWalletIndexerClientFactory.swift */, + FAE6014B2ECA700100000001 /* UniversalWalletIdentity.swift */, + FAE6014F2ECA700100000001 /* UniversalWalletIndexerContract.swift */, + FAE601532ECA700100000001 /* UniversalWalletSigningContract.swift */, + FAE601572ECA700100000001 /* UniversalWalletMigrationContract.swift */, + FAE6015B2ECA700100000001 /* UniversalWalletRegistryContract.swift */, FA6261F12AC2A535005D3D95 /* MIME+PathExtensions.swift */, 0783EEB12AE193F3006476F4 /* BokoloConstants.swift */, FA30671F2B621144006A0BA5 /* TokenLock.swift */, @@ -13255,6 +13490,9 @@ FA41B6252C64BE6800D0713A /* ViscanHistoryOperationFactory.swift */, FAD240D52C64D3D100B389FF /* ZChainHistoryOperationFactory.swift */, FAD240DF2C64EA1D00B389FF /* KaiaHistoryOperationFactory.swift */, + FAE601A32ECA700100000001 /* BitcoinHistoryOperationFactory.swift */, + FAE601AB2ECA700100000001 /* SolanaHistoryOperationFactory.swift */, + FAE601AF2ECA700100000001 /* IrohaHistoryOperationFactory.swift */, AE26E70B2DFA1C7E001FD062 /* TonModels.swift */, ); path = Main; @@ -15506,8 +15744,8 @@ FA8810962BDCAF260084CC4B /* XCRemoteSwiftPackageReference "shared-features-spm" */, FAF600732C48D79500E56558 /* XCRemoteSwiftPackageReference "Cosmos" */, FAB482E92C58A8AA00594D89 /* XCRemoteSwiftPackageReference "web3-swift" */, - FAFE00012DCAF00100E5C0DE /* XCLocalSwiftPackageReference "Packages/FearlessUtilsCompat" */, - FAFE10002DCF000100E5C0DE /* XCLocalSwiftPackageReference "Packages/FearlessDependencies" */, + FAFE00012DCAF00100E5C0DE /* XCLocalSwiftPackageReference "FearlessUtilsCompat" */, + FAFE10002DCF000100E5C0DE /* XCLocalSwiftPackageReference "FearlessDependencies" */, FAFEAA122DCF200100E5C0DE /* XCRemoteSwiftPackageReference "reown-swift" */, ); productRefGroup = 849013A924A80984008F705E /* Products */; @@ -15615,14 +15853,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-fearlessAll-fearlessIntegrationTests/Pods-fearlessAll-fearlessIntegrationTests-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-fearlessAll-fearlessIntegrationTests/Pods-fearlessAll-fearlessIntegrationTests-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-fearlessAll-fearlessIntegrationTests/Pods-fearlessAll-fearlessIntegrationTests-frameworks.sh\"\n"; @@ -15779,14 +16013,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-fearlessTests/Pods-fearlessTests-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-fearlessTests/Pods-fearlessTests-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-fearlessTests/Pods-fearlessTests-frameworks.sh\"\n"; @@ -15810,7 +16040,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "#check if env-vars.sh exists\nif [ -f $PROJECT_DIR/$PROJECT_NAME/env-vars.sh ]\nthen\nsource $PROJECT_DIR/$PROJECT_NAME/env-vars.sh\nfi\n#no `else` case needed if the CI works as expected\n\nWORK_DIR=\"$PROJECT_DIR/$PROJECT_NAME\"\necho \"Sourcery Work Directory = $WORK_DIR\"\n\nOUT_FILE=\"$PROJECT_DIR/CIKeys.generated.swift\"\necho \"Sourcery Output File = $OUT_FILE\"\n\n\"$PODS_ROOT/Sourcery/bin/sourcery\" --templates \"$WORK_DIR\" --sources \"$WORK_DIR\" --output \"$OUT_FILE\" --args moonPaySecretKey=$MOONPAY_PRODUCTION_SECRET,moonPayTestSecretKey=$MOONPAY_TEST_SECRET,subscanAPIKey=$SUBSCAN_API_KEY,soraCardAPIKey=$SORA_CARD_API_KEY,soraCardDomain=$SORA_CARD_DOMAIN,soraCardKycEndpoint=$SORA_CARD_KYC_ENDPOINT_URL,soraCardKycUsername=$SORA_CARD_KYC_USERNAME,soraCardKycPassword=$SORA_CARD_KYC_PASSWORD,paywingsRepositoryUrl=$PAY_WINGS_REPOSITORY_URL,paywingsUsername=$PAY_WINGS_USERNAME,paywingsPassword=$PAY_WINGS_PASSWORD,x1EndpointUrlRelease=$X1_ENDPOINT_URL_RELEASE,x1WidgetIdRelease=$X1_WIDGET_ID_RELEASE,x1EndpointUrlDebug=$X1_ENDPOINT_URL_DEBUG,x1WidgetIdDebug=$X1_WIDGET_ID_DEBUG,ethereumApiKey=$FL_BLAST_API_ETHEREUM_KEY,bscApiKey=$FL_BLAST_API_BSC_KEY,sepoliaApiKey=$FL_BLAST_API_SEPOLIA_KEY,goerliApiKey=$FL_BLAST_API_GOERLI_KEY,polygonApiKey=$FL_BLAST_API_POLYGON_KEY,walletConnectProjectId=$FL_WALLET_CONNECT_PROJECT_ID,webClientIdRelease=$WEB_CLIENT_ID_RELEASE,fearlessGoogleUrlSchemeRelease=$FEARLESS_GOOGLE_URL_SCHEME_RELEASE,webClientIdDebug=$WEB_CLIENT_ID_DEBUG,fearlessGoogleUrlSchemeDebug=$FEARLESS_GOOGLE_URL_SCHEME_DEBUG,etherscanApiKey=$FL_IOS_ETHERSCAN_API_KEY,bscscanApiKey=$FL_IOS_BSCSCAN_API_KEY,polygonscanApiKey=$FL_IOS_POLYGONSCAN_API_KEY,alchemyApiKey=$FL_IOS_ALCHEMY_API_ETHEREUM_KEY,oklinkApiKey=$FL_OKLINK_API_KEY,opMainnetApiKey=$FL_IOS_OPTIMISTIC_ETHERSCAN_API_KEY,dwellirApiKey=$FL_DWELLIR_API_KEY,coinbaseAppId=$COINBASE_APP_ID,tonApiKey=$FL_TON_API_KEY,tonApiKeyDebug=$FL_TON_API_KEY_DEBUG\n\n#add params to the xcconfig files\nvariableNames=(\"FEARLESS_GOOGLE_TOKEN\" \"FEARLESS_GOOGLE_URL_SCHEME\")\n\n# Iterate over the array\nfor variableName in \"${variableNames[@]}\"; do\n for file in \"$PROJECT_DIR\"/fearless/Configs/*.xcconfig; do\n if ! grep -q \"^$variableName = ${!variableName}$\" \"$file\"; then\n echo \"$variableName = ${!variableName}\" >> \"$file\"\n fi\n done\ndone\n\n/usr/libexec/PlistBuddy -c \"Set :CFBundleURLTypes:0:CFBundleURLSchemes:0 $FEARLESS_GOOGLE_URL_SCHEME\" \"$PROJECT_DIR\"/fearless/Info.plist\n\n/usr/libexec/PlistBuddy -c \"Set :GIDClientID $FEARLESS_GOOGLE_TOKEN\" \"$PROJECT_DIR\"/fearless/Info.plist\n"; + shellScript = "#check if env-vars.sh exists\nif [ -f $PROJECT_DIR/$PROJECT_NAME/env-vars.sh ]\nthen\nsource $PROJECT_DIR/$PROJECT_NAME/env-vars.sh\nfi\n#no `else` case needed if the CI works as expected\n\nWORK_DIR=\"$PROJECT_DIR/$PROJECT_NAME\"\necho \"Sourcery Work Directory = $WORK_DIR\"\n\nOUT_FILE=\"$PROJECT_DIR/CIKeys.generated.swift\"\necho \"Sourcery Output File = $OUT_FILE\"\n\n\"$PODS_ROOT/Sourcery/bin/sourcery\" --templates \"$WORK_DIR\" --sources \"$WORK_DIR\" --output \"$OUT_FILE\" --args moonPaySecretKey=$MOONPAY_PRODUCTION_SECRET,moonPayTestSecretKey=$MOONPAY_TEST_SECRET,moonPayPublicKey=$MOONPAY_PUBLIC_KEY,subscanAPIKey=$SUBSCAN_API_KEY,soraCardAPIKey=$SORA_CARD_API_KEY,soraCardDomain=$SORA_CARD_DOMAIN,soraCardKycEndpoint=$SORA_CARD_KYC_ENDPOINT_URL,soraCardKycUsername=$SORA_CARD_KYC_USERNAME,soraCardKycPassword=$SORA_CARD_KYC_PASSWORD,paywingsRepositoryUrl=$PAY_WINGS_REPOSITORY_URL,paywingsUsername=$PAY_WINGS_USERNAME,paywingsPassword=$PAY_WINGS_PASSWORD,x1EndpointUrlRelease=$X1_ENDPOINT_URL_RELEASE,x1WidgetIdRelease=$X1_WIDGET_ID_RELEASE,x1EndpointUrlDebug=$X1_ENDPOINT_URL_DEBUG,x1WidgetIdDebug=$X1_WIDGET_ID_DEBUG,ethereumApiKey=$FL_BLAST_API_ETHEREUM_KEY,bscApiKey=$FL_BLAST_API_BSC_KEY,sepoliaApiKey=$FL_BLAST_API_SEPOLIA_KEY,goerliApiKey=$FL_BLAST_API_GOERLI_KEY,polygonApiKey=$FL_BLAST_API_POLYGON_KEY,walletConnectProjectId=$FL_WALLET_CONNECT_PROJECT_ID,webClientIdRelease=$WEB_CLIENT_ID_RELEASE,fearlessGoogleUrlSchemeRelease=$FEARLESS_GOOGLE_URL_SCHEME_RELEASE,webClientIdDebug=$WEB_CLIENT_ID_DEBUG,fearlessGoogleUrlSchemeDebug=$FEARLESS_GOOGLE_URL_SCHEME_DEBUG,etherscanApiKey=$FL_IOS_ETHERSCAN_API_KEY,bscscanApiKey=$FL_IOS_BSCSCAN_API_KEY,polygonscanApiKey=$FL_IOS_POLYGONSCAN_API_KEY,alchemyApiKey=$FL_IOS_ALCHEMY_API_ETHEREUM_KEY,oklinkApiKey=$FL_OKLINK_API_KEY,opMainnetApiKey=$FL_IOS_OPTIMISTIC_ETHERSCAN_API_KEY,dwellirApiKey=$FL_DWELLIR_API_KEY,coinbaseAppId=$COINBASE_APP_ID,tonApiKey=$FL_TON_API_KEY,tonApiKeyDebug=$FL_TON_API_KEY_DEBUG\n\n#add params to the xcconfig files\nvariableNames=(\"FEARLESS_GOOGLE_TOKEN\" \"FEARLESS_GOOGLE_URL_SCHEME\")\n\n# Iterate over the array\nfor variableName in \"${variableNames[@]}\"; do\n for file in \"$PROJECT_DIR\"/fearless/Configs/*.xcconfig; do\n if ! grep -q \"^$variableName = ${!variableName}$\" \"$file\"; then\n echo \"$variableName = ${!variableName}\" >> \"$file\"\n fi\n done\ndone\n\n/usr/libexec/PlistBuddy -c \"Set :CFBundleURLTypes:0:CFBundleURLSchemes:0 $FEARLESS_GOOGLE_URL_SCHEME\" \"$PROJECT_DIR\"/fearless/Info.plist\n\n/usr/libexec/PlistBuddy -c \"Set :GIDClientID $FEARLESS_GOOGLE_TOKEN\" \"$PROJECT_DIR\"/fearless/Info.plist\n"; }; B60C6E08CACA1CEBC1520619 /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; @@ -15820,14 +16050,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-fearlessAll-fearless/Pods-fearlessAll-fearless-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-fearlessAll-fearless/Pods-fearlessAll-fearless-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-fearlessAll-fearless/Pods-fearlessAll-fearless-frameworks.sh\"\n"; @@ -15939,10 +16165,10 @@ buildActionMask = 2147483647; files = ( 2D2FA50F16CD470C878A8336 /* CoreDataAliases.swift in Sources */, - FAFE10072DCAF11200E5C0DE /* fearless/Common/Compatibility/NetworkWorkerCompatibility.swift in Sources */, - F1E2D3C4B5A60718293A4B5C /* fearless/Common/Network/GraphQL/GraphQLResponse.swift in Sources */, - BBCCDDEEAABB112233445566 /* fearless/Modules/Send/Validators/XcmMinAmountInspectorShim.swift in Sources */, - 2D5B1A6B315FEF23A885A55E /* fearless/Common/Keys/FearlessKeysShim.swift in Sources */, + FAFE10072DCAF11200E5C0DE /* NetworkWorkerCompatibility.swift in Sources */, + F1E2D3C4B5A60718293A4B5C /* GraphQLResponse.swift in Sources */, + BBCCDDEEAABB112233445566 /* XcmMinAmountInspectorShim.swift in Sources */, + 2D5B1A6B315FEF23A885A55E /* FearlessKeysShim.swift in Sources */, AEF505102616769F0098574D /* InitiatedBonding.swift in Sources */, FA9A8F072A6FADFB008FA99F /* EtherscanHistoryElement.swift in Sources */, FAF92E6627B4275F005467CE /* Bool+ToInt.swift in Sources */, @@ -16729,6 +16955,9 @@ C6267B8D28BDF6A5001E31BF /* ChainAssetListAssembly.swift in Sources */, F4D96B7926380A4100B23D3D /* StakingBalanceUnbondingWidgetViewModel.swift in Sources */, FA5137BB29AC76EB00560EBA /* HistoryOperationFactory.swift in Sources */, + FAE601A22ECA700100000001 /* BitcoinHistoryOperationFactory.swift in Sources */, + FAE601AA2ECA700100000001 /* SolanaHistoryOperationFactory.swift in Sources */, + FAE601AE2ECA700100000001 /* IrohaHistoryOperationFactory.swift in Sources */, 07DFA44D289A37B70035A8AB /* ModalSheetBlurPresentationAppearanceAnimator.swift in Sources */, 8401F24F24E524900081D8F8 /* String+Helpers.swift in Sources */, F4113B3F260C77FF00DF4DBA /* StakingRewardPayoutsViewLayout.swift in Sources */, @@ -16959,6 +17188,48 @@ FA6262642AC2E35A005D3D95 /* WalletConnectProposalPresenter.swift in Sources */, AE2C84E925EF98D500986716 /* ValidatorInfoViewFactory.swift in Sources */, C633AD1129B99FA400EA8C29 /* ChainEcosystem.swift in Sources */, + FAE601042ECA700100000001 /* UniversalWalletEcosystem.swift in Sources */, + FAE601062ECA700100000001 /* UniversalWalletRegistry.swift in Sources */, + FAE601082ECA700100000001 /* UniversalWalletDerivationPaths.swift in Sources */, + FAE6010A2ECA700100000001 /* IrohaAddressCodec.swift in Sources */, + FAE6010E2ECA700100000001 /* SolanaKeyDerivation.swift in Sources */, + FAE601102ECA700100000001 /* SolanaIndexerContract.swift in Sources */, + FAE601142ECA700100000001 /* BitcoinKeyDerivation.swift in Sources */, + FAE601162ECA700100000001 /* BitcoinIndexerContract.swift in Sources */, + FAE601202ECA700100000001 /* SolanaSigner.swift in Sources */, + FAE601222ECA700100000001 /* SolanaTransactionSigner.swift in Sources */, + FAE601262ECA700100000001 /* IrohaKeyDerivation.swift in Sources */, + FAE601282ECA700100000001 /* TonAddressCodec.swift in Sources */, + FAE6012A2ECA700100000001 /* TonKeyDerivation.swift in Sources */, + FAE6012C2ECA700100000001 /* TonIndexerContract.swift in Sources */, + FAE601302ECA700100000001 /* IrohaToriiContract.swift in Sources */, + FAE601342ECA700100000001 /* IrohaToriiClient.swift in Sources */, + FAE601382ECA700100000001 /* TonIndexerClient.swift in Sources */, + FAE6013C2ECA700100000001 /* SolanaIndexerClient.swift in Sources */, + FAE6018E2ECA700100000001 /* SolanaBalanceSync.swift in Sources */, + FAE601922ECA700100000001 /* SolanaTransactionHistorySync.swift in Sources */, + FAE601962ECA700100000001 /* SolanaRpcClient.swift in Sources */, + FAE6019A2ECA700100000001 /* SolanaTransferTransactionBuilder.swift in Sources */, + FAE6019C2ECA700100000001 /* SolanaSendService.swift in Sources */, + FAE601402ECA700100000001 /* BitcoinIndexerClient.swift in Sources */, + FAE6016A2ECA700100000001 /* BitcoinReceiveDiscovery.swift in Sources */, + FAE6016E2ECA700100000001 /* BitcoinBalanceSync.swift in Sources */, + FAE601722ECA700100000001 /* BitcoinFeeEstimator.swift in Sources */, + FAE601762ECA700100000001 /* BitcoinUtxoSelector.swift in Sources */, + FAE6017A2ECA700100000001 /* BitcoinSendPlanner.swift in Sources */, + FAE6017E2ECA700100000001 /* BitcoinTransactionBuilder.swift in Sources */, + FAE601822ECA700100000001 /* BitcoinTransactionBroadcaster.swift in Sources */, + FAE601862ECA700100000001 /* BitcoinTransactionHistorySync.swift in Sources */, + FAE6018A2ECA700100000001 /* BitcoinSendService.swift in Sources */, + FAE601A62ECA700100000001 /* UniversalWalletAccountAddressResolver.swift in Sources */, + FAE601B22ECA700100000001 /* PasskeyBackupContract.swift in Sources */, + FAE601442ECA700100000001 /* UniversalWalletHTTPTransport.swift in Sources */, + FAE601462ECA700100000001 /* UniversalWalletIndexerClientFactory.swift in Sources */, + FAE6014A2ECA700100000001 /* UniversalWalletIdentity.swift in Sources */, + FAE6014E2ECA700100000001 /* UniversalWalletIndexerContract.swift in Sources */, + FAE601522ECA700100000001 /* UniversalWalletSigningContract.swift in Sources */, + FAE601562ECA700100000001 /* UniversalWalletMigrationContract.swift in Sources */, + FAE6015A2ECA700100000001 /* UniversalWalletRegistryContract.swift in Sources */, FA17B4B727E8695A006E0735 /* WarningPresentable.swift in Sources */, 844CB57026F9CB3300396E13 /* CrowdloanLocalSubscriptionHandler.swift in Sources */, FAD429302A865680001D6A16 /* BackupWalletImportedAssembly.swift in Sources */, @@ -18675,6 +18946,41 @@ buildActionMask = 2147483647; files = ( 84B66A1826FE05B00038B963 /* ChainRegistryStub.swift in Sources */, + FAE601022ECA700100000001 /* UniversalWalletV2VectorsTests.swift in Sources */, + FAE6010C2ECA700100000001 /* IrohaAddressCodecTests.swift in Sources */, + FAE601122ECA700100000001 /* SolanaIndexerContractTests.swift in Sources */, + FAE601182ECA700100000001 /* BitcoinIndexerContractTests.swift in Sources */, + FAE601242ECA700100000001 /* SolanaTransactionSignerTests.swift in Sources */, + FAE6012E2ECA700100000001 /* TonIndexerContractTests.swift in Sources */, + FAE601322ECA700100000001 /* IrohaToriiContractTests.swift in Sources */, + FAE601362ECA700100000001 /* IrohaToriiClientTests.swift in Sources */, + FAE6013A2ECA700100000001 /* TonIndexerClientTests.swift in Sources */, + FAE6013E2ECA700100000001 /* SolanaIndexerClientTests.swift in Sources */, + FAE601902ECA700100000001 /* SolanaBalanceSyncTests.swift in Sources */, + FAE601942ECA700100000001 /* SolanaTransactionHistorySyncTests.swift in Sources */, + FAE601982ECA700100000001 /* SolanaRpcClientTests.swift in Sources */, + FAE6019E2ECA700100000001 /* SolanaTransferTransactionBuilderTests.swift in Sources */, + FAE601A02ECA700100000001 /* SolanaSendServiceTests.swift in Sources */, + FAE601422ECA700100000001 /* BitcoinIndexerClientTests.swift in Sources */, + FAE6016C2ECA700100000001 /* BitcoinReceiveDiscoveryTests.swift in Sources */, + FAE601702ECA700100000001 /* BitcoinBalanceSyncTests.swift in Sources */, + FAE601742ECA700100000001 /* BitcoinFeeEstimatorTests.swift in Sources */, + FAE601782ECA700100000001 /* BitcoinUtxoSelectorTests.swift in Sources */, + FAE6017C2ECA700100000001 /* BitcoinSendPlannerTests.swift in Sources */, + FAE601802ECA700100000001 /* BitcoinTransactionBuilderTests.swift in Sources */, + FAE601842ECA700100000001 /* BitcoinTransactionBroadcasterTests.swift in Sources */, + FAE601882ECA700100000001 /* BitcoinTransactionHistorySyncTests.swift in Sources */, + FAE601A42ECA700100000001 /* BitcoinHistoryOperationFactoryTests.swift in Sources */, + FAE601A82ECA700100000001 /* UniversalWalletAccountAddressResolverTests.swift in Sources */, + FAE601AC2ECA700100000001 /* SolanaHistoryOperationFactoryTests.swift in Sources */, + FAE601B02ECA700100000001 /* IrohaHistoryOperationFactoryTests.swift in Sources */, + FAE6018C2ECA700100000001 /* BitcoinSendServiceTests.swift in Sources */, + FAE601482ECA700100000001 /* UniversalWalletIndexerClientFactoryTests.swift in Sources */, + FAE6014C2ECA700100000001 /* UniversalWalletIdentityTests.swift in Sources */, + FAE601502ECA700100000001 /* UniversalWalletIndexerContractTests.swift in Sources */, + FAE601542ECA700100000001 /* UniversalWalletSigningContractTests.swift in Sources */, + FAE601582ECA700100000001 /* UniversalWalletMigrationContractTests.swift in Sources */, + FAE6015C2ECA700100000001 /* UniversalWalletRegistryContractTests.swift in Sources */, 8467FD3924EACE08005D486C /* AccountCreationHelper.swift in Sources */, 8476CA0625EFB072003EEAE1 /* SingleValueProviderFactoryStub.swift in Sources */, FAD428A82A865636001D6A16 /* BackupPasswordTests.swift in Sources */, @@ -19159,8 +19465,8 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon$(ICON_SUFFIX)"; CLONED_SOURCE_PACKAGES_DIR_PATH = "$(SRCROOT)/SourcePackages"; - CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_ENTITLEMENTS = fearless/WalletConnect.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 7; DEVELOPMENT_TEAM = YLWWUD25VZ; @@ -19300,8 +19606,8 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon$(ICON_SUFFIX)"; CLONED_SOURCE_PACKAGES_DIR_PATH = "$(SRCROOT)/SourcePackages"; - CODE_SIGN_IDENTITY = "Apple Distribution"; CODE_SIGN_ENTITLEMENTS = fearless/WalletConnect.entitlements; + CODE_SIGN_IDENTITY = "Apple Distribution"; CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 7; DEVELOPMENT_TEAM = YLWWUD25VZ; @@ -19396,11 +19702,11 @@ /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ - FAFE00012DCAF00100E5C0DE /* XCLocalSwiftPackageReference "Packages/FearlessUtilsCompat" */ = { + FAFE00012DCAF00100E5C0DE /* XCLocalSwiftPackageReference "FearlessUtilsCompat" */ = { isa = XCLocalSwiftPackageReference; relativePath = Packages/FearlessUtilsCompat; }; - FAFE10002DCF000100E5C0DE /* XCLocalSwiftPackageReference "Packages/FearlessDependencies" */ = { + FAFE10002DCF000100E5C0DE /* XCLocalSwiftPackageReference "FearlessDependencies" */ = { isa = XCLocalSwiftPackageReference; relativePath = Packages/FearlessDependencies; }; @@ -19558,22 +19864,18 @@ isa = XCSwiftPackageProductDependency; productName = SSFXCM; }; - C6182B462C631AAC0089558D /* SoraKeystore */ = { - isa = XCSwiftPackageProductDependency; - productName = SoraKeystore; - }; C6182B482C631AAC0089558D /* keccak */ = { isa = XCSwiftPackageProductDependency; productName = keccak; }; FAFE00022DCAF00100E5C0DE /* FearlessUtils */ = { isa = XCSwiftPackageProductDependency; - package = FAFE00012DCAF00100E5C0DE /* XCLocalSwiftPackageReference "Packages/FearlessUtilsCompat" */; + package = FAFE00012DCAF00100E5C0DE /* XCLocalSwiftPackageReference "FearlessUtilsCompat" */; productName = FearlessUtils; }; FAFE10012DCF000100E5C0DE /* FearlessDependencies */ = { isa = XCSwiftPackageProductDependency; - package = FAFE10002DCF000100E5C0DE /* XCLocalSwiftPackageReference "Packages/FearlessDependencies" */; + package = FAFE10002DCF000100E5C0DE /* XCLocalSwiftPackageReference "FearlessDependencies" */; productName = FearlessDependencies; }; FAFEAA132DCF200100E5C0DE /* ReownWalletKit */ = { diff --git a/fearless.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/fearless.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index bc5db6fcea..03a56be7eb 100644 --- a/fearless.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/fearless.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "f16c3748b2521abc9e665465554020f3ad60527690e2cbe485ae7eb48d5b1d9e", + "originHash" : "66437b32f2895202438a2119c897760cc8a6a2d4d3f57440805ccb430238d958", "pins" : [ { "identity" : "appauth-ios", @@ -381,19 +381,19 @@ { "identity" : "ton-api-swift", "kind" : "remoteSourceControl", - "location" : "https://github.com/DRadmir/ton-api-swift.git", + "location" : "https://github.com/tonkeeper/ton-api-swift.git", "state" : { - "revision" : "8ddff19a40d3d00503cab7fb9d9eb77459169488", - "version" : "0.5.0" + "revision" : "d57d457cbaea081467ae65648e92fc6094ea342b", + "version" : "0.1.7" } }, { "identity" : "ton-swift", "kind" : "remoteSourceControl", - "location" : "https://github.com/DRadmir/ton-swift.git", + "location" : "https://github.com/tonkeeper/ton-swift.git", "state" : { - "branch" : "main", - "revision" : "73c9894e2be8d6d16b87853342eb2755d2e4be8a" + "revision" : "03dbe1f4cef7471b012993ec54e0783882acb3ab", + "version" : "1.0.4" } }, { diff --git a/fearless.xcworkspace/xcshareddata/swiftpm/Package.resolved b/fearless.xcworkspace/xcshareddata/swiftpm/Package.resolved index bc5db6fcea..03a56be7eb 100644 --- a/fearless.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/fearless.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "f16c3748b2521abc9e665465554020f3ad60527690e2cbe485ae7eb48d5b1d9e", + "originHash" : "66437b32f2895202438a2119c897760cc8a6a2d4d3f57440805ccb430238d958", "pins" : [ { "identity" : "appauth-ios", @@ -381,19 +381,19 @@ { "identity" : "ton-api-swift", "kind" : "remoteSourceControl", - "location" : "https://github.com/DRadmir/ton-api-swift.git", + "location" : "https://github.com/tonkeeper/ton-api-swift.git", "state" : { - "revision" : "8ddff19a40d3d00503cab7fb9d9eb77459169488", - "version" : "0.5.0" + "revision" : "d57d457cbaea081467ae65648e92fc6094ea342b", + "version" : "0.1.7" } }, { "identity" : "ton-swift", "kind" : "remoteSourceControl", - "location" : "https://github.com/DRadmir/ton-swift.git", + "location" : "https://github.com/tonkeeper/ton-swift.git", "state" : { - "branch" : "main", - "revision" : "73c9894e2be8d6d16b87853342eb2755d2e4be8a" + "revision" : "03dbe1f4cef7471b012993ec54e0783882acb3ab", + "version" : "1.0.4" } }, { diff --git a/fearless/ApplicationLayer/Services/Balance/RemoteSubscription/AccountInfoRemoteService.swift b/fearless/ApplicationLayer/Services/Balance/RemoteSubscription/AccountInfoRemoteService.swift index 700b19f3bf..4301f65752 100644 --- a/fearless/ApplicationLayer/Services/Balance/RemoteSubscription/AccountInfoRemoteService.swift +++ b/fearless/ApplicationLayer/Services/Balance/RemoteSubscription/AccountInfoRemoteService.swift @@ -5,6 +5,9 @@ import SSFNetwork import SSFModels import SSFUtils import RobinHood +import BigInt +import IrohaCrypto +import SoraKeystore protocol AccountInfoRemoteService { func fetchAccountInfos( @@ -18,24 +21,101 @@ protocol AccountInfoRemoteService { ) async throws -> AccountInfo? } +protocol SolanaBalanceSyncing { + func balances( + wallet: String, + network: UniversalWalletRegistry.SolanaNetwork, + baseURL: String?, + includeTokenMetadata: Bool + ) async throws -> SolanaBalanceSyncResult +} + +extension SolanaBalanceSync: SolanaBalanceSyncing {} + +protocol BitcoinBalanceSyncing { + func balance( + mnemonic: String, + passphrase: String, + network: BitcoinKeyDerivation.Network, + baseURL: String?, + gapLimit: Int?, + maxLookahead: Int + ) async throws -> BitcoinBalanceSyncResult +} + +extension BitcoinBalanceSync: BitcoinBalanceSyncing {} + +protocol UniversalWalletMnemonicProviding { + func mnemonic(for wallet: MetaAccountModel, chain: ChainModel) throws -> String? +} + +protocol BitcoinMnemonicProviding: UniversalWalletMnemonicProviding {} + +final class KeychainUniversalWalletMnemonicProvider: BitcoinMnemonicProviding { + private let keystore: KeystoreProtocol + + init(keystore: KeystoreProtocol = Keychain()) { + self.keystore = keystore + } + + func mnemonic(for wallet: MetaAccountModel, chain: ChainModel) throws -> String? { + let accountResponse = wallet.fetch(for: chain.accountRequest()) + var accountIds: [AccountId?] = [] + + if accountResponse?.isChainAccount == true { + accountIds.append(accountResponse?.accountId) + } + + accountIds.append(nil) + + for accountId in accountIds { + let entropyTag = KeystoreTagV2.entropyTagForMetaId(wallet.metaId, accountId: accountId) + guard let entropy = try? keystore.fetchKey(for: entropyTag) else { + continue + } + + return try IRMnemonicCreator().mnemonic(fromEntropy: entropy).toString() + } + + return nil + } +} + +typealias KeychainBitcoinMnemonicProvider = KeychainUniversalWalletMnemonicProvider + final class AccountInfoRemoteServiceDefault: AccountInfoRemoteService { private enum ChainKind { case substrate case ethereum case ton + case bitcoin(UniversalWalletRegistry.BitcoinNetwork) + case solana(UniversalWalletRegistry.SolanaNetwork) + case iroha(UniversalWalletRegistry.IrohaNetwork) } private let ethereumRemoteBalanceFetching: AccountInfoFetchingProtocol private let tonRemoteBalanceFetching: AccountInfoRemoteService? + private let bitcoinBalanceSync: BitcoinBalanceSyncing? + private let bitcoinMnemonicProvider: BitcoinMnemonicProviding + private let solanaBalanceSync: SolanaBalanceSyncing? + private let irohaToriiClient: IrohaToriiClientProtocol? private let storagePerformer: SSFStorageQueryKit.StorageRequestPerformer init( ethereumRemoteBalanceFetching: AccountInfoFetchingProtocol, tonRemoteBalanceFetching: AccountInfoRemoteService?, + bitcoinBalanceSync: BitcoinBalanceSyncing? = nil, + bitcoinMnemonicProvider: BitcoinMnemonicProviding = KeychainBitcoinMnemonicProvider(), + solanaBalanceSync: SolanaBalanceSyncing? = nil, + irohaToriiClient: IrohaToriiClientProtocol? = IrohaToriiClient(), storagePerformer: SSFStorageQueryKit.StorageRequestPerformer ) { self.ethereumRemoteBalanceFetching = ethereumRemoteBalanceFetching self.tonRemoteBalanceFetching = tonRemoteBalanceFetching + self.bitcoinBalanceSync = bitcoinBalanceSync + self.bitcoinMnemonicProvider = bitcoinMnemonicProvider + self.solanaBalanceSync = solanaBalanceSync + self.irohaToriiClient = irohaToriiClient self.storagePerformer = storagePerformer } @@ -45,22 +125,27 @@ final class AccountInfoRemoteServiceDefault: AccountInfoRemoteService { for chain: ChainModel, wallet: MetaAccountModel ) async throws -> [ChainAssetId: AccountInfo?] { - guard let accountId = wallet.fetch(for: chain.accountRequest())?.accountId else { - let emptyMap = Dictionary( - uniqueKeysWithValues: chain.chainAssets.map { ($0.chainAssetId, Optional.none) } - ) - return emptyMap - } - switch chainKind(for: chain) { case .ethereum: + guard wallet.fetch(for: chain.accountRequest())?.accountId != nil else { + return emptyAccountInfos(for: chain) + } return try await fetchEthereum(for: chain, wallet: wallet) case .ton: guard let tonRemoteBalanceFetching else { throw ConvenienceError(error: "TON remote fetching unavailable") } return try await tonRemoteBalanceFetching.fetchAccountInfos(for: chain, wallet: wallet) + case let .bitcoin(network): + return try await fetchBitcoin(for: chain, wallet: wallet, network: network) + case let .solana(network): + return try await fetchSolana(for: chain, wallet: wallet, network: network) + case let .iroha(network): + return try await fetchIroha(for: chain, wallet: wallet, network: network) case .substrate: + guard let accountId = wallet.fetch(for: chain.accountRequest())?.accountId else { + return emptyAccountInfos(for: chain) + } return try await fetchSubstrate(for: chain, accountId: accountId) } } @@ -69,11 +154,11 @@ final class AccountInfoRemoteServiceDefault: AccountInfoRemoteService { for chainAsset: ChainAsset, wallet: MetaAccountModel ) async throws -> AccountInfo? { - guard let accountId = wallet.fetch(for: chainAsset.chain.accountRequest())?.accountId else { - return nil - } switch chainKind(for: chainAsset.chain) { case .ethereum: + guard let accountId = wallet.fetch(for: chainAsset.chain.accountRequest())?.accountId else { + return nil + } let response = try await ethereumRemoteBalanceFetching.fetch(for: chainAsset, accountId: accountId) return response.1 case .ton: @@ -81,7 +166,19 @@ final class AccountInfoRemoteServiceDefault: AccountInfoRemoteService { throw ConvenienceError(error: "TON remote fetching unavailable") } return try await tonRemoteBalanceFetching.fetchAccountInfo(for: chainAsset, wallet: wallet) + case let .bitcoin(network): + let map = try await fetchBitcoin(for: chainAsset.chain, wallet: wallet, network: network) + return map[chainAsset.chainAssetId] ?? nil + case let .solana(network): + let map = try await fetchSolana(for: chainAsset.chain, wallet: wallet, network: network) + return map[chainAsset.chainAssetId] ?? nil + case let .iroha(network): + let map = try await fetchIroha(for: chainAsset.chain, wallet: wallet, network: network) + return map[chainAsset.chainAssetId] ?? nil case .substrate: + guard let accountId = wallet.fetch(for: chainAsset.chain.accountRequest())?.accountId else { + return nil + } let request = createSubstrateRequest(for: chainAsset, accountId: accountId) let response = try await storagePerformer.perform([request], chain: chainAsset.chain) let map = try createSubstrateMap(from: response, chain: chainAsset.chain) @@ -95,6 +192,18 @@ final class AccountInfoRemoteServiceDefault: AccountInfoRemoteService { return .ton } + if let network = bitcoinNetwork(for: chain) { + return .bitcoin(network) + } + + if let network = solanaNetwork(for: chain) { + return .solana(network) + } + + if let network = irohaNetwork(for: chain) { + return .iroha(network) + } + if chain.chainBaseType == .ethereum { return .ethereum } @@ -102,6 +211,43 @@ final class AccountInfoRemoteServiceDefault: AccountInfoRemoteService { return .substrate } + private func bitcoinNetwork(for chain: ChainModel) -> UniversalWalletRegistry.BitcoinNetwork? { + switch chain.chainId.lowercased() { + case UniversalWalletRegistry.bitcoinMainnet.chainId, UniversalWalletRegistry.bitcoinMainnet.id: + return UniversalWalletRegistry.bitcoinMainnet + case UniversalWalletRegistry.bitcoinTestnet.chainId, UniversalWalletRegistry.bitcoinTestnet.id: + return UniversalWalletRegistry.bitcoinTestnet + default: + return nil + } + } + + private func solanaNetwork(for chain: ChainModel) -> UniversalWalletRegistry.SolanaNetwork? { + switch chain.chainId.lowercased() { + case UniversalWalletRegistry.solanaMainnet.chainId, UniversalWalletRegistry.solanaMainnet.id: + return UniversalWalletRegistry.solanaMainnet + case UniversalWalletRegistry.solanaDevnet.chainId, UniversalWalletRegistry.solanaDevnet.id: + return UniversalWalletRegistry.solanaDevnet + default: + return nil + } + } + + private func irohaNetwork(for chain: ChainModel) -> UniversalWalletRegistry.IrohaNetwork? { + switch chain.chainId.lowercased() { + case UniversalWalletRegistry.taira.chainId, UniversalWalletRegistry.taira.id: + return UniversalWalletRegistry.taira + case UniversalWalletRegistry.nexus.chainId, UniversalWalletRegistry.nexus.id: + return UniversalWalletRegistry.nexus + default: + return nil + } + } + + private func emptyAccountInfos(for chain: ChainModel) -> [ChainAssetId: AccountInfo?] { + Dictionary(uniqueKeysWithValues: chain.chainAssets.map { ($0.chainAssetId, AccountInfo?.none) }) + } + // MARK: - Private substrate methods private func fetchSubstrate( @@ -259,4 +405,284 @@ final class AccountInfoRemoteServiceDefault: AccountInfoRemoteService { let map = Dictionary(uniqueKeysWithValues: mapped) return map } + + // MARK: - Private bitcoin methods + + private func fetchBitcoin( + for chain: ChainModel, + wallet: MetaAccountModel, + network: UniversalWalletRegistry.BitcoinNetwork + ) async throws -> [ChainAssetId: AccountInfo?] { + var accountInfos = emptyAccountInfos(for: chain) + + guard + let bitcoinBalanceSync, + let mnemonic = try bitcoinMnemonicProvider.mnemonic(for: wallet, chain: chain) + else { + return accountInfos + } + + do { + let result = try await bitcoinBalanceSync.balance( + mnemonic: mnemonic, + passphrase: "", + network: bitcoinKeyDerivationNetwork(for: network), + baseURL: bitcoinBalanceBaseURL(for: chain), + gapLimit: network.defaultGapLimit, + maxLookahead: BitcoinReceiveDiscovery.defaultMaxLookahead + ) + + chain.chainAssets.forEach { chainAsset in + accountInfos[chainAsset.chainAssetId] = bitcoinAccountInfo( + for: chainAsset, + network: network, + balanceResult: result + ) + } + } catch is CancellationError { + throw CancellationError() + } catch { + return accountInfos + } + + return accountInfos + } + + private func bitcoinKeyDerivationNetwork( + for network: UniversalWalletRegistry.BitcoinNetwork + ) -> BitcoinKeyDerivation.Network { + network == UniversalWalletRegistry.bitcoinTestnet ? .testnet : .mainnet + } + + private func bitcoinBalanceBaseURL(for chain: ChainModel) -> String? { + chain.externalApi?.history?.url.absoluteString + } + + private func bitcoinAccountInfo( + for chainAsset: ChainAsset, + network: UniversalWalletRegistry.BitcoinNetwork, + balanceResult: BitcoinBalanceSyncResult + ) -> AccountInfo? { + guard + isSupportedNativeBitcoinAsset(chainAsset.asset, network: network), + balanceResult.totalSats >= 0 + else { + return nil + } + + return AccountInfo(ethBalance: BigUInt(UInt64(balanceResult.totalSats))) + } + + private func isSupportedNativeBitcoinAsset( + _ asset: AssetModel, + network: UniversalWalletRegistry.BitcoinNetwork + ) -> Bool { + asset.id.uppercased() == network.nativeAsset.id && + asset.symbol.uppercased() == network.nativeAsset.symbol && + asset.precision == UInt16(network.nativeAsset.decimals) && + asset.isNative + } + + // MARK: - Private solana methods + + private func fetchSolana( + for chain: ChainModel, + wallet: MetaAccountModel, + network: UniversalWalletRegistry.SolanaNetwork + ) async throws -> [ChainAssetId: AccountInfo?] { + var accountInfos = emptyAccountInfos(for: chain) + + guard + network == UniversalWalletRegistry.solanaMainnet, + let solanaBalanceSync, + let address = UniversalWalletAccountAddressResolver.address(for: chain, wallet: wallet) + else { + return accountInfos + } + + do { + let result = try await solanaBalanceSync.balances( + wallet: address, + network: network, + baseURL: solanaBalanceBaseURL(for: chain), + includeTokenMetadata: false + ) + + chain.chainAssets.forEach { chainAsset in + accountInfos[chainAsset.chainAssetId] = solanaAccountInfo( + for: chainAsset, + network: network, + balanceResult: result + ) + } + } catch is CancellationError { + throw CancellationError() + } catch { + return accountInfos + } + + return accountInfos + } + + private func solanaBalanceBaseURL(for chain: ChainModel) -> String? { + chain.externalApi?.history?.url.absoluteString + } + + private func solanaAccountInfo( + for chainAsset: ChainAsset, + network: UniversalWalletRegistry.SolanaNetwork, + balanceResult: SolanaBalanceSyncResult + ) -> AccountInfo? { + let indexedBalance: UniversalWalletIndexedAssetBalance? + if isSupportedNativeSolanaAsset(chainAsset.asset, network: network) { + indexedBalance = balanceResult.nativeBalance + } else { + indexedBalance = balanceResult.tokenBalances.first { + $0.assetId == chainAsset.asset.id || $0.contractAddress == chainAsset.asset.id + } + } + + guard + let indexedBalance, + indexedBalance.decimals == Int(chainAsset.asset.precision), + let amount = BigUInt(indexedBalance.amount) + else { + return nil + } + + return AccountInfo(ethBalance: amount) + } + + private func isSupportedNativeSolanaAsset( + _ asset: AssetModel, + network: UniversalWalletRegistry.SolanaNetwork + ) -> Bool { + asset.id.uppercased() == network.nativeAsset.id && + asset.symbol.uppercased() == network.nativeAsset.symbol && + asset.precision == UInt16(network.nativeAsset.decimals) && + asset.isNative + } + + // MARK: - Private Iroha methods + + private func fetchIroha( + for chain: ChainModel, + wallet: MetaAccountModel, + network: UniversalWalletRegistry.IrohaNetwork + ) async throws -> [ChainAssetId: AccountInfo?] { + var accountInfos = emptyAccountInfos(for: chain) + + guard + let irohaToriiClient, + let address = UniversalWalletAccountAddressResolver.address(for: chain, wallet: wallet) + else { + return accountInfos + } + + do { + let response = try await irohaToriiClient.accountAssets( + accountID: address, + baseURL: irohaBalanceBaseURL(for: chain), + limit: IrohaToriiRoutes.maxLimit, + offset: nil, + countMode: .bounded, + asset: nil, + scope: nil, + network: network + ) + + chain.chainAssets.forEach { chainAsset in + accountInfos[chainAsset.chainAssetId] = irohaAccountInfo( + for: chainAsset, + address: address, + response: response + ) + } + } catch is CancellationError { + throw CancellationError() + } catch { + return accountInfos + } + + return accountInfos + } + + private func irohaBalanceBaseURL(for chain: ChainModel) -> String? { + chain.externalApi?.history?.url.absoluteString + } + + private func irohaAccountInfo( + for chainAsset: ChainAsset, + address: String, + response: IrohaAccountAssetListResponse + ) -> AccountInfo? { + let amounts = response.items + .filter { irohaAccountAsset($0, matchesAddress: address) && irohaAccountAsset($0, matchesAsset: chainAsset.asset) } + .compactMap { irohaPlanks(from: $0.quantity, precision: chainAsset.asset.precision) } + + guard !amounts.isEmpty else { + return nil + } + + let amount = amounts.reduce(BigUInt.zero, +) + return AccountInfo(ethBalance: amount) + } + + private func irohaAccountAsset( + _ item: IrohaAccountAssetListItem, + matchesAddress address: String + ) -> Bool { + guard let accountId = item.accountID, !accountId.isEmpty else { + return true + } + + return accountId == address + } + + private func irohaAccountAsset( + _ item: IrohaAccountAssetListItem, + matchesAsset asset: AssetModel + ) -> Bool { + let chainAssetIds = [ + asset.id, + asset.currencyId + ].compactMap { $0 } + + let itemAssetIds = [ + item.asset, + item.assetID, + item.assetName, + item.assetAlias + ].compactMap { $0 } + + return itemAssetIds.contains { itemId in + chainAssetIds.contains(itemId) + } + } + + private func irohaPlanks(from quantity: String, precision: UInt16) -> BigUInt? { + let normalized = quantity.trimmingCharacters(in: .whitespacesAndNewlines) + let pattern = #"^(0|[1-9][0-9]*)(\.[0-9]+)?$"# + guard normalized.range(of: pattern, options: .regularExpression) != nil else { + return nil + } + + let parts = normalized.split(separator: ".", omittingEmptySubsequences: false) + guard parts.count <= 2 else { + return nil + } + + let integerPart = String(parts[0]) + let fractionPart = parts.count == 2 ? String(parts[1]) : "" + guard fractionPart.count <= Int(precision) else { + return nil + } + + let paddedFraction = fractionPart.padding( + toLength: Int(precision), + withPad: "0", + startingAt: 0 + ) + return BigUInt(integerPart + paddedFraction) + } } diff --git a/fearless/ApplicationLayer/Services/Transfer/Tokens/TransferService.swift b/fearless/ApplicationLayer/Services/Transfer/Tokens/TransferService.swift index a7ecdf75c5..b0c256825c 100644 --- a/fearless/ApplicationLayer/Services/Transfer/Tokens/TransferService.swift +++ b/fearless/ApplicationLayer/Services/Transfer/Tokens/TransferService.swift @@ -37,3 +37,905 @@ extension TransferServiceProtocol { func estimateFee(for _: XorlessTransfer) async throws -> BigUInt { .zero } func submit(transfer _: XorlessTransfer) async throws -> String { "" } } + +final class BitcoinTransferService: TransferServiceProtocol { + private let wallet: MetaAccountModel + private let chain: ChainModel + private let planner: BitcoinSendPlanner + private let sendService: BitcoinSendService + private let mnemonicProvider: BitcoinMnemonicProviding + private var feeTask: Task? + + init( + wallet: MetaAccountModel, + chain: ChainModel, + client: BitcoinIndexerClientProtocol = BitcoinIndexerClient(), + mnemonicProvider: BitcoinMnemonicProviding = KeychainBitcoinMnemonicProvider() + ) { + self.wallet = wallet + self.chain = chain + planner = BitcoinSendPlanner(client: client) + sendService = BitcoinSendService(client: client) + self.mnemonicProvider = mnemonicProvider + } + + func estimateFee(for transfer: Transfer) async throws -> BigUInt { + let context = try resolveContext(failure: TransferServiceError.cannotEstimateFee(reason:)) + let amountSats = try resolveAmountSats( + transfer.amount, + failure: TransferServiceError.cannotEstimateFee(reason:) + ) + let plan = try await planner.plan( + amountSats: amountSats, + sources: [BitcoinUtxoSource(address: context.sourceAddress)], + recipientAddress: transfer.receiver, + changeAddress: context.sourceAddress, + network: context.network, + baseURL: context.baseURL + ) + + return BigUInt(UInt64(plan.feeSats)) + } + + func submit(transfer: Transfer) async throws -> String { + let context = try resolveContext(failure: TransferServiceError.transferFailed(reason:)) + let amountSats = try resolveAmountSats( + transfer.amount, + failure: TransferServiceError.transferFailed(reason:) + ) + + guard let mnemonic = try mnemonicProvider.mnemonic(for: wallet, chain: chain) else { + throw TransferServiceError.transferFailed(reason: "Bitcoin mnemonic root material is unavailable") + } + + let derivedAddress: String + do { + derivedAddress = try BitcoinKeyDerivation.deriveAccount( + mnemonic: mnemonic, + network: bitcoinKeyDerivationNetwork(for: context.network) + ).firstReceiveAddress + } catch { + throw TransferServiceError.transferFailed(reason: "Bitcoin mnemonic root material is invalid") + } + + guard derivedAddress.lowercased() == context.sourceAddress.lowercased() else { + throw TransferServiceError.transferFailed(reason: "Bitcoin mnemonic does not match selected wallet") + } + + let result = try await sendService.send( + BitcoinSendRequest( + mnemonic: mnemonic, + amountSats: amountSats, + sources: [BitcoinUtxoSource(address: context.sourceAddress)], + recipientAddress: transfer.receiver, + changeAddress: context.sourceAddress, + network: context.network, + baseURL: context.baseURL + ) + ) + + return result.broadcastTxid + } + + func subscribeForFee(transfer: Transfer, listener: TransferFeeEstimationListener) { + feeTask?.cancel() + feeTask = Task { [weak self, weak listener] in + guard let self else { + return + } + + do { + let fee = try await self.estimateFee(for: transfer) + guard !Task.isCancelled else { + return + } + + listener?.didReceiveFee(fee: fee) + } catch { + guard !Task.isCancelled else { + return + } + + listener?.didReceiveFeeError(feeError: error) + } + } + } + + func unsubscribe() { + feeTask?.cancel() + feeTask = nil + } + + private func resolveContext( + failure: (String) -> TransferServiceError + ) throws -> BitcoinTransferContext { + guard let network = bitcoinNetwork(for: chain) else { + throw failure("Unsupported Bitcoin chain: \(chain.chainId)") + } + + guard let sourceAddress = UniversalWalletAccountAddressResolver.address(for: chain, wallet: wallet) else { + throw failure("Bitcoin account address is unavailable for \(chain.chainId)") + } + + let indexerNetwork = bitcoinIndexerNetwork(for: network) + let normalizedSourceAddress: String + do { + normalizedSourceAddress = try BitcoinIndexerRoutes.normalizeAddress( + sourceAddress, + network: indexerNetwork + ) + } catch { + throw failure("Bitcoin source address is invalid") + } + + return BitcoinTransferContext( + sourceAddress: normalizedSourceAddress, + network: indexerNetwork, + baseURL: chain.externalApi?.history?.url.absoluteString + ) + } + + private func resolveAmountSats( + _ amount: BigUInt, + failure: (String) -> TransferServiceError + ) throws -> Int64 { + guard amount > .zero else { + throw failure("Bitcoin transfer amount must be positive") + } + + guard + amount <= BigUInt(UInt64(Int64.max)), + let amountSats = Int64(amount.description) + else { + throw failure("Bitcoin transfer amount is too large") + } + + return amountSats + } + + private func bitcoinNetwork(for chain: ChainModel) -> UniversalWalletRegistry.BitcoinNetwork? { + switch chain.chainId.lowercased() { + case UniversalWalletRegistry.bitcoinMainnet.chainId, UniversalWalletRegistry.bitcoinMainnet.id: + return UniversalWalletRegistry.bitcoinMainnet + case UniversalWalletRegistry.bitcoinTestnet.chainId, UniversalWalletRegistry.bitcoinTestnet.id: + return UniversalWalletRegistry.bitcoinTestnet + default: + return nil + } + } + + private func bitcoinIndexerNetwork( + for network: UniversalWalletRegistry.BitcoinNetwork + ) -> BitcoinIndexerNetwork { + network == UniversalWalletRegistry.bitcoinTestnet ? .testnet : .mainnet + } + + private func bitcoinKeyDerivationNetwork( + for network: BitcoinIndexerNetwork + ) -> BitcoinKeyDerivation.Network { + switch network { + case .mainnet: + return .mainnet + case .testnet: + return .testnet + } + } +} + +final class SolanaTransferService: TransferServiceProtocol { + private let wallet: MetaAccountModel + private let chain: ChainModel + private let rpcClient: SolanaRpcClientProtocol + private let sendService: SolanaSendService + private let balanceSync: SolanaBalanceSyncing + private let mnemonicProvider: UniversalWalletMnemonicProviding + private var feeTask: Task? + + init( + wallet: MetaAccountModel, + chain: ChainModel, + rpcClient: SolanaRpcClientProtocol? = nil, + balanceSync: SolanaBalanceSyncing = SolanaBalanceSync(client: SolanaIndexerClient()), + mnemonicProvider: UniversalWalletMnemonicProviding = KeychainUniversalWalletMnemonicProvider() + ) throws { + self.wallet = wallet + self.chain = chain + if let rpcClient { + self.rpcClient = rpcClient + } else { + self.rpcClient = try SolanaRpcClient() + } + self.balanceSync = balanceSync + self.mnemonicProvider = mnemonicProvider + let network = try Self.solanaNetwork(for: chain) + sendService = SolanaSendService( + rpcClient: self.rpcClient, + balanceProvider: SolanaTransferBalanceProvider( + balanceSync: balanceSync, + network: network, + baseURL: chain.externalApi?.history?.url.absoluteString + ) + ) + } + + func estimateFee(for transfer: Transfer) async throws -> BigUInt { + let context = try resolveContext( + for: transfer, + failure: TransferServiceError.cannotEstimateFee(reason:) + ) + if isNativeSolAsset(transfer.chainAsset.asset, network: context.network) { + return try await estimateNativeFee(for: transfer, context: context) + } + + return try await estimateTokenFee(for: transfer, context: context) + } + + private func estimateNativeFee( + for transfer: Transfer, + context: SolanaTransferContext + ) async throws -> BigUInt { + let lamports = try resolveLamports( + transfer.amount, + failure: TransferServiceError.cannotEstimateFee(reason:) + ) + + let blockhash = try await rpcClient.latestBlockhash( + commitment: .confirmed, + rpcURL: context.rpcURL + ) + let unsigned = try SolanaTransferTransactionBuilder.buildNativeTransfer( + senderAddress: context.sourceAddress, + recipientAddress: transfer.receiver, + lamports: lamports, + recentBlockhash: blockhash.value.blockhash + ) + let fee = try await rpcClient.feeForMessage( + unsigned.messageBase64, + commitment: .confirmed, + rpcURL: context.rpcURL + ).value + + guard let fee else { + throw TransferServiceError.cannotEstimateFee(reason: "Solana transfer fee is unavailable") + } + + return BigUInt(UInt64(fee)) + } + + private func estimateTokenFee( + for transfer: Transfer, + context: SolanaTransferContext + ) async throws -> BigUInt { + let token = try await resolveTokenTransferContext( + for: transfer, + context: context, + failure: TransferServiceError.cannotEstimateFee(reason:) + ) + let destinationTokenAccount = try SolanaTransferTransactionBuilder.deriveAssociatedTokenAccountAddress( + walletAddress: transfer.receiver, + mintAddress: token.mintAddress, + tokenProgram: .splToken + ) + let destinationTokenAccountExists = try await rpcClient.accountExists( + address: destinationTokenAccount, + commitment: .confirmed, + rpcURL: context.rpcURL + ) + let blockhash = try await rpcClient.latestBlockhash( + commitment: .confirmed, + rpcURL: context.rpcURL + ) + let unsigned = try SolanaTransferTransactionBuilder.buildTokenSendChecked( + ownerAddress: context.sourceAddress, + sourceTokenAccount: token.sourceTokenAccount, + destinationTokenAccount: destinationTokenAccountExists ? destinationTokenAccount : nil, + mintAddress: token.mintAddress, + rawAmount: token.rawAmount, + decimals: token.decimals, + recentBlockhash: blockhash.value.blockhash, + tokenProgram: .splToken, + destinationWalletAddress: destinationTokenAccountExists ? nil : transfer.receiver + ) + let fee = try await rpcClient.feeForMessage( + unsigned.messageBase64, + commitment: .confirmed, + rpcURL: context.rpcURL + ).value + + guard let fee else { + throw TransferServiceError.cannotEstimateFee(reason: "Solana token transfer fee is unavailable") + } + + return BigUInt(UInt64(fee)) + } + + func submit(transfer: Transfer) async throws -> String { + let context = try resolveContext( + for: transfer, + failure: TransferServiceError.transferFailed(reason:) + ) + + guard let mnemonic = try mnemonicProvider.mnemonic(for: wallet, chain: chain) else { + throw TransferServiceError.transferFailed(reason: "Solana mnemonic root material is unavailable") + } + + let derivedAddress: String + do { + derivedAddress = try SolanaKeyDerivation.deriveAccount(mnemonic: mnemonic).address + } catch { + throw TransferServiceError.transferFailed(reason: "Solana mnemonic root material is invalid") + } + + guard derivedAddress == context.sourceAddress else { + throw TransferServiceError.transferFailed(reason: "Solana mnemonic does not match selected wallet") + } + + if isNativeSolAsset(transfer.chainAsset.asset, network: context.network) { + return try await submitNative(transfer: transfer, context: context, mnemonic: mnemonic) + } + + return try await submitToken(transfer: transfer, context: context, mnemonic: mnemonic) + } + + private func submitNative( + transfer: Transfer, + context: SolanaTransferContext, + mnemonic: String + ) async throws -> String { + let lamports = try resolveLamports( + transfer.amount, + failure: TransferServiceError.transferFailed(reason:) + ) + let result = try await sendService.send( + SolanaSendRequest( + mnemonic: mnemonic, + recipientAddress: transfer.receiver, + lamports: lamports, + rpcURL: context.rpcURL + ) + ) + + return result.signature + } + + private func submitToken( + transfer: Transfer, + context: SolanaTransferContext, + mnemonic: String + ) async throws -> String { + let token = try await resolveTokenTransferContext( + for: transfer, + context: context, + failure: TransferServiceError.transferFailed(reason:) + ) + let result = try await sendService.sendTokenTransfer( + SolanaTokenSendRequest( + mnemonic: mnemonic, + sourceTokenAccount: token.sourceTokenAccount, + mintAddress: token.mintAddress, + rawAmount: token.rawAmount, + decimals: token.decimals, + rpcURL: context.rpcURL, + tokenProgram: .splToken, + destinationWalletAddress: transfer.receiver + ) + ) + + return result.signature + } + + func subscribeForFee(transfer: Transfer, listener: TransferFeeEstimationListener) { + feeTask?.cancel() + feeTask = Task { [weak self, weak listener] in + guard let self else { + return + } + + do { + let fee = try await self.estimateFee(for: transfer) + guard !Task.isCancelled else { + return + } + + listener?.didReceiveFee(fee: fee) + } catch { + guard !Task.isCancelled else { + return + } + + listener?.didReceiveFeeError(feeError: error) + } + } + } + + func unsubscribe() { + feeTask?.cancel() + feeTask = nil + } + + private func resolveContext( + for _: Transfer, + failure: (String) -> TransferServiceError + ) throws -> SolanaTransferContext { + let network = try Self.solanaNetwork(for: chain) + + guard let sourceAddress = UniversalWalletAccountAddressResolver.address(for: chain, wallet: wallet) else { + throw failure("Solana account address is unavailable for \(chain.chainId)") + } + + let normalizedSourceAddress: String + do { + normalizedSourceAddress = try SolanaRpcRoutes.normalizePublicKey(sourceAddress) + } catch { + throw failure("Solana source address is invalid") + } + + return SolanaTransferContext( + sourceAddress: normalizedSourceAddress, + network: network, + indexerBaseURL: chain.externalApi?.history?.url.absoluteString, + rpcURL: rpcURL(for: network) + ) + } + + private static func solanaNetwork(for chain: ChainModel) throws -> UniversalWalletRegistry.SolanaNetwork { + switch chain.chainId.lowercased() { + case UniversalWalletRegistry.solanaMainnet.chainId, UniversalWalletRegistry.solanaMainnet.id: + return UniversalWalletRegistry.solanaMainnet + case UniversalWalletRegistry.solanaDevnet.chainId, UniversalWalletRegistry.solanaDevnet.id: + return UniversalWalletRegistry.solanaDevnet + default: + throw TransferServiceError.transferFailed(reason: "Unsupported Solana chain: \(chain.chainId)") + } + } + + private func isNativeSolAsset( + _ asset: AssetModel, + network: UniversalWalletRegistry.SolanaNetwork + ) -> Bool { + asset.isNative && + asset.id.uppercased() == network.nativeAsset.id && + asset.symbol.uppercased() == network.nativeAsset.symbol && + asset.precision == UInt16(network.nativeAsset.decimals) + } + + private func resolveLamports( + _ amount: BigUInt, + failure: (String) -> TransferServiceError + ) throws -> Int64 { + guard amount > .zero else { + throw failure("Solana transfer amount must be positive") + } + + guard + amount <= BigUInt(UInt64(Int64.max)), + let lamports = Int64(amount.description) + else { + throw failure("Solana transfer amount is too large") + } + + return lamports + } + + private func resolveRawAmount( + _ amount: BigUInt, + unitName: String, + failure: (String) -> TransferServiceError + ) throws -> Int64 { + guard amount > .zero else { + throw failure("Solana \(unitName) amount must be positive") + } + + guard + amount <= BigUInt(UInt64(Int64.max)), + let rawAmount = Int64(amount.description) + else { + throw failure("Solana \(unitName) amount is too large") + } + + return rawAmount + } + + private func resolveTokenTransferContext( + for transfer: Transfer, + context: SolanaTransferContext, + failure: (String) -> TransferServiceError + ) async throws -> SolanaTokenTransferContext { + let rawAmount = try resolveRawAmount( + transfer.amount, + unitName: "token raw", + failure: failure + ) + let balances = try await balanceSync.balances( + wallet: context.sourceAddress, + network: context.network, + baseURL: context.indexerBaseURL, + includeTokenMetadata: false + ) + guard let balance = balances.tokenBalances.first(where: { matchesTokenBalance($0, asset: transfer.chainAsset.asset) }) else { + throw failure("Solana token balance is unavailable for \(transfer.chainAsset.asset.symbol)") + } + + guard balance.tokenProgram == Self.splTokenProgramName else { + throw failure("Solana token transfers currently support only SPL Token assets") + } + + guard let sourceTokenAccount = balance.tokenAccountId?.nonEmpty else { + throw failure("Solana token source account is unavailable for \(transfer.chainAsset.asset.symbol)") + } + + guard let mintAddress = balance.contractAddress?.nonEmpty ?? balance.assetId.nonEmpty else { + throw failure("Solana token mint is unavailable for \(transfer.chainAsset.asset.symbol)") + } + + return SolanaTokenTransferContext( + sourceTokenAccount: sourceTokenAccount, + mintAddress: mintAddress, + decimals: balance.decimals, + rawAmount: rawAmount + ) + } + + private func matchesTokenBalance( + _ balance: UniversalWalletIndexedAssetBalance, + asset: AssetModel + ) -> Bool { + guard !balance.isNative, balance.decimals == Int(asset.precision) else { + return false + } + + let identifiers = [asset.id, asset.currencyId].compactMap { $0?.nonEmpty } + return identifiers.contains { $0 == balance.assetId || $0 == balance.contractAddress } + } + + private func rpcURL(for network: UniversalWalletRegistry.SolanaNetwork) -> String { + if let selectedNode = chain.selectedNode?.url.absoluteString { + return selectedNode + } + + return chain.nodes + .sorted { $0.url.absoluteString < $1.url.absoluteString } + .first? + .url + .absoluteString ?? network.rpcURL.absoluteString + } + + private static let splTokenProgramName = "spl-token" +} + +protocol IrohaTransferSigning { + func buildAndSignTransfer(_ request: IrohaTransferSigningRequest) async throws -> IrohaSignedTransfer +} + +struct UnavailableIrohaTransferSigner: IrohaTransferSigning { + func buildAndSignTransfer(_: IrohaTransferSigningRequest) async throws -> IrohaSignedTransfer { + throw TransferServiceError.transferFailed(reason: "Iroha transfer signing codec is unavailable") + } +} + +struct IrohaTransferSigningRequest: Equatable { + let amount: String + let assetDefinitionId: String + let authority: String + let chainId: String + let derivationPath: String + let destinationAccountId: String + let mnemonicOrSeed: String + let network: String + let signingPublicKeyHex: String + let sourceAccountId: String + let sourceAssetId: String +} + +struct IrohaSignedTransfer: Equatable { + let signedTransaction: Data + let transactionHashHex: String? +} + +final class IrohaTransferService: TransferServiceProtocol { + private let wallet: MetaAccountModel + private let chain: ChainModel + private let toriiClient: IrohaToriiClientProtocol + private let signer: IrohaTransferSigning + private let mnemonicProvider: UniversalWalletMnemonicProviding + private var feeTask: Task? + + init( + wallet: MetaAccountModel, + chain: ChainModel, + toriiClient: IrohaToriiClientProtocol = IrohaToriiClient(), + signer: IrohaTransferSigning = UnavailableIrohaTransferSigner(), + mnemonicProvider: UniversalWalletMnemonicProviding = KeychainUniversalWalletMnemonicProvider() + ) { + self.wallet = wallet + self.chain = chain + self.toriiClient = toriiClient + self.signer = signer + self.mnemonicProvider = mnemonicProvider + } + + func estimateFee(for transfer: Transfer) async throws -> BigUInt { + _ = try resolveContext( + for: transfer, + failure: TransferServiceError.cannotEstimateFee(reason:) + ) + + return .zero + } + + func submit(transfer: Transfer) async throws -> String { + let context = try resolveContext( + for: transfer, + failure: TransferServiceError.transferFailed(reason:) + ) + let signedTransfer = try await signer.buildAndSignTransfer(context.signingRequest) + + guard !signedTransfer.signedTransaction.isEmpty else { + throw TransferServiceError.transferFailed(reason: "Iroha transfer signer returned an empty transaction") + } + + let receipt = try await toriiClient.submitTransaction( + noritoBytes: signedTransfer.signedTransaction, + baseURL: context.toriiBaseURL + ) + + return signedTransfer.transactionHashHex + ?? receipt.payload.signedTransactionHash + ?? receipt.payload.txHash + } + + func subscribeForFee(transfer: Transfer, listener: TransferFeeEstimationListener) { + feeTask?.cancel() + feeTask = Task { [weak self, weak listener] in + guard let self else { + return + } + + do { + let fee = try await self.estimateFee(for: transfer) + guard !Task.isCancelled else { + return + } + + listener?.didReceiveFee(fee: fee) + } catch { + guard !Task.isCancelled else { + return + } + + listener?.didReceiveFeeError(feeError: error) + } + } + } + + func unsubscribe() { + feeTask?.cancel() + feeTask = nil + } + + private func resolveContext( + for transfer: Transfer, + failure: (String) -> TransferServiceError + ) throws -> IrohaTransferContext { + let network = try Self.irohaNetwork( + for: chain, + failure: failure + ) + let toriiBaseURL = try Self.toriiBaseURL( + for: chain, + network: network, + failure: failure + ) + + guard let sourceAddress = UniversalWalletAccountAddressResolver.address(for: chain, wallet: wallet) else { + throw failure("Iroha account address is unavailable for \(chain.chainId)") + } + + let sourceDetails: IrohaAddressDetails + do { + sourceDetails = try IrohaAddressCodec.parse( + sourceAddress, + expectedDiscriminant: network.chainDiscriminant + ) + } catch { + throw failure("Iroha source address is invalid") + } + + let destinationAddress: String + do { + destinationAddress = try IrohaAddressCodec.parse( + transfer.receiver, + expectedDiscriminant: network.chainDiscriminant + ).i105 + } catch { + throw failure("Iroha recipient address is invalid") + } + + guard let mnemonic = try mnemonicProvider.mnemonic(for: wallet, chain: chain) else { + throw failure("Iroha mnemonic root material is unavailable") + } + + let derivedAddress: String + do { + derivedAddress = try IrohaKeyDerivation.deriveAddress( + mnemonic: mnemonic, + chainDiscriminant: network.chainDiscriminant + ).i105 + } catch { + throw failure("Iroha mnemonic root material is invalid") + } + + guard derivedAddress == sourceDetails.i105 else { + throw failure("Iroha mnemonic does not match selected wallet") + } + + let assetDefinitionId = try Self.normalizeAssetDefinitionId( + transfer.chainAsset.asset.id, + failure: failure + ) + let amount = try Self.normalizeAmount( + rawAmount: transfer.amount, + precision: Int(transfer.chainAsset.asset.precision), + failure: failure + ) + + return IrohaTransferContext( + toriiBaseURL: toriiBaseURL, + signingRequest: IrohaTransferSigningRequest( + amount: amount, + assetDefinitionId: assetDefinitionId, + authority: sourceDetails.i105, + chainId: network.chainId, + derivationPath: UniversalWalletDerivationPaths.irohaDefault, + destinationAccountId: destinationAddress, + mnemonicOrSeed: mnemonic, + network: Self.networkKey(for: network), + signingPublicKeyHex: sourceDetails.publicKeyHex, + sourceAccountId: sourceDetails.i105, + sourceAssetId: "\(assetDefinitionId)#\(sourceDetails.i105)" + ) + ) + } + + private static func irohaNetwork( + for chain: ChainModel, + failure: (String) -> TransferServiceError + ) throws -> UniversalWalletRegistry.IrohaNetwork { + switch chain.chainId.lowercased() { + case UniversalWalletRegistry.taira.chainId, UniversalWalletRegistry.taira.id: + return UniversalWalletRegistry.taira + case UniversalWalletRegistry.nexus.chainId, UniversalWalletRegistry.nexus.id: + return UniversalWalletRegistry.nexus + default: + throw failure("Unsupported Iroha chain: \(chain.chainId)") + } + } + + private static func toriiBaseURL( + for chain: ChainModel, + network: UniversalWalletRegistry.IrohaNetwork, + failure: (String) -> TransferServiceError + ) throws -> String { + if let url = chain.externalApi?.history?.url.absoluteString.nonEmpty { + return url + } + + guard let url = network.toriiBaseURL?.absoluteString.nonEmpty else { + throw failure("Iroha Torii endpoint is not configured for \(chain.chainId)") + } + + return url + } + + private static func normalizeAmount( + rawAmount: BigUInt, + precision: Int, + failure: (String) -> TransferServiceError + ) throws -> String { + guard rawAmount > .zero else { + throw failure("Iroha transfer amount must be positive") + } + + guard precision >= 0, precision <= 28 else { + throw failure("Iroha asset precision is unsupported") + } + + let raw = rawAmount.description + guard precision > 0 else { + return raw + } + + let padded = raw.count <= precision + ? String(repeating: "0", count: precision - raw.count + 1) + raw + : raw + let splitIndex = padded.index(padded.endIndex, offsetBy: -precision) + let whole = String(padded[.. TransferServiceError + ) throws -> String { + let pattern = #"^[^\s%/?:#]+#[^\s%/?:#]+$"# + guard assetDefinitionId.range(of: pattern, options: .regularExpression) != nil else { + throw failure("Iroha asset definition id is invalid") + } + + return assetDefinitionId + } + + private static func networkKey(for network: UniversalWalletRegistry.IrohaNetwork) -> String { + if network == UniversalWalletRegistry.taira { + return "taira" + } + + if network == UniversalWalletRegistry.nexus { + return "nexus" + } + + return network.id + } +} + +private struct BitcoinTransferContext { + let sourceAddress: String + let network: BitcoinIndexerNetwork + let baseURL: String? +} + +private struct SolanaTransferContext { + let sourceAddress: String + let network: UniversalWalletRegistry.SolanaNetwork + let indexerBaseURL: String? + let rpcURL: String +} + +private struct SolanaTokenTransferContext { + let sourceTokenAccount: String + let mintAddress: String + let decimals: Int + let rawAmount: Int64 +} + +private struct IrohaTransferContext { + let toriiBaseURL: String + let signingRequest: IrohaTransferSigningRequest +} + +private final class SolanaTransferBalanceProvider: SolanaSendBalanceProvider { + private let balanceSync: SolanaBalanceSyncing + private let network: UniversalWalletRegistry.SolanaNetwork + private let baseURL: String? + + init( + balanceSync: SolanaBalanceSyncing, + network: UniversalWalletRegistry.SolanaNetwork, + baseURL: String? + ) { + self.balanceSync = balanceSync + self.network = network + self.baseURL = baseURL + } + + func balances(wallet: String) async throws -> SolanaBalanceSyncResult { + try await balanceSync.balances( + wallet: wallet, + network: network, + baseURL: baseURL, + includeTokenMetadata: false + ) + } +} + +private extension String { + var nonEmpty: String? { + let normalized = trimmingCharacters(in: .whitespacesAndNewlines) + return normalized.isEmpty ? nil : normalized + } +} diff --git a/fearless/ApplicationLayer/Services/WalletConnect/eip_mew/Address.swift b/fearless/ApplicationLayer/Services/WalletConnect/eip_mew/Address.swift index e42cfbaed9..29c3f2047c 100644 --- a/fearless/ApplicationLayer/Services/WalletConnect/eip_mew/Address.swift +++ b/fearless/ApplicationLayer/Services/WalletConnect/eip_mew/Address.swift @@ -8,6 +8,7 @@ import Foundation import CryptoSwift +import SSFModels public struct Address: CustomDebugStringConvertible { public enum Ethereum { @@ -20,7 +21,7 @@ public struct Address: CustomDebugStringConvertible { } public var data: Data { - Data(hex: address) + (try? Data(hexStringSSF: address)) ?? Data() } public init?(data: Data, prefix: String? = nil) { diff --git a/fearless/ApplicationLayer/Services/WalletConnect/eip_mew/SignTypedData.swift b/fearless/ApplicationLayer/Services/WalletConnect/eip_mew/SignTypedData.swift index 5d273de6c2..40b3265ae7 100644 --- a/fearless/ApplicationLayer/Services/WalletConnect/eip_mew/SignTypedData.swift +++ b/fearless/ApplicationLayer/Services/WalletConnect/eip_mew/SignTypedData.swift @@ -8,6 +8,7 @@ import Foundation import CryptoSwift +import SSFModels public enum TypedMessageSignError: Error { case invalidVersion @@ -180,7 +181,7 @@ public func hash(message: TypedMessage, version: SignTypedDataVersion) throws -> return hash case .v3 where message.message.count == 1, .v4 where message.message.count == 1: - var data = Data(hex: "1901") + var data = try Data(hexStringSSF: "1901") data.append( try hashStruct( diff --git a/fearless/ApplicationLayer/Services/WalletConnect/eip_mew/abi/ABIEncoding.swift b/fearless/ApplicationLayer/Services/WalletConnect/eip_mew/abi/ABIEncoding.swift index 8da572b9b1..2c16eecfdb 100755 --- a/fearless/ApplicationLayer/Services/WalletConnect/eip_mew/abi/ABIEncoding.swift +++ b/fearless/ApplicationLayer/Services/WalletConnect/eip_mew/abi/ABIEncoding.swift @@ -6,6 +6,7 @@ import Foundation import BigInt import CryptoSwift +import SSFModels // swiftlint:disable identifier_name @@ -114,7 +115,7 @@ public extension ABIEncoder { return convertToData(value as AnyObject, type: type) } if d.hasHexPrefix() { - return Data(hex: d) + return try? Data(hexStringSSF: d) } let str = d.data(using: .utf8) if str != nil { @@ -229,7 +230,7 @@ public extension ABIEncoder { if let string = value as? String { var dataGuess: Data? if string.hasHexPrefix() { - dataGuess = Data(hex: string.lowercased()) + dataGuess = try? Data(hexStringSSF: string.lowercased()) } else { dataGuess = string.data(using: .utf8) } diff --git a/fearless/CIKeys.stencil b/fearless/CIKeys.stencil index ed41ad5f14..2fc83a2362 100644 --- a/fearless/CIKeys.stencil +++ b/fearless/CIKeys.stencil @@ -1,6 +1,7 @@ enum MoonPayCIKeys { static var secretKey: String = "{{ argument.moonPaySecretKey }}" static var testSecretKey: String = "{{ argument.moonPayTestSecretKey }}" + static var publicKey: String = "{{ argument.moonPayPublicKey }}" } enum SubscanCIKeys { diff --git a/fearless/Common/Configs/ApplicationConfigs.swift b/fearless/Common/Configs/ApplicationConfigs.swift index 9b74999af3..2a04687fa6 100644 --- a/fearless/Common/Configs/ApplicationConfigs.swift +++ b/fearless/Common/Configs/ApplicationConfigs.swift @@ -90,7 +90,7 @@ extension ApplicationConfig: ApplicationConfigProtocol, XcmConfigProtocol { } var moonPayApiKey: String { - "pk_live_Boi6Rl107p7XuJWBL8GJRzGWlmUSoxbz" + MoonPayKeys.publicKey } var purchaseRedirect: URL { diff --git a/fearless/Common/Helpers/ChainAccountFetching.swift b/fearless/Common/Helpers/ChainAccountFetching.swift index 1333523f5c..465c576a42 100644 --- a/fearless/Common/Helpers/ChainAccountFetching.swift +++ b/fearless/Common/Helpers/ChainAccountFetching.swift @@ -19,25 +19,38 @@ enum ChainAccountFetchingError: Error { extension ChainAccountResponse { func toDisplayAddress() throws -> DisplayAddress { - let chainFormat: ChainFormat = isEthereumBased ? .ethereum : .substrate(addressPrefix) - let address = try accountId.toAddress(using: chainFormat) + let address = try displayAddress() return DisplayAddress(address: address, username: name) } func toAddress() -> AccountAddress? { - let chainFormat: ChainFormat = isEthereumBased ? .ethereum : .substrate(addressPrefix) - return try? accountId.toAddress(using: chainFormat) + try? displayAddress() } func chainFormat() -> ChainFormat { isEthereumBased ? .ethereum : .substrate(addressPrefix) } + + private func displayAddress() throws -> AccountAddress { + if UniversalWalletChainAccountSupport.isUniversalWalletChain(chainId) { + guard let address = UniversalWalletChainAccountSupport.address(for: chainId, publicKey: publicKey) else { + throw ChainAccountFetchingError.accountNotExists + } + + return address + } + + let chainFormat: ChainFormat = isEthereumBased ? .ethereum : .substrate(addressPrefix) + return try accountId.toAddress(using: chainFormat) + } } extension MetaAccountModel { func fetch(for request: ChainAccountRequest) -> ChainAccountResponse? { - if let chainAccount = chainAccounts.first(where: { $0.chainId == request.chainId }) { + if let chainAccount = chainAccounts.first(where: { + UniversalWalletChainAccountSupport.chainId($0.chainId, matches: request.chainId) + }) { guard let cryptoType = CryptoType(rawValue: chainAccount.cryptoType) else { return nil } @@ -55,6 +68,10 @@ extension MetaAccountModel { ) } + guard !UniversalWalletChainAccountSupport.isUniversalWalletChain(request.chainId) else { + return nil + } + if request.isEthereumBased { guard let publicKey = ethereumPublicKey, let accountId = ethereumAddress else { return nil diff --git a/fearless/Common/Model/BitcoinBalanceSync.swift b/fearless/Common/Model/BitcoinBalanceSync.swift new file mode 100644 index 0000000000..2bb1e49297 --- /dev/null +++ b/fearless/Common/Model/BitcoinBalanceSync.swift @@ -0,0 +1,68 @@ +import Foundation + +final class BitcoinBalanceSync { + private let discovery: BitcoinReceiveDiscovery + + init(discovery: BitcoinReceiveDiscovery) { + self.discovery = discovery + } + + func balance( + mnemonic: String, + passphrase: String = "", + network: BitcoinKeyDerivation.Network = .mainnet, + baseURL: String? = nil, + gapLimit: Int? = nil, + maxLookahead: Int = BitcoinReceiveDiscovery.defaultMaxLookahead + ) async throws -> BitcoinBalanceSyncResult { + let discoveryResult = try await discovery.discover( + mnemonic: mnemonic, + passphrase: passphrase, + network: network, + baseURL: baseURL, + gapLimit: gapLimit, + maxLookahead: maxLookahead + ) + var confirmedSats: Int64 = 0 + var mempoolSats: Int64 = 0 + + for address in discoveryResult.addresses { + guard address.confirmedSats >= 0, address.totalSats >= 0 else { + throw BitcoinBalanceSyncError.invalidAddressBalance + } + + confirmedSats = try safeAdd(confirmedSats, address.confirmedSats) + mempoolSats = try safeAdd(mempoolSats, address.mempoolSats) + } + + return BitcoinBalanceSyncResult( + confirmedSats: confirmedSats, + mempoolSats: mempoolSats, + totalSats: try safeAdd(confirmedSats, mempoolSats), + usedAddresses: discoveryResult.usedAddresses, + discovery: discoveryResult + ) + } + + private func safeAdd(_ left: Int64, _ right: Int64) throws -> Int64 { + let sum = left.addingReportingOverflow(right) + guard !sum.overflow else { + throw BitcoinBalanceSyncError.balanceOverflow + } + + return sum.partialValue + } +} + +struct BitcoinBalanceSyncResult: Equatable { + let confirmedSats: Int64 + let mempoolSats: Int64 + let totalSats: Int64 + let usedAddresses: [BitcoinReceiveDiscoveredAddress] + let discovery: BitcoinReceiveDiscoveryResult +} + +enum BitcoinBalanceSyncError: Error, Equatable { + case invalidAddressBalance + case balanceOverflow +} diff --git a/fearless/Common/Model/BitcoinFeeEstimator.swift b/fearless/Common/Model/BitcoinFeeEstimator.swift new file mode 100644 index 0000000000..9e3bbc0738 --- /dev/null +++ b/fearless/Common/Model/BitcoinFeeEstimator.swift @@ -0,0 +1,79 @@ +import Foundation + +final class BitcoinFeeEstimator { + static let defaultTargetBlocks = 2 + static let maxTargetBlocks = 1008 + static let maxFeeRateSatPerVbyte = 10000.0 + + private let client: BitcoinIndexerClientProtocol + + init(client: BitcoinIndexerClientProtocol) { + self.client = client + } + + func estimate( + network: BitcoinIndexerNetwork = .mainnet, + baseURL: String? = nil, + targetBlocks: Int = BitcoinFeeEstimator.defaultTargetBlocks + ) async throws -> BitcoinFeeEstimate { + try select( + estimates: try await client.feeEstimates(network: network, baseURL: baseURL), + targetBlocks: targetBlocks + ) + } + + func select( + estimates: [String: Double], + targetBlocks: Int = BitcoinFeeEstimator.defaultTargetBlocks + ) throws -> BitcoinFeeEstimate { + try validateTargetBlocks(targetBlocks) + let entries = estimates.compactMap { entry -> (blocks: Int, feeRate: Double)? in + guard let parsedBlocks = Int(entry.key), parsedBlocks > 0, entry.value.isFinite, entry.value > 0 else { + return nil + } + + return (parsedBlocks, entry.value) + }.sorted { $0.blocks < $1.blocks } + + guard !entries.isEmpty else { + throw BitcoinFeeEstimatorError.feeEstimatesUnavailable + } + + let selected = entries.first { $0.blocks >= targetBlocks } ?? entries[entries.count - 1] + try validateFeeRate(selected.feeRate) + + return BitcoinFeeEstimate( + requestedTargetBlocks: targetBlocks, + selectedTargetBlocks: selected.blocks, + feeRateSatPerVbyte: selected.feeRate + ) + } + + private func validateTargetBlocks(_ targetBlocks: Int) throws { + guard targetBlocks > 0, targetBlocks <= Self.maxTargetBlocks else { + throw BitcoinFeeEstimatorError.invalidFeeTarget + } + } + + private func validateFeeRate(_ feeRateSatPerVbyte: Double) throws { + guard + feeRateSatPerVbyte.isFinite, + feeRateSatPerVbyte > 0, + feeRateSatPerVbyte <= Self.maxFeeRateSatPerVbyte + else { + throw BitcoinFeeEstimatorError.invalidFeeRate + } + } +} + +struct BitcoinFeeEstimate: Equatable { + let requestedTargetBlocks: Int + let selectedTargetBlocks: Int + let feeRateSatPerVbyte: Double +} + +enum BitcoinFeeEstimatorError: Error, Equatable { + case invalidFeeTarget + case invalidFeeRate + case feeEstimatesUnavailable +} diff --git a/fearless/Common/Model/BitcoinIndexerClient.swift b/fearless/Common/Model/BitcoinIndexerClient.swift new file mode 100644 index 0000000000..ee19b62ef3 --- /dev/null +++ b/fearless/Common/Model/BitcoinIndexerClient.swift @@ -0,0 +1,95 @@ +import Foundation + +protocol BitcoinIndexerClientProtocol { + func address(address: String, network: BitcoinIndexerNetwork, baseURL: String?) async throws -> BitcoinEsploraAddress + func utxos(address: String, network: BitcoinIndexerNetwork, baseURL: String?) async throws -> [BitcoinEsploraUtxo] + func transactions( + address: String, + network: BitcoinIndexerNetwork, + baseURL: String?, + lastSeenTxid: String?, + mempool: Bool + ) async throws -> [BitcoinEsploraTransaction] + func feeEstimates(network: BitcoinIndexerNetwork, baseURL: String?) async throws -> [String: Double] + func broadcastTransaction(txHex: String, network: BitcoinIndexerNetwork, baseURL: String?) async throws -> String +} + +final class BitcoinIndexerClient: BitcoinIndexerClientProtocol { + private let transport: UniversalWalletHTTPTransport + private let decoder: JSONDecoder + + init( + transport: UniversalWalletHTTPTransport = URLSessionUniversalWalletHTTPTransport(), + decoder: JSONDecoder = JSONDecoder() + ) { + self.transport = transport + self.decoder = decoder + } + + func address( + address: String, + network: BitcoinIndexerNetwork = .mainnet, + baseURL: String? = nil + ) async throws -> BitcoinEsploraAddress { + try await get(try BitcoinIndexerRoutes.addressURL(address: address, network: network, baseURL: baseURL)) + } + + func utxos( + address: String, + network: BitcoinIndexerNetwork = .mainnet, + baseURL: String? = nil + ) async throws -> [BitcoinEsploraUtxo] { + try await get(try BitcoinIndexerRoutes.utxosURL(address: address, network: network, baseURL: baseURL)) + } + + func transactions( + address: String, + network: BitcoinIndexerNetwork = .mainnet, + baseURL: String? = nil, + lastSeenTxid: String? = nil, + mempool: Bool = false + ) async throws -> [BitcoinEsploraTransaction] { + try await get( + try BitcoinIndexerRoutes.transactionsURL( + address: address, + network: network, + baseURL: baseURL, + lastSeenTxid: lastSeenTxid, + mempool: mempool + ) + ) + } + + func feeEstimates( + network: BitcoinIndexerNetwork = .mainnet, + baseURL: String? = nil + ) async throws -> [String: Double] { + try await get(try BitcoinIndexerRoutes.feeEstimatesURL(network: network, baseURL: baseURL)) + } + + func broadcastTransaction( + txHex: String, + network: BitcoinIndexerNetwork = .mainnet, + baseURL: String? = nil + ) async throws -> String { + let normalizedHex = try BitcoinIndexerRoutes.normalizeBroadcastTransactionBody(txHex) + var request = urlRequest( + url: try BitcoinIndexerRoutes.broadcastTransactionURL(network: network, baseURL: baseURL), + method: "POST" + ) + request.setValue("text/plain", forHTTPHeaderField: "Content-Type") + request.httpBody = Data(normalizedHex.utf8) + + return String(decoding: try await transport.perform(request), as: UTF8.self) + } + + private func get(_ url: URL) async throws -> T { + try decoder.decode(T.self, from: try await transport.perform(urlRequest(url: url))) + } + + private func urlRequest(url: URL, method: String = "GET") -> URLRequest { + var request = URLRequest(url: url) + request.httpMethod = method + return request + } +} diff --git a/fearless/Common/Model/BitcoinIndexerContract.swift b/fearless/Common/Model/BitcoinIndexerContract.swift new file mode 100644 index 0000000000..a31e8af444 --- /dev/null +++ b/fearless/Common/Model/BitcoinIndexerContract.swift @@ -0,0 +1,288 @@ +import Foundation + +enum BitcoinIndexerRouteError: Error, Equatable { + case invalidBaseURL + case invalidAddress + case invalidTxid + case invalidTxHex +} + +enum BitcoinIndexerNetwork: Equatable { + case mainnet + case testnet + + var defaultBaseURL: URL { + switch self { + case .mainnet: + return UniversalWalletRegistry.bitcoinMainnetIndexerBaseURL + case .testnet: + return UniversalWalletRegistry.bitcoinTestnetIndexerBaseURL + } + } +} + +enum BitcoinIndexerRoutes { + static let maxTxHexLength = 800_000 + + static func addressURL( + address: String, + network: BitcoinIndexerNetwork = .mainnet, + baseURL: String? = nil + ) throws -> URL { + try makeURL("\(normalizeBaseURL(baseURL ?? network.defaultBaseURL.absoluteString))/address/\(normalizeAddress(address, network: network))") + } + + static func utxosURL( + address: String, + network: BitcoinIndexerNetwork = .mainnet, + baseURL: String? = nil + ) throws -> URL { + try makeURL("\(addressURL(address: address, network: network, baseURL: baseURL).absoluteString)/utxo") + } + + static func transactionsURL( + address: String, + network: BitcoinIndexerNetwork = .mainnet, + baseURL: String? = nil, + lastSeenTxid: String? = nil, + mempool: Bool = false + ) throws -> URL { + let normalizedBaseURL = try normalizeBaseURL(baseURL ?? network.defaultBaseURL.absoluteString) + let normalizedAddress = try normalizeAddress(address, network: network) + let base = "\(normalizedBaseURL)/address/\(normalizedAddress)/txs" + + if mempool { + return try makeURL("\(base)/mempool") + } + + if let lastSeenTxid { + return try makeURL("\(base)/chain/\(normalizeTxid(lastSeenTxid))") + } + + return try makeURL(base) + } + + static func feeEstimatesURL( + network: BitcoinIndexerNetwork = .mainnet, + baseURL: String? = nil + ) throws -> URL { + try makeURL("\(normalizeBaseURL(baseURL ?? network.defaultBaseURL.absoluteString))/fee-estimates") + } + + static func broadcastTransactionURL( + network: BitcoinIndexerNetwork = .mainnet, + baseURL: String? = nil + ) throws -> URL { + try makeURL("\(normalizeBaseURL(baseURL ?? network.defaultBaseURL.absoluteString))/tx") + } + + static func normalizeBroadcastTransactionBody(_ txHex: String) throws -> String { + let normalized = txHex.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard + normalized.count <= maxTxHexLength, + normalized.range(of: "^(?:[0-9a-f]{2})+$", options: .regularExpression) != nil + else { + throw BitcoinIndexerRouteError.invalidTxHex + } + + return normalized + } + + static func normalizeBaseURL(_ baseURL: String) throws -> String { + let trimmed = baseURL.trimmingCharacters(in: .whitespacesAndNewlines) + guard let url = URL(string: trimmed), let scheme = url.scheme, let host = url.host else { + throw BitcoinIndexerRouteError.invalidBaseURL + } + + let isLocal = host == "localhost" || host == "127.0.0.1" + guard scheme == "https" || isLocal else { + throw BitcoinIndexerRouteError.invalidBaseURL + } + + return trimmed.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + } + + static func normalizeAddress(_ address: String, network: BitcoinIndexerNetwork) throws -> String { + let normalized = address.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let pattern: String + + switch network { + case .mainnet: + pattern = "^bc1[ac-hj-np-z02-9]{11,90}$" + case .testnet: + pattern = "^tb1[ac-hj-np-z02-9]{11,90}$" + } + + guard normalized.range(of: pattern, options: .regularExpression) != nil else { + throw BitcoinIndexerRouteError.invalidAddress + } + + return normalized + } + + static func normalizeTxid(_ txid: String) throws -> String { + let normalized = txid.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard normalized.range(of: "^[0-9a-f]{64}$", options: .regularExpression) != nil else { + throw BitcoinIndexerRouteError.invalidTxid + } + + return normalized + } + + private static func makeURL(_ value: String) throws -> URL { + guard let url = URL(string: value) else { + throw BitcoinIndexerRouteError.invalidBaseURL + } + + return url + } +} + +struct BitcoinEsploraStats: Codable, Equatable { + let fundedTxoCount: Int + let fundedTxoSum: Int64 + let spentTxoCount: Int + let spentTxoSum: Int64 + let txCount: Int + + private enum CodingKeys: String, CodingKey { + case fundedTxoCount = "funded_txo_count" + case fundedTxoSum = "funded_txo_sum" + case spentTxoCount = "spent_txo_count" + case spentTxoSum = "spent_txo_sum" + case txCount = "tx_count" + } +} + +struct BitcoinEsploraAddress: Codable, Equatable { + let address: String + let chainStats: BitcoinEsploraStats + let mempoolStats: BitcoinEsploraStats + + var confirmedSats: Int64 { + chainStats.fundedTxoSum - chainStats.spentTxoSum + } + + var mempoolSats: Int64 { + mempoolStats.fundedTxoSum - mempoolStats.spentTxoSum + } + + var totalSats: Int64 { + confirmedSats + mempoolSats + } + + private enum CodingKeys: String, CodingKey { + case address + case chainStats = "chain_stats" + case mempoolStats = "mempool_stats" + } +} + +struct BitcoinEsploraTxStatus: Codable, Equatable { + let confirmed: Bool + let blockHash: String? + let blockHeight: Int64? + let blockTime: Int64? + + private enum CodingKeys: String, CodingKey { + case confirmed + case blockHash = "block_hash" + case blockHeight = "block_height" + case blockTime = "block_time" + } +} + +struct BitcoinEsploraUtxo: Codable, Equatable { + let txid: String + let vout: Int64 + let value: Int64 + let status: BitcoinEsploraTxStatus +} + +struct BitcoinEsploraTransaction: Codable, Equatable { + let txid: String + let status: BitcoinEsploraTxStatus + let fee: Int64? + let weight: Int64? + let size: Int64? + let version: Int64? + let locktime: Int64? + let vin: [BitcoinEsploraTransactionInput] + let vout: [BitcoinEsploraTransactionOutput] + + init( + txid: String, + status: BitcoinEsploraTxStatus, + fee: Int64? = nil, + weight: Int64? = nil, + size: Int64? = nil, + version: Int64? = nil, + locktime: Int64? = nil, + vin: [BitcoinEsploraTransactionInput] = [], + vout: [BitcoinEsploraTransactionOutput] = [] + ) { + self.txid = txid + self.status = status + self.fee = fee + self.weight = weight + self.size = size + self.version = version + self.locktime = locktime + self.vin = vin + self.vout = vout + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + txid = try container.decode(String.self, forKey: .txid) + status = try container.decode(BitcoinEsploraTxStatus.self, forKey: .status) + fee = try container.decodeIfPresent(Int64.self, forKey: .fee) + weight = try container.decodeIfPresent(Int64.self, forKey: .weight) + size = try container.decodeIfPresent(Int64.self, forKey: .size) + version = try container.decodeIfPresent(Int64.self, forKey: .version) + locktime = try container.decodeIfPresent(Int64.self, forKey: .locktime) + vin = try container.decodeIfPresent([BitcoinEsploraTransactionInput].self, forKey: .vin) ?? [] + vout = try container.decodeIfPresent([BitcoinEsploraTransactionOutput].self, forKey: .vout) ?? [] + } + + private enum CodingKeys: String, CodingKey { + case txid + case status + case fee + case weight + case size + case version + case locktime + case vin + case vout + } +} + +struct BitcoinEsploraTransactionInput: Codable, Equatable { + let prevout: BitcoinEsploraTransactionOutput? + + init(prevout: BitcoinEsploraTransactionOutput? = nil) { + self.prevout = prevout + } +} + +struct BitcoinEsploraTransactionOutput: Codable, Equatable { + let scriptPubKeyAddress: String? + let value: Int64? + + init(scriptPubKeyAddress: String? = nil, value: Int64? = nil) { + self.scriptPubKeyAddress = scriptPubKeyAddress + self.value = value + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + scriptPubKeyAddress = try container.decodeIfPresent(String.self, forKey: .scriptPubKeyAddress) + value = try? container.decodeIfPresent(Int64.self, forKey: .value) + } + + private enum CodingKeys: String, CodingKey { + case scriptPubKeyAddress = "scriptpubkey_address" + case value + } +} diff --git a/fearless/Common/Model/BitcoinKeyDerivation.swift b/fearless/Common/Model/BitcoinKeyDerivation.swift new file mode 100644 index 0000000000..eaa272c4ec --- /dev/null +++ b/fearless/Common/Model/BitcoinKeyDerivation.swift @@ -0,0 +1,720 @@ +import BigInt +import CommonCrypto +import Foundation +import secp256k1 + +enum BitcoinKeyDerivation { + enum Network: Equatable { + case mainnet + case testnet + + var hrp: String { + switch self { + case .mainnet: + return "bc" + case .testnet: + return "tb" + } + } + + var accountPath: String { + switch self { + case .mainnet: + return UniversalWalletDerivationPaths.bitcoinMainnetAccount + case .testnet: + return UniversalWalletDerivationPaths.bitcoinTestnetAccount + } + } + + var firstReceivePath: String { + switch self { + case .mainnet: + return UniversalWalletDerivationPaths.bitcoinMainnetFirstReceive + case .testnet: + return UniversalWalletDerivationPaths.bitcoinTestnetFirstReceive + } + } + } + + enum DerivationError: Error, Equatable { + case emptyMnemonic + case emptySeed + case invalidDerivationPath + case invalidDerivationIndex + case invalidPrivateKeyLength + case invalidPrivateKeyRange + case invalidPublicKeyLength + case childPrivateKeyTweakOutOfRange + case childPrivateKeyIsZero + case keyDerivationFailed + case secp256k1ContextUnavailable + case publicKeyCannotBeCreated + case publicKeyCannotBeSerialized + case integerDoesNotFit + } + + struct Account: Equatable { + let network: Network + let accountPath: String + let firstReceivePath: String + let accountXpub: String + let privateKey: Data + let chainCode: Data + let publicKey: Data + let firstReceiveAddress: String + } + + struct DerivedKey: Equatable { + let network: Network + let derivationPath: String + let privateKey: Data + let chainCode: Data + let publicKey: Data + let address: String + } + + struct ExtendedPrivateKey: Equatable { + let privateKey: Data + let chainCode: Data + let depth: Int + let parentFingerprint: Data + let childNumber: UInt32 + } + + private struct PathComponent { + let index: UInt32 + let hardened: Bool + + var serializedIndex: UInt32 { + index + (hardened ? BitcoinKeyDerivation.hardenedOffset : 0) + } + } + + private static let bitcoinSeedKey = Data("Bitcoin seed".utf8) + private static let hardenedOffset: UInt32 = 0x8000_0000 + private static let maxChildIndex: UInt32 = 0x7FFF_FFFF + private static let privateKeyLength = 32 + private static let publicKeyLength = 33 + private static let chainCodeLength = 32 + private static let bip39Rounds = 2048 + private static let bip39SeedLength = 64 + private static let xpubPayloadLength = 78 + private static let xpubVersion: UInt32 = 0x0488_B21E + private static let curveOrder = BigUInt( + "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141", + radix: 16 + )! + private static let base58Alphabet = Array("123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz") + private static let bech32Alphabet = Array("qpzry9x8gf2tvdw0s3jn54khce6mua7l") + private static let bech32Generators: [Int] = [ + 0x3B6A_57B2, + 0x2650_8E6D, + 0x1EA1_19FA, + 0x3D42_33DD, + 0x2A14_62B3 + ] + + static func deriveAccount( + mnemonic: String, + passphrase: String = "", + network: Network = .mainnet + ) throws -> Account { + let normalizedMnemonic = try normalizeMnemonic(mnemonic) + let seed = try bip39Seed(mnemonic: normalizedMnemonic, passphrase: passphrase) + let accountNode = try derivePrivateKey(seed: seed, derivationPath: network.accountPath) + let receiveNode = try derivePrivateKey(seed: seed, derivationPath: network.firstReceivePath) + let receivePublicKey = try publicKey(fromPrivateKey: receiveNode.privateKey) + + return Account( + network: network, + accountPath: network.accountPath, + firstReceivePath: network.firstReceivePath, + accountXpub: try serializeXpub(accountNode), + privateKey: receiveNode.privateKey, + chainCode: receiveNode.chainCode, + publicKey: receivePublicKey, + firstReceiveAddress: try address(fromPublicKey: receivePublicKey, network: network) + ) + } + + static func deriveKey( + mnemonic: String, + passphrase: String = "", + derivationPath: String, + network: Network = .mainnet + ) throws -> DerivedKey { + let normalizedMnemonic = try normalizeMnemonic(mnemonic) + let seed = try bip39Seed(mnemonic: normalizedMnemonic, passphrase: passphrase) + let node = try derivePrivateKey(seed: seed, derivationPath: derivationPath) + let derivedPublicKey = try publicKey(fromPrivateKey: node.privateKey) + + return DerivedKey( + network: network, + derivationPath: derivationPath, + privateKey: node.privateKey, + chainCode: node.chainCode, + publicKey: derivedPublicKey, + address: try address(fromPublicKey: derivedPublicKey, network: network) + ) + } + + static func derivePrivateKey(seed: Data, derivationPath: String) throws -> ExtendedPrivateKey { + guard !seed.isEmpty else { + throw DerivationError.emptySeed + } + + let digest = hmacSha512(key: bitcoinSeedKey, data: seed) + var node = ExtendedPrivateKey( + privateKey: try validatePrivateKey(Data(digest.prefix(privateKeyLength))), + chainCode: Data(digest.suffix(chainCodeLength)), + depth: 0, + parentFingerprint: Data(repeating: 0, count: 4), + childNumber: 0 + ) + + for component in try parseDerivationPath(derivationPath) { + node = try deriveChild(parent: node, component: component) + } + + return node + } + + static func publicKey(fromPrivateKey privateKey: Data) throws -> Data { + guard privateKey.count == privateKeyLength else { + throw DerivationError.invalidPrivateKeyLength + } + + _ = try validatePrivateKey(privateKey) + + guard let context = secp256k1_context_create(UInt32(SECP256K1_CONTEXT_SIGN)) else { + throw DerivationError.secp256k1ContextUnavailable + } + + defer { + secp256k1_context_destroy(context) + } + + var publicKey = secp256k1_pubkey() + let createResult = privateKey.withUnsafeBytes { keyBytes in + guard let keyPointer = keyBytes.baseAddress?.assumingMemoryBound(to: UInt8.self) else { + return 0 + } + + return Int(secp256k1_ec_pubkey_create(context, &publicKey, keyPointer)) + } + + guard createResult != 0 else { + throw DerivationError.publicKeyCannotBeCreated + } + + var outputLength = publicKeyLength + var output = Data(repeating: 0, count: outputLength) + let serializeResult = output.withUnsafeMutableBytes { outputBytes in + withUnsafeMutablePointer(to: &outputLength) { outputLengthPointer in + withUnsafePointer(to: &publicKey) { publicKeyPointer in + guard let outputPointer = outputBytes.baseAddress?.assumingMemoryBound(to: UInt8.self) else { + return 0 + } + + return Int(secp256k1_ec_pubkey_serialize( + context, + outputPointer, + outputLengthPointer, + publicKeyPointer, + UInt32(SECP256K1_EC_COMPRESSED) + )) + } + } + } + + guard serializeResult != 0 else { + throw DerivationError.publicKeyCannotBeSerialized + } + + return output + } + + static func address(fromPublicKey publicKey: Data, network: Network = .mainnet) throws -> String { + guard publicKey.count == publicKeyLength else { + throw DerivationError.invalidPublicKeyLength + } + + let witnessProgram = hash160(publicKey) + return try bech32Encode( + hrp: network.hrp, + values: [0] + convertBits(Array(witnessProgram), fromBits: 8, toBits: 5, pad: true) + ) + } + + static func getReceivePath(network: Network = .mainnet, index: UInt32 = 0, change: UInt32 = 0) throws -> String { + guard index <= maxChildIndex else { + throw DerivationError.invalidDerivationIndex + } + + guard change == 0 || change == 1 else { + throw DerivationError.invalidDerivationIndex + } + + let coinType: UInt32 = network == .mainnet ? 0 : 1 + return "m/84'/\(coinType)'/0'/\(change)/\(index)" + } + + private static func deriveChild(parent: ExtendedPrivateKey, component: PathComponent) throws -> ExtendedPrivateKey { + let serializedIndex = component.serializedIndex + var data = Data() + + if component.hardened { + data.append(0) + data.append(parent.privateKey) + } else { + data.append(try publicKey(fromPrivateKey: parent.privateKey)) + } + + appendUInt32(serializedIndex, to: &data) + + let digest = hmacSha512(key: parent.chainCode, data: data) + let tweak = BigUInt(Data(digest.prefix(privateKeyLength))) + + guard tweak < curveOrder else { + throw DerivationError.childPrivateKeyTweakOutOfRange + } + + let parentKey = BigUInt(parent.privateKey) + let childKey = (tweak + parentKey) % curveOrder + + guard childKey > BigUInt.zero else { + throw DerivationError.childPrivateKeyIsZero + } + + let parentFingerprint = fingerprint(try publicKey(fromPrivateKey: parent.privateKey)) + + return ExtendedPrivateKey( + privateKey: try childKey.fixedLengthData(privateKeyLength), + chainCode: Data(digest.suffix(chainCodeLength)), + depth: parent.depth + 1, + parentFingerprint: parentFingerprint, + childNumber: serializedIndex + ) + } + + private static func serializeXpub(_ node: ExtendedPrivateKey) throws -> String { + let nodePublicKey = try publicKey(fromPrivateKey: node.privateKey) + var payload = Data(capacity: xpubPayloadLength) + + appendUInt32(xpubVersion, to: &payload) + payload.append(UInt8(node.depth)) + payload.append(node.parentFingerprint) + appendUInt32(node.childNumber, to: &payload) + payload.append(node.chainCode) + payload.append(nodePublicKey) + + return base58CheckEncode(payload) + } + + private static func parseDerivationPath(_ derivationPath: String) throws -> [PathComponent] { + guard !derivationPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw DerivationError.invalidDerivationPath + } + + guard derivationPath == "m" || derivationPath.hasPrefix("m/") else { + throw DerivationError.invalidDerivationPath + } + + guard derivationPath != "m" else { + return [] + } + + return try derivationPath + .dropFirst(2) + .split(separator: "/", omittingEmptySubsequences: false) + .map { component in + let hardened = component.hasSuffix("'") + let indexText = hardened ? component.dropLast() : component + + guard !indexText.isEmpty, let index = UInt32(indexText), index <= maxChildIndex else { + throw DerivationError.invalidDerivationIndex + } + + return PathComponent(index: index, hardened: hardened) + } + } + + private static func normalizeMnemonic(_ mnemonic: String) throws -> String { + let words = mnemonic + .split(whereSeparator: { $0.isWhitespace }) + .map(String.init) + + guard !words.isEmpty else { + throw DerivationError.emptyMnemonic + } + + return words.joined(separator: " ") + } + + private static func bip39Seed(mnemonic: String, passphrase: String) throws -> Data { + let password = Array(mnemonic.utf8) + let salt = Array("mnemonic\(passphrase)".utf8) + var output = [UInt8](repeating: 0, count: bip39SeedLength) + + let status = password.withUnsafeBufferPointer { passwordBuffer in + salt.withUnsafeBufferPointer { saltBuffer in + passwordBuffer.baseAddress!.withMemoryRebound(to: Int8.self, capacity: password.count) { passwordPointer in + CCKeyDerivationPBKDF( + CCPBKDFAlgorithm(kCCPBKDF2), + passwordPointer, + password.count, + saltBuffer.baseAddress!, + salt.count, + CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA512), + UInt32(bip39Rounds), + &output, + output.count + ) + } + } + } + + guard status == kCCSuccess else { + throw DerivationError.keyDerivationFailed + } + + return Data(output) + } + + private static func hmacSha512(key: Data, data: Data) -> Data { + var output = [UInt8](repeating: 0, count: Int(CC_SHA512_DIGEST_LENGTH)) + + key.withUnsafeBytes { keyBytes in + data.withUnsafeBytes { dataBytes in + CCHmac( + CCHmacAlgorithm(kCCHmacAlgSHA512), + keyBytes.baseAddress!, + key.count, + dataBytes.baseAddress!, + data.count, + &output + ) + } + } + + return Data(output) + } + + private static func sha256(_ data: Data) -> Data { + var output = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH)) + + data.withUnsafeBytes { dataBytes in + _ = CC_SHA256(dataBytes.baseAddress, CC_LONG(data.count), &output) + } + + return Data(output) + } + + private static func hash160(_ data: Data) -> Data { + ripemd160(sha256(data)) + } + + private static func fingerprint(_ publicKey: Data) -> Data { + Data(hash160(publicKey).prefix(4)) + } + + private static func validatePrivateKey(_ privateKey: Data) throws -> Data { + let privateKeyValue = BigUInt(privateKey) + + guard privateKeyValue > BigUInt.zero, privateKeyValue < curveOrder else { + throw DerivationError.invalidPrivateKeyRange + } + + return privateKey + } + + private static func appendUInt32(_ value: UInt32, to data: inout Data) { + var bigEndian = value.bigEndian + withUnsafeBytes(of: &bigEndian) { data.append(contentsOf: $0) } + } + + private static func base58CheckEncode(_ payload: Data) -> String { + let checksum = Data(sha256(sha256(payload)).prefix(4)) + return base58Encode(Array(payload + checksum)) + } + + private static func base58Encode(_ bytes: [UInt8]) -> String { + guard !bytes.isEmpty else { + return "" + } + + var digits: [Int] = [] + + for byte in bytes { + var carry = Int(byte) + + for index in digits.indices { + let value = digits[index] * 256 + carry + digits[index] = value % base58Alphabet.count + carry = value / base58Alphabet.count + } + + while carry > 0 { + digits.append(carry % base58Alphabet.count) + carry /= base58Alphabet.count + } + } + + var result = String(repeating: String(base58Alphabet[0]), count: bytes.prefix { $0 == 0 }.count) + for digit in digits.reversed() { + result.append(base58Alphabet[digit]) + } + + return result + } + + private static func bech32Encode(hrp: String, values: [Int]) throws -> String { + guard !hrp.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw DerivationError.invalidDerivationPath + } + + let encodedValues = values + bech32CreateChecksum(hrp: hrp, values: values) + return hrp + "1" + encodedValues.map { String(bech32Alphabet[$0]) }.joined() + } + + private static func bech32CreateChecksum(hrp: String, values: [Int]) -> [Int] { + let polymodValues = expandHrp(hrp) + values + Array(repeating: 0, count: 6) + let polymod = bech32Polymod(polymodValues) ^ 1 + + return (0 ..< 6).map { index in + (polymod >> (5 * (5 - index))) & 31 + } + } + + private static func bech32Polymod(_ values: [Int]) -> Int { + var checksum = 1 + + for value in values { + let top = checksum >> 25 + checksum = ((checksum & 0x1FFFFFF) << 5) ^ value + + for (index, generator) in bech32Generators.enumerated() where ((top >> index) & 1) == 1 { + checksum ^= generator + } + } + + return checksum + } + + private static func expandHrp(_ hrp: String) -> [Int] { + hrp.map { Int($0.asciiValue ?? 0) >> 5 } + [0] + hrp.map { Int($0.asciiValue ?? 0) & 31 } + } + + private static func convertBits(_ values: [UInt8], fromBits: Int, toBits: Int, pad: Bool) throws -> [Int] { + var accumulator = 0 + var bits = 0 + let maxValue = (1 << toBits) - 1 + let maxAccumulator = (1 << (fromBits + toBits - 1)) - 1 + var result: [Int] = [] + + for rawValue in values { + let value = Int(rawValue) + + guard value >> fromBits == 0 else { + throw DerivationError.invalidPublicKeyLength + } + + accumulator = ((accumulator << fromBits) | value) & maxAccumulator + bits += fromBits + + while bits >= toBits { + bits -= toBits + result.append((accumulator >> bits) & maxValue) + } + } + + if pad { + if bits > 0 { + result.append((accumulator << (toBits - bits)) & maxValue) + } + } else { + guard bits < fromBits, ((accumulator << (toBits - bits)) & maxValue) == 0 else { + throw DerivationError.invalidPublicKeyLength + } + } + + return result + } + + private static func ripemd160(_ data: Data) -> Data { + var message = Array(data) + let bitLength = UInt64(message.count) * 8 + message.append(0x80) + + while message.count % 64 != 56 { + message.append(0) + } + + var littleEndianBitLength = bitLength.littleEndian + withUnsafeBytes(of: &littleEndianBitLength) { message.append(contentsOf: $0) } + + var h0: UInt32 = 0x6745_2301 + var h1: UInt32 = 0xEFCD_AB89 + var h2: UInt32 = 0x98BA_DCFE + var h3: UInt32 = 0x1032_5476 + var h4: UInt32 = 0xC3D2_E1F0 + + for chunkStart in stride(from: 0, to: message.count, by: 64) { + var words = [UInt32](repeating: 0, count: 16) + for index in 0 ..< 16 { + let offset = chunkStart + index * 4 + words[index] = UInt32(message[offset]) + | (UInt32(message[offset + 1]) << 8) + | (UInt32(message[offset + 2]) << 16) + | (UInt32(message[offset + 3]) << 24) + } + + var a1 = h0 + var b1 = h1 + var c1 = h2 + var d1 = h3 + var e1 = h4 + var a2 = h0 + var b2 = h1 + var c2 = h2 + var d2 = h3 + var e2 = h4 + + for index in 0 ..< 80 { + let left = rotateLeft( + a1 + &+ ripemd160F(index, b1, c1, d1) + &+ words[ripemd160R1[index]] + &+ ripemd160K1(index), + by: ripemd160S1[index] + ) &+ e1 + a1 = e1 + e1 = d1 + d1 = rotateLeft(c1, by: 10) + c1 = b1 + b1 = left + + let right = rotateLeft( + a2 + &+ ripemd160F(79 - index, b2, c2, d2) + &+ words[ripemd160R2[index]] + &+ ripemd160K2(index), + by: ripemd160S2[index] + ) &+ e2 + a2 = e2 + e2 = d2 + d2 = rotateLeft(c2, by: 10) + c2 = b2 + b2 = right + } + + let temporary = h1 &+ c1 &+ d2 + h1 = h2 &+ d1 &+ e2 + h2 = h3 &+ e1 &+ a2 + h3 = h4 &+ a1 &+ b2 + h4 = h0 &+ b1 &+ c2 + h0 = temporary + } + + var output = Data(capacity: 20) + [h0, h1, h2, h3, h4].forEach { value in + var littleEndian = value.littleEndian + withUnsafeBytes(of: &littleEndian) { output.append(contentsOf: $0) } + } + + return output + } + + private static func rotateLeft(_ value: UInt32, by shift: Int) -> UInt32 { + (value << shift) | (value >> (32 - shift)) + } + + private static func ripemd160F(_ index: Int, _ x: UInt32, _ y: UInt32, _ z: UInt32) -> UInt32 { + switch index { + case 0 ... 15: + return x ^ y ^ z + case 16 ... 31: + return (x & y) | (~x & z) + case 32 ... 47: + return (x | ~y) ^ z + case 48 ... 63: + return (x & z) | (y & ~z) + default: + return x ^ (y | ~z) + } + } + + private static func ripemd160K1(_ index: Int) -> UInt32 { + switch index { + case 0 ... 15: + return 0x0000_0000 + case 16 ... 31: + return 0x5A82_7999 + case 32 ... 47: + return 0x6ED9_EBA1 + case 48 ... 63: + return 0x8F1B_BCDC + default: + return 0xA953_FD4E + } + } + + private static func ripemd160K2(_ index: Int) -> UInt32 { + switch index { + case 0 ... 15: + return 0x50A2_8BE6 + case 16 ... 31: + return 0x5C4D_D124 + case 32 ... 47: + return 0x6D70_3EF3 + case 48 ... 63: + return 0x7A6D_76E9 + default: + return 0x0000_0000 + } + } + + private static let ripemd160R1 = [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, + 7, 4, 13, 1, 10, 6, 15, 3, 12, 0, 9, 5, 2, 14, 11, 8, + 3, 10, 14, 4, 9, 15, 8, 1, 2, 7, 0, 6, 13, 11, 5, 12, + 1, 9, 11, 10, 0, 8, 12, 4, 13, 3, 7, 15, 14, 5, 6, 2, + 4, 0, 5, 9, 7, 12, 2, 10, 14, 1, 3, 8, 11, 6, 15, 13 + ] + + private static let ripemd160R2 = [ + 5, 14, 7, 0, 9, 2, 11, 4, 13, 6, 15, 8, 1, 10, 3, 12, + 6, 11, 3, 7, 0, 13, 5, 10, 14, 15, 8, 12, 4, 9, 1, 2, + 15, 5, 1, 3, 7, 14, 6, 9, 11, 8, 12, 2, 10, 0, 4, 13, + 8, 6, 4, 1, 3, 11, 15, 0, 5, 12, 2, 13, 9, 7, 10, 14, + 12, 15, 10, 4, 1, 5, 8, 7, 6, 2, 13, 14, 0, 3, 9, 11 + ] + + private static let ripemd160S1 = [ + 11, 14, 15, 12, 5, 8, 7, 9, 11, 13, 14, 15, 6, 7, 9, 8, + 7, 6, 8, 13, 11, 9, 7, 15, 7, 12, 15, 9, 11, 7, 13, 12, + 11, 13, 6, 7, 14, 9, 13, 15, 14, 8, 13, 6, 5, 12, 7, 5, + 11, 12, 14, 15, 14, 15, 9, 8, 9, 14, 5, 6, 8, 6, 5, 12, + 9, 15, 5, 11, 6, 8, 13, 12, 5, 12, 13, 14, 11, 8, 5, 6 + ] + + private static let ripemd160S2 = [ + 8, 9, 9, 11, 13, 15, 15, 5, 7, 7, 8, 11, 14, 14, 12, 6, + 9, 13, 15, 7, 12, 8, 9, 11, 7, 7, 12, 7, 6, 15, 13, 11, + 9, 7, 15, 11, 8, 6, 6, 14, 12, 13, 5, 14, 13, 13, 7, 5, + 15, 5, 8, 11, 14, 14, 6, 14, 6, 9, 12, 9, 12, 5, 15, 8, + 8, 5, 12, 9, 12, 5, 14, 6, 8, 13, 6, 5, 15, 13, 11, 11 + ] +} + +private extension BigUInt { + func fixedLengthData(_ length: Int) throws -> Data { + let serialized = serialize() + + guard serialized.count <= length else { + throw BitcoinKeyDerivation.DerivationError.integerDoesNotFit + } + + return Data(repeating: 0, count: length - serialized.count) + serialized + } +} diff --git a/fearless/Common/Model/BitcoinReceiveDiscovery.swift b/fearless/Common/Model/BitcoinReceiveDiscovery.swift new file mode 100644 index 0000000000..7b53462958 --- /dev/null +++ b/fearless/Common/Model/BitcoinReceiveDiscovery.swift @@ -0,0 +1,160 @@ +import Foundation + +final class BitcoinReceiveDiscovery { + static let defaultMaxLookahead = 1000 + static let maxGapLimit = 100 + static let maxLookahead = 10000 + + private let client: BitcoinIndexerClientProtocol + + init(client: BitcoinIndexerClientProtocol) { + self.client = client + } + + func discover( + mnemonic: String, + passphrase: String = "", + network: BitcoinKeyDerivation.Network = .mainnet, + baseURL: String? = nil, + gapLimit: Int? = nil, + maxLookahead: Int = BitcoinReceiveDiscovery.defaultMaxLookahead + ) async throws -> BitcoinReceiveDiscoveryResult { + let resolvedGapLimit = gapLimit ?? Self.defaultGapLimit(for: network) + try validateParams(mnemonic: mnemonic, gapLimit: resolvedGapLimit, maxLookahead: maxLookahead) + + let indexerNetwork = network.indexerNetwork + var addresses: [BitcoinReceiveDiscoveredAddress] = [] + var consecutiveUnused = 0 + var index = 0 + var lastUsedIndex: Int? + + while consecutiveUnused < resolvedGapLimit, index < maxLookahead { + let path = try BitcoinKeyDerivation.getReceivePath(network: network, index: UInt32(index)) + let address = try BitcoinKeyDerivation.deriveKey( + mnemonic: mnemonic, + passphrase: passphrase, + derivationPath: path, + network: network + ).address + let stats = try await client.address(address: address, network: indexerNetwork, baseURL: baseURL) + let txCount = try transactionCount(stats) + let used = txCount > 0 + let discovered = BitcoinReceiveDiscoveredAddress( + address: address, + index: index, + path: path, + confirmedSats: stats.confirmedSats, + mempoolSats: stats.mempoolSats, + totalSats: stats.totalSats, + txCount: txCount, + used: used + ) + + addresses.append(discovered) + + if used { + lastUsedIndex = index + consecutiveUnused = 0 + } else { + consecutiveUnused += 1 + } + + index += 1 + } + + guard consecutiveUnused >= resolvedGapLimit else { + throw BitcoinReceiveDiscoveryError.lookaheadExhausted + } + + let nextReceiveIndex = (lastUsedIndex ?? -1) + 1 + let nextReceivePath = try BitcoinKeyDerivation.getReceivePath(network: network, index: UInt32(nextReceiveIndex)) + let nextReceiveAddress = try BitcoinKeyDerivation.deriveKey( + mnemonic: mnemonic, + passphrase: passphrase, + derivationPath: nextReceivePath, + network: network + ).address + + return BitcoinReceiveDiscoveryResult( + addresses: addresses, + gapLimit: resolvedGapLimit, + lastUsedIndex: lastUsedIndex, + nextReceiveAddress: nextReceiveAddress, + nextReceiveIndex: nextReceiveIndex, + usedAddresses: addresses.filter(\.used) + ) + } + + private func validateParams(mnemonic: String, gapLimit: Int, maxLookahead: Int) throws { + guard !mnemonic.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw BitcoinReceiveDiscoveryError.mnemonicRequired + } + guard gapLimit > 0, gapLimit <= Self.maxGapLimit else { + throw BitcoinReceiveDiscoveryError.invalidGapLimit + } + guard maxLookahead >= gapLimit, maxLookahead <= Self.maxLookahead else { + throw BitcoinReceiveDiscoveryError.invalidMaxLookahead + } + } + + private func transactionCount(_ address: BitcoinEsploraAddress) throws -> Int { + guard address.chainStats.txCount >= 0, address.mempoolStats.txCount >= 0 else { + throw BitcoinReceiveDiscoveryError.invalidTransactionCount + } + + let total = address.chainStats.txCount.addingReportingOverflow(address.mempoolStats.txCount) + guard !total.overflow, total.partialValue >= 0 else { + throw BitcoinReceiveDiscoveryError.invalidTransactionCount + } + + return total.partialValue + } + + private static func defaultGapLimit(for network: BitcoinKeyDerivation.Network) -> Int { + switch network { + case .mainnet: + return UniversalWalletRegistry.bitcoinMainnet.defaultGapLimit + case .testnet: + return UniversalWalletRegistry.bitcoinTestnet.defaultGapLimit + } + } +} + +struct BitcoinReceiveDiscoveredAddress: Equatable { + let address: String + let index: Int + let path: String + let confirmedSats: Int64 + let mempoolSats: Int64 + let totalSats: Int64 + let txCount: Int + let used: Bool +} + +struct BitcoinReceiveDiscoveryResult: Equatable { + let addresses: [BitcoinReceiveDiscoveredAddress] + let gapLimit: Int + let lastUsedIndex: Int? + let nextReceiveAddress: String + let nextReceiveIndex: Int + let usedAddresses: [BitcoinReceiveDiscoveredAddress] +} + +enum BitcoinReceiveDiscoveryError: Error, Equatable { + case mnemonicRequired + case invalidGapLimit + case invalidMaxLookahead + case invalidTransactionCount + case lookaheadExhausted +} + +private extension BitcoinKeyDerivation.Network { + var indexerNetwork: BitcoinIndexerNetwork { + switch self { + case .mainnet: + return .mainnet + case .testnet: + return .testnet + } + } +} diff --git a/fearless/Common/Model/BitcoinSendPlanner.swift b/fearless/Common/Model/BitcoinSendPlanner.swift new file mode 100644 index 0000000000..90029afb0c --- /dev/null +++ b/fearless/Common/Model/BitcoinSendPlanner.swift @@ -0,0 +1,184 @@ +import Foundation + +final class BitcoinSendPlanner { + private let client: BitcoinIndexerClientProtocol + private let feeEstimator: BitcoinFeeEstimator + private let utxoSelector: BitcoinUtxoSelector + + init( + client: BitcoinIndexerClientProtocol, + feeEstimator: BitcoinFeeEstimator? = nil, + utxoSelector: BitcoinUtxoSelector = BitcoinUtxoSelector() + ) { + self.client = client + self.feeEstimator = feeEstimator ?? BitcoinFeeEstimator(client: client) + self.utxoSelector = utxoSelector + } + + func plan( + amountSats: Int64, + sources: [BitcoinUtxoSource], + recipientAddress: String, + changeAddress: String? = nil, + feeRateSatPerVbyte: Double? = nil, + feeTargetBlocks: Int = BitcoinFeeEstimator.defaultTargetBlocks, + includeUnconfirmed: Bool = false, + maxInputs: Int = BitcoinUtxoSelector.defaultMaxInputs, + network: BitcoinIndexerNetwork = .mainnet, + baseURL: String? = nil + ) async throws -> BitcoinSendPlan { + let normalizedSources = try normalizeSources(sources, network: network) + let normalizedRecipient = try normalizeAddress( + recipientAddress, + network: network, + error: .invalidRecipientAddress + ) + let normalizedChange = try normalizeAddress( + changeAddress ?? normalizedSources[0].address, + network: network, + error: .invalidChangeAddress + ) + let resolvedFeeRate: Double + if let feeRateSatPerVbyte { + resolvedFeeRate = feeRateSatPerVbyte + } else { + resolvedFeeRate = try await feeEstimator.estimate( + network: network, + baseURL: baseURL, + targetBlocks: feeTargetBlocks + ).feeRateSatPerVbyte + } + try validateFeeRate(resolvedFeeRate) + + let spendableUtxos = try await normalizedSources.asyncFlatMap { source in + try utxoSelector.spendableUtxos( + source: source, + utxos: try await client.utxos(address: source.address, network: network, baseURL: baseURL), + network: network, + includeUnconfirmed: includeUnconfirmed + ) + } + + guard !spendableUtxos.isEmpty else { + throw BitcoinSendPlannerError.noSpendableUtxos + } + + let selection = try utxoSelector.select( + amountSats: amountSats, + changeAddress: normalizedChange, + feeRateSatPerVbyte: resolvedFeeRate, + maxInputs: maxInputs, + network: network, + utxos: spendableUtxos + ) + + return BitcoinSendPlan( + amountSats: amountSats, + recipientAddress: normalizedRecipient, + changeAddress: selection.changeAddress, + feeRateSatPerVbyte: resolvedFeeRate, + feeTargetBlocks: feeRateSatPerVbyte == nil ? feeTargetBlocks : nil, + includeUnconfirmed: includeUnconfirmed, + network: network, + sourceAddresses: normalizedSources.map(\.address), + selectedUtxos: selection.selectedUtxos, + inputTotalSats: selection.inputTotalSats, + feeSats: selection.feeSats, + changeSats: selection.changeSats, + absorbedDustSats: selection.absorbedDustSats + ) + } + + private func normalizeSources( + _ sources: [BitcoinUtxoSource], + network: BitcoinIndexerNetwork + ) throws -> [BitcoinUtxoSource] { + guard !sources.isEmpty else { + throw BitcoinSendPlannerError.sourcesRequired + } + guard sources.count <= Self.maxSources else { + throw BitcoinSendPlannerError.tooManySources + } + var seen = Set() + + return try sources.map { source in + let address = try normalizeAddress(source.address, network: network, error: .invalidSourceAddress) + if let derivationPath = source.derivationPath { + try validateDerivationPath(derivationPath) + } + guard seen.insert(address.lowercased()).inserted else { + throw BitcoinSendPlannerError.duplicateSourceAddress + } + + return BitcoinUtxoSource(address: address, derivationPath: source.derivationPath) + } + } + + private func normalizeAddress( + _ address: String, + network: BitcoinIndexerNetwork, + error plannerError: BitcoinSendPlannerError + ) throws -> String { + do { + return try BitcoinIndexerRoutes.normalizeAddress(address, network: network) + } catch { + throw plannerError + } + } + + private func validateFeeRate(_ feeRateSatPerVbyte: Double) throws { + guard + feeRateSatPerVbyte.isFinite, + feeRateSatPerVbyte > 0, + feeRateSatPerVbyte <= BitcoinFeeEstimator.maxFeeRateSatPerVbyte + else { + throw BitcoinSendPlannerError.invalidFeeRate + } + } + + private func validateDerivationPath(_ derivationPath: String) throws { + guard derivationPath.range(of: "^m(?:/\\d+'?)+$", options: .regularExpression) != nil else { + throw BitcoinSendPlannerError.invalidDerivationPath + } + } + + private static let maxSources = 100 +} + +struct BitcoinSendPlan: Equatable { + let amountSats: Int64 + let recipientAddress: String + let changeAddress: String? + let feeRateSatPerVbyte: Double + let feeTargetBlocks: Int? + let includeUnconfirmed: Bool + let network: BitcoinIndexerNetwork + let sourceAddresses: [String] + let selectedUtxos: [BitcoinSpendableUtxo] + let inputTotalSats: Int64 + let feeSats: Int64 + let changeSats: Int64 + let absorbedDustSats: Int64 +} + +enum BitcoinSendPlannerError: Error, Equatable { + case duplicateSourceAddress + case invalidChangeAddress + case invalidDerivationPath + case invalidFeeRate + case invalidRecipientAddress + case invalidSourceAddress + case noSpendableUtxos + case sourcesRequired + case tooManySources +} + +private extension Array { + func asyncFlatMap(_ transform: (Element) async throws -> [T]) async throws -> [T] { + var result: [T] = [] + for element in self { + result.append(contentsOf: try await transform(element)) + } + return result + } +} diff --git a/fearless/Common/Model/BitcoinSendService.swift b/fearless/Common/Model/BitcoinSendService.swift new file mode 100644 index 0000000000..6c90c77974 --- /dev/null +++ b/fearless/Common/Model/BitcoinSendService.swift @@ -0,0 +1,141 @@ +import Foundation + +final class BitcoinSendService { + private let planner: BitcoinSendPlanner + private let broadcaster: BitcoinTransactionBroadcaster + + init( + planner: BitcoinSendPlanner, + broadcaster: BitcoinTransactionBroadcaster + ) { + self.planner = planner + self.broadcaster = broadcaster + } + + convenience init(client: BitcoinIndexerClientProtocol) { + self.init( + planner: BitcoinSendPlanner(client: client), + broadcaster: BitcoinTransactionBroadcaster(client: client) + ) + } + + func prepare(_ request: BitcoinSendRequest) async throws -> BitcoinPreparedSendTransaction { + let plan = try await planner.plan( + amountSats: request.amountSats, + sources: request.sources, + recipientAddress: request.recipientAddress, + changeAddress: request.changeAddress, + feeRateSatPerVbyte: request.feeRateSatPerVbyte, + feeTargetBlocks: request.feeTargetBlocks, + includeUnconfirmed: request.includeUnconfirmed, + maxInputs: request.maxInputs, + network: request.network, + baseURL: request.baseURL + ) + let transaction = try BitcoinTransactionBuilder.buildP2wpkhTransaction( + mnemonic: request.mnemonic, + passphrase: request.passphrase, + inputs: plan.selectedUtxos, + outputs: [BitcoinPaymentOutput(address: plan.recipientAddress, valueSats: plan.amountSats)], + changeAddress: plan.changeAddress, + feeSats: plan.feeSats, + network: request.network.keyDerivationNetwork + ) + + guard transaction.feeSats == plan.feeSats, transaction.changeSats == plan.changeSats else { + throw BitcoinSendServiceError.plannedTransactionMismatch + } + + return BitcoinPreparedSendTransaction( + plan: plan, + transaction: transaction + ) + } + + func send(_ request: BitcoinSendRequest) async throws -> BitcoinSentTransaction { + let prepared = try await prepare(request) + let broadcast = try await broadcaster.broadcast( + txHex: prepared.transaction.txHex, + network: request.network, + baseURL: request.baseURL + ) + + guard broadcast.txid == prepared.transaction.txid else { + throw BitcoinSendServiceError.broadcastTxidMismatch + } + + return BitcoinSentTransaction( + broadcastTxid: broadcast.txid, + prepared: prepared + ) + } +} + +struct BitcoinSendRequest: Equatable { + let mnemonic: String + let amountSats: Int64 + let sources: [BitcoinUtxoSource] + let recipientAddress: String + let passphrase: String + let changeAddress: String? + let feeRateSatPerVbyte: Double? + let feeTargetBlocks: Int + let includeUnconfirmed: Bool + let maxInputs: Int + let network: BitcoinIndexerNetwork + let baseURL: String? + + init( + mnemonic: String, + amountSats: Int64, + sources: [BitcoinUtxoSource], + recipientAddress: String, + passphrase: String = "", + changeAddress: String? = nil, + feeRateSatPerVbyte: Double? = nil, + feeTargetBlocks: Int = BitcoinFeeEstimator.defaultTargetBlocks, + includeUnconfirmed: Bool = false, + maxInputs: Int = BitcoinUtxoSelector.defaultMaxInputs, + network: BitcoinIndexerNetwork = .mainnet, + baseURL: String? = nil + ) { + self.mnemonic = mnemonic + self.amountSats = amountSats + self.sources = sources + self.recipientAddress = recipientAddress + self.passphrase = passphrase + self.changeAddress = changeAddress + self.feeRateSatPerVbyte = feeRateSatPerVbyte + self.feeTargetBlocks = feeTargetBlocks + self.includeUnconfirmed = includeUnconfirmed + self.maxInputs = maxInputs + self.network = network + self.baseURL = baseURL + } +} + +struct BitcoinPreparedSendTransaction: Equatable { + let plan: BitcoinSendPlan + let transaction: BitcoinBuiltTransaction +} + +struct BitcoinSentTransaction: Equatable { + let broadcastTxid: String + let prepared: BitcoinPreparedSendTransaction +} + +enum BitcoinSendServiceError: Error, Equatable { + case broadcastTxidMismatch + case plannedTransactionMismatch +} + +private extension BitcoinIndexerNetwork { + var keyDerivationNetwork: BitcoinKeyDerivation.Network { + switch self { + case .mainnet: + return .mainnet + case .testnet: + return .testnet + } + } +} diff --git a/fearless/Common/Model/BitcoinTransactionBroadcaster.swift b/fearless/Common/Model/BitcoinTransactionBroadcaster.swift new file mode 100644 index 0000000000..77e5fe1d0e --- /dev/null +++ b/fearless/Common/Model/BitcoinTransactionBroadcaster.swift @@ -0,0 +1,51 @@ +import Foundation + +final class BitcoinTransactionBroadcaster { + private let client: BitcoinIndexerClientProtocol + + init(client: BitcoinIndexerClientProtocol) { + self.client = client + } + + func broadcast( + txHex: String, + network: BitcoinIndexerNetwork = .mainnet, + baseURL: String? = nil + ) async throws -> BitcoinBroadcastResult { + let normalizedTxHex: String + do { + normalizedTxHex = try BitcoinIndexerRoutes.normalizeBroadcastTransactionBody(txHex) + } catch { + throw BitcoinBroadcastError.invalidTxHex + } + + let txid = try await client.broadcastTransaction( + txHex: normalizedTxHex, + network: network, + baseURL: baseURL + ) + let normalizedTxid: String + do { + normalizedTxid = try BitcoinIndexerRoutes.normalizeTxid(txid) + } catch { + throw BitcoinBroadcastError.invalidTxidResponse + } + + return BitcoinBroadcastResult( + network: network, + txHex: normalizedTxHex, + txid: normalizedTxid + ) + } +} + +struct BitcoinBroadcastResult: Equatable { + let network: BitcoinIndexerNetwork + let txHex: String + let txid: String +} + +enum BitcoinBroadcastError: Error, Equatable { + case invalidTxHex + case invalidTxidResponse +} diff --git a/fearless/Common/Model/BitcoinTransactionBuilder.swift b/fearless/Common/Model/BitcoinTransactionBuilder.swift new file mode 100644 index 0000000000..4013e9a3e8 --- /dev/null +++ b/fearless/Common/Model/BitcoinTransactionBuilder.swift @@ -0,0 +1,617 @@ +import CommonCrypto +import Foundation +import secp256k1 + +enum BitcoinTransactionBuilder { + private static let defaultSequence = UInt32.max + private static let sighashAll: UInt32 = 0x01 + private static let txLocktime: UInt32 = 0 + private static let txVersion: UInt32 = 2 + private static let maxUInt32 = Int64(UInt32.max) + private static let bech32Alphabet = Array("qpzry9x8gf2tvdw0s3jn54khce6mua7l") + private static let bech32Generators = [ + 0x3B6A_57B2, + 0x2650_8E6D, + 0x1EA1_19FA, + 0x3D42_33DD, + 0x2A14_62B3 + ] + + static func buildP2wpkhTransaction( + mnemonic: String, + passphrase: String = "", + inputs: [BitcoinSpendableUtxo], + outputs: [BitcoinPaymentOutput], + changeAddress: String? = nil, + feeRateSatPerVbyte: Double? = nil, + feeSats: Int64? = nil, + network: BitcoinKeyDerivation.Network = .mainnet + ) throws -> BitcoinBuiltTransaction { + let normalizedInputs = try normalizeInputs(inputs) + let normalizedOutputs = try normalizeOutputs(outputs, network: network) + let inputTotal = try sumSats(normalizedInputs.map(\.valueSats)) + let outputTotal = try sumSats(normalizedOutputs.map(\.valueSats)) + let fee = try normalizeFee( + feeSats: feeSats, + feeRateSatPerVbyte: feeRateSatPerVbyte, + inputCount: normalizedInputs.count, + outputCount: normalizedOutputs.count + (changeAddress == nil ? 0 : 1) + ) + let change = inputTotal - outputTotal - fee + + guard change >= 0 else { + throw BitcoinTransactionError.insufficientFunds + } + + var finalOutputs = normalizedOutputs + if change > 0 { + guard let changeAddress else { + throw BitcoinTransactionError.changeAddressRequired + } + guard change >= BitcoinUtxoSelector.bitcoinP2wpkhDustSats else { + throw BitcoinTransactionError.changeBelowDust + } + finalOutputs.append(try normalizeOutput(BitcoinPaymentOutput(address: changeAddress, valueSats: change), network: network)) + } + + let signingInputs = try normalizedInputs.map { input in + let derivationPath: String + if let inputDerivationPath = input.derivationPath { + derivationPath = inputDerivationPath + } else { + derivationPath = try BitcoinKeyDerivation.getReceivePath(network: network) + } + let key: BitcoinKeyDerivation.DerivedKey + do { + key = try BitcoinKeyDerivation.deriveKey( + mnemonic: mnemonic, + passphrase: passphrase, + derivationPath: derivationPath, + network: network + ) + } catch BitcoinKeyDerivation.DerivationError.emptyMnemonic { + throw BitcoinTransactionError.invalidMnemonic + } catch { + throw BitcoinTransactionError.invalidDerivationPath + } + + let witnessScript = try p2wpkhOutputScript(fromAddress: key.address) + + if let address = input.address, address.lowercased() != key.address.lowercased() { + throw BitcoinTransactionError.utxoAddressMismatch + } + if let scriptPubKey = input.scriptPubKey, scriptPubKey.lowercased() != witnessScript.hexString { + throw BitcoinTransactionError.utxoScriptMismatch + } + + return BitcoinSigningInput( + privateKey: key.privateKey, + publicKey: key.publicKey, + scriptCode: try p2wpkhScriptCode(fromWitnessScript: witnessScript), + txid: input.txid, + valueSats: input.valueSats, + vout: input.vout + ) + } + let serializedOutputs = try finalOutputs.map { + BitcoinSerializedOutput(script: try p2wpkhOutputScript(fromAddress: $0.address), valueSats: $0.valueSats) + } + let witnesses = try signingInputs.map { try signP2wpkhInput(inputs: signingInputs, outputs: serializedOutputs, input: $0) } + let baseTransaction = try serializeTransaction(inputs: signingInputs, outputs: serializedOutputs) + let witnessTransaction = try serializeTransaction(inputs: signingInputs, outputs: serializedOutputs, witnesses: witnesses) + + return BitcoinBuiltTransaction( + changeSats: change, + feeSats: fee, + inputTotalSats: inputTotal, + outputTotalSats: outputTotal, + txHex: witnessTransaction.hexString, + txid: Data(doubleSha256(baseTransaction).reversed()).hexString, + vsize: Int(ceil(Double(baseTransaction.count * 3 + witnessTransaction.count) / 4.0)) + ) + } + + static func estimateP2wpkhTransactionVSize(inputCount: Int, outputCount: Int) throws -> Int { + try BitcoinUtxoSelector().estimateP2wpkhTransactionVSize(inputCount: inputCount, outputCount: outputCount) + } + + private static func normalizeInputs(_ inputs: [BitcoinSpendableUtxo]) throws -> [BitcoinSpendableUtxo] { + guard !inputs.isEmpty else { + throw BitcoinTransactionError.inputsRequired + } + var outpoints = Set() + + return try inputs.map { input in + let normalizedTxid = input.txid.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard normalizedTxid.range(of: "^[0-9a-f]{64}$", options: .regularExpression) != nil else { + throw BitcoinTransactionError.invalidTxid + } + guard input.vout >= 0, input.vout <= maxUInt32 else { + throw BitcoinTransactionError.invalidVout + } + guard input.valueSats > 0, input.valueSats <= BitcoinUtxoSelector.maxSatoshi else { + throw BitcoinTransactionError.invalidUtxoValue + } + if let scriptPubKey = input.scriptPubKey { + guard scriptPubKey.range(of: "^(?:[0-9a-fA-F]{2})+$", options: .regularExpression) != nil else { + throw BitcoinTransactionError.invalidScriptPubKey + } + } + if let derivationPath = input.derivationPath { + guard derivationPath.range(of: "^m(?:/\\d+'?)+$", options: .regularExpression) != nil else { + throw BitcoinTransactionError.invalidDerivationPath + } + } + + let outpoint = "\(normalizedTxid):\(input.vout)" + guard outpoints.insert(outpoint).inserted else { + throw BitcoinTransactionError.duplicateUtxo + } + + return BitcoinSpendableUtxo( + address: input.address, + derivationPath: input.derivationPath, + scriptPubKey: input.scriptPubKey, + txid: normalizedTxid, + valueSats: input.valueSats, + vout: input.vout + ) + } + } + + private static func normalizeOutputs(_ outputs: [BitcoinPaymentOutput], network: BitcoinKeyDerivation.Network) throws -> [BitcoinPaymentOutput] { + guard !outputs.isEmpty else { + throw BitcoinTransactionError.outputsRequired + } + + return try outputs.map { try normalizeOutput($0, network: network) } + } + + private static func normalizeOutput(_ output: BitcoinPaymentOutput, network: BitcoinKeyDerivation.Network) throws -> BitcoinPaymentOutput { + let normalizedAddress: String + do { + normalizedAddress = try BitcoinIndexerRoutes.normalizeAddress(output.address, network: network.indexerNetwork) + } catch { + throw BitcoinTransactionError.invalidOutputAddress + } + guard + output.valueSats >= BitcoinUtxoSelector.bitcoinP2wpkhDustSats, + output.valueSats <= BitcoinUtxoSelector.maxSatoshi + else { + throw BitcoinTransactionError.invalidOutputValue + } + + return BitcoinPaymentOutput(address: normalizedAddress, valueSats: output.valueSats) + } + + private static func normalizeFee( + feeSats: Int64?, + feeRateSatPerVbyte: Double?, + inputCount: Int, + outputCount: Int + ) throws -> Int64 { + if let feeSats { + guard feeSats > 0, feeSats <= BitcoinUtxoSelector.maxSatoshi else { + throw BitcoinTransactionError.invalidFee + } + + return feeSats + } + + guard let feeRateSatPerVbyte else { + throw BitcoinTransactionError.feeRequired + } + guard + feeRateSatPerVbyte.isFinite, + feeRateSatPerVbyte > 0, + feeRateSatPerVbyte <= BitcoinUtxoSelector.maxFeeRateSatPerVbyte + else { + throw BitcoinTransactionError.invalidFeeRate + } + + return Int64(ceil(Double(try estimateP2wpkhTransactionVSize(inputCount: inputCount, outputCount: outputCount)) * feeRateSatPerVbyte)) + } + + private static func signP2wpkhInput( + inputs: [BitcoinSigningInput], + outputs: [BitcoinSerializedOutput], + input: BitcoinSigningInput + ) throws -> [Data] { + let sighash = try bip143SignatureHash(inputs: inputs, outputs: outputs, input: input) + let signature = try secp256k1Sign(sighash: sighash, privateKey: input.privateKey) + Data([UInt8(sighashAll)]) + + return [signature, input.publicKey] + } + + private static func secp256k1Sign(sighash: Data, privateKey: Data) throws -> Data { + guard sighash.count == 32, privateKey.count == 32 else { + throw BitcoinTransactionError.signingFailed + } + guard let context = secp256k1_context_create(UInt32(SECP256K1_CONTEXT_SIGN)) else { + throw BitcoinTransactionError.signingFailed + } + defer { + secp256k1_context_destroy(context) + } + + var signature = secp256k1_ecdsa_signature() + let signResult = sighash.withUnsafeBytes { sighashBytes in + privateKey.withUnsafeBytes { privateKeyBytes in + guard + let sighashPointer = sighashBytes.baseAddress?.assumingMemoryBound(to: UInt8.self), + let privateKeyPointer = privateKeyBytes.baseAddress?.assumingMemoryBound(to: UInt8.self) + else { + return 0 + } + + return Int(secp256k1_ecdsa_sign(context, &signature, sighashPointer, privateKeyPointer, nil, nil)) + } + } + + guard signResult != 0 else { + throw BitcoinTransactionError.signingFailed + } + + var normalizedSignature = secp256k1_ecdsa_signature() + secp256k1_ecdsa_signature_normalize(context, &normalizedSignature, &signature) + + var derSignature = [UInt8](repeating: 0, count: 72) + var derLength = derSignature.count + let serializeResult = derSignature.withUnsafeMutableBufferPointer { derBuffer in + withUnsafeMutablePointer(to: &derLength) { derLengthPointer in + withUnsafePointer(to: &normalizedSignature) { signaturePointer in + guard let derPointer = derBuffer.baseAddress else { + return 0 + } + + return Int(secp256k1_ecdsa_signature_serialize_der(context, derPointer, derLengthPointer, signaturePointer)) + } + } + } + + guard serializeResult != 0 else { + throw BitcoinTransactionError.signingFailed + } + + return Data(derSignature.prefix(derLength)) + } + + private static func bip143SignatureHash( + inputs: [BitcoinSigningInput], + outputs: [BitcoinSerializedOutput], + input: BitcoinSigningInput + ) throws -> Data { + var payload = Data() + payload.append(uint32LE(txVersion)) + payload.append(doubleSha256(try concat(inputs.map { try serializeOutpoint($0) }))) + payload.append(doubleSha256(concat(inputs.map { _ in uint32LE(defaultSequence) }))) + payload.append(try serializeOutpoint(input)) + payload.append(varSlice(input.scriptCode)) + payload.append(try uint64LE(input.valueSats)) + payload.append(uint32LE(defaultSequence)) + payload.append(doubleSha256(try concat(outputs.map { try serializeOutput($0) }))) + payload.append(uint32LE(txLocktime)) + payload.append(uint32LE(sighashAll)) + + return doubleSha256(payload) + } + + private static func serializeTransaction( + inputs: [BitcoinSigningInput], + outputs: [BitcoinSerializedOutput], + witnesses: [[Data]]? = nil + ) throws -> Data { + var transaction = Data() + transaction.append(uint32LE(txVersion)) + if witnesses != nil { + transaction.append(contentsOf: [0x00, 0x01]) + } + transaction.append(varInt(UInt64(inputs.count))) + transaction.append(try concat(inputs.map { try serializeInput($0) })) + transaction.append(varInt(UInt64(outputs.count))) + transaction.append(try concat(outputs.map { try serializeOutput($0) })) + if let witnesses { + transaction.append(concat(witnesses.map(serializeWitness))) + } + transaction.append(uint32LE(txLocktime)) + + return transaction + } + + private static func serializeInput(_ input: BitcoinSigningInput) throws -> Data { + var serialized = try serializeOutpoint(input) + serialized.append(varSlice(Data())) + serialized.append(uint32LE(defaultSequence)) + + return serialized + } + + private static func serializeOutpoint(_ input: BitcoinSigningInput) throws -> Data { + var outpoint = try Data(bitcoinHex: input.txid).reversedData() + outpoint.append(uint32LE(UInt32(input.vout))) + + return outpoint + } + + private static func serializeOutput(_ output: BitcoinSerializedOutput) throws -> Data { + var serialized = try uint64LE(output.valueSats) + serialized.append(varSlice(output.script)) + + return serialized + } + + private static func serializeWitness(_ witness: [Data]) -> Data { + var serialized = varInt(UInt64(witness.count)) + serialized.append(concat(witness.map(varSlice))) + + return serialized + } + + private static func p2wpkhOutputScript(fromAddress address: String) throws -> Data { + let decoded = try bech32Decode(address) + let program = try Data(convertBits(Array(decoded.words.dropFirst()), fromBits: 5, toBits: 8, pad: false).map(UInt8.init)) + + guard decoded.words.first == 0, program.count == 20 else { + throw BitcoinTransactionError.invalidOutputAddress + } + + return Data([0x00, 0x14]) + program + } + + private static func p2wpkhScriptCode(fromWitnessScript witnessScript: Data) throws -> Data { + guard witnessScript.count == 22, witnessScript.first == 0, witnessScript.dropFirst().first == 0x14 else { + throw BitcoinTransactionError.utxoScriptMismatch + } + + return Data([0x76, 0xA9, 0x14]) + witnessScript.suffix(20) + Data([0x88, 0xAC]) + } + + private static func bech32Decode(_ address: String) throws -> Bech32Decoded { + guard address == address.lowercased() || address == address.uppercased() else { + throw BitcoinTransactionError.invalidOutputAddress + } + let normalized = address.lowercased() + guard + let separator = normalized.lastIndex(of: "1"), + separator != normalized.startIndex, + normalized.distance(from: separator, to: normalized.endIndex) >= 7 + else { + throw BitcoinTransactionError.invalidOutputAddress + } + + let hrp = String(normalized[.. Int in + guard let index = bech32Alphabet.firstIndex(of: character) else { + throw BitcoinTransactionError.invalidOutputAddress + } + + return index + } + + guard bech32Polymod(expandHrp(hrp) + values) == 1 else { + throw BitcoinTransactionError.invalidOutputAddress + } + + return Bech32Decoded(words: Array(values.dropLast(6))) + } + + private static func bech32Polymod(_ values: [Int]) -> Int { + var checksum = 1 + + for value in values { + let top = checksum >> 25 + checksum = ((checksum & 0x1FFFFFF) << 5) ^ value + + for (index, generator) in bech32Generators.enumerated() where ((top >> index) & 1) == 1 { + checksum ^= generator + } + } + + return checksum + } + + private static func expandHrp(_ hrp: String) -> [Int] { + hrp.map { Int($0.asciiValue ?? 0) >> 5 } + [0] + hrp.map { Int($0.asciiValue ?? 0) & 31 } + } + + private static func convertBits(_ values: [Int], fromBits: Int, toBits: Int, pad: Bool) throws -> [Int] { + var accumulator = 0 + var bits = 0 + let maxValue = (1 << toBits) - 1 + let maxAccumulator = (1 << (fromBits + toBits - 1)) - 1 + var result: [Int] = [] + + for value in values { + guard value >= 0, value >> fromBits == 0 else { + throw BitcoinTransactionError.invalidOutputAddress + } + accumulator = ((accumulator << fromBits) | value) & maxAccumulator + bits += fromBits + + while bits >= toBits { + bits -= toBits + result.append((accumulator >> bits) & maxValue) + } + } + + if pad { + if bits > 0 { + result.append((accumulator << (toBits - bits)) & maxValue) + } + } else if bits >= fromBits || ((accumulator << (toBits - bits)) & maxValue) != 0 { + throw BitcoinTransactionError.invalidOutputAddress + } + + return result + } + + private static func doubleSha256(_ data: Data) -> Data { + data.sha256().sha256() + } + + private static func varSlice(_ value: Data) -> Data { + var serialized = varInt(UInt64(value.count)) + serialized.append(value) + + return serialized + } + + private static func varInt(_ value: UInt64) -> Data { + switch value { + case 0 ..< 0xFD: + return Data([UInt8(value)]) + case 0xFD ... 0xFFFF: + return Data([0xFD]) + uint16LE(UInt16(value)) + case 0x10000 ... 0xFFFF_FFFF: + return Data([0xFE]) + uint32LE(UInt32(value)) + default: + return Data([0xFF]) + uint64LE(value) + } + } + + private static func uint16LE(_ value: UInt16) -> Data { + var littleEndian = value.littleEndian + return withUnsafeBytes(of: &littleEndian) { Data($0) } + } + + private static func uint32LE(_ value: UInt32) -> Data { + var littleEndian = value.littleEndian + return withUnsafeBytes(of: &littleEndian) { Data($0) } + } + + private static func uint64LE(_ value: Int64) throws -> Data { + guard value >= 0 else { + throw BitcoinTransactionError.invalidUtxoValue + } + + return uint64LE(UInt64(value)) + } + + private static func uint64LE(_ value: UInt64) -> Data { + var littleEndian = value.littleEndian + return withUnsafeBytes(of: &littleEndian) { Data($0) } + } + + private static func sumSats(_ values: [Int64]) throws -> Int64 { + try values.reduce(0) { total, value in + let sum = total.addingReportingOverflow(value) + guard !sum.overflow, sum.partialValue <= BitcoinUtxoSelector.maxSatoshi else { + throw BitcoinTransactionError.satoshiOverflow + } + + return sum.partialValue + } + } + + private static func concat(_ chunks: [Data]) -> Data { + chunks.reduce(into: Data()) { $0.append($1) } + } +} + +struct BitcoinPaymentOutput: Equatable { + let address: String + let valueSats: Int64 +} + +struct BitcoinBuiltTransaction: Equatable { + let changeSats: Int64 + let feeSats: Int64 + let inputTotalSats: Int64 + let outputTotalSats: Int64 + let txHex: String + let txid: String + let vsize: Int +} + +enum BitcoinTransactionError: Error, Equatable { + case changeAddressRequired + case changeBelowDust + case duplicateUtxo + case feeRequired + case inputsRequired + case insufficientFunds + case invalidDerivationPath + case invalidFee + case invalidFeeRate + case invalidMnemonic + case invalidOutputAddress + case invalidOutputValue + case invalidScriptPubKey + case invalidTxid + case invalidUtxoValue + case invalidVarInt + case invalidVout + case outputsRequired + case satoshiOverflow + case signingFailed + case utxoAddressMismatch + case utxoScriptMismatch +} + +private struct BitcoinSigningInput { + let privateKey: Data + let publicKey: Data + let scriptCode: Data + let txid: String + let valueSats: Int64 + let vout: Int64 +} + +private struct BitcoinSerializedOutput { + let script: Data + let valueSats: Int64 +} + +private struct Bech32Decoded { + let words: [Int] +} + +private extension BitcoinKeyDerivation.Network { + var indexerNetwork: BitcoinIndexerNetwork { + switch self { + case .mainnet: + return .mainnet + case .testnet: + return .testnet + } + } +} + +private extension Data { + init(bitcoinHex: String) throws { + guard bitcoinHex.count.isMultiple(of: 2) else { + throw BitcoinTransactionError.invalidTxid + } + + var output = Data(capacity: bitcoinHex.count / 2) + var index = bitcoinHex.startIndex + + while index < bitcoinHex.endIndex { + let nextIndex = bitcoinHex.index(index, offsetBy: 2) + guard let byte = UInt8(bitcoinHex[index ..< nextIndex], radix: 16) else { + throw BitcoinTransactionError.invalidTxid + } + output.append(byte) + index = nextIndex + } + + self = output + } + + var hexString: String { + map { String(format: "%02x", $0) }.joined() + } + + func reversedData() -> Data { + Data(reversed()) + } + + func sha256() -> Data { + var output = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH)) + withUnsafeBytes { dataBytes in + _ = CC_SHA256(dataBytes.baseAddress, CC_LONG(count), &output) + } + + return Data(output) + } +} diff --git a/fearless/Common/Model/BitcoinTransactionHistorySync.swift b/fearless/Common/Model/BitcoinTransactionHistorySync.swift new file mode 100644 index 0000000000..9b71219ae8 --- /dev/null +++ b/fearless/Common/Model/BitcoinTransactionHistorySync.swift @@ -0,0 +1,247 @@ +import Foundation + +final class BitcoinTransactionHistorySync { + private let client: BitcoinIndexerClientProtocol + + init(client: BitcoinIndexerClientProtocol) { + self.client = client + } + + func historyWindow( + address: String, + network: BitcoinIndexerNetwork = .mainnet, + baseURL: String? = nil, + lastSeenTxid: String? = nil + ) async throws -> BitcoinTransactionHistoryPage { + let normalizedAddress = try normalizeHistoryAddress(address, network: network) + var entries: [BitcoinTransactionHistoryEntry] = [] + var seenTxids = Set() + var seenCursors = Set() + var cursor = lastSeenTxid + var nextLastSeenTxid: String? + var pagesFetched = 0 + + while pagesFetched < bitcoinHistoryMaxPages { + let currentCursor = cursor + if let currentCursor, !seenCursors.insert(currentCursor).inserted { + break + } + + let page = try await fetchHistoryPage( + address: normalizedAddress, + network: network, + baseURL: baseURL, + lastSeenTxid: currentCursor, + mempool: false + ) + pagesFetched += 1 + + var newEntries = 0 + for entry in page.history.entries where seenTxids.insert(entry.txid).inserted { + entries.append(entry) + newEntries += 1 + } + + nextLastSeenTxid = page.history.nextLastSeenTxid + + guard + page.transactionCount >= bitcoinEsploraHistoryPageSize, + let nextCursor = nextLastSeenTxid, + nextCursor != currentCursor, + newEntries > 0 + else { + break + } + + cursor = nextCursor + } + + return BitcoinTransactionHistoryPage( + address: normalizedAddress, + network: network, + entries: entries, + nextLastSeenTxid: nextLastSeenTxid + ) + } + + func history( + address: String, + network: BitcoinIndexerNetwork = .mainnet, + baseURL: String? = nil, + lastSeenTxid: String? = nil, + mempool: Bool = false + ) async throws -> BitcoinTransactionHistoryPage { + let normalizedAddress = try normalizeHistoryAddress(address, network: network) + return try await fetchHistoryPage( + address: normalizedAddress, + network: network, + baseURL: baseURL, + lastSeenTxid: lastSeenTxid, + mempool: mempool + ).history + } + + private func fetchHistoryPage( + address: String, + network: BitcoinIndexerNetwork, + baseURL: String?, + lastSeenTxid: String?, + mempool: Bool + ) async throws -> BitcoinFetchedHistoryPage { + let transactions = try await client.transactions( + address: address, + network: network, + baseURL: baseURL, + lastSeenTxid: lastSeenTxid, + mempool: mempool + ) + let entries = transactions.compactMap { normalizeTransaction($0, address: address) } + let nextLastSeenTxid = transactions.reversed().compactMap { transaction in + try? BitcoinIndexerRoutes.normalizeTxid(transaction.txid) + }.first + + return BitcoinFetchedHistoryPage( + history: BitcoinTransactionHistoryPage( + address: address, + network: network, + entries: entries, + nextLastSeenTxid: nextLastSeenTxid + ), + transactionCount: transactions.count + ) + } + + private func normalizeHistoryAddress( + _ address: String, + network: BitcoinIndexerNetwork + ) throws -> String { + do { + return try BitcoinIndexerRoutes.normalizeAddress(address, network: network) + } catch { + throw BitcoinTransactionHistoryError.invalidAddress + } + } + + private func normalizeTransaction( + _ transaction: BitcoinEsploraTransaction, + address: String + ) -> BitcoinTransactionHistoryEntry? { + guard let txid = try? BitcoinIndexerRoutes.normalizeTxid(transaction.txid) else { + return nil + } + let walletAddress = address.lowercased() + guard + let sent = sumWalletValues(transaction.vin.compactMap(\.prevout), walletAddress: walletAddress), + let received = sumWalletValues(transaction.vout, walletAddress: walletAddress), + sent > 0 || received > 0 + else { + return nil + } + + let isOutgoing = sent > 0 + let fee = transaction.fee ?? 0 + guard fee >= 0 else { + return nil + } + let amount = isOutgoing ? sent - received - fee : received + guard amount > 0, amount <= BitcoinUtxoSelector.maxSatoshi else { + return nil + } + let inputs = transaction.vin.compactMap { $0.prevout?.parsedOutput() } + let outputs = transaction.vout.compactMap { $0.parsedOutput() } + let counterparty = isOutgoing + ? outputs.first { $0.address.lowercased() != walletAddress && $0.valueSats > 0 }?.address + : inputs.first { $0.address.lowercased() != walletAddress && $0.valueSats > 0 }?.address + guard let counterparty else { + return nil + } + + return BitcoinTransactionHistoryEntry( + address: address, + amountSats: amount, + blockHash: transaction.status.blockHash ?? txid, + blockHeight: transaction.status.blockHeight, + confirmed: transaction.status.confirmed, + feeSats: isOutgoing ? fee : 0, + from: isOutgoing ? address : counterparty, + outgoing: isOutgoing, + timestamp: transaction.status.blockTime ?? 0, + to: isOutgoing ? counterparty : address, + txid: txid + ) + } + + private func sumWalletValues( + _ outputs: [BitcoinEsploraTransactionOutput], + walletAddress: String + ) -> Int64? { + outputs.reduce(Int64?.some(0)) { result, output -> Int64? in + guard let total = result else { + return nil + } + guard output.scriptPubKeyAddress?.lowercased() == walletAddress else { + return total + } + guard let parsed = output.parsedOutput() else { + return nil + } + let sum = total.addingReportingOverflow(parsed.valueSats) + guard !sum.overflow, sum.partialValue <= BitcoinUtxoSelector.maxSatoshi else { + return nil + } + + return sum.partialValue + } + } +} + +private let bitcoinEsploraHistoryPageSize = 25 +private let bitcoinHistoryMaxPages = 12 + +private struct BitcoinFetchedHistoryPage { + let history: BitcoinTransactionHistoryPage + let transactionCount: Int +} + +struct BitcoinTransactionHistoryPage: Equatable { + let address: String + let network: BitcoinIndexerNetwork + let entries: [BitcoinTransactionHistoryEntry] + let nextLastSeenTxid: String? +} + +struct BitcoinTransactionHistoryEntry: Equatable { + let address: String + let amountSats: Int64 + let blockHash: String + let blockHeight: Int64? + let confirmed: Bool + let feeSats: Int64 + let from: String + let outgoing: Bool + let timestamp: Int64 + let to: String + let txid: String +} + +enum BitcoinTransactionHistoryError: Error, Equatable { + case invalidAddress +} + +private struct BitcoinParsedTransactionOutput { + let address: String + let valueSats: Int64 +} + +private extension BitcoinEsploraTransactionOutput { + func parsedOutput() -> BitcoinParsedTransactionOutput? { + guard let address = scriptPubKeyAddress, !address.isEmpty, let value else { + return nil + } + guard value >= 0, value <= BitcoinUtxoSelector.maxSatoshi else { + return nil + } + + return BitcoinParsedTransactionOutput(address: address, valueSats: value) + } +} diff --git a/fearless/Common/Model/BitcoinUtxoSelector.swift b/fearless/Common/Model/BitcoinUtxoSelector.swift new file mode 100644 index 0000000000..6407078664 --- /dev/null +++ b/fearless/Common/Model/BitcoinUtxoSelector.swift @@ -0,0 +1,301 @@ +import Foundation + +final class BitcoinUtxoSelector { + static let bitcoinP2wpkhDustSats: Int64 = 330 + static let defaultMaxInputs = 100 + static let maxFeeRateSatPerVbyte = 10000.0 + static let maxSatoshi: Int64 = 2_100_000_000_000_000 + + private static let maxUInt32 = Int64(UInt32.max) + private static let p2wpkhInputVbytes = 68 + private static let p2wpkhOutputVbytes = 31 + private static let txOverheadVbytes = 11 + + func spendableUtxos( + source: BitcoinUtxoSource, + utxos: [BitcoinEsploraUtxo], + network: BitcoinIndexerNetwork = .mainnet, + includeUnconfirmed: Bool = false + ) throws -> [BitcoinSpendableUtxo] { + let address: String + do { + address = try BitcoinIndexerRoutes.normalizeAddress(source.address, network: network) + } catch { + throw BitcoinUtxoSelectionError.invalidSourceAddress + } + if let derivationPath = source.derivationPath { + try validateDerivationPath(derivationPath) + } + + return utxos + .filter { includeUnconfirmed || $0.status.confirmed } + .map { + BitcoinSpendableUtxo( + address: address, + derivationPath: source.derivationPath, + txid: $0.txid, + valueSats: $0.value, + vout: $0.vout + ) + } + } + + func select( + amountSats: Int64, + changeAddress: String, + feeRateSatPerVbyte: Double, + maxInputs: Int = BitcoinUtxoSelector.defaultMaxInputs, + network: BitcoinIndexerNetwork = .mainnet, + utxos: [BitcoinSpendableUtxo] + ) throws -> BitcoinUtxoSelectionResult { + try validateAmount(amountSats) + let normalizedChangeAddress: String + do { + normalizedChangeAddress = try BitcoinIndexerRoutes.normalizeAddress(changeAddress, network: network) + } catch { + throw BitcoinUtxoSelectionError.invalidChangeAddress + } + try validateFeeRate(feeRateSatPerVbyte) + try validateMaxInputs(maxInputs) + let normalizedUtxos = try normalizeUtxos(utxos) + var selected: [BitcoinSpendableUtxo] = [] + var total: Int64 = 0 + + for utxo in normalizedUtxos.sorted(by: compareUtxos) { + selected.append(utxo) + guard selected.count <= maxInputs else { + throw BitcoinUtxoSelectionError.tooManyInputsRequired + } + total = try safeAdd(total, utxo.valueSats) + guard total <= Self.maxSatoshi else { + throw BitcoinUtxoSelectionError.satoshiOverflow + } + + let noChangeFee = try estimateFee(inputCount: selected.count, outputCount: 1, feeRateSatPerVbyte: feeRateSatPerVbyte) + let noChangeRemainder = total - amountSats - noChangeFee + + if noChangeRemainder == 0 { + return BitcoinUtxoSelectionResult( + selectedUtxos: selected, + inputTotalSats: total, + feeSats: noChangeFee, + changeAddress: nil, + changeSats: 0, + absorbedDustSats: 0 + ) + } + + if noChangeRemainder > 0, noChangeRemainder < Self.bitcoinP2wpkhDustSats { + return BitcoinUtxoSelectionResult( + selectedUtxos: selected, + inputTotalSats: total, + feeSats: try safeAdd(noChangeFee, noChangeRemainder), + changeAddress: nil, + changeSats: 0, + absorbedDustSats: noChangeRemainder + ) + } + + let withChangeFee = try estimateFee(inputCount: selected.count, outputCount: 2, feeRateSatPerVbyte: feeRateSatPerVbyte) + let change = total - amountSats - withChangeFee + + if change >= Self.bitcoinP2wpkhDustSats { + return BitcoinUtxoSelectionResult( + selectedUtxos: selected, + inputTotalSats: total, + feeSats: withChangeFee, + changeAddress: normalizedChangeAddress, + changeSats: change, + absorbedDustSats: 0 + ) + } + } + + throw BitcoinUtxoSelectionError.insufficientFunds + } + + func estimateP2wpkhTransactionVSize(inputCount: Int, outputCount: Int) throws -> Int { + guard inputCount > 0 else { + throw BitcoinUtxoSelectionError.invalidInputCount + } + guard outputCount > 0 else { + throw BitcoinUtxoSelectionError.invalidOutputCount + } + + return Self.txOverheadVbytes + inputCount * Self.p2wpkhInputVbytes + outputCount * Self.p2wpkhOutputVbytes + } + + private func normalizeUtxos(_ utxos: [BitcoinSpendableUtxo]) throws -> [BitcoinSpendableUtxo] { + guard !utxos.isEmpty else { + throw BitcoinUtxoSelectionError.utxosRequired + } + var outpoints = Set() + + return try utxos.map { utxo in + let normalizedTxid = utxo.txid.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard normalizedTxid.range(of: "^[0-9a-f]{64}$", options: .regularExpression) != nil else { + throw BitcoinUtxoSelectionError.invalidTxid + } + guard utxo.vout >= 0, utxo.vout <= Self.maxUInt32 else { + throw BitcoinUtxoSelectionError.invalidVout + } + guard utxo.valueSats > 0, utxo.valueSats <= Self.maxSatoshi else { + throw BitcoinUtxoSelectionError.invalidUtxoValue + } + if let scriptPubKey = utxo.scriptPubKey { + try validateScriptPubKey(scriptPubKey) + } + if let derivationPath = utxo.derivationPath { + try validateDerivationPath(derivationPath) + } + + let outpoint = "\(normalizedTxid):\(utxo.vout)" + guard outpoints.insert(outpoint).inserted else { + throw BitcoinUtxoSelectionError.duplicateUtxo + } + + return BitcoinSpendableUtxo( + address: utxo.address, + derivationPath: utxo.derivationPath, + scriptPubKey: utxo.scriptPubKey, + txid: normalizedTxid, + valueSats: utxo.valueSats, + vout: utxo.vout + ) + } + } + + private func validateAmount(_ amountSats: Int64) throws { + guard amountSats > 0, amountSats <= Self.maxSatoshi else { + throw BitcoinUtxoSelectionError.invalidAmount + } + guard amountSats >= Self.bitcoinP2wpkhDustSats else { + throw BitcoinUtxoSelectionError.amountBelowDust + } + } + + private func validateFeeRate(_ feeRateSatPerVbyte: Double) throws { + guard + feeRateSatPerVbyte.isFinite, + feeRateSatPerVbyte > 0, + feeRateSatPerVbyte <= Self.maxFeeRateSatPerVbyte + else { + throw BitcoinUtxoSelectionError.invalidFeeRate + } + } + + private func validateMaxInputs(_ maxInputs: Int) throws { + guard maxInputs > 0, maxInputs <= Self.defaultMaxInputs else { + throw BitcoinUtxoSelectionError.invalidMaxInputs + } + } + + private func validateScriptPubKey(_ scriptPubKey: String) throws { + guard scriptPubKey.trimmingCharacters(in: .whitespacesAndNewlines).range( + of: "^(?:[0-9a-fA-F]{2})+$", + options: .regularExpression + ) != nil else { + throw BitcoinUtxoSelectionError.invalidScriptPubKey + } + } + + private func validateDerivationPath(_ derivationPath: String) throws { + guard derivationPath.range(of: "^m(?:/\\d+'?)+$", options: .regularExpression) != nil else { + throw BitcoinUtxoSelectionError.invalidDerivationPath + } + } + + private func estimateFee(inputCount: Int, outputCount: Int, feeRateSatPerVbyte: Double) throws -> Int64 { + let fee = ceil(Double(try estimateP2wpkhTransactionVSize(inputCount: inputCount, outputCount: outputCount)) * feeRateSatPerVbyte) + guard fee.isFinite, fee > 0, fee <= Double(Int64.max) else { + throw BitcoinUtxoSelectionError.invalidFeeRate + } + + return Int64(fee) + } + + private func safeAdd(_ left: Int64, _ right: Int64) throws -> Int64 { + let sum = left.addingReportingOverflow(right) + guard !sum.overflow else { + throw BitcoinUtxoSelectionError.satoshiOverflow + } + + return sum.partialValue + } + + private func compareUtxos(_ left: BitcoinSpendableUtxo, _ right: BitcoinSpendableUtxo) -> Bool { + if left.valueSats != right.valueSats { + return left.valueSats > right.valueSats + } + if left.txid.lowercased() != right.txid.lowercased() { + return left.txid.lowercased() < right.txid.lowercased() + } + + return left.vout < right.vout + } +} + +struct BitcoinUtxoSource: Equatable { + let address: String + let derivationPath: String? + + init(address: String, derivationPath: String? = nil) { + self.address = address + self.derivationPath = derivationPath + } +} + +struct BitcoinSpendableUtxo: Equatable { + let address: String? + let derivationPath: String? + let scriptPubKey: String? + let txid: String + let valueSats: Int64 + let vout: Int64 + + init( + address: String? = nil, + derivationPath: String? = nil, + scriptPubKey: String? = nil, + txid: String, + valueSats: Int64, + vout: Int64 + ) { + self.address = address + self.derivationPath = derivationPath + self.scriptPubKey = scriptPubKey + self.txid = txid + self.valueSats = valueSats + self.vout = vout + } +} + +struct BitcoinUtxoSelectionResult: Equatable { + let selectedUtxos: [BitcoinSpendableUtxo] + let inputTotalSats: Int64 + let feeSats: Int64 + let changeAddress: String? + let changeSats: Int64 + let absorbedDustSats: Int64 +} + +enum BitcoinUtxoSelectionError: Error, Equatable { + case amountBelowDust + case duplicateUtxo + case insufficientFunds + case invalidAmount + case invalidChangeAddress + case invalidDerivationPath + case invalidFeeRate + case invalidInputCount + case invalidMaxInputs + case invalidOutputCount + case invalidScriptPubKey + case invalidSourceAddress + case invalidTxid + case invalidUtxoValue + case invalidVout + case satoshiOverflow + case tooManyInputsRequired + case utxosRequired +} diff --git a/fearless/Common/Model/IrohaAddressCodec.swift b/fearless/Common/Model/IrohaAddressCodec.swift new file mode 100644 index 0000000000..0276ff3471 --- /dev/null +++ b/fearless/Common/Model/IrohaAddressCodec.swift @@ -0,0 +1,499 @@ +import Foundation + +enum IrohaNetworkKind: String, Equatable { + case taira + case nexus + case dev + case custom +} + +enum IrohaAddressErrorCode: String, Equatable { + case invalidLength = "ERR_INVALID_LENGTH" + case checksumMismatch = "ERR_CHECKSUM_MISMATCH" + case invalidHexAddress = "ERR_INVALID_HEX_ADDRESS" + case missingI105Sentinel = "ERR_MISSING_I105_SENTINEL" + case i105TooShort = "ERR_I105_TOO_SHORT" + case invalidI105Base = "ERR_INVALID_I105_BASE" + case invalidI105Char = "ERR_INVALID_I105_CHAR" + case invalidI105Digit = "ERR_INVALID_I105_DIGIT" + case unsupportedAddressFormat = "ERR_UNSUPPORTED_ADDRESS_FORMAT" + case unexpectedNetworkPrefix = "ERR_UNEXPECTED_NETWORK_PREFIX" + case invalidI105Prefix = "ERR_INVALID_I105_PREFIX" + case invalidHeaderVersion = "ERR_INVALID_HEADER_VERSION" + case invalidNormVersion = "ERR_INVALID_NORM_VERSION" + case unknownAddressClass = "ERR_UNKNOWN_ADDRESS_CLASS" + case unexpectedExtensionFlag = "ERR_UNEXPECTED_EXTENSION_FLAG" + case unknownControllerTag = "ERR_UNKNOWN_CONTROLLER_TAG" + case unknownCurve = "ERR_UNKNOWN_CURVE" + case unexpectedTrailingBytes = "ERR_UNEXPECTED_TRAILING_BYTES" +} + +struct IrohaAddressError: Error, Equatable { + let code: IrohaAddressErrorCode +} + +struct IrohaAddressDetails: Equatable { + let chainDiscriminant: Int + let network: IrohaNetworkKind + let canonicalHex: String + let publicKeyHex: String + let i105: String +} + +enum IrohaAddressCodec { + static let tairaDiscriminant = 369 + static let nexusDiscriminant = 753 + static let devDiscriminant = 0 + + private static let i105DiscriminantMax = 0x3FFF + private static let i105SentinelSora = "sora" + private static let i105SentinelTest = "test" + private static let i105SentinelDev = "dev" + private static let i105SentinelFallbackPrefix = "n" + private static let i105ChecksumLength = 6 + private static let i105Base = 105 + private static let bech32mConst: UInt32 = 0x2BC8_30A3 + private static let i105Hrp = "snx" + private static let controllerSingleKeyTag: UInt8 = 0x00 + private static let curveEd25519: UInt8 = 0x01 + private static let ed25519PublicKeyLength = 32 + private static let base58Alphabet = Array("123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz") + private static let irohaPoemKanaHalfwidth: [Character] = [ + "\u{ff72}", + "\u{ff9b}", + "\u{ff8a}", + "\u{ff86}", + "\u{ff8e}", + "\u{ff8d}", + "\u{ff84}", + "\u{ff81}", + "\u{ff98}", + "\u{ff87}", + "\u{ff99}", + "\u{ff66}", + "\u{ff9c}", + "\u{ff76}", + "\u{ff96}", + "\u{ff80}", + "\u{ff9a}", + "\u{ff7f}", + "\u{ff82}", + "\u{ff88}", + "\u{ff85}", + "\u{ff97}", + "\u{ff91}", + "\u{ff73}", + "\u{30f0}", + "\u{ff89}", + "\u{ff75}", + "\u{ff78}", + "\u{ff94}", + "\u{ff8f}", + "\u{ff79}", + "\u{ff8c}", + "\u{ff7a}", + "\u{ff74}", + "\u{ff83}", + "\u{ff71}", + "\u{ff7b}", + "\u{ff77}", + "\u{ff95}", + "\u{ff92}", + "\u{ff90}", + "\u{ff7c}", + "\u{30f1}", + "\u{ff8b}", + "\u{ff93}", + "\u{ff7e}", + "\u{ff7d}" + ] + private static let bech32Generators: [UInt32] = [0x3B6A_57B2, 0x2650_8E6D, 0x1EA1_19FA, 0x3D42_33DD, 0x2A14_62B3] + + private static var i105Alphabet: [Character] { + base58Alphabet + irohaPoemKanaHalfwidth + } + + private static var i105DigitTable: [Character: Int] { + Dictionary(uniqueKeysWithValues: i105Alphabet.enumerated().map { ($0.element, $0.offset) }) + } + + static func canonicalHex(publicKeyHex: String) throws -> String { + try publicKeyToCanonicalBytes(publicKeyHex: publicKeyHex).hexString() + } + + static func encode(publicKeyHex: String, chainDiscriminant: Int) throws -> String { + try encodeCanonicalHex(try canonicalHex(publicKeyHex: publicKeyHex), chainDiscriminant: chainDiscriminant) + } + + static func encodeCanonicalHex(_ canonicalHex: String, chainDiscriminant: Int) throws -> String { + try encodeI105Literal( + chainDiscriminant: resolveDiscriminant(chainDiscriminant), + canonicalBytes: normalizeHexBytes(canonicalHex) + ) + } + + static func parse(_ address: String, expectedDiscriminant: Int? = nil) throws -> IrohaAddressDetails { + if address.isEmpty || address.trimmingCharacters(in: .whitespacesAndNewlines) != address { + throw IrohaAddressError(code: .unsupportedAddressFormat) + } + if address.hasPrefix("0x") || address.hasPrefix("0X") { + throw IrohaAddressError(code: .unsupportedAddressFormat) + } + + let decoded = try decodeI105Literal(address) + let expected = try expectedDiscriminant.map(resolveDiscriminant) + + if let expected, decoded.chainDiscriminant != expected { + throw IrohaAddressError(code: .unexpectedNetworkPrefix) + } + + let publicKeyHex = try decodeCanonicalSingleEd25519(decoded.canonicalBytes) + let i105 = try encodeI105Literal(chainDiscriminant: decoded.chainDiscriminant, canonicalBytes: decoded.canonicalBytes) + + if i105 != address { + throw IrohaAddressError(code: .unsupportedAddressFormat) + } + + return IrohaAddressDetails( + chainDiscriminant: decoded.chainDiscriminant, + network: networkFromDiscriminant(decoded.chainDiscriminant), + canonicalHex: decoded.canonicalBytes.hexString(), + publicKeyHex: publicKeyHex, + i105: i105 + ) + } + + static func isValid(_ address: String, expectedDiscriminant: Int? = nil) -> Bool { + (try? parse(address, expectedDiscriminant: expectedDiscriminant)) != nil + } + + static func networkKind(_ address: String) -> IrohaNetworkKind? { + (try? parse(address))?.network + } + + private static func resolveDiscriminant(_ chainDiscriminant: Int) throws -> Int { + guard (0 ... i105DiscriminantMax).contains(chainDiscriminant) else { + throw IrohaAddressError(code: .invalidI105Prefix) + } + + return chainDiscriminant + } + + private static func networkFromDiscriminant(_ discriminant: Int) -> IrohaNetworkKind { + switch discriminant { + case UniversalWalletRegistry.taira.chainDiscriminant: + return .taira + case UniversalWalletRegistry.nexus.chainDiscriminant: + return .nexus + case devDiscriminant: + return .dev + default: + return .custom + } + } + + private static func sentinelForDiscriminant(_ discriminant: Int) -> String { + switch discriminant { + case UniversalWalletRegistry.nexus.chainDiscriminant: + return i105SentinelSora + case UniversalWalletRegistry.taira.chainDiscriminant: + return i105SentinelTest + case devDiscriminant: + return i105SentinelDev + default: + return "\(i105SentinelFallbackPrefix)\(discriminant)" + } + } + + private static func discriminantFromSentinel(_ input: String) -> Int? { + if input.hasPrefix(i105SentinelSora) { + return UniversalWalletRegistry.nexus.chainDiscriminant + } + if input.hasPrefix(i105SentinelTest) { + return UniversalWalletRegistry.taira.chainDiscriminant + } + if input.hasPrefix(i105SentinelDev) { + return devDiscriminant + } + guard input.hasPrefix(i105SentinelFallbackPrefix) else { + return nil + } + + let digits = input.dropFirst().prefix(5).prefix { isAsciiDigit($0) } + guard !digits.isEmpty, let discriminant = Int(String(digits)), discriminant <= i105DiscriminantMax else { + return nil + } + + return discriminant + } + + private static func encodeI105Literal(chainDiscriminant: Int, canonicalBytes: [UInt8]) throws -> String { + let digits = try encodeBaseN(bytes: canonicalBytes, base: i105Base) + let checksum = i105ChecksumDigits(canonicalBytes) + + return try ([sentinelForDiscriminant(chainDiscriminant)] + (digits + checksum).map { String(try i105DigitSymbol($0)) }) + .joined() + } + + private static func decodeI105Literal(_ input: String) throws -> (chainDiscriminant: Int, canonicalBytes: [UInt8]) { + guard let discriminant = discriminantFromSentinel(input) else { + throw IrohaAddressError(code: .missingI105Sentinel) + } + + let sentinel = sentinelForDiscriminant(discriminant) + guard input.hasPrefix(sentinel) else { + throw IrohaAddressError(code: .unsupportedAddressFormat) + } + + return (discriminant, try decodeI105Payload(String(input.dropFirst(sentinel.count)))) + } + + private static func decodeI105Payload(_ payload: String) throws -> [UInt8] { + let table = i105DigitTable + let digits = try payload.map { character in + guard let digit = table[character] else { + throw IrohaAddressError(code: .invalidI105Char) + } + + return digit + } + + guard digits.count > i105ChecksumLength else { + throw IrohaAddressError(code: .i105TooShort) + } + + let splitAt = digits.count - i105ChecksumLength + let canonicalBytes = try decodeBaseN(digits: Array(digits[.. Character { + guard i105Alphabet.indices.contains(digit) else { + throw IrohaAddressError(code: .invalidI105Digit) + } + + return i105Alphabet[digit] + } + + private static func encodeBaseN(bytes: [UInt8], base: Int) throws -> [Int] { + guard base >= 2 else { + throw IrohaAddressError(code: .invalidI105Base) + } + guard !bytes.isEmpty else { + return [0] + } + + var value = bytes.map(Int.init) + let leadingZeros = value.prefix { $0 == 0 }.count + var digits: [Int] = [] + var start = leadingZeros + + while start < value.count { + var remainder = 0 + + for index in start ..< value.count { + let accumulator = (remainder << 8) | value[index] + value[index] = accumulator / base + remainder = accumulator % base + } + + digits.append(remainder) + + while start < value.count, value[start] == 0 { + start += 1 + } + } + + digits.append(contentsOf: Array(repeating: 0, count: leadingZeros)) + if digits.isEmpty { + digits.append(0) + } + + return Array(digits.reversed()) + } + + private static func decodeBaseN(digits: [Int], base: Int) throws -> [UInt8] { + guard base >= 2 else { + throw IrohaAddressError(code: .invalidI105Base) + } + guard !digits.isEmpty else { + throw IrohaAddressError(code: .invalidLength) + } + + var value = digits + let leadingZeros = value.prefix { $0 == 0 }.count + var bytes: [Int] = [] + var start = leadingZeros + + while start < value.count { + var remainder = 0 + + for index in start ..< value.count { + let digit = value[index] + guard digit < base else { + throw IrohaAddressError(code: .invalidI105Digit) + } + + let accumulator = remainder * base + digit + value[index] = accumulator / 256 + remainder = accumulator % 256 + } + + bytes.append(remainder) + + while start < value.count, value[start] == 0 { + start += 1 + } + } + + bytes.append(contentsOf: Array(repeating: 0, count: leadingZeros)) + return bytes.reversed().map(UInt8.init) + } + + private static func i105ChecksumDigits(_ canonicalBytes: [UInt8]) -> [Int] { + let data = convertToBase32(canonicalBytes) + let values = expandHrp(i105Hrp) + data + Array(repeating: 0, count: i105ChecksumLength) + let polymod = bech32Polymod(values) ^ bech32mConst + + return (0 ..< i105ChecksumLength).map { index in + Int((polymod >> UInt32(5 * (i105ChecksumLength - 1 - index))) & 0x1F) + } + } + + private static func convertToBase32(_ bytes: [UInt8]) -> [Int] { + var accumulator = 0 + var bits = 0 + var result: [Int] = [] + + for byte in bytes { + accumulator = ((accumulator << 8) | Int(byte)) & 0xFFF + bits += 8 + + while bits >= 5 { + bits -= 5 + result.append((accumulator >> bits) & 0x1F) + } + } + + if bits > 0 { + result.append((accumulator << (5 - bits)) & 0x1F) + } + + return result + } + + private static func bech32Polymod(_ values: [Int]) -> UInt32 { + var checksum: UInt32 = 1 + + for value in values { + let top = checksum >> 25 + checksum = ((checksum & 0x1FFFFFF) << 5) ^ UInt32(value) + + for (index, generator) in bech32Generators.enumerated() where ((top >> UInt32(index)) & 1) == 1 { + checksum ^= generator + } + } + + return checksum + } + + private static func expandHrp(_ hrp: String) -> [Int] { + hrp.utf8.map { Int($0 >> 5) } + [0] + hrp.utf8.map { Int($0 & 31) } + } + + private static func decodeCanonicalSingleEd25519(_ canonicalBytes: [UInt8]) throws -> String { + guard let header = canonicalBytes.first else { + throw IrohaAddressError(code: .invalidLength) + } + + let version = header >> 5 + let classBits = (header >> 3) & 0b11 + let normVersion = (header >> 1) & 0b11 + let extFlag = (header & 1) == 1 + + if extFlag { + throw IrohaAddressError(code: .unexpectedExtensionFlag) + } + if version != 0 { + throw IrohaAddressError(code: .invalidHeaderVersion) + } + if normVersion != 1 { + throw IrohaAddressError(code: .invalidNormVersion) + } + if classBits != 0 { + throw IrohaAddressError(code: .unknownAddressClass) + } + guard canonicalBytes.count >= 4 else { + throw IrohaAddressError(code: .invalidLength) + } + + let tag = canonicalBytes[1] + let curve = canonicalBytes[2] + let length = Int(canonicalBytes[3]) + + if tag != controllerSingleKeyTag { + throw IrohaAddressError(code: .unknownControllerTag) + } + if curve != curveEd25519 { + throw IrohaAddressError(code: .unknownCurve) + } + if length != ed25519PublicKeyLength { + throw IrohaAddressError(code: .invalidLength) + } + if canonicalBytes.count < 4 + length { + throw IrohaAddressError(code: .invalidLength) + } + if canonicalBytes.count != 4 + length { + throw IrohaAddressError(code: .unexpectedTrailingBytes) + } + + return String(Array(canonicalBytes[4 ..< (4 + length)]).hexString().dropFirst(2)) + } + + private static func publicKeyToCanonicalBytes(publicKeyHex: String) throws -> [UInt8] { + let publicKey = try normalizeHexBytes(publicKeyHex, expectedBytes: ed25519PublicKeyLength) + return [0x02, controllerSingleKeyTag, curveEd25519, UInt8(publicKey.count)] + publicKey + } + + private static func normalizeHexBytes(_ value: String, expectedBytes: Int? = nil) throws -> [UInt8] { + let hex = value.hasPrefix("0x") || value.hasPrefix("0X") ? String(value.dropFirst(2)) : value + + guard !hex.isEmpty, hex.count % 2 == 0, hex.range(of: "^[0-9a-fA-F]+$", options: .regularExpression) != nil else { + throw IrohaAddressError(code: .invalidHexAddress) + } + if let expectedBytes, hex.count != expectedBytes * 2 { + throw IrohaAddressError(code: .invalidLength) + } + + var result: [UInt8] = [] + var index = hex.startIndex + + while index < hex.endIndex { + let next = hex.index(index, offsetBy: 2) + guard let byte = UInt8(String(hex[index ..< next]), radix: 16) else { + throw IrohaAddressError(code: .invalidHexAddress) + } + + result.append(byte) + index = next + } + + return result + } + + private static func isAsciiDigit(_ character: Character) -> Bool { + character.unicodeScalars.count == 1 && character.unicodeScalars.first.map { (48 ... 57).contains(Int($0.value)) } == true + } +} + +private extension Array where Element == UInt8 { + func hexString() -> String { + "0x" + map { String(format: "%02x", $0) }.joined() + } +} diff --git a/fearless/Common/Model/IrohaKeyDerivation.swift b/fearless/Common/Model/IrohaKeyDerivation.swift new file mode 100644 index 0000000000..c1e75ecdd0 --- /dev/null +++ b/fearless/Common/Model/IrohaKeyDerivation.swift @@ -0,0 +1,68 @@ +import Foundation + +struct IrohaDerivedAccount: Equatable { + let derivationPath: String + let privateKey: Data + let chainCode: Data + let publicKey: Data + let publicKeyHex: String + let canonicalHex: String +} + +struct IrohaDerivedAddress: Equatable { + let account: IrohaDerivedAccount + let chainDiscriminant: Int + let i105: String +} + +enum IrohaKeyDerivation { + static func deriveAccount( + mnemonic: String, + passphrase: String = "", + derivationPath: String = UniversalWalletDerivationPaths.irohaDefault + ) throws -> IrohaDerivedAccount { + let ed25519 = try SolanaKeyDerivation.deriveAccount( + mnemonic: mnemonic, + passphrase: passphrase, + derivationPath: derivationPath + ) + let publicKeyHex = ed25519.publicKey.hexString + + return IrohaDerivedAccount( + derivationPath: derivationPath, + privateKey: ed25519.privateKey, + chainCode: ed25519.chainCode, + publicKey: ed25519.publicKey, + publicKeyHex: publicKeyHex, + canonicalHex: try IrohaAddressCodec.canonicalHex(publicKeyHex: publicKeyHex) + ) + } + + static func deriveAddress( + mnemonic: String, + passphrase: String = "", + derivationPath: String = UniversalWalletDerivationPaths.irohaDefault, + chainDiscriminant: Int = UniversalWalletRegistry.taira.chainDiscriminant + ) throws -> IrohaDerivedAddress { + let account = try deriveAccount( + mnemonic: mnemonic, + passphrase: passphrase, + derivationPath: derivationPath + ) + + return IrohaDerivedAddress( + account: account, + chainDiscriminant: chainDiscriminant, + i105: try IrohaAddressCodec.encode( + publicKeyHex: account.publicKeyHex, + chainDiscriminant: chainDiscriminant + ) + ) + } +} + +private extension Data { + var hexString: String { + map { String(format: "%02x", $0) }.joined() + } +} diff --git a/fearless/Common/Model/IrohaToriiClient.swift b/fearless/Common/Model/IrohaToriiClient.swift new file mode 100644 index 0000000000..b03ef0b908 --- /dev/null +++ b/fearless/Common/Model/IrohaToriiClient.swift @@ -0,0 +1,191 @@ +import Foundation + +protocol IrohaToriiClientProtocol { + func health(baseURL: String?) async throws -> Data + func accounts(baseURL: String?, limit: Int?, offset: Int64?, countMode: IrohaToriiCountMode?) async throws -> IrohaAccountListResponse + func account(accountID: String, baseURL: String?, network: UniversalWalletRegistry.IrohaNetwork) async throws -> IrohaAccountListItem + func accountAssets( + accountID: String, + baseURL: String?, + limit: Int?, + offset: Int64?, + countMode: IrohaToriiCountMode?, + asset: String?, + scope: String?, + network: UniversalWalletRegistry.IrohaNetwork + ) async throws -> IrohaAccountAssetListResponse + func assetDefinitions(baseURL: String?) async throws -> IrohaAssetDefinitionListResponse + func submitTransaction(noritoBytes: Data, baseURL: String?) async throws -> IrohaTransactionSubmissionReceipt + func transactionStatus(hash: String, baseURL: String?, scope: IrohaTransactionStatusScope) async throws -> IrohaPipelineTransactionStatusResponse + func mcpCapabilities(network: UniversalWalletRegistry.IrohaNetwork, baseURL: String?) async throws -> Data + func mcpJSONRPC( + _ request: IrohaMcpJsonRPCRequest, + network: UniversalWalletRegistry.IrohaNetwork, + baseURL: String? + ) async throws -> IrohaMcpJsonRPCResponse +} + +final class IrohaToriiClient: IrohaToriiClientProtocol { + private let transport: UniversalWalletHTTPTransport + private let defaultNetwork: UniversalWalletRegistry.IrohaNetwork + private let decoder: JSONDecoder + private let encoder: JSONEncoder + + init( + transport: UniversalWalletHTTPTransport = URLSessionUniversalWalletHTTPTransport(), + defaultNetwork: UniversalWalletRegistry.IrohaNetwork = UniversalWalletRegistry.taira, + decoder: JSONDecoder = JSONDecoder(), + encoder: JSONEncoder = JSONEncoder() + ) { + self.transport = transport + self.defaultNetwork = defaultNetwork + self.decoder = decoder + self.encoder = encoder + } + + func health(baseURL: String? = nil) async throws -> Data { + try await transport.perform(urlRequest(url: try IrohaToriiRoutes.healthURL(baseURL: resolvedBaseURL(baseURL)))) + } + + func accounts( + baseURL: String? = nil, + limit: Int? = nil, + offset: Int64? = nil, + countMode: IrohaToriiCountMode? = nil + ) async throws -> IrohaAccountListResponse { + try await get( + try IrohaToriiRoutes.accountsURL( + baseURL: resolvedBaseURL(baseURL), + limit: limit, + offset: offset, + countMode: countMode + ) + ) + } + + func account( + accountID: String, + baseURL: String? = nil, + network: UniversalWalletRegistry.IrohaNetwork = UniversalWalletRegistry.taira + ) async throws -> IrohaAccountListItem { + try await get( + try IrohaToriiRoutes.accountURL( + accountID: normalizeWalletAccountID(accountID, network: network), + baseURL: resolvedBaseURL(baseURL, network: network) + ) + ) + } + + func accountAssets( + accountID: String, + baseURL: String? = nil, + limit: Int? = nil, + offset: Int64? = nil, + countMode: IrohaToriiCountMode? = nil, + asset: String? = nil, + scope: String? = nil, + network: UniversalWalletRegistry.IrohaNetwork = UniversalWalletRegistry.taira + ) async throws -> IrohaAccountAssetListResponse { + try await get( + try IrohaToriiRoutes.accountAssetsURL( + accountID: normalizeWalletAccountID(accountID, network: network), + baseURL: resolvedBaseURL(baseURL, network: network), + limit: limit, + offset: offset, + countMode: countMode, + asset: asset, + scope: scope + ) + ) + } + + func assetDefinitions(baseURL: String? = nil) async throws -> IrohaAssetDefinitionListResponse { + try await get(try IrohaToriiRoutes.assetDefinitionsURL(baseURL: resolvedBaseURL(baseURL))) + } + + func submitTransaction( + noritoBytes: Data, + baseURL: String? = nil + ) async throws -> IrohaTransactionSubmissionReceipt { + var request = urlRequest( + url: try IrohaToriiRoutes.submitTransactionURL(baseURL: resolvedBaseURL(baseURL)), + method: "POST" + ) + request.setValue("application/x-norito", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.httpBody = noritoBytes + + return try decode(try await transport.perform(request)) + } + + func transactionStatus( + hash: String, + baseURL: String? = nil, + scope: IrohaTransactionStatusScope = .auto + ) async throws -> IrohaPipelineTransactionStatusResponse { + try await get( + try IrohaToriiRoutes.transactionStatusURL( + hash: hash, + baseURL: resolvedBaseURL(baseURL), + scope: scope + ) + ) + } + + func mcpCapabilities( + network: UniversalWalletRegistry.IrohaNetwork = UniversalWalletRegistry.taira, + baseURL: String? = nil + ) async throws -> Data { + try await transport.perform( + urlRequest(url: try IrohaToriiRoutes.mcpURL(network: network, baseURL: resolvedBaseURL(baseURL, network: network))) + ) + } + + func mcpJSONRPC( + _ request: IrohaMcpJsonRPCRequest, + network: UniversalWalletRegistry.IrohaNetwork = UniversalWalletRegistry.taira, + baseURL: String? = nil + ) async throws -> IrohaMcpJsonRPCResponse { + var urlRequest = urlRequest( + url: try IrohaToriiRoutes.mcpURL(network: network, baseURL: resolvedBaseURL(baseURL, network: network)), + method: "POST" + ) + urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") + urlRequest.setValue("application/json", forHTTPHeaderField: "Accept") + urlRequest.httpBody = try encoder.encode(request) + + return try decode(try await transport.perform(urlRequest)) + } + + private func get(_ url: URL) async throws -> T { + try decode(try await transport.perform(urlRequest(url: url))) + } + + private func normalizeWalletAccountID( + _ accountID: String, + network: UniversalWalletRegistry.IrohaNetwork + ) throws -> String { + try IrohaAddressCodec.parse(accountID, expectedDiscriminant: network.chainDiscriminant).i105 + } + + private func decode(_ data: Data) throws -> T { + try decoder.decode(T.self, from: data) + } + + private func urlRequest(url: URL, method: String = "GET") -> URLRequest { + var request = URLRequest(url: url) + request.httpMethod = method + return request + } + + private func resolvedBaseURL( + _ baseURL: String?, + network: UniversalWalletRegistry.IrohaNetwork? = nil + ) throws -> String { + if let baseURL { + return baseURL + } + + return try IrohaToriiRoutes.requireToriiBaseURL(network ?? defaultNetwork) + } +} diff --git a/fearless/Common/Model/IrohaToriiContract.swift b/fearless/Common/Model/IrohaToriiContract.swift new file mode 100644 index 0000000000..c7fbb7b9ab --- /dev/null +++ b/fearless/Common/Model/IrohaToriiContract.swift @@ -0,0 +1,531 @@ +import Foundation + +enum IrohaToriiRouteError: Error, Equatable { + case invalidBaseURL + case missingToriiBaseURL + case invalidPath + case invalidAccountID + case invalidAsset + case invalidScope + case invalidHash + case invalidLimit + case invalidOffset + case invalidJSONRPCID + case invalidMCPMethod +} + +enum IrohaToriiCountMode: String, Codable { + case bounded + case exact +} + +enum IrohaTransactionStatusScope: String, Codable { + case local + case auto + case global +} + +enum IrohaToriiRoutes { + static let defaultLimit = 100 + static let maxLimit = 500 + + static func healthURL(baseURL: String? = nil) throws -> URL { + try makeURL("\(normalizeBaseURL(try resolvedBaseURL(baseURL)))/health") + } + + static func mcpURL( + network: UniversalWalletRegistry.IrohaNetwork = UniversalWalletRegistry.taira, + baseURL: String? = nil + ) throws -> URL { + let base = try resolvedBaseURL(baseURL, network: network) + return try makeURL("\(normalizeBaseURL(base))\(normalizePath(network.mcpPath))") + } + + static func accountsURL( + baseURL: String? = nil, + limit: Int? = nil, + offset: Int64? = nil, + countMode: IrohaToriiCountMode? = nil + ) throws -> URL { + try makeURL( + base: "\(normalizeBaseURL(try resolvedBaseURL(baseURL)))/v1/accounts", + queryItems: pageQuery(limit: limit, offset: offset, countMode: countMode) + ) + } + + static func accountURL(accountID: String, baseURL: String? = nil) throws -> URL { + try makeURL( + "\(normalizeBaseURL(try resolvedBaseURL(baseURL)))/v1/accounts/\(encodePathSegment(normalizeAccountID(accountID)))" + ) + } + + static func accountAssetsURL( + accountID: String, + baseURL: String? = nil, + limit: Int? = nil, + offset: Int64? = nil, + countMode: IrohaToriiCountMode? = nil, + asset: String? = nil, + scope: String? = nil + ) throws -> URL { + var queryItems = try pageQuery(limit: limit, offset: offset, countMode: countMode) + if let asset { + queryItems.append(URLQueryItem(name: "asset", value: try normalizeAssetSelector(asset))) + } + if let scope { + queryItems.append(URLQueryItem(name: "scope", value: try normalizeScope(scope))) + } + + return try makeURL( + base: "\(accountURL(accountID: accountID, baseURL: baseURL).absoluteString)/assets", + queryItems: queryItems + ) + } + + static func assetDefinitionsURL(baseURL: String? = nil) throws -> URL { + try makeURL("\(normalizeBaseURL(try resolvedBaseURL(baseURL)))/v1/assets/definitions") + } + + static func submitTransactionURL(baseURL: String? = nil) throws -> URL { + try makeURL("\(normalizeBaseURL(try resolvedBaseURL(baseURL)))/v1/pipeline/transactions") + } + + static func transactionStatusURL( + hash: String, + baseURL: String? = nil, + scope: IrohaTransactionStatusScope = .auto + ) throws -> URL { + try makeURL( + base: "\(normalizeBaseURL(try resolvedBaseURL(baseURL)))/v1/pipeline/transactions/status", + queryItems: [ + URLQueryItem(name: "hash", value: try normalizeHash(hash)), + URLQueryItem(name: "scope", value: scope.rawValue) + ] + ) + } + + static func mcpJSONRPCRequest( + method: String, + id: String, + params: [String: IrohaJSONValue]? = nil + ) throws -> IrohaMcpJsonRPCRequest { + IrohaMcpJsonRPCRequest( + id: try normalizeJSONRPCID(id), + method: try normalizeMCPMethod(method), + params: params + ) + } + + static func normalizeBaseURL(_ baseURL: String) throws -> String { + let trimmed = baseURL.trimmingCharacters(in: .whitespacesAndNewlines) + guard let url = URL(string: trimmed), let scheme = url.scheme, let host = url.host else { + throw IrohaToriiRouteError.invalidBaseURL + } + + let isLocal = host == "localhost" || host == "127.0.0.1" + guard scheme == "https" || isLocal else { + throw IrohaToriiRouteError.invalidBaseURL + } + + return trimmed.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + } + + static func normalizeAccountID(_ accountID: String) throws -> String { + try normalizePathValue(accountID, error: .invalidAccountID) + } + + static func normalizeAssetSelector(_ asset: String) throws -> String { + let normalized = asset.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalized.isEmpty, + normalized.count <= 256, + !containsASCIIWhitespaceOrControl(normalized), + normalized.range(of: "[/?]", options: .regularExpression) == nil else { + throw IrohaToriiRouteError.invalidAsset + } + + return normalized + } + + static func requireToriiBaseURL(_ network: UniversalWalletRegistry.IrohaNetwork) throws -> String { + guard let url = network.toriiBaseURL else { + throw IrohaToriiRouteError.missingToriiBaseURL + } + + return url.absoluteString + } + + private static func resolvedBaseURL( + _ baseURL: String?, + network: UniversalWalletRegistry.IrohaNetwork = UniversalWalletRegistry.taira + ) throws -> String { + if let baseURL { + return baseURL + } + + return try requireToriiBaseURL(network) + } + + private static func normalizeScope(_ scope: String) throws -> String { + let normalized = scope.trimmingCharacters(in: .whitespacesAndNewlines) + guard normalized == "global" || matches(normalized, "^dataspace:[A-Za-z0-9._:-]{1,128}$") else { + throw IrohaToriiRouteError.invalidScope + } + + return normalized + } + + private static func normalizePath(_ path: String) throws -> String { + let normalized = path.trimmingCharacters(in: .whitespacesAndNewlines) + guard normalized.hasPrefix("/"), + !normalized.contains(".."), + !normalized.contains("?"), + !normalized.contains("#") else { + throw IrohaToriiRouteError.invalidPath + } + + return normalized.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + .isEmpty ? "/" : "/" + normalized.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + } + + private static func normalizePathValue(_ value: String, error: IrohaToriiRouteError) throws -> String { + let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalized.isEmpty, + normalized.count <= 256, + !containsASCIIWhitespaceOrControl(normalized), + normalized.range(of: "[/?#]", options: .regularExpression) == nil else { + throw error + } + + return normalized + } + + private static func normalizeHash(_ hash: String) throws -> String { + let normalized = hash + .trimmingCharacters(in: .whitespacesAndNewlines) + .dropPrefix("0x") + .lowercased() + guard matches(normalized, "^[0-9a-f]{64}$") else { + throw IrohaToriiRouteError.invalidHash + } + + return normalized + } + + private static func normalizeJSONRPCID(_ id: String) throws -> String { + let normalized = id.trimmingCharacters(in: .whitespacesAndNewlines) + guard matches(normalized, "^[A-Za-z0-9._:-]{1,64}$") else { + throw IrohaToriiRouteError.invalidJSONRPCID + } + + return normalized + } + + private static func normalizeMCPMethod(_ method: String) throws -> String { + let normalized = method.trimmingCharacters(in: .whitespacesAndNewlines) + guard matches(normalized, "^[A-Za-z][A-Za-z0-9_/.-]{0,127}$") else { + throw IrohaToriiRouteError.invalidMCPMethod + } + + return normalized + } + + private static func pageQuery( + limit: Int?, + offset: Int64?, + countMode: IrohaToriiCountMode? + ) throws -> [URLQueryItem] { + var queryItems: [URLQueryItem] = [] + + if let limit { + guard (1 ... maxLimit).contains(limit) else { + throw IrohaToriiRouteError.invalidLimit + } + queryItems.append(URLQueryItem(name: "limit", value: String(limit))) + } + if let offset { + guard offset >= 0 else { + throw IrohaToriiRouteError.invalidOffset + } + queryItems.append(URLQueryItem(name: "offset", value: String(offset))) + } + if let countMode { + queryItems.append(URLQueryItem(name: "count_mode", value: countMode.rawValue)) + } + + return queryItems + } + + private static func makeURL(base: String, queryItems: [URLQueryItem]) throws -> URL { + guard var components = URLComponents(string: base) else { + throw IrohaToriiRouteError.invalidBaseURL + } + components.queryItems = queryItems.isEmpty ? nil : queryItems + + guard let url = components.url else { + throw IrohaToriiRouteError.invalidBaseURL + } + + return url + } + + private static func makeURL(_ value: String) throws -> URL { + guard let url = URL(string: value) else { + throw IrohaToriiRouteError.invalidBaseURL + } + + return url + } + + private static func encodePathSegment(_ value: String) -> String { + value.addingPercentEncoding(withAllowedCharacters: pathSegmentAllowed) ?? value + } + + private static func containsASCIIWhitespaceOrControl(_ value: String) -> Bool { + value.unicodeScalars.contains { $0.value <= 0x20 || $0.value == 0x7F } + } + + private static func matches(_ value: String, _ pattern: String) -> Bool { + value.range(of: pattern, options: .regularExpression) != nil + } + + private static let pathSegmentAllowed = CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~") +} + +private extension String { + func dropPrefix(_ prefix: String) -> String { + hasPrefix(prefix) ? String(dropFirst(prefix.count)) : self + } +} + +enum IrohaJSONValue: Codable, Equatable { + case string(String) + case int(Int64) + case double(Double) + case bool(Bool) + case object([String: IrohaJSONValue]) + case array([IrohaJSONValue]) + case null + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + if container.decodeNil() { + self = .null + } else if let value = try? container.decode(Bool.self) { + self = .bool(value) + } else if let value = try? container.decode(Int64.self) { + self = .int(value) + } else if let value = try? container.decode(Double.self) { + self = .double(value) + } else if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode([String: IrohaJSONValue].self) { + self = .object(value) + } else if let value = try? container.decode([IrohaJSONValue].self) { + self = .array(value) + } else { + throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported JSON value") + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + + switch self { + case let .string(value): + try container.encode(value) + case let .int(value): + try container.encode(value) + case let .double(value): + try container.encode(value) + case let .bool(value): + try container.encode(value) + case let .object(value): + try container.encode(value) + case let .array(value): + try container.encode(value) + case .null: + try container.encodeNil() + } + } +} + +struct IrohaAccountListResponse: Codable, Equatable { + let items: [IrohaAccountListItem] + let hasMore: Bool + let countMode: String + let total: Int64? + + private enum CodingKeys: String, CodingKey { + case items + case hasMore = "has_more" + case countMode = "count_mode" + case total + } +} + +struct IrohaAccountListItem: Codable, Equatable { + let id: String + let primaryAlias: String? + let primaryAliasName: String? + let primaryAliasDataspace: String? + let primaryAliasDomain: String? + let hasPrimaryAlias: Bool? + + private enum CodingKeys: String, CodingKey { + case id + case primaryAlias = "primary_alias" + case primaryAliasName = "primary_alias_name" + case primaryAliasDataspace = "primary_alias_dataspace" + case primaryAliasDomain = "primary_alias_domain" + case hasPrimaryAlias = "has_primary_alias" + } +} + +struct IrohaAccountAssetListResponse: Codable, Equatable { + let items: [IrohaAccountAssetListItem] + let hasMore: Bool + let countMode: String + let total: Int64? + + private enum CodingKeys: String, CodingKey { + case items + case hasMore = "has_more" + case countMode = "count_mode" + case total + } +} + +struct IrohaAccountAssetListItem: Codable, Equatable { + let accountID: String? + let asset: String + let assetID: String? + let assetName: String? + let assetAlias: String? + let quantity: String + let scope: String? + + private enum CodingKeys: String, CodingKey { + case accountID = "account_id" + case asset + case assetID = "asset_id" + case assetName = "asset_name" + case assetAlias = "asset_alias" + case quantity + case scope + } +} + +struct IrohaAssetDefinitionListResponse: Codable, Equatable { + let items: [IrohaAssetDefinitionListItem] + let hasMore: Bool + let countMode: String + let total: Int64? + + private enum CodingKeys: String, CodingKey { + case items + case hasMore = "has_more" + case countMode = "count_mode" + case total + } +} + +struct IrohaAssetDefinitionListItem: Codable, Equatable { + let id: String + let name: String? + let alias: String? + let ownedBy: String? + let metadata: [String: IrohaJSONValue]? + let aliasBinding: [String: IrohaJSONValue]? + + private enum CodingKeys: String, CodingKey { + case id + case name + case alias + case ownedBy = "owned_by" + case metadata + case aliasBinding = "alias_binding" + } +} + +struct IrohaTransactionSubmissionReceipt: Codable, Equatable { + let payload: IrohaTransactionSubmissionPayload + let signature: IrohaJSONValue? +} + +struct IrohaTransactionSubmissionPayload: Codable, Equatable { + let txHash: String + let entrypointHash: String + let signedTransactionHash: String? + let submittedAtMs: Int64 + let submittedAtHeight: Int64 + let signer: IrohaJSONValue? + + private enum CodingKeys: String, CodingKey { + case txHash = "tx_hash" + case entrypointHash = "entrypoint_hash" + case signedTransactionHash = "signed_transaction_hash" + case submittedAtMs = "submitted_at_ms" + case submittedAtHeight = "submitted_at_height" + case signer + } +} + +struct IrohaPipelineTransactionStatusResponse: Codable, Equatable { + let hash: String + let status: IrohaPipelineTransactionStatus + let scope: String + let resolvedFrom: String + + private enum CodingKeys: String, CodingKey { + case hash + case status + case scope + case resolvedFrom = "resolved_from" + } +} + +struct IrohaPipelineTransactionStatus: Codable, Equatable { + let kind: String + let blockHeight: Int64? + let rejectionReason: IrohaJSONValue? + + private enum CodingKeys: String, CodingKey { + case kind + case blockHeight = "block_height" + case rejectionReason = "rejection_reason" + } +} + +struct IrohaErrorEnvelope: Codable, Equatable { + let code: String + let message: String + let details: IrohaJSONValue? +} + +struct IrohaMcpJsonRPCRequest: Codable, Equatable { + let jsonrpc: String + let id: String + let method: String + let params: [String: IrohaJSONValue]? + + init(jsonrpc: String = "2.0", id: String, method: String, params: [String: IrohaJSONValue]? = nil) { + self.jsonrpc = jsonrpc + self.id = id + self.method = method + self.params = params + } +} + +struct IrohaMcpJsonRPCResponse: Codable, Equatable { + let jsonrpc: String + let id: IrohaJSONValue? + let result: IrohaJSONValue? + let error: IrohaMcpJsonRPCError? +} + +struct IrohaMcpJsonRPCError: Codable, Equatable { + let code: Int + let message: String + let data: IrohaJSONValue? +} diff --git a/fearless/Common/Model/PasskeyBackupContract.swift b/fearless/Common/Model/PasskeyBackupContract.swift new file mode 100644 index 0000000000..0b9c28f06e --- /dev/null +++ b/fearless/Common/Model/PasskeyBackupContract.swift @@ -0,0 +1,1083 @@ +import AuthenticationServices +import CloudKit +import Foundation + +enum PasskeyBackupError: Error, Equatable { + case unsupportedRelyingParty(String) + case invalidChallengeLength(Int) + case invalidUserIdLength(Int) + case invalidUserName + case invalidDisplayName + case invalidStorageKey + case invalidWalletId + case invalidCreatedAtMillis + case emptyEncryptedPayload + case unsupportedSchemaVersion(Int) + case unavailableCloudStorage + case unavailableCloudKitAccount + case invalidCloudKitRecordType(String) + case missingCloudKitEncryptedPayload + case invalidChallengeServiceURL(String) + case invalidCeremonyId + case challengeServiceHTTPStatus(Int) + case emptyChallengeServiceResponse + case malformedChallengeServiceResponse + case invalidCredentialResponse + case mismatchedChallengeStorageKey + case missingCloudBackup + case passkeyBackupDisabled +} + +enum PasskeyBackupContract { + static let PASSKEY_RP_ID = "fearlesswallet.io" + static let passkeyRelyingPartyId = PASSKEY_RP_ID + static let schemaVersion = 1 + + static func validateRelyingPartyId(_ relyingPartyId: String = PASSKEY_RP_ID) throws { + guard relyingPartyId == PASSKEY_RP_ID else { + throw PasskeyBackupError.unsupportedRelyingParty(relyingPartyId) + } + } + + static func validateChallenge(_ challenge: Data) throws { + guard (16 ... 1024).contains(challenge.count) else { + throw PasskeyBackupError.invalidChallengeLength(challenge.count) + } + } + + static func validateUserId(_ userId: Data) throws { + guard (16 ... 64).contains(userId.count) else { + throw PasskeyBackupError.invalidUserIdLength(userId.count) + } + } + + static func validateAccountName(_ accountName: String) throws -> String { + let normalized = accountName.trimmingCharacters(in: .whitespacesAndNewlines) + + guard !normalized.isEmpty else { + throw PasskeyBackupError.invalidUserName + } + + guard normalized.count <= 320 else { + throw PasskeyBackupError.invalidUserName + } + + guard normalized.rangeOfCharacter(from: .whitespacesAndNewlines) == nil, + normalized.rangeOfCharacter(from: .controlCharacters) == nil, + normalized.contains("@") else { + throw PasskeyBackupError.invalidUserName + } + + return normalized + } + + static func validateMatchingAccountName( + expected: String, + actual: String + ) throws -> String { + let normalizedExpected = try validateAccountName(expected) + let normalizedActual = try validateAccountName(actual) + + guard normalizedActual.caseInsensitiveCompare(normalizedExpected) == .orderedSame else { + throw PasskeyBackupError.invalidUserName + } + + return normalizedExpected + } + + static func validateStorageKey(_ storageKey: String) throws -> String { + let normalized = storageKey.trimmingCharacters(in: .whitespacesAndNewlines) + let pattern = #"^[A-Za-z0-9._:-]{8,128}$"# + let range = NSRange(normalized.startIndex ..< normalized.endIndex, in: normalized) + let regex = try NSRegularExpression(pattern: pattern) + + guard regex.firstMatch(in: normalized, range: range) != nil else { + throw PasskeyBackupError.invalidStorageKey + } + + return normalized + } + + static func validateWalletId(_ walletId: String) throws -> String { + let normalized = walletId.trimmingCharacters(in: .whitespacesAndNewlines) + let pattern = #"^[A-Za-z0-9._:-]{8,128}$"# + let range = NSRange(normalized.startIndex ..< normalized.endIndex, in: normalized) + let regex = try NSRegularExpression(pattern: pattern) + + guard regex.firstMatch(in: normalized, range: range) != nil else { + throw PasskeyBackupError.invalidWalletId + } + + return normalized + } + + static func validateCreatedAtMillis(_ createdAtMillis: Int64) throws -> Int64 { + guard createdAtMillis > 0, + createdAtMillis <= 4_102_444_800_000 else { + throw PasskeyBackupError.invalidCreatedAtMillis + } + + return createdAtMillis + } + + static func validateCeremonyId(_ ceremonyId: String) throws -> String { + let normalized = ceremonyId.trimmingCharacters(in: .whitespacesAndNewlines) + let pattern = #"^[A-Za-z0-9._:-]{8,128}$"# + let range = NSRange(normalized.startIndex ..< normalized.endIndex, in: normalized) + let regex = try NSRegularExpression(pattern: pattern) + + guard regex.firstMatch(in: normalized, range: range) != nil else { + throw PasskeyBackupError.invalidCeremonyId + } + + return normalized + } + + static func decodeBase64URL(_ value: String) throws -> Data { + let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalized.isEmpty else { + throw PasskeyBackupError.malformedChallengeServiceResponse + } + + var base64 = normalized + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + + switch base64.count % 4 { + case 0: + break + case 2: + base64.append("==") + case 3: + base64.append("=") + default: + throw PasskeyBackupError.malformedChallengeServiceResponse + } + + guard let data = Data(base64Encoded: base64) else { + throw PasskeyBackupError.malformedChallengeServiceResponse + } + + return data + } +} + +enum PasskeyBackupReleaseConfig { + static let challengeServiceBaseURL = URL(string: "https://backup.fearlesswallet.io")! + static let isPasskeyBackupEnabled = false + static let passkeyBackupEnabled = isPasskeyBackupEnabled + + static func validateEnabled(_ isEnabled: Bool = isPasskeyBackupEnabled) throws { + guard isEnabled else { + throw PasskeyBackupError.passkeyBackupDisabled + } + } +} + +struct PasskeyBackupRegistrationChallenge: Equatable { + let registrationId: String + let challenge: Data + let userId: Data + let userName: String + let displayName: String + let storageKey: String + let schemaVersion: Int + + init( + registrationId: String, + challenge: Data, + userId: Data, + userName: String, + displayName: String, + storageKey: String, + schemaVersion: Int = PasskeyBackupContract.schemaVersion + ) throws { + self.registrationId = try PasskeyBackupContract.validateCeremonyId(registrationId) + try PasskeyBackupContract.validateChallenge(challenge) + try PasskeyBackupContract.validateUserId(userId) + + let normalizedDisplayName = displayName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedDisplayName.isEmpty else { + throw PasskeyBackupError.invalidDisplayName + } + + guard schemaVersion == PasskeyBackupContract.schemaVersion else { + throw PasskeyBackupError.unsupportedSchemaVersion(schemaVersion) + } + + self.challenge = challenge + self.userId = userId + self.userName = try PasskeyBackupContract.validateAccountName(userName) + self.displayName = normalizedDisplayName + self.storageKey = try PasskeyBackupContract.validateStorageKey(storageKey) + self.schemaVersion = schemaVersion + } +} + +struct PasskeyBackupAssertionChallenge: Equatable { + let assertionId: String + let challenge: Data + let storageKey: String + let schemaVersion: Int + + init( + assertionId: String, + challenge: Data, + storageKey: String, + schemaVersion: Int = PasskeyBackupContract.schemaVersion + ) throws { + self.assertionId = try PasskeyBackupContract.validateCeremonyId(assertionId) + try PasskeyBackupContract.validateChallenge(challenge) + + guard schemaVersion == PasskeyBackupContract.schemaVersion else { + throw PasskeyBackupError.unsupportedSchemaVersion(schemaVersion) + } + + self.challenge = challenge + self.storageKey = try PasskeyBackupContract.validateStorageKey(storageKey) + self.schemaVersion = schemaVersion + } +} + +struct PasskeyBackupChallengeResult: Equatable { + let storageKey: String + let schemaVersion: Int + + init( + storageKey: String, + schemaVersion: Int = PasskeyBackupContract.schemaVersion + ) throws { + guard schemaVersion == PasskeyBackupContract.schemaVersion else { + throw PasskeyBackupError.unsupportedSchemaVersion(schemaVersion) + } + + self.storageKey = try PasskeyBackupContract.validateStorageKey(storageKey) + self.schemaVersion = schemaVersion + } +} + +protocol PasskeyBackupChallengeService { + func registrationChallenge( + walletId: String, + accountName: String, + displayName: String + ) async throws -> PasskeyBackupRegistrationChallenge + + func completeRegistration( + registrationId: String, + credentialResponseJSON: String + ) async throws -> PasskeyBackupChallengeResult + + func assertionChallenge(storageKey: String) async throws -> PasskeyBackupAssertionChallenge + + func completeAssertion( + assertionId: String, + credentialResponseJSON: String + ) async throws -> PasskeyBackupChallengeResult +} + +struct PasskeyBackupHTTPRequest: Equatable { + let method: String + let url: URL + let headers: [String: String] + let body: Data? + + init( + method: String, + url: URL, + headers: [String: String] = [:], + body: Data? = nil + ) { + self.method = method + self.url = url + self.headers = headers + self.body = body + } +} + +struct PasskeyBackupHTTPResponse: Equatable { + let statusCode: Int + let body: Data + + init(statusCode: Int, body: Data = Data()) { + self.statusCode = statusCode + self.body = body + } +} + +protocol PasskeyBackupHTTPTransport { + func execute(_ request: PasskeyBackupHTTPRequest) async throws -> PasskeyBackupHTTPResponse +} + +@available(iOS 15.0, macOS 12.0, *) +final class URLSessionPasskeyBackupHTTPTransport: PasskeyBackupHTTPTransport { + private let session: URLSession + + init(session: URLSession = .shared) { + self.session = session + } + + func execute(_ request: PasskeyBackupHTTPRequest) async throws -> PasskeyBackupHTTPResponse { + var urlRequest = URLRequest(url: request.url) + urlRequest.httpMethod = request.method + request.headers.forEach { key, value in + urlRequest.setValue(value, forHTTPHeaderField: key) + } + urlRequest.httpBody = request.body + + let (data, response) = try await session.data(for: urlRequest) + guard let httpResponse = response as? HTTPURLResponse else { + throw PasskeyBackupError.malformedChallengeServiceResponse + } + + return PasskeyBackupHTTPResponse(statusCode: httpResponse.statusCode, body: data) + } +} + +final class HTTPPasskeyBackupChallengeService: PasskeyBackupChallengeService { + private static let registrationChallengePath = "/api/passkey-backup/v1/registration/challenge" + private static let registrationCompletePath = "/api/passkey-backup/v1/registration/complete" + private static let assertionChallengePath = "/api/passkey-backup/v1/assertion/challenge" + private static let assertionCompletePath = "/api/passkey-backup/v1/assertion/complete" + + private let baseURL: URL + private let transport: PasskeyBackupHTTPTransport + + convenience init(baseURL: String, transport: PasskeyBackupHTTPTransport) throws { + let normalized = baseURL.trimmingCharacters(in: .whitespacesAndNewlines) + guard let url = URL(string: normalized) else { + throw PasskeyBackupError.invalidChallengeServiceURL(baseURL) + } + + try self.init(baseURL: url, transport: transport) + } + + init(baseURL: URL, transport: PasskeyBackupHTTPTransport) throws { + self.baseURL = try Self.normalizedBaseURL(baseURL) + self.transport = transport + } + + @available(iOS 15.0, macOS 12.0, *) + convenience init(baseURL: String) throws { + try self.init(baseURL: baseURL, transport: URLSessionPasskeyBackupHTTPTransport()) + } + + func registrationChallenge( + walletId: String, + accountName: String, + displayName: String + ) async throws -> PasskeyBackupRegistrationChallenge { + let normalizedWalletId = try PasskeyBackupContract.validateWalletId(walletId) + let normalizedAccountName = try PasskeyBackupContract.validateAccountName(accountName) + let normalizedDisplayName = try Self.requiredLocalString(displayName) + + let response = try await post( + path: Self.registrationChallengePath, + body: [ + "walletId": normalizedWalletId, + "accountName": normalizedAccountName, + "displayName": normalizedDisplayName, + "rpId": PasskeyBackupContract.PASSKEY_RP_ID, + "schemaVersion": PasskeyBackupContract.schemaVersion + ] + ) + + try requireRelyingPartyId(response) + let schemaVersion = try requiredInt(response, name: "schemaVersion") + + return try PasskeyBackupRegistrationChallenge( + registrationId: requiredString(response, name: "registrationId"), + challenge: try PasskeyBackupContract.decodeBase64URL( + requiredString(response, name: "challenge") + ), + userId: try PasskeyBackupContract.decodeBase64URL( + requiredString(response, name: "userId") + ), + userName: requiredString(response, name: "userName"), + displayName: requiredString(response, name: "displayName"), + storageKey: requiredString(response, name: "storageKey"), + schemaVersion: schemaVersion + ) + } + + func completeRegistration( + registrationId: String, + credentialResponseJSON: String + ) async throws -> PasskeyBackupChallengeResult { + let response = try await post( + path: Self.registrationCompletePath, + body: [ + "registrationId": try PasskeyBackupContract.validateCeremonyId(registrationId), + "rpId": PasskeyBackupContract.PASSKEY_RP_ID, + "credential": try credentialJSONObject(credentialResponseJSON) + ] + ) + + return try challengeResult(response) + } + + func assertionChallenge(storageKey: String) async throws -> PasskeyBackupAssertionChallenge { + let normalizedStorageKey = try PasskeyBackupContract.validateStorageKey(storageKey) + let response = try await post( + path: Self.assertionChallengePath, + body: [ + "storageKey": normalizedStorageKey, + "rpId": PasskeyBackupContract.PASSKEY_RP_ID, + "schemaVersion": PasskeyBackupContract.schemaVersion + ] + ) + + try requireRelyingPartyId(response) + let responseStorageKey = try PasskeyBackupContract.validateStorageKey( + requiredString(response, name: "storageKey") + ) + + guard responseStorageKey == normalizedStorageKey else { + throw PasskeyBackupError.mismatchedChallengeStorageKey + } + + return try PasskeyBackupAssertionChallenge( + assertionId: requiredString(response, name: "assertionId"), + challenge: try PasskeyBackupContract.decodeBase64URL( + requiredString(response, name: "challenge") + ), + storageKey: responseStorageKey, + schemaVersion: requiredInt(response, name: "schemaVersion") + ) + } + + func completeAssertion( + assertionId: String, + credentialResponseJSON: String + ) async throws -> PasskeyBackupChallengeResult { + let response = try await post( + path: Self.assertionCompletePath, + body: [ + "assertionId": try PasskeyBackupContract.validateCeremonyId(assertionId), + "rpId": PasskeyBackupContract.PASSKEY_RP_ID, + "credential": try credentialJSONObject(credentialResponseJSON) + ] + ) + + return try challengeResult(response) + } + + private func post(path: String, body: [String: Any]) async throws -> [String: Any] { + let requestBody = try JSONSerialization.data(withJSONObject: body, options: []) + let response = try await transport.execute( + PasskeyBackupHTTPRequest( + method: "POST", + url: endpoint(path: path), + headers: ["Content-Type": "application/json; charset=utf-8"], + body: requestBody + ) + ) + + guard (200 ... 299).contains(response.statusCode) else { + throw PasskeyBackupError.challengeServiceHTTPStatus(response.statusCode) + } + + guard !response.body.isEmpty else { + throw PasskeyBackupError.emptyChallengeServiceResponse + } + + do { + guard let root = try JSONSerialization.jsonObject(with: response.body) as? [String: Any] else { + throw PasskeyBackupError.malformedChallengeServiceResponse + } + + return root + } catch let error as PasskeyBackupError { + throw error + } catch { + throw PasskeyBackupError.malformedChallengeServiceResponse + } + } + + private func endpoint(path: String) -> URL { + var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)! + var basePath = components.percentEncodedPath.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + let endpointPath = path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + + if !basePath.isEmpty { + basePath += "/" + } + + components.percentEncodedPath = "/" + basePath + endpointPath + return components.url! + } + + private func challengeResult(_ response: [String: Any]) throws -> PasskeyBackupChallengeResult { + try requireRelyingPartyId(response) + return try PasskeyBackupChallengeResult( + storageKey: requiredString(response, name: "storageKey"), + schemaVersion: requiredInt(response, name: "schemaVersion") + ) + } + + private func requireRelyingPartyId(_ response: [String: Any]) throws { + try PasskeyBackupContract.validateRelyingPartyId(requiredString(response, name: "rpId")) + } + + private func credentialJSONObject(_ credentialResponseJSON: String) throws -> [String: Any] { + let normalized = credentialResponseJSON.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalized.isEmpty, let data = normalized.data(using: .utf8) else { + throw PasskeyBackupError.invalidCredentialResponse + } + + do { + guard let credential = try JSONSerialization.jsonObject(with: data) as? [String: Any], + !credential.isEmpty else { + throw PasskeyBackupError.invalidCredentialResponse + } + + return credential + } catch let error as PasskeyBackupError { + throw error + } catch { + throw PasskeyBackupError.invalidCredentialResponse + } + } + + private func requiredString(_ response: [String: Any], name: String) throws -> String { + guard let value = response[name] as? String, + !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw PasskeyBackupError.malformedChallengeServiceResponse + } + + return value + } + + private func requiredInt(_ response: [String: Any], name: String) throws -> Int { + guard let value = response[name] as? NSNumber, + String(cString: value.objCType) != "c" else { + throw PasskeyBackupError.malformedChallengeServiceResponse + } + + return value.intValue + } + + private static func requiredLocalString(_ value: String) throws -> String { + let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalized.isEmpty else { + throw PasskeyBackupError.malformedChallengeServiceResponse + } + + return normalized + } + + private static func normalizedBaseURL(_ url: URL) throws -> URL { + guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false), + components.scheme == "https", + !(components.host ?? "").isEmpty, + components.user == nil, + components.password == nil, + components.query == nil, + components.fragment == nil else { + throw PasskeyBackupError.invalidChallengeServiceURL(url.absoluteString) + } + + while components.percentEncodedPath.hasSuffix("/") { + components.percentEncodedPath.removeLast() + } + + guard let normalized = components.url else { + throw PasskeyBackupError.invalidChallengeServiceURL(url.absoluteString) + } + + return normalized + } +} + +struct PasskeyBackupEncryptedRecord: Equatable { + let storageKey: String + let walletId: String + let accountName: String + let createdAtMillis: Int64 + let encryptedPayload: Data + let schemaVersion: Int + + init( + storageKey: String, + walletId: String, + accountName: String, + createdAtMillis: Int64, + encryptedPayload: Data, + schemaVersion: Int = PasskeyBackupContract.schemaVersion + ) throws { + self.storageKey = try PasskeyBackupContract.validateStorageKey(storageKey) + self.walletId = try PasskeyBackupContract.validateWalletId(walletId) + self.accountName = try PasskeyBackupContract.validateAccountName(accountName) + self.createdAtMillis = try PasskeyBackupContract.validateCreatedAtMillis( + createdAtMillis + ) + + guard !encryptedPayload.isEmpty else { + throw PasskeyBackupError.emptyEncryptedPayload + } + + guard schemaVersion == PasskeyBackupContract.schemaVersion else { + throw PasskeyBackupError.unsupportedSchemaVersion(schemaVersion) + } + + self.encryptedPayload = encryptedPayload + self.schemaVersion = schemaVersion + } + + func cloudKitRecordID() throws -> CKRecord.ID { + CKRecord.ID(recordName: try PasskeyBackupContract.validateStorageKey(storageKey)) + } +} + +protocol PasskeyBackupCloudStorage { + func savePasskeyBackup(_ record: PasskeyBackupEncryptedRecord) async throws + func loadPasskeyBackup(storageKey: String) async throws -> PasskeyBackupEncryptedRecord? + func deletePasskeyBackup(storageKey: String) async throws +} + +protocol PasskeyBackupCloudKitDatabase { + func saveRecord(_ record: CKRecord) async throws + func fetchRecord(recordID: CKRecord.ID) async throws -> CKRecord? + func deleteRecord(recordID: CKRecord.ID) async throws +} + +protocol PasskeyBackupCloudKitAccountStatusProvider { + func accountStatus() async throws -> CKAccountStatus +} + +final class UnavailablePasskeyBackupCloudStorage: PasskeyBackupCloudStorage { + func savePasskeyBackup(_: PasskeyBackupEncryptedRecord) async throws { + throw PasskeyBackupError.unavailableCloudStorage + } + + func loadPasskeyBackup(storageKey _: String) async throws -> PasskeyBackupEncryptedRecord? { + throw PasskeyBackupError.unavailableCloudStorage + } + + func deletePasskeyBackup(storageKey _: String) async throws { + throw PasskeyBackupError.unavailableCloudStorage + } +} + +final class SystemPasskeyBackupCloudKitDatabase: PasskeyBackupCloudKitDatabase { + private let database: CKDatabase + + init(database: CKDatabase = CKContainer.default().privateCloudDatabase) { + self.database = database + } + + func saveRecord(_ record: CKRecord) async throws { + _ = try await database.save(record) + } + + func fetchRecord(recordID: CKRecord.ID) async throws -> CKRecord? { + do { + return try await database.record(for: recordID) + } catch let error as CKError where error.code == .unknownItem { + return nil + } + } + + func deleteRecord(recordID: CKRecord.ID) async throws { + do { + _ = try await database.deleteRecord(withID: recordID) + } catch let error as CKError where error.code == .unknownItem { + return + } + } +} + +final class SystemPasskeyBackupCloudKitAccountStatusProvider: PasskeyBackupCloudKitAccountStatusProvider { + private let container: CKContainer + + init(container: CKContainer = CKContainer.default()) { + self.container = container + } + + func accountStatus() async throws -> CKAccountStatus { + try await withCheckedThrowingContinuation { continuation in + container.accountStatus { status, error in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume(returning: status) + } + } + } + } +} + +final class CloudKitPasskeyBackupCloudStorage: PasskeyBackupCloudStorage { + static let recordType = "FearlessPasskeyBackup" + static let storageKeyField = "storageKey" + static let walletIdField = "walletId" + static let accountNameField = "accountName" + static let createdAtMillisField = "createdAtMillis" + static let encryptedPayloadField = "encryptedPayload" + static let schemaVersionField = "schemaVersion" + static let updatedAtField = "updatedAt" + + private let database: PasskeyBackupCloudKitDatabase + private let accountStatusProvider: PasskeyBackupCloudKitAccountStatusProvider + + init( + database: PasskeyBackupCloudKitDatabase = SystemPasskeyBackupCloudKitDatabase(), + accountStatusProvider: PasskeyBackupCloudKitAccountStatusProvider = + SystemPasskeyBackupCloudKitAccountStatusProvider() + ) { + self.database = database + self.accountStatusProvider = accountStatusProvider + } + + func savePasskeyBackup(_ record: PasskeyBackupEncryptedRecord) async throws { + try await requireAvailableCloudKitAccount() + + let cloudRecord = CKRecord( + recordType: Self.recordType, + recordID: try record.cloudKitRecordID() + ) + cloudRecord[Self.storageKeyField] = record.storageKey as NSString + cloudRecord[Self.walletIdField] = record.walletId as NSString + cloudRecord[Self.accountNameField] = record.accountName as NSString + cloudRecord[Self.createdAtMillisField] = NSNumber(value: record.createdAtMillis) + cloudRecord[Self.encryptedPayloadField] = record.encryptedPayload as NSData + cloudRecord[Self.schemaVersionField] = NSNumber(value: record.schemaVersion) + cloudRecord[Self.updatedAtField] = Date() as NSDate + + try await database.saveRecord(cloudRecord) + } + + func loadPasskeyBackup(storageKey: String) async throws -> PasskeyBackupEncryptedRecord? { + let normalizedStorageKey = try PasskeyBackupContract.validateStorageKey(storageKey) + try await requireAvailableCloudKitAccount() + let recordID = CKRecord.ID(recordName: normalizedStorageKey) + + guard let cloudRecord = try await database.fetchRecord(recordID: recordID) else { + return nil + } + + guard cloudRecord.recordType == Self.recordType else { + throw PasskeyBackupError.invalidCloudKitRecordType(cloudRecord.recordType) + } + + guard let encryptedPayload = encryptedPayload(from: cloudRecord) else { + throw PasskeyBackupError.missingCloudKitEncryptedPayload + } + + let metadataStorageKey = try PasskeyBackupContract.validateStorageKey( + stringField(Self.storageKeyField, from: cloudRecord) + ) + guard metadataStorageKey == normalizedStorageKey else { + throw PasskeyBackupError.invalidStorageKey + } + let walletId = try PasskeyBackupContract.validateWalletId( + stringField(Self.walletIdField, from: cloudRecord) + ) + let accountName = try PasskeyBackupContract.validateAccountName( + stringField(Self.accountNameField, from: cloudRecord) + ) + let createdAtMillis = try PasskeyBackupContract.validateCreatedAtMillis( + int64Field(Self.createdAtMillisField, from: cloudRecord) + ) + let schemaVersion = (cloudRecord[Self.schemaVersionField] as? NSNumber)?.intValue ?? + PasskeyBackupContract.schemaVersion + + return try PasskeyBackupEncryptedRecord( + storageKey: normalizedStorageKey, + walletId: walletId, + accountName: accountName, + createdAtMillis: createdAtMillis, + encryptedPayload: encryptedPayload, + schemaVersion: schemaVersion + ) + } + + func deletePasskeyBackup(storageKey: String) async throws { + let normalizedStorageKey = try PasskeyBackupContract.validateStorageKey(storageKey) + try await requireAvailableCloudKitAccount() + try await database.deleteRecord(recordID: CKRecord.ID(recordName: normalizedStorageKey)) + } + + private func requireAvailableCloudKitAccount() async throws { + guard try await accountStatusProvider.accountStatus() == .available else { + throw PasskeyBackupError.unavailableCloudKitAccount + } + } + + private func encryptedPayload(from record: CKRecord) -> Data? { + if let payload = record[Self.encryptedPayloadField] as? Data { + return payload + } + + if let payload = record[Self.encryptedPayloadField] as? NSData { + return payload as Data + } + + return nil + } + + private func stringField(_ field: String, from record: CKRecord) throws -> String { + let rawValue: String? + if let value = record[field] as? String { + rawValue = value + } else if let value = record[field] as? NSString { + rawValue = value as String + } else { + rawValue = nil + } + + guard let value = rawValue, + !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw PasskeyBackupError.malformedChallengeServiceResponse + } + + return value + } + + private func int64Field(_ field: String, from record: CKRecord) throws -> Int64 { + guard let value = record[field] as? NSNumber else { + throw PasskeyBackupError.malformedChallengeServiceResponse + } + + return value.int64Value + } +} + +struct PendingPasskeyBackupRegistration: Equatable { + let registrationId: String + let storageKey: String + let walletId: String + let accountName: String + let challenge: Data + let userId: Data + let userName: String + let displayName: String + + init( + challenge: PasskeyBackupRegistrationChallenge, + walletId: String, + accountName: String + ) throws { + registrationId = challenge.registrationId + storageKey = challenge.storageKey + self.walletId = try PasskeyBackupContract.validateWalletId(walletId) + self.accountName = try PasskeyBackupContract.validateAccountName(accountName) + self.challenge = challenge.challenge + userId = challenge.userId + userName = challenge.userName + displayName = challenge.displayName + } +} + +struct PendingPasskeyBackupAssertion: Equatable { + let assertionId: String + let storageKey: String + let challenge: Data + + init(challenge: PasskeyBackupAssertionChallenge) { + assertionId = challenge.assertionId + storageKey = challenge.storageKey + self.challenge = challenge.challenge + } +} + +final class PasskeyBackupWorkflow { + private let challengeService: PasskeyBackupChallengeService + private let cloudStorage: PasskeyBackupCloudStorage + private let isReleaseEnabled: Bool + private let createdAtMillisProvider: () -> Int64 + + init( + challengeService: PasskeyBackupChallengeService, + cloudStorage: PasskeyBackupCloudStorage, + relyingPartyId: String = PasskeyBackupContract.PASSKEY_RP_ID, + isReleaseEnabled: Bool = PasskeyBackupReleaseConfig.isPasskeyBackupEnabled, + createdAtMillisProvider: @escaping () -> Int64 = { + Int64(Date().timeIntervalSince1970 * 1000) + } + ) throws { + try PasskeyBackupContract.validateRelyingPartyId(relyingPartyId) + self.challengeService = challengeService + self.cloudStorage = cloudStorage + self.isReleaseEnabled = isReleaseEnabled + self.createdAtMillisProvider = createdAtMillisProvider + } + + func beginRegistration( + walletId: String, + accountName: String, + displayName: String + ) async throws -> PendingPasskeyBackupRegistration { + try PasskeyBackupReleaseConfig.validateEnabled(isReleaseEnabled) + let normalizedWalletId = try PasskeyBackupContract.validateWalletId(walletId) + let selectedAccountName = try PasskeyBackupContract.validateAccountName(accountName) + let challenge = try await challengeService.registrationChallenge( + walletId: normalizedWalletId, + accountName: selectedAccountName, + displayName: displayName + ) + _ = try PasskeyBackupContract.validateMatchingAccountName( + expected: selectedAccountName, + actual: challenge.userName + ) + + return try PendingPasskeyBackupRegistration( + challenge: challenge, + walletId: normalizedWalletId, + accountName: selectedAccountName + ) + } + + func finishRegistration( + pending: PendingPasskeyBackupRegistration, + credentialResponseJSON: String, + encryptedPayload: Data + ) async throws -> PasskeyBackupEncryptedRecord { + try PasskeyBackupReleaseConfig.validateEnabled(isReleaseEnabled) + let result = try await challengeService.completeRegistration( + registrationId: pending.registrationId, + credentialResponseJSON: credentialResponseJSON + ) + let storageKey = try requireMatchingStorageKey( + expected: pending.storageKey, + actual: result.storageKey + ) + let record = try PasskeyBackupEncryptedRecord( + storageKey: storageKey, + walletId: pending.walletId, + accountName: pending.accountName, + createdAtMillis: createdAtMillisProvider(), + encryptedPayload: encryptedPayload + ) + + try await cloudStorage.savePasskeyBackup(record) + return record + } + + func beginRestore(storageKey: String) async throws -> PendingPasskeyBackupAssertion { + try PasskeyBackupReleaseConfig.validateEnabled(isReleaseEnabled) + let normalizedStorageKey = try PasskeyBackupContract.validateStorageKey(storageKey) + let challenge = try await challengeService.assertionChallenge(storageKey: normalizedStorageKey) + _ = try requireMatchingStorageKey( + expected: normalizedStorageKey, + actual: challenge.storageKey + ) + + return PendingPasskeyBackupAssertion(challenge: challenge) + } + + func finishRestore( + pending: PendingPasskeyBackupAssertion, + credentialResponseJSON: String + ) async throws -> PasskeyBackupEncryptedRecord { + try PasskeyBackupReleaseConfig.validateEnabled(isReleaseEnabled) + let result = try await challengeService.completeAssertion( + assertionId: pending.assertionId, + credentialResponseJSON: credentialResponseJSON + ) + let storageKey = try requireMatchingStorageKey( + expected: pending.storageKey, + actual: result.storageKey + ) + + guard let record = try await cloudStorage.loadPasskeyBackup(storageKey: storageKey) else { + throw PasskeyBackupError.missingCloudBackup + } + + return record + } + + func deleteBackup(storageKey: String) async throws { + try PasskeyBackupReleaseConfig.validateEnabled(isReleaseEnabled) + try await cloudStorage.deletePasskeyBackup( + storageKey: try PasskeyBackupContract.validateStorageKey(storageKey) + ) + } + + private func requireMatchingStorageKey( + expected: String, + actual: String + ) throws -> String { + let normalizedExpected = try PasskeyBackupContract.validateStorageKey(expected) + let normalizedActual = try PasskeyBackupContract.validateStorageKey(actual) + + guard normalizedActual == normalizedExpected else { + throw PasskeyBackupError.mismatchedChallengeStorageKey + } + + return normalizedActual + } +} + +@available(iOS 15.0, *) +final class PasskeyBackupCoordinator { + let relyingPartyId: String + + private let provider: ASAuthorizationPlatformPublicKeyCredentialProvider + private let cloudStorage: PasskeyBackupCloudStorage + private let isReleaseEnabled: Bool + + init( + relyingPartyId: String = PasskeyBackupContract.PASSKEY_RP_ID, + cloudStorage: PasskeyBackupCloudStorage = UnavailablePasskeyBackupCloudStorage(), + isReleaseEnabled: Bool = PasskeyBackupReleaseConfig.isPasskeyBackupEnabled + ) throws { + try PasskeyBackupContract.validateRelyingPartyId(relyingPartyId) + + self.relyingPartyId = relyingPartyId + provider = ASAuthorizationPlatformPublicKeyCredentialProvider( + relyingPartyIdentifier: relyingPartyId + ) + self.cloudStorage = cloudStorage + self.isReleaseEnabled = isReleaseEnabled + } + + func registrationRequest( + challenge: Data, + userId: Data, + userName: String, + displayName: String + ) throws -> ASAuthorizationPlatformPublicKeyCredentialRegistrationRequest { + try PasskeyBackupReleaseConfig.validateEnabled(isReleaseEnabled) + try PasskeyBackupContract.validateChallenge(challenge) + try PasskeyBackupContract.validateUserId(userId) + + let normalizedDisplayName = displayName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedDisplayName.isEmpty else { + throw PasskeyBackupError.invalidDisplayName + } + + return provider.createCredentialRegistrationRequest( + challenge: challenge, + name: try PasskeyBackupContract.validateAccountName(userName), + userID: userId + ) + } + + func assertionRequest( + challenge: Data + ) throws -> ASAuthorizationPlatformPublicKeyCredentialAssertionRequest { + try PasskeyBackupReleaseConfig.validateEnabled(isReleaseEnabled) + try PasskeyBackupContract.validateChallenge(challenge) + return provider.createCredentialAssertionRequest(challenge: challenge) + } + + func saveEncryptedCloudBackup(_ record: PasskeyBackupEncryptedRecord) async throws { + try PasskeyBackupReleaseConfig.validateEnabled(isReleaseEnabled) + try await cloudStorage.savePasskeyBackup(record) + } + + func loadEncryptedCloudBackup(storageKey: String) async throws -> PasskeyBackupEncryptedRecord? { + try PasskeyBackupReleaseConfig.validateEnabled(isReleaseEnabled) + return try await cloudStorage.loadPasskeyBackup( + storageKey: try PasskeyBackupContract.validateStorageKey(storageKey) + ) + } + + func deleteEncryptedCloudBackup(storageKey: String) async throws { + try PasskeyBackupReleaseConfig.validateEnabled(isReleaseEnabled) + try await cloudStorage.deletePasskeyBackup( + storageKey: try PasskeyBackupContract.validateStorageKey(storageKey) + ) + } +} diff --git a/fearless/Common/Model/SolanaBalanceSync.swift b/fearless/Common/Model/SolanaBalanceSync.swift new file mode 100644 index 0000000000..cef6e6b539 --- /dev/null +++ b/fearless/Common/Model/SolanaBalanceSync.swift @@ -0,0 +1,259 @@ +import Foundation + +final class SolanaBalanceSync { + private let client: SolanaIndexerClientProtocol + + init(client: SolanaIndexerClientProtocol) { + self.client = client + } + + func balances( + wallet: String, + network: UniversalWalletRegistry.SolanaNetwork = UniversalWalletRegistry.solanaMainnet, + baseURL: String? = nil, + includeTokenMetadata: Bool = true + ) async throws -> SolanaBalanceSyncResult { + let resolvedBaseURL = baseURL ?? network.indexerBaseURL.absoluteString + _ = try SolanaIndexerRoutes.balancesURL(wallet: wallet, baseURL: resolvedBaseURL) + + _ = try await client.verifyServiceInfo( + baseURL: resolvedBaseURL, + expectedChainId: network.chainId + ) + let response = try await client.balances(wallet: wallet, baseURL: resolvedBaseURL) + guard response.wallet == wallet else { + throw SolanaBalanceSyncError.walletMismatch + } + guard response.syncedAt > 0 else { + throw SolanaBalanceSyncError.invalidSyncTimestamp + } + + let native = try nativeBalance( + response.native, + wallet: wallet, + network: network, + syncedAtMillis: response.syncedAt + ) + let normalizedTokens = response.tokens.compactMap { + tokenBalance( + $0, + wallet: wallet, + network: network, + syncedAtMillis: response.syncedAt + ) + } + let metadataByMint = includeTokenMetadata + ? try await tokenMetadataByMint(normalizedTokens.map(\.source.mint), baseURL: resolvedBaseURL) + : [:] + let tokens = normalizedTokens.map { normalized in + UniversalWalletIndexedAssetBalance( + accountId: normalized.balance.accountId, + ecosystem: normalized.balance.ecosystem, + chainId: normalized.balance.chainId, + assetId: normalized.balance.assetId, + amount: normalized.balance.amount, + decimals: normalized.balance.decimals, + isNative: normalized.balance.isNative, + symbol: tokenSymbol(mint: normalized.source.mint, metadata: metadataByMint[normalized.source.mint]), + name: tokenName(mint: normalized.source.mint, metadata: metadataByMint[normalized.source.mint]), + uiAmountString: normalized.balance.uiAmountString, + tokenAccountId: normalized.balance.tokenAccountId, + contractAddress: normalized.balance.contractAddress, + tokenProgram: normalized.balance.tokenProgram, + syncedAtMillis: normalized.balance.syncedAtMillis + ) + } + let allBalances = [native] + tokens + + return SolanaBalanceSyncResult( + wallet: wallet, + networkId: network.id, + chainId: network.chainId, + syncedAtMillis: response.syncedAt, + nativeBalance: native, + tokenBalances: tokens, + balances: allBalances + ) + } + + private func nativeBalance( + _ native: SolanaNativeBalance, + wallet _: String, + network: UniversalWalletRegistry.SolanaNetwork, + syncedAtMillis: Int64 + ) throws -> UniversalWalletIndexedAssetBalance { + guard native.type == "native", + native.mint == network.nativeAsset.id, + Self.isUnsignedInteger(native.lamports), + Self.decimalRange.contains(native.decimals), + Self.isHumanText(native.uiAmountString, maxLength: 80) else { + throw SolanaBalanceSyncError.invalidNativeBalance + } + + let balance = UniversalWalletIndexedAssetBalance( + accountId: network.id, + ecosystem: .solana, + chainId: network.chainId, + assetId: network.nativeAsset.id, + amount: native.lamports, + decimals: native.decimals, + isNative: true, + symbol: network.nativeAsset.symbol, + name: network.name, + uiAmountString: native.uiAmountString, + syncedAtMillis: syncedAtMillis + ) + guard balance.validationErrors().isEmpty else { + throw SolanaBalanceSyncError.invalidNativeBalance + } + + return balance + } + + private func tokenBalance( + _ token: SolanaTokenBalance, + wallet: String, + network: UniversalWalletRegistry.SolanaNetwork, + syncedAtMillis: Int64 + ) -> NormalizedSolanaTokenBalance? { + guard token.type == "token", + token.owner == wallet, + !token.isNative, + Self.supportedTokenPrograms.contains(token.program), + Self.isBase58PublicKey(token.accountAddress), + Self.isBase58PublicKey(token.mint), + Self.isBase58PublicKey(token.programId), + Self.isUnsignedInteger(token.amount), + Self.decimalRange.contains(token.decimals), + Self.isHumanText(token.uiAmountString, maxLength: 80) else { + return nil + } + + let balance = UniversalWalletIndexedAssetBalance( + accountId: network.id, + ecosystem: .solana, + chainId: network.chainId, + assetId: token.mint, + amount: token.amount, + decimals: token.decimals, + isNative: false, + symbol: Self.shortenMint(token.mint), + name: token.mint, + uiAmountString: token.uiAmountString, + tokenAccountId: token.accountAddress, + contractAddress: token.mint, + tokenProgram: token.program, + syncedAtMillis: syncedAtMillis + ) + + guard balance.validationErrors().isEmpty else { + return nil + } + + return NormalizedSolanaTokenBalance(source: token, balance: balance) + } + + private func tokenMetadataByMint( + _ mints: [String], + baseURL: String + ) async throws -> [String: SolanaTokenMetadata] { + let uniqueMints = Array(Set(mints)).sorted() + guard !uniqueMints.isEmpty else { + return [:] + } + + var metadataByMint: [String: SolanaTokenMetadata] = [:] + do { + for chunk in uniqueMints.chunked(size: SolanaIndexerRoutes.maxMetadataBatchSize) { + let response = try await client.tokenMetadataBatch(mints: chunk, baseURL: baseURL) + response.tokens.forEach { metadata in + if metadata.exists, Self.isBase58PublicKey(metadata.mint) { + metadataByMint[metadata.mint] = metadata + } + } + } + } catch is CancellationError { + throw CancellationError() + } catch { + return [:] + } + + return metadataByMint + } + + private func tokenSymbol(mint: String, metadata: SolanaTokenMetadata?) -> String { + Self.safeHumanText(metadata?.symbol, maxLength: 32) ?? Self.shortenMint(mint) + } + + private func tokenName(mint: String, metadata: SolanaTokenMetadata?) -> String { + Self.safeHumanText(metadata?.name, maxLength: 96) ?? mint + } + + private struct NormalizedSolanaTokenBalance { + let source: SolanaTokenBalance + let balance: UniversalWalletIndexedAssetBalance + } + + private static let decimalRange = 0 ... 255 + private static let supportedTokenPrograms: Set = ["spl-token", "token-2022"] + + private static func isUnsignedInteger(_ value: String) -> Bool { + matches(value, #"^(0|[1-9][0-9]*)$"#) + } + + private static func isBase58PublicKey(_ value: String) -> Bool { + matches(value, #"^[1-9A-HJ-NP-Za-km-z]{32,44}$"#) + } + + private static func isHumanText(_ value: String, maxLength: Int) -> Bool { + let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines) + return !normalized.isEmpty && + normalized.count <= maxLength && + normalized.rangeOfCharacter(from: .controlCharacters) == nil + } + + private static func safeHumanText(_ value: String?, maxLength: Int) -> String? { + guard let normalized = value?.trimmingCharacters(in: .whitespacesAndNewlines), + isHumanText(normalized, maxLength: maxLength) else { + return nil + } + + return normalized + } + + private static func shortenMint(_ mint: String) -> String { + guard mint.count > 12 else { + return mint + } + + return "\(mint.prefix(4))...\(mint.suffix(4))" + } + + private static func matches(_ value: String, _ pattern: String) -> Bool { + value.range(of: pattern, options: .regularExpression) != nil + } +} + +struct SolanaBalanceSyncResult: Equatable { + let wallet: String + let networkId: String + let chainId: String + let syncedAtMillis: Int64 + let nativeBalance: UniversalWalletIndexedAssetBalance + let tokenBalances: [UniversalWalletIndexedAssetBalance] + let balances: [UniversalWalletIndexedAssetBalance] +} + +enum SolanaBalanceSyncError: Error, Equatable { + case walletMismatch + case invalidSyncTimestamp + case invalidNativeBalance +} + +private extension Array { + func chunked(size: Int) -> [[Element]] { + stride(from: 0, to: count, by: size).map { + Array(self[$0 ..< Swift.min($0 + size, count)]) + } + } +} diff --git a/fearless/Common/Model/SolanaIndexerClient.swift b/fearless/Common/Model/SolanaIndexerClient.swift new file mode 100644 index 0000000000..c2097ba711 --- /dev/null +++ b/fearless/Common/Model/SolanaIndexerClient.swift @@ -0,0 +1,125 @@ +import Foundation + +protocol SolanaIndexerClientProtocol { + func serviceInfo(baseURL: String?) async throws -> SolanaIndexerServiceInfo + func verifyServiceInfo(baseURL: String?, expectedChainId: String) async throws -> SolanaIndexerServiceInfo + func balances(wallet: String, baseURL: String?) async throws -> SolanaWalletBalancesResponse + func assets(wallet: String, baseURL: String?) async throws -> SolanaWalletAssetsResponse + func state(wallet: String, baseURL: String?) async throws -> SolanaWalletStateResponse + func transactions(wallet: String, baseURL: String?, before: String?, limit: Int) async throws -> SolanaWalletTransactionsResponse + func tokenMetadata(mint: String, baseURL: String?) async throws -> SolanaTokenMetadata + func tokenMetadataBatch(mints: [String], baseURL: String?) async throws -> SolanaTokenMetadataBatchResponse +} + +extension SolanaIndexerClientProtocol { + func verifyServiceInfo(baseURL: String?) async throws -> SolanaIndexerServiceInfo { + try await verifyServiceInfo( + baseURL: baseURL, + expectedChainId: UniversalWalletRegistry.solanaMainnet.chainId + ) + } +} + +enum SolanaIndexerClientError: Error, Equatable { + case unexpectedServiceInfo(SolanaIndexerServiceInfo) +} + +final class SolanaIndexerClient: SolanaIndexerClientProtocol { + private let transport: UniversalWalletHTTPTransport + private let defaultBaseURL: String + private let decoder: JSONDecoder + private let encoder: JSONEncoder + + init( + transport: UniversalWalletHTTPTransport = URLSessionUniversalWalletHTTPTransport(), + defaultBaseURL: String = UniversalWalletRegistry.solanaIndexerBaseURL.absoluteString, + decoder: JSONDecoder = JSONDecoder(), + encoder: JSONEncoder = JSONEncoder() + ) { + self.transport = transport + self.defaultBaseURL = defaultBaseURL + self.decoder = decoder + self.encoder = encoder + } + + func serviceInfo(baseURL: String? = nil) async throws -> SolanaIndexerServiceInfo { + try await get(try SolanaIndexerRoutes.serviceInfoURL(baseURL: resolvedBaseURL(baseURL))) + } + + func verifyServiceInfo( + baseURL: String? = nil, + expectedChainId: String = UniversalWalletRegistry.solanaMainnet.chainId + ) async throws -> SolanaIndexerServiceInfo { + let info = try await serviceInfo(baseURL: baseURL) + guard info.isExpectedSIServiceInfo(expectedChainId: expectedChainId) else { + throw SolanaIndexerClientError.unexpectedServiceInfo(info) + } + return info + } + + func balances(wallet: String, baseURL: String? = nil) async throws -> SolanaWalletBalancesResponse { + try await get(try SolanaIndexerRoutes.balancesURL(wallet: wallet, baseURL: resolvedBaseURL(baseURL))) + } + + func assets(wallet: String, baseURL: String? = nil) async throws -> SolanaWalletAssetsResponse { + try await get(try SolanaIndexerRoutes.assetsURL(wallet: wallet, baseURL: resolvedBaseURL(baseURL))) + } + + func state(wallet: String, baseURL: String? = nil) async throws -> SolanaWalletStateResponse { + try await get(try SolanaIndexerRoutes.stateURL(wallet: wallet, baseURL: resolvedBaseURL(baseURL))) + } + + func transactions( + wallet: String, + baseURL: String? = nil, + before: String? = nil, + limit: Int = SolanaIndexerRoutes.defaultLimit + ) async throws -> SolanaWalletTransactionsResponse { + try await get( + try SolanaIndexerRoutes.transactionsURL( + wallet: wallet, + baseURL: resolvedBaseURL(baseURL), + before: before, + limit: limit + ) + ) + } + + func tokenMetadata(mint: String, baseURL: String? = nil) async throws -> SolanaTokenMetadata { + try await get(try SolanaIndexerRoutes.tokenMetadataURL(mint: mint, baseURL: resolvedBaseURL(baseURL))) + } + + func tokenMetadataBatch( + mints: [String], + baseURL: String? = nil + ) async throws -> SolanaTokenMetadataBatchResponse { + let body = try SolanaIndexerRoutes.tokenMetadataBatchRequest(mints: mints) + var request = urlRequest( + url: try SolanaIndexerRoutes.tokenMetadataBatchURL(baseURL: resolvedBaseURL(baseURL)), + method: "POST" + ) + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.httpBody = try encoder.encode(body) + + return try decode(try await transport.perform(request)) + } + + private func get(_ url: URL) async throws -> T { + try decode(try await transport.perform(urlRequest(url: url))) + } + + private func decode(_ data: Data) throws -> T { + try decoder.decode(T.self, from: data) + } + + private func urlRequest(url: URL, method: String = "GET") -> URLRequest { + var request = URLRequest(url: url) + request.httpMethod = method + return request + } + + private func resolvedBaseURL(_ baseURL: String?) -> String { + baseURL ?? defaultBaseURL + } +} diff --git a/fearless/Common/Model/SolanaIndexerContract.swift b/fearless/Common/Model/SolanaIndexerContract.swift new file mode 100644 index 0000000000..49d79f55e8 --- /dev/null +++ b/fearless/Common/Model/SolanaIndexerContract.swift @@ -0,0 +1,308 @@ +import Foundation + +enum SolanaIndexerRouteError: Error, Equatable { + case invalidBaseURL + case invalidWallet + case invalidMint + case invalidMints + case invalidBefore + case invalidLimit +} + +enum SolanaIndexerRoutes { + static let defaultLimit = 100 + static let maxLimit = 250 + static let maxMetadataBatchSize = 100 + + static func serviceInfoURL( + baseURL: String = UniversalWalletRegistry.solanaIndexerBaseURL.absoluteString + ) throws -> URL { + try makeURL("\(normalizeBaseURL(baseURL))/api/indexer/v1/service-info") + } + + static func balancesURL( + wallet: String, + baseURL: String = UniversalWalletRegistry.solanaIndexerBaseURL.absoluteString + ) throws -> URL { + try accountURL(baseURL: baseURL, wallet: wallet, section: "balances") + } + + static func assetsURL( + wallet: String, + baseURL: String = UniversalWalletRegistry.solanaIndexerBaseURL.absoluteString + ) throws -> URL { + try accountURL(baseURL: baseURL, wallet: wallet, section: "assets") + } + + static func stateURL( + wallet: String, + baseURL: String = UniversalWalletRegistry.solanaIndexerBaseURL.absoluteString + ) throws -> URL { + try accountURL(baseURL: baseURL, wallet: wallet, section: "state") + } + + static func transactionsURL( + wallet: String, + baseURL: String = UniversalWalletRegistry.solanaIndexerBaseURL.absoluteString, + before: String? = nil, + limit: Int = defaultLimit + ) throws -> URL { + guard (1 ... maxLimit).contains(limit) else { + throw SolanaIndexerRouteError.invalidLimit + } + + let wallet = try normalizePublicKey(wallet, error: .invalidWallet) + guard var components = URLComponents(string: "\(try normalizeBaseURL(baseURL))/api/indexer/v1/accounts/\(wallet)/txs") else { + throw SolanaIndexerRouteError.invalidBaseURL + } + + var queryItems = [URLQueryItem(name: "limit", value: String(limit))] + if let before { + queryItems.append(URLQueryItem(name: "before", value: try normalizeSignature(before))) + } + components.queryItems = queryItems + + guard let url = components.url else { + throw SolanaIndexerRouteError.invalidBaseURL + } + + return url + } + + static func tokenMetadataURL( + mint: String, + baseURL: String = UniversalWalletRegistry.solanaIndexerBaseURL.absoluteString + ) throws -> URL { + let mint = try normalizePublicKey(mint, error: .invalidMint) + return try makeURL("\(normalizeBaseURL(baseURL))/api/indexer/v1/tokens/\(mint)/metadata") + } + + static func tokenMetadataBatchURL( + baseURL: String = UniversalWalletRegistry.solanaIndexerBaseURL.absoluteString + ) throws -> URL { + try makeURL("\(normalizeBaseURL(baseURL))/api/indexer/v1/tokens/metadata") + } + + static func tokenMetadataBatchRequest(mints: [String]) throws -> SolanaTokenMetadataBatchRequest { + guard !mints.isEmpty, mints.count <= maxMetadataBatchSize else { + throw SolanaIndexerRouteError.invalidMints + } + + return SolanaTokenMetadataBatchRequest( + mints: try mints.map { try normalizePublicKey($0, error: .invalidMint) } + ) + } + + static func normalizeBaseURL(_ baseURL: String) throws -> String { + let trimmed = baseURL.trimmingCharacters(in: .whitespacesAndNewlines) + guard let url = URL(string: trimmed), let scheme = url.scheme, let host = url.host else { + throw SolanaIndexerRouteError.invalidBaseURL + } + + let isLocal = host == "localhost" || host == "127.0.0.1" + guard scheme == "https" || isLocal else { + throw SolanaIndexerRouteError.invalidBaseURL + } + + return trimmed.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + } + + private static func accountURL(baseURL: String, wallet: String, section: String) throws -> URL { + let wallet = try normalizePublicKey(wallet, error: .invalidWallet) + return try makeURL("\(normalizeBaseURL(baseURL))/api/indexer/v1/accounts/\(wallet)/\(section)") + } + + private static func makeURL(_ value: String) throws -> URL { + guard let url = URL(string: value) else { + throw SolanaIndexerRouteError.invalidBaseURL + } + + return url + } + + private static func normalizePublicKey(_ value: String, error: SolanaIndexerRouteError) throws -> String { + guard value.range(of: "^[1-9A-HJ-NP-Za-km-z]{32,44}$", options: .regularExpression) != nil else { + throw error + } + + return value + } + + private static func normalizeSignature(_ value: String) throws -> String { + guard value.range(of: "^[1-9A-HJ-NP-Za-km-z]{64,128}$", options: .regularExpression) != nil else { + throw SolanaIndexerRouteError.invalidBefore + } + + return value + } +} + +struct SolanaIndexerServiceInfo: Codable, Equatable { + let schemaVersion: Int + let serviceId: String + let serviceName: String + let ecosystem: String + let chainId: String + let network: String + let publicBaseUrl: String + let readOnly: Bool + let capabilities: [String] + let endpoints: [String: String] + + func isExpectedSIServiceInfo(expectedChainId: String = UniversalWalletRegistry.solanaMainnet.chainId) -> Bool { + schemaVersion == 1 && + serviceId == "si.soramitsu.io" && + ecosystem == "solana" && + chainId == expectedChainId && + publicBaseUrl == "https://si.soramitsu.io" && + readOnly + } +} + +struct SolanaNativeBalance: Codable, Equatable { + let type: String + let mint: String + let lamports: String + let decimals: Int + let uiAmountString: String +} + +struct SolanaTokenBalance: Codable, Equatable { + let type: String + let accountAddress: String + let mint: String + let owner: String + let program: String + let programId: String + let amount: String + let decimals: Int + let uiAmountString: String + let state: String? + let isNative: Bool + let delegatedAmount: String? + let rentExemptReserve: String? +} + +struct SolanaWalletBalancesResponse: Codable, Equatable { + let wallet: String + let native: SolanaNativeBalance + let tokens: [SolanaTokenBalance] + let total: Int + let syncedAt: Int64 +} + +struct SolanaWalletAsset: Codable, Equatable { + let type: String + let mint: String + let lamports: String? + let accountAddress: String? + let owner: String? + let program: String? + let programId: String? + let amount: String? + let decimals: Int + let uiAmountString: String + let state: String? + let isNative: Bool? + let delegatedAmount: String? + let rentExemptReserve: String? +} + +struct SolanaWalletAssetsResponse: Codable, Equatable { + let wallet: String + let assets: [SolanaWalletAsset] + let total: Int + let syncedAt: Int64 +} + +struct SolanaWalletStateResponse: Codable, Equatable { + let wallet: String + let exists: Bool + let lamports: String + let owner: String? + let executable: Bool + let rentEpoch: String? + let dataLength: Int + let syncedAt: Int64 +} + +struct SolanaTokenBalanceChange: Codable, Equatable { + let mint: String + let preAmount: String + let postAmount: String + let amountDelta: String + let decimals: Int + let uiAmountDeltaString: String +} + +struct SolanaWalletTransactionRecord: Codable, Equatable { + let signature: String + let slot: Int64 + let timestamp: Int64 + let status: String + let feeLamports: String? + let nativeBalanceChangeLamports: String? + let tokenBalanceChanges: [SolanaTokenBalanceChange] + let programIds: [String] + let solswapRoute: String? +} + +struct SolanaWalletTransactionsResponse: Codable, Equatable { + let wallet: String + let before: String? + let nextBefore: String? + let limit: Int + let total: Int + let syncedAt: Int64 + let transactions: [SolanaWalletTransactionRecord] +} + +struct SolanaTokenMetadata: Codable, Equatable { + let mint: String + let exists: Bool + let program: String + let programId: String? + let extensions: [String]? + let transferFeeConfig: SolanaTokenTransferFeeConfig? + let transferHook: SolanaTokenTransferHook? + let decimals: Int? + let supply: String? + let uiSupplyString: String? + let mintAuthority: String? + let freezeAuthority: String? + let isInitialized: Bool? + let name: String? + let symbol: String? + let uri: String? + let syncedAt: Int64 +} + +struct SolanaTokenTransferFee: Codable, Equatable { + let epoch: String? + let maximumFee: String? + let transferFeeBasisPoints: Int? +} + +struct SolanaTokenTransferFeeConfig: Codable, Equatable { + let transferFeeConfigAuthority: String? + let withdrawWithheldAuthority: String? + let withheldAmount: String? + let olderTransferFee: SolanaTokenTransferFee? + let newerTransferFee: SolanaTokenTransferFee? +} + +struct SolanaTokenTransferHook: Codable, Equatable { + let authority: String? + let programId: String? + let extraAccountMetasAddress: String? +} + +struct SolanaTokenMetadataBatchRequest: Codable, Equatable { + let mints: [String] +} + +struct SolanaTokenMetadataBatchResponse: Codable, Equatable { + let total: Int + let syncedAt: Int64 + let tokens: [SolanaTokenMetadata] +} diff --git a/fearless/Common/Model/SolanaKeyDerivation.swift b/fearless/Common/Model/SolanaKeyDerivation.swift new file mode 100644 index 0000000000..b6418146a1 --- /dev/null +++ b/fearless/Common/Model/SolanaKeyDerivation.swift @@ -0,0 +1,224 @@ +import CommonCrypto +import CryptoKit +import Foundation + +enum SolanaKeyDerivationError: Error, Equatable { + case emptyMnemonic + case emptySeed + case invalidDerivationPath + case nonHardenedDerivationComponent + case invalidDerivationIndex + case invalidPrivateKeyLength + case invalidPublicKeyLength + case keyDerivationFailed +} + +struct SolanaAccount: Equatable { + let derivationPath: String + let privateKey: Data + let chainCode: Data + let publicKey: Data + let address: String +} + +struct SolanaDerivedPrivateKey: Equatable { + let privateKey: Data + let chainCode: Data +} + +enum SolanaKeyDerivation { + private static let ed25519SeedKey = Data("ed25519 seed".utf8) + private static let hardenedOffset: Int64 = 0x8000_0000 + private static let maxChildIndex: Int64 = 0x7FFF_FFFF + private static let privateKeyLength = 32 + private static let publicKeyLength = 32 + private static let bip39Rounds = 2048 + private static let bip39SeedLength = 64 + private static let base58Alphabet = Array("123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz") + + static func deriveAccount( + mnemonic: String, + passphrase: String = "", + derivationPath: String = UniversalWalletDerivationPaths.solanaDefault + ) throws -> SolanaAccount { + let normalizedMnemonic = try normalizeMnemonic(mnemonic) + let seed = try bip39Seed(mnemonic: normalizedMnemonic, passphrase: passphrase) + let derivedKey = try derivePrivateKey(seed: seed, derivationPath: derivationPath) + let publicKey = try publicKey(fromPrivateKey: derivedKey.privateKey) + + return SolanaAccount( + derivationPath: derivationPath, + privateKey: derivedKey.privateKey, + chainCode: derivedKey.chainCode, + publicKey: publicKey, + address: try address(fromPublicKey: publicKey) + ) + } + + static func derivePrivateKey( + seed: Data, + derivationPath: String = UniversalWalletDerivationPaths.solanaDefault + ) throws -> SolanaDerivedPrivateKey { + guard !seed.isEmpty else { + throw SolanaKeyDerivationError.emptySeed + } + + let nodes = try parseHardenedDerivationPath(derivationPath) + var digest = hmacSha512(key: ed25519SeedKey, data: seed) + var privateKey = digest.prefix(privateKeyLength) + var chainCode = digest.suffix(privateKeyLength) + + for index in nodes { + var data = Data([0]) + data.append(privateKey) + + var hardenedIndex = UInt32(index + hardenedOffset).bigEndian + withUnsafeBytes(of: &hardenedIndex) { data.append(contentsOf: $0) } + + digest = hmacSha512(key: chainCode, data: data) + privateKey = digest.prefix(privateKeyLength) + chainCode = digest.suffix(privateKeyLength) + } + + return SolanaDerivedPrivateKey(privateKey: privateKey, chainCode: chainCode) + } + + static func publicKey(fromPrivateKey privateKey: Data) throws -> Data { + guard privateKey.count == privateKeyLength else { + throw SolanaKeyDerivationError.invalidPrivateKeyLength + } + + return try Curve25519.Signing.PrivateKey(rawRepresentation: privateKey) + .publicKey + .rawRepresentation + } + + static func address(fromPublicKey publicKey: Data) throws -> String { + guard publicKey.count == publicKeyLength else { + throw SolanaKeyDerivationError.invalidPublicKeyLength + } + + return base58Encode(Array(publicKey)) + } + + private static func parseHardenedDerivationPath(_ derivationPath: String) throws -> [Int64] { + guard !derivationPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw SolanaKeyDerivationError.invalidDerivationPath + } + + guard derivationPath == "m" || derivationPath.hasPrefix("m/") else { + throw SolanaKeyDerivationError.invalidDerivationPath + } + + guard derivationPath != "m" else { + return [] + } + + return try derivationPath + .dropFirst(2) + .split(separator: "/", omittingEmptySubsequences: false) + .map { component in + guard component.hasSuffix("'") else { + throw SolanaKeyDerivationError.nonHardenedDerivationComponent + } + + let indexText = component.dropLast() + guard !indexText.isEmpty, let index = Int64(indexText), index >= 0, index <= maxChildIndex else { + throw SolanaKeyDerivationError.invalidDerivationIndex + } + + return index + } + } + + private static func normalizeMnemonic(_ mnemonic: String) throws -> String { + let words = mnemonic + .split(whereSeparator: { $0.isWhitespace }) + .map(String.init) + + guard !words.isEmpty else { + throw SolanaKeyDerivationError.emptyMnemonic + } + + return words.joined(separator: " ") + } + + private static func bip39Seed(mnemonic: String, passphrase: String) throws -> Data { + let password = Array(mnemonic.utf8) + let salt = Array("mnemonic\(passphrase)".utf8) + var output = [UInt8](repeating: 0, count: bip39SeedLength) + + let status = password.withUnsafeBufferPointer { passwordBuffer in + salt.withUnsafeBufferPointer { saltBuffer in + passwordBuffer.baseAddress!.withMemoryRebound(to: Int8.self, capacity: password.count) { passwordPointer in + CCKeyDerivationPBKDF( + CCPBKDFAlgorithm(kCCPBKDF2), + passwordPointer, + password.count, + saltBuffer.baseAddress!, + salt.count, + CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA512), + UInt32(bip39Rounds), + &output, + output.count + ) + } + } + } + + guard status == kCCSuccess else { + throw SolanaKeyDerivationError.keyDerivationFailed + } + + return Data(output) + } + + private static func hmacSha512(key: Data, data: Data) -> Data { + var output = [UInt8](repeating: 0, count: Int(CC_SHA512_DIGEST_LENGTH)) + + key.withUnsafeBytes { keyBuffer in + data.withUnsafeBytes { dataBuffer in + CCHmac( + CCHmacAlgorithm(kCCHmacAlgSHA512), + keyBuffer.baseAddress!, + key.count, + dataBuffer.baseAddress!, + data.count, + &output + ) + } + } + + return Data(output) + } + + private static func base58Encode(_ bytes: [UInt8]) -> String { + guard !bytes.isEmpty else { + return "" + } + + var digits: [Int] = [] + + for byte in bytes { + var carry = Int(byte) + + for index in digits.indices { + let value = digits[index] * 256 + carry + digits[index] = value % base58Alphabet.count + carry = value / base58Alphabet.count + } + + while carry > 0 { + digits.append(carry % base58Alphabet.count) + carry /= base58Alphabet.count + } + } + + var result = String(repeating: String(base58Alphabet[0]), count: bytes.prefix { $0 == 0 }.count) + for digit in digits.reversed() { + result.append(base58Alphabet[digit]) + } + + return result + } +} diff --git a/fearless/Common/Model/SolanaRpcClient.swift b/fearless/Common/Model/SolanaRpcClient.swift new file mode 100644 index 0000000000..f5e43c9c4e --- /dev/null +++ b/fearless/Common/Model/SolanaRpcClient.swift @@ -0,0 +1,556 @@ +import Foundation + +protocol SolanaRpcClientProtocol { + func latestBlockhash(commitment: SolanaRpcCommitment, rpcURL: String?) async throws -> SolanaLatestBlockhashResponse + func feeForMessage(_ messageBase64: String, commitment: SolanaRpcCommitment, rpcURL: String?) async throws -> SolanaFeeForMessageResponse + func minimumBalanceForRentExemption(dataLength: Int, commitment: SolanaRpcCommitment, rpcURL: String?) async throws -> Int64 + func accountExists(address: String, commitment: SolanaRpcCommitment, rpcURL: String?) async throws -> Bool + func simulateTransaction(_ transactionBase64: String, options: SolanaSimulationOptions, rpcURL: String?) async throws -> SolanaSimulationResponse + func sendRawTransaction(_ transactionBase64: String, options: SolanaBroadcastOptions, rpcURL: String?) async throws -> String +} + +final class SolanaRpcClient: SolanaRpcClientProtocol { + private let transport: UniversalWalletHTTPTransport + private let defaultRPCURL: String + private var nextId: Int64 = 1 + + init( + transport: UniversalWalletHTTPTransport = URLSessionUniversalWalletHTTPTransport(), + defaultRPCURL: String = UniversalWalletRegistry.solanaMainnetRPCURL.absoluteString + ) throws { + _ = try SolanaRpcRoutes.normalizeRPCURL(defaultRPCURL) + self.transport = transport + self.defaultRPCURL = defaultRPCURL + } + + func latestBlockhash( + commitment: SolanaRpcCommitment = .confirmed, + rpcURL: String? = nil + ) async throws -> SolanaLatestBlockhashResponse { + try await request( + method: "getLatestBlockhash", + params: [["commitment": commitment.rawValue]], + rpcURL: rpcURL, + normalize: normalizeLatestBlockhashResponse + ) + } + + func feeForMessage( + _ messageBase64: String, + commitment: SolanaRpcCommitment = .confirmed, + rpcURL: String? = nil + ) async throws -> SolanaFeeForMessageResponse { + try await request( + method: "getFeeForMessage", + params: [ + try SolanaRpcRoutes.normalizeTransactionBase64(messageBase64), + ["commitment": commitment.rawValue] + ], + rpcURL: rpcURL, + normalize: normalizeFeeForMessageResponse + ) + } + + func minimumBalanceForRentExemption( + dataLength: Int, + commitment: SolanaRpcCommitment = .confirmed, + rpcURL: String? = nil + ) async throws -> Int64 { + try await request( + method: "getMinimumBalanceForRentExemption", + params: [ + try SolanaRpcRoutes.normalizeDataLength(dataLength), + ["commitment": commitment.rawValue] + ], + rpcURL: rpcURL + ) { value in + try Self.requireUnsignedInt64(value, error: .invalidRentResponse) + } + } + + func accountExists( + address: String, + commitment: SolanaRpcCommitment = .confirmed, + rpcURL: String? = nil + ) async throws -> Bool { + try await request( + method: "getAccountInfo", + params: [ + try SolanaRpcRoutes.normalizePublicKey(address), + [ + "commitment": commitment.rawValue, + "encoding": "base64" + ] + ], + rpcURL: rpcURL, + normalize: normalizeAccountInfoExists + ) + } + + func simulateTransaction( + _ transactionBase64: String, + options: SolanaSimulationOptions = SolanaSimulationOptions(), + rpcURL: String? = nil + ) async throws -> SolanaSimulationResponse { + if options.sigVerify, options.replaceRecentBlockhash { + throw SolanaRpcClientError.invalidSimulationOptions + } + + return try await request( + method: "simulateTransaction", + params: [ + try SolanaRpcRoutes.normalizeTransactionBase64(transactionBase64), + [ + "commitment": options.commitment.rawValue, + "encoding": "base64", + "replaceRecentBlockhash": options.replaceRecentBlockhash, + "sigVerify": options.sigVerify + ] + ], + rpcURL: rpcURL, + normalize: normalizeSimulationResponse + ) + } + + func sendRawTransaction( + _ transactionBase64: String, + options: SolanaBroadcastOptions = SolanaBroadcastOptions(), + rpcURL: String? = nil + ) async throws -> String { + if let maxRetries = options.maxRetries, !(0 ... 10).contains(maxRetries) { + throw SolanaRpcClientError.invalidMaxRetries + } + + var rpcOptions: [String: Any] = [ + "encoding": "base64", + "preflightCommitment": options.preflightCommitment.rawValue, + "skipPreflight": options.skipPreflight + ] + if let maxRetries = options.maxRetries { + rpcOptions["maxRetries"] = maxRetries + } + + return try await request( + method: "sendTransaction", + params: [ + try SolanaRpcRoutes.normalizeTransactionBase64(transactionBase64), + rpcOptions + ], + rpcURL: rpcURL, + normalize: normalizeSignature + ) + } + + private func request( + method: String, + params: [Any], + rpcURL: String?, + normalize: (Any) throws -> T + ) async throws -> T { + let id = nextId + nextId += 1 + let url = try URL(string: SolanaRpcRoutes.normalizeRPCURL(rpcURL ?? defaultRPCURL)).orThrow(.invalidRPCURL) + let body: [String: Any] = [ + "id": id, + "jsonrpc": "2.0", + "method": method, + "params": params + ] + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONSerialization.data(withJSONObject: body) + + let data: Data + do { + data = try await transport.perform(request) + } catch let UniversalWalletHTTPTransportError.httpStatusCode(status, _) { + throw SolanaRpcClientError.httpStatusCode(status) + } + + let json: Any + do { + json = try JSONSerialization.jsonObject(with: data) + } catch { + throw SolanaRpcClientError.invalidRPCResponse + } + guard let object = json as? [String: Any], + Self.jsonRpcVersion(object["jsonrpc"]) == "2.0", + Self.int64(object["id"]) == id else { + throw SolanaRpcClientError.invalidRPCResponse + } + if let error = object["error"], !(error is NSNull) { + throw SolanaRpcClientError.rpcError(Self.normalizeRPCError(error)) + } + guard let result = object["result"] else { + throw SolanaRpcClientError.invalidRPCResponse + } + + return try normalize(result) + } + + private func normalizeLatestBlockhashResponse(_ value: Any) throws -> SolanaLatestBlockhashResponse { + guard let object = value as? [String: Any] else { + throw SolanaRpcClientError.invalidBlockhashResponse + } + return SolanaLatestBlockhashResponse( + context: try Self.normalizeContext(object["context"], error: .invalidBlockhashResponse), + value: try Self.normalizeLatestBlockhash(object["value"], error: .invalidBlockhashResponse) + ) + } + + private func normalizeFeeForMessageResponse(_ value: Any) throws -> SolanaFeeForMessageResponse { + guard let object = value as? [String: Any] else { + throw SolanaRpcClientError.invalidFeeResponse + } + let fee = Self.isJSONNull(object["value"]) + ? nil + : try Self.requireUnsignedInt64(object["value"], error: .invalidFeeResponse) + + return SolanaFeeForMessageResponse( + context: try Self.normalizeContext(object["context"], error: .invalidFeeResponse), + value: fee + ) + } + + private func normalizeSimulationResponse(_ value: Any) throws -> SolanaSimulationResponse { + guard let object = value as? [String: Any], + let simulationValue = object["value"] as? [String: Any] else { + throw SolanaRpcClientError.invalidSimulationResponse + } + let logs: [String]? + if Self.isJSONNull(simulationValue["logs"]) || simulationValue["logs"] == nil { + logs = nil + } else if let values = simulationValue["logs"] as? [String] { + logs = values + } else { + throw SolanaRpcClientError.invalidSimulationResponse + } + let unitsConsumed = Self.isJSONNull(simulationValue["unitsConsumed"]) || simulationValue["unitsConsumed"] == nil + ? nil + : try Self.requireUnsignedInt64(simulationValue["unitsConsumed"], error: .invalidSimulationResponse) + let replacementBlockhash = Self.isJSONNull(simulationValue["replacementBlockhash"]) || simulationValue["replacementBlockhash"] == nil + ? nil + : try Self.normalizeLatestBlockhash(simulationValue["replacementBlockhash"], error: .invalidSimulationResponse) + let errorJSON: String? + if let errorValue = simulationValue["err"], !Self.isJSONNull(errorValue) { + errorJSON = Self.jsonString(errorValue) + } else { + errorJSON = nil + } + + return SolanaSimulationResponse( + context: try Self.normalizeContext(object["context"], error: .invalidSimulationResponse), + value: SolanaSimulationValue( + errorJSON: errorJSON, + logs: logs, + replacementBlockhash: replacementBlockhash, + unitsConsumed: unitsConsumed + ) + ) + } + + private func normalizeSignature(_ value: Any) throws -> String { + guard let signature = value as? String, + Self.matches(signature, #"^[1-9A-HJ-NP-Za-km-z]{64,128}$"#) else { + throw SolanaRpcClientError.invalidSignatureResponse + } + + return signature + } + + private static func normalizeContext(_ value: Any?, error: SolanaRpcClientError) throws -> SolanaRpcContext { + guard let object = value as? [String: Any] else { + throw error + } + if let apiVersion = object["apiVersion"], !isJSONNull(apiVersion), !(apiVersion is String) { + throw error + } + + return SolanaRpcContext( + apiVersion: object["apiVersion"] as? String, + slot: try requireUnsignedInt64(object["slot"], error: error) + ) + } + + private func normalizeAccountInfoExists(_ value: Any) throws -> Bool { + guard let object = value as? [String: Any] else { + throw SolanaRpcClientError.invalidAccountInfoResponse + } + _ = try Self.normalizeContext(object["context"], error: .invalidAccountInfoResponse) + guard let accountValue = object["value"] else { + throw SolanaRpcClientError.invalidAccountInfoResponse + } + if Self.isJSONNull(accountValue) { + return false + } + guard accountValue is [String: Any] else { + throw SolanaRpcClientError.invalidAccountInfoResponse + } + + return true + } + + private static func normalizeLatestBlockhash(_ value: Any?, error: SolanaRpcClientError) throws -> SolanaLatestBlockhash { + guard let object = value as? [String: Any], + let blockhash = object["blockhash"] as? String, + matches(blockhash, #"^[1-9A-HJ-NP-Za-km-z]{32,64}$"#) else { + throw error + } + + return SolanaLatestBlockhash( + blockhash: blockhash, + lastValidBlockHeight: try requireUnsignedInt64(object["lastValidBlockHeight"], error: error) + ) + } + + private static func requireUnsignedInt64(_ value: Any?, error: SolanaRpcClientError) throws -> Int64 { + if value is String { + throw error + } + guard let value = int64(value), value >= 0 else { + throw error + } + + return value + } + + private static func int64(_ value: Any?) -> Int64? { + if let value = value as? String { + return Int64(value) + } + if let value = value as? Int64 { + return value + } + if let value = value as? Int { + return Int64(value) + } + if let value = value as? UInt64 { + return Int64(exactly: value) + } + if let value = value as? UInt { + return Int64(exactly: value) + } + if let value = value as? Double { + guard value.isFinite, value.rounded(.towardZero) == value else { + return nil + } + return Int64(exactly: value) + } + guard let number = value as? NSNumber, !(value is Bool) else { + return nil + } + let double = number.doubleValue + guard double.isFinite, double.rounded(.towardZero) == double else { + return nil + } + + return number.int64Value + } + + private static func jsonRpcVersion(_ value: Any?) -> String? { + if let value = value as? String { + return value + } + if let number = value as? NSNumber, !(value is Bool) { + return number.stringValue + } + + return nil + } + + private static func isJSONNull(_ value: Any?) -> Bool { + guard let value else { + return false + } + + return value is NSNull + } + + private static func normalizeRPCError(_ value: Any) -> SolanaJsonRpcErrorPayload { + guard let object = value as? [String: Any] else { + return SolanaJsonRpcErrorPayload(code: -1, message: "Unknown Solana RPC error", dataJSON: nil) + } + + return SolanaJsonRpcErrorPayload( + code: int64(object["code"]).flatMap { Int(exactly: $0) } ?? -1, + message: object["message"] as? String ?? "Unknown Solana RPC error", + dataJSON: object["data"].flatMap(jsonString) + ) + } + + private static func jsonString(_ value: Any) -> String? { + guard JSONSerialization.isValidJSONObject(value), + let data = try? JSONSerialization.data(withJSONObject: value, options: [.sortedKeys]) else { + return nil + } + + return String(data: data, encoding: .utf8) + } + + private static func matches(_ value: String, _ pattern: String) -> Bool { + value.range(of: pattern, options: .regularExpression) != nil + } +} + +enum SolanaRpcRoutes { + private static let maxTransactionBase64Length = 1_000_000 + private static let maxRentDataLength = 10_000_000 + + static func normalizeRPCURL(_ rpcURL: String) throws -> String { + let trimmed = rpcURL.trimmingCharacters(in: .whitespacesAndNewlines) + guard var components = URLComponents(string: trimmed), + let scheme = components.scheme, + let host = components.host else { + throw SolanaRpcClientError.invalidRPCURL + } + let isLocal = host == "localhost" || host == "127.0.0.1" + guard scheme == "https" || isLocal else { + throw SolanaRpcClientError.invalidRPCURL + } + + components.query = nil + components.fragment = nil + guard let url = components.url else { + throw SolanaRpcClientError.invalidRPCURL + } + + return url.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + } + + static func normalizeTransactionBase64(_ transactionBase64: String) throws -> String { + guard !transactionBase64.isEmpty, + transactionBase64.count <= maxTransactionBase64Length, + transactionBase64.count % 4 == 0, + transactionBase64.range( + of: #"^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$"#, + options: .regularExpression + ) != nil else { + throw SolanaRpcClientError.invalidTransaction + } + + return transactionBase64 + } + + static func normalizeDataLength(_ dataLength: Int) throws -> Int { + guard (0 ... maxRentDataLength).contains(dataLength) else { + throw SolanaRpcClientError.invalidDataLength + } + + return dataLength + } + + static func normalizePublicKey(_ publicKey: String) throws -> String { + let trimmed = publicKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.range(of: #"^[1-9A-HJ-NP-Za-km-z]{32,44}$"#, options: .regularExpression) != nil else { + throw SolanaRpcClientError.invalidAccountAddress + } + + return trimmed + } +} + +enum SolanaRpcCommitment: String, Equatable { + case processed + case confirmed + case finalized +} + +struct SolanaBroadcastOptions: Equatable { + let maxRetries: Int? + let preflightCommitment: SolanaRpcCommitment + let skipPreflight: Bool + + init( + maxRetries: Int? = nil, + preflightCommitment: SolanaRpcCommitment = .confirmed, + skipPreflight: Bool = false + ) { + self.maxRetries = maxRetries + self.preflightCommitment = preflightCommitment + self.skipPreflight = skipPreflight + } +} + +struct SolanaSimulationOptions: Equatable { + let commitment: SolanaRpcCommitment + let replaceRecentBlockhash: Bool + let sigVerify: Bool + + init( + commitment: SolanaRpcCommitment = .confirmed, + replaceRecentBlockhash: Bool = true, + sigVerify: Bool = false + ) { + self.commitment = commitment + self.replaceRecentBlockhash = replaceRecentBlockhash + self.sigVerify = sigVerify + } +} + +struct SolanaRpcContext: Equatable { + let apiVersion: String? + let slot: Int64 +} + +struct SolanaLatestBlockhash: Equatable { + let blockhash: String + let lastValidBlockHeight: Int64 +} + +struct SolanaLatestBlockhashResponse: Equatable { + let context: SolanaRpcContext + let value: SolanaLatestBlockhash +} + +struct SolanaFeeForMessageResponse: Equatable { + let context: SolanaRpcContext + let value: Int64? +} + +struct SolanaSimulationValue: Equatable { + let errorJSON: String? + let logs: [String]? + let replacementBlockhash: SolanaLatestBlockhash? + let unitsConsumed: Int64? +} + +struct SolanaSimulationResponse: Equatable { + let context: SolanaRpcContext + let value: SolanaSimulationValue +} + +struct SolanaJsonRpcErrorPayload: Equatable { + let code: Int + let message: String + let dataJSON: String? +} + +enum SolanaRpcClientError: Error, Equatable { + case invalidRPCURL + case invalidAccountAddress + case invalidTransaction + case invalidMaxRetries + case invalidDataLength + case invalidSimulationOptions + case httpStatusCode(Int) + case invalidRPCResponse + case rpcError(SolanaJsonRpcErrorPayload) + case invalidBlockhashResponse + case invalidFeeResponse + case invalidRentResponse + case invalidAccountInfoResponse + case invalidSimulationResponse + case invalidSignatureResponse +} + +private extension Optional where Wrapped == URL { + func orThrow(_ error: SolanaRpcClientError) throws -> URL { + guard let value = self else { + throw error + } + + return value + } +} diff --git a/fearless/Common/Model/SolanaSendService.swift b/fearless/Common/Model/SolanaSendService.swift new file mode 100644 index 0000000000..3c8fc2f9f2 --- /dev/null +++ b/fearless/Common/Model/SolanaSendService.swift @@ -0,0 +1,566 @@ +import Foundation + +final class SolanaSendService { + private let rpcClient: SolanaRpcClientProtocol + private let balanceProvider: SolanaSendBalanceProvider? + + init( + rpcClient: SolanaRpcClientProtocol, + balanceProvider: SolanaSendBalanceProvider? = nil + ) { + self.rpcClient = rpcClient + self.balanceProvider = balanceProvider + } + + func prepare(_ request: SolanaSendRequest) async throws -> SolanaPreparedTransferTransaction { + try SolanaTransferTransactionBuilder.requireLamports(request.lamports) + _ = try SolanaTransferTransactionBuilder.normalizeRecipientAddress(request.recipientAddress) + + let account: SolanaAccount + do { + account = try SolanaKeyDerivation.deriveAccount( + mnemonic: request.mnemonic, + passphrase: request.passphrase, + derivationPath: request.derivationPath + ) + } catch { + throw SolanaSendServiceError.invalidAccount + } + + let blockhash = try await rpcClient.latestBlockhash( + commitment: request.commitment, + rpcURL: request.rpcURL + ) + let unsigned = try SolanaTransferTransactionBuilder.buildNativeTransfer( + senderAddress: account.address, + recipientAddress: request.recipientAddress, + lamports: request.lamports, + recentBlockhash: blockhash.value.blockhash + ) + let feeResponse = try await rpcClient.feeForMessage( + unsigned.messageBase64, + commitment: request.commitment, + rpcURL: request.rpcURL + ) + let fee = feeResponse.value + + guard let fee else { + throw SolanaSendServiceError.feeUnavailable + } + + if request.validateBalance { + try await requireSufficientSolBalance( + wallet: account.address, + requiredLamports: try Self.addUnsignedLamports(request.lamports, fee) + ) + } + + let signed = try SolanaTransactionSigner.signSerializedTransaction( + mnemonic: request.mnemonic, + transactionBase64: unsigned.transactionBase64, + expectedSigner: account.address, + derivationPath: request.derivationPath, + passphrase: request.passphrase + ) + let simulation: SolanaSimulationResponse? + if request.simulateBeforeSend { + let response = try await rpcClient.simulateTransaction( + signed.signedTransactionBase64, + options: request.simulationOptions, + rpcURL: request.rpcURL + ) + if response.value.errorJSON != nil { + throw SolanaSendServiceError.simulationFailed + } + simulation = response + } else { + simulation = nil + } + + return SolanaPreparedTransferTransaction( + blockhash: blockhash, + feeLamports: fee, + signed: signed, + simulation: simulation, + unsigned: unsigned + ) + } + + func send(_ request: SolanaSendRequest) async throws -> SolanaSentTransferTransaction { + let prepared = try await prepare(request) + let signature = try await rpcClient.sendRawTransaction( + prepared.signed.signedTransactionBase64, + options: request.broadcastOptions, + rpcURL: request.rpcURL + ) + + guard signature == prepared.signed.signatureBase58 else { + throw SolanaSendServiceError.broadcastSignatureMismatch + } + + return SolanaSentTransferTransaction( + prepared: prepared, + signature: signature + ) + } + + func prepareTokenTransfer(_ request: SolanaTokenSendRequest) async throws -> SolanaPreparedTokenTransferTransaction { + let account: SolanaAccount + do { + account = try SolanaKeyDerivation.deriveAccount( + mnemonic: request.mnemonic, + passphrase: request.passphrase, + derivationPath: request.derivationPath + ) + } catch { + throw SolanaSendServiceError.invalidAccount + } + + try validateToken2022TransferPolicy(request) + + let blockhash = try await rpcClient.latestBlockhash( + commitment: request.commitment, + rpcURL: request.rpcURL + ) + let destinationAssociatedTokenAccount: String? + if let destinationWalletAddress = request.destinationWalletAddress { + let associated = try SolanaTransferTransactionBuilder.deriveAssociatedTokenAccountAddress( + walletAddress: destinationWalletAddress, + mintAddress: request.mintAddress, + tokenProgram: request.tokenProgram + ) + if let requestedDestination = request.destinationTokenAccount?.trimmingCharacters(in: .whitespacesAndNewlines), + !requestedDestination.isEmpty, + requestedDestination != associated { + throw SolanaTransferTransactionError.invalidAssociatedTokenAccount + } + destinationAssociatedTokenAccount = associated + } else { + destinationAssociatedTokenAccount = nil + } + let destinationAssociatedTokenAccountExists: Bool + if let destinationAssociatedTokenAccount { + destinationAssociatedTokenAccountExists = try await rpcClient.accountExists( + address: destinationAssociatedTokenAccount, + commitment: request.commitment, + rpcURL: request.rpcURL + ) + } else { + destinationAssociatedTokenAccountExists = false + } + let unsigned = try SolanaTransferTransactionBuilder.buildTokenSendChecked( + ownerAddress: account.address, + sourceTokenAccount: request.sourceTokenAccount, + destinationTokenAccount: destinationAssociatedTokenAccountExists + ? destinationAssociatedTokenAccount + : request.destinationTokenAccount, + mintAddress: request.mintAddress, + rawAmount: request.rawAmount, + decimals: request.decimals, + recentBlockhash: blockhash.value.blockhash, + tokenProgram: request.tokenProgram, + extraAccounts: request.extraAccounts, + destinationWalletAddress: destinationAssociatedTokenAccountExists + ? nil + : request.destinationWalletAddress, + associatedTokenAccountInstruction: request.associatedTokenAccountInstruction + ) + let feeResponse = try await rpcClient.feeForMessage( + unsigned.messageBase64, + commitment: request.commitment, + rpcURL: request.rpcURL + ) + let fee = feeResponse.value + + guard let fee else { + throw SolanaSendServiceError.feeUnavailable + } + + if request.validateBalance { + let rentLamports: Int64 + if unsigned.createsDestinationAssociatedTokenAccount, balanceProvider != nil { + rentLamports = try await rpcClient.minimumBalanceForRentExemption( + dataLength: Self.solanaTokenAccountDataLength, + commitment: request.commitment, + rpcURL: request.rpcURL + ) + } else { + rentLamports = 0 + } + try await requireSufficientTokenTransferBalance( + wallet: account.address, + request: request, + requiredNativeLamports: try Self.addUnsignedLamports(fee, rentLamports) + ) + } + + let signed = try SolanaTransactionSigner.signSerializedTransaction( + mnemonic: request.mnemonic, + transactionBase64: unsigned.transactionBase64, + expectedSigner: account.address, + derivationPath: request.derivationPath, + passphrase: request.passphrase + ) + let simulation: SolanaSimulationResponse? + if request.simulateBeforeSend { + let response = try await rpcClient.simulateTransaction( + signed.signedTransactionBase64, + options: request.simulationOptions, + rpcURL: request.rpcURL + ) + if response.value.errorJSON != nil { + throw SolanaSendServiceError.simulationFailed + } + simulation = response + } else { + simulation = nil + } + + return SolanaPreparedTokenTransferTransaction( + blockhash: blockhash, + feeLamports: fee, + signed: signed, + simulation: simulation, + unsigned: unsigned + ) + } + + func sendTokenTransfer(_ request: SolanaTokenSendRequest) async throws -> SolanaSentTokenTransferTransaction { + let prepared = try await prepareTokenTransfer(request) + let signature = try await rpcClient.sendRawTransaction( + prepared.signed.signedTransactionBase64, + options: request.broadcastOptions, + rpcURL: request.rpcURL + ) + + guard signature == prepared.signed.signatureBase58 else { + throw SolanaSendServiceError.broadcastSignatureMismatch + } + + return SolanaSentTokenTransferTransaction( + prepared: prepared, + signature: signature + ) + } + + private func requireSufficientSolBalance( + wallet: String, + requiredLamports: String + ) async throws { + guard let balanceProvider else { + return + } + + let balances = try await balanceProvider.balances(wallet: wallet) + guard Self.compareUnsignedDecimal(balances.nativeBalance.amount, requiredLamports) != .orderedAscending else { + throw SolanaSendServiceError.insufficientSolBalance + } + } + + private func requireSufficientTokenTransferBalance( + wallet: String, + request: SolanaTokenSendRequest, + requiredNativeLamports: String + ) async throws { + guard let balanceProvider else { + return + } + + let balances = try await balanceProvider.balances(wallet: wallet) + guard Self.compareUnsignedDecimal(balances.nativeBalance.amount, requiredNativeLamports) != .orderedAscending else { + throw SolanaSendServiceError.insufficientSolBalance + } + guard let tokenBalance = balances.tokenBalances.first(where: { + $0.tokenAccountId == request.sourceTokenAccount && $0.contractAddress == request.mintAddress + }) else { + throw SolanaSendServiceError.insufficientTokenBalance + } + guard tokenBalance.decimals == request.decimals else { + throw SolanaSendServiceError.tokenBalanceMismatch + } + guard Self.compareUnsignedDecimal(tokenBalance.amount, String(request.rawAmount)) != .orderedAscending else { + throw SolanaSendServiceError.insufficientTokenBalance + } + } + + private func validateToken2022TransferPolicy(_ request: SolanaTokenSendRequest) throws { + guard let metadata = request.tokenMetadata else { + return + } + guard metadata.exists, metadata.mint == request.mintAddress else { + throw SolanaSendServiceError.tokenMetadataMismatch + } + + let expectedProgramName: String + switch request.tokenProgram { + case .splToken: + expectedProgramName = "spl-token" + case .token2022: + expectedProgramName = "token-2022" + } + guard metadata.program == expectedProgramName, + metadata.programId.map({ $0 == request.tokenProgram.programAddress }) != false else { + throw SolanaSendServiceError.tokenMetadataMismatch + } + guard request.tokenProgram == .token2022 else { + return + } + + let extensions = Set(metadata.extensions ?? []) + if extensions.contains("nonTransferable") { + throw SolanaSendServiceError.unsupportedToken2022Extension + } + if metadata.transferFeeConfig != nil || extensions.contains("transferFeeConfig"), + !request.acknowledgeToken2022TransferFee { + throw SolanaSendServiceError.token2022TransferFeeNotAcknowledged + } + if metadata.transferHook != nil || extensions.contains("transferHook"), request.extraAccounts.isEmpty { + throw SolanaSendServiceError.token2022TransferHookAccountsMissing + } + } + + private static func compareUnsignedDecimal(_ left: String, _ right: String) -> ComparisonResult { + let normalizedLeft = normalizeUnsignedDecimal(left) + let normalizedRight = normalizeUnsignedDecimal(right) + + guard let normalizedLeft, let normalizedRight else { + return .orderedAscending + } + if normalizedLeft.count != normalizedRight.count { + return normalizedLeft.count < normalizedRight.count ? .orderedAscending : .orderedDescending + } + if normalizedLeft == normalizedRight { + return .orderedSame + } + + return normalizedLeft < normalizedRight ? .orderedAscending : .orderedDescending + } + + private static func normalizeUnsignedDecimal(_ value: String) -> String? { + guard value.range(of: #"^(0|[1-9][0-9]*)$"#, options: .regularExpression) != nil else { + return nil + } + + return value + } + + private static func addUnsignedLamports(_ left: Int64, _ right: Int64) throws -> String { + guard left >= 0, right >= 0 else { + throw SolanaSendServiceError.feeUnavailable + } + + let added = left.addingReportingOverflow(right) + guard added.overflow else { + return String(added.partialValue) + } + + return addUnsignedDecimal(String(left), String(right)) + } + + private static func addUnsignedDecimal(_ left: String, _ right: String) -> String { + var leftDigits = left.reversed().map { Int(String($0))! } + var rightDigits = right.reversed().map { Int(String($0))! } + let maxCount = max(leftDigits.count, rightDigits.count) + leftDigits += Array(repeating: 0, count: maxCount - leftDigits.count) + rightDigits += Array(repeating: 0, count: maxCount - rightDigits.count) + + var carry = 0 + var result: [Int] = [] + for index in 0 ..< maxCount { + let sum = leftDigits[index] + rightDigits[index] + carry + result.append(sum % 10) + carry = sum / 10 + } + if carry > 0 { + result.append(carry) + } + + return result.reversed().map(String.init).joined() + } + + private static let solanaTokenAccountDataLength = 165 +} + +struct SolanaSendRequest: Equatable { + let mnemonic: String + let recipientAddress: String + let lamports: Int64 + let passphrase: String + let derivationPath: String + let commitment: SolanaRpcCommitment + let rpcURL: String? + let simulationOptions: SolanaSimulationOptions + let broadcastOptions: SolanaBroadcastOptions + let simulateBeforeSend: Bool + let validateBalance: Bool + + init( + mnemonic: String, + recipientAddress: String, + lamports: Int64, + passphrase: String = "", + derivationPath: String = UniversalWalletDerivationPaths.solanaDefault, + commitment: SolanaRpcCommitment = .confirmed, + rpcURL: String? = nil, + simulationOptions: SolanaSimulationOptions? = nil, + broadcastOptions: SolanaBroadcastOptions? = nil, + simulateBeforeSend: Bool = true, + validateBalance: Bool = true + ) { + self.mnemonic = mnemonic + self.recipientAddress = recipientAddress + self.lamports = lamports + self.passphrase = passphrase + self.derivationPath = derivationPath + self.commitment = commitment + self.rpcURL = rpcURL + self.simulationOptions = simulationOptions ?? SolanaSimulationOptions( + commitment: commitment, + replaceRecentBlockhash: false, + sigVerify: true + ) + self.broadcastOptions = broadcastOptions ?? SolanaBroadcastOptions(preflightCommitment: commitment) + self.simulateBeforeSend = simulateBeforeSend + self.validateBalance = validateBalance + } +} + +struct SolanaPreparedTransferTransaction: Equatable { + let blockhash: SolanaLatestBlockhashResponse + let feeLamports: Int64 + let signed: SignedSolanaTransaction + let simulation: SolanaSimulationResponse? + let unsigned: SolanaUnsignedTransferTransaction +} + +struct SolanaSentTransferTransaction: Equatable { + let prepared: SolanaPreparedTransferTransaction + let signature: String +} + +struct SolanaTokenSendRequest: Equatable { + let mnemonic: String + let sourceTokenAccount: String + let destinationTokenAccount: String? + let mintAddress: String + let rawAmount: Int64 + let decimals: Int + let passphrase: String + let derivationPath: String + let commitment: SolanaRpcCommitment + let rpcURL: String? + let tokenProgram: SolanaTokenProgram + let extraAccounts: [SolanaTokenTransferExtraAccount] + let tokenMetadata: SolanaTokenMetadata? + let acknowledgeToken2022TransferFee: Bool + let destinationWalletAddress: String? + let associatedTokenAccountInstruction: SolanaAssociatedTokenAccountInstruction + let simulationOptions: SolanaSimulationOptions + let broadcastOptions: SolanaBroadcastOptions + let simulateBeforeSend: Bool + let validateBalance: Bool + + init( + mnemonic: String, + sourceTokenAccount: String, + destinationTokenAccount: String? = nil, + mintAddress: String, + rawAmount: Int64, + decimals: Int, + passphrase: String = "", + derivationPath: String = UniversalWalletDerivationPaths.solanaDefault, + commitment: SolanaRpcCommitment = .confirmed, + rpcURL: String? = nil, + tokenProgram: SolanaTokenProgram = .splToken, + extraAccounts: [SolanaTokenTransferExtraAccount] = [], + tokenMetadata: SolanaTokenMetadata? = nil, + acknowledgeToken2022TransferFee: Bool = false, + destinationWalletAddress: String? = nil, + associatedTokenAccountInstruction: SolanaAssociatedTokenAccountInstruction = .createIdempotent, + simulationOptions: SolanaSimulationOptions? = nil, + broadcastOptions: SolanaBroadcastOptions? = nil, + simulateBeforeSend: Bool = true, + validateBalance: Bool = true + ) { + self.mnemonic = mnemonic + self.sourceTokenAccount = sourceTokenAccount + self.destinationTokenAccount = destinationTokenAccount + self.mintAddress = mintAddress + self.rawAmount = rawAmount + self.decimals = decimals + self.passphrase = passphrase + self.derivationPath = derivationPath + self.commitment = commitment + self.rpcURL = rpcURL + self.tokenProgram = tokenProgram + self.extraAccounts = extraAccounts + self.tokenMetadata = tokenMetadata + self.acknowledgeToken2022TransferFee = acknowledgeToken2022TransferFee + self.destinationWalletAddress = destinationWalletAddress + self.associatedTokenAccountInstruction = associatedTokenAccountInstruction + self.simulationOptions = simulationOptions ?? SolanaSimulationOptions( + commitment: commitment, + replaceRecentBlockhash: false, + sigVerify: true + ) + self.broadcastOptions = broadcastOptions ?? SolanaBroadcastOptions(preflightCommitment: commitment) + self.simulateBeforeSend = simulateBeforeSend + self.validateBalance = validateBalance + } +} + +struct SolanaPreparedTokenTransferTransaction: Equatable { + let blockhash: SolanaLatestBlockhashResponse + let feeLamports: Int64 + let signed: SignedSolanaTransaction + let simulation: SolanaSimulationResponse? + let unsigned: SolanaUnsignedTokenSendTransaction +} + +struct SolanaSentTokenTransferTransaction: Equatable { + let prepared: SolanaPreparedTokenTransferTransaction + let signature: String +} + +enum SolanaSendServiceError: Error, Equatable { + case invalidAccount + case feeUnavailable + case simulationFailed + case broadcastSignatureMismatch + case insufficientSolBalance + case insufficientTokenBalance + case tokenBalanceMismatch + case tokenMetadataMismatch + case token2022TransferFeeNotAcknowledged + case token2022TransferHookAccountsMissing + case unsupportedToken2022Extension +} + +protocol SolanaSendBalanceProvider { + func balances(wallet: String) async throws -> SolanaBalanceSyncResult +} + +final class SolanaBalanceSyncSendBalanceProvider: SolanaSendBalanceProvider { + private let balanceSync: SolanaBalanceSync + private let network: UniversalWalletRegistry.SolanaNetwork + private let baseURL: String? + + init( + balanceSync: SolanaBalanceSync, + network: UniversalWalletRegistry.SolanaNetwork = UniversalWalletRegistry.solanaMainnet, + baseURL: String? = nil + ) { + self.balanceSync = balanceSync + self.network = network + self.baseURL = baseURL + } + + func balances(wallet: String) async throws -> SolanaBalanceSyncResult { + try await balanceSync.balances( + wallet: wallet, + network: network, + baseURL: baseURL, + includeTokenMetadata: false + ) + } +} diff --git a/fearless/Common/Model/SolanaSigner.swift b/fearless/Common/Model/SolanaSigner.swift new file mode 100644 index 0000000000..8e5c307601 --- /dev/null +++ b/fearless/Common/Model/SolanaSigner.swift @@ -0,0 +1,124 @@ +import CryptoKit +import Foundation + +enum SolanaSignerError: Error, Equatable { + case emptyMessage + case messageTooLarge + case invalidPrivateKeyLength + case invalidPublicKeyLength + case invalidSignatureLength +} + +struct SolanaSignature: Equatable { + let derivationPath: String + let address: String + let publicKey: Data + let message: Data + let signature: Data + let signatureBase58: String + let signatureHex: String +} + +enum SolanaSigner { + private static let privateKeyLength = 32 + private static let publicKeyLength = 32 + private static let signatureLength = 64 + private static let maxMessageLength = 64 * 1024 + private static let base58Alphabet = Array("123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz") + + static func signMessage( + mnemonic: String, + message: Data, + passphrase: String = "", + derivationPath: String = UniversalWalletDerivationPaths.solanaDefault + ) throws -> SolanaSignature { + let account = try SolanaKeyDerivation.deriveAccount( + mnemonic: mnemonic, + passphrase: passphrase, + derivationPath: derivationPath + ) + let signature = try signMessage(privateKey: account.privateKey, message: message) + + return SolanaSignature( + derivationPath: account.derivationPath, + address: account.address, + publicKey: account.publicKey, + message: message, + signature: signature, + signatureBase58: base58Encode(Array(signature)), + signatureHex: signature.hexString + ) + } + + static func signMessage(privateKey: Data, message: Data) throws -> Data { + guard privateKey.count == privateKeyLength else { + throw SolanaSignerError.invalidPrivateKeyLength + } + + try validateMessage(message) + + return try Curve25519.Signing.PrivateKey(rawRepresentation: privateKey) + .signature(for: message) + } + + static func verifyMessage(publicKey: Data, message: Data, signature: Data) throws -> Bool { + guard publicKey.count == publicKeyLength else { + throw SolanaSignerError.invalidPublicKeyLength + } + + guard signature.count == signatureLength else { + throw SolanaSignerError.invalidSignatureLength + } + + try validateMessage(message) + + return try Curve25519.Signing.PublicKey(rawRepresentation: publicKey) + .isValidSignature(signature, for: message) + } + + private static func validateMessage(_ message: Data) throws { + guard !message.isEmpty else { + throw SolanaSignerError.emptyMessage + } + + guard message.count <= maxMessageLength else { + throw SolanaSignerError.messageTooLarge + } + } + + private static func base58Encode(_ bytes: [UInt8]) -> String { + guard !bytes.isEmpty else { + return "" + } + + var digits: [Int] = [] + + for byte in bytes { + var carry = Int(byte) + + for index in digits.indices { + let value = digits[index] * 256 + carry + digits[index] = value % base58Alphabet.count + carry = value / base58Alphabet.count + } + + while carry > 0 { + digits.append(carry % base58Alphabet.count) + carry /= base58Alphabet.count + } + } + + var result = String(repeating: String(base58Alphabet[0]), count: bytes.prefix { $0 == 0 }.count) + for digit in digits.reversed() { + result.append(base58Alphabet[digit]) + } + + return result + } +} + +private extension Data { + var hexString: String { + map { String(format: "%02x", $0) }.joined() + } +} diff --git a/fearless/Common/Model/SolanaTransactionHistorySync.swift b/fearless/Common/Model/SolanaTransactionHistorySync.swift new file mode 100644 index 0000000000..07f0adefcf --- /dev/null +++ b/fearless/Common/Model/SolanaTransactionHistorySync.swift @@ -0,0 +1,326 @@ +import Foundation + +final class SolanaTransactionHistorySync { + private let client: SolanaIndexerClientProtocol + + init(client: SolanaIndexerClientProtocol) { + self.client = client + } + + func history( + wallet: String, + assetId: String = UniversalWalletRegistry.solanaMainnet.nativeAsset.id, + isNative: Bool = true, + network: UniversalWalletRegistry.SolanaNetwork = UniversalWalletRegistry.solanaMainnet, + baseURL: String? = nil, + before: String? = nil, + limit: Int = SolanaIndexerRoutes.defaultLimit + ) async throws -> SolanaTransactionHistoryPage { + let resolvedBaseURL: String + do { + resolvedBaseURL = try SolanaIndexerRoutes.normalizeBaseURL(baseURL ?? network.indexerBaseURL.absoluteString) + } catch { + throw SolanaTransactionHistoryError.invalidInput + } + try validateInputs( + wallet: wallet, + assetId: assetId, + isNative: isNative, + network: network, + baseURL: resolvedBaseURL, + before: before, + limit: limit + ) + + _ = try await client.verifyServiceInfo( + baseURL: resolvedBaseURL, + expectedChainId: network.chainId + ) + let response = try await client.transactions( + wallet: wallet, + baseURL: resolvedBaseURL, + before: before, + limit: limit + ) + guard response.wallet == wallet else { + throw SolanaTransactionHistoryError.walletMismatch + } + guard response.syncedAt > 0 else { + throw SolanaTransactionHistoryError.invalidPage + } + + let transactions = response.transactions.compactMap { + normalizeTransaction( + $0, + wallet: wallet, + assetId: assetId, + isNative: isNative, + network: network, + syncedAtMillis: response.syncedAt + ) + } + let pageInfo = UniversalWalletIndexerPageInfo( + nextCursor: response.nextBefore.flatMap { Self.isBase58Signature($0) ? $0 : nil }, + limit: response.limit, + total: response.total, + syncedAtMillis: response.syncedAt + ) + guard pageInfo.validationErrors().isEmpty else { + throw SolanaTransactionHistoryError.invalidPage + } + + return SolanaTransactionHistoryPage( + wallet: wallet, + networkId: network.id, + chainId: network.chainId, + assetId: assetId, + isNative: isNative, + transactions: transactions, + pageInfo: pageInfo + ) + } + + private func validateInputs( + wallet: String, + assetId: String, + isNative: Bool, + network: UniversalWalletRegistry.SolanaNetwork, + baseURL: String, + before: String?, + limit: Int + ) throws { + do { + _ = try SolanaIndexerRoutes.transactionsURL( + wallet: wallet, + baseURL: baseURL, + before: before, + limit: limit + ) + if isNative { + guard assetId == network.nativeAsset.id else { + throw SolanaIndexerRouteError.invalidMint + } + } else { + _ = try SolanaIndexerRoutes.tokenMetadataURL(mint: assetId, baseURL: baseURL) + } + } catch { + throw SolanaTransactionHistoryError.invalidInput + } + } + + private func normalizeTransaction( + _ transaction: SolanaWalletTransactionRecord, + wallet _: String, + assetId: String, + isNative: Bool, + network: UniversalWalletRegistry.SolanaNetwork, + syncedAtMillis: Int64 + ) -> UniversalWalletIndexedTransaction? { + guard Self.isBase58Signature(transaction.signature), transaction.slot >= 0 else { + return nil + } + guard let amount = amountDelta(transaction, assetId: assetId, isNative: isNative), !amount.isZero else { + return nil + } + guard let fee = Self.unsignedInteger(transaction.feeLamports ?? "0") else { + return nil + } + let timestampMillis = transaction.timestamp.multipliedReportingOverflow(by: 1000) + guard !timestampMillis.overflow, timestampMillis.partialValue > 0 else { + return nil + } + let status: UniversalWalletIndexedTransactionStatus + switch transaction.status { + case "success": + status = .confirmed + case "failed": + status = .failed + default: + return nil + } + + let indexed = UniversalWalletIndexedTransaction( + accountId: network.id, + ecosystem: UniversalWalletEcosystem.solana.rawValue, + chainId: network.chainId, + transactionId: transaction.signature, + status: status, + direction: amount.isNegative ? .outgoing : .incoming, + operationType: transaction.solswapRoute == nil ? .transfer : .swap, + timestampMillis: timestampMillis.partialValue, + amount: amount.absoluteDigits, + assetId: assetId, + feeAmount: fee, + feeAssetId: network.nativeAsset.id, + counterpartyAddress: nil, + blockNumber: String(transaction.slot), + cursor: transaction.signature, + explorerUrl: nil, + syncedAtMillis: syncedAtMillis + ) + + return indexed.validationErrors().isEmpty ? indexed : nil + } + + private func amountDelta( + _ transaction: SolanaWalletTransactionRecord, + assetId: String, + isNative: Bool + ) -> SignedDecimalInteger? { + if isNative { + return Self.signedInteger(transaction.nativeBalanceChangeLamports) + } + + return transaction.tokenBalanceChanges.reduce(nil) { total, change -> SignedDecimalInteger? in + guard change.mint == assetId, let delta = Self.signedInteger(change.amountDelta) else { + return total + } + + return (total ?? .zero).adding(delta) + } + } + + private struct SignedDecimalInteger: Equatable { + static let zero = SignedDecimalInteger(sign: 0, digits: "0") + + let sign: Int + let digits: String + + var isZero: Bool { + sign == 0 + } + + var isNegative: Bool { + sign < 0 + } + + var absoluteDigits: String { + digits + } + + init(sign: Int, digits: String) { + self.sign = digits == "0" ? 0 : sign + self.digits = Self.trimLeadingZeros(digits) + } + + func adding(_ other: SignedDecimalInteger) -> SignedDecimalInteger { + if isZero { + return other + } + if other.isZero { + return self + } + if sign == other.sign { + return SignedDecimalInteger(sign: sign, digits: Self.addDigits(digits, other.digits)) + } + + switch Self.compareDigits(digits, other.digits) { + case 1: + return SignedDecimalInteger(sign: sign, digits: Self.subtractDigits(digits, other.digits)) + case -1: + return SignedDecimalInteger(sign: other.sign, digits: Self.subtractDigits(other.digits, digits)) + default: + return .zero + } + } + + private static func addDigits(_ left: String, _ right: String) -> String { + var carry = 0 + var result: [Character] = [] + let leftDigits = Array(left.reversed()).map { Int(String($0))! } + let rightDigits = Array(right.reversed()).map { Int(String($0))! } + let count = max(leftDigits.count, rightDigits.count) + + for index in 0 ..< count { + let sum = (index < leftDigits.count ? leftDigits[index] : 0) + + (index < rightDigits.count ? rightDigits[index] : 0) + + carry + result.append(Character(String(sum % 10))) + carry = sum / 10 + } + if carry > 0 { + result.append(Character(String(carry))) + } + + return String(result.reversed()) + } + + private static func subtractDigits(_ left: String, _ right: String) -> String { + var borrow = 0 + var result: [Character] = [] + let leftDigits = Array(left.reversed()).map { Int(String($0))! } + let rightDigits = Array(right.reversed()).map { Int(String($0))! } + + for index in 0 ..< leftDigits.count { + var digit = leftDigits[index] - borrow - (index < rightDigits.count ? rightDigits[index] : 0) + if digit < 0 { + digit += 10 + borrow = 1 + } else { + borrow = 0 + } + result.append(Character(String(digit))) + } + + return trimLeadingZeros(String(result.reversed())) + } + + private static func compareDigits(_ left: String, _ right: String) -> Int { + if left.count != right.count { + return left.count > right.count ? 1 : -1 + } + if left == right { + return 0 + } + + return left > right ? 1 : -1 + } + + private static func trimLeadingZeros(_ value: String) -> String { + let trimmed = value.drop { $0 == "0" } + return trimmed.isEmpty ? "0" : String(trimmed) + } + } + + private static func signedInteger(_ value: String?) -> SignedDecimalInteger? { + guard let value, matches(value, #"^-?(0|[1-9][0-9]*)$"#) else { + return nil + } + if value == "0" || value == "-0" { + return .zero + } + if value.hasPrefix("-") { + return SignedDecimalInteger(sign: -1, digits: String(value.dropFirst())) + } + + return SignedDecimalInteger(sign: 1, digits: value) + } + + private static func unsignedInteger(_ value: String) -> String? { + matches(value, #"^(0|[1-9][0-9]*)$"#) ? value : nil + } + + private static func isBase58Signature(_ value: String) -> Bool { + matches(value, #"^[1-9A-HJ-NP-Za-km-z]{64,128}$"#) + } + + private static func matches(_ value: String, _ pattern: String) -> Bool { + value.range(of: pattern, options: .regularExpression) != nil + } +} + +struct SolanaTransactionHistoryPage: Equatable { + let wallet: String + let networkId: String + let chainId: String + let assetId: String + let isNative: Bool + let transactions: [UniversalWalletIndexedTransaction] + let pageInfo: UniversalWalletIndexerPageInfo +} + +enum SolanaTransactionHistoryError: Error, Equatable { + case invalidInput + case walletMismatch + case invalidPage +} diff --git a/fearless/Common/Model/SolanaTransactionSigner.swift b/fearless/Common/Model/SolanaTransactionSigner.swift new file mode 100644 index 0000000000..1e82167fd6 --- /dev/null +++ b/fearless/Common/Model/SolanaTransactionSigner.swift @@ -0,0 +1,388 @@ +import Foundation + +enum SolanaTransactionSignerError: Error, Equatable { + case emptyTransaction + case invalidTransactionBase64 + case invalidSignatureCount + case missingSignatureSlot + case truncatedSignatures + case missingMessage + case unsupportedTransactionVersion + case malformedMessageHeader + case invalidAccountKeys + case invalidRequiredSignatureCount + case truncatedAccountKeys + case truncatedRecentBlockhash + case invalidInstructionCount + case truncatedInstructionProgram + case invalidInstructionAccounts + case truncatedInstructionAccounts + case invalidInstructionData + case truncatedInstructionData + case invalidAddressTableLookups + case truncatedAddressTableLookup + case invalidAddressTableWritableIndexes + case truncatedAddressTableWritableIndexes + case invalidAddressTableReadonlyIndexes + case truncatedAddressTableReadonlyIndexes + case trailingMessageBytes + case missingRequiredSignatureSlots + case signerMismatch + case signerNotFound + case signerNotRequired +} + +enum SolanaTransactionVersion: Equatable { + case legacy + case v0 +} + +struct ParsedSolanaTransaction: Equatable { + let accountKeys: [String] + let addressTableLookupCount: Int + let instructionCount: Int + let messageBytes: Data + let messageOffset: Int + let readonlySignedAccounts: Int + let readonlyUnsignedAccounts: Int + let recentBlockhash: String + let requiredSignatures: Int + let signatureCount: Int + let signaturesOffset: Int + let version: SolanaTransactionVersion +} + +struct SignedSolanaTransaction: Equatable { + let requiredSignatures: Int + let signedTransaction: Data + let signedTransactionBase64: String + let signatureBase58: String + let signer: String + let version: SolanaTransactionVersion +} + +enum SolanaTransactionSigner { + private static let signatureBytes = 64 + private static let publicKeyBytes = 32 + private static let versionPrefixMask = 0x80 + private static let versionValueMask = 0x7F + private static let maxCompactU16Bytes = 3 + private static let base58Alphabet = Array("123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz") + + static func parseSerializedTransaction(_ transaction: Data) throws -> ParsedSolanaTransaction { + guard !transaction.isEmpty else { + throw SolanaTransactionSignerError.emptyTransaction + } + + let signatureCount = try readCompactU16(transaction, offset: 0, error: .invalidSignatureCount) + let signaturesOffset = signatureCount.offset + let messageOffset = signaturesOffset + signatureCount.value * signatureBytes + + guard signatureCount.value >= 1 else { + throw SolanaTransactionSignerError.missingSignatureSlot + } + + try ensureAvailable(transaction, offset: signaturesOffset, length: signatureCount.value * signatureBytes, error: .truncatedSignatures) + try ensureAvailable(transaction, offset: messageOffset, length: 1, error: .missingMessage) + + let messageBytes = transaction.subdata(in: messageOffset ..< transaction.count) + let message = try parseMessage(messageBytes) + + guard signatureCount.value >= message.requiredSignatures else { + throw SolanaTransactionSignerError.missingRequiredSignatureSlots + } + + return ParsedSolanaTransaction( + accountKeys: message.accountKeys, + addressTableLookupCount: message.addressTableLookupCount, + instructionCount: message.instructionCount, + messageBytes: messageBytes, + messageOffset: messageOffset, + readonlySignedAccounts: message.readonlySignedAccounts, + readonlyUnsignedAccounts: message.readonlyUnsignedAccounts, + recentBlockhash: message.recentBlockhash, + requiredSignatures: message.requiredSignatures, + signatureCount: signatureCount.value, + signaturesOffset: signaturesOffset, + version: message.version + ) + } + + static func parseSerializedTransaction(base64: String) throws -> ParsedSolanaTransaction { + try parseSerializedTransaction(decodeBase64Transaction(base64)) + } + + static func signSerializedTransaction( + mnemonic: String, + transaction: Data, + expectedSigner: String? = nil, + derivationPath: String = UniversalWalletDerivationPaths.solanaDefault, + passphrase: String = "" + ) throws -> SignedSolanaTransaction { + let parsed = try parseSerializedTransaction(transaction) + let account = try SolanaKeyDerivation.deriveAccount( + mnemonic: mnemonic, + passphrase: passphrase, + derivationPath: derivationPath + ) + + if let expectedSigner, expectedSigner != account.address { + throw SolanaTransactionSignerError.signerMismatch + } + + guard let signerIndex = parsed.accountKeys.firstIndex(of: account.address) else { + throw SolanaTransactionSignerError.signerNotFound + } + + guard signerIndex < parsed.requiredSignatures else { + throw SolanaTransactionSignerError.signerNotRequired + } + + guard signerIndex < parsed.signatureCount else { + throw SolanaTransactionSignerError.missingSignatureSlot + } + + let signature = try SolanaSigner.signMessage(privateKey: account.privateKey, message: parsed.messageBytes) + var signedTransaction = transaction + + signedTransaction.replaceSubrange( + parsed.signaturesOffset + signerIndex * signatureBytes ..< parsed.signaturesOffset + (signerIndex + 1) * signatureBytes, + with: signature + ) + + return SignedSolanaTransaction( + requiredSignatures: parsed.requiredSignatures, + signedTransaction: signedTransaction, + signedTransactionBase64: signedTransaction.base64EncodedString(), + signatureBase58: base58Encode(Array(signature)), + signer: account.address, + version: parsed.version + ) + } + + static func signSerializedTransaction( + mnemonic: String, + transactionBase64: String, + expectedSigner: String? = nil, + derivationPath: String = UniversalWalletDerivationPaths.solanaDefault, + passphrase: String = "" + ) throws -> SignedSolanaTransaction { + try signSerializedTransaction( + mnemonic: mnemonic, + transaction: decodeBase64Transaction(transactionBase64), + expectedSigner: expectedSigner, + derivationPath: derivationPath, + passphrase: passphrase + ) + } + + private static func parseMessage(_ message: Data) throws -> ParsedSolanaTransaction { + var offset = 0 + var version = SolanaTransactionVersion.legacy + + if (Int(message[offset]) & versionPrefixMask) != 0 { + let versionValue = Int(message[offset]) & versionValueMask + + guard versionValue == 0 else { + throw SolanaTransactionSignerError.unsupportedTransactionVersion + } + + version = .v0 + offset += 1 + } + + try ensureAvailable(message, offset: offset, length: 3, error: .malformedMessageHeader) + + let requiredSignatures = Int(message[offset]) + let readonlySignedAccounts = Int(message[offset + 1]) + let readonlyUnsignedAccounts = Int(message[offset + 2]) + + offset += 3 + + let accountCount = try readCompactU16(message, offset: offset, error: .invalidAccountKeys) + offset = accountCount.offset + + guard accountCount.value >= requiredSignatures else { + throw SolanaTransactionSignerError.invalidRequiredSignatureCount + } + guard readonlySignedAccounts <= requiredSignatures else { + throw SolanaTransactionSignerError.invalidRequiredSignatureCount + } + guard readonlyUnsignedAccounts <= accountCount.value - requiredSignatures else { + throw SolanaTransactionSignerError.invalidRequiredSignatureCount + } + + try ensureAvailable(message, offset: offset, length: accountCount.value * publicKeyBytes, error: .truncatedAccountKeys) + + var accountKeys: [String] = [] + for _ in 0 ..< accountCount.value { + accountKeys.append(base58Encode(Array(message[offset ..< offset + publicKeyBytes]))) + offset += publicKeyBytes + } + + try ensureAvailable(message, offset: offset, length: publicKeyBytes, error: .truncatedRecentBlockhash) + let recentBlockhash = base58Encode(Array(message[offset ..< offset + publicKeyBytes])) + offset += publicKeyBytes + + let instructions = try skipCompiledInstructions(message, offset: offset) + offset = instructions.offset + + var addressTableLookupCount = 0 + if version == .v0 { + let lookups = try skipAddressTableLookups(message, offset: offset) + addressTableLookupCount = lookups.count + offset = lookups.offset + } + + guard offset == message.count else { + throw SolanaTransactionSignerError.trailingMessageBytes + } + + return ParsedSolanaTransaction( + accountKeys: accountKeys, + addressTableLookupCount: addressTableLookupCount, + instructionCount: instructions.count, + messageBytes: Data(), + messageOffset: 0, + readonlySignedAccounts: readonlySignedAccounts, + readonlyUnsignedAccounts: readonlyUnsignedAccounts, + recentBlockhash: recentBlockhash, + requiredSignatures: requiredSignatures, + signatureCount: 0, + signaturesOffset: 0, + version: version + ) + } + + private static func skipCompiledInstructions(_ message: Data, offset: Int) throws -> (count: Int, offset: Int) { + var offset = offset + let instructionCount = try readCompactU16(message, offset: offset, error: .invalidInstructionCount) + offset = instructionCount.offset + + for _ in 0 ..< instructionCount.value { + try ensureAvailable(message, offset: offset, length: 1, error: .truncatedInstructionProgram) + offset += 1 + + let accountIndexCount = try readCompactU16(message, offset: offset, error: .invalidInstructionAccounts) + offset = accountIndexCount.offset + try ensureAvailable(message, offset: offset, length: accountIndexCount.value, error: .truncatedInstructionAccounts) + offset += accountIndexCount.value + + let dataLength = try readCompactU16(message, offset: offset, error: .invalidInstructionData) + offset = dataLength.offset + try ensureAvailable(message, offset: offset, length: dataLength.value, error: .truncatedInstructionData) + offset += dataLength.value + } + + return (instructionCount.value, offset) + } + + private static func skipAddressTableLookups(_ message: Data, offset: Int) throws -> (count: Int, offset: Int) { + var offset = offset + let lookupCount = try readCompactU16(message, offset: offset, error: .invalidAddressTableLookups) + offset = lookupCount.offset + + for _ in 0 ..< lookupCount.value { + try ensureAvailable(message, offset: offset, length: publicKeyBytes, error: .truncatedAddressTableLookup) + offset += publicKeyBytes + + let writableCount = try readCompactU16(message, offset: offset, error: .invalidAddressTableWritableIndexes) + offset = writableCount.offset + try ensureAvailable(message, offset: offset, length: writableCount.value, error: .truncatedAddressTableWritableIndexes) + offset += writableCount.value + + let readonlyCount = try readCompactU16(message, offset: offset, error: .invalidAddressTableReadonlyIndexes) + offset = readonlyCount.offset + try ensureAvailable(message, offset: offset, length: readonlyCount.value, error: .truncatedAddressTableReadonlyIndexes) + offset += readonlyCount.value + } + + return (lookupCount.value, offset) + } + + private static func readCompactU16( + _ bytes: Data, + offset: Int, + error: SolanaTransactionSignerError + ) throws -> (value: Int, offset: Int) { + var value = 0 + var shift = 0 + var offset = offset + + for _ in 0 ..< maxCompactU16Bytes { + try ensureAvailable(bytes, offset: offset, length: 1, error: error) + + let byte = Int(bytes[offset]) + offset += 1 + value |= (byte & 0x7F) << shift + + if (byte & 0x80) == 0 { + return (value, offset) + } + + shift += 7 + } + + throw error + } + + private static func ensureAvailable( + _ bytes: Data, + offset: Int, + length: Int, + error: SolanaTransactionSignerError + ) throws { + guard offset >= 0, length >= 0, offset + length <= bytes.count else { + throw error + } + } + + private static func decodeBase64Transaction(_ transactionBase64: String) throws -> Data { + guard !transactionBase64.isEmpty else { + throw SolanaTransactionSignerError.emptyTransaction + } + + guard transactionBase64.range( + of: "^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$", + options: .regularExpression + ) != nil else { + throw SolanaTransactionSignerError.invalidTransactionBase64 + } + + guard let data = Data(base64Encoded: transactionBase64) else { + throw SolanaTransactionSignerError.invalidTransactionBase64 + } + + return data + } + + private static func base58Encode(_ bytes: [UInt8]) -> String { + guard !bytes.isEmpty else { + return "" + } + + var digits: [Int] = [] + + for byte in bytes { + var carry = Int(byte) + + for index in digits.indices { + let value = digits[index] * 256 + carry + digits[index] = value % base58Alphabet.count + carry = value / base58Alphabet.count + } + + while carry > 0 { + digits.append(carry % base58Alphabet.count) + carry /= base58Alphabet.count + } + } + + var result = String(repeating: String(base58Alphabet[0]), count: bytes.prefix { $0 == 0 }.count) + for digit in digits.reversed() { + result.append(base58Alphabet[digit]) + } + + return result + } +} diff --git a/fearless/Common/Model/SolanaTransferTransactionBuilder.swift b/fearless/Common/Model/SolanaTransferTransactionBuilder.swift new file mode 100644 index 0000000000..93ea68093c --- /dev/null +++ b/fearless/Common/Model/SolanaTransferTransactionBuilder.swift @@ -0,0 +1,788 @@ +import Foundation +import BigInt +import CryptoKit + +enum SolanaTransferTransactionBuilder { + static let systemProgramAddress = "11111111111111111111111111111111" + static let splTokenProgramAddress = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + static let token2022ProgramAddress = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb" + static let associatedTokenProgramAddress = "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL" + + private static let publicKeyBytes = 32 + private static let signatureBytes = 64 + private static let systemTransferInstruction: UInt32 = 2 + private static let tokenTransferCheckedInstruction: UInt8 = 12 + private static let maxExtraTokenAccounts = 32 + private static let programDerivedAddressMarker = Data("ProgramDerivedAddress".utf8) + private static let ed25519Prime = (BigUInt(1) << 255) - BigUInt(19) + private static let ed25519D = BigUInt("37095705934669439343138083508754565189542113879843219016388785533085940283555") + private static let ed25519SqrtMinusOne = BigUInt("19681161376707505956807079304988542015446066515923890162744021073123829784752") + private static let ed25519SqrtExponent = (((BigUInt(1) << 255) - BigUInt(19)) - BigUInt(5)) >> 3 + private static let base58Alphabet = Array("123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz") + private static let base58Index = Dictionary(uniqueKeysWithValues: base58Alphabet.enumerated().map { ($0.element, $0.offset) }) + + static func buildNativeTransfer( + senderAddress: String, + recipientAddress: String, + lamports: Int64, + recentBlockhash: String + ) throws -> SolanaUnsignedTransferTransaction { + try requireLamports(lamports) + + let normalizedSender = try normalizeSenderAddress(senderAddress) + let normalizedRecipient = try normalizeRecipientAddress(recipientAddress) + let normalizedBlockhash = try normalizeRecentBlockhash(recentBlockhash) + let sender = try decodePublicKey(normalizedSender, error: .invalidSender) + let recipient = try decodePublicKey(normalizedRecipient, error: .invalidRecipient) + let systemProgram = try decodePublicKey(systemProgramAddress, error: .invalidSender) + let blockhash = try decodePublicKey(normalizedBlockhash, error: .invalidRecentBlockhash) + + var message = Data([1, 0, 1]) + message.append(compactU16(3)) + message.append(sender) + message.append(recipient) + message.append(systemProgram) + message.append(blockhash) + message.append(compactU16(1)) + message.append(2) + message.append(compactU16(2)) + message.append(contentsOf: [0, 1]) + + var instructionData = littleEndian(systemTransferInstruction) + instructionData.append(littleEndian(UInt64(lamports))) + message.append(compactU16(instructionData.count)) + message.append(instructionData) + + let transaction = transactionWithSingleEmptySignature(message) + + return SolanaUnsignedTransferTransaction( + lamports: lamports, + message: message, + messageBase64: message.base64EncodedString(), + recentBlockhash: normalizedBlockhash, + recipientAddress: normalizedRecipient, + senderAddress: normalizedSender, + transaction: transaction, + transactionBase64: transaction.base64EncodedString() + ) + } + + static func buildTokenTransferChecked( + ownerAddress: String, + sourceTokenAccount: String, + destinationTokenAccount: String, + mintAddress: String, + rawAmount: Int64, + decimals: Int, + recentBlockhash: String, + tokenProgram: SolanaTokenProgram = .splToken, + extraAccounts: [SolanaTokenTransferExtraAccount] = [] + ) throws -> SolanaUnsignedTokenTransferTransaction { + guard rawAmount > 0 else { + throw SolanaTransferTransactionError.invalidTokenAmount + } + guard (0 ... 255).contains(decimals) else { + throw SolanaTransferTransactionError.invalidTokenDecimals + } + guard extraAccounts.count <= maxExtraTokenAccounts else { + throw SolanaTransferTransactionError.tooManyExtraAccounts + } + + let normalizedOwner = try normalizePublicKey(ownerAddress, error: .invalidSender) + let normalizedSource = try normalizePublicKey(sourceTokenAccount, error: .invalidSourceTokenAccount) + let normalizedDestination = try normalizePublicKey(destinationTokenAccount, error: .invalidDestinationTokenAccount) + let normalizedMint = try normalizePublicKey(mintAddress, error: .invalidMint) + let normalizedProgram = try normalizePublicKey(tokenProgram.programAddress, error: .invalidTokenProgram) + let normalizedBlockhash = try normalizeRecentBlockhash(recentBlockhash) + let normalizedExtras = try extraAccounts.map { + SolanaTokenTransferExtraAccount( + address: try normalizePublicKey($0.address, error: .invalidExtraAccount), + isWritable: $0.isWritable + ) + } + let allKeys = [ + normalizedOwner, + normalizedSource, + normalizedDestination, + normalizedMint, + normalizedProgram + ] + normalizedExtras.map(\.address) + guard Set(allKeys).count == allKeys.count else { + throw SolanaTransferTransactionError.duplicateAccount + } + + let writableExtras = normalizedExtras.filter(\.isWritable) + let readonlyExtras = normalizedExtras.filter { !$0.isWritable } + let accountKeys = [ + normalizedOwner, + normalizedSource, + normalizedDestination + ] + writableExtras.map(\.address) + [ + normalizedMint, + normalizedProgram + ] + readonlyExtras.map(\.address) + let accountIndex = Dictionary(uniqueKeysWithValues: accountKeys.enumerated().map { ($0.element, $0.offset) }) + let instructionAccounts = [ + accountIndex[normalizedSource]!, + accountIndex[normalizedMint]!, + accountIndex[normalizedDestination]!, + accountIndex[normalizedOwner]! + ] + normalizedExtras.map { accountIndex[$0.address]! } + + var instructionData = Data([tokenTransferCheckedInstruction]) + instructionData.append(littleEndian(UInt64(rawAmount))) + instructionData.append(UInt8(decimals)) + + var message = Data([1, 0, UInt8(2 + readonlyExtras.count)]) + message.append(compactU16(accountKeys.count)) + for accountKey in accountKeys { + message.append(try decodePublicKey(accountKey, error: .invalidExtraAccount)) + } + message.append(try decodePublicKey(normalizedBlockhash, error: .invalidRecentBlockhash)) + message.append(compactU16(1)) + message.append(UInt8(accountIndex[normalizedProgram]!)) + message.append(compactU16(instructionAccounts.count)) + message.append(contentsOf: instructionAccounts.map(UInt8.init)) + message.append(compactU16(instructionData.count)) + message.append(instructionData) + + let transaction = transactionWithSingleEmptySignature(message) + + return SolanaUnsignedTokenTransferTransaction( + decimals: decimals, + destinationTokenAccount: normalizedDestination, + extraAccounts: normalizedExtras, + message: message, + messageBase64: message.base64EncodedString(), + mintAddress: normalizedMint, + ownerAddress: normalizedOwner, + rawAmount: rawAmount, + recentBlockhash: normalizedBlockhash, + sourceTokenAccount: normalizedSource, + tokenProgram: tokenProgram, + transaction: transaction, + transactionBase64: transaction.base64EncodedString() + ) + } + + static func buildCreateAssociatedTokenAccount( + fundingAddress: String, + walletAddress: String, + associatedTokenAccount: String? = nil, + mintAddress: String, + recentBlockhash: String, + tokenProgram: SolanaTokenProgram = .splToken, + instruction: SolanaAssociatedTokenAccountInstruction = .createIdempotent + ) throws -> SolanaUnsignedAssociatedTokenAccountCreateTransaction { + let normalizedFunding = try normalizePublicKey(fundingAddress, error: .invalidFundingAccount) + let normalizedWallet = try normalizePublicKey(walletAddress, error: .invalidWallet) + let normalizedMint = try normalizePublicKey(mintAddress, error: .invalidMint) + let normalizedTokenProgram = try normalizePublicKey(tokenProgram.programAddress, error: .invalidTokenProgram) + let normalizedAssociatedProgram = try normalizePublicKey(associatedTokenProgramAddress, error: .invalidAssociatedTokenProgram) + let derivedAssociated = try deriveAssociatedTokenAccountAddress( + walletAddress: normalizedWallet, + mintAddress: normalizedMint, + tokenProgramAddress: normalizedTokenProgram, + associatedTokenProgramAddress: normalizedAssociatedProgram + ) + let normalizedAssociated = try associatedTokenAccount.flatMap { value -> String? in + guard !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return nil + } + + return try normalizePublicKey(value, error: .invalidAssociatedTokenAccount) + } ?? derivedAssociated + guard normalizedAssociated == derivedAssociated else { + throw SolanaTransferTransactionError.invalidAssociatedTokenAccount + } + let normalizedBlockhash = try normalizeRecentBlockhash(recentBlockhash) + + if normalizedAssociated == normalizedFunding || + normalizedAssociated == normalizedWallet || + normalizedAssociated == normalizedMint || + normalizedAssociated == systemProgramAddress || + normalizedAssociated == normalizedTokenProgram || + normalizedAssociated == normalizedAssociatedProgram || + normalizedMint == normalizedWallet || + normalizedMint == normalizedTokenProgram || + normalizedMint == normalizedAssociatedProgram { + throw SolanaTransferTransactionError.duplicateAccount + } + + let readonlyAccounts = [ + normalizedWallet, + normalizedMint, + systemProgramAddress, + normalizedTokenProgram, + normalizedAssociatedProgram + ].reduce(into: [String]()) { result, address in + if address != normalizedFunding, + address != normalizedAssociated, + !result.contains(address) { + result.append(address) + } + } + let accountKeys = [normalizedFunding, normalizedAssociated] + readonlyAccounts + let accountIndex = Dictionary(uniqueKeysWithValues: accountKeys.enumerated().map { ($0.element, $0.offset) }) + let instructionAccounts = [ + accountIndex[normalizedFunding]!, + accountIndex[normalizedAssociated]!, + accountIndex[normalizedWallet]!, + accountIndex[normalizedMint]!, + accountIndex[systemProgramAddress]!, + accountIndex[normalizedTokenProgram]! + ] + + var message = Data([1, 0, UInt8(readonlyAccounts.count)]) + message.append(compactU16(accountKeys.count)) + for accountKey in accountKeys { + message.append(try decodePublicKey(accountKey, error: .invalidAssociatedTokenAccount)) + } + message.append(try decodePublicKey(normalizedBlockhash, error: .invalidRecentBlockhash)) + message.append(compactU16(1)) + message.append(UInt8(accountIndex[normalizedAssociatedProgram]!)) + message.append(compactU16(instructionAccounts.count)) + message.append(contentsOf: instructionAccounts.map(UInt8.init)) + message.append(compactU16(1)) + message.append(instruction.discriminator) + + let transaction = transactionWithSingleEmptySignature(message) + + return SolanaUnsignedAssociatedTokenAccountCreateTransaction( + associatedTokenAccount: normalizedAssociated, + fundingAddress: normalizedFunding, + instruction: instruction, + message: message, + messageBase64: message.base64EncodedString(), + mintAddress: normalizedMint, + recentBlockhash: normalizedBlockhash, + tokenProgram: tokenProgram, + transaction: transaction, + transactionBase64: transaction.base64EncodedString(), + walletAddress: normalizedWallet + ) + } + + static func buildTokenSendChecked( + ownerAddress: String, + sourceTokenAccount: String, + destinationTokenAccount: String? = nil, + mintAddress: String, + rawAmount: Int64, + decimals: Int, + recentBlockhash: String, + tokenProgram: SolanaTokenProgram = .splToken, + extraAccounts: [SolanaTokenTransferExtraAccount] = [], + destinationWalletAddress: String? = nil, + associatedTokenAccountInstruction: SolanaAssociatedTokenAccountInstruction = .createIdempotent + ) throws -> SolanaUnsignedTokenSendTransaction { + guard rawAmount > 0 else { + throw SolanaTransferTransactionError.invalidTokenAmount + } + guard (0 ... 255).contains(decimals) else { + throw SolanaTransferTransactionError.invalidTokenDecimals + } + guard extraAccounts.count <= maxExtraTokenAccounts else { + throw SolanaTransferTransactionError.tooManyExtraAccounts + } + + let normalizedOwner = try normalizePublicKey(ownerAddress, error: .invalidSender) + let normalizedSource = try normalizePublicKey(sourceTokenAccount, error: .invalidSourceTokenAccount) + let normalizedDestinationWallet = try destinationWalletAddress.map { + try normalizePublicKey($0, error: .invalidWallet) + } + let normalizedMint = try normalizePublicKey(mintAddress, error: .invalidMint) + let normalizedProgram = try normalizePublicKey(tokenProgram.programAddress, error: .invalidTokenProgram) + let normalizedAssociatedProgram = try normalizePublicKey(associatedTokenProgramAddress, error: .invalidAssociatedTokenProgram) + let derivedAssociated = try normalizedDestinationWallet.map { + try deriveAssociatedTokenAccountAddress( + walletAddress: $0, + mintAddress: normalizedMint, + tokenProgramAddress: normalizedProgram, + associatedTokenProgramAddress: normalizedAssociatedProgram + ) + } + let normalizedDestination = try destinationTokenAccount.flatMap { value -> String? in + guard !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return nil + } + + return try normalizePublicKey(value, error: .invalidDestinationTokenAccount) + } ?? derivedAssociated + guard let normalizedDestination else { + throw SolanaTransferTransactionError.invalidDestinationTokenAccount + } + if let derivedAssociated, normalizedDestination != derivedAssociated { + throw SolanaTransferTransactionError.invalidAssociatedTokenAccount + } + let normalizedBlockhash = try normalizeRecentBlockhash(recentBlockhash) + let normalizedExtras = try extraAccounts.map { + SolanaTokenTransferExtraAccount( + address: try normalizePublicKey($0.address, error: .invalidExtraAccount), + isWritable: $0.isWritable + ) + } + + var uniqueRoleKeys = [ + normalizedOwner, + normalizedSource, + normalizedDestination + ] + if let normalizedDestinationWallet, normalizedDestinationWallet != normalizedOwner { + uniqueRoleKeys.append(normalizedDestinationWallet) + } + uniqueRoleKeys.append(normalizedMint) + if normalizedDestinationWallet != nil { + uniqueRoleKeys.append(systemProgramAddress) + } + uniqueRoleKeys.append(normalizedProgram) + if normalizedDestinationWallet != nil { + uniqueRoleKeys.append(normalizedAssociatedProgram) + } + uniqueRoleKeys.append(contentsOf: normalizedExtras.map(\.address)) + guard Set(uniqueRoleKeys).count == uniqueRoleKeys.count else { + throw SolanaTransferTransactionError.duplicateAccount + } + + let writableExtras = normalizedExtras.filter(\.isWritable) + let readonlyExtras = normalizedExtras.filter { !$0.isWritable } + let unsignedWritableAccounts = [ + normalizedSource, + normalizedDestination + ] + writableExtras.map(\.address) + var unsignedReadonlyAccounts: [String] = [] + if let normalizedDestinationWallet, normalizedDestinationWallet != normalizedOwner { + unsignedReadonlyAccounts.append(normalizedDestinationWallet) + } + unsignedReadonlyAccounts.append(normalizedMint) + if normalizedDestinationWallet != nil { + unsignedReadonlyAccounts.append(systemProgramAddress) + } + unsignedReadonlyAccounts.append(normalizedProgram) + if normalizedDestinationWallet != nil { + unsignedReadonlyAccounts.append(normalizedAssociatedProgram) + } + unsignedReadonlyAccounts.append(contentsOf: readonlyExtras.map(\.address)) + + let accountKeys = [normalizedOwner] + unsignedWritableAccounts + unsignedReadonlyAccounts + let accountIndex = Dictionary(uniqueKeysWithValues: accountKeys.enumerated().map { ($0.element, $0.offset) }) + var instructions: [CompiledSolanaInstruction] = [] + + if let normalizedDestinationWallet { + instructions.append( + CompiledSolanaInstruction( + programAddress: normalizedAssociatedProgram, + accountAddresses: [ + normalizedOwner, + normalizedDestination, + normalizedDestinationWallet, + normalizedMint, + systemProgramAddress, + normalizedProgram + ], + data: Data([associatedTokenAccountInstruction.discriminator]) + ) + ) + } + + var transferData = Data([tokenTransferCheckedInstruction]) + transferData.append(littleEndian(UInt64(rawAmount))) + transferData.append(UInt8(decimals)) + instructions.append( + CompiledSolanaInstruction( + programAddress: normalizedProgram, + accountAddresses: [ + normalizedSource, + normalizedMint, + normalizedDestination, + normalizedOwner + ] + normalizedExtras.map(\.address), + data: transferData + ) + ) + + var message = Data([1, 0, UInt8(unsignedReadonlyAccounts.count)]) + message.append(compactU16(accountKeys.count)) + for accountKey in accountKeys { + message.append(try decodePublicKey(accountKey, error: .invalidExtraAccount)) + } + message.append(try decodePublicKey(normalizedBlockhash, error: .invalidRecentBlockhash)) + message.append(compactU16(instructions.count)) + for instruction in instructions { + message.append(UInt8(accountIndex[instruction.programAddress]!)) + message.append(compactU16(instruction.accountAddresses.count)) + message.append(contentsOf: instruction.accountAddresses.map { UInt8(accountIndex[$0]!) }) + message.append(compactU16(instruction.data.count)) + message.append(instruction.data) + } + + let transaction = transactionWithSingleEmptySignature(message) + + return SolanaUnsignedTokenSendTransaction( + createsDestinationAssociatedTokenAccount: normalizedDestinationWallet != nil, + decimals: decimals, + destinationTokenAccount: normalizedDestination, + destinationWalletAddress: normalizedDestinationWallet, + extraAccounts: normalizedExtras, + message: message, + messageBase64: message.base64EncodedString(), + mintAddress: normalizedMint, + ownerAddress: normalizedOwner, + rawAmount: rawAmount, + recentBlockhash: normalizedBlockhash, + sourceTokenAccount: normalizedSource, + tokenProgram: tokenProgram, + transaction: transaction, + transactionBase64: transaction.base64EncodedString() + ) + } + + static func deriveAssociatedTokenAccountAddress( + walletAddress: String, + mintAddress: String, + tokenProgram: SolanaTokenProgram = .splToken + ) throws -> String { + let normalizedWallet = try normalizePublicKey(walletAddress, error: .invalidWallet) + let normalizedMint = try normalizePublicKey(mintAddress, error: .invalidMint) + let normalizedTokenProgram = try normalizePublicKey(tokenProgram.programAddress, error: .invalidTokenProgram) + let normalizedAssociatedProgram = try normalizePublicKey(associatedTokenProgramAddress, error: .invalidAssociatedTokenProgram) + + return try deriveAssociatedTokenAccountAddress( + walletAddress: normalizedWallet, + mintAddress: normalizedMint, + tokenProgramAddress: normalizedTokenProgram, + associatedTokenProgramAddress: normalizedAssociatedProgram + ) + } + + @discardableResult + static func requireLamports(_ lamports: Int64) throws -> Int64 { + guard lamports > 0 else { + throw SolanaTransferTransactionError.invalidLamports + } + + return lamports + } + + static func normalizeSenderAddress(_ senderAddress: String) throws -> String { + try normalizePublicKey(senderAddress, error: .invalidSender) + } + + static func normalizeRecipientAddress(_ recipientAddress: String) throws -> String { + try normalizePublicKey(recipientAddress, error: .invalidRecipient) + } + + static func normalizeRecentBlockhash(_ recentBlockhash: String) throws -> String { + try normalizePublicKey(recentBlockhash, error: .invalidRecentBlockhash) + } + + private static func normalizePublicKey(_ value: String, error: SolanaTransferTransactionError) throws -> String { + let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines) + _ = try decodePublicKey(normalized, error: error) + + return normalized + } + + private static func decodePublicKey(_ value: String, error: SolanaTransferTransactionError) throws -> Data { + let decoded = try base58Decode(value, error: error) + guard decoded.count == publicKeyBytes else { + throw error + } + + return decoded + } + + private static func deriveAssociatedTokenAccountAddress( + walletAddress: String, + mintAddress: String, + tokenProgramAddress: String, + associatedTokenProgramAddress: String + ) throws -> String { + let seeds = try [ + decodePublicKey(walletAddress, error: .invalidWallet), + decodePublicKey(tokenProgramAddress, error: .invalidTokenProgram), + decodePublicKey(mintAddress, error: .invalidMint) + ] + let programId = try decodePublicKey(associatedTokenProgramAddress, error: .invalidAssociatedTokenProgram) + + for bump in stride(from: 255, through: 0, by: -1) { + guard let candidate = createProgramAddress(seeds: seeds + [Data([UInt8(bump)])], programId: programId) else { + continue + } + + return base58Encode(Array(candidate)) + } + + throw SolanaTransferTransactionError.invalidAssociatedTokenAccount + } + + private static func createProgramAddress(seeds: [Data], programId: Data) -> Data? { + var payload = Data() + for seed in seeds { + payload.append(seed) + } + payload.append(programId) + payload.append(programDerivedAddressMarker) + + let candidate = Data(SHA256.hash(data: payload)) + return isOnEd25519Curve(candidate) ? nil : candidate + } + + private static func isOnEd25519Curve(_ publicKey: Data) -> Bool { + guard publicKey.count == publicKeyBytes else { + return false + } + + var yBytes = Array(publicKey) + yBytes[31] &= 0x7F + let y = littleEndianBigUInt(yBytes) + guard y < ed25519Prime else { + return false + } + + let ySquared = mod(y * y) + let numerator = mod(ySquared + ed25519Prime - BigUInt(1)) + let denominator = mod(ed25519D * ySquared + BigUInt(1)) + + return sqrtRatioM1(numerator, denominator) != nil + } + + private static func sqrtRatioM1(_ numerator: BigUInt, _ denominator: BigUInt) -> BigUInt? { + let denominatorSquared = mod(denominator * denominator) + let denominatorCubed = mod(denominatorSquared * denominator) + let denominatorSeventh = mod(mod(denominatorCubed * denominatorCubed) * denominator) + var x = mod(numerator * denominatorCubed) + x = mod(x * mod(mod(numerator * denominatorSeventh).power(ed25519SqrtExponent, modulus: ed25519Prime))) + + let check = mod(denominator * mod(x * x)) + if check == numerator { + return x + } + if check == mod(ed25519Prime - numerator) { + return mod(x * ed25519SqrtMinusOne) + } + + return nil + } + + private static func littleEndianBigUInt(_ bytes: [UInt8]) -> BigUInt { + var result = BigUInt.zero + for (index, byte) in bytes.enumerated() { + result += BigUInt(byte) << (index * 8) + } + + return result + } + + private static func mod(_ value: BigUInt) -> BigUInt { + value % ed25519Prime + } + + private static func base58Encode(_ bytes: [UInt8]) -> String { + guard !bytes.isEmpty else { + return "" + } + + var number = BigUInt(Data(bytes)) + let radix = BigUInt(base58Alphabet.count) + var encoded = "" + + while number > 0 { + let remainder = number % radix + encoded.append(base58Alphabet[Int(remainder)]) + number /= radix + } + + for _ in bytes.prefix(while: { $0 == 0 }) { + encoded.append(base58Alphabet[0]) + } + + return String(encoded.reversed()) + } + + private static func base58Decode(_ value: String, error: SolanaTransferTransactionError) throws -> Data { + guard !value.isEmpty else { + throw error + } + + var bytes: [Int] = [] + for character in value { + guard var carry = base58Index[character] else { + throw error + } + + for index in bytes.indices { + let next = bytes[index] * base58Alphabet.count + carry + bytes[index] = next & 0xFF + carry = next >> 8 + } + + while carry > 0 { + bytes.append(carry & 0xFF) + carry >>= 8 + } + } + + let leadingZeroes = value.prefix { $0 == base58Alphabet[0] }.count + return Data(repeating: 0, count: leadingZeroes) + Data(bytes.reversed().map(UInt8.init)) + } + + private static func compactU16(_ value: Int) -> Data { + precondition((0 ... 0xFFFF).contains(value), "Compact-u16 value out of range") + + var result = Data() + var next = value + + repeat { + var byte = next & 0x7F + next >>= 7 + if next > 0 { + byte |= 0x80 + } + result.append(UInt8(byte)) + } while next > 0 + + return result + } + + private static func littleEndian(_ value: UInt32) -> Data { + var littleEndianValue = value.littleEndian + return Data(bytes: &littleEndianValue, count: MemoryLayout.size) + } + + private static func littleEndian(_ value: UInt64) -> Data { + var littleEndianValue = value.littleEndian + return Data(bytes: &littleEndianValue, count: MemoryLayout.size) + } + + private static func transactionWithSingleEmptySignature(_ message: Data) -> Data { + var transaction = compactU16(1) + transaction.append(Data(repeating: 0, count: signatureBytes)) + transaction.append(message) + + return transaction + } + + private struct CompiledSolanaInstruction { + let programAddress: String + let accountAddresses: [String] + let data: Data + } +} + +struct SolanaUnsignedTransferTransaction: Equatable { + let lamports: Int64 + let message: Data + let messageBase64: String + let recentBlockhash: String + let recipientAddress: String + let senderAddress: String + let transaction: Data + let transactionBase64: String +} + +enum SolanaTokenProgram: Equatable { + case splToken + case token2022 + + var programAddress: String { + switch self { + case .splToken: + return SolanaTransferTransactionBuilder.splTokenProgramAddress + case .token2022: + return SolanaTransferTransactionBuilder.token2022ProgramAddress + } + } +} + +enum SolanaAssociatedTokenAccountInstruction: Equatable { + case create + case createIdempotent + + var discriminator: UInt8 { + switch self { + case .create: + return 0 + case .createIdempotent: + return 1 + } + } +} + +struct SolanaTokenTransferExtraAccount: Equatable { + let address: String + let isWritable: Bool + + init(address: String, isWritable: Bool = false) { + self.address = address + self.isWritable = isWritable + } +} + +struct SolanaUnsignedTokenTransferTransaction: Equatable { + let decimals: Int + let destinationTokenAccount: String + let extraAccounts: [SolanaTokenTransferExtraAccount] + let message: Data + let messageBase64: String + let mintAddress: String + let ownerAddress: String + let rawAmount: Int64 + let recentBlockhash: String + let sourceTokenAccount: String + let tokenProgram: SolanaTokenProgram + let transaction: Data + let transactionBase64: String +} + +struct SolanaUnsignedAssociatedTokenAccountCreateTransaction: Equatable { + let associatedTokenAccount: String + let fundingAddress: String + let instruction: SolanaAssociatedTokenAccountInstruction + let message: Data + let messageBase64: String + let mintAddress: String + let recentBlockhash: String + let tokenProgram: SolanaTokenProgram + let transaction: Data + let transactionBase64: String + let walletAddress: String +} + +struct SolanaUnsignedTokenSendTransaction: Equatable { + let createsDestinationAssociatedTokenAccount: Bool + let decimals: Int + let destinationTokenAccount: String + let destinationWalletAddress: String? + let extraAccounts: [SolanaTokenTransferExtraAccount] + let message: Data + let messageBase64: String + let mintAddress: String + let ownerAddress: String + let rawAmount: Int64 + let recentBlockhash: String + let sourceTokenAccount: String + let tokenProgram: SolanaTokenProgram + let transaction: Data + let transactionBase64: String +} + +enum SolanaTransferTransactionError: Error, Equatable { + case invalidSender + case invalidRecipient + case invalidRecentBlockhash + case invalidLamports + case invalidSourceTokenAccount + case invalidDestinationTokenAccount + case invalidMint + case invalidTokenProgram + case invalidTokenAmount + case invalidTokenDecimals + case invalidExtraAccount + case tooManyExtraAccounts + case duplicateAccount + case invalidFundingAccount + case invalidWallet + case invalidAssociatedTokenAccount + case invalidAssociatedTokenProgram +} diff --git a/fearless/Common/Model/TonAddressCodec.swift b/fearless/Common/Model/TonAddressCodec.swift new file mode 100644 index 0000000000..782d28ec8d --- /dev/null +++ b/fearless/Common/Model/TonAddressCodec.swift @@ -0,0 +1,178 @@ +import CryptoKit +import Foundation + +enum TonAddressCodecError: Error, Equatable { + case invalidPublicKeyLength + case invalidAccountHashLength + case invalidCellReference +} + +struct TonV4R2AddressSet: Equatable { + let accountHash: Data + let bounceable: String + let nonBounceable: String + let testnetNonBounceable: String +} + +enum TonAddressCodec { + private static let workchain = 0 + private static let walletId: UInt32 = 698_983_191 + private static let publicKeyLength = 32 + private static let accountHashLength = 32 + private static let cellHashLength = 32 + private static let walletDataCellBitCount = 321 + private static let stateInitCellBitCount = 5 + private static let walletDataTopUpByte: UInt8 = 0x40 + private static let stateInitFlagsWithCodeAndData: UInt8 = 0x34 + private static let walletV4R2CodeDepth = 7 + private static let walletV4R2CodeHash = Data(tonHex: "feb5ff6820e2ff0d9483e7e0d62c817d846789fb4ae580c878866d959dabd5c0") + + static func v4R2Addresses(publicKey: Data) throws -> TonV4R2AddressSet { + let accountHash = try v4R2AccountHash(publicKey: publicKey) + + return TonV4R2AddressSet( + accountHash: accountHash, + bounceable: try friendlyAddress(accountHash: accountHash, isTestnet: false, bounceable: true), + nonBounceable: try friendlyAddress(accountHash: accountHash, isTestnet: false, bounceable: false), + testnetNonBounceable: try friendlyAddress(accountHash: accountHash, isTestnet: true, bounceable: false) + ) + } + + static func v4R2AccountHash(publicKey: Data) throws -> Data { + guard publicKey.count == publicKeyLength else { + throw TonAddressCodecError.invalidPublicKeyLength + } + + var dataCellBytes = Data(repeating: 0, count: 4) + dataCellBytes.append(walletId.bigEndianBytes) + dataCellBytes.append(publicKey) + dataCellBytes.append(walletDataTopUpByte) + + let dataCell = try cellHash( + bitsCount: walletDataCellBitCount, + dataWithTopUp: dataCellBytes, + refs: [] + ) + + let stateInitCell = try cellHash( + bitsCount: stateInitCellBitCount, + dataWithTopUp: Data([stateInitFlagsWithCodeAndData]), + refs: [ + CellReference(depth: walletV4R2CodeDepth, hash: walletV4R2CodeHash), + CellReference(depth: dataCell.depth, hash: dataCell.hash) + ] + ) + + return stateInitCell.hash + } + + static func friendlyAddress( + accountHash: Data, + isTestnet: Bool, + bounceable: Bool + ) throws -> String { + guard accountHash.count == accountHashLength else { + throw TonAddressCodecError.invalidAccountHashLength + } + + var tag: UInt8 = bounceable ? 0x11 : 0x51 + if isTestnet { + tag |= 0x80 + } + + var payload = Data([tag, UInt8(workchain)]) + payload.append(accountHash) + payload.append(crc16Xmodem(payload).bigEndianBytes) + + return payload.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + + private static func cellHash( + bitsCount: Int, + dataWithTopUp: Data, + refs: [CellReference] + ) throws -> CellDigest { + guard refs.count <= 4, refs.allSatisfy({ $0.depth >= 0 && $0.hash.count == cellHashLength }) else { + throw TonAddressCodecError.invalidCellReference + } + + let fullBytes = bitsCount / 8 + let ceilBytes = (bitsCount + 7) / 8 + let descriptor = UInt8(fullBytes + ceilBytes) + var representation = Data([UInt8(refs.count), descriptor]) + representation.append(dataWithTopUp) + + for ref in refs { + representation.append(UInt16(ref.depth).bigEndianBytes) + } + + for ref in refs { + representation.append(ref.hash) + } + + let hash = Data(SHA256.hash(data: representation)) + let depth = refs.map(\.depth).max().map { $0 + 1 } ?? 0 + + return CellDigest(depth: depth, hash: hash) + } + + private static func crc16Xmodem(_ data: Data) -> UInt16 { + var crc: UInt16 = 0 + + for byte in data { + crc ^= UInt16(byte) << 8 + + for _ in 0 ..< 8 { + if crc & 0x8000 != 0 { + crc = (crc << 1) ^ 0x1021 + } else { + crc <<= 1 + } + } + } + + return crc + } +} + +private struct CellReference { + let depth: Int + let hash: Data +} + +private struct CellDigest { + let depth: Int + let hash: Data +} + +private extension UInt16 { + var bigEndianBytes: Data { + var value = bigEndian + return Data(bytes: &value, count: MemoryLayout.size) + } +} + +private extension UInt32 { + var bigEndianBytes: Data { + var value = bigEndian + return Data(bytes: &value, count: MemoryLayout.size) + } +} + +private extension Data { + init(tonHex: String) { + var bytes: [UInt8] = [] + var index = tonHex.startIndex + + while index < tonHex.endIndex { + let nextIndex = tonHex.index(index, offsetBy: 2) + bytes.append(UInt8(tonHex[index ..< nextIndex], radix: 16)!) + index = nextIndex + } + + self.init(bytes) + } +} diff --git a/fearless/Common/Model/TonIndexerClient.swift b/fearless/Common/Model/TonIndexerClient.swift new file mode 100644 index 0000000000..c43486ee8d --- /dev/null +++ b/fearless/Common/Model/TonIndexerClient.swift @@ -0,0 +1,198 @@ +import Foundation + +protocol TonIndexerClientProtocol { + func health(baseURL: String?) async throws -> TonHealthStatus + func contracts(baseURL: String?) async throws -> TonContractsResponse + func serviceInfo(baseURL: String?) async throws -> TonIndexerServiceInfo + func verifyServiceInfo(baseURL: String?) async throws -> TonIndexerServiceInfo + func balance(address: String, baseURL: String?) async throws -> TonBalanceResponse + func balances(address: String, baseURL: String?) async throws -> TonBalancesResponse + func assets(address: String, baseURL: String?) async throws -> TonBalancesResponse + func state(address: String, baseURL: String?) async throws -> TonAccountStateResponse + func transactions( + address: String, + baseURL: String?, + page: Int, + cursorLt: String?, + cursorHash: String? + ) async throws -> TonTransactionsResponse + func swaps( + address: String, + baseURL: String?, + limit: Int, + fromUtime: Int64?, + toUtime: Int64?, + payToken: String?, + receiveToken: String?, + executionType: TonSwapExecutionType?, + status: TonSwapStatus?, + includeReverse: Bool? + ) async throws -> TonSwapsResponse + func jettonTransferPayload(jetton: String, owner: String, baseURL: String?) async throws -> TonJettonTransferPayloadResponse + func runGetMethod(address: String, method: String, stack: [[TonJSONValue]], baseURL: String?) async throws -> TonRunGetMethodResponse + func runGetMethods(calls: [TonRunGetMethodRequest], baseURL: String?) async throws -> TonRunGetMethodsResponse +} + +enum TonIndexerClientError: Error, Equatable { + case unexpectedServiceInfo(TonIndexerServiceInfo) +} + +final class TonIndexerClient: TonIndexerClientProtocol { + private let transport: UniversalWalletHTTPTransport + private let defaultBaseURL: String + private let decoder: JSONDecoder + private let encoder: JSONEncoder + + init( + transport: UniversalWalletHTTPTransport = URLSessionUniversalWalletHTTPTransport(), + defaultBaseURL: String = UniversalWalletRegistry.tonIndexerBaseURL.absoluteString, + decoder: JSONDecoder = JSONDecoder(), + encoder: JSONEncoder = JSONEncoder() + ) { + self.transport = transport + self.defaultBaseURL = defaultBaseURL + self.decoder = decoder + self.encoder = encoder + } + + func health(baseURL: String? = nil) async throws -> TonHealthStatus { + try await get(try TonIndexerRoutes.healthURL(baseURL: resolvedBaseURL(baseURL))) + } + + func contracts(baseURL: String? = nil) async throws -> TonContractsResponse { + try await get(try TonIndexerRoutes.contractsURL(baseURL: resolvedBaseURL(baseURL))) + } + + func serviceInfo(baseURL: String? = nil) async throws -> TonIndexerServiceInfo { + try await get(try TonIndexerRoutes.serviceInfoURL(baseURL: resolvedBaseURL(baseURL))) + } + + func verifyServiceInfo(baseURL: String? = nil) async throws -> TonIndexerServiceInfo { + let info = try await serviceInfo(baseURL: baseURL) + guard info.isExpectedTIServiceInfo else { + throw TonIndexerClientError.unexpectedServiceInfo(info) + } + return info + } + + func balance(address: String, baseURL: String? = nil) async throws -> TonBalanceResponse { + try await get(try TonIndexerRoutes.balanceURL(address: address, baseURL: resolvedBaseURL(baseURL))) + } + + func balances(address: String, baseURL: String? = nil) async throws -> TonBalancesResponse { + try await get(try TonIndexerRoutes.balancesURL(address: address, baseURL: resolvedBaseURL(baseURL))) + } + + func assets(address: String, baseURL: String? = nil) async throws -> TonBalancesResponse { + try await get(try TonIndexerRoutes.assetsURL(address: address, baseURL: resolvedBaseURL(baseURL))) + } + + func state(address: String, baseURL: String? = nil) async throws -> TonAccountStateResponse { + try await get(try TonIndexerRoutes.stateURL(address: address, baseURL: resolvedBaseURL(baseURL))) + } + + func transactions( + address: String, + baseURL: String? = nil, + page: Int = TonIndexerRoutes.defaultTxPage, + cursorLt: String? = nil, + cursorHash: String? = nil + ) async throws -> TonTransactionsResponse { + try await get( + try TonIndexerRoutes.transactionsURL( + address: address, + baseURL: resolvedBaseURL(baseURL), + page: page, + cursorLt: cursorLt, + cursorHash: cursorHash + ) + ) + } + + func swaps( + address: String, + baseURL: String? = nil, + limit: Int = TonIndexerRoutes.defaultSwapLimit, + fromUtime: Int64? = nil, + toUtime: Int64? = nil, + payToken: String? = nil, + receiveToken: String? = nil, + executionType: TonSwapExecutionType? = nil, + status: TonSwapStatus? = nil, + includeReverse: Bool? = nil + ) async throws -> TonSwapsResponse { + try await get( + try TonIndexerRoutes.swapsURL( + address: address, + baseURL: resolvedBaseURL(baseURL), + limit: limit, + fromUtime: fromUtime, + toUtime: toUtime, + payToken: payToken, + receiveToken: receiveToken, + executionType: executionType, + status: status, + includeReverse: includeReverse + ) + ) + } + + func jettonTransferPayload( + jetton: String, + owner: String, + baseURL: String? = nil + ) async throws -> TonJettonTransferPayloadResponse { + try await get( + try TonIndexerRoutes.jettonTransferPayloadURL( + jetton: jetton, + owner: owner, + baseURL: resolvedBaseURL(baseURL) + ) + ) + } + + func runGetMethod( + address: String, + method: String, + stack: [[TonJSONValue]] = [], + baseURL: String? = nil + ) async throws -> TonRunGetMethodResponse { + let body = try TonIndexerRoutes.runGetMethodRequest(address: address, method: method, stack: stack) + return try await postJSON(body, to: try TonIndexerRoutes.runGetMethodURL(baseURL: resolvedBaseURL(baseURL))) + } + + func runGetMethods( + calls: [TonRunGetMethodRequest], + baseURL: String? = nil + ) async throws -> TonRunGetMethodsResponse { + let body = try TonIndexerRoutes.runGetMethodsRequest(calls: calls) + return try await postJSON(body, to: try TonIndexerRoutes.runGetMethodsURL(baseURL: resolvedBaseURL(baseURL))) + } + + private func get(_ url: URL) async throws -> T { + try decode(try await transport.perform(urlRequest(url: url))) + } + + private func postJSON(_ body: Body, to url: URL) async throws -> Response { + var request = urlRequest(url: url, method: "POST") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.httpBody = try encoder.encode(body) + + return try decode(try await transport.perform(request)) + } + + private func decode(_ data: Data) throws -> T { + try decoder.decode(T.self, from: data) + } + + private func urlRequest(url: URL, method: String = "GET") -> URLRequest { + var request = URLRequest(url: url) + request.httpMethod = method + return request + } + + private func resolvedBaseURL(_ baseURL: String?) -> String { + baseURL ?? defaultBaseURL + } +} diff --git a/fearless/Common/Model/TonIndexerContract.swift b/fearless/Common/Model/TonIndexerContract.swift new file mode 100644 index 0000000000..c4083c3b0f --- /dev/null +++ b/fearless/Common/Model/TonIndexerContract.swift @@ -0,0 +1,562 @@ +import Foundation + +enum TonIndexerRouteError: Error, Equatable { + case invalidBaseURL + case invalidAddress + case invalidPage + case cursorMismatch + case invalidCursor + case invalidLimit + case invalidUtime + case invalidUtimeRange + case invalidTokenFilter + case invalidMethod + case invalidCalls +} + +enum TonSwapExecutionType: String, Codable { + case market + case limit + case twap + case unknown +} + +enum TonSwapStatus: String, Codable { + case success + case failed + case pending +} + +enum TonIndexerRoutes { + static let defaultTxPage = 1 + static let defaultSwapLimit = 100 + static let maxSwapLimit = 500 + static let maxRunGetBatchSize = 64 + + static func healthURL(baseURL: String = UniversalWalletRegistry.tonIndexerBaseURL.absoluteString) throws -> URL { + try makeURL("\(normalizeBaseURL(baseURL))/api/indexer/v1/health") + } + + static func contractsURL(baseURL: String = UniversalWalletRegistry.tonIndexerBaseURL.absoluteString) throws -> URL { + try makeURL("\(normalizeBaseURL(baseURL))/api/indexer/v1/contracts") + } + + static func serviceInfoURL(baseURL: String = UniversalWalletRegistry.tonIndexerBaseURL.absoluteString) throws -> URL { + try makeURL("\(normalizeBaseURL(baseURL))/api/indexer/v1/service-info") + } + + static func balanceURL(address: String, baseURL: String = UniversalWalletRegistry.tonIndexerBaseURL.absoluteString) throws -> URL { + try accountURL(baseURL: baseURL, address: address, section: "balance") + } + + static func balancesURL(address: String, baseURL: String = UniversalWalletRegistry.tonIndexerBaseURL.absoluteString) throws -> URL { + try accountURL(baseURL: baseURL, address: address, section: "balances") + } + + static func assetsURL(address: String, baseURL: String = UniversalWalletRegistry.tonIndexerBaseURL.absoluteString) throws -> URL { + try accountURL(baseURL: baseURL, address: address, section: "assets") + } + + static func stateURL(address: String, baseURL: String = UniversalWalletRegistry.tonIndexerBaseURL.absoluteString) throws -> URL { + try accountURL(baseURL: baseURL, address: address, section: "state") + } + + static func transactionsURL( + address: String, + baseURL: String = UniversalWalletRegistry.tonIndexerBaseURL.absoluteString, + page: Int = defaultTxPage, + cursorLt: String? = nil, + cursorHash: String? = nil + ) throws -> URL { + guard page >= 1 else { + throw TonIndexerRouteError.invalidPage + } + guard (cursorLt == nil) == (cursorHash == nil) else { + throw TonIndexerRouteError.cursorMismatch + } + + guard var components = URLComponents( + string: "\(try accountURL(baseURL: baseURL, address: address, section: "txs").absoluteString)" + ) else { + throw TonIndexerRouteError.invalidBaseURL + } + + var queryItems = [URLQueryItem(name: "page", value: String(page))] + if let cursorLt, let cursorHash { + queryItems.append(URLQueryItem(name: "cursor_lt", value: try normalizeLt(cursorLt))) + queryItems.append(URLQueryItem(name: "cursor_hash", value: try normalizeHash(cursorHash))) + } + components.queryItems = queryItems + + guard let url = components.url else { + throw TonIndexerRouteError.invalidBaseURL + } + + return url + } + + static func swapsURL( + address: String, + baseURL: String = UniversalWalletRegistry.tonIndexerBaseURL.absoluteString, + limit: Int = defaultSwapLimit, + fromUtime: Int64? = nil, + toUtime: Int64? = nil, + payToken: String? = nil, + receiveToken: String? = nil, + executionType: TonSwapExecutionType? = nil, + status: TonSwapStatus? = nil, + includeReverse: Bool? = nil + ) throws -> URL { + guard (1 ... maxSwapLimit).contains(limit) else { + throw TonIndexerRouteError.invalidLimit + } + if fromUtime.map({ $0 < 1 }) == true || toUtime.map({ $0 < 1 }) == true { + throw TonIndexerRouteError.invalidUtime + } + if let fromUtime, let toUtime, fromUtime > toUtime { + throw TonIndexerRouteError.invalidUtimeRange + } + + guard var components = URLComponents( + string: "\(try accountURL(baseURL: baseURL, address: address, section: "swaps").absoluteString)" + ) else { + throw TonIndexerRouteError.invalidBaseURL + } + + var queryItems = [URLQueryItem(name: "limit", value: String(limit))] + fromUtime.map { queryItems.append(URLQueryItem(name: "from_utime", value: String($0))) } + toUtime.map { queryItems.append(URLQueryItem(name: "to_utime", value: String($0))) } + if let payToken { + queryItems.append(URLQueryItem(name: "pay_token", value: try normalizeTokenFilter(payToken))) + } + if let receiveToken { + queryItems.append(URLQueryItem(name: "receive_token", value: try normalizeTokenFilter(receiveToken))) + } + executionType.map { queryItems.append(URLQueryItem(name: "execution_type", value: $0.rawValue)) } + status.map { queryItems.append(URLQueryItem(name: "status", value: $0.rawValue)) } + includeReverse.map { queryItems.append(URLQueryItem(name: "include_reverse", value: $0 ? "true" : "false")) } + components.queryItems = queryItems + + guard let url = components.url else { + throw TonIndexerRouteError.invalidBaseURL + } + + return url + } + + static func jettonTransferPayloadURL( + jetton: String, + owner: String, + baseURL: String = UniversalWalletRegistry.tonIndexerBaseURL.absoluteString + ) throws -> URL { + try makeURL( + "\(normalizeBaseURL(baseURL))/api/indexer/v1/jettons/\(normalizeAddress(jetton))/transfer/\(normalizeAddress(owner))/payload" + ) + } + + static func runGetMethodURL(baseURL: String = UniversalWalletRegistry.tonIndexerBaseURL.absoluteString) throws -> URL { + try makeURL("\(normalizeBaseURL(baseURL))/api/indexer/v1/runGetMethod") + } + + static func runGetMethodsURL(baseURL: String = UniversalWalletRegistry.tonIndexerBaseURL.absoluteString) throws -> URL { + try makeURL("\(normalizeBaseURL(baseURL))/api/indexer/v1/runGetMethods") + } + + static func runGetMethodRequest( + address: String, + method: String, + stack: [[TonJSONValue]] = [] + ) throws -> TonRunGetMethodRequest { + TonRunGetMethodRequest( + address: try normalizeAddress(address), + method: try normalizeGetterMethod(method), + stack: stack + ) + } + + static func runGetMethodsRequest(calls: [TonRunGetMethodRequest]) throws -> TonRunGetMethodsRequest { + guard !calls.isEmpty, calls.count <= maxRunGetBatchSize else { + throw TonIndexerRouteError.invalidCalls + } + + return TonRunGetMethodsRequest(calls: calls) + } + + static func normalizeBaseURL(_ baseURL: String) throws -> String { + let trimmed = baseURL.trimmingCharacters(in: .whitespacesAndNewlines) + guard let url = URL(string: trimmed), let scheme = url.scheme, let host = url.host else { + throw TonIndexerRouteError.invalidBaseURL + } + + let isLocal = host == "localhost" || host == "127.0.0.1" + guard scheme == "https" || isLocal else { + throw TonIndexerRouteError.invalidBaseURL + } + + return trimmed.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + } + + static func normalizeAddress(_ address: String) throws -> String { + let normalized = address.trimmingCharacters(in: .whitespacesAndNewlines) + if matches(normalized, "^[A-Za-z0-9_-]{48}$") { + return normalized + } + + let lowercased = normalized.lowercased() + guard matches(lowercased, "^(?:-1|0):[0-9a-f]{64}$") else { + throw TonIndexerRouteError.invalidAddress + } + + return lowercased + } + + private static func accountURL(baseURL: String, address: String, section: String) throws -> URL { + try makeURL("\(normalizeBaseURL(baseURL))/api/indexer/v1/accounts/\(normalizeAddress(address))/\(section)") + } + + private static func normalizeLt(_ value: String) throws -> String { + let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard matches(normalized, "^\\d+$") else { + throw TonIndexerRouteError.invalidCursor + } + + return normalized + } + + private static func normalizeHash(_ value: String) throws -> String { + let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard matches(normalized, "^[A-Za-z0-9_+/=-]{40,64}$") else { + throw TonIndexerRouteError.invalidCursor + } + + return normalized + } + + private static func normalizeTokenFilter(_ value: String) throws -> String { + let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard matches(normalized, "^[A-Za-z0-9._:-]{1,32}$") else { + throw TonIndexerRouteError.invalidTokenFilter + } + + return normalized + } + + private static func normalizeGetterMethod(_ value: String) throws -> String { + let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard matches(normalized, "^[A-Za-z_][A-Za-z0-9_]{0,63}$") else { + throw TonIndexerRouteError.invalidMethod + } + + return normalized + } + + private static func makeURL(_ value: String) throws -> URL { + guard let url = URL(string: value) else { + throw TonIndexerRouteError.invalidBaseURL + } + + return url + } + + private static func matches(_ value: String, _ pattern: String) -> Bool { + value.range(of: pattern, options: .regularExpression) != nil + } +} + +enum TonJSONValue: Codable, Equatable { + case string(String) + case int(Int64) + case double(Double) + case bool(Bool) + case object([String: TonJSONValue]) + case array([TonJSONValue]) + case null + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + if container.decodeNil() { + self = .null + } else if let value = try? container.decode(Bool.self) { + self = .bool(value) + } else if let value = try? container.decode(Int64.self) { + self = .int(value) + } else if let value = try? container.decode(Double.self) { + self = .double(value) + } else if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode([String: TonJSONValue].self) { + self = .object(value) + } else if let value = try? container.decode([TonJSONValue].self) { + self = .array(value) + } else { + throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported JSON value") + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + + switch self { + case let .string(value): + try container.encode(value) + case let .int(value): + try container.encode(value) + case let .double(value): + try container.encode(value) + case let .bool(value): + try container.encode(value) + case let .object(value): + try container.encode(value) + case let .array(value): + try container.encode(value) + case .null: + try container.encodeNil() + } + } +} + +struct TonHealthStatus: Codable, Equatable { + let lastMasterSeqno: Int64? + let indexerLagSec: Double? + let liteserverPoolStatus: String? +} + +struct TonContractsResponse: Codable, Equatable { + let network: String? + let count: Int + let contracts: [String: String] +} + +struct TonIndexerServiceInfo: Codable, Equatable { + let schemaVersion: Int + let serviceId: String + let serviceName: String + let ecosystem: String + let chainId: String + let network: String + let publicBaseUrl: String + let readOnly: Bool + let capabilities: [String] + let endpoints: [String: String] + + var isExpectedTIServiceInfo: Bool { + schemaVersion == 1 && + serviceId == "ti.soramitsu.io" && + ecosystem == "ton" && + chainId == "ton:mainnet" && + publicBaseUrl == "https://ti.soramitsu.io" && + readOnly + } +} + +struct TonBalanceResponse: Codable, Equatable { + let ton: TonNativeBalance + let jettons: [TonIndexerJettonBalance] + let confirmed: Bool + let updatedAt: Int64 + let network: String + + private enum CodingKeys: String, CodingKey { + case ton + case jettons + case confirmed + case updatedAt = "updated_at" + case network + } +} + +struct TonNativeBalance: Codable, Equatable { + let balance: String + let lastTxLt: String? + let lastTxHash: String? + + private enum CodingKeys: String, CodingKey { + case balance + case lastTxLt = "last_tx_lt" + case lastTxHash = "last_tx_hash" + } +} + +struct TonIndexerJettonBalance: Codable, Equatable { + let master: String + let wallet: String + let balance: String + let decimals: Int? + let symbol: String? +} + +struct TonBalancesResponse: Codable, Equatable { + let address: String + let tonRaw: String + let ton: String + let assets: [TonAssetBalance] + let confirmed: Bool + let updatedAt: Int64 + let network: String + + private enum CodingKeys: String, CodingKey { + case address + case tonRaw = "ton_raw" + case ton + case assets + case confirmed + case updatedAt = "updated_at" + case network + } +} + +struct TonAssetBalance: Codable, Equatable { + let kind: String + let symbol: String? + let address: String? + let wallet: String? + let balanceRaw: String + let balance: String + let decimals: Int + + private enum CodingKeys: String, CodingKey { + case kind + case symbol + case address + case wallet + case balanceRaw = "balance_raw" + case balance + case decimals + } +} + +struct TonAccountStateResponse: Codable, Equatable { + let address: String + let balance: String + let lastTxLt: String? + let lastTxHash: String? + let accountState: String? + let codeBoc: String? + let dataBoc: String? + let updatedAt: Int64 +} + +struct TonJettonTransferPayloadResponse: Codable, Equatable { + let customPayload: String? + let stateInit: String? + + private enum CodingKeys: String, CodingKey { + case customPayload = "custom_payload" + case stateInit = "state_init" + } +} + +struct TonTransactionsResponse: Codable, Equatable { + let page: Int + let pageSize: Int + let totalTxs: Int + let totalPages: Int? + let totalPagesMin: Int + let historyComplete: Bool + let txs: [TonTransactionEntry] + let network: String + + private enum CodingKeys: String, CodingKey { + case page + case pageSize = "page_size" + case totalTxs = "total_txs" + case totalPages = "total_pages" + case totalPagesMin = "total_pages_min" + case historyComplete = "history_complete" + case txs + case network + } +} + +struct TonTransactionEntry: Codable, Equatable { + let txId: String + let utime: Int64 + let status: String + let reason: String? + let txType: String + let inSource: String? + let inValue: String? + let outCount: Int + let detail: [String: TonJSONValue] + let kind: String + let actions: [[String: TonJSONValue]] + let lt: String + let hash: String + let inMessage: TonMessageSummary? + let outMessages: [TonMessageSummary] +} + +struct TonMessageSummary: Codable, Equatable { + let source: String? + let destination: String? + let value: String? + let op: Int64? + let body: String? +} + +struct TonSwapsResponse: Codable, Equatable { + let address: String + let swaps: [TonSwapExecution] + let count: Int + let network: String +} + +struct TonSwapExecution: Codable, Equatable { + let txId: String + let lt: String + let hash: String + let utime: Int64 + let status: String + let reason: String? + let payToken: String? + let receiveToken: String? + let payAmount: String? + let receiveAmount: String? + let queryId: String? + let executionType: String? +} + +struct TonRunGetMethodRequest: Codable, Equatable { + let address: String + let method: String + let stack: [[TonJSONValue]] +} + +struct TonRunGetMethodsRequest: Codable, Equatable { + let calls: [TonRunGetMethodRequest] +} + +struct TonRunGetMethodResponse: Codable, Equatable { + let exitCode: Int + let gasUsed: Int64 + let stack: [[TonJSONValue]] + + private enum CodingKeys: String, CodingKey { + case exitCode = "exit_code" + case gasUsed = "gas_used" + case stack + } +} + +struct TonRunGetMethodsResponse: Codable, Equatable { + let results: [TonRunGetMethodBatchResult] +} + +struct TonRunGetMethodBatchResult: Codable, Equatable { + let ok: Bool + let exitCode: Int? + let gasUsed: Int64? + let stack: [[TonJSONValue]]? + let code: String? + let error: String? + + private enum CodingKeys: String, CodingKey { + case ok + case exitCode = "exit_code" + case gasUsed = "gas_used" + case stack + case code + case error + } +} diff --git a/fearless/Common/Model/TonKeyDerivation.swift b/fearless/Common/Model/TonKeyDerivation.swift new file mode 100644 index 0000000000..705367db94 --- /dev/null +++ b/fearless/Common/Model/TonKeyDerivation.swift @@ -0,0 +1,46 @@ +import Foundation + +struct TonDerivedAccount: Equatable { + let derivationPath: String + let privateKey: Data + let chainCode: Data + let publicKey: Data + let publicKeyHex: String + let accountHash: Data + let addressBounceable: String + let addressNonBounceable: String + let testnetNonBounceable: String +} + +enum TonKeyDerivation { + static func deriveAccount( + mnemonic: String, + passphrase: String = "", + derivationPath: String = UniversalWalletDerivationPaths.tonDefault + ) throws -> TonDerivedAccount { + let ed25519 = try SolanaKeyDerivation.deriveAccount( + mnemonic: mnemonic, + passphrase: passphrase, + derivationPath: derivationPath + ) + let addresses = try TonAddressCodec.v4R2Addresses(publicKey: ed25519.publicKey) + + return TonDerivedAccount( + derivationPath: derivationPath, + privateKey: ed25519.privateKey, + chainCode: ed25519.chainCode, + publicKey: ed25519.publicKey, + publicKeyHex: ed25519.publicKey.hexString, + accountHash: addresses.accountHash, + addressBounceable: addresses.bounceable, + addressNonBounceable: addresses.nonBounceable, + testnetNonBounceable: addresses.testnetNonBounceable + ) + } +} + +private extension Data { + var hexString: String { + map { String(format: "%02x", $0) }.joined() + } +} diff --git a/fearless/Common/Model/UniversalWalletAccountAddressResolver.swift b/fearless/Common/Model/UniversalWalletAccountAddressResolver.swift new file mode 100644 index 0000000000..897aa09f9a --- /dev/null +++ b/fearless/Common/Model/UniversalWalletAccountAddressResolver.swift @@ -0,0 +1,144 @@ +import Foundation +import SSFModels + +enum UniversalWalletAccountAddressResolver { + static func address(for chain: ChainModel, wallet: MetaAccountModel) -> AccountAddress? { + if UniversalWalletChainAccountSupport.isUniversalWalletChain(chain.chainId) { + guard let account = wallet.chainAccounts.first(where: { + UniversalWalletChainAccountSupport.chainId($0.chainId, matches: chain.chainId) + }) else { + return nil + } + + return UniversalWalletChainAccountSupport.address( + for: chain.chainId, + publicKey: account.publicKey + ) + } else { + return wallet.fetch(for: chain.accountRequest())?.toAddress() + } + } +} + +enum UniversalWalletChainAccountSupport { + static func isUniversalWalletChain(_ chainId: String) -> Bool { + equivalentChainIds(for: chainId) != nil + } + + static func chainId(_ storedChainId: String, matches requestedChainId: String) -> Bool { + guard let equivalentIds = equivalentChainIds(for: requestedChainId) else { + return storedChainId == requestedChainId + } + + return equivalentIds.contains(storedChainId.lowercased()) + } + + static func address(for chainId: String, publicKey: Data) -> AccountAddress? { + switch chainId.lowercased() { + case UniversalWalletRegistry.bitcoinMainnet.chainId, UniversalWalletRegistry.bitcoinMainnet.id: + return bitcoinAddress(fromPublicKey: publicKey, network: .mainnet) + case UniversalWalletRegistry.bitcoinTestnet.chainId, UniversalWalletRegistry.bitcoinTestnet.id: + return bitcoinAddress(fromPublicKey: publicKey, network: .testnet) + case UniversalWalletRegistry.solanaMainnet.chainId, UniversalWalletRegistry.solanaMainnet.id, + UniversalWalletRegistry.solanaDevnet.chainId, UniversalWalletRegistry.solanaDevnet.id: + return solanaAddress(fromPublicKey: publicKey) + case UniversalWalletRegistry.taira.chainId, UniversalWalletRegistry.taira.id: + return irohaAddress( + fromPublicKey: publicKey, + chainDiscriminant: UniversalWalletRegistry.taira.chainDiscriminant + ) + case UniversalWalletRegistry.nexus.chainId, UniversalWalletRegistry.nexus.id: + return irohaAddress( + fromPublicKey: publicKey, + chainDiscriminant: UniversalWalletRegistry.nexus.chainDiscriminant + ) + default: + return nil + } + } + + private static func equivalentChainIds(for chainId: String) -> Set? { + switch chainId.lowercased() { + case UniversalWalletRegistry.bitcoinMainnet.chainId, UniversalWalletRegistry.bitcoinMainnet.id: + return [ + UniversalWalletRegistry.bitcoinMainnet.chainId, + UniversalWalletRegistry.bitcoinMainnet.id + ] + case UniversalWalletRegistry.bitcoinTestnet.chainId, UniversalWalletRegistry.bitcoinTestnet.id: + return [ + UniversalWalletRegistry.bitcoinTestnet.chainId, + UniversalWalletRegistry.bitcoinTestnet.id + ] + case UniversalWalletRegistry.solanaMainnet.chainId, UniversalWalletRegistry.solanaMainnet.id: + return [ + UniversalWalletRegistry.solanaMainnet.chainId, + UniversalWalletRegistry.solanaMainnet.id + ] + case UniversalWalletRegistry.solanaDevnet.chainId, UniversalWalletRegistry.solanaDevnet.id: + return [ + UniversalWalletRegistry.solanaDevnet.chainId, + UniversalWalletRegistry.solanaDevnet.id + ] + case UniversalWalletRegistry.taira.chainId, UniversalWalletRegistry.taira.id: + return [ + UniversalWalletRegistry.taira.chainId, + UniversalWalletRegistry.taira.id + ] + case UniversalWalletRegistry.nexus.chainId, UniversalWalletRegistry.nexus.id: + return [ + UniversalWalletRegistry.nexus.chainId, + UniversalWalletRegistry.nexus.id + ] + default: + return nil + } + } + + private static func bitcoinAddress( + fromPublicKey publicKey: Data, + network: BitcoinKeyDerivation.Network + ) -> AccountAddress? { + let indexerNetwork: BitcoinIndexerNetwork = network == .mainnet ? .mainnet : .testnet + + guard + let address = try? BitcoinKeyDerivation.address(fromPublicKey: publicKey, network: network), + let normalizedAddress = try? BitcoinIndexerRoutes.normalizeAddress(address, network: indexerNetwork) + else { + return nil + } + + return normalizedAddress + } + + private static func solanaAddress(fromPublicKey publicKey: Data) -> AccountAddress? { + guard + let address = try? SolanaKeyDerivation.address(fromPublicKey: publicKey), + (try? SolanaIndexerRoutes.balancesURL(wallet: address)) != nil + else { + return nil + } + + return address + } + + private static func irohaAddress( + fromPublicKey publicKey: Data, + chainDiscriminant: Int + ) -> AccountAddress? { + let publicKeyHex = publicKey.map { String(format: "%02x", $0) }.joined() + guard + let address = try? IrohaAddressCodec.encode( + publicKeyHex: publicKeyHex, + chainDiscriminant: chainDiscriminant + ), + (try? IrohaAddressCodec.parse( + address, + expectedDiscriminant: chainDiscriminant + )) != nil + else { + return nil + } + + return address + } +} diff --git a/fearless/Common/Model/UniversalWalletDerivationPaths.swift b/fearless/Common/Model/UniversalWalletDerivationPaths.swift new file mode 100644 index 0000000000..0587a2ac16 --- /dev/null +++ b/fearless/Common/Model/UniversalWalletDerivationPaths.swift @@ -0,0 +1,11 @@ +enum UniversalWalletDerivationPaths { + static let substrateRoot = "" + static let evmDefault = "m/44'/60'/0'/0/0" + static let bitcoinMainnetAccount = "m/84'/0'/0'" + static let bitcoinMainnetFirstReceive = "m/84'/0'/0'/0/0" + static let bitcoinTestnetAccount = "m/84'/1'/0'" + static let bitcoinTestnetFirstReceive = "m/84'/1'/0'/0/0" + static let solanaDefault = "m/44'/501'/0'/0'" + static let tonDefault = "m/44'/607'/0'/0'/0'" + static let irohaDefault = "m/44'/617'/0'/0'" +} diff --git a/fearless/Common/Model/UniversalWalletEcosystem.swift b/fearless/Common/Model/UniversalWalletEcosystem.swift new file mode 100644 index 0000000000..9e46fc88a0 --- /dev/null +++ b/fearless/Common/Model/UniversalWalletEcosystem.swift @@ -0,0 +1,8 @@ +enum UniversalWalletEcosystem: String, CaseIterable, Codable, Equatable { + case substrate + case evm + case ton + case bitcoin + case solana + case iroha +} diff --git a/fearless/Common/Model/UniversalWalletHTTPTransport.swift b/fearless/Common/Model/UniversalWalletHTTPTransport.swift new file mode 100644 index 0000000000..962be0e61f --- /dev/null +++ b/fearless/Common/Model/UniversalWalletHTTPTransport.swift @@ -0,0 +1,30 @@ +import Foundation + +protocol UniversalWalletHTTPTransport { + func perform(_ request: URLRequest) async throws -> Data +} + +enum UniversalWalletHTTPTransportError: Error, Equatable { + case invalidResponse + case httpStatusCode(Int, Data) +} + +final class URLSessionUniversalWalletHTTPTransport: UniversalWalletHTTPTransport { + private let session: URLSession + + init(session: URLSession = .shared) { + self.session = session + } + + func perform(_ request: URLRequest) async throws -> Data { + let (data, response) = try await session.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + throw UniversalWalletHTTPTransportError.invalidResponse + } + guard (200 ... 299).contains(httpResponse.statusCode) else { + throw UniversalWalletHTTPTransportError.httpStatusCode(httpResponse.statusCode, data) + } + + return data + } +} diff --git a/fearless/Common/Model/UniversalWalletIdentity.swift b/fearless/Common/Model/UniversalWalletIdentity.swift new file mode 100644 index 0000000000..58d50d5ea9 --- /dev/null +++ b/fearless/Common/Model/UniversalWalletIdentity.swift @@ -0,0 +1,226 @@ +import Foundation + +struct UniversalWalletIdentity: Codable, Equatable { + static let schemaVersionValue = 2 + + let schemaVersion: Int + let walletId: String + let displayName: String + let source: Source + let status: Status + let publicAccounts: [PublicAccount] + let createdAtMillis: Int64 + let updatedAtMillis: Int64? + let legacyExportOnlyReason: String? + + init( + schemaVersion: Int = UniversalWalletIdentity.schemaVersionValue, + walletId: String, + displayName: String, + source: Source, + status: Status, + publicAccounts: [PublicAccount], + createdAtMillis: Int64, + updatedAtMillis: Int64? = nil, + legacyExportOnlyReason: String? = nil + ) { + self.schemaVersion = schemaVersion + self.walletId = walletId + self.displayName = displayName + self.source = source + self.status = status + self.publicAccounts = publicAccounts + self.createdAtMillis = createdAtMillis + self.updatedAtMillis = updatedAtMillis + self.legacyExportOnlyReason = legacyExportOnlyReason + } + + func validationErrors() -> Set { + var errors = Set() + + if schemaVersion != Self.schemaVersionValue { + errors.insert(.invalidSchemaVersion) + } + if !Self.matches(walletId, #"^uw2_[A-Za-z0-9_-]{16,64}$"#) { + errors.insert(.invalidWalletId) + } + if !Self.isHumanText(displayName, maxLength: 64) { + errors.insert(.invalidDisplayName) + } + if createdAtMillis <= 0 || updatedAtMillis.map({ $0 < createdAtMillis }) == true { + errors.insert(.invalidTimestamps) + } + + if status != .legacyExportOnly, publicAccounts.isEmpty { + errors.insert(.publicAccountsRequired) + } + if status == .active { + let ecosystems = Set(publicAccounts.compactMap { UniversalWalletEcosystem(rawValue: $0.ecosystem) }) + if ecosystems != Set(UniversalWalletEcosystem.allCases) { + errors.insert(.missingActiveEcosystem) + } + } + if status == .legacyExportOnly { + if source != .legacyImport { + errors.insert(.invalidLegacySource) + } + if !Self.isHumanText(legacyExportOnlyReason, maxLength: 160) { + errors.insert(.legacyReasonRequired) + } + } else if legacyExportOnlyReason?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false { + errors.insert(.legacyReasonNotAllowed) + } + + var accountIds = Set() + publicAccounts.forEach { account in + errors.formUnion(account.validationErrors()) + if !accountIds.insert(account.accountId).inserted { + errors.insert(.duplicateAccountId) + } + } + + return errors + } + + func requireValid() throws -> UniversalWalletIdentity { + let errors = validationErrors() + if !errors.isEmpty { + throw UniversalWalletIdentityError(errors: errors) + } + + return self + } + + struct PublicAccount: Codable, Equatable { + let accountId: String + let ecosystem: String + let address: String + let chainId: String? + let derivationPath: String? + let publicKeyHex: String? + let isDefault: Bool + + init( + accountId: String, + ecosystem: String, + address: String, + chainId: String? = nil, + derivationPath: String? = nil, + publicKeyHex: String? = nil, + isDefault: Bool = true + ) { + self.accountId = accountId + self.ecosystem = ecosystem + self.address = address + self.chainId = chainId + self.derivationPath = derivationPath + self.publicKeyHex = publicKeyHex + self.isDefault = isDefault + } + + init( + accountId: String, + ecosystem: UniversalWalletEcosystem, + address: String, + chainId: String? = nil, + derivationPath: String? = nil, + publicKeyHex: String? = nil, + isDefault: Bool = true + ) { + self.init( + accountId: accountId, + ecosystem: ecosystem.rawValue, + address: address, + chainId: chainId, + derivationPath: derivationPath, + publicKeyHex: publicKeyHex, + isDefault: isDefault + ) + } + + func validationErrors() -> Set { + var errors = Set() + + if !UniversalWalletIdentity.matches(accountId, #"^[a-z0-9][a-z0-9._:-]{1,63}$"#) { + errors.insert(.invalidAccountId) + } + if UniversalWalletEcosystem(rawValue: ecosystem) == nil { + errors.insert(.invalidEcosystem) + } + if !UniversalWalletIdentity.isMachineText(address, maxLength: 256) { + errors.insert(.invalidAddress) + } + if let chainId, !UniversalWalletIdentity.matches(chainId, #"^[A-Za-z0-9._:-]{2,128}$"#) { + errors.insert(.invalidChainId) + } + if let derivationPath, + !derivationPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + !UniversalWalletIdentity.matches(derivationPath, #"^m(?:/[0-9]+'?)*$"#) { + errors.insert(.invalidDerivationPath) + } + if let publicKeyHex, + !UniversalWalletIdentity.matches(publicKeyHex, #"^[0-9a-f]{64,260}$"#) || publicKeyHex.count % 2 != 0 { + errors.insert(.invalidPublicKeyHex) + } + + return errors + } + } + + enum Source: String, Codable { + case created24Word = "created-24-word" + case imported12Word = "imported-12-word" + case imported24Word = "imported-24-word" + case legacyImport = "legacy-import" + } + + enum Status: String, Codable { + case active + case migrationRequired = "migration-required" + case legacyExportOnly = "legacy-export-only" + } + + enum ValidationError: String, Error, CaseIterable { + case invalidSchemaVersion + case invalidWalletId + case invalidDisplayName + case invalidTimestamps + case publicAccountsRequired + case missingActiveEcosystem + case invalidLegacySource + case legacyReasonRequired + case legacyReasonNotAllowed + case duplicateAccountId + case invalidAccountId + case invalidEcosystem + case invalidAddress + case invalidChainId + case invalidDerivationPath + case invalidPublicKeyHex + } + + struct UniversalWalletIdentityError: Error, Equatable { + let errors: Set + } + + private static func isHumanText(_ value: String?, maxLength: Int) -> Bool { + guard let normalized = value?.trimmingCharacters(in: .whitespacesAndNewlines) else { + return false + } + + return !normalized.isEmpty && + normalized.count <= maxLength && + normalized.rangeOfCharacter(from: .controlCharacters) == nil + } + + private static func isMachineText(_ value: String, maxLength: Int) -> Bool { + value == value.trimmingCharacters(in: .whitespacesAndNewlines) && + !value.isEmpty && + value.count <= maxLength && + value.unicodeScalars.allSatisfy { $0.value > 0x20 && $0.value != 0x7F } + } + + private static func matches(_ value: String, _ pattern: String) -> Bool { + value.range(of: pattern, options: .regularExpression) != nil + } +} diff --git a/fearless/Common/Model/UniversalWalletIndexerClientFactory.swift b/fearless/Common/Model/UniversalWalletIndexerClientFactory.swift new file mode 100644 index 0000000000..408c540df6 --- /dev/null +++ b/fearless/Common/Model/UniversalWalletIndexerClientFactory.swift @@ -0,0 +1,33 @@ +import Foundation + +final class UniversalWalletIndexerClientFactory { + private let transport: UniversalWalletHTTPTransport + private let decoder: JSONDecoder + private let encoder: JSONEncoder + + init( + transport: UniversalWalletHTTPTransport = URLSessionUniversalWalletHTTPTransport(), + decoder: JSONDecoder = JSONDecoder(), + encoder: JSONEncoder = JSONEncoder() + ) { + self.transport = transport + self.decoder = decoder + self.encoder = encoder + } + + func bitcoinClient() -> BitcoinIndexerClient { + BitcoinIndexerClient(transport: transport, decoder: decoder) + } + + func solanaClient() -> SolanaIndexerClient { + SolanaIndexerClient(transport: transport, decoder: decoder, encoder: encoder) + } + + func tonClient() -> TonIndexerClient { + TonIndexerClient(transport: transport, decoder: decoder, encoder: encoder) + } + + func irohaClient() -> IrohaToriiClient { + IrohaToriiClient(transport: transport, decoder: decoder, encoder: encoder) + } +} diff --git a/fearless/Common/Model/UniversalWalletIndexerContract.swift b/fearless/Common/Model/UniversalWalletIndexerContract.swift new file mode 100644 index 0000000000..acc5de1889 --- /dev/null +++ b/fearless/Common/Model/UniversalWalletIndexerContract.swift @@ -0,0 +1,372 @@ +import Foundation + +struct UniversalWalletIndexedAssetBalance: Codable, Equatable { + let accountId: String + let ecosystem: String + let chainId: String + let assetId: String + let amount: String + let decimals: Int + let isNative: Bool + let symbol: String? + let name: String? + let uiAmountString: String? + let tokenAccountId: String? + let contractAddress: String? + let tokenProgram: String? + let syncedAtMillis: Int64 + + init( + accountId: String, + ecosystem: String, + chainId: String, + assetId: String, + amount: String, + decimals: Int, + isNative: Bool, + symbol: String? = nil, + name: String? = nil, + uiAmountString: String? = nil, + tokenAccountId: String? = nil, + contractAddress: String? = nil, + tokenProgram: String? = nil, + syncedAtMillis: Int64 + ) { + self.accountId = accountId + self.ecosystem = ecosystem + self.chainId = chainId + self.assetId = assetId + self.amount = amount + self.decimals = decimals + self.isNative = isNative + self.symbol = symbol + self.name = name + self.uiAmountString = uiAmountString + self.tokenAccountId = tokenAccountId + self.contractAddress = contractAddress + self.tokenProgram = tokenProgram + self.syncedAtMillis = syncedAtMillis + } + + init( + accountId: String, + ecosystem: UniversalWalletEcosystem, + chainId: String, + assetId: String, + amount: String, + decimals: Int, + isNative: Bool, + symbol: String? = nil, + name: String? = nil, + uiAmountString: String? = nil, + tokenAccountId: String? = nil, + contractAddress: String? = nil, + tokenProgram: String? = nil, + syncedAtMillis: Int64 + ) { + self.init( + accountId: accountId, + ecosystem: ecosystem.rawValue, + chainId: chainId, + assetId: assetId, + amount: amount, + decimals: decimals, + isNative: isNative, + symbol: symbol, + name: name, + uiAmountString: uiAmountString, + tokenAccountId: tokenAccountId, + contractAddress: contractAddress, + tokenProgram: tokenProgram, + syncedAtMillis: syncedAtMillis + ) + } + + func validationErrors() -> Set { + var errors = Set() + UniversalWalletIndexerContractValidator.validateEnvelope( + accountId: accountId, + ecosystem: ecosystem, + chainId: chainId, + syncedAtMillis: syncedAtMillis, + errors: &errors + ) + if !UniversalWalletIndexerContractValidator.isMachineText(assetId, maxLength: 160) { + errors.insert(.invalidAssetId) + } + if !UniversalWalletIndexerContractValidator.isUnsignedInteger(amount) { + errors.insert(.invalidAmount) + } + if !UniversalWalletIndexerContractValidator.decimalRange.contains(decimals) { + errors.insert(.invalidDecimals) + } + if let symbol, !symbol.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + !UniversalWalletIndexerContractValidator.isHumanText(symbol, maxLength: 32) { + errors.insert(.invalidSymbol) + } + if let name, !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + !UniversalWalletIndexerContractValidator.isHumanText(name, maxLength: 96) { + errors.insert(.invalidName) + } + if let uiAmountString, !uiAmountString.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + !UniversalWalletIndexerContractValidator.isHumanText(uiAmountString, maxLength: 80) { + errors.insert(.invalidAmount) + } + if let tokenAccountId, !tokenAccountId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + !UniversalWalletIndexerContractValidator.isMachineText(tokenAccountId, maxLength: 256) { + errors.insert(.invalidAccountId) + } + if let contractAddress, !contractAddress.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + !UniversalWalletIndexerContractValidator.isMachineText(contractAddress, maxLength: 256) { + errors.insert(.invalidAddress) + } + if let tokenProgram, !tokenProgram.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + !UniversalWalletIndexerContractValidator.isMachineText(tokenProgram, maxLength: 64) { + errors.insert(.invalidAssetId) + } + + return errors + } +} + +struct UniversalWalletIndexedTransaction: Codable, Equatable { + let accountId: String + let ecosystem: String + let chainId: String + let transactionId: String + let status: UniversalWalletIndexedTransactionStatus + let direction: UniversalWalletIndexedTransactionDirection + let operationType: UniversalWalletIndexedOperationType + let timestampMillis: Int64? + let amount: String? + let assetId: String? + let feeAmount: String? + let feeAssetId: String? + let counterpartyAddress: String? + let blockNumber: String? + let cursor: String? + let explorerUrl: String? + let syncedAtMillis: Int64 + + func validationErrors() -> Set { + var errors = Set() + UniversalWalletIndexerContractValidator.validateEnvelope( + accountId: accountId, + ecosystem: ecosystem, + chainId: chainId, + syncedAtMillis: syncedAtMillis, + errors: &errors + ) + if !UniversalWalletIndexerContractValidator.isMachineText(transactionId, maxLength: 256) { + errors.insert(.invalidTransactionId) + } + if let timestampMillis, timestampMillis <= 0 { + errors.insert(.invalidTimestamp) + } + if let amount, !UniversalWalletIndexerContractValidator.isUnsignedInteger(amount) { + errors.insert(.invalidAmount) + } + if let assetId, !UniversalWalletIndexerContractValidator.isMachineText(assetId, maxLength: 160) { + errors.insert(.invalidAssetId) + } + if let feeAmount, !UniversalWalletIndexerContractValidator.isUnsignedInteger(feeAmount) { + errors.insert(.invalidAmount) + } + if let feeAssetId, !UniversalWalletIndexerContractValidator.isMachineText(feeAssetId, maxLength: 160) { + errors.insert(.invalidAssetId) + } + if let counterpartyAddress, !UniversalWalletIndexerContractValidator.isMachineText(counterpartyAddress, maxLength: 256) { + errors.insert(.invalidAddress) + } + if let blockNumber, !UniversalWalletIndexerContractValidator.isUnsignedInteger(blockNumber) { + errors.insert(.invalidBlockNumber) + } + if let cursor, !UniversalWalletIndexerContractValidator.isMachineText(cursor, maxLength: 512) { + errors.insert(.invalidCursor) + } + if let explorerUrl, !UniversalWalletIndexerContractValidator.isHttpsUrl(explorerUrl) { + errors.insert(.invalidUrl) + } + + return errors + } +} + +struct UniversalWalletIndexedTokenMetadata: Codable, Equatable { + let ecosystem: String + let chainId: String + let assetId: String + let decimals: Int + let symbol: String? + let name: String? + let iconUrl: String? + let metadataUrl: String? + let isVerified: Bool + let syncedAtMillis: Int64 + + func validationErrors() -> Set { + var errors = Set() + + if UniversalWalletEcosystem(rawValue: ecosystem) == nil { + errors.insert(.invalidEcosystem) + } + if !UniversalWalletIndexerContractValidator.matches(chainId, #"^[A-Za-z0-9._:-]{2,128}$"#) { + errors.insert(.invalidChainId) + } + if !UniversalWalletIndexerContractValidator.isMachineText(assetId, maxLength: 160) { + errors.insert(.invalidAssetId) + } + if !UniversalWalletIndexerContractValidator.decimalRange.contains(decimals) { + errors.insert(.invalidDecimals) + } + if let symbol, !symbol.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + !UniversalWalletIndexerContractValidator.isHumanText(symbol, maxLength: 32) { + errors.insert(.invalidSymbol) + } + if let name, !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + !UniversalWalletIndexerContractValidator.isHumanText(name, maxLength: 96) { + errors.insert(.invalidName) + } + if let iconUrl, !UniversalWalletIndexerContractValidator.isAssetUrl(iconUrl) { + errors.insert(.invalidUrl) + } + if let metadataUrl, !UniversalWalletIndexerContractValidator.isAssetUrl(metadataUrl) { + errors.insert(.invalidUrl) + } + if syncedAtMillis <= 0 { + errors.insert(.invalidSyncedAt) + } + + return errors + } +} + +struct UniversalWalletIndexerPageInfo: Codable, Equatable { + let nextCursor: String? + let limit: Int + let total: Int? + let syncedAtMillis: Int64 + + func validationErrors() -> Set { + var errors = Set() + + if let nextCursor, !UniversalWalletIndexerContractValidator.isMachineText(nextCursor, maxLength: 512) { + errors.insert(.invalidCursor) + } + if !(1 ... UniversalWalletIndexerContractValidator.maxPageLimit).contains(limit) { + errors.insert(.invalidLimit) + } + if let total, total < 0 { + errors.insert(.invalidTotal) + } + if syncedAtMillis <= 0 { + errors.insert(.invalidSyncedAt) + } + + return errors + } +} + +enum UniversalWalletIndexedTransactionStatus: String, Codable { + case pending + case confirmed + case failed +} + +enum UniversalWalletIndexedTransactionDirection: String, Codable { + case incoming + case outgoing + case `self` + case unknown +} + +enum UniversalWalletIndexedOperationType: String, Codable { + case transfer + case swap + case stake + case unstake + case governance + case offlineCash = "offline-cash" + case sccp + case contractCall = "contract-call" + case mint + case burn + case fee + case unknown +} + +enum UniversalWalletIndexerValidationError: String, Error, CaseIterable { + case invalidAccountId + case invalidEcosystem + case invalidChainId + case invalidAssetId + case invalidAmount + case invalidDecimals + case invalidSymbol + case invalidName + case invalidAddress + case invalidTransactionId + case invalidTimestamp + case invalidBlockNumber + case invalidCursor + case invalidLimit + case invalidTotal + case invalidUrl + case invalidSyncedAt +} + +enum UniversalWalletIndexerContractValidator { + static let decimalRange = 0 ... 255 + static let maxPageLimit = 250 + + static func validateEnvelope( + accountId: String, + ecosystem: String, + chainId: String, + syncedAtMillis: Int64, + errors: inout Set + ) { + if !matches(accountId, #"^[a-z0-9][a-z0-9._:-]{1,63}$"#) { + errors.insert(.invalidAccountId) + } + if UniversalWalletEcosystem(rawValue: ecosystem) == nil { + errors.insert(.invalidEcosystem) + } + if !matches(chainId, #"^[A-Za-z0-9._:-]{2,128}$"#) { + errors.insert(.invalidChainId) + } + if syncedAtMillis <= 0 { + errors.insert(.invalidSyncedAt) + } + } + + static func isUnsignedInteger(_ value: String) -> Bool { + matches(value, #"^(0|[1-9][0-9]*)$"#) + } + + static func isHttpsUrl(_ value: String) -> Bool { + matches(value, #"^https://[^\s]+$"#) + } + + static func isAssetUrl(_ value: String) -> Bool { + matches(value, #"^(https|ipfs)://[^\s]+$"#) + } + + static func isHumanText(_ value: String, maxLength: Int) -> Bool { + let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines) + return !normalized.isEmpty && + normalized.count <= maxLength && + normalized.rangeOfCharacter(from: .controlCharacters) == nil + } + + static func isMachineText(_ value: String, maxLength: Int) -> Bool { + value == value.trimmingCharacters(in: .whitespacesAndNewlines) && + !value.isEmpty && + value.count <= maxLength && + value.unicodeScalars.allSatisfy { $0.value > 0x20 && $0.value != 0x7F } + } + + static func matches(_ value: String, _ pattern: String) -> Bool { + value.range(of: pattern, options: .regularExpression) != nil + } +} diff --git a/fearless/Common/Model/UniversalWalletMigrationContract.swift b/fearless/Common/Model/UniversalWalletMigrationContract.swift new file mode 100644 index 0000000000..4bbebf88c1 --- /dev/null +++ b/fearless/Common/Model/UniversalWalletMigrationContract.swift @@ -0,0 +1,567 @@ +import Foundation +import RobinHood +import SSFModels + +enum UniversalWalletMigrationCutoff { + static let defaultAtMillis: Int64 = 1_710_000_000_000 +} + +struct UniversalWalletMigrationSnapshot: Codable, Equatable { + static let schemaVersionValue = 1 + + let schemaVersion: Int + let platform: UniversalWalletMigrationPlatform + let hasUniversalWallet: Bool + let legacyVaults: [UniversalWalletLegacyVaultDescriptor] + let cutoffAtMillis: Int64 + let evaluatedAtMillis: Int64 + + init( + schemaVersion: Int = UniversalWalletMigrationSnapshot.schemaVersionValue, + platform: UniversalWalletMigrationPlatform, + hasUniversalWallet: Bool, + legacyVaults: [UniversalWalletLegacyVaultDescriptor] = [], + cutoffAtMillis: Int64, + evaluatedAtMillis: Int64 + ) { + self.schemaVersion = schemaVersion + self.platform = platform + self.hasUniversalWallet = hasUniversalWallet + self.legacyVaults = legacyVaults + self.cutoffAtMillis = cutoffAtMillis + self.evaluatedAtMillis = evaluatedAtMillis + } + + func requiredAction() -> UniversalWalletMigrationRequiredAction { + if hasUniversalWallet { + return .normalAccess + } + + return legacyVaults.isEmpty ? .createUniversalWallet : .migrateBeforeAccess + } + + func allowsNormalWalletAccess() -> Bool { + requiredAction() == .normalAccess + } + + func allowsLegacySecretExport() -> Bool { + legacyVaults.contains { $0.canExportSecrets } + } + + func validationErrors() -> Set { + var errors = Set() + + if schemaVersion != Self.schemaVersionValue { + errors.insert(.invalidSchemaVersion) + } + if cutoffAtMillis <= 0 || evaluatedAtMillis <= 0 { + errors.insert(.invalidTimestamp) + } + + var vaultIds = Set() + legacyVaults.forEach { vault in + errors.formUnion(vault.validationErrors()) + if !vaultIds.insert(vault.vaultId).inserted { + errors.insert(.duplicateVaultId) + } + } + + return errors + } +} + +struct UniversalWalletLegacyVaultDescriptor: Codable, Equatable { + let vaultId: String + let accountId: String + let ecosystem: String + let address: String + let displayName: String? + let mode: UniversalWalletLegacyVaultMode + let exportOnlyReason: String + let canExportSecrets: Bool + let canSignTransactions: Bool + let discoveredAtMillis: Int64 + let lastExportedAtMillis: Int64? + + init( + vaultId: String, + accountId: String, + ecosystem: String, + address: String, + displayName: String? = nil, + mode: UniversalWalletLegacyVaultMode = .exportOnly, + exportOnlyReason: String, + canExportSecrets: Bool = true, + canSignTransactions: Bool = false, + discoveredAtMillis: Int64, + lastExportedAtMillis: Int64? = nil + ) { + self.vaultId = vaultId + self.accountId = accountId + self.ecosystem = ecosystem + self.address = address + self.displayName = displayName + self.mode = mode + self.exportOnlyReason = exportOnlyReason + self.canExportSecrets = canExportSecrets + self.canSignTransactions = canSignTransactions + self.discoveredAtMillis = discoveredAtMillis + self.lastExportedAtMillis = lastExportedAtMillis + } + + init( + vaultId: String, + accountId: String, + ecosystem: UniversalWalletEcosystem, + address: String, + displayName: String? = nil, + mode: UniversalWalletLegacyVaultMode = .exportOnly, + exportOnlyReason: String, + canExportSecrets: Bool = true, + canSignTransactions: Bool = false, + discoveredAtMillis: Int64, + lastExportedAtMillis: Int64? = nil + ) { + self.init( + vaultId: vaultId, + accountId: accountId, + ecosystem: ecosystem.rawValue, + address: address, + displayName: displayName, + mode: mode, + exportOnlyReason: exportOnlyReason, + canExportSecrets: canExportSecrets, + canSignTransactions: canSignTransactions, + discoveredAtMillis: discoveredAtMillis, + lastExportedAtMillis: lastExportedAtMillis + ) + } + + func validationErrors() -> Set { + var errors = Set() + + if !UniversalWalletMigrationContractValidator.matches(vaultId, #"^legacy_[A-Za-z0-9_-]{8,64}$"#) { + errors.insert(.invalidVaultId) + } + if !UniversalWalletMigrationContractValidator.matches(accountId, #"^[a-z0-9][a-z0-9._:-]{1,63}$"#) { + errors.insert(.invalidAccountId) + } + if UniversalWalletEcosystem(rawValue: ecosystem) == nil { + errors.insert(.invalidEcosystem) + } + if !UniversalWalletMigrationContractValidator.isMachineText(address, maxLength: 256) { + errors.insert(.invalidAddress) + } + if let displayName, !UniversalWalletMigrationContractValidator.isHumanText(displayName, maxLength: 64) { + errors.insert(.invalidDisplayName) + } + if mode != .exportOnly { + errors.insert(.invalidLegacyMode) + } + if !UniversalWalletMigrationContractValidator.isHumanText(exportOnlyReason, maxLength: 160) { + errors.insert(.invalidExportReason) + } + if !canExportSecrets { + errors.insert(.exportDisabled) + } + if canSignTransactions { + errors.insert(.legacySigningEnabled) + } + if discoveredAtMillis <= 0 || lastExportedAtMillis.map({ $0 < discoveredAtMillis }) == true { + errors.insert(.invalidTimestamp) + } + + return errors + } +} + +enum UniversalWalletMigrationPlatform: String, Codable { + case android + case ios + case web +} + +enum UniversalWalletMigrationRequiredAction: String, Codable { + case normalAccess = "normal-access" + case createUniversalWallet = "create-universal-wallet" + case migrateBeforeAccess = "migrate-before-access" +} + +enum UniversalWalletLegacyVaultMode: String, Codable { + case exportOnly = "export-only" +} + +enum UniversalWalletMigrationValidationError: String, Error, CaseIterable { + case invalidSchemaVersion + case invalidVaultId + case duplicateVaultId + case invalidAccountId + case invalidEcosystem + case invalidAddress + case invalidDisplayName + case invalidLegacyMode + case invalidExportReason + case exportDisabled + case legacySigningEnabled + case invalidTimestamp +} + +enum UniversalWalletMigrationContractValidator { + static func isHumanText(_ value: String, maxLength: Int) -> Bool { + let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines) + return !normalized.isEmpty && + normalized.count <= maxLength && + normalized.rangeOfCharacter(from: .controlCharacters) == nil + } + + static func isMachineText(_ value: String, maxLength: Int) -> Bool { + value == value.trimmingCharacters(in: .whitespacesAndNewlines) && + !value.isEmpty && + value.count <= maxLength && + value.unicodeScalars.allSatisfy { $0.value > 0x20 && $0.value != 0x7F } + } + + static func matches(_ value: String, _ pattern: String) -> Bool { + value.range(of: pattern, options: .regularExpression) != nil + } +} + +struct IOSUniversalWalletMigrationSnapshotBuilder { + let cutoffAtMillis: Int64 + let clockMillis: () -> Int64 + + init( + cutoffAtMillis: Int64, + clockMillis: @escaping () -> Int64 = { + Int64(Date().timeIntervalSince1970 * 1000) + } + ) { + self.cutoffAtMillis = cutoffAtMillis + self.clockMillis = clockMillis + } + + func build(accounts: [MetaAccountModel]) -> UniversalWalletMigrationSnapshot { + UniversalWalletMigrationSnapshot( + platform: .ios, + hasUniversalWallet: accounts.contains { $0.hasCompleteUniversalWallet }, + legacyVaults: accounts.flatMap { account in + account.hasCompleteUniversalWallet ? [] : account.legacyVaultDescriptors(cutoffAtMillis: cutoffAtMillis) + }, + cutoffAtMillis: cutoffAtMillis, + evaluatedAtMillis: max(clockMillis(), cutoffAtMillis) + ) + } +} + +protocol UniversalWalletMigrationSnapshotProviding { + func fetchSnapshot(completion: @escaping (Result) -> Void) +} + +final class IOSUniversalWalletMigrationSnapshotProvider: UniversalWalletMigrationSnapshotProviding { + typealias FetchAccountsClosure = (@escaping (Result<[MetaAccountModel], Error>) -> Void) -> Void + + private let builder: IOSUniversalWalletMigrationSnapshotBuilder + private let fetchAccounts: FetchAccountsClosure + + init( + builder: IOSUniversalWalletMigrationSnapshotBuilder = IOSUniversalWalletMigrationSnapshotBuilder( + cutoffAtMillis: UniversalWalletMigrationCutoff.defaultAtMillis + ), + fetchAccounts: @escaping FetchAccountsClosure + ) { + self.builder = builder + self.fetchAccounts = fetchAccounts + } + + convenience init( + repository: AnyDataProviderRepository, + operationManager: OperationManagerProtocol, + builder: IOSUniversalWalletMigrationSnapshotBuilder = IOSUniversalWalletMigrationSnapshotBuilder( + cutoffAtMillis: UniversalWalletMigrationCutoff.defaultAtMillis + ) + ) { + self.init(builder: builder) { completion in + let operation = repository.fetchAllOperation( + with: RepositoryFetchOptions(includesProperties: true, includesSubentities: true) + ) + operation.completionBlock = { + DispatchQueue.main.async { + if let result = operation.result { + completion(result) + } else { + completion(.failure(BaseOperationError.parentOperationCancelled)) + } + } + } + + operationManager.enqueue(operations: [operation], in: .transient) + } + } + + func fetchSnapshot(completion: @escaping (Result) -> Void) { + fetchAccounts { [builder] result in + completion(result.map { builder.build(accounts: $0) }) + } + } +} + +struct IOSUniversalWalletLegacyExportTarget: Equatable { + let wallet: MetaAccountModel + let chainId: ChainModel.Id + let accountId: AccountId? + let isEthereumBased: Bool + let address: String + let ecosystem: UniversalWalletEcosystem + let descriptor: UniversalWalletLegacyVaultDescriptor +} + +enum IOSUniversalWalletLegacyExportResolver { + static func resolve( + descriptor: UniversalWalletLegacyVaultDescriptor, + accounts: [MetaAccountModel] + ) -> IOSUniversalWalletLegacyExportTarget? { + guard descriptor.canExportSecrets, + !descriptor.canSignTransactions, + descriptor.mode == .exportOnly, + descriptor.validationErrors().isEmpty, + let ecosystem = UniversalWalletEcosystem(rawValue: descriptor.ecosystem), + let chainId = legacyExportChainId(for: ecosystem) else { + return nil + } + + guard let wallet = accounts.first(where: { + $0.matchesLegacyVaultDescriptor(descriptor, ecosystem: ecosystem) && + $0.hasLegacyRootMaterial(for: ecosystem) + }) else { + return nil + } + + return IOSUniversalWalletLegacyExportTarget( + wallet: wallet, + chainId: chainId, + accountId: nil, + isEthereumBased: ecosystem == .evm, + address: descriptor.address, + ecosystem: ecosystem, + descriptor: descriptor + ) + } + + private static func legacyExportChainId(for ecosystem: UniversalWalletEcosystem) -> ChainModel.Id? { + switch ecosystem { + case .substrate: + return Chain.polkadot.genesisHash + case .evm: + return Chain.moonriver.genesisHash + case .ton, .bitcoin, .solana, .iroha: + return nil + } + } +} + +private extension MetaAccountModel { + var hasCompleteUniversalWallet: Bool { + substrateAddress != nil && + evmAddress != nil && + tonAddress != nil && + bitcoinAddress != nil && + solanaAddress != nil && + irohaAddress != nil + } + + func legacyVaultDescriptors(cutoffAtMillis: Int64) -> [UniversalWalletLegacyVaultDescriptor] { + var descriptors = [ + legacyVault( + ecosystem: .substrate, + address: substrateAddress ?? unavailableAddress(for: .substrate), + cutoffAtMillis: cutoffAtMillis + ) + ] + + if ethereumAddress != nil || ethereumPublicKey != nil { + descriptors.append( + legacyVault( + ecosystem: .evm, + address: evmAddress ?? unavailableAddress(for: .evm), + cutoffAtMillis: cutoffAtMillis + ) + ) + } + + return descriptors + } + + func legacyVault( + ecosystem: UniversalWalletEcosystem, + address: String, + cutoffAtMillis: Int64 + ) -> UniversalWalletLegacyVaultDescriptor { + UniversalWalletLegacyVaultDescriptor( + vaultId: "legacy_ios_\(migrationSafeId)_\(ecosystem.rawValue)", + accountId: "ios-\(migrationSafeId)-\(ecosystem.rawValue)", + ecosystem: ecosystem, + address: address, + displayName: name.safeMigrationDisplayName, + exportOnlyReason: "pre-cutoff account export", + canExportSecrets: true, + canSignTransactions: false, + discoveredAtMillis: cutoffAtMillis + ) + } + + func matchesLegacyVaultDescriptor( + _ descriptor: UniversalWalletLegacyVaultDescriptor, + ecosystem: UniversalWalletEcosystem + ) -> Bool { + descriptor.vaultId == "legacy_ios_\(migrationSafeId)_\(ecosystem.rawValue)" && + descriptor.accountId == "ios-\(migrationSafeId)-\(ecosystem.rawValue)" && + descriptor.address == legacyVaultAddress(for: ecosystem) + } + + func hasLegacyRootMaterial(for ecosystem: UniversalWalletEcosystem) -> Bool { + switch ecosystem { + case .substrate: + return !substrateAccountId.isEmpty && !substratePublicKey.isEmpty + case .evm: + return ethereumAddress != nil || ethereumPublicKey != nil + case .ton, .bitcoin, .solana, .iroha: + return false + } + } + + func legacyVaultAddress(for ecosystem: UniversalWalletEcosystem) -> String { + switch ecosystem { + case .substrate: + return substrateAddress ?? unavailableAddress(for: .substrate) + case .evm: + return evmAddress ?? unavailableAddress(for: .evm) + case .ton, .bitcoin, .solana, .iroha: + return unavailableAddress(for: ecosystem) + } + } + + var substrateAddress: String? { + guard substrateAccountId.count == 32 else { + return nil + } + + return try? substrateAccountId.toAddress(using: .substrate(0)) + } + + var evmAddress: String? { + guard let ethereumAddress, ethereumAddress.count == 20 else { + return nil + } + + return try? ethereumAddress.toAddress(using: .ethereum) + } + + var tonAddress: String? { + guard let account = chainAccount(matching: Self.tonChainIds) else { + return nil + } + + return try? TonAddressCodec.v4R2Addresses(publicKey: account.publicKey).nonBounceable + } + + var bitcoinAddress: String? { + chainAccount(matching: Self.bitcoinMainnetChainIds).flatMap { + UniversalWalletChainAccountSupport.address( + for: UniversalWalletRegistry.bitcoinMainnet.chainId, + publicKey: $0.publicKey + ) + } ?? chainAccount(matching: Self.bitcoinTestnetChainIds).flatMap { + UniversalWalletChainAccountSupport.address( + for: UniversalWalletRegistry.bitcoinTestnet.chainId, + publicKey: $0.publicKey + ) + } + } + + var solanaAddress: String? { + chainAccount(matching: Self.solanaChainIds).flatMap { + UniversalWalletChainAccountSupport.address( + for: UniversalWalletRegistry.solanaMainnet.chainId, + publicKey: $0.publicKey + ) + } + } + + var irohaAddress: String? { + chainAccount(matching: Self.tairaChainIds).flatMap { + UniversalWalletChainAccountSupport.address( + for: UniversalWalletRegistry.taira.chainId, + publicKey: $0.publicKey + ) + } ?? chainAccount(matching: Self.nexusChainIds).flatMap { + UniversalWalletChainAccountSupport.address( + for: UniversalWalletRegistry.nexus.chainId, + publicKey: $0.publicKey + ) + } + } + + func chainAccount(matching chainIds: Set) -> ChainAccountModel? { + chainAccounts.first { chainIds.contains($0.chainId.lowercased()) } + } + + func unavailableAddress(for ecosystem: UniversalWalletEcosystem) -> String { + "unavailable:ios:\(migrationSafeId):\(ecosystem.rawValue)" + } + + var migrationSafeId: String { + let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyz0123456789._:-") + let normalized = metaId + .lowercased() + .unicodeScalars + .map { allowed.contains($0) ? Character($0) : "_" } + + let value = String(normalized).prefix(40) + return value.isEmpty ? "account" : String(value) + } + + static let tonChainIds: Set = [ + "ton-mainnet", + "ton:mainnet" + ] + + static let bitcoinMainnetChainIds: Set = [ + UniversalWalletRegistry.bitcoinMainnet.chainId, + UniversalWalletRegistry.bitcoinMainnet.id + ] + + static let bitcoinTestnetChainIds: Set = [ + UniversalWalletRegistry.bitcoinTestnet.chainId, + UniversalWalletRegistry.bitcoinTestnet.id + ] + + static let solanaChainIds: Set = [ + UniversalWalletRegistry.solanaMainnet.chainId, + UniversalWalletRegistry.solanaMainnet.id, + UniversalWalletRegistry.solanaDevnet.chainId, + UniversalWalletRegistry.solanaDevnet.id + ] + + static let tairaChainIds: Set = [ + UniversalWalletRegistry.taira.chainId, + UniversalWalletRegistry.taira.id + ] + + static let nexusChainIds: Set = [ + UniversalWalletRegistry.nexus.chainId, + UniversalWalletRegistry.nexus.id + ] +} + +private extension String { + var safeMigrationDisplayName: String? { + let value = trimmingCharacters(in: .whitespacesAndNewlines) + .unicodeScalars + .filter { !CharacterSet.controlCharacters.contains($0) } + .map(Character.init) + .prefix(64) + + let sanitized = String(value) + return sanitized.isEmpty ? nil : sanitized + } +} diff --git a/fearless/Common/Model/UniversalWalletRegistry.swift b/fearless/Common/Model/UniversalWalletRegistry.swift new file mode 100644 index 0000000000..3b5fb3da3d --- /dev/null +++ b/fearless/Common/Model/UniversalWalletRegistry.swift @@ -0,0 +1,292 @@ +import Foundation + +enum UniversalWalletRegistry { + static let bitcoinMainnetIndexerBaseURL = URL(string: "https://blockstream.info/api")! + static let bitcoinTestnetIndexerBaseURL = URL(string: "https://blockstream.info/testnet/api")! + static let tonIndexerBaseURL = URL(string: "https://ti.soramitsu.io")! + static let solanaIndexerBaseURL = URL(string: "https://si.soramitsu.io")! + static let solanaMainnetRPCURL = URL(string: "https://api.mainnet-beta.solana.com")! + static let solanaDevnetRPCURL = URL(string: "https://api.devnet.solana.com")! + + static let bitcoinMainnet = BitcoinNetwork( + id: "bitcoin-mainnet", + chainId: "bitcoin:mainnet", + name: "Bitcoin", + slip44CoinType: 0, + addressHrp: "bc", + accountPath: UniversalWalletDerivationPaths.bitcoinMainnetAccount, + firstReceivePath: UniversalWalletDerivationPaths.bitcoinMainnetFirstReceive, + indexerBaseURL: bitcoinMainnetIndexerBaseURL, + defaultGapLimit: 20, + enabledByDefault: true, + nativeAsset: BitcoinNativeAsset( + id: "BTC", + symbol: "BTC", + decimals: 8 + ) + ) + + static let bitcoinTestnet = BitcoinNetwork( + id: "bitcoin-testnet", + chainId: "bitcoin:testnet", + name: "Bitcoin Testnet", + slip44CoinType: 1, + addressHrp: "tb", + accountPath: UniversalWalletDerivationPaths.bitcoinTestnetAccount, + firstReceivePath: UniversalWalletDerivationPaths.bitcoinTestnetFirstReceive, + indexerBaseURL: bitcoinTestnetIndexerBaseURL, + defaultGapLimit: 20, + enabledByDefault: false, + nativeAsset: BitcoinNativeAsset( + id: "BTC", + symbol: "BTC", + decimals: 8 + ) + ) + + static let solanaMainnet = SolanaNetwork( + id: "solana-mainnet", + chainId: "solana:mainnet", + name: "Solana", + indexerBaseURL: solanaIndexerBaseURL, + rpcURL: solanaMainnetRPCURL, + enabledByDefault: true, + nativeAsset: SolanaNativeAsset( + id: "SOL", + symbol: "SOL", + decimals: 9 + ) + ) + + static let solanaDevnet = SolanaNetwork( + id: "solana-devnet", + chainId: "solana:devnet", + name: "Solana Devnet", + indexerBaseURL: solanaIndexerBaseURL, + rpcURL: solanaDevnetRPCURL, + enabledByDefault: false, + nativeAsset: SolanaNativeAsset( + id: "SOL", + symbol: "SOL", + decimals: 9 + ) + ) + + static let taira = IrohaNetwork( + id: "taira-testnet", + chainId: "iroha3-taira", + chainDiscriminant: 369, + toriiBaseURL: URL(string: "https://taira.sora.org")!, + mcpPath: "/v1/mcp", + enabledByDefault: true, + features: ["transfer"] + ) + + static let nexus = IrohaNetwork( + id: "sora-nexus-mainnet", + chainId: "sora:nexus:global", + chainDiscriminant: 753, + toriiBaseURL: URL(string: "https://minamoto.sora.org")!, + mcpPath: "/v1/mcp", + enabledByDefault: false, + features: ["transfer", "offline-cash", "sccp", "governance"] + ) + + static let bitcoinMainnetRegistryEntry = makeBitcoinRegistryEntry(bitcoinMainnet) + static let bitcoinTestnetRegistryEntry = makeBitcoinRegistryEntry(bitcoinTestnet) + static let tonMainnetRegistryEntry = UniversalWalletChainRegistryEntry( + id: "ton-mainnet", + ecosystem: .ton, + chainId: "ton:mainnet", + displayName: "TON", + enabledByDefault: true, + nativeAsset: UniversalWalletRegistryAsset( + id: "TON", + symbol: "TON", + decimals: 9, + name: "Toncoin" + ), + derivationPath: UniversalWalletDerivationPaths.tonDefault, + slip44CoinType: 607, + endpoints: [ + UniversalWalletRegistryEndpoint( + id: "ton-mainnet-indexer", + kind: .indexer, + url: tonIndexerBaseURL.absoluteString, + readOnly: true + ) + ] + ) + static let solanaMainnetRegistryEntry = makeSolanaRegistryEntry( + solanaMainnet, + derivationPath: UniversalWalletDerivationPaths.solanaDefault, + slip44CoinType: 501 + ) + static let solanaDevnetRegistryEntry = makeSolanaRegistryEntry( + solanaDevnet, + derivationPath: UniversalWalletDerivationPaths.solanaDefault, + slip44CoinType: 501 + ) + static let tairaRegistryEntry = makeIrohaRegistryEntry(taira, displayName: "Taira Testnet") + static let nexusRegistryEntry = makeIrohaRegistryEntry(nexus, displayName: "SORA Nexus") + static let chainRegistry = UniversalWalletChainRegistry( + chains: [ + bitcoinMainnetRegistryEntry, + bitcoinTestnetRegistryEntry, + tonMainnetRegistryEntry, + solanaMainnetRegistryEntry, + solanaDevnetRegistryEntry, + tairaRegistryEntry, + nexusRegistryEntry + ] + ) + + struct IrohaNetwork: Equatable { + let id: String + let chainId: String + let chainDiscriminant: Int + let toriiBaseURL: URL? + let mcpPath: String + let enabledByDefault: Bool + let features: [String] + + var mcpEndpointURL: URL? { + guard let toriiBaseURL = toriiBaseURL else { + return nil + } + + return mcpPath + .split(separator: "/") + .reduce(toriiBaseURL) { url, component in + url.appendingPathComponent(String(component)) + } + } + } + + struct BitcoinNetwork: Equatable { + let id: String + let chainId: String + let name: String + let slip44CoinType: Int + let addressHrp: String + let accountPath: String + let firstReceivePath: String + let indexerBaseURL: URL + let defaultGapLimit: Int + let enabledByDefault: Bool + let nativeAsset: BitcoinNativeAsset + } + + struct BitcoinNativeAsset: Equatable { + let id: String + let symbol: String + let decimals: Int + } + + struct SolanaNetwork: Equatable { + let id: String + let chainId: String + let name: String + let indexerBaseURL: URL + let rpcURL: URL + let enabledByDefault: Bool + let nativeAsset: SolanaNativeAsset + } + + struct SolanaNativeAsset: Equatable { + let id: String + let symbol: String + let decimals: Int + } + + private static func makeBitcoinRegistryEntry(_ network: BitcoinNetwork) -> UniversalWalletChainRegistryEntry { + UniversalWalletChainRegistryEntry( + id: network.id, + ecosystem: .bitcoin, + chainId: network.chainId, + displayName: network.name, + enabledByDefault: network.enabledByDefault, + nativeAsset: UniversalWalletRegistryAsset( + id: network.nativeAsset.id, + symbol: network.nativeAsset.symbol, + decimals: network.nativeAsset.decimals, + name: network.name + ), + derivationPath: network.accountPath, + slip44CoinType: network.slip44CoinType, + endpoints: [ + UniversalWalletRegistryEndpoint( + id: "\(network.id)-indexer", + kind: .indexer, + url: network.indexerBaseURL.absoluteString, + readOnly: true + ) + ] + ) + } + + private static func makeSolanaRegistryEntry( + _ network: SolanaNetwork, + derivationPath: String, + slip44CoinType: Int + ) -> UniversalWalletChainRegistryEntry { + UniversalWalletChainRegistryEntry( + id: network.id, + ecosystem: .solana, + chainId: network.chainId, + displayName: network.name, + enabledByDefault: network.enabledByDefault, + nativeAsset: UniversalWalletRegistryAsset( + id: network.nativeAsset.id, + symbol: network.nativeAsset.symbol, + decimals: network.nativeAsset.decimals, + name: network.name + ), + derivationPath: derivationPath, + slip44CoinType: slip44CoinType, + endpoints: [ + UniversalWalletRegistryEndpoint( + id: "\(network.id)-indexer", + kind: .indexer, + url: network.indexerBaseURL.absoluteString, + readOnly: true + ), + UniversalWalletRegistryEndpoint( + id: "\(network.id)-rpc", + kind: .rpc, + url: network.rpcURL.absoluteString, + readOnly: false, + priority: 1 + ) + ] + ) + } + + private static func makeIrohaRegistryEntry( + _ network: IrohaNetwork, + displayName: String + ) -> UniversalWalletChainRegistryEntry { + let endpoints = network.mcpEndpointURL.map { endpoint in + [ + UniversalWalletRegistryEndpoint( + id: "\(network.id)-torii-mcp", + kind: .toriiMcp, + url: endpoint.absoluteString, + readOnly: false + ) + ] + } ?? [] + + return UniversalWalletChainRegistryEntry( + id: network.id, + ecosystem: .iroha, + chainId: network.chainId, + displayName: displayName, + enabledByDefault: network.enabledByDefault, + derivationPath: UniversalWalletDerivationPaths.irohaDefault, + slip44CoinType: 617, + features: network.features, + endpoints: endpoints + ) + } +} diff --git a/fearless/Common/Model/UniversalWalletRegistryContract.swift b/fearless/Common/Model/UniversalWalletRegistryContract.swift new file mode 100644 index 0000000000..18e1de2392 --- /dev/null +++ b/fearless/Common/Model/UniversalWalletRegistryContract.swift @@ -0,0 +1,263 @@ +import Foundation + +struct UniversalWalletChainRegistry: Codable, Equatable { + static let schemaVersionValue = 1 + + let schemaVersion: Int + let chains: [UniversalWalletChainRegistryEntry] + + init(schemaVersion: Int = UniversalWalletChainRegistry.schemaVersionValue, chains: [UniversalWalletChainRegistryEntry]) { + self.schemaVersion = schemaVersion + self.chains = chains + } + + func validationErrors() -> Set { + var errors = Set() + + if schemaVersion != Self.schemaVersionValue { + errors.insert(.invalidSchemaVersion) + } + if chains.isEmpty { + errors.insert(.chainsRequired) + } + + var ids = Set() + var chainIds = Set() + chains.forEach { chain in + errors.formUnion(chain.validationErrors()) + if !ids.insert(chain.id).inserted { + errors.insert(.duplicateChainId) + } + if !chainIds.insert(chain.chainId).inserted { + errors.insert(.duplicateChainId) + } + } + + return errors + } +} + +struct UniversalWalletChainRegistryEntry: Codable, Equatable { + let id: String + let ecosystem: String + let chainId: String + let displayName: String + let enabledByDefault: Bool + let nativeAsset: UniversalWalletRegistryAsset? + let derivationPath: String? + let slip44CoinType: Int? + let features: [String] + let endpoints: [UniversalWalletRegistryEndpoint] + + init( + id: String, + ecosystem: String, + chainId: String, + displayName: String, + enabledByDefault: Bool, + nativeAsset: UniversalWalletRegistryAsset? = nil, + derivationPath: String? = nil, + slip44CoinType: Int? = nil, + features: [String] = [], + endpoints: [UniversalWalletRegistryEndpoint] = [] + ) { + self.id = id + self.ecosystem = ecosystem + self.chainId = chainId + self.displayName = displayName + self.enabledByDefault = enabledByDefault + self.nativeAsset = nativeAsset + self.derivationPath = derivationPath + self.slip44CoinType = slip44CoinType + self.features = features + self.endpoints = endpoints + } + + init( + id: String, + ecosystem: UniversalWalletEcosystem, + chainId: String, + displayName: String, + enabledByDefault: Bool, + nativeAsset: UniversalWalletRegistryAsset? = nil, + derivationPath: String? = nil, + slip44CoinType: Int? = nil, + features: [String] = [], + endpoints: [UniversalWalletRegistryEndpoint] = [] + ) { + self.init( + id: id, + ecosystem: ecosystem.rawValue, + chainId: chainId, + displayName: displayName, + enabledByDefault: enabledByDefault, + nativeAsset: nativeAsset, + derivationPath: derivationPath, + slip44CoinType: slip44CoinType, + features: features, + endpoints: endpoints + ) + } + + func validationErrors() -> Set { + var errors = Set() + + if !UniversalWalletRegistryContractValidator.matches(id, #"^[a-z0-9][a-z0-9._:-]{1,63}$"#) { + errors.insert(.invalidId) + } + if UniversalWalletEcosystem(rawValue: ecosystem) == nil { + errors.insert(.invalidEcosystem) + } + if !UniversalWalletRegistryContractValidator.matches(chainId, #"^[A-Za-z0-9._:-]{2,128}$"#) { + errors.insert(.invalidChainId) + } + if !UniversalWalletRegistryContractValidator.isHumanText(displayName, maxLength: 80) { + errors.insert(.invalidDisplayName) + } + if enabledByDefault, endpoints.isEmpty { + errors.insert(.endpointRequired) + } + if let nativeAsset { + errors.formUnion(nativeAsset.validationErrors()) + } + if let derivationPath, + !derivationPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + !UniversalWalletRegistryContractValidator.matches(derivationPath, #"^m(?:/[0-9]+'?)*$"#) { + errors.insert(.invalidDerivationPath) + } + if let slip44CoinType, slip44CoinType < 0 { + errors.insert(.invalidSlip44CoinType) + } + + var featureIds = Set() + features.forEach { feature in + if !UniversalWalletRegistryContractValidator.featureIds.contains(feature) { + errors.insert(.invalidFeatureId) + } + if !featureIds.insert(feature).inserted { + errors.insert(.duplicateFeatureId) + } + } + + var endpointIds = Set() + endpoints.forEach { endpoint in + errors.formUnion(endpoint.validationErrors()) + if !endpointIds.insert(endpoint.id).inserted { + errors.insert(.duplicateEndpointId) + } + } + + return errors + } +} + +struct UniversalWalletRegistryAsset: Codable, Equatable { + let id: String + let symbol: String + let decimals: Int + let name: String? + + func validationErrors() -> Set { + var errors = Set() + + if !UniversalWalletRegistryContractValidator.matches(id, #"^[A-Za-z0-9._:-]{1,128}$"#) { + errors.insert(.invalidAssetId) + } + if !UniversalWalletRegistryContractValidator.matches(symbol, #"^[A-Z0-9]{2,16}$"#) { + errors.insert(.invalidAssetSymbol) + } + if !(0 ... 255).contains(decimals) { + errors.insert(.invalidAssetDecimals) + } + if let name, !UniversalWalletRegistryContractValidator.isHumanText(name, maxLength: 80) { + errors.insert(.invalidAssetName) + } + + return errors + } +} + +struct UniversalWalletRegistryEndpoint: Codable, Equatable { + let id: String + let kind: UniversalWalletRegistryEndpointKind + let url: String + let readOnly: Bool + let priority: Int + + init(id: String, kind: UniversalWalletRegistryEndpointKind, url: String, readOnly: Bool, priority: Int = 0) { + self.id = id + self.kind = kind + self.url = url + self.readOnly = readOnly + self.priority = priority + } + + func validationErrors() -> Set { + var errors = Set() + + if !UniversalWalletRegistryContractValidator.matches(id, #"^[a-z0-9][a-z0-9._:-]{1,63}$"#) { + errors.insert(.invalidEndpointId) + } + if !UniversalWalletRegistryContractValidator.isPublicOrLocalURL(url) { + errors.insert(.invalidEndpointUrl) + } + if priority < 0 { + errors.insert(.invalidEndpointPriority) + } + if !readOnly, kind == .indexer { + errors.insert(.publicWriteIndexer) + } + + return errors + } +} + +enum UniversalWalletRegistryEndpointKind: String, Codable { + case indexer + case rpc + case toriiMcp = "torii-mcp" + case explorer +} + +enum UniversalWalletRegistryValidationError: String, Error, CaseIterable { + case invalidSchemaVersion + case chainsRequired + case duplicateChainId + case invalidId + case invalidEcosystem + case invalidChainId + case invalidDisplayName + case endpointRequired + case invalidDerivationPath + case invalidSlip44CoinType + case invalidAssetId + case invalidAssetSymbol + case invalidAssetDecimals + case invalidAssetName + case duplicateEndpointId + case invalidEndpointId + case invalidEndpointUrl + case invalidEndpointPriority + case duplicateFeatureId + case invalidFeatureId + case publicWriteIndexer +} + +enum UniversalWalletRegistryContractValidator { + static let featureIds: Set = ["transfer", "offline-cash", "sccp", "governance"] + + static func isPublicOrLocalURL(_ value: String) -> Bool { + matches(value, #"^(https://[^\s]+|http://(?:localhost|127\.0\.0\.1)(?::[0-9]+)?(?:/[^\s]*)?)$"#) + } + + static func isHumanText(_ value: String, maxLength: Int) -> Bool { + let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines) + return !normalized.isEmpty && + normalized.count <= maxLength && + normalized.rangeOfCharacter(from: .controlCharacters) == nil + } + + static func matches(_ value: String, _ pattern: String) -> Bool { + value.range(of: pattern, options: .regularExpression) != nil + } +} diff --git a/fearless/Common/Model/UniversalWalletSigningContract.swift b/fearless/Common/Model/UniversalWalletSigningContract.swift new file mode 100644 index 0000000000..f46d692666 --- /dev/null +++ b/fearless/Common/Model/UniversalWalletSigningContract.swift @@ -0,0 +1,349 @@ +import Foundation + +struct UniversalWalletSigningRequest: Codable, Equatable { + let requestId: String + let accountId: String + let ecosystem: String + let chainId: String + let origin: String + let method: UniversalWalletSigningMethod + let message: UniversalWalletSigningPayload? + let transactionBase64: String? + let transactionsBase64: [String] + let createdAtMillis: Int64 + let expiresAtMillis: Int64? + + func validationErrors() -> Set { + var errors = Set() + UniversalWalletSigningContractValidator.validateEnvelope( + requestId: requestId, + accountId: accountId, + ecosystem: ecosystem, + chainId: chainId, + origin: origin, + createdAtMillis: createdAtMillis, + expiresAtMillis: expiresAtMillis, + errors: &errors + ) + + switch method { + case .signMessage: + if let message { + errors.formUnion(message.validationErrors()) + } else { + errors.insert(.messageRequired) + } + if transactionBase64 != nil || !transactionsBase64.isEmpty { + errors.insert(.payloadNotAllowed) + } + case .signTransaction, .signAndSendTransaction: + if !UniversalWalletSigningContractValidator.isBase64Payload( + transactionBase64, + maxLength: UniversalWalletSigningContractValidator.maxTransactionBase64Length + ) { + errors.insert(.transactionRequired) + } + if message != nil || !transactionsBase64.isEmpty { + errors.insert(.payloadNotAllowed) + } + case .signAllTransactions: + if transactionsBase64.isEmpty || + transactionsBase64.count > UniversalWalletSigningContractValidator.maxTransactionBatch { + errors.insert(.invalidBatch) + } + if transactionsBase64.contains(where: { + !UniversalWalletSigningContractValidator.isBase64Payload( + $0, + maxLength: UniversalWalletSigningContractValidator.maxTransactionBase64Length + ) + }) { + errors.insert(.invalidBase64) + } + if message != nil || transactionBase64 != nil { + errors.insert(.payloadNotAllowed) + } + } + + return errors + } +} + +struct UniversalWalletSigningPayload: Codable, Equatable { + let encoding: UniversalWalletSigningPayloadEncoding + let value: String + let display: UniversalWalletSigningDisplay + + func validationErrors() -> Set { + var errors = Set() + + switch encoding { + case .base64: + if !UniversalWalletSigningContractValidator.isBase64Payload( + value, + maxLength: UniversalWalletSigningContractValidator.maxMessageBase64Length + ) { + errors.insert(.invalidBase64) + } + case .hex: + if !UniversalWalletSigningContractValidator.isHexPayload( + value, + maxLength: UniversalWalletSigningContractValidator.maxMessageHexLength + ) { + errors.insert(.invalidHex) + } + case .utf8: + if !UniversalWalletSigningContractValidator.isHumanText( + value, + maxLength: UniversalWalletSigningContractValidator.maxMessageTextLength + ) { + errors.insert(.invalidMessage) + } + } + + return errors + } +} + +struct UniversalWalletSigningResult: Codable, Equatable { + let requestId: String + let accountId: String + let ecosystem: String + let chainId: String + let method: UniversalWalletSigningMethod + let status: UniversalWalletSigningResultStatus + let publicKey: String? + let signatureHex: String? + let signatureBase64: String? + let signatureBase58: String? + let signedTransactionBase64: String? + let signedTransactionsBase64: [String] + let transactionHash: String? + let errorCode: String? + let signedAtMillis: Int64 + + func validationErrors() -> Set { + var errors = Set() + UniversalWalletSigningContractValidator.validateEnvelope( + requestId: requestId, + accountId: accountId, + ecosystem: ecosystem, + chainId: chainId, + origin: "result", + createdAtMillis: signedAtMillis, + expiresAtMillis: nil, + errors: &errors + ) + + if status == .approved { + if let publicKey { + if !UniversalWalletSigningContractValidator.isMachineText(publicKey, maxLength: 256) { + errors.insert(.invalidPublicKey) + } + } else { + errors.insert(.publicKeyRequired) + } + validateApprovedPayload(errors: &errors) + if errorCode != nil { + errors.insert(.errorNotAllowed) + } + } else { + if errorCode.map({ UniversalWalletSigningContractValidator.matches($0, #"^[a-z][a-z0-9_:-]{1,63}$"#) }) != true { + errors.insert(.errorCodeRequired) + } + if publicKey != nil || signatureHex != nil || signatureBase64 != nil || signatureBase58 != nil || + signedTransactionBase64 != nil || !signedTransactionsBase64.isEmpty || transactionHash != nil { + errors.insert(.signatureNotAllowed) + } + } + + return errors + } + + private func validateApprovedPayload(errors: inout Set) { + let hasSignature = signatureHex != nil || signatureBase64 != nil || signatureBase58 != nil + + if let signatureHex, + !UniversalWalletSigningContractValidator.matches(signatureHex, #"^(?:[0-9a-f]{2}){32,130}$"#) { + errors.insert(.invalidSignature) + } + if let signatureBase64, + !UniversalWalletSigningContractValidator.isBase64Payload(signatureBase64, maxLength: 512) { + errors.insert(.invalidSignature) + } + if let signatureBase58, + !UniversalWalletSigningContractValidator.matches(signatureBase58, #"^[1-9A-HJ-NP-Za-km-z]{64,128}$"#) { + errors.insert(.invalidSignature) + } + + switch method { + case .signMessage: + if !hasSignature { + errors.insert(.signatureRequired) + } + if signedTransactionBase64 != nil || !signedTransactionsBase64.isEmpty || transactionHash != nil { + errors.insert(.payloadNotAllowed) + } + case .signTransaction: + if !UniversalWalletSigningContractValidator.isBase64Payload( + signedTransactionBase64, + maxLength: UniversalWalletSigningContractValidator.maxTransactionBase64Length + ) { + errors.insert(.signedTransactionRequired) + } + if !signedTransactionsBase64.isEmpty || transactionHash != nil { + errors.insert(.payloadNotAllowed) + } + case .signAndSendTransaction: + if !UniversalWalletSigningContractValidator.isBase64Payload( + signedTransactionBase64, + maxLength: UniversalWalletSigningContractValidator.maxTransactionBase64Length + ) { + errors.insert(.signedTransactionRequired) + } + if !UniversalWalletSigningContractValidator.isMachineText(transactionHash ?? "", maxLength: 256) { + errors.insert(.transactionHashRequired) + } + if !signedTransactionsBase64.isEmpty { + errors.insert(.payloadNotAllowed) + } + case .signAllTransactions: + if signedTransactionsBase64.isEmpty || + signedTransactionsBase64.count > UniversalWalletSigningContractValidator.maxTransactionBatch { + errors.insert(.signedTransactionRequired) + } + if signedTransactionsBase64.contains(where: { + !UniversalWalletSigningContractValidator.isBase64Payload( + $0, + maxLength: UniversalWalletSigningContractValidator.maxTransactionBase64Length + ) + }) { + errors.insert(.invalidBase64) + } + if signedTransactionBase64 != nil || transactionHash != nil { + errors.insert(.payloadNotAllowed) + } + } + } +} + +enum UniversalWalletSigningMethod: String, Codable { + case signMessage = "sign-message" + case signTransaction = "sign-transaction" + case signAndSendTransaction = "sign-and-send-transaction" + case signAllTransactions = "sign-all-transactions" +} + +enum UniversalWalletSigningPayloadEncoding: String, Codable { + case base64 + case hex + case utf8 +} + +enum UniversalWalletSigningDisplay: String, Codable { + case raw + case utf8 + case hex +} + +enum UniversalWalletSigningResultStatus: String, Codable { + case approved + case rejected + case failed +} + +enum UniversalWalletSigningValidationError: String, Error, CaseIterable { + case invalidRequestId + case invalidAccountId + case invalidEcosystem + case invalidChainId + case invalidOrigin + case invalidTimestamp + case messageRequired + case transactionRequired + case invalidBatch + case invalidBase64 + case invalidHex + case invalidMessage + case payloadNotAllowed + case publicKeyRequired + case invalidPublicKey + case signatureRequired + case invalidSignature + case signedTransactionRequired + case transactionHashRequired + case errorCodeRequired + case errorNotAllowed + case signatureNotAllowed +} + +enum UniversalWalletSigningContractValidator { + static let maxMessageTextLength = 64 * 1024 + static let maxMessageBase64Length = 88 * 1024 + static let maxMessageHexLength = 128 * 1024 + static let maxTransactionBase64Length = 352 * 1024 + static let maxTransactionBatch = 16 + + static func validateEnvelope( + requestId: String, + accountId: String, + ecosystem: String, + chainId: String, + origin: String, + createdAtMillis: Int64, + expiresAtMillis: Int64?, + errors: inout Set + ) { + if !matches(requestId, #"^sign_[A-Za-z0-9_-]{16,64}$"#) { + errors.insert(.invalidRequestId) + } + if !matches(accountId, #"^[a-z0-9][a-z0-9._:-]{1,63}$"#) { + errors.insert(.invalidAccountId) + } + if UniversalWalletEcosystem(rawValue: ecosystem) == nil { + errors.insert(.invalidEcosystem) + } + if !matches(chainId, #"^[A-Za-z0-9._:-]{2,128}$"#) { + errors.insert(.invalidChainId) + } + if !isMachineText(origin, maxLength: 512) { + errors.insert(.invalidOrigin) + } + if createdAtMillis <= 0 || expiresAtMillis.map({ $0 <= createdAtMillis }) == true { + errors.insert(.invalidTimestamp) + } + } + + static func isBase64Payload(_ value: String?, maxLength: Int) -> Bool { + guard let value else { + return false + } + + return !value.isEmpty && + value.count <= maxLength && + matches(value, #"^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$"#) + } + + static func isHexPayload(_ value: String, maxLength: Int) -> Bool { + !value.isEmpty && + value.count <= maxLength && + matches(value, #"^(?:[0-9a-f]{2})+$"#) + } + + static func isHumanText(_ value: String, maxLength: Int) -> Bool { + let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines) + return !normalized.isEmpty && + normalized.count <= maxLength && + normalized.rangeOfCharacter(from: .controlCharacters) == nil + } + + static func isMachineText(_ value: String, maxLength: Int) -> Bool { + value == value.trimmingCharacters(in: .whitespacesAndNewlines) && + !value.isEmpty && + value.count <= maxLength && + value.unicodeScalars.allSatisfy { $0.value > 0x20 && $0.value != 0x7F } + } + + static func matches(_ value: String, _ pattern: String) -> Bool { + value.range(of: pattern, options: .regularExpression) != nil + } +} diff --git a/fearless/Common/Network/Chainlink/ChainlinkOperationFactory.swift b/fearless/Common/Network/Chainlink/ChainlinkOperationFactory.swift index 49517c6d5e..048b88818b 100644 --- a/fearless/Common/Network/Chainlink/ChainlinkOperationFactory.swift +++ b/fearless/Common/Network/Chainlink/ChainlinkOperationFactory.swift @@ -48,7 +48,8 @@ final class ChainlinkOperationFactoryImpl: ChainlinkOperationFactory { ws.call(call: priceCall, block: .latest) { resp in switch resp.status { case let .success(result): - let decoded = ABIDecoder.decode(types: outputs, data: Data(hex: result.hex())) + let resultData = (try? Data(hexStringSSF: result.hex())) ?? Data() + let decoded = ABIDecoder.decode(types: outputs, data: resultData) guard let price = decoded?[safe: 1] as? BigInt, let precision = chainAsset.asset.priceProvider?.precision, diff --git a/fearless/Common/Protocols/AccountFetching.swift b/fearless/Common/Protocols/AccountFetching.swift index 48365b74e9..9258f04470 100644 --- a/fearless/Common/Protocols/AccountFetching.swift +++ b/fearless/Common/Protocols/AccountFetching.swift @@ -73,7 +73,16 @@ extension AccountFetching { return true } + guard !UniversalWalletChainAccountSupport.isUniversalWalletChain(chain.chainId) else { + closure(.failure(ChainAccountFetchingError.accountNotExists)) + return false + } + for chainAccount in meta.chainAccounts { + guard !UniversalWalletChainAccountSupport.isUniversalWalletChain(chainAccount.chainId) else { + continue + } + let chainFormat: ChainFormat = chainAccount.ethereumBased ? .ethereum : .substrate(chain.addressPrefix) if let chainAddress = try? chainAccount.accountId.toAddress(using: chainFormat), chainAddress == address { @@ -82,7 +91,7 @@ extension AccountFetching { accountId: chainAccount.accountId, publicKey: chainAccount.publicKey, name: meta.name, - cryptoType: CryptoType(rawValue: meta.substrateCryptoType) ?? .sr25519, + cryptoType: CryptoType(rawValue: chainAccount.cryptoType) ?? .sr25519, addressPrefix: chain.addressPrefix, isEthereumBased: chainAccount.ethereumBased, isChainAccount: true, @@ -155,15 +164,26 @@ extension AccountFetching { responses.append(nativeChainAccount) } + guard !UniversalWalletChainAccountSupport.isUniversalWalletChain(chain.chainId) else { + continue + } + for chainAccount in meta.chainAccounts { + guard + chainAccount.chainId != chain.chainId, + !UniversalWalletChainAccountSupport.isUniversalWalletChain(chainAccount.chainId) + else { + continue + } + responses.append(ChainAccountResponse( chainId: chain.chainId, accountId: chainAccount.accountId, publicKey: chainAccount.publicKey, name: meta.name, - cryptoType: CryptoType(rawValue: meta.substrateCryptoType) ?? .sr25519, + cryptoType: CryptoType(rawValue: chainAccount.cryptoType) ?? .sr25519, addressPrefix: chain.addressPrefix, - isEthereumBased: false, + isEthereumBased: chainAccount.ethereumBased, isChainAccount: true, walletId: meta.metaId )) @@ -203,7 +223,15 @@ extension AccountFetching { return } + guard !UniversalWalletChainAccountSupport.isUniversalWalletChain(chain.chainId) else { + continue + } + for chainAccount in meta.chainAccounts { + guard !UniversalWalletChainAccountSupport.isUniversalWalletChain(chainAccount.chainId) else { + continue + } + let chainFormat: ChainFormat = chainAccount.ethereumBased ? .ethereum : .substrate(chain.addressPrefix) if let chainAddress = try? chainAccount.accountId.toAddress(using: chainFormat), chainAddress == address { @@ -211,9 +239,10 @@ extension AccountFetching { return } } - closure(.failure(ChainAccountFetchingError.accountNotExists)) - return } + + closure(.failure(ChainAccountFetchingError.accountNotExists)) + return } else { closure(.failure(BaseOperationError.parentOperationCancelled)) return diff --git a/fearless/Common/PurchaseProvider/MoonPayKeys.swift b/fearless/Common/PurchaseProvider/MoonPayKeys.swift index a2a4ac93c2..44345b1fd2 100644 --- a/fearless/Common/PurchaseProvider/MoonPayKeys.swift +++ b/fearless/Common/PurchaseProvider/MoonPayKeys.swift @@ -5,6 +5,8 @@ enum MoonPayKeys { MoonPayKeys.variable(named: "MOONPAY_PRODUCTION_SECRET") ?? MoonPayCIKeys.secretKey static var testSecretKey: String = MoonPayKeys.variable(named: "MOONPAY_TEST_SECRET") ?? MoonPayCIKeys.testSecretKey + static var publicKey: String = + MoonPayKeys.variable(named: "MOONPAY_PUBLIC_KEY") ?? MoonPayCIKeys.publicKey static func variable(named name: String) -> String? { let processInfo = ProcessInfo.processInfo diff --git a/fearless/Common/Services/ChainRegistry/ConnectionPool/ConnectionFactory.swift b/fearless/Common/Services/ChainRegistry/ConnectionPool/ConnectionFactory.swift index 31c398573d..be08b8e252 100644 --- a/fearless/Common/Services/ChainRegistry/ConnectionPool/ConnectionFactory.swift +++ b/fearless/Common/Services/ChainRegistry/ConnectionPool/ConnectionFactory.swift @@ -51,7 +51,7 @@ private final class FailoverChainConnection: JSONRPCEngine, WebSocketEngineDeleg } self.urls = urls - self.externalDelegate = delegate + externalDelegate = delegate self.processingQueue = processingQueue self.logger = logger self.reconnectionStrategy = reconnectionStrategy diff --git a/fearless/Common/Services/RemoteSubscription/StakingAccountResolving/StakingAccountResolverV13.swift b/fearless/Common/Services/RemoteSubscription/StakingAccountResolving/StakingAccountResolverV13.swift index 1cb8f132db..4ecd330671 100644 --- a/fearless/Common/Services/RemoteSubscription/StakingAccountResolving/StakingAccountResolverV13.swift +++ b/fearless/Common/Services/RemoteSubscription/StakingAccountResolving/StakingAccountResolverV13.swift @@ -106,7 +106,9 @@ final class StakingAccountResolverV13: BaseStakingAccountResolver { ledgerDecoding?.addDependency(codingFactory) let mapOperation = ClosureOperation { - let controller = (try controllerDecoding?.extractNoCancellableResultData()).map { Data(hex: $0) } + let controller = (try controllerDecoding?.extractNoCancellableResultData()).map { + (try? Data(hexStringSSF: $0)) ?? Data() + } let ledger = try ledgerDecoding?.extractNoCancellableResultData() return DecodedChanges(controller: controller, ledger: ledger) diff --git a/fearless/Common/Services/ServiceCoordinator.swift b/fearless/Common/Services/ServiceCoordinator.swift index f53f996b9a..7a99aecd37 100644 --- a/fearless/Common/Services/ServiceCoordinator.swift +++ b/fearless/Common/Services/ServiceCoordinator.swift @@ -150,6 +150,8 @@ extension ServiceCoordinator { let accountInfoRemote = AccountInfoRemoteServiceDefault( ethereumRemoteBalanceFetching: ethereumRemoteBalanceFetching, tonRemoteBalanceFetching: tonRemoteBalanceFetching, + bitcoinBalanceSync: BitcoinBalanceSync(discovery: BitcoinReceiveDiscovery(client: BitcoinIndexerClient())), + solanaBalanceSync: SolanaBalanceSync(client: SolanaIndexerClient()), storagePerformer: storagePerformer ) diff --git a/fearless/Common/Substrate/Calls/BondCall.swift b/fearless/Common/Substrate/Calls/BondCall.swift index 66af949971..494b137250 100644 --- a/fearless/Common/Substrate/Calls/BondCall.swift +++ b/fearless/Common/Substrate/Calls/BondCall.swift @@ -1,4 +1,5 @@ import Foundation +import SSFModels import SSFUtils import BigInt @@ -59,7 +60,7 @@ extension RewardDestinationArg: Codable { case Self.accountField: do { let data = try container.decode(String.self) - self = .account(Data(hex: data)) + self = .account(try Data(hexStringSSF: data)) } catch { let data = try container.decode(Data.self) self = .account(data) diff --git a/fearless/Common/Substrate/Types/Nomination.swift b/fearless/Common/Substrate/Types/Nomination.swift index 30957b939d..3aa499fd07 100644 --- a/fearless/Common/Substrate/Types/Nomination.swift +++ b/fearless/Common/Substrate/Types/Nomination.swift @@ -1,4 +1,5 @@ import Foundation +import SSFModels import SSFUtils struct Nomination: Codable, Equatable { @@ -17,7 +18,7 @@ struct Nomination: Codable, Equatable { targets = try container.decode([Data].self, forKey: .targets) } catch { let targetsHex = try container.decode([String].self, forKey: .targets) - targets = targetsHex.compactMap { Data(hex: $0) } + targets = targetsHex.compactMap { try? Data(hexStringSSF: $0) } } submittedIn = try container.decode(StringScaleMapper.self, forKey: .submittedIn).value diff --git a/fearless/Common/Substrate/Types/StakingLedger.swift b/fearless/Common/Substrate/Types/StakingLedger.swift index 90c84b181d..6ad6172b11 100644 --- a/fearless/Common/Substrate/Types/StakingLedger.swift +++ b/fearless/Common/Substrate/Types/StakingLedger.swift @@ -1,4 +1,5 @@ import Foundation +import SSFModels import SSFUtils import BigInt @@ -25,7 +26,7 @@ struct StakingLedger: Decodable, Equatable { stash = try container.decode(Data.self, forKey: .stash) } catch { let stashString = try container.decode(String.self, forKey: .stash) - stash = Data(hex: stashString) + stash = try Data(hexStringSSF: stashString) } total = try container.decode(StringScaleMapper.self, forKey: .total).value active = try container.decode(StringScaleMapper.self, forKey: .active).value diff --git a/fearless/Common/Substrate/Types/SuperIdentity.swift b/fearless/Common/Substrate/Types/SuperIdentity.swift index c5601a48ef..9fba54259e 100644 --- a/fearless/Common/Substrate/Types/SuperIdentity.swift +++ b/fearless/Common/Substrate/Types/SuperIdentity.swift @@ -1,4 +1,5 @@ import Foundation +import SSFModels import SSFUtils struct SuperIdentity: Codable { @@ -20,7 +21,7 @@ struct SuperIdentity: Codable { parentAccountId = try container.decode(Data.self) } catch { let parentAccountIdString = try container.decode(String.self) - parentAccountId = Data(hex: parentAccountIdString) + parentAccountId = try Data(hexStringSSF: parentAccountIdString) } data = try container.decode(ChainData.self) } diff --git a/fearless/CoreLayer/OperationFactory/BlockExplorer/History/Main/BitcoinHistoryOperationFactory.swift b/fearless/CoreLayer/OperationFactory/BlockExplorer/History/Main/BitcoinHistoryOperationFactory.swift new file mode 100644 index 0000000000..9e12b6600d --- /dev/null +++ b/fearless/CoreLayer/OperationFactory/BlockExplorer/History/Main/BitcoinHistoryOperationFactory.swift @@ -0,0 +1,184 @@ +import BigInt +import Foundation +import RobinHood +import SSFModels +import SSFUtils + +final class BitcoinHistoryOperationFactory { + static let paginationLastSeenTxidKey = "lastSeenTxid" + + private static let supportedChainIds: Set = [ + UniversalWalletRegistry.bitcoinMainnet.chainId, + UniversalWalletRegistry.bitcoinMainnet.id, + UniversalWalletRegistry.bitcoinTestnet.chainId, + UniversalWalletRegistry.bitcoinTestnet.id + ] + + private let sync: BitcoinTransactionHistorySync + + init(client: BitcoinIndexerClientProtocol = BitcoinIndexerClient()) { + sync = BitcoinTransactionHistorySync(client: client) + } + + static func supports(chain: ChainModel) -> Bool { + supportedChainIds.contains(chain.chainId.lowercased()) + } + + private func network(for chain: ChainModel) -> BitcoinIndexerNetwork { + switch chain.chainId.lowercased() { + case UniversalWalletRegistry.bitcoinTestnet.chainId, UniversalWalletRegistry.bitcoinTestnet.id: + return .testnet + default: + return .mainnet + } + } + + private func historyBaseURL(for chain: ChainModel) -> String? { + chain.externalApi?.history?.url.absoluteString + } + + private func isSupportedBitcoinAsset(_ asset: AssetModel) -> Bool { + asset.id.uppercased() == UniversalWalletRegistry.bitcoinMainnet.nativeAsset.id && + asset.symbol.uppercased() == UniversalWalletRegistry.bitcoinMainnet.nativeAsset.symbol && + asset.precision == UInt16(UniversalWalletRegistry.bitcoinMainnet.nativeAsset.decimals) + } + + private func shouldFetch(filters: [WalletTransactionHistoryFilter]) -> Bool { + filters.isEmpty || filters.contains { $0.type == .transfer && $0.selected } + } + + private func createHistoryOperation( + asset: AssetModel, + chain: ChainModel, + address: String, + pagination: Pagination + ) -> BaseOperation { + AwaitOperation { [sync] in + do { + let page = try await self.fetchBitcoinHistoryPage( + sync: sync, + asset: asset, + chain: chain, + address: address, + pagination: pagination + ) + + return page + } catch { + return AssetTransactionPageData(transactions: []) + } + } + } + + private func fetchBitcoinHistoryPage( + sync: BitcoinTransactionHistorySync, + asset: AssetModel, + chain: ChainModel, + address: String, + pagination: Pagination + ) async throws -> AssetTransactionPageData { + let requestedCount = pagination.count + let page = try await sync.historyWindow( + address: address, + network: network(for: chain), + baseURL: historyBaseURL(for: chain), + lastSeenTxid: pagination.context?[Self.paginationLastSeenTxidKey] + ) + + let visibleEntries = Array(page.entries.prefix(requestedCount)) + let nextContext: PaginationContext? + if page.entries.count > visibleEntries.count, let lastVisibleTxid = visibleEntries.last?.txid { + nextContext = [Self.paginationLastSeenTxidKey: lastVisibleTxid] + } else { + nextContext = nil + } + + return AssetTransactionPageData( + transactions: visibleEntries.map { transactionData(from: $0, asset: asset) }, + context: nextContext + ) + } + + private func transactionData( + from entry: BitcoinTransactionHistoryEntry, + asset: AssetModel + ) -> AssetTransactionData { + let amount = decimalFromSats(entry.amountSats, precision: asset.precision) + let peerAddress = entry.outgoing ? entry.to : entry.from + let fee = feeData(from: entry, asset: asset) + let type: TransactionType = entry.outgoing ? .outgoing : .incoming + + return AssetTransactionData( + transactionId: entry.txid, + status: entry.confirmed ? .commited : .pending, + assetId: asset.id, + peerId: peerAddress, + peerFirstName: nil, + peerLastName: nil, + peerName: peerAddress, + details: "", + amount: AmountDecimal(value: amount), + fees: fee.map { [$0] } ?? [], + timestamp: entry.timestamp, + type: type.rawValue, + reason: "", + context: nil + ) + } + + private func feeData( + from entry: BitcoinTransactionHistoryEntry, + asset: AssetModel + ) -> AssetTransactionFee? { + guard entry.outgoing, entry.feeSats > 0 else { + return nil + } + + let amount = decimalFromSats(entry.feeSats, precision: asset.precision) + return AssetTransactionFee( + identifier: asset.id, + assetId: asset.id, + amount: AmountDecimal(value: amount), + context: nil + ) + } + + private func decimalFromSats(_ sats: Int64, precision: UInt16) -> Decimal { + guard sats > 0, precision <= UInt16(Int16.max), let value = BigUInt(String(sats)) else { + return .zero + } + + return Decimal.fromSubstrateAmount(value, precision: Int16(precision)) ?? .zero + } + + private func emptyPage() -> CompoundOperationWrapper { + CompoundOperationWrapper.createWithResult(AssetTransactionPageData(transactions: [])) + } +} + +extension BitcoinHistoryOperationFactory: HistoryOperationFactoryProtocol { + func fetchTransactionHistoryOperation( + asset: AssetModel, + chain: ChainModel, + address: String, + filters: [WalletTransactionHistoryFilter], + pagination: Pagination + ) -> CompoundOperationWrapper { + guard + pagination.count >= 1, + isSupportedBitcoinAsset(asset), + shouldFetch(filters: filters) + else { + return emptyPage() + } + + let historyOperation = createHistoryOperation( + asset: asset, + chain: chain, + address: address, + pagination: pagination + ) + + return CompoundOperationWrapper(targetOperation: historyOperation) + } +} diff --git a/fearless/CoreLayer/OperationFactory/BlockExplorer/History/Main/HistoryOperationFactory.swift b/fearless/CoreLayer/OperationFactory/BlockExplorer/History/Main/HistoryOperationFactory.swift index 68c6f5c609..afb5c1e421 100644 --- a/fearless/CoreLayer/OperationFactory/BlockExplorer/History/Main/HistoryOperationFactory.swift +++ b/fearless/CoreLayer/OperationFactory/BlockExplorer/History/Main/HistoryOperationFactory.swift @@ -11,6 +11,18 @@ final class HistoryOperationFactoriesAssembly { ) -> HistoryOperationFactoryProtocol? { let historyUrl = chain.externalApi?.history?.url.absoluteString.lowercased() ?? "" + if BitcoinHistoryOperationFactory.supports(chain: chain) { + return BitcoinHistoryOperationFactory() + } + + if SolanaHistoryOperationFactory.supports(chain: chain) { + return SolanaHistoryOperationFactory() + } + + if IrohaHistoryOperationFactory.supports(chain: chain) { + return IrohaHistoryOperationFactory() + } + if historyUrl.contains("blockscout") { return BlockscoutHistoryOperationFactory() } diff --git a/fearless/CoreLayer/OperationFactory/BlockExplorer/History/Main/IrohaHistoryOperationFactory.swift b/fearless/CoreLayer/OperationFactory/BlockExplorer/History/Main/IrohaHistoryOperationFactory.swift new file mode 100644 index 0000000000..c09daa832a --- /dev/null +++ b/fearless/CoreLayer/OperationFactory/BlockExplorer/History/Main/IrohaHistoryOperationFactory.swift @@ -0,0 +1,398 @@ +import BigInt +import Foundation +import RobinHood +import SSFModels +import SSFUtils + +final class IrohaHistoryOperationFactory { + static let paginationPageKey = "page" + + private let client: IrohaToriiClientProtocol + + init(client: IrohaToriiClientProtocol = IrohaToriiClient()) { + self.client = client + } + + static func supports(chain: ChainModel) -> Bool { + network(for: chain) != nil + } + + private static func network(for chain: ChainModel) -> UniversalWalletRegistry.IrohaNetwork? { + switch chain.chainId.lowercased() { + case UniversalWalletRegistry.taira.chainId, UniversalWalletRegistry.taira.id: + return UniversalWalletRegistry.taira + case UniversalWalletRegistry.nexus.chainId, UniversalWalletRegistry.nexus.id: + return UniversalWalletRegistry.nexus + default: + return nil + } + } + + private func shouldFetch(filters: [WalletTransactionHistoryFilter]) -> Bool { + filters.isEmpty || filters.contains { $0.type == .transfer && $0.selected } + } + + private func historyBaseURL(for chain: ChainModel) -> String? { + chain.externalApi?.history?.url.absoluteString + } + + private func normalizedAddress( + _ address: String, + network: UniversalWalletRegistry.IrohaNetwork + ) -> String? { + try? IrohaAddressCodec.parse(address, expectedDiscriminant: network.chainDiscriminant).i105 + } + + private func createHistoryOperation( + asset: AssetModel, + chain: ChainModel, + network: UniversalWalletRegistry.IrohaNetwork, + address: String, + pagination: Pagination + ) -> BaseOperation { + AwaitOperation { [client] in + do { + return try await self.fetchIrohaHistoryPage( + client: client, + asset: asset, + chain: chain, + network: network, + address: address, + pagination: pagination + ) + } catch { + return AssetTransactionPageData(transactions: []) + } + } + } + + private func fetchIrohaHistoryPage( + client: IrohaToriiClientProtocol, + asset: AssetModel, + chain: ChainModel, + network: UniversalWalletRegistry.IrohaNetwork, + address: String, + pagination: Pagination + ) async throws -> AssetTransactionPageData { + let page = pagination.context?[Self.paginationPageKey] + .flatMap { Int($0) } + .flatMap { $0 >= 0 ? $0 : nil } ?? 0 + let limit = min(pagination.count, IrohaToriiRoutes.maxLimit) + let request = try IrohaToriiRoutes.mcpJSONRPCRequest( + method: "tools/call", + id: "history-\(page)", + params: [ + "name": .string("iroha.instructions.list"), + "arguments": .object([ + "account": .string(address), + "asset_id": .string(asset.id), + "kind": .string("Transfer"), + "page": .int(Int64(page)), + "per_page": .int(Int64(limit)), + "transaction_status": .string("committed"), + "accept": .string("application/json") + ]) + ] + ) + let response = try await client.mcpJSONRPC( + request, + network: network, + baseURL: historyBaseURL(for: chain) + ) + + guard response.error == nil else { + return AssetTransactionPageData(transactions: []) + } + + let items = response.result.instructionItems() + let transactions = items.enumerated().flatMap { index, item in + item.transactionData(index: index, accountAddress: address, asset: asset) + } + let nextContext = items.count >= limit ? [Self.paginationPageKey: String(page + 1)] : nil + + return AssetTransactionPageData(transactions: transactions, context: nextContext) + } + + private func emptyPage() -> CompoundOperationWrapper { + CompoundOperationWrapper.createWithResult(AssetTransactionPageData(transactions: [])) + } +} + +extension IrohaHistoryOperationFactory: HistoryOperationFactoryProtocol { + func fetchTransactionHistoryOperation( + asset: AssetModel, + chain: ChainModel, + address: String, + filters: [WalletTransactionHistoryFilter], + pagination: Pagination + ) -> CompoundOperationWrapper { + guard + let network = Self.network(for: chain), + pagination.count >= 1, + shouldFetch(filters: filters), + let normalizedAddress = normalizedAddress(address, network: network) + else { + return emptyPage() + } + + let historyOperation = createHistoryOperation( + asset: asset, + chain: chain, + network: network, + address: normalizedAddress, + pagination: pagination + ) + + return CompoundOperationWrapper(targetOperation: historyOperation) + } +} + +private extension IrohaJSONValue? { + func instructionItems() -> [[String: IrohaJSONValue]] { + guard let result = self?.objectValue else { + return [] + } + + let body = result["body"]?.objectValue ?? result + return body["items"]?.arrayValue?.compactMap(\.objectValue) ?? [] + } +} + +private extension [String: IrohaJSONValue] { + func transactionData( + index: Int, + accountAddress: String, + asset: AssetModel + ) -> [AssetTransactionData] { + guard + let hash = stringValue("transaction_hash", "transactionHash", "hash"), + let timestamp = timestampSeconds(stringValue("created_at", "createdAt", "timestamp")), + let payload = self["box"]?.objectValue?["json"]?.objectValue?["payload"]?.objectValue + else { + return [] + } + + let status: AssetTransactionStatus = stringValue("transaction_status", "transactionStatus", "status") + .caseInsensitiveEquals("Rejected") ? .rejected : .commited + let transfers = payload.transferPayloads(accountAddress: accountAddress, assetId: asset.id) + + return transfers.enumerated().compactMap { transferIndex, transfer in + guard + asset.precision <= UInt16(Int16.max), + let amount = Decimal.fromSubstrateAmount(transfer.amount, precision: Int16(asset.precision)) + else { + return nil + } + + let isOutgoing = transfer.from.caseInsensitiveEquals(accountAddress) + let peerAddress = isOutgoing ? transfer.to : transfer.from + + return AssetTransactionData( + transactionId: transfers.count == 1 ? hash : "\(hash):\(index + transferIndex)", + status: status, + assetId: asset.id, + peerId: peerAddress, + peerFirstName: nil, + peerLastName: nil, + peerName: peerAddress.isEmpty ? nil : peerAddress, + details: "", + amount: AmountDecimal(value: amount), + fees: [], + timestamp: timestamp, + type: (isOutgoing ? TransactionType.outgoing : .incoming).rawValue, + reason: "", + context: nil + ) + } + } + + func transferPayloads(accountAddress: String, assetId: String) -> [IrohaTransferPayload] { + guard let variant = stringValue("variant") else { + return [] + } + + switch variant { + case "Asset": + return self["value"]?.objectValue?.assetTransfer(accountAddress: accountAddress, assetId: assetId).map { [$0] } ?? [] + case "AssetBatch": + let value = self["value"]?.objectValue + let entries = value?.firstArray("entries", "transfers", "items") ?? self["value"]?.arrayValue + return entries?.compactMap { + $0.objectValue?.assetTransfer(accountAddress: accountAddress, assetId: assetId) + } ?? [] + default: + return [] + } + } + + func assetTransfer(accountAddress: String, assetId: String) -> IrohaTransferPayload? { + let source = stringValue("source", "source_id", "asset", "asset_id") + guard + let destination = stringValue("destination", "destination_id", "to", "account_id"), + let amount = normalizeIrohaAmount(self["object"] ?? self["amount"] ?? self["quantity"] ?? self["value"]) + else { + return nil + } + + if let source, !source.irohaAssetMatches(assetId) { + return nil + } + + let sourceAccount = stringValue("source_account", "from", "account") ?? source?.extractIrohaAssetAccount() + let incoming = destination.caseInsensitiveEquals(accountAddress) + let outgoing = sourceAccount?.caseInsensitiveEquals(accountAddress) == true || source?.contains(accountAddress) == true + + guard incoming || outgoing else { + return nil + } + + return IrohaTransferPayload( + amount: amount, + from: outgoing ? accountAddress : sourceAccount ?? "", + to: incoming ? accountAddress : destination + ) + } + + func stringValue(_ keys: String...) -> String? { + for key in keys { + if let value = self[key]?.trimmedString { + return value + } + } + + return nil + } + + func firstArray(_ keys: String...) -> [IrohaJSONValue]? { + for key in keys { + if let value = self[key]?.arrayValue { + return value + } + } + + return nil + } +} + +private extension IrohaJSONValue { + var objectValue: [String: IrohaJSONValue]? { + if case let .object(value) = self { + return value + } + + return nil + } + + var arrayValue: [IrohaJSONValue]? { + if case let .array(value) = self { + return value + } + + return nil + } + + var trimmedString: String? { + guard case let .string(value) = self else { + return nil + } + + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } +} + +private func normalizeIrohaAmount(_ value: IrohaJSONValue?) -> BigUInt? { + switch value { + case let .string(raw): + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard unsignedIntegerRegex.firstMatch( + in: trimmed, + range: NSRange(trimmed.startIndex..., in: trimmed) + ) != nil else { + return nil + } + + return BigUInt(trimmed) + case let .int(value): + return value > 0 ? BigUInt(UInt64(value)) : nil + case let .double(value): + guard value > 0, value <= Double(Int64.max), value.rounded(.towardZero) == value else { + return nil + } + + return BigUInt(String(Int64(value))) + case let .object(value): + if let scale = value["scale"], !scale.isZeroScale { + return nil + } + + return normalizeIrohaAmount(value["value"] ?? value["amount"] ?? value["mantissa"]) + default: + return nil + } +} + +private extension IrohaJSONValue { + var isZeroScale: Bool { + switch self { + case let .string(value): + return value.trimmingCharacters(in: .whitespacesAndNewlines) == "0" + case let .int(value): + return value == 0 + case let .double(value): + return value == 0 + default: + return false + } + } +} + +private func timestampSeconds(_ value: String?) -> Int64? { + guard let value else { + return nil + } + + if value.allSatisfy(\.isNumber) { + guard let raw = Int64(value) else { + return nil + } + + return raw < millisThreshold ? raw : raw / 1000 + } + + return iso8601DateFormatter.date(from: value).map { Int64($0.timeIntervalSince1970) } +} + +private extension String? { + func caseInsensitiveEquals(_ value: String) -> Bool { + self?.caseInsensitiveCompare(value) == .orderedSame + } +} + +private extension String { + func caseInsensitiveEquals(_ value: String) -> Bool { + caseInsensitiveCompare(value) == .orderedSame + } + + func extractIrohaAssetAccount() -> String? { + guard let index = lastIndex(of: "#"), index < self.index(before: endIndex) else { + return nil + } + + return String(self[self.index(after: index)...]) + } + + func irohaAssetMatches(_ assetId: String) -> Bool { + self == assetId || hasPrefix("\(assetId)#") + } +} + +private struct IrohaTransferPayload { + let amount: BigUInt + let from: String + let to: String +} + +private let millisThreshold: Int64 = 10_000_000_000 +private let unsignedIntegerRegex = try! NSRegularExpression(pattern: "^[1-9][0-9]*$") +private let iso8601DateFormatter = ISO8601DateFormatter() diff --git a/fearless/CoreLayer/OperationFactory/BlockExplorer/History/Main/SolanaHistoryOperationFactory.swift b/fearless/CoreLayer/OperationFactory/BlockExplorer/History/Main/SolanaHistoryOperationFactory.swift new file mode 100644 index 0000000000..b690a2f463 --- /dev/null +++ b/fearless/CoreLayer/OperationFactory/BlockExplorer/History/Main/SolanaHistoryOperationFactory.swift @@ -0,0 +1,253 @@ +import BigInt +import Foundation +import RobinHood +import SSFModels +import SSFUtils + +final class SolanaHistoryOperationFactory { + static let paginationCursorKey = "before" + + private let sync: SolanaTransactionHistorySync + + init(client: SolanaIndexerClientProtocol = SolanaIndexerClient()) { + sync = SolanaTransactionHistorySync(client: client) + } + + static func supports(chain: ChainModel) -> Bool { + network(for: chain) != nil + } + + private static func network(for chain: ChainModel) -> UniversalWalletRegistry.SolanaNetwork? { + switch chain.chainId.lowercased() { + case UniversalWalletRegistry.solanaMainnet.chainId, UniversalWalletRegistry.solanaMainnet.id: + return UniversalWalletRegistry.solanaMainnet + case UniversalWalletRegistry.solanaDevnet.chainId, UniversalWalletRegistry.solanaDevnet.id: + return UniversalWalletRegistry.solanaDevnet + default: + return nil + } + } + + private func historyBaseURL(for chain: ChainModel) -> String? { + chain.externalApi?.history?.url.absoluteString + } + + private func solanaHistoryAsset( + for asset: AssetModel, + network: UniversalWalletRegistry.SolanaNetwork + ) -> SolanaHistoryAsset? { + if asset.id.uppercased() == network.nativeAsset.id, + asset.symbol.uppercased() == network.nativeAsset.symbol, + asset.precision == UInt16(network.nativeAsset.decimals), + asset.isNative { + return SolanaHistoryAsset(assetId: network.nativeAsset.id, isNative: true) + } + + guard !asset.isNative, !asset.id.isEmpty, asset.id != network.nativeAsset.id else { + return nil + } + + return SolanaHistoryAsset(assetId: asset.id, isNative: false) + } + + private func shouldFetch(filters: [WalletTransactionHistoryFilter]) -> Bool { + filters.isEmpty || filters.contains { $0.type == .transfer && $0.selected } + } + + private func createHistoryOperation( + asset: AssetModel, + chain: ChainModel, + network: UniversalWalletRegistry.SolanaNetwork, + historyAsset: SolanaHistoryAsset, + address: String, + pagination: Pagination + ) -> BaseOperation { + AwaitOperation { [sync] in + do { + return try await self.fetchSolanaHistoryPage( + sync: sync, + asset: asset, + chain: chain, + network: network, + historyAsset: historyAsset, + address: address, + pagination: pagination + ) + } catch { + return AssetTransactionPageData(transactions: []) + } + } + } + + private func fetchSolanaHistoryPage( + sync: SolanaTransactionHistorySync, + asset: AssetModel, + chain: ChainModel, + network: UniversalWalletRegistry.SolanaNetwork, + historyAsset: SolanaHistoryAsset, + address: String, + pagination: Pagination + ) async throws -> AssetTransactionPageData { + let limit = min(pagination.count, SolanaIndexerRoutes.maxLimit) + let page = try await sync.history( + wallet: address, + assetId: historyAsset.assetId, + isNative: historyAsset.isNative, + network: network, + baseURL: historyBaseURL(for: chain), + before: pagination.context?[Self.paginationCursorKey], + limit: limit + ) + + let transactions = page.transactions + .filter { $0.operationType == .transfer } + .compactMap { + transactionData( + from: $0, + asset: asset, + network: network, + historyAsset: historyAsset + ) + } + + let nextContext = page.pageInfo.nextCursor.map { [Self.paginationCursorKey: $0] } + return AssetTransactionPageData(transactions: transactions, context: nextContext) + } + + private func transactionData( + from entry: UniversalWalletIndexedTransaction, + asset: AssetModel, + network: UniversalWalletRegistry.SolanaNetwork, + historyAsset: SolanaHistoryAsset + ) -> AssetTransactionData? { + guard + entry.assetId == historyAsset.assetId, + let amount = decimalFromRawAmount(entry.amount, precision: asset.precision), + let timestampMillis = entry.timestampMillis + else { + return nil + } + + let type: TransactionType + switch entry.direction { + case .incoming: + type = .incoming + case .outgoing: + type = .outgoing + case .self, .unknown: + return nil + } + + let status: AssetTransactionStatus + switch entry.status { + case .confirmed: + status = .commited + case .pending: + status = .pending + case .failed: + status = .rejected + } + + let peerAddress = entry.counterpartyAddress ?? "" + + return AssetTransactionData( + transactionId: entry.transactionId, + status: status, + assetId: asset.id, + peerId: peerAddress, + peerFirstName: nil, + peerLastName: nil, + peerName: peerAddress.isEmpty ? nil : peerAddress, + details: "", + amount: AmountDecimal(value: amount), + fees: feeData( + from: entry, + network: network, + asset: asset, + includeNativeFee: historyAsset.isNative + ).map { [$0] } ?? [], + timestamp: timestampMillis / 1000, + type: type.rawValue, + reason: "", + context: entry.cursor.map { [Self.paginationCursorKey: $0] } + ) + } + + private func feeData( + from entry: UniversalWalletIndexedTransaction, + network: UniversalWalletRegistry.SolanaNetwork, + asset: AssetModel, + includeNativeFee: Bool + ) -> AssetTransactionFee? { + guard + includeNativeFee, + entry.direction == .outgoing, + entry.feeAssetId == network.nativeAsset.id, + let amount = decimalFromRawAmount(entry.feeAmount, precision: asset.precision), + !amount.isZero + else { + return nil + } + + return AssetTransactionFee( + identifier: asset.id, + assetId: asset.id, + amount: AmountDecimal(value: amount), + context: nil + ) + } + + private func decimalFromRawAmount(_ value: String?, precision: UInt16) -> Decimal? { + guard + let value, + precision <= UInt16(Int16.max), + let amount = BigUInt(value) + else { + return nil + } + + return Decimal.fromSubstrateAmount(amount, precision: Int16(precision)) + } + + private func emptyPage() -> CompoundOperationWrapper { + CompoundOperationWrapper.createWithResult(AssetTransactionPageData(transactions: [])) + } +} + +extension SolanaHistoryOperationFactory: HistoryOperationFactoryProtocol { + func fetchTransactionHistoryOperation( + asset: AssetModel, + chain: ChainModel, + address: String, + filters: [WalletTransactionHistoryFilter], + pagination: Pagination + ) -> CompoundOperationWrapper { + guard + let network = Self.network(for: chain), + pagination.count >= 1, + shouldFetch(filters: filters) + else { + return emptyPage() + } + + guard let historyAsset = solanaHistoryAsset(for: asset, network: network) else { + return emptyPage() + } + + let historyOperation = createHistoryOperation( + asset: asset, + chain: chain, + network: network, + historyAsset: historyAsset, + address: address, + pagination: pagination + ) + + return CompoundOperationWrapper(targetOperation: historyOperation) + } +} + +private struct SolanaHistoryAsset { + let assetId: String + let isNative: Bool +} diff --git a/fearless/Modules/AccountCreate/AccountCreateInteractor.swift b/fearless/Modules/AccountCreate/AccountCreateInteractor.swift index 2ef50bd182..9536721e16 100644 --- a/fearless/Modules/AccountCreate/AccountCreateInteractor.swift +++ b/fearless/Modules/AccountCreate/AccountCreateInteractor.swift @@ -17,7 +17,7 @@ final class AccountCreateInteractor { extension AccountCreateInteractor: AccountCreateInteractorInputProtocol { func setup() { do { - let mnemonic = try mnemonicCreator.randomMnemonic(.entropy128) + let mnemonic = try mnemonicCreator.randomMnemonic(.entropy256) presenter.didReceive(mnemonic: mnemonic.allWords()) } catch { diff --git a/fearless/Modules/AssetManagement/AssetManagementAssembly.swift b/fearless/Modules/AssetManagement/AssetManagementAssembly.swift index 4c4e394f13..41a00796ce 100644 --- a/fearless/Modules/AssetManagement/AssetManagementAssembly.swift +++ b/fearless/Modules/AssetManagement/AssetManagementAssembly.swift @@ -77,6 +77,8 @@ final class AssetManagementAssembly { let accountInfoRemote = AccountInfoRemoteServiceDefault( ethereumRemoteBalanceFetching: ethereumRemoteBalanceFetching, tonRemoteBalanceFetching: tonRemoteBalanceFetching, + bitcoinBalanceSync: BitcoinBalanceSync(discovery: BitcoinReceiveDiscovery(client: BitcoinIndexerClient())), + solanaBalanceSync: SolanaBalanceSync(client: SolanaIndexerClient()), storagePerformer: storagePerformer ) diff --git a/fearless/Modules/LiquidityPools/LiquidityPoolDetails/LiquidityPoolDetailsInteractor.swift b/fearless/Modules/LiquidityPools/LiquidityPoolDetails/LiquidityPoolDetailsInteractor.swift index 3882ac7f89..6ad684b8ae 100644 --- a/fearless/Modules/LiquidityPools/LiquidityPoolDetails/LiquidityPoolDetailsInteractor.swift +++ b/fearless/Modules/LiquidityPools/LiquidityPoolDetails/LiquidityPoolDetailsInteractor.swift @@ -120,7 +120,7 @@ extension LiquidityPoolDetailsInteractor: LiquidityPoolDetailsInteractorInput { func fetchApy(reservesId: String) { Task { do { - let address = try AddressFactory.address(for: Data(hex: reservesId), chain: chain) + let address = try AddressFactory.address(for: Data(hexStringSSF: reservesId), chain: chain) let apyStream = try await liquidityPoolService.subscribePoolsAPY(poolIds: [address]) for await apy in apyStream { diff --git a/fearless/Modules/LiquidityPools/LiquidityPoolRemoveLiquidity/LiquidityPoolRemoveLiquidityInteractor.swift b/fearless/Modules/LiquidityPools/LiquidityPoolRemoveLiquidity/LiquidityPoolRemoveLiquidityInteractor.swift index eaf7688db0..491ad2393e 100644 --- a/fearless/Modules/LiquidityPools/LiquidityPoolRemoveLiquidity/LiquidityPoolRemoveLiquidityInteractor.swift +++ b/fearless/Modules/LiquidityPools/LiquidityPoolRemoveLiquidity/LiquidityPoolRemoveLiquidityInteractor.swift @@ -99,7 +99,10 @@ final class LiquidityPoolRemoveLiquidityInteractor { return } - let reservesId = Data(hex: reservesIdString) + guard let reservesId = try? Data(hexStringSSF: reservesIdString) else { + output?.didReceiveTotalIssuanceError(error: CommonError.internal) + return + } Task { do { diff --git a/fearless/Modules/LiquidityPools/LiquidityPoolSupply/LiquidityPoolSupplyInteractor.swift b/fearless/Modules/LiquidityPools/LiquidityPoolSupply/LiquidityPoolSupplyInteractor.swift index ca949bc53e..2e26a14a03 100644 --- a/fearless/Modules/LiquidityPools/LiquidityPoolSupply/LiquidityPoolSupplyInteractor.swift +++ b/fearless/Modules/LiquidityPools/LiquidityPoolSupply/LiquidityPoolSupplyInteractor.swift @@ -97,7 +97,7 @@ extension LiquidityPoolSupplyInteractor: LiquidityPoolSupplyInteractorInput { Task { do { - let address = try AddressFactory.address(for: Data(hex: reservesId), chain: chain) + let address = try AddressFactory.address(for: Data(hexStringSSF: reservesId), chain: chain) let apyStream = try await lpDataService.subscribePoolsAPY(poolIds: [address]) for await apy in apyStream { diff --git a/fearless/Modules/LiquidityPools/LiquidityPoolSupplyConfirm/LiquidityPoolSupplyConfirmInteractor.swift b/fearless/Modules/LiquidityPools/LiquidityPoolSupplyConfirm/LiquidityPoolSupplyConfirmInteractor.swift index 7aaac0e3a4..fd78ced548 100644 --- a/fearless/Modules/LiquidityPools/LiquidityPoolSupplyConfirm/LiquidityPoolSupplyConfirmInteractor.swift +++ b/fearless/Modules/LiquidityPools/LiquidityPoolSupplyConfirm/LiquidityPoolSupplyConfirmInteractor.swift @@ -90,7 +90,7 @@ extension LiquidityPoolSupplyConfirmInteractor: LiquidityPoolSupplyConfirmIntera Task { do { - let address = try AddressFactory.address(for: Data(hex: reservesId), chain: chain) + let address = try AddressFactory.address(for: Data(hexStringSSF: reservesId), chain: chain) let apyStream = try await lpDataService.subscribePoolsAPY(poolIds: [address]) for await apy in apyStream { diff --git a/fearless/Modules/LiquidityPools/LiquidityPoolsList/Flows/AvailablePools/AvailableLiquidityPoolsListInteractor.swift b/fearless/Modules/LiquidityPools/LiquidityPoolsList/Flows/AvailablePools/AvailableLiquidityPoolsListInteractor.swift index 100ce39150..df02f6cb9c 100644 --- a/fearless/Modules/LiquidityPools/LiquidityPoolsList/Flows/AvailablePools/AvailableLiquidityPoolsListInteractor.swift +++ b/fearless/Modules/LiquidityPools/LiquidityPoolsList/Flows/AvailablePools/AvailableLiquidityPoolsListInteractor.swift @@ -99,7 +99,8 @@ extension AvailableLiquidityPoolsListInteractor: AvailableLiquidityPoolsListInte guard let reservesId = pool.reservesId, - let address = try? AddressFactory.address(for: Data(hex: reservesId), chain: chain) + let reservesData = try? Data(hexStringSSF: reservesId), + let address = try? AddressFactory.address(for: reservesData, chain: chain) else { return nil } diff --git a/fearless/Modules/LiquidityPools/LiquidityPoolsList/Flows/AvailablePools/AvailableLiquidityPoolsListPresenter.swift b/fearless/Modules/LiquidityPools/LiquidityPoolsList/Flows/AvailablePools/AvailableLiquidityPoolsListPresenter.swift index e738f10700..015f30f51b 100644 --- a/fearless/Modules/LiquidityPools/LiquidityPoolsList/Flows/AvailablePools/AvailableLiquidityPoolsListPresenter.swift +++ b/fearless/Modules/LiquidityPools/LiquidityPoolsList/Flows/AvailablePools/AvailableLiquidityPoolsListPresenter.swift @@ -84,7 +84,9 @@ extension AvailableLiquidityPoolsListPresenter: LiquidityPoolsListViewOutput { } let reserves = reserves?.value?.first(where: { $0.poolId == liquidityPair.pairId }) - let reservesAddress = liquidityPair.reservesId.map { try? AddressFactory.address(for: Data(hex: $0), chain: chain) } + let reservesAddress = liquidityPair.reservesId + .flatMap { try? Data(hexStringSSF: $0) } + .flatMap { try? AddressFactory.address(for: $0, chain: chain) } let apyInfo = apy?.first(where: { $0.poolId == reservesAddress }) let assetIdPair = AssetIdPair(baseAssetIdCode: liquidityPair.baseAssetId, targetAssetIdCode: liquidityPair.targetAssetId) diff --git a/fearless/Modules/LiquidityPools/LiquidityPoolsList/Flows/AvailablePools/AvailableLiquidityPoolsListViewModelFactory.swift b/fearless/Modules/LiquidityPools/LiquidityPoolsList/Flows/AvailablePools/AvailableLiquidityPoolsListViewModelFactory.swift index 7b3764774c..3c901dd3b5 100644 --- a/fearless/Modules/LiquidityPools/LiquidityPoolsList/Flows/AvailablePools/AvailableLiquidityPoolsListViewModelFactory.swift +++ b/fearless/Modules/LiquidityPools/LiquidityPoolsList/Flows/AvailablePools/AvailableLiquidityPoolsListViewModelFactory.swift @@ -95,7 +95,9 @@ final class AvailableLiquidityPoolsListViewModelFactoryDefault: AvailableLiquidi let iconsViewModel = TokenPairsIconViewModel(firstTokenIconViewModel: baseAssetIconViewModel, secondTokenIconViewModel: targetAssetIconViewModel) let tokenPairName = "\(baseAsset.symbol.uppercased())-\(targetAsset.symbol.uppercased())" - let reservesAddress = pair.reservesId.map { try? AddressFactory.address(for: Data(hex: $0), chain: chain) } + let reservesAddress = pair.reservesId + .flatMap { try? Data(hexStringSSF: $0) } + .flatMap { try? AddressFactory.address(for: $0, chain: chain) } let rewardTokenNameLabelText = R.string.localizable.lpRewardTokenText(rewardAsset.symbol.uppercased(), preferredLanguages: locale.rLanguages) let apyValue = apyInfos?.first(where: { $0.poolId == reservesAddress })?.apy let apyLabelText = apyValue.flatMap { NumberFormatter.percentAPY.stringFromDecimal($0) } diff --git a/fearless/Modules/LiquidityPools/LiquidityPoolsList/Flows/UserPools/UserLiquidityPoolsListInteractor.swift b/fearless/Modules/LiquidityPools/LiquidityPoolsList/Flows/UserPools/UserLiquidityPoolsListInteractor.swift index ebaf4e15cd..3c25166063 100644 --- a/fearless/Modules/LiquidityPools/LiquidityPoolsList/Flows/UserPools/UserLiquidityPoolsListInteractor.swift +++ b/fearless/Modules/LiquidityPools/LiquidityPoolsList/Flows/UserPools/UserLiquidityPoolsListInteractor.swift @@ -76,7 +76,8 @@ extension UserLiquidityPoolsListInteractor: UserLiquidityPoolsListInteractorInpu let poolIds: [String] = pools.compactMap { guard let reservesId = $0.reservesId, - let address = try? AddressFactory.address(for: Data(hex: reservesId), chain: chain) + let reservesData = try? Data(hexStringSSF: reservesId), + let address = try? AddressFactory.address(for: reservesData, chain: chain) else { return nil } diff --git a/fearless/Modules/LiquidityPools/LiquidityPoolsList/Flows/UserPools/UserLiquidityPoolsListPresenter.swift b/fearless/Modules/LiquidityPools/LiquidityPoolsList/Flows/UserPools/UserLiquidityPoolsListPresenter.swift index 829656feab..de97b05432 100644 --- a/fearless/Modules/LiquidityPools/LiquidityPoolsList/Flows/UserPools/UserLiquidityPoolsListPresenter.swift +++ b/fearless/Modules/LiquidityPools/LiquidityPoolsList/Flows/UserPools/UserLiquidityPoolsListPresenter.swift @@ -89,7 +89,9 @@ extension UserLiquidityPoolsListPresenter: LiquidityPoolsListViewOutput { } let reserves = reserves?.value?.first(where: { $0.poolId == liquidityPair.pairId }) - let reservesAddress = liquidityPair.reservesId.map { try? AddressFactory.address(for: Data(hex: $0), chain: chain) } + let reservesAddress = liquidityPair.reservesId + .flatMap { try? Data(hexStringSSF: $0) } + .flatMap { try? AddressFactory.address(for: $0, chain: chain) } let apyInfo = apy?.first(where: { $0.poolId == reservesAddress }) let accountPool = accountPools?.first(where: { $0.poolId == liquidityPair.pairId }) let assetIdPair = AssetIdPair(baseAssetIdCode: liquidityPair.baseAssetId, targetAssetIdCode: liquidityPair.targetAssetId) diff --git a/fearless/Modules/LiquidityPools/LiquidityPoolsList/Flows/UserPools/UserLiquidityPoolsListViewModelFactory.swift b/fearless/Modules/LiquidityPools/LiquidityPoolsList/Flows/UserPools/UserLiquidityPoolsListViewModelFactory.swift index e024dc2425..80ff9b5ea2 100644 --- a/fearless/Modules/LiquidityPools/LiquidityPoolsList/Flows/UserPools/UserLiquidityPoolsListViewModelFactory.swift +++ b/fearless/Modules/LiquidityPools/LiquidityPoolsList/Flows/UserPools/UserLiquidityPoolsListViewModelFactory.swift @@ -55,7 +55,9 @@ final class UserLiquidityPoolsListViewModelFactoryDefault: UserLiquidityPoolsLis let iconsViewModel = TokenPairsIconViewModel(firstTokenIconViewModel: baseAssetIconViewModel, secondTokenIconViewModel: targetAssetIconViewModel) let tokenPairName = "\(baseAsset.symbol.uppercased())-\(targetAsset.symbol.uppercased())" - let reservesAddress = pair.reservesId.map { try? AddressFactory.address(for: Data(hex: $0), chain: chain) } + let reservesAddress = pair.reservesId + .flatMap { try? Data(hexStringSSF: $0) } + .flatMap { try? AddressFactory.address(for: $0, chain: chain) } let rewardTokenNameLabelText = R.string.localizable.lpRewardTokenText(rewardAsset.symbol.uppercased(), preferredLanguages: locale.rLanguages) let apyInfo = apyInfos?.first(where: { $0.poolId == reservesAddress }) let apyValue = apyInfo?.apy diff --git a/fearless/Modules/NewWallet/ChainAssetList/ChainAssetListAssembly.swift b/fearless/Modules/NewWallet/ChainAssetList/ChainAssetListAssembly.swift index 30ecc1c2e1..208a5bb1cb 100644 --- a/fearless/Modules/NewWallet/ChainAssetList/ChainAssetListAssembly.swift +++ b/fearless/Modules/NewWallet/ChainAssetList/ChainAssetListAssembly.swift @@ -88,6 +88,8 @@ final class ChainAssetListAssembly { let accountInfoRemoteService = AccountInfoRemoteServiceDefault( ethereumRemoteBalanceFetching: ethereumRemoteBalanceFetching, tonRemoteBalanceFetching: tonRemoteBalanceFetching, + bitcoinBalanceSync: BitcoinBalanceSync(discovery: BitcoinReceiveDiscovery(client: BitcoinIndexerClient())), + solanaBalanceSync: SolanaBalanceSync(client: SolanaIndexerClient()), storagePerformer: storagePerformer ) diff --git a/fearless/Modules/NewWallet/WalletTransactionHistory/WalletTransactionHistoryDependencyContainer.swift b/fearless/Modules/NewWallet/WalletTransactionHistory/WalletTransactionHistoryDependencyContainer.swift index 718ba322d0..d1009af8c2 100644 --- a/fearless/Modules/NewWallet/WalletTransactionHistory/WalletTransactionHistoryDependencyContainer.swift +++ b/fearless/Modules/NewWallet/WalletTransactionHistory/WalletTransactionHistoryDependencyContainer.swift @@ -43,7 +43,7 @@ final class WalletTransactionHistoryDependencyContainer { $0.items as? [WalletTransactionHistoryFilter] }.reduce([], +) - if let address = selectedAccount.fetch(for: chainAsset.chain.accountRequest())?.toAddress() { + if let address = UniversalWalletAccountAddressResolver.address(for: chainAsset.chain, wallet: selectedAccount) { dataProvider = try? dataProviderFactory.createDataProvider( for: address, asset: chainAsset.asset, diff --git a/fearless/Modules/ReceiveAndRequestAsset/ReceiveAndRequestAssetPresenter.swift b/fearless/Modules/ReceiveAndRequestAsset/ReceiveAndRequestAssetPresenter.swift index 9e207befa1..1209da27ac 100644 --- a/fearless/Modules/ReceiveAndRequestAsset/ReceiveAndRequestAssetPresenter.swift +++ b/fearless/Modules/ReceiveAndRequestAsset/ReceiveAndRequestAssetPresenter.swift @@ -36,7 +36,7 @@ final class ReceiveAndRequestAssetPresenter { private var accountInfos: [ChainAssetKey: AccountInfo?] = [:] private var address: String? { - wallet.fetch(for: chainAsset.chain.accountRequest())?.toAddress() + UniversalWalletAccountAddressResolver.address(for: chainAsset.chain, wallet: wallet) } // MARK: - Constructors @@ -135,11 +135,14 @@ final class ReceiveAndRequestAssetPresenter { qrOperation = Task { do { - guard let account = wallet.fetch(for: chainAsset.chain.accountRequest()), let address = account.toAddress() else { + guard let address = address else { throw ChainAccountFetchingError.accountNotExists } var qrType: QRType = .address(address) if chainAsset.chain.isSora { + guard let account = wallet.fetch(for: chainAsset.chain.accountRequest()) else { + throw ChainAccountFetchingError.accountNotExists + } let balance = getBalance() var inputAmount = inputResult?.absoluteValue(from: balance).stringWithPointSeparator if @@ -238,7 +241,7 @@ extension ReceiveAndRequestAssetPresenter: ReceiveAndRequestAssetViewOutput { } func presentAccountOptions() { - guard let address = wallet.fetch(for: chainAsset.chain.accountRequest())?.toAddress(), let view = view else { + guard let address = address, let view = view else { return } diff --git a/fearless/Modules/Send/SendDependencyContainer.swift b/fearless/Modules/Send/SendDependencyContainer.swift index c4457db7f9..cc123077cb 100644 --- a/fearless/Modules/Send/SendDependencyContainer.swift +++ b/fearless/Modules/Send/SendDependencyContainer.swift @@ -26,6 +26,10 @@ struct SendDependencies { let storageRequestPerformer: StorageRequestPerformer? } +enum UniversalWalletSendRoutingError: Error, Equatable { + case unsupported(chainId: String) +} + final class SendDepencyContainer { private let wallet: MetaAccountModel private let operationManager: OperationManagerProtocol @@ -111,6 +115,22 @@ final class SendDepencyContainer { throw ChainAccountFetchingError.accountNotExists } + if isUniversalWalletBitcoin(chainAsset.chain) { + return BitcoinTransferService(wallet: wallet, chain: chainAsset.chain) + } + + if isUniversalWalletSolana(chainAsset.chain) { + return try SolanaTransferService(wallet: wallet, chain: chainAsset.chain) + } + + if isUniversalWalletIroha(chainAsset.chain) { + return IrohaTransferService(wallet: wallet, chain: chainAsset.chain) + } + + if chainAsset.chain.isTonCompatibilityChain { + throw ConvenienceError(error: "TON transfer not yet supported.") + } + if chainAsset.chain.chainBaseType == .substrate { guard let nativeRuntimeService = (ChainRegistryFacade.sharedRegistry as ChainRegistryProtocol).getRuntimeProvider(for: chainAsset.chain.chainId) else { throw ChainRegistryError.runtimeMetadaUnavailable @@ -160,6 +180,42 @@ final class SendDepencyContainer { throw ConvenienceError(error: "TON transfer not yet supported.") } + private func isUniversalWalletBitcoin(_ chain: ChainModel) -> Bool { + switch chain.chainId.lowercased() { + case UniversalWalletRegistry.bitcoinMainnet.chainId, + UniversalWalletRegistry.bitcoinMainnet.id, + UniversalWalletRegistry.bitcoinTestnet.chainId, + UniversalWalletRegistry.bitcoinTestnet.id: + return true + default: + return false + } + } + + private func isUniversalWalletIroha(_ chain: ChainModel) -> Bool { + switch chain.chainId.lowercased() { + case UniversalWalletRegistry.taira.chainId, + UniversalWalletRegistry.taira.id, + UniversalWalletRegistry.nexus.chainId, + UniversalWalletRegistry.nexus.id: + return true + default: + return false + } + } + + private func isUniversalWalletSolana(_ chain: ChainModel) -> Bool { + switch chain.chainId.lowercased() { + case UniversalWalletRegistry.solanaMainnet.chainId, + UniversalWalletRegistry.solanaMainnet.id, + UniversalWalletRegistry.solanaDevnet.chainId, + UniversalWalletRegistry.solanaDevnet.id: + return true + default: + return false + } + } + private func createEqTotalBalanceService(chainAsset: ChainAsset) -> EquilibriumTotalBalanceServiceProtocol? { guard chainAsset.chain.isEquilibrium else { return nil diff --git a/fearless/Modules/Send/SendInteractor.swift b/fearless/Modules/Send/SendInteractor.swift index d2068d4d74..1be4fba10c 100644 --- a/fearless/Modules/Send/SendInteractor.swift +++ b/fearless/Modules/Send/SendInteractor.swift @@ -144,7 +144,14 @@ extension SendInteractor: SendInteractorInput { } func estimateFee(for amount: BigUInt, tip: BigUInt?, for address: String?, chainAsset: ChainAsset) { - guard let dependencies = dependencies, let senderAddress = dependencies.wallet.fetch(for: chainAsset.chain.accountRequest())?.toAddress() else { + guard let dependencies = dependencies else { + return + } + + let senderAddress = UniversalWalletAccountAddressResolver.address(for: chainAsset.chain, wallet: dependencies.wallet) ?? + dependencies.wallet.fetch(for: chainAsset.chain.accountRequest())?.toAddress() + + guard let senderAddress else { return } diff --git a/fearless/WalletConnect.entitlements b/fearless/WalletConnect.entitlements index 2966da27df..c464aa54f5 100644 --- a/fearless/WalletConnect.entitlements +++ b/fearless/WalletConnect.entitlements @@ -2,6 +2,19 @@ + com.apple.developer.associated-domains + + webcredentials:fearlesswallet.io + + com.apple.developer.icloud-container-identifiers + + iCloud.jp.co.soramitsu.fearless + iCloud.jp.co.soramitsu.fearlesswallet.dev + + com.apple.developer.icloud-services + + CloudKit + com.apple.security.application-groups group.com.walletconnect.sdk diff --git a/fearlessIntegrationTests/ChainRegistry+Setup.swift b/fearlessIntegrationTests/ChainRegistry+Setup.swift index 0c47e77071..81331d0375 100644 --- a/fearlessIntegrationTests/ChainRegistry+Setup.swift +++ b/fearlessIntegrationTests/ChainRegistry+Setup.swift @@ -3,13 +3,19 @@ import Foundation import XCTest extension ChainRegistryFacade { + static func skipUnlessLiveIntegrationEnabled() throws { + guard ProcessInfo.processInfo.environment["FEARLESS_RUN_LIVE_INTEGRATION_TESTS"] == "1" else { + throw XCTSkip("Set FEARLESS_RUN_LIVE_INTEGRATION_TESTS=1 to run live chain-registry integration tests") + } + } + static func setupForIntegrationTest( with storageFacade: StorageFacadeProtocol ) -> ChainRegistryProtocol { _ = storageFacade let chainRegistry = sharedRegistry - let timeout = Date().addingTimeInterval(30) + let timeout = Date().addingTimeInterval(integrationSetupTimeout) if chainRegistry.availableChains.isEmpty { chainRegistry.performColdBoot() @@ -21,4 +27,15 @@ extension ChainRegistryFacade { return chainRegistry } + + private static var integrationSetupTimeout: TimeInterval { + guard + let rawValue = ProcessInfo.processInfo.environment["FEARLESS_INTEGRATION_SETUP_TIMEOUT_SECONDS"], + let value = TimeInterval(rawValue) + else { + return 30 + } + + return min(max(value, 0), 300) + } } diff --git a/fearlessIntegrationTests/EraCountdownOperationFactoryTests.swift b/fearlessIntegrationTests/EraCountdownOperationFactoryTests.swift index 09bac6b767..9f752e70d9 100644 --- a/fearlessIntegrationTests/EraCountdownOperationFactoryTests.swift +++ b/fearlessIntegrationTests/EraCountdownOperationFactoryTests.swift @@ -7,6 +7,8 @@ import SSFModels class EraCountdownOperationFactoryTests: XCTestCase { func testService() throws { + try ChainRegistryFacade.skipUnlessLiveIntegrationEnabled() + let operationManager: OperationManagerProtocol = OperationManager() let chainId = Chain.kusama.genesisHash diff --git a/fearlessIntegrationTests/MortalEraFactoryTests.swift b/fearlessIntegrationTests/MortalEraFactoryTests.swift index d8900b53a9..df7956b43a 100644 --- a/fearlessIntegrationTests/MortalEraFactoryTests.swift +++ b/fearlessIntegrationTests/MortalEraFactoryTests.swift @@ -19,6 +19,8 @@ class MortalEraFactoryTests: XCTestCase { func performMortalEraCalculation(chainId: ChainModel.Id) throws { + try ChainRegistryFacade.skipUnlessLiveIntegrationEnabled() + // given let logger = Logger.shared diff --git a/fearlessIntegrationTests/StakingInfoTests.swift b/fearlessIntegrationTests/StakingInfoTests.swift index d04d5e0df4..39fc5dd402 100644 --- a/fearlessIntegrationTests/StakingInfoTests.swift +++ b/fearlessIntegrationTests/StakingInfoTests.swift @@ -41,6 +41,8 @@ class StakingInfoTests: XCTestCase { address: String, expectedPrefix: UInt16 ) throws { + try ChainRegistryFacade.skipUnlessLiveIntegrationEnabled() + let logger = Logger.shared let storageFacade = SubstrateStorageTestFacade() let chainRegistry = ChainRegistryFacade.setupForIntegrationTest(with: storageFacade) @@ -72,19 +74,19 @@ class StakingInfoTests: XCTestCase { let validatorService = try stakingServiceFactory.createEraValidatorService( for: chainAsset.chain ) - + let operationManager: OperationManagerProtocol = OperationManager() let storageRequestFactory = StorageRequestFactory( remoteFactory: StorageKeyFactory(), operationManager: operationManager ) - + guard chainRegistry.getRuntimeProvider(for: chainAsset.chain.chainId) != nil, chainRegistry.getConnection(for: chainAsset.chain.chainId) != nil else { throw ChainRegistryError.connectionUnavailable } - + let identityOperationFactory = IdentityOperationFactory(requestFactory: storageRequestFactory) let rewardOperationFactory = SubqueryRewardOperationFactory(url: chainAsset.chain.externalApi?.staking?.url) let collatorOperationFactory = ParachainCollatorOperationFactory( diff --git a/fearlessTests/ApplicationLayer/Services/FeatureToggle/TonChainSelectionTests.swift b/fearlessTests/ApplicationLayer/Services/FeatureToggle/TonChainSelectionTests.swift index 51372aa3e4..22d4f6e48b 100644 --- a/fearlessTests/ApplicationLayer/Services/FeatureToggle/TonChainSelectionTests.swift +++ b/fearlessTests/ApplicationLayer/Services/FeatureToggle/TonChainSelectionTests.swift @@ -291,6 +291,370 @@ final class AccountInfoRemoteServiceTests: XCTestCase { XCTAssertEqual(requestTypeName, String(describing: EquilibriumAccountInfotorageRequest.self)) } + func testFetchAccountInfosMapsBitcoinBalanceWithoutSubstrateStorage() async throws { + let chain = makeBitcoinChain(historyBaseURL: "https://blockstream.info/api/") + let wallet = try walletWithBitcoinAccount(chainId: chain.chainId) + let bitcoinSync = BitcoinBalanceSyncStub(result: bitcoinBalanceResult(totalSats: 125_000)) + let mnemonicProvider = BitcoinMnemonicProviderStub(mnemonic: Self.mnemonic) + let storagePerformer = StorageRequestPerformerStub() + let service = makeAccountInfoRemoteService( + bitcoinBalanceSync: bitcoinSync, + bitcoinMnemonicProvider: mnemonicProvider, + storagePerformer: storagePerformer + ) + + let result = try await service.fetchAccountInfos(for: chain, wallet: wallet) + + XCTAssertEqual(bitcoinSync.invocations.count, 1) + XCTAssertEqual(bitcoinSync.invocations.first?.mnemonic, Self.mnemonic) + XCTAssertEqual(bitcoinSync.invocations.first?.network, .mainnet) + XCTAssertEqual(bitcoinSync.invocations.first?.baseURL, "https://blockstream.info/api/") + XCTAssertEqual(bitcoinSync.invocations.first?.gapLimit, UniversalWalletRegistry.bitcoinMainnet.defaultGapLimit) + XCTAssertEqual(storagePerformer.performMixInvocations, 0) + + let nativeInfo = try XCTUnwrap(result[Self.btcAsset.chainAssetId(chainId: chain.chainId)] ?? nil) + XCTAssertEqual(nativeInfo.data.free, BigUInt(125_000)) + } + + func testFetchAccountInfoReturnsSingleBitcoinBalance() async throws { + let chain = makeBitcoinChain() + let chainAsset = ChainAsset(chain: chain, asset: Self.btcAsset) + let wallet = try walletWithBitcoinAccount(chainId: chain.chainId) + let bitcoinSync = BitcoinBalanceSyncStub(result: bitcoinBalanceResult(totalSats: 50_001)) + let service = makeAccountInfoRemoteService( + bitcoinBalanceSync: bitcoinSync, + bitcoinMnemonicProvider: BitcoinMnemonicProviderStub(mnemonic: Self.mnemonic) + ) + + let result = try await service.fetchAccountInfo(for: chainAsset, wallet: wallet) + + XCTAssertEqual(bitcoinSync.invocations.count, 1) + XCTAssertEqual(result?.data.free, BigUInt(50_001)) + } + + func testFetchAccountInfosUsesBitcoinTestnetNetwork() async throws { + let chain = makeBitcoinChain(chainId: UniversalWalletRegistry.bitcoinTestnet.chainId) + let wallet = try walletWithBitcoinAccount(chainId: chain.chainId, network: .testnet) + let bitcoinSync = BitcoinBalanceSyncStub(result: bitcoinBalanceResult(totalSats: 7)) + let service = makeAccountInfoRemoteService( + bitcoinBalanceSync: bitcoinSync, + bitcoinMnemonicProvider: BitcoinMnemonicProviderStub(mnemonic: Self.mnemonic) + ) + + _ = try await service.fetchAccountInfos(for: chain, wallet: wallet) + + XCTAssertEqual(bitcoinSync.invocations.first?.network, .testnet) + XCTAssertEqual(bitcoinSync.invocations.first?.gapLimit, UniversalWalletRegistry.bitcoinTestnet.defaultGapLimit) + } + + func testFetchAccountInfosFailsClosedForBitcoinWithoutMnemonicOrSubstrateStorage() async throws { + let chain = makeBitcoinChain() + let wallet = try walletWithBitcoinAccount(chainId: chain.chainId) + let bitcoinSync = BitcoinBalanceSyncStub(result: bitcoinBalanceResult(totalSats: 1)) + let storagePerformer = StorageRequestPerformerStub() + let service = makeAccountInfoRemoteService( + bitcoinBalanceSync: bitcoinSync, + bitcoinMnemonicProvider: BitcoinMnemonicProviderStub(mnemonic: nil), + storagePerformer: storagePerformer + ) + + let result = try await service.fetchAccountInfos(for: chain, wallet: wallet) + + XCTAssertTrue(result.values.allSatisfy { $0 == nil }) + XCTAssertEqual(bitcoinSync.invocations.count, 0) + XCTAssertEqual(storagePerformer.performMixInvocations, 0) + } + + func testFetchAccountInfosFailsClosedWhenBitcoinIndexerFails() async throws { + let chain = makeBitcoinChain() + let wallet = try walletWithBitcoinAccount(chainId: chain.chainId) + let bitcoinSync = BitcoinBalanceSyncStub(error: TestError.indexerUnavailable) + let storagePerformer = StorageRequestPerformerStub() + let service = makeAccountInfoRemoteService( + bitcoinBalanceSync: bitcoinSync, + bitcoinMnemonicProvider: BitcoinMnemonicProviderStub(mnemonic: Self.mnemonic), + storagePerformer: storagePerformer + ) + + let result = try await service.fetchAccountInfos(for: chain, wallet: wallet) + + XCTAssertTrue(result.values.allSatisfy { $0 == nil }) + XCTAssertEqual(bitcoinSync.invocations.count, 1) + XCTAssertEqual(storagePerformer.performMixInvocations, 0) + } + + func testFetchAccountInfosRejectsMalformedBitcoinBalanceAndAssetMismatch() async throws { + let chain = makeBitcoinChain(assets: [Self.btcPrecisionMismatchAsset, Self.usdcAsset]) + let wallet = try walletWithBitcoinAccount(chainId: chain.chainId) + let bitcoinSync = BitcoinBalanceSyncStub(result: bitcoinBalanceResult(totalSats: -1)) + let service = makeAccountInfoRemoteService( + bitcoinBalanceSync: bitcoinSync, + bitcoinMnemonicProvider: BitcoinMnemonicProviderStub(mnemonic: Self.mnemonic) + ) + + let result = try await service.fetchAccountInfos(for: chain, wallet: wallet) + + XCTAssertTrue(result.values.allSatisfy { $0 == nil }) + XCTAssertEqual(bitcoinSync.invocations.count, 1) + } + + func testFetchAccountInfosMapsSolanaNativeAndTokenBalancesWithoutSubstrateStorage() async throws { + let chain = makeSolanaChain(historyBaseURL: "https://si.soramitsu.io/") + let wallet = try walletWithSolanaAccount(chainId: chain.chainId) + let solanaSync = SolanaBalanceSyncStub(result: solanaBalanceResult()) + let storagePerformer = StorageRequestPerformerStub() + let service = makeAccountInfoRemoteService( + solanaBalanceSync: solanaSync, + storagePerformer: storagePerformer + ) + + let result = try await service.fetchAccountInfos(for: chain, wallet: wallet) + + XCTAssertEqual(solanaSync.invocations.count, 1) + XCTAssertEqual(solanaSync.invocations.first?.network, UniversalWalletRegistry.solanaMainnet) + XCTAssertEqual(solanaSync.invocations.first?.baseURL, "https://si.soramitsu.io/") + XCTAssertEqual(solanaSync.invocations.first?.includeTokenMetadata, false) + XCTAssertEqual(storagePerformer.performMixInvocations, 0) + + let nativeInfo = try XCTUnwrap(result[Self.solAsset.chainAssetId(chainId: chain.chainId)] ?? nil) + let tokenInfo = try XCTUnwrap(result[Self.usdcAsset.chainAssetId(chainId: chain.chainId)] ?? nil) + XCTAssertEqual(nativeInfo.data.free, BigUInt(1_234_567_890)) + XCTAssertEqual(tokenInfo.data.free, BigUInt(42_000_000)) + } + + func testFetchAccountInfoReturnsSingleSolanaTokenBalance() async throws { + let chain = makeSolanaChain() + let tokenChainAsset = ChainAsset(chain: chain, asset: Self.usdcAsset) + let wallet = try walletWithSolanaAccount(chainId: chain.chainId) + let solanaSync = SolanaBalanceSyncStub(result: solanaBalanceResult()) + let storagePerformer = StorageRequestPerformerStub() + let service = makeAccountInfoRemoteService( + solanaBalanceSync: solanaSync, + storagePerformer: storagePerformer + ) + + let result = try await service.fetchAccountInfo(for: tokenChainAsset, wallet: wallet) + + XCTAssertEqual(solanaSync.invocations.count, 1) + XCTAssertEqual(result?.data.free, BigUInt(42_000_000)) + XCTAssertEqual(storagePerformer.performMixInvocations, 0) + } + + func testFetchAccountInfosFailsClosedForSolanaDevnetWithoutIndexerOrSubstrateStorage() async throws { + let chain = makeSolanaChain(chainId: UniversalWalletRegistry.solanaDevnet.chainId) + let wallet = try walletWithSolanaAccount(chainId: chain.chainId) + let solanaSync = SolanaBalanceSyncStub(result: solanaBalanceResult()) + let storagePerformer = StorageRequestPerformerStub() + let service = makeAccountInfoRemoteService( + solanaBalanceSync: solanaSync, + storagePerformer: storagePerformer + ) + + let result = try await service.fetchAccountInfos(for: chain, wallet: wallet) + + XCTAssertTrue(result.values.allSatisfy { $0 == nil }) + XCTAssertEqual(solanaSync.invocations.count, 0) + XCTAssertEqual(storagePerformer.performMixInvocations, 0) + } + + func testFetchAccountInfosFailsClosedForSolanaWithoutMatchingChainAccount() async throws { + let chain = makeSolanaChain() + let solanaSync = SolanaBalanceSyncStub(result: solanaBalanceResult()) + let storagePerformer = StorageRequestPerformerStub() + let service = makeAccountInfoRemoteService( + solanaBalanceSync: solanaSync, + storagePerformer: storagePerformer + ) + + let result = try await service.fetchAccountInfos(for: chain, wallet: AccountGenerator.generateMetaAccount()) + + XCTAssertTrue(result.values.allSatisfy { $0 == nil }) + XCTAssertEqual(solanaSync.invocations.count, 0) + XCTAssertEqual(storagePerformer.performMixInvocations, 0) + } + + func testFetchAccountInfosFailsClosedWhenSolanaIndexerFails() async throws { + let chain = makeSolanaChain() + let wallet = try walletWithSolanaAccount(chainId: chain.chainId) + let solanaSync = SolanaBalanceSyncStub(error: TestError.indexerUnavailable) + let storagePerformer = StorageRequestPerformerStub() + let service = makeAccountInfoRemoteService( + solanaBalanceSync: solanaSync, + storagePerformer: storagePerformer + ) + + let result = try await service.fetchAccountInfos(for: chain, wallet: wallet) + + XCTAssertTrue(result.values.allSatisfy { $0 == nil }) + XCTAssertEqual(solanaSync.invocations.count, 1) + XCTAssertEqual(storagePerformer.performMixInvocations, 0) + } + + func testFetchAccountInfosRejectsMalformedSolanaAmountsAndPrecisionMismatches() async throws { + let chain = makeSolanaChain() + let wallet = try walletWithSolanaAccount(chainId: chain.chainId) + let result = solanaBalanceResult( + nativeAmount: "-1", + tokenAmount: "42000000", + tokenDecimals: 5 + ) + let solanaSync = SolanaBalanceSyncStub(result: result) + let storagePerformer = StorageRequestPerformerStub() + let service = makeAccountInfoRemoteService( + solanaBalanceSync: solanaSync, + storagePerformer: storagePerformer + ) + + let accountInfos = try await service.fetchAccountInfos(for: chain, wallet: wallet) + + XCTAssertTrue(accountInfos.values.allSatisfy { $0 == nil }) + XCTAssertEqual(solanaSync.invocations.count, 1) + XCTAssertEqual(storagePerformer.performMixInvocations, 0) + } + + func testFetchAccountInfosMapsIrohaTairaBalancesThroughTorii() async throws { + let chain = makeIrohaChain( + chainId: UniversalWalletRegistry.taira.chainId, + historyBaseURL: "https://taira.sora.org/", + assets: [Self.irohaToriiAsset] + ) + let wallet = try walletWithIrohaAccount(chainId: chain.chainId) + let address = try IrohaKeyDerivation.deriveAddress( + mnemonic: Self.mnemonic, + chainDiscriminant: UniversalWalletRegistry.taira.chainDiscriminant + ).i105 + let client = IrohaToriiClientStub( + accountAssetsResponse: IrohaAccountAssetListResponse( + items: [ + IrohaAccountAssetListItem( + accountID: address, + asset: Self.irohaToriiAsset.id, + assetID: nil, + assetName: nil, + assetAlias: nil, + quantity: "1.5", + scope: "global" + ), + IrohaAccountAssetListItem( + accountID: address, + asset: Self.irohaToriiAsset.id, + assetID: nil, + assetName: nil, + assetAlias: nil, + quantity: "0.25", + scope: "rewards" + ) + ], + hasMore: false, + countMode: IrohaToriiCountMode.bounded.rawValue, + total: 2 + ) + ) + let storagePerformer = StorageRequestPerformerStub() + let service = makeAccountInfoRemoteService( + irohaToriiClient: client, + storagePerformer: storagePerformer + ) + + let result = try await service.fetchAccountInfos(for: chain, wallet: wallet) + + XCTAssertEqual(client.accountAssetsInvocations.count, 1) + XCTAssertEqual(client.accountAssetsInvocations.first?.accountID, address) + XCTAssertNil(client.accountAssetsInvocations.first?.baseURL) + XCTAssertEqual(client.accountAssetsInvocations.first?.limit, IrohaToriiRoutes.maxLimit) + XCTAssertEqual(client.accountAssetsInvocations.first?.countMode, .bounded) + XCTAssertEqual(client.accountAssetsInvocations.first?.network, UniversalWalletRegistry.taira) + XCTAssertEqual(storagePerformer.performMixInvocations, 0) + + let balance = try XCTUnwrap(result[Self.irohaToriiAsset.chainAssetId(chainId: chain.chainId)] ?? nil) + let expectedBalance = try XCTUnwrap(BigUInt("1750000000000000000")) + XCTAssertEqual(balance.data.free, expectedBalance) + } + + func testFetchAccountInfoReturnsSingleIrohaBalance() async throws { + let chain = makeIrohaChain( + chainId: UniversalWalletRegistry.taira.chainId, + assets: [Self.irohaToriiAsset] + ) + let chainAsset = ChainAsset(chain: chain, asset: Self.irohaToriiAsset) + let wallet = try walletWithIrohaAccount(chainId: chain.chainId) + let address = try IrohaKeyDerivation.deriveAddress( + mnemonic: Self.mnemonic, + chainDiscriminant: UniversalWalletRegistry.taira.chainDiscriminant + ).i105 + let client = IrohaToriiClientStub( + accountAssetsResponse: IrohaAccountAssetListResponse( + items: [ + IrohaAccountAssetListItem( + accountID: address, + asset: Self.irohaToriiAsset.id, + assetID: nil, + assetName: nil, + assetAlias: nil, + quantity: "2", + scope: "global" + ) + ], + hasMore: false, + countMode: IrohaToriiCountMode.bounded.rawValue, + total: 1 + ) + ) + let service = makeAccountInfoRemoteService(irohaToriiClient: client) + + let result = try await service.fetchAccountInfo(for: chainAsset, wallet: wallet) + + XCTAssertEqual(client.accountAssetsInvocations.count, 1) + let expectedBalance = try XCTUnwrap(BigUInt("2000000000000000000")) + XCTAssertEqual(result?.data.free, expectedBalance) + } + + func testFetchAccountInfosFailsClosedForIrohaChainsWithoutSubstrateStorage() async throws { + let chainIds = [ + UniversalWalletRegistry.taira.chainId, + UniversalWalletRegistry.taira.id, + UniversalWalletRegistry.nexus.chainId, + UniversalWalletRegistry.nexus.id + ] + + for chainId in chainIds { + let chain = makeIrohaChain(chainId: chainId) + let ethereumFetching = AccountInfoFetchingStub() + let tonService = AccountInfoRemoteServiceStub() + let storagePerformer = StorageRequestPerformerStub() + let service = AccountInfoRemoteServiceDefault( + ethereumRemoteBalanceFetching: ethereumFetching, + tonRemoteBalanceFetching: tonService, + storagePerformer: storagePerformer + ) + + let result = try await service.fetchAccountInfos( + for: chain, + wallet: AccountGenerator.generateMetaAccount() + ) + + XCTAssertTrue(result.values.allSatisfy { $0 == nil }, "Unexpected balance for \(chainId)") + XCTAssertEqual(ethereumFetching.fetchManyInvocations, 0) + XCTAssertEqual(tonService.fetchInfosInvocations, 0) + XCTAssertEqual(storagePerformer.performMixInvocations, 0) + } + } + + func testFetchAccountInfoFailsClosedForIrohaChainAssetWithoutSubstrateStorage() async throws { + let chain = makeIrohaChain(chainId: UniversalWalletRegistry.taira.chainId) + let chainAsset = ChainAsset(chain: chain, asset: Self.irohaAsset) + let storagePerformer = StorageRequestPerformerStub() + let service = makeAccountInfoRemoteService(storagePerformer: storagePerformer) + + let result = try await service.fetchAccountInfo( + for: chainAsset, + wallet: AccountGenerator.generateMetaAccount() + ) + + XCTAssertNil(result) + XCTAssertEqual(storagePerformer.performMixInvocations, 0) + } + private func makeTonLikeChain() -> ChainModel { let node = ChainNodeModel( url: URL(string: "wss://rpc.ton.org")!, @@ -385,6 +749,863 @@ final class AccountInfoRemoteServiceTests: XCTestCase { identityChain: nil ) } + + private func makeIrohaChain( + chainId: String, + historyBaseURL: String? = nil, + assets: [AssetModel] = [AccountInfoRemoteServiceTests.irohaAsset] + ) -> ChainModel { + let externalApi = historyBaseURL.map { + ChainModel.ExternalApiSet( + staking: nil, + history: ChainModel.BlockExplorer( + type: "iroha", + url: URL(string: $0)! + ), + crowdloans: nil, + explorers: nil + ) + } + + return ChainModel( + rank: nil, + disabled: false, + chainId: chainId, + parentId: nil, + paraId: nil, + name: chainId.contains("nexus") || chainId.contains("sora:nexus") ? "SORA Nexus" : "Taira Testnet", + assets: Set(assets), + xcm: nil, + nodes: [ + ChainNodeModel( + url: URL(string: "https://taira.sora.org")!, + name: "Taira", + apikey: nil + ) + ], + addressPrefix: 0, + types: nil, + icon: nil, + options: nil, + externalApi: externalApi, + selectedNode: nil, + customNodes: nil, + iosMinAppVersion: nil, + identityChain: nil + ) + } + + private func makeAccountInfoRemoteService( + bitcoinBalanceSync: BitcoinBalanceSyncing? = nil, + bitcoinMnemonicProvider: BitcoinMnemonicProviding = BitcoinMnemonicProviderStub(mnemonic: nil), + solanaBalanceSync: SolanaBalanceSyncing? = nil, + irohaToriiClient: IrohaToriiClientProtocol? = nil, + storagePerformer: StorageRequestPerformerStub = StorageRequestPerformerStub() + ) -> AccountInfoRemoteServiceDefault { + AccountInfoRemoteServiceDefault( + ethereumRemoteBalanceFetching: AccountInfoFetchingStub(), + tonRemoteBalanceFetching: AccountInfoRemoteServiceStub(), + bitcoinBalanceSync: bitcoinBalanceSync, + bitcoinMnemonicProvider: bitcoinMnemonicProvider, + solanaBalanceSync: solanaBalanceSync, + irohaToriiClient: irohaToriiClient, + storagePerformer: storagePerformer + ) + } + + private func walletWithBitcoinAccount( + chainId: String, + network: BitcoinKeyDerivation.Network = .mainnet + ) throws -> MetaAccountModel { + let account = try BitcoinKeyDerivation.deriveAccount(mnemonic: Self.mnemonic, network: network) + let chainAccount = ChainAccountModel( + chainId: chainId, + accountId: account.publicKey, + publicKey: account.publicKey, + cryptoType: CryptoType.ecdsa.rawValue, + ethereumBased: false + ) + + return AccountGenerator.generateMetaAccount(with: [chainAccount]) + } + + private func walletWithSolanaAccount(chainId: String) throws -> MetaAccountModel { + let account = try SolanaKeyDerivation.deriveAccount(mnemonic: Self.mnemonic) + let chainAccount = ChainAccountModel( + chainId: chainId, + accountId: account.publicKey, + publicKey: account.publicKey, + cryptoType: CryptoType.ed25519.rawValue, + ethereumBased: false + ) + + return AccountGenerator.generateMetaAccount(with: [chainAccount]) + } + + private func walletWithIrohaAccount(chainId: String) throws -> MetaAccountModel { + let account = try IrohaKeyDerivation.deriveAccount(mnemonic: Self.mnemonic) + let chainAccount = ChainAccountModel( + chainId: chainId, + accountId: account.publicKey, + publicKey: account.publicKey, + cryptoType: CryptoType.ed25519.rawValue, + ethereumBased: false + ) + + return AccountGenerator.generateMetaAccount(with: [chainAccount]) + } + + private func makeBitcoinChain( + chainId: String = UniversalWalletRegistry.bitcoinMainnet.chainId, + historyBaseURL: String? = nil, + assets: [AssetModel] = [AccountInfoRemoteServiceTests.btcAsset] + ) -> ChainModel { + let node = ChainNodeModel( + url: URL(string: "https://bitcoin.node.example")!, + name: "Bitcoin", + apikey: nil + ) + + let externalApi = historyBaseURL.map { + ChainModel.ExternalApiSet( + staking: nil, + history: ChainModel.BlockExplorer( + type: "subsquid", + url: URL(string: $0)! + ), + crowdloans: nil, + explorers: nil + ) + } + + return ChainModel( + rank: nil, + disabled: false, + chainId: chainId, + parentId: nil, + paraId: nil, + name: chainId == UniversalWalletRegistry.bitcoinTestnet.chainId ? "Bitcoin Testnet" : "Bitcoin", + assets: Set(assets), + xcm: nil, + nodes: [node], + addressPrefix: 0, + types: nil, + icon: nil, + options: chainId == UniversalWalletRegistry.bitcoinTestnet.chainId ? [.testnet] : nil, + externalApi: externalApi, + selectedNode: nil, + customNodes: nil, + iosMinAppVersion: nil, + identityChain: nil + ) + } + + private func makeSolanaChain( + chainId: String = UniversalWalletRegistry.solanaMainnet.chainId, + historyBaseURL: String? = nil + ) -> ChainModel { + let node = ChainNodeModel( + url: URL(string: "https://api.mainnet-beta.solana.com")!, + name: "Solana", + apikey: nil + ) + + let externalApi = historyBaseURL.map { + ChainModel.ExternalApiSet( + staking: nil, + history: ChainModel.BlockExplorer( + type: "subquery", + url: URL(string: $0)! + ), + explorers: nil + ) + } + + return ChainModel( + rank: nil, + disabled: false, + chainId: chainId, + parentId: nil, + paraId: nil, + name: chainId == UniversalWalletRegistry.solanaDevnet.chainId ? "Solana Devnet" : "Solana", + assets: [Self.solAsset, Self.usdcAsset], + xcm: nil, + nodes: [node], + addressPrefix: 0, + types: nil, + icon: nil, + options: chainId == UniversalWalletRegistry.solanaDevnet.chainId ? [.testnet] : nil, + externalApi: externalApi, + selectedNode: nil, + customNodes: nil, + iosMinAppVersion: nil, + identityChain: nil + ) + } + + private func bitcoinBalanceResult(totalSats: Int64) -> BitcoinBalanceSyncResult { + let discovered = BitcoinReceiveDiscoveredAddress( + address: "bc1qunitbitcoinaddress000000000000000000000", + index: 0, + path: UniversalWalletDerivationPaths.bitcoinMainnetFirstReceive, + confirmedSats: totalSats, + mempoolSats: 0, + totalSats: totalSats, + txCount: totalSats > 0 ? 1 : 0, + used: totalSats > 0 + ) + let discovery = BitcoinReceiveDiscoveryResult( + addresses: [discovered], + gapLimit: UniversalWalletRegistry.bitcoinMainnet.defaultGapLimit, + lastUsedIndex: totalSats > 0 ? 0 : nil, + nextReceiveAddress: "bc1qnextbitcoinaddress00000000000000000000", + nextReceiveIndex: totalSats > 0 ? 1 : 0, + usedAddresses: totalSats > 0 ? [discovered] : [] + ) + + return BitcoinBalanceSyncResult( + confirmedSats: totalSats, + mempoolSats: 0, + totalSats: totalSats, + usedAddresses: discovery.usedAddresses, + discovery: discovery + ) + } + + private func solanaBalanceResult( + nativeAmount: String = "1234567890", + tokenAmount: String = "42000000", + tokenDecimals: Int = 6 + ) -> SolanaBalanceSyncResult { + let native = UniversalWalletIndexedAssetBalance( + accountId: UniversalWalletRegistry.solanaMainnet.id, + ecosystem: .solana, + chainId: UniversalWalletRegistry.solanaMainnet.chainId, + assetId: UniversalWalletRegistry.solanaMainnet.nativeAsset.id, + amount: nativeAmount, + decimals: UniversalWalletRegistry.solanaMainnet.nativeAsset.decimals, + isNative: true, + symbol: UniversalWalletRegistry.solanaMainnet.nativeAsset.symbol, + name: UniversalWalletRegistry.solanaMainnet.name, + uiAmountString: "1.23456789", + syncedAtMillis: 1_715_000_000_000 + ) + let token = UniversalWalletIndexedAssetBalance( + accountId: UniversalWalletRegistry.solanaMainnet.id, + ecosystem: .solana, + chainId: UniversalWalletRegistry.solanaMainnet.chainId, + assetId: Self.usdcMint, + amount: tokenAmount, + decimals: tokenDecimals, + isNative: false, + symbol: "USDC", + name: "USD Coin", + uiAmountString: "42", + tokenAccountId: "TokenAccount11111111111111111111111111111", + contractAddress: Self.usdcMint, + syncedAtMillis: 1_715_000_000_000 + ) + + return SolanaBalanceSyncResult( + wallet: Self.solanaWallet, + networkId: UniversalWalletRegistry.solanaMainnet.id, + chainId: UniversalWalletRegistry.solanaMainnet.chainId, + syncedAtMillis: 1_715_000_000_000, + nativeBalance: native, + tokenBalances: [token], + balances: [native, token] + ) + } + + private static let mnemonic = "legal winner thank year wave sausage worth useful legal winner thank yellow" + private static let btcAsset = AssetModel( + id: "BTC", + name: "Bitcoin", + symbol: "BTC", + precision: 8, + isUtility: true, + isNative: true + ) + private static let btcPrecisionMismatchAsset = AssetModel( + id: "BTC", + name: "Bitcoin", + symbol: "BTC", + precision: 7, + isUtility: true, + isNative: true + ) + private static let solanaWallet = "HAgk14JpMQLgt6rVgv7cBQFJWFto5Dqxi472uT3DKpqk" + private static let usdcMint = "So11111111111111111111111111111111111111112" + private static let solAsset = AssetModel( + id: "SOL", + name: "Solana", + symbol: "SOL", + precision: 9, + isUtility: true, + isNative: true + ) + private static let usdcAsset = AssetModel( + id: usdcMint, + name: "USD Coin", + symbol: "USDC", + precision: 6, + isUtility: false, + isNative: false + ) + private static let irohaAsset = AssetModel( + id: "XOR", + name: "SORA", + symbol: "XOR", + precision: 18, + isUtility: true, + isNative: true + ) + private static let irohaToriiAsset = AssetModel( + id: "xor#sora", + name: "SORA", + symbol: "XOR", + precision: 18, + isUtility: true, + isNative: true + ) + + private enum TestError: Error { + case indexerUnavailable + } +} + +private extension AssetModel { + func chainAssetId(chainId: String) -> ChainAssetId { + ChainAssetId(chainId: chainId, assetId: id) + } +} + +final class SendDependencyContainerUniversalWalletRoutingTests: XCTestCase { + func testPrepareDependenciesDoesNotFailClosedForNativeTransferUniversalWalletChains() async { + let chainIds = [ + UniversalWalletRegistry.bitcoinMainnet.chainId, + UniversalWalletRegistry.bitcoinMainnet.id, + UniversalWalletRegistry.bitcoinTestnet.chainId, + UniversalWalletRegistry.bitcoinTestnet.id, + UniversalWalletRegistry.solanaMainnet.chainId, + UniversalWalletRegistry.solanaMainnet.id, + UniversalWalletRegistry.solanaDevnet.chainId, + UniversalWalletRegistry.solanaDevnet.id, + UniversalWalletRegistry.taira.chainId, + UniversalWalletRegistry.taira.id, + UniversalWalletRegistry.nexus.chainId, + UniversalWalletRegistry.nexus.id + ] + + for chainId in chainIds { + let chain = makeChain(chainId: chainId) + let container = SendDepencyContainer( + wallet: AccountGenerator.generateMetaAccount(), + operationManager: fearless.OperationManagerFacade.sharedManager + ) + + do { + _ = try await container.prepareDepencies( + chainAsset: ChainAsset(chain: chain, asset: Self.asset) + ) + XCTFail("Expected missing account for \(chainId)") + } catch let error as UniversalWalletSendRoutingError { + XCTFail("Native transfer chain should route to a guarded service, not unsupported error: \(error)") + } catch ChainAccountFetchingError.accountNotExists { + continue + } catch { + XCTFail("Unexpected error for \(chainId): \(error)") + } + } + } + + func testPrepareDependenciesFailsClosedForTonCompatibilityTransferBeforeSubstrateRouting() async throws { + let chain = makeTonTransferChain() + let wallet = walletWithChainAccount(chainId: chain.chainId) + let container = SendDepencyContainer( + wallet: wallet, + operationManager: fearless.OperationManagerFacade.sharedManager + ) + + do { + _ = try await container.prepareDepencies( + chainAsset: ChainAsset(chain: chain, asset: Self.asset) + ) + XCTFail("Expected TON transfer routing to fail closed") + } catch { + XCTAssertEqual((error as NSError).localizedDescription, "TON transfer not yet supported.") + } + } + + func testPrepareDependenciesCreatesIrohaTransferServiceForTairaAccount() async throws { + let chain = makeIrohaChain(chainId: UniversalWalletRegistry.taira.chainId) + let wallet = try walletWithIrohaAccount(chainId: chain.chainId) + let container = SendDepencyContainer( + wallet: wallet, + operationManager: fearless.OperationManagerFacade.sharedManager + ) + + let dependencies = try await container.prepareDepencies( + chainAsset: ChainAsset(chain: chain, asset: Self.irohaAsset) + ) + + XCTAssertTrue(dependencies.transferService is IrohaTransferService) + } + + func testPrepareDependenciesCreatesIrohaTransferServiceForNexusAccount() async throws { + let chain = makeIrohaChain(chainId: UniversalWalletRegistry.nexus.chainId) + let wallet = try walletWithIrohaAccount(chainId: chain.chainId) + let container = SendDepencyContainer( + wallet: wallet, + operationManager: fearless.OperationManagerFacade.sharedManager + ) + + let dependencies = try await container.prepareDepencies( + chainAsset: ChainAsset(chain: chain, asset: Self.irohaAsset) + ) + + XCTAssertTrue(dependencies.transferService is IrohaTransferService) + } + + func testIrohaTransferServiceBuildsSignerRequestAndSubmitsNorito() async throws { + let chain = makeIrohaChain( + chainId: UniversalWalletRegistry.taira.chainId, + historyBaseURL: "https://taira.sora.org" + ) + let wallet = try walletWithIrohaAccount(chainId: chain.chainId) + let sourceAddress = try IrohaKeyDerivation.deriveAddress( + mnemonic: Self.mnemonic, + chainDiscriminant: UniversalWalletRegistry.taira.chainDiscriminant + ).i105 + let recipientAddress = try IrohaAddressCodec.encode( + publicKeyHex: String(repeating: "11", count: 32), + chainDiscriminant: UniversalWalletRegistry.taira.chainDiscriminant + ) + let toriiClient = IrohaSubmitClientStub() + let signer = IrohaTransferSignerStub() + let service = IrohaTransferService( + wallet: wallet, + chain: chain, + toriiClient: toriiClient, + signer: signer, + mnemonicProvider: UniversalWalletMnemonicProviderStub(mnemonic: Self.mnemonic) + ) + let transfer = Transfer( + chainAsset: ChainAsset(chain: chain, asset: Self.irohaAsset), + amount: BigUInt("1250000000000000000"), + receiver: recipientAddress, + tip: nil, + appId: nil + ) + + let fee = try await service.estimateFee(for: transfer) + let hash = try await service.submit(transfer: transfer) + + XCTAssertEqual(fee, BigUInt.zero) + XCTAssertEqual(hash, Self.signedTransactionHash) + XCTAssertEqual(toriiClient.lastSubmittedNorito, Self.signedTransaction) + XCTAssertEqual(toriiClient.lastSubmitBaseURL, "https://taira.sora.org") + XCTAssertEqual(signer.lastRequest?.amount, "1.25") + XCTAssertEqual(signer.lastRequest?.assetDefinitionId, Self.irohaAsset.id) + XCTAssertEqual(signer.lastRequest?.authority, sourceAddress) + XCTAssertEqual(signer.lastRequest?.chainId, UniversalWalletRegistry.taira.chainId) + XCTAssertEqual(signer.lastRequest?.derivationPath, UniversalWalletDerivationPaths.irohaDefault) + XCTAssertEqual(signer.lastRequest?.destinationAccountId, recipientAddress) + XCTAssertEqual(signer.lastRequest?.mnemonicOrSeed, Self.mnemonic) + XCTAssertEqual(signer.lastRequest?.network, "taira") + XCTAssertEqual( + signer.lastRequest?.signingPublicKeyHex, + try IrohaAddressCodec.parse( + sourceAddress, + expectedDiscriminant: UniversalWalletRegistry.taira.chainDiscriminant + ).publicKeyHex + ) + XCTAssertEqual(signer.lastRequest?.sourceAccountId, sourceAddress) + XCTAssertEqual(signer.lastRequest?.sourceAssetId, "\(Self.irohaAsset.id)#\(sourceAddress)") + } + + func testIrohaTransferServiceBuildsNexusSignerRequestAndSubmitsNorito() async throws { + let chain = makeIrohaChain( + chainId: UniversalWalletRegistry.nexus.chainId, + historyBaseURL: "https://minamoto.sora.org" + ) + let wallet = try walletWithIrohaAccount(chainId: chain.chainId) + let sourceAddress = try IrohaKeyDerivation.deriveAddress( + mnemonic: Self.mnemonic, + chainDiscriminant: UniversalWalletRegistry.nexus.chainDiscriminant + ).i105 + let recipientAddress = try IrohaAddressCodec.encode( + publicKeyHex: String(repeating: "22", count: 32), + chainDiscriminant: UniversalWalletRegistry.nexus.chainDiscriminant + ) + let toriiClient = IrohaSubmitClientStub() + let signer = IrohaTransferSignerStub() + let service = IrohaTransferService( + wallet: wallet, + chain: chain, + toriiClient: toriiClient, + signer: signer, + mnemonicProvider: UniversalWalletMnemonicProviderStub(mnemonic: Self.mnemonic) + ) + let transfer = Transfer( + chainAsset: ChainAsset(chain: chain, asset: Self.irohaAsset), + amount: BigUInt("1250000000000000000"), + receiver: recipientAddress, + tip: nil, + appId: nil + ) + + let fee = try await service.estimateFee(for: transfer) + let hash = try await service.submit(transfer: transfer) + + XCTAssertEqual(fee, BigUInt.zero) + XCTAssertEqual(hash, Self.signedTransactionHash) + XCTAssertEqual(toriiClient.lastSubmittedNorito, Self.signedTransaction) + XCTAssertEqual(toriiClient.lastSubmitBaseURL, "https://minamoto.sora.org") + XCTAssertEqual(signer.lastRequest?.amount, "1.25") + XCTAssertEqual(signer.lastRequest?.assetDefinitionId, Self.irohaAsset.id) + XCTAssertEqual(signer.lastRequest?.authority, sourceAddress) + XCTAssertEqual(signer.lastRequest?.chainId, UniversalWalletRegistry.nexus.chainId) + XCTAssertEqual(signer.lastRequest?.derivationPath, UniversalWalletDerivationPaths.irohaDefault) + XCTAssertEqual(signer.lastRequest?.destinationAccountId, recipientAddress) + XCTAssertEqual(signer.lastRequest?.mnemonicOrSeed, Self.mnemonic) + XCTAssertEqual(signer.lastRequest?.network, "nexus") + XCTAssertEqual( + signer.lastRequest?.signingPublicKeyHex, + try IrohaAddressCodec.parse( + sourceAddress, + expectedDiscriminant: UniversalWalletRegistry.nexus.chainDiscriminant + ).publicKeyHex + ) + XCTAssertEqual(signer.lastRequest?.sourceAccountId, sourceAddress) + XCTAssertEqual(signer.lastRequest?.sourceAssetId, "\(Self.irohaAsset.id)#\(sourceAddress)") + } + + func testIrohaTransferServiceDefaultSignerFailsClosedAfterValidation() async throws { + let chain = makeIrohaChain(chainId: UniversalWalletRegistry.taira.chainId) + let wallet = try walletWithIrohaAccount(chainId: chain.chainId) + let recipientAddress = try IrohaAddressCodec.encode( + publicKeyHex: String(repeating: "11", count: 32), + chainDiscriminant: UniversalWalletRegistry.taira.chainDiscriminant + ) + let service = IrohaTransferService( + wallet: wallet, + chain: chain, + toriiClient: IrohaSubmitClientStub(), + mnemonicProvider: UniversalWalletMnemonicProviderStub(mnemonic: Self.mnemonic) + ) + let transfer = Transfer( + chainAsset: ChainAsset(chain: chain, asset: Self.irohaAsset), + amount: BigUInt("1000000000000000000"), + receiver: recipientAddress, + tip: nil, + appId: nil + ) + + let fee = try await service.estimateFee(for: transfer) + XCTAssertEqual(fee, BigUInt.zero) + + do { + _ = try await service.submit(transfer: transfer) + XCTFail("Expected unavailable Iroha signer to fail closed") + } catch TransferServiceError.transferFailed(let reason) { + XCTAssertTrue(reason.contains("signing codec")) + } catch { + XCTFail("Unexpected error: \(error)") + } + } + + func testIrohaTransferServiceRejectsMnemonicMismatchBeforeSignerOrToriiCalls() async throws { + let chain = makeIrohaChain(chainId: UniversalWalletRegistry.taira.chainId) + let wallet = try walletWithIrohaAccount(chainId: chain.chainId) + let recipientAddress = try IrohaAddressCodec.encode( + publicKeyHex: String(repeating: "11", count: 32), + chainDiscriminant: UniversalWalletRegistry.taira.chainDiscriminant + ) + let toriiClient = IrohaSubmitClientStub() + let signer = IrohaTransferSignerStub() + let service = IrohaTransferService( + wallet: wallet, + chain: chain, + toriiClient: toriiClient, + signer: signer, + mnemonicProvider: UniversalWalletMnemonicProviderStub(mnemonic: Self.otherMnemonic) + ) + let transfer = Transfer( + chainAsset: ChainAsset(chain: chain, asset: Self.irohaAsset), + amount: BigUInt("1000000000000000000"), + receiver: recipientAddress, + tip: nil, + appId: nil + ) + + do { + _ = try await service.submit(transfer: transfer) + XCTFail("Expected Iroha transfer service to reject mismatched mnemonic material") + } catch TransferServiceError.transferFailed(let reason) { + XCTAssertTrue(reason.contains("does not match selected wallet")) + } catch { + XCTFail("Unexpected error: \(error)") + } + XCTAssertNil(signer.lastRequest) + XCTAssertNil(toriiClient.lastSubmittedNorito) + } + + private func makeChain(chainId: String) -> ChainModel { + ChainModel( + rank: nil, + disabled: false, + chainId: chainId, + parentId: nil, + paraId: nil, + name: "Universal Wallet Unsupported", + assets: [Self.asset], + xcm: nil, + nodes: [ + ChainNodeModel( + url: URL(string: "https://node.example")!, + name: "Node", + apikey: nil + ) + ], + addressPrefix: 0, + types: nil, + icon: nil, + options: nil, + externalApi: nil, + selectedNode: nil, + customNodes: nil, + iosMinAppVersion: nil, + identityChain: nil + ) + } + + private func makeTonTransferChain() -> ChainModel { + ChainModel( + rank: nil, + disabled: false, + chainId: "ton-mainnet-unit", + parentId: nil, + paraId: nil, + name: "TON Mainnet", + assets: [Self.asset], + xcm: nil, + nodes: [ + ChainNodeModel( + url: URL(string: "wss://rpc.ton.org")!, + name: "TON", + apikey: nil + ) + ], + addressPrefix: 0, + types: nil, + icon: nil, + options: nil, + externalApi: nil, + selectedNode: nil, + customNodes: nil, + iosMinAppVersion: nil, + identityChain: nil + ) + } + + private func makeIrohaChain( + chainId: String, + historyBaseURL: String? = nil + ) -> ChainModel { + let externalApi = historyBaseURL.map { + ChainModel.ExternalApiSet( + staking: nil, + history: ChainModel.BlockExplorer( + type: "iroha", + url: URL(string: $0)! + ), + crowdloans: nil, + explorers: nil + ) + } + + return ChainModel( + rank: nil, + disabled: false, + chainId: chainId, + parentId: nil, + paraId: nil, + name: "Taira Testnet", + assets: [Self.irohaAsset], + xcm: nil, + nodes: [ + ChainNodeModel( + url: URL(string: "https://taira.sora.org")!, + name: "Taira", + apikey: nil + ) + ], + addressPrefix: 0, + types: nil, + icon: nil, + options: nil, + externalApi: externalApi, + selectedNode: nil, + customNodes: nil, + iosMinAppVersion: nil, + identityChain: nil + ) + } + + private func walletWithIrohaAccount(chainId: String) throws -> MetaAccountModel { + let account = try IrohaKeyDerivation.deriveAccount(mnemonic: Self.mnemonic) + let chainAccount = ChainAccountModel( + chainId: chainId, + accountId: account.publicKey, + publicKey: account.publicKey, + cryptoType: CryptoType.ed25519.rawValue, + ethereumBased: false + ) + + return AccountGenerator.generateMetaAccount(with: [chainAccount]) + } + + private func walletWithChainAccount(chainId: String) -> MetaAccountModel { + let chainAccount = ChainAccountModel( + chainId: chainId, + accountId: Data.random(of: 32)!, + publicKey: Data.random(of: 32)!, + cryptoType: CryptoType.sr25519.rawValue, + ethereumBased: false + ) + + return AccountGenerator.generateMetaAccount(with: [chainAccount]) + } + + private static let asset = AssetModel( + id: "UNIT", + name: "Unit", + symbol: "UNIT", + precision: 12, + isUtility: true, + isNative: true + ) + + private static let irohaAsset = AssetModel( + id: "xor#sora", + name: "XOR", + symbol: "XOR", + precision: 18, + isUtility: true, + isNative: true + ) + private static let mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + private static let otherMnemonic = "legal winner thank year wave sausage worth useful legal winner thank yellow" + private static let signedTransaction = Data([1, 2, 3, 4]) + private static let signedTransactionHash = "signed-transaction-hash" + private static let receiptTxHash = "receipt-tx-hash" + private static let receiptEntrypointHash = "receipt-entrypoint-hash" + private static let receiptSignedTransactionHash = "receipt-signed-transaction-hash" + + private final class IrohaTransferSignerStub: IrohaTransferSigning { + private(set) var lastRequest: IrohaTransferSigningRequest? + + func buildAndSignTransfer(_ request: IrohaTransferSigningRequest) async throws -> IrohaSignedTransfer { + lastRequest = request + + return IrohaSignedTransfer( + signedTransaction: SendDependencyContainerUniversalWalletRoutingTests.signedTransaction, + transactionHashHex: SendDependencyContainerUniversalWalletRoutingTests.signedTransactionHash + ) + } + } + + private final class IrohaSubmitClientStub: IrohaToriiClientProtocol { + private(set) var lastSubmittedNorito: Data? + private(set) var lastSubmitBaseURL: String? + + func health(baseURL _: String?) async throws -> Data { + Data() + } + + func accounts( + baseURL _: String?, + limit _: Int?, + offset _: Int64?, + countMode _: IrohaToriiCountMode? + ) async throws -> IrohaAccountListResponse { + throw AccountInfoRemoteServiceStubError.notImplemented + } + + func account( + accountID _: String, + baseURL _: String?, + network _: UniversalWalletRegistry.IrohaNetwork + ) async throws -> IrohaAccountListItem { + throw AccountInfoRemoteServiceStubError.notImplemented + } + + func accountAssets( + accountID _: String, + baseURL _: String?, + limit _: Int?, + offset _: Int64?, + countMode _: IrohaToriiCountMode?, + asset _: String?, + scope _: String?, + network _: UniversalWalletRegistry.IrohaNetwork + ) async throws -> IrohaAccountAssetListResponse { + throw AccountInfoRemoteServiceStubError.notImplemented + } + + func assetDefinitions(baseURL _: String?) async throws -> IrohaAssetDefinitionListResponse { + throw AccountInfoRemoteServiceStubError.notImplemented + } + + func submitTransaction(noritoBytes: Data, baseURL: String?) async throws -> IrohaTransactionSubmissionReceipt { + lastSubmittedNorito = noritoBytes + lastSubmitBaseURL = baseURL + + return IrohaTransactionSubmissionReceipt( + payload: IrohaTransactionSubmissionPayload( + txHash: SendDependencyContainerUniversalWalletRoutingTests.receiptTxHash, + entrypointHash: SendDependencyContainerUniversalWalletRoutingTests.receiptEntrypointHash, + signedTransactionHash: SendDependencyContainerUniversalWalletRoutingTests.receiptSignedTransactionHash, + submittedAtMs: 1, + submittedAtHeight: 2, + signer: nil + ), + signature: nil + ) + } + + func transactionStatus( + hash _: String, + baseURL _: String?, + scope _: IrohaTransactionStatusScope + ) async throws -> IrohaPipelineTransactionStatusResponse { + throw AccountInfoRemoteServiceStubError.notImplemented + } + + func mcpCapabilities( + network _: UniversalWalletRegistry.IrohaNetwork, + baseURL _: String? + ) async throws -> Data { + throw AccountInfoRemoteServiceStubError.notImplemented + } + + func mcpJSONRPC( + _: IrohaMcpJsonRPCRequest, + network _: UniversalWalletRegistry.IrohaNetwork, + baseURL _: String? + ) async throws -> IrohaMcpJsonRPCResponse { + throw AccountInfoRemoteServiceStubError.notImplemented + } + } + + private struct UniversalWalletMnemonicProviderStub: UniversalWalletMnemonicProviding { + let mnemonic: String? + + func mnemonic(for _: MetaAccountModel, chain _: ChainModel) throws -> String? { + mnemonic + } + } } final class ChainRegistryTonNodeSelectionTests: XCTestCase { @@ -618,6 +1839,252 @@ private final class AccountInfoRemoteServiceStub: AccountInfoRemoteService { } } +private final class BitcoinBalanceSyncStub: BitcoinBalanceSyncing { + struct Invocation: Equatable { + let mnemonic: String + let passphrase: String + let network: BitcoinKeyDerivation.Network + let baseURL: String? + let gapLimit: Int? + let maxLookahead: Int + } + + private let result: BitcoinBalanceSyncResult + private let error: Error? + private(set) var invocations: [Invocation] = [] + + init( + result: BitcoinBalanceSyncResult = BitcoinBalanceSyncResult( + confirmedSats: 0, + mempoolSats: 0, + totalSats: 0, + usedAddresses: [], + discovery: BitcoinReceiveDiscoveryResult( + addresses: [], + gapLimit: UniversalWalletRegistry.bitcoinMainnet.defaultGapLimit, + lastUsedIndex: nil, + nextReceiveAddress: "", + nextReceiveIndex: 0, + usedAddresses: [] + ) + ), + error: Error? = nil + ) { + self.result = result + self.error = error + } + + func balance( + mnemonic: String, + passphrase: String, + network: BitcoinKeyDerivation.Network, + baseURL: String?, + gapLimit: Int?, + maxLookahead: Int + ) async throws -> BitcoinBalanceSyncResult { + invocations.append( + Invocation( + mnemonic: mnemonic, + passphrase: passphrase, + network: network, + baseURL: baseURL, + gapLimit: gapLimit, + maxLookahead: maxLookahead + ) + ) + + if let error { + throw error + } + + return result + } +} + +private struct BitcoinMnemonicProviderStub: BitcoinMnemonicProviding { + let mnemonic: String? + + func mnemonic(for _: MetaAccountModel, chain _: ChainModel) throws -> String? { + mnemonic + } +} + +private final class SolanaBalanceSyncStub: SolanaBalanceSyncing { + struct Invocation: Equatable { + let wallet: String + let network: UniversalWalletRegistry.SolanaNetwork + let baseURL: String? + let includeTokenMetadata: Bool + } + + private let result: SolanaBalanceSyncResult + private let error: Error? + private(set) var invocations: [Invocation] = [] + + init( + result: SolanaBalanceSyncResult = SolanaBalanceSyncResult( + wallet: "", + networkId: UniversalWalletRegistry.solanaMainnet.id, + chainId: UniversalWalletRegistry.solanaMainnet.chainId, + syncedAtMillis: 1, + nativeBalance: UniversalWalletIndexedAssetBalance( + accountId: UniversalWalletRegistry.solanaMainnet.id, + ecosystem: .solana, + chainId: UniversalWalletRegistry.solanaMainnet.chainId, + assetId: UniversalWalletRegistry.solanaMainnet.nativeAsset.id, + amount: "0", + decimals: UniversalWalletRegistry.solanaMainnet.nativeAsset.decimals, + isNative: true, + syncedAtMillis: 1 + ), + tokenBalances: [], + balances: [] + ), + error: Error? = nil + ) { + self.result = result + self.error = error + } + + func balances( + wallet: String, + network: UniversalWalletRegistry.SolanaNetwork, + baseURL: String?, + includeTokenMetadata: Bool + ) async throws -> SolanaBalanceSyncResult { + invocations.append( + Invocation( + wallet: wallet, + network: network, + baseURL: baseURL, + includeTokenMetadata: includeTokenMetadata + ) + ) + + if let error { + throw error + } + + return result + } +} + +private final class IrohaToriiClientStub: IrohaToriiClientProtocol { + struct AccountAssetsInvocation: Equatable { + let accountID: String + let baseURL: String? + let limit: Int? + let offset: Int64? + let countMode: IrohaToriiCountMode? + let asset: String? + let scope: String? + let network: UniversalWalletRegistry.IrohaNetwork + } + + private let accountAssetsResponse: IrohaAccountAssetListResponse + private let accountAssetsError: Error? + private(set) var accountAssetsInvocations: [AccountAssetsInvocation] = [] + + init( + accountAssetsResponse: IrohaAccountAssetListResponse = IrohaAccountAssetListResponse( + items: [], + hasMore: false, + countMode: IrohaToriiCountMode.bounded.rawValue, + total: 0 + ), + accountAssetsError: Error? = nil + ) { + self.accountAssetsResponse = accountAssetsResponse + self.accountAssetsError = accountAssetsError + } + + func health(baseURL _: String?) async throws -> Data { + throw AccountInfoRemoteServiceStubError.notImplemented + } + + func accounts( + baseURL _: String?, + limit _: Int?, + offset _: Int64?, + countMode _: IrohaToriiCountMode? + ) async throws -> IrohaAccountListResponse { + throw AccountInfoRemoteServiceStubError.notImplemented + } + + func account( + accountID _: String, + baseURL _: String?, + network _: UniversalWalletRegistry.IrohaNetwork + ) async throws -> IrohaAccountListItem { + throw AccountInfoRemoteServiceStubError.notImplemented + } + + func accountAssets( + accountID: String, + baseURL: String?, + limit: Int?, + offset: Int64?, + countMode: IrohaToriiCountMode?, + asset: String?, + scope: String?, + network: UniversalWalletRegistry.IrohaNetwork + ) async throws -> IrohaAccountAssetListResponse { + accountAssetsInvocations.append( + AccountAssetsInvocation( + accountID: accountID, + baseURL: baseURL, + limit: limit, + offset: offset, + countMode: countMode, + asset: asset, + scope: scope, + network: network + ) + ) + + if let accountAssetsError { + throw accountAssetsError + } + + return accountAssetsResponse + } + + func assetDefinitions(baseURL _: String?) async throws -> IrohaAssetDefinitionListResponse { + throw AccountInfoRemoteServiceStubError.notImplemented + } + + func submitTransaction(noritoBytes _: Data, baseURL _: String?) async throws -> IrohaTransactionSubmissionReceipt { + throw AccountInfoRemoteServiceStubError.notImplemented + } + + func transactionStatus( + hash _: String, + baseURL _: String?, + scope _: IrohaTransactionStatusScope + ) async throws -> IrohaPipelineTransactionStatusResponse { + throw AccountInfoRemoteServiceStubError.notImplemented + } + + func mcpCapabilities( + network _: UniversalWalletRegistry.IrohaNetwork, + baseURL _: String? + ) async throws -> Data { + throw AccountInfoRemoteServiceStubError.notImplemented + } + + func mcpJSONRPC( + _: IrohaMcpJsonRPCRequest, + network _: UniversalWalletRegistry.IrohaNetwork, + baseURL _: String? + ) async throws -> IrohaMcpJsonRPCResponse { + throw AccountInfoRemoteServiceStubError.notImplemented + } +} + +private enum AccountInfoRemoteServiceStubError: Error { + case notImplemented +} + private final class StorageRequestPerformerStub: SSFStorageQueryKit.StorageRequestPerformer { var performMixInvocations = 0 var lastMixRequests: [any MixStorageRequest] = [] diff --git a/fearlessTests/BitcoinBalanceSyncTests.swift b/fearlessTests/BitcoinBalanceSyncTests.swift new file mode 100644 index 0000000000..e174c8ef85 --- /dev/null +++ b/fearlessTests/BitcoinBalanceSyncTests.swift @@ -0,0 +1,191 @@ +import XCTest +@testable import fearless + +final class BitcoinBalanceSyncTests: XCTestCase { + func testSumsConfirmedAndMempoolBalancesFromDiscoveredReceiveAddresses() async throws { + let balances = try Self.balancesByAddress([ + 0: AddressBalance(confirmedSats: 10, mempoolSats: 2), + 1: AddressBalance(confirmedSats: 3, mempoolSats: 5) + ]) + let client = FakeBitcoinIndexerClient(balances: balances) + let balanceSync = BitcoinBalanceSync(discovery: BitcoinReceiveDiscovery(client: client)) + + let result = try await balanceSync.balance( + mnemonic: Self.mnemonic, + gapLimit: 2, + maxLookahead: 5 + ) + + XCTAssertEqual(result.confirmedSats, 13) + XCTAssertEqual(result.mempoolSats, 7) + XCTAssertEqual(result.totalSats, 20) + XCTAssertEqual(result.usedAddresses.map(\.index), [0, 1]) + XCTAssertEqual(client.addressCalls.count, 4) + } + + func testRejectsBalanceOverflowAcrossDiscoveredAddresses() async throws { + let balances = try Self.balancesByAddress([ + 0: AddressBalance(confirmedSats: Int64.max, mempoolSats: 0), + 1: AddressBalance(confirmedSats: Int64.max, mempoolSats: 0) + ]) + let client = FakeBitcoinIndexerClient(balances: balances) + let balanceSync = BitcoinBalanceSync(discovery: BitcoinReceiveDiscovery(client: client)) + + do { + _ = try await balanceSync.balance( + mnemonic: Self.mnemonic, + gapLimit: 1, + maxLookahead: 4 + ) + XCTFail("Expected Bitcoin balance overflow to be rejected") + } catch { + XCTAssertEqual(error as? BitcoinBalanceSyncError, .balanceOverflow) + } + } + + func testAllowsNegativeMempoolDeltaForPendingOutgoingTransactions() async throws { + let balances = try Self.balancesByAddress([ + 0: AddressBalance(confirmedSats: 100_000, mempoolSats: -25_000) + ]) + let client = FakeBitcoinIndexerClient(balances: balances) + let balanceSync = BitcoinBalanceSync(discovery: BitcoinReceiveDiscovery(client: client)) + + let result = try await balanceSync.balance( + mnemonic: Self.mnemonic, + gapLimit: 1, + maxLookahead: 4 + ) + + XCTAssertEqual(result.confirmedSats, 100_000) + XCTAssertEqual(result.mempoolSats, -25_000) + XCTAssertEqual(result.totalSats, 75_000) + XCTAssertEqual(result.usedAddresses.map(\.index), [0]) + } + + func testRejectsNegativeTotalAddressBalance() async throws { + let balances = try Self.balancesByAddress([ + 0: AddressBalance(confirmedSats: 10_000, mempoolSats: -25_000) + ]) + let client = FakeBitcoinIndexerClient(balances: balances) + let balanceSync = BitcoinBalanceSync(discovery: BitcoinReceiveDiscovery(client: client)) + + do { + _ = try await balanceSync.balance( + mnemonic: Self.mnemonic, + gapLimit: 1, + maxLookahead: 4 + ) + XCTFail("Expected negative total address balance to be rejected") + } catch { + XCTAssertEqual(error as? BitcoinBalanceSyncError, .invalidAddressBalance) + } + } + + private final class FakeBitcoinIndexerClient: BitcoinIndexerClientProtocol { + private let balances: [String: AddressBalance] + private(set) var addressCalls: [String] = [] + + init(balances: [String: AddressBalance]) { + self.balances = balances + } + + func address( + address: String, + network: BitcoinIndexerNetwork, + baseURL: String? + ) async throws -> BitcoinEsploraAddress { + addressCalls.append(address) + let balance = balances[address] ?? AddressBalance() + + return BitcoinBalanceSyncTests.addressStats( + address: address, + confirmedSats: balance.confirmedSats, + mempoolSats: balance.mempoolSats, + txCount: balance.confirmedSats > 0 || balance.mempoolSats > 0 ? 1 : 0 + ) + } + + func utxos( + address: String, + network: BitcoinIndexerNetwork, + baseURL: String? + ) async throws -> [BitcoinEsploraUtxo] { + throw UnexpectedEndpointError() + } + + func transactions( + address: String, + network: BitcoinIndexerNetwork, + baseURL: String?, + lastSeenTxid: String?, + mempool: Bool + ) async throws -> [BitcoinEsploraTransaction] { + throw UnexpectedEndpointError() + } + + func feeEstimates( + network: BitcoinIndexerNetwork, + baseURL: String? + ) async throws -> [String: Double] { + throw UnexpectedEndpointError() + } + + func broadcastTransaction( + txHex: String, + network: BitcoinIndexerNetwork, + baseURL: String? + ) async throws -> String { + throw UnexpectedEndpointError() + } + } + + private struct UnexpectedEndpointError: Error {} + + private struct AddressBalance { + let confirmedSats: Int64 + let mempoolSats: Int64 + + init(confirmedSats: Int64 = 0, mempoolSats: Int64 = 0) { + self.confirmedSats = confirmedSats + self.mempoolSats = mempoolSats + } + } + + private static let mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + + private static func balancesByAddress(_ balances: [Int: AddressBalance]) throws -> [String: AddressBalance] { + try Dictionary(uniqueKeysWithValues: balances.map { entry in + let address = try BitcoinKeyDerivation.deriveKey( + mnemonic: mnemonic, + derivationPath: BitcoinKeyDerivation.getReceivePath(index: UInt32(entry.key)) + ).address + + return (address, entry.value) + }) + } + + private static func addressStats( + address: String, + confirmedSats: Int64, + mempoolSats: Int64, + txCount: Int + ) -> BitcoinEsploraAddress { + BitcoinEsploraAddress( + address: address, + chainStats: BitcoinEsploraStats( + fundedTxoCount: txCount, + fundedTxoSum: confirmedSats, + spentTxoCount: 0, + spentTxoSum: 0, + txCount: txCount + ), + mempoolStats: BitcoinEsploraStats( + fundedTxoCount: mempoolSats > 0 ? 1 : 0, + fundedTxoSum: max(mempoolSats, 0), + spentTxoCount: mempoolSats < 0 ? 1 : 0, + spentTxoSum: max(-mempoolSats, 0), + txCount: mempoolSats == 0 ? 0 : 1 + ) + ) + } +} diff --git a/fearlessTests/BitcoinFeeEstimatorTests.swift b/fearlessTests/BitcoinFeeEstimatorTests.swift new file mode 100644 index 0000000000..bc0c4a5089 --- /dev/null +++ b/fearlessTests/BitcoinFeeEstimatorTests.swift @@ -0,0 +1,121 @@ +import XCTest +@testable import fearless + +final class BitcoinFeeEstimatorTests: XCTestCase { + func testSelectsExactNextSlowerOrSlowestAvailableFeeEstimate() throws { + let estimator = BitcoinFeeEstimator(client: FakeBitcoinIndexerClient()) + + XCTAssertEqual( + try estimator.select(estimates: ["1": 9, "2": 4, "6": 2], targetBlocks: 2).feeRateSatPerVbyte, + 4 + ) + XCTAssertEqual( + try estimator.select(estimates: ["1": 9, "6": 2], targetBlocks: 2).feeRateSatPerVbyte, + 2 + ) + XCTAssertEqual( + try estimator.select(estimates: ["1": 9, "6": 2], targetBlocks: 10).feeRateSatPerVbyte, + 2 + ) + } + + func testRejectsUnsafeFeeEstimatesAndTargets() { + let estimator = BitcoinFeeEstimator(client: FakeBitcoinIndexerClient()) + + assertFeeError(.feeEstimatesUnavailable) { + _ = try estimator.select(estimates: [:], targetBlocks: 2) + } + assertFeeError(.invalidFeeTarget) { + _ = try estimator.select(estimates: ["2": 1], targetBlocks: 0) + } + assertFeeError(.invalidFeeRate) { + _ = try estimator.select(estimates: ["2": 10_001], targetBlocks: 2) + } + } + + func testFetchesEstimatesThroughSelectedBitcoinIndexerNetwork() async throws { + let client = FakeBitcoinIndexerClient(estimates: ["3": 5]) + let estimator = BitcoinFeeEstimator(client: client) + + let result = try await estimator.estimate( + network: .testnet, + baseURL: "https://bitcoin.example/api", + targetBlocks: 2 + ) + + XCTAssertEqual(result.feeRateSatPerVbyte, 5) + XCTAssertEqual(result.requestedTargetBlocks, 2) + XCTAssertEqual(result.selectedTargetBlocks, 3) + XCTAssertEqual(client.lastNetwork, .testnet) + XCTAssertEqual(client.lastBaseURL, "https://bitcoin.example/api") + } + + private func assertFeeError( + _ expected: BitcoinFeeEstimatorError, + file: StaticString = #filePath, + line: UInt = #line, + block: () throws -> Void + ) { + do { + try block() + XCTFail("Expected Bitcoin fee estimator error \(expected)", file: file, line: line) + } catch { + XCTAssertEqual(error as? BitcoinFeeEstimatorError, expected, file: file, line: line) + } + } + + private final class FakeBitcoinIndexerClient: BitcoinIndexerClientProtocol { + private let estimates: [String: Double] + private(set) var lastNetwork: BitcoinIndexerNetwork? + private(set) var lastBaseURL: String? + + init(estimates: [String: Double] = [:]) { + self.estimates = estimates + } + + func feeEstimates( + network: BitcoinIndexerNetwork, + baseURL: String? + ) async throws -> [String: Double] { + lastNetwork = network + lastBaseURL = baseURL + return estimates + } + + func address( + address: String, + network: BitcoinIndexerNetwork, + baseURL: String? + ) async throws -> BitcoinEsploraAddress { + throw UnexpectedEndpointError() + } + + func utxos( + address: String, + network: BitcoinIndexerNetwork, + baseURL: String? + ) async throws -> [BitcoinEsploraUtxo] { + throw UnexpectedEndpointError() + } + + func transactions( + address: String, + network: BitcoinIndexerNetwork, + baseURL: String?, + lastSeenTxid: String?, + mempool: Bool + ) async throws -> [BitcoinEsploraTransaction] { + throw UnexpectedEndpointError() + } + + func broadcastTransaction( + txHex: String, + network: BitcoinIndexerNetwork, + baseURL: String? + ) async throws -> String { + throw UnexpectedEndpointError() + } + } + + private struct UnexpectedEndpointError: Error {} +} diff --git a/fearlessTests/BitcoinHistoryOperationFactoryTests.swift b/fearlessTests/BitcoinHistoryOperationFactoryTests.swift new file mode 100644 index 0000000000..25d40f936d --- /dev/null +++ b/fearlessTests/BitcoinHistoryOperationFactoryTests.swift @@ -0,0 +1,447 @@ +import RobinHood +import SSFModels +import XCTest +@testable import fearless + +final class BitcoinHistoryOperationFactoryTests: XCTestCase { + func testAssemblyRoutesBitcoinChainsToBitcoinHistoryFactory() { + let txStorage: CoreDataRepository = + SubstrateDataStorageFacade.shared.createRepository() + let repository = AnyDataProviderRepository(txStorage) + + let mainnetFactory = HistoryOperationFactoriesAssembly.createOperationFactory( + chain: Self.bitcoinChain(), + txStorage: repository + ) + let testnetFactory = HistoryOperationFactoriesAssembly.createOperationFactory( + chain: Self.bitcoinChain(chainId: UniversalWalletRegistry.bitcoinTestnet.chainId), + txStorage: repository + ) + let registryMainnetFactory = HistoryOperationFactoriesAssembly.createOperationFactory( + chain: Self.bitcoinChain(chainId: UniversalWalletRegistry.bitcoinMainnet.id), + txStorage: repository + ) + let registryTestnetFactory = HistoryOperationFactoriesAssembly.createOperationFactory( + chain: Self.bitcoinChain(chainId: UniversalWalletRegistry.bitcoinTestnet.id), + txStorage: repository + ) + + XCTAssertTrue(mainnetFactory is BitcoinHistoryOperationFactory) + XCTAssertTrue(testnetFactory is BitcoinHistoryOperationFactory) + XCTAssertTrue(registryMainnetFactory is BitcoinHistoryOperationFactory) + XCTAssertTrue(registryTestnetFactory is BitcoinHistoryOperationFactory) + } + + func testMapsBitcoinHistoryIntoWalletTransactionPage() throws { + let client = FakeBitcoinIndexerClient( + transactionProvider: { cursor in + XCTAssertNil(cursor) + return [ + Self.outgoingTransaction( + txid: Self.txid1, + fee: 141, + blockTime: 1_710_000_000 + ), + Self.incomingTransaction( + txid: Self.txid2, + confirmed: false, + blockTime: nil + ) + ] + } + ) + + let result = try execute( + BitcoinHistoryOperationFactory(client: client).fetchTransactionHistoryOperation( + asset: Self.btcAsset, + chain: Self.bitcoinChain(), + address: Self.mainnetAddress, + filters: [WalletTransactionHistoryFilter(type: .transfer, selected: true)], + pagination: Pagination(count: 10) + ) + ) + + let page = try XCTUnwrap(result) + XCTAssertEqual(client.calls, 1) + XCTAssertEqual(client.lastAddress, Self.mainnetAddress) + XCTAssertEqual(client.lastNetwork, .mainnet) + XCTAssertNil(client.lastBaseURL) + XCTAssertEqual(page.transactions.count, 2) + XCTAssertNil(page.context) + + let outgoing = page.transactions[0] + XCTAssertEqual(outgoing.transactionId, Self.txid1) + XCTAssertEqual(outgoing.status, .commited) + XCTAssertEqual(outgoing.assetId, "BTC") + XCTAssertEqual(outgoing.peerId, Self.counterparty) + XCTAssertEqual(outgoing.peerName, Self.counterparty) + XCTAssertEqual(outgoing.amount.decimalValue, Decimal(string: "0.0006")) + XCTAssertEqual(outgoing.fees.first?.amount.decimalValue, Decimal(string: "0.00000141")) + XCTAssertEqual(outgoing.timestamp, 1_710_000_000) + XCTAssertEqual(outgoing.type, TransactionType.outgoing.rawValue) + + let incoming = page.transactions[1] + XCTAssertEqual(incoming.transactionId, Self.txid2) + XCTAssertEqual(incoming.status, .pending) + XCTAssertEqual(incoming.amount.decimalValue, Decimal(string: "0.00075")) + XCTAssertTrue(incoming.fees.isEmpty) + XCTAssertEqual(incoming.type, TransactionType.incoming.rawValue) + } + + func testUsesPaginationContextAndLimitsReturnedTransactions() throws { + let client = FakeBitcoinIndexerClient( + transactionProvider: { cursor in + if cursor == Self.txid0 { + return [ + Self.outgoingTransaction(txid: Self.txid1), + Self.outgoingTransaction(txid: Self.txid2) + ] + } + + return [] + } + ) + + let result = try execute( + BitcoinHistoryOperationFactory(client: client).fetchTransactionHistoryOperation( + asset: Self.btcAsset, + chain: Self.bitcoinChain(), + address: Self.mainnetAddress, + filters: [], + pagination: Pagination( + count: 1, + context: [BitcoinHistoryOperationFactory.paginationLastSeenTxidKey: Self.txid0] + ) + ) + ) + + let page = try XCTUnwrap(result) + XCTAssertEqual(client.calls, 1) + XCTAssertEqual(client.lastSeenTxids, [Self.txid0]) + XCTAssertEqual(page.transactions.map(\.transactionId), [Self.txid1]) + XCTAssertEqual(page.context?[BitcoinHistoryOperationFactory.paginationLastSeenTxidKey], Self.txid1) + } + + func testGuardPathsAvoidIndexerCalls() throws { + let filters = [WalletTransactionHistoryFilter(type: .reward, selected: true)] + let unsupportedAsset = AssetModel( + id: "USDT", + name: "Tether", + symbol: "USDT", + precision: 6, + isUtility: false, + isNative: false + ) + + for (asset, pagination, filterSet) in [ + (unsupportedAsset, Pagination(count: 10), [WalletTransactionHistoryFilter(type: .transfer, selected: true)]), + (Self.btcAsset, Pagination(count: 0), [WalletTransactionHistoryFilter(type: .transfer, selected: true)]), + (Self.btcAsset, Pagination(count: 10), filters) + ] { + let client = FakeBitcoinIndexerClient() + let result = try execute( + BitcoinHistoryOperationFactory(client: client).fetchTransactionHistoryOperation( + asset: asset, + chain: Self.bitcoinChain(), + address: Self.mainnetAddress, + filters: filterSet, + pagination: pagination + ) + ) + + XCTAssertEqual(client.calls, 0) + XCTAssertEqual(result?.transactions, []) + XCTAssertNil(result?.context) + } + } + + func testWrongNetworkAddressReturnsEmptyPageBeforeNetworkCall() throws { + let client = FakeBitcoinIndexerClient() + + let result = try execute( + BitcoinHistoryOperationFactory(client: client).fetchTransactionHistoryOperation( + asset: Self.btcAsset, + chain: Self.bitcoinChain(), + address: Self.testnetAddress, + filters: [WalletTransactionHistoryFilter(type: .transfer, selected: true)], + pagination: Pagination(count: 10) + ) + ) + + XCTAssertEqual(client.calls, 0) + XCTAssertEqual(result?.transactions, []) + XCTAssertNil(result?.context) + } + + func testIndexerOutageReturnsEmptyPage() throws { + let client = FakeBitcoinIndexerClient(error: TestError.indexerUnavailable) + + let result = try execute( + BitcoinHistoryOperationFactory(client: client).fetchTransactionHistoryOperation( + asset: Self.btcAsset, + chain: Self.bitcoinChain(), + address: Self.mainnetAddress, + filters: [WalletTransactionHistoryFilter(type: .transfer, selected: true)], + pagination: Pagination(count: 10) + ) + ) + + XCTAssertEqual(client.calls, 1) + XCTAssertEqual(result?.transactions, []) + XCTAssertNil(result?.context) + } + + func testTestnetChainUsesTestnetAddressAndNetwork() throws { + let client = FakeBitcoinIndexerClient( + transactions: [Self.testnetIncomingTransaction(txid: Self.txid1)] + ) + + let result = try execute( + BitcoinHistoryOperationFactory(client: client).fetchTransactionHistoryOperation( + asset: Self.btcAsset, + chain: Self.bitcoinChain(chainId: UniversalWalletRegistry.bitcoinTestnet.chainId), + address: Self.testnetAddress, + filters: [WalletTransactionHistoryFilter(type: .transfer, selected: true)], + pagination: Pagination(count: 10) + ) + ) + + XCTAssertEqual(client.calls, 1) + XCTAssertEqual(client.lastNetwork, .testnet) + XCTAssertEqual(result?.transactions.first?.transactionId, Self.txid1) + } + + func testRegistryIdTestnetChainUsesTestnetAddressAndNetwork() throws { + let client = FakeBitcoinIndexerClient( + transactions: [Self.testnetIncomingTransaction(txid: Self.txid1)] + ) + + let result = try execute( + BitcoinHistoryOperationFactory(client: client).fetchTransactionHistoryOperation( + asset: Self.btcAsset, + chain: Self.bitcoinChain(chainId: UniversalWalletRegistry.bitcoinTestnet.id), + address: Self.testnetAddress, + filters: [WalletTransactionHistoryFilter(type: .transfer, selected: true)], + pagination: Pagination(count: 10) + ) + ) + + XCTAssertEqual(client.calls, 1) + XCTAssertEqual(client.lastNetwork, .testnet) + XCTAssertEqual(result?.transactions.first?.transactionId, Self.txid1) + } + + private func execute( + _ wrapper: CompoundOperationWrapper + ) throws -> AssetTransactionPageData? { + OperationQueue().addOperations(wrapper.allOperations, waitUntilFinished: true) + return try wrapper.targetOperation.extractResultData(throwing: BaseOperationError.parentOperationCancelled) + } + + private final class FakeBitcoinIndexerClient: BitcoinIndexerClientProtocol { + private let transactionsResponse: [BitcoinEsploraTransaction] + private let transactionProvider: ((String?) -> [BitcoinEsploraTransaction])? + private let error: Error? + + private(set) var calls = 0 + private(set) var lastAddress: String? + private(set) var lastNetwork: BitcoinIndexerNetwork? + private(set) var lastBaseURL: String? + private(set) var lastSeenTxids: [String?] = [] + + init( + transactions: [BitcoinEsploraTransaction] = [], + transactionProvider: ((String?) -> [BitcoinEsploraTransaction])? = nil, + error: Error? = nil + ) { + transactionsResponse = transactions + self.transactionProvider = transactionProvider + self.error = error + } + + func address( + address _: String, + network _: BitcoinIndexerNetwork, + baseURL _: String? + ) async throws -> BitcoinEsploraAddress { + fatalError("Not used") + } + + func utxos( + address _: String, + network _: BitcoinIndexerNetwork, + baseURL _: String? + ) async throws -> [BitcoinEsploraUtxo] { + fatalError("Not used") + } + + func transactions( + address: String, + network: BitcoinIndexerNetwork, + baseURL: String?, + lastSeenTxid: String?, + mempool _: Bool + ) async throws -> [BitcoinEsploraTransaction] { + calls += 1 + lastAddress = address + lastNetwork = network + lastBaseURL = baseURL + lastSeenTxids.append(lastSeenTxid) + + if let error { + throw error + } + + return transactionProvider?(lastSeenTxid) ?? transactionsResponse + } + + func feeEstimates( + network _: BitcoinIndexerNetwork, + baseURL _: String? + ) async throws -> [String: Double] { + fatalError("Not used") + } + + func broadcastTransaction( + txHex _: String, + network _: BitcoinIndexerNetwork, + baseURL _: String? + ) async throws -> String { + fatalError("Not used") + } + } + + private enum TestError: Error { + case indexerUnavailable + } + + private static let mainnetAddress = "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu" + private static let testnetAddress = "tb1q6rz28mcfaxtmd6v789l9rrlrusdprr9pqcpvkl" + private static let counterparty = "bc1q6rz28mcfaxtmd6v789l9rrlrusdprr9pkv76kj" + private static let testnetCounterparty = "tb1qcr8te4kr609gcawutmrza0j4xv80jy8z69lhm6" + private static let txid0 = String(repeating: "00", count: 32) + private static let txid1 = String(repeating: "11", count: 32) + private static let txid2 = String(repeating: "22", count: 32) + + private static let btcAsset = AssetModel( + id: "BTC", + name: "Bitcoin", + symbol: "BTC", + precision: 8, + isUtility: true, + isNative: true + ) + + private static func bitcoinChain( + chainId: String = UniversalWalletRegistry.bitcoinMainnet.chainId + ) -> ChainModel { + let node = ChainNodeModel( + url: URL(string: "https://node.example")!, + name: "Bitcoin", + apikey: nil + ) + + let testnetChainIds = [ + UniversalWalletRegistry.bitcoinTestnet.chainId, + UniversalWalletRegistry.bitcoinTestnet.id + ] + let options: [ChainOptions]? = testnetChainIds.contains(chainId) ? [.testnet] : nil + + let chain = ChainModel( + rank: nil, + disabled: false, + chainId: chainId, + parentId: nil, + paraId: nil, + name: testnetChainIds.contains(chainId) ? "Bitcoin Testnet" : "Bitcoin", + assets: [btcAsset], + xcm: nil, + nodes: [node], + addressPrefix: 0, + icon: nil, + options: options, + externalApi: nil, + customNodes: nil, + iosMinAppVersion: nil, + identityChain: nil + ) + + return chain + } + + private static func outgoingTransaction( + txid: String, + fee: Int64 = 100, + blockTime: Int64 = 1_710_000_000 + ) -> BitcoinEsploraTransaction { + transaction( + txid: txid, + fee: fee, + status: BitcoinEsploraTxStatus( + confirmed: true, + blockHash: String(repeating: "aa", count: 32), + blockHeight: 100, + blockTime: blockTime + ), + vin: [input(mainnetAddress, 100_000)], + vout: [output(counterparty, 60_000), output(mainnetAddress, 39_859)] + ) + } + + private static func incomingTransaction( + txid: String, + confirmed: Bool = true, + blockTime: Int64? + ) -> BitcoinEsploraTransaction { + transaction( + txid: txid, + fee: 200, + status: BitcoinEsploraTxStatus( + confirmed: confirmed, + blockHash: confirmed ? String(repeating: "bb", count: 32) : nil, + blockHeight: confirmed ? 101 : nil, + blockTime: blockTime + ), + vin: [input(counterparty, 75_200)], + vout: [output(mainnetAddress, 75_000)] + ) + } + + private static func testnetIncomingTransaction(txid: String) -> BitcoinEsploraTransaction { + transaction( + txid: txid, + fee: 200, + vin: [BitcoinEsploraTransactionInput(prevout: output(testnetCounterparty, 75_200))], + vout: [output(testnetAddress, 75_000)] + ) + } + + private static func transaction( + txid: String, + fee: Int64? = nil, + status: BitcoinEsploraTxStatus = BitcoinEsploraTxStatus( + confirmed: true, + blockHash: nil, + blockHeight: nil, + blockTime: nil + ), + vin: [BitcoinEsploraTransactionInput], + vout: [BitcoinEsploraTransactionOutput] + ) -> BitcoinEsploraTransaction { + BitcoinEsploraTransaction( + txid: txid, + status: status, + fee: fee, + vin: vin, + vout: vout + ) + } + + private static func input(_ address: String, _ value: Int64) -> BitcoinEsploraTransactionInput { + BitcoinEsploraTransactionInput(prevout: output(address, value)) + } + + private static func output(_ address: String, _ value: Int64) -> BitcoinEsploraTransactionOutput { + BitcoinEsploraTransactionOutput(scriptPubKeyAddress: address, value: value) + } +} diff --git a/fearlessTests/BitcoinIndexerClientTests.swift b/fearlessTests/BitcoinIndexerClientTests.swift new file mode 100644 index 0000000000..ab89a6b4e2 --- /dev/null +++ b/fearlessTests/BitcoinIndexerClientTests.swift @@ -0,0 +1,120 @@ +import XCTest +@testable import fearless + +final class BitcoinIndexerClientTests: XCTestCase { + func testDelegatesEsploraReadEndpointsThroughValidatedRoutes() async throws { + let transport = FakeBitcoinIndexerTransport() + let client = BitcoinIndexerClient(transport: transport) + + transport.enqueue(Self.addressJSON) + _ = try await client.address(address: Self.mainnetAddress) + XCTAssertEqual( + transport.lastRequest?.url?.absoluteString, + "https://blockstream.info/api/address/\(Self.mainnetAddress)" + ) + XCTAssertEqual(transport.lastRequest?.httpMethod, "GET") + + transport.enqueue(Self.utxosJSON) + _ = try await client.utxos(address: Self.testnetAddress, network: .testnet) + XCTAssertEqual( + transport.lastRequest?.url?.absoluteString, + "https://blockstream.info/testnet/api/address/\(Self.testnetAddress)/utxo" + ) + + transport.enqueue(Self.transactionsJSON) + _ = try await client.transactions(address: Self.mainnetAddress, lastSeenTxid: Self.txid.uppercased()) + XCTAssertEqual( + transport.lastRequest?.url?.absoluteString, + "https://blockstream.info/api/address/\(Self.mainnetAddress)/txs/chain/\(Self.txid)" + ) + + transport.enqueue(Self.transactionsJSON) + _ = try await client.transactions(address: Self.mainnetAddress, mempool: true) + XCTAssertEqual( + transport.lastRequest?.url?.absoluteString, + "https://blockstream.info/api/address/\(Self.mainnetAddress)/txs/mempool" + ) + } + + func testDelegatesBroadcastWithNormalizedTextTransactionBody() async throws { + let transport = FakeBitcoinIndexerTransport() + let client = BitcoinIndexerClient(transport: transport) + + transport.enqueue(Data(Self.txid.utf8)) + _ = try await client.broadcastTransaction(txHex: " 00AA ", network: .testnet) + + XCTAssertEqual(transport.lastRequest?.url?.absoluteString, "https://blockstream.info/testnet/api/tx") + XCTAssertEqual(transport.lastRequest?.httpMethod, "POST") + XCTAssertEqual(transport.lastRequest?.value(forHTTPHeaderField: "Content-Type"), "text/plain") + XCTAssertEqual(transport.lastRequest?.httpBody, Data("00aa".utf8)) + + do { + _ = try await client.broadcastTransaction(txHex: "00gg") + XCTFail("Expected invalid Bitcoin tx hex to be rejected") + } catch { + XCTAssertEqual(error as? BitcoinIndexerRouteError, .invalidTxHex) + } + } + + private final class FakeBitcoinIndexerTransport: UniversalWalletHTTPTransport { + private var queuedResponses: [Data] = [] + private(set) var lastRequest: URLRequest? + + func enqueue(_ data: Data) { + queuedResponses.append(data) + } + + func perform(_ request: URLRequest) async throws -> Data { + lastRequest = request + return queuedResponses.isEmpty ? Data("{}".utf8) : queuedResponses.removeFirst() + } + } + + private static let mainnetAddress = "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu" + private static let testnetAddress = "tb1q6rz28mcfaxtmd6v789l9rrlrusdprr9pqcpvkl" + private static let txid = String(repeating: "11", count: 32) + + private static let addressJSON = Data(""" + { + "address": "\(mainnetAddress)", + "chain_stats": { + "funded_txo_count": 0, + "funded_txo_sum": 0, + "spent_txo_count": 0, + "spent_txo_sum": 0, + "tx_count": 0 + }, + "mempool_stats": { + "funded_txo_count": 0, + "funded_txo_sum": 0, + "spent_txo_count": 0, + "spent_txo_sum": 0, + "tx_count": 0 + } + } + """.utf8) + + private static let utxosJSON = Data(""" + [ + { + "txid": "\(txid)", + "vout": 0, + "value": 1, + "status": { + "confirmed": true + } + } + ] + """.utf8) + + private static let transactionsJSON = Data(""" + [ + { + "txid": "\(txid)", + "status": { + "confirmed": true + } + } + ] + """.utf8) +} diff --git a/fearlessTests/BitcoinIndexerContractTests.swift b/fearlessTests/BitcoinIndexerContractTests.swift new file mode 100644 index 0000000000..482dc30160 --- /dev/null +++ b/fearlessTests/BitcoinIndexerContractTests.swift @@ -0,0 +1,160 @@ +import XCTest +@testable import fearless + +final class BitcoinIndexerContractTests: XCTestCase { + func testBuildsBitcoinEsploraEndpointURLsWithValidatedParameters() throws { + XCTAssertEqual( + try BitcoinIndexerRoutes.addressURL(address: Self.mainnetAddress).absoluteString, + "\(UniversalWalletRegistry.bitcoinMainnetIndexerBaseURL.absoluteString)/address/\(Self.mainnetAddress)" + ) + XCTAssertEqual( + try BitcoinIndexerRoutes.utxosURL(address: Self.testnetAddress, network: .testnet).absoluteString, + "https://blockstream.info/testnet/api/address/\(Self.testnetAddress)/utxo" + ) + XCTAssertEqual( + try BitcoinIndexerRoutes.transactionsURL( + address: Self.mainnetAddress, + baseURL: "https://bitcoin.example/api/" + ).absoluteString, + "https://bitcoin.example/api/address/\(Self.mainnetAddress)/txs" + ) + XCTAssertEqual( + try BitcoinIndexerRoutes.transactionsURL( + address: Self.mainnetAddress, + baseURL: "https://bitcoin.example/api/", + lastSeenTxid: Self.txid.uppercased() + ).absoluteString, + "https://bitcoin.example/api/address/\(Self.mainnetAddress)/txs/chain/\(Self.txid)" + ) + XCTAssertEqual( + try BitcoinIndexerRoutes.transactionsURL( + address: Self.mainnetAddress, + baseURL: "https://bitcoin.example/api/", + mempool: true + ).absoluteString, + "https://bitcoin.example/api/address/\(Self.mainnetAddress)/txs/mempool" + ) + XCTAssertEqual( + try BitcoinIndexerRoutes.feeEstimatesURL().absoluteString, + "https://blockstream.info/api/fee-estimates" + ) + XCTAssertEqual( + try BitcoinIndexerRoutes.broadcastTransactionURL(network: .testnet).absoluteString, + "https://blockstream.info/testnet/api/tx" + ) + XCTAssertEqual(try BitcoinIndexerRoutes.normalizeBroadcastTransactionBody(" 00AA "), "00aa") + } + + func testAllowsLocalHTTPBaseURLsButRejectsNonlocalInsecureBaseURLs() throws { + XCTAssertEqual(try BitcoinIndexerRoutes.normalizeBaseURL("http://localhost:3000/api/"), "http://localhost:3000/api") + XCTAssertEqual(try BitcoinIndexerRoutes.normalizeBaseURL("http://127.0.0.1:3000/api/"), "http://127.0.0.1:3000/api") + + assertRouteError(.invalidBaseURL) { + try BitcoinIndexerRoutes.normalizeBaseURL("http://blockstream.info/api") + } + assertRouteError(.invalidBaseURL) { + try BitcoinIndexerRoutes.normalizeBaseURL("not a url") + } + } + + func testRejectsMalformedBitcoinRouteInputsBeforeNetworkCalls() { + assertRouteError(.invalidAddress) { + _ = try BitcoinIndexerRoutes.addressURL(address: "../bad") + } + assertRouteError(.invalidAddress) { + _ = try BitcoinIndexerRoutes.addressURL(address: Self.testnetAddress, network: .mainnet) + } + assertRouteError(.invalidTxid) { + _ = try BitcoinIndexerRoutes.transactionsURL(address: Self.mainnetAddress, lastSeenTxid: "../../../bad") + } + assertRouteError(.invalidTxHex) { + _ = try BitcoinIndexerRoutes.normalizeBroadcastTransactionBody("abc") + } + assertRouteError(.invalidTxHex) { + _ = try BitcoinIndexerRoutes.normalizeBroadcastTransactionBody("00gg") + } + assertRouteError(.invalidTxHex) { + _ = try BitcoinIndexerRoutes.normalizeBroadcastTransactionBody(String(repeating: "00", count: BitcoinIndexerRoutes.maxTxHexLength / 2 + 1)) + } + } + + func testParsesEsploraAddressStatsWithoutLosingSatoshiPrecision() throws { + let data = """ + { + "address": "\(Self.mainnetAddress)", + "chain_stats": { + "funded_txo_count": 2, + "funded_txo_sum": 2100000000000000, + "spent_txo_count": 1, + "spent_txo_sum": 123456789, + "tx_count": 3 + }, + "mempool_stats": { + "funded_txo_count": 1, + "funded_txo_sum": 5000, + "spent_txo_count": 0, + "spent_txo_sum": 0, + "tx_count": 1 + } + } + """.data(using: .utf8)! + + let response = try JSONDecoder().decode(BitcoinEsploraAddress.self, from: data) + + XCTAssertEqual(response.address, Self.mainnetAddress) + XCTAssertEqual(response.confirmedSats, 2_099_999_876_543_211) + XCTAssertEqual(response.mempoolSats, 5_000) + XCTAssertEqual(response.totalSats, 2_099_999_876_548_211) + } + + func testParsesEsploraTransactionMovementOutputsWithoutCoercingStringAmounts() throws { + let data = """ + { + "txid": "\(Self.txid)", + "status": { + "confirmed": true + }, + "vin": [ + { + "prevout": { + "scriptpubkey_address": "\(Self.mainnetAddress)", + "value": 1000 + } + } + ], + "vout": [ + { + "scriptpubkey_address": "\(Self.mainnetAddress)", + "value": "900" + } + ] + } + """.data(using: .utf8)! + + let response = try JSONDecoder().decode(BitcoinEsploraTransaction.self, from: data) + + XCTAssertEqual(response.vin.single?.prevout?.value, 1_000) + XCTAssertNil(response.vout.single?.value) + } + + private func assertRouteError( + _ expected: BitcoinIndexerRouteError, + file: StaticString = #filePath, + line: UInt = #line, + block: () throws -> Void + ) { + XCTAssertThrowsError(try block(), file: file, line: line) { error in + XCTAssertEqual(error as? BitcoinIndexerRouteError, expected, file: file, line: line) + } + } + + private static let mainnetAddress = "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu" + private static let testnetAddress = "tb1q6rz28mcfaxtmd6v789l9rrlrusdprr9pqcpvkl" + private static let txid = String(repeating: "11", count: 32) +} + +private extension Array { + var single: Element? { + count == 1 ? first : nil + } +} diff --git a/fearlessTests/BitcoinReceiveDiscoveryTests.swift b/fearlessTests/BitcoinReceiveDiscoveryTests.swift new file mode 100644 index 0000000000..eb46aaea11 --- /dev/null +++ b/fearlessTests/BitcoinReceiveDiscoveryTests.swift @@ -0,0 +1,196 @@ +import XCTest +@testable import fearless + +final class BitcoinReceiveDiscoveryTests: XCTestCase { + func testScansUntilConfiguredUnusedGapAfterLastUsedAddress() async throws { + let client = FakeBitcoinIndexerClient(usedAddresses: try Self.usedAddresses(0, 2)) + let discovery = BitcoinReceiveDiscovery(client: client) + + let result = try await discovery.discover( + mnemonic: Self.mnemonic, + gapLimit: 3, + maxLookahead: 20 + ) + + XCTAssertEqual(result.gapLimit, 3) + XCTAssertEqual(result.lastUsedIndex, 2) + XCTAssertEqual(result.nextReceiveIndex, 3) + XCTAssertEqual(result.usedAddresses.map(\.index), [0, 2]) + XCTAssertEqual(client.addressCalls.count, 6) + } + + func testUsesRegistryGapLimitWhenNoOverrideIsProvided() async throws { + let client = FakeBitcoinIndexerClient() + let discovery = BitcoinReceiveDiscovery(client: client) + + let result = try await discovery.discover(mnemonic: Self.mnemonic) + + XCTAssertEqual(result.gapLimit, UniversalWalletRegistry.bitcoinMainnet.defaultGapLimit) + XCTAssertEqual(client.addressCalls.count, UniversalWalletRegistry.bitcoinMainnet.defaultGapLimit) + } + + func testDerivesTestnetReceiveAddressesThroughTestnetIndexerNetwork() async throws { + let client = FakeBitcoinIndexerClient() + let discovery = BitcoinReceiveDiscovery(client: client) + + let result = try await discovery.discover( + mnemonic: Self.mnemonic, + network: .testnet, + gapLimit: 1 + ) + + XCTAssertTrue(try XCTUnwrap(result.addresses.first).address.hasPrefix("tb1q")) + XCTAssertEqual(client.networkCalls, [.testnet]) + } + + func testRejectsUnsafeDiscoveryParametersBeforeIndexerCalls() async throws { + let client = FakeBitcoinIndexerClient() + let discovery = BitcoinReceiveDiscovery(client: client) + + try await assertDiscoveryError(.mnemonicRequired) { + _ = try await discovery.discover(mnemonic: "", gapLimit: 2) + } + try await assertDiscoveryError(.invalidGapLimit) { + _ = try await discovery.discover(mnemonic: Self.mnemonic, gapLimit: 0) + } + try await assertDiscoveryError(.invalidGapLimit) { + _ = try await discovery.discover(mnemonic: Self.mnemonic, gapLimit: 101) + } + try await assertDiscoveryError(.invalidMaxLookahead) { + _ = try await discovery.discover(mnemonic: Self.mnemonic, gapLimit: 5, maxLookahead: 4) + } + XCTAssertTrue(client.addressCalls.isEmpty) + } + + func testFailsWhenMaxLookaheadIsExhaustedBeforeUnusedGapIsReached() async throws { + let client = FakeBitcoinIndexerClient(usedAddresses: try Self.usedAddresses(0)) + let discovery = BitcoinReceiveDiscovery(client: client) + + try await assertDiscoveryError(.lookaheadExhausted) { + _ = try await discovery.discover(mnemonic: Self.mnemonic, gapLimit: 3, maxLookahead: 3) + } + } + + func testRejectsImpossibleTransactionCountsFromIndexerResponses() async throws { + let client = FakeBitcoinIndexerClient { address in + Self.addressStats(address: address, chainTxCount: Int.max, mempoolTxCount: 1) + } + let discovery = BitcoinReceiveDiscovery(client: client) + + try await assertDiscoveryError(.invalidTransactionCount) { + _ = try await discovery.discover(mnemonic: Self.mnemonic, gapLimit: 1) + } + } + + private final class FakeBitcoinIndexerClient: BitcoinIndexerClientProtocol { + private let response: (String) -> BitcoinEsploraAddress + private(set) var addressCalls: [String] = [] + private(set) var networkCalls: [BitcoinIndexerNetwork] = [] + + init( + usedAddresses: Set = [], + response: ((String) -> BitcoinEsploraAddress)? = nil + ) { + self.response = response ?? { address in + BitcoinReceiveDiscoveryTests.addressStats( + address: address, + chainTxCount: usedAddresses.contains(address) ? 1 : 0 + ) + } + } + + func address( + address: String, + network: BitcoinIndexerNetwork, + baseURL: String? + ) async throws -> BitcoinEsploraAddress { + addressCalls.append(address) + networkCalls.append(network) + return response(address) + } + + func utxos( + address: String, + network: BitcoinIndexerNetwork, + baseURL: String? + ) async throws -> [BitcoinEsploraUtxo] { + throw UnexpectedEndpointError() + } + + func transactions( + address: String, + network: BitcoinIndexerNetwork, + baseURL: String?, + lastSeenTxid: String?, + mempool: Bool + ) async throws -> [BitcoinEsploraTransaction] { + throw UnexpectedEndpointError() + } + + func feeEstimates( + network: BitcoinIndexerNetwork, + baseURL: String? + ) async throws -> [String: Double] { + throw UnexpectedEndpointError() + } + + func broadcastTransaction( + txHex: String, + network: BitcoinIndexerNetwork, + baseURL: String? + ) async throws -> String { + throw UnexpectedEndpointError() + } + } + + private struct UnexpectedEndpointError: Error {} + + private static let mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + + private static func usedAddresses(_ indexes: Int...) throws -> Set { + Set(try indexes.map { index in + try BitcoinKeyDerivation.deriveKey( + mnemonic: mnemonic, + derivationPath: BitcoinKeyDerivation.getReceivePath(index: UInt32(index)) + ).address + }) + } + + private static func addressStats( + address: String, + chainTxCount: Int = 0, + mempoolTxCount: Int = 0 + ) -> BitcoinEsploraAddress { + BitcoinEsploraAddress( + address: address, + chainStats: BitcoinEsploraStats( + fundedTxoCount: chainTxCount, + fundedTxoSum: 0, + spentTxoCount: 0, + spentTxoSum: 0, + txCount: chainTxCount + ), + mempoolStats: BitcoinEsploraStats( + fundedTxoCount: mempoolTxCount, + fundedTxoSum: 0, + spentTxoCount: 0, + spentTxoSum: 0, + txCount: mempoolTxCount + ) + ) + } + + private func assertDiscoveryError( + _ expected: BitcoinReceiveDiscoveryError, + operation: () async throws -> Void, + file: StaticString = #filePath, + line: UInt = #line + ) async throws { + do { + try await operation() + XCTFail("Expected Bitcoin discovery error \(expected)", file: file, line: line) + } catch { + XCTAssertEqual(error as? BitcoinReceiveDiscoveryError, expected, file: file, line: line) + } + } +} diff --git a/fearlessTests/BitcoinSendPlannerTests.swift b/fearlessTests/BitcoinSendPlannerTests.swift new file mode 100644 index 0000000000..24cff86463 --- /dev/null +++ b/fearlessTests/BitcoinSendPlannerTests.swift @@ -0,0 +1,270 @@ +import XCTest +@testable import fearless + +final class BitcoinSendPlannerTests: XCTestCase { + func testPlansUnsignedSpendWithEstimatedFeeAndConfirmedUtxos() async throws { + let client = FakeBitcoinIndexerClient( + estimates: ["2": 2], + utxosByAddress: [Self.mainnetAddress: [Self.utxo(valueSats: 100_000)]] + ) + let planner = BitcoinSendPlanner(client: client) + + let plan = try await planner.plan( + amountSats: 50_000, + sources: [BitcoinUtxoSource(address: Self.mainnetAddress, derivationPath: "m/84'/0'/0'/0/0")], + recipientAddress: Self.recipientAddress + ) + + XCTAssertEqual(plan.amountSats, 50_000) + XCTAssertEqual(plan.recipientAddress, Self.recipientAddress) + XCTAssertEqual(plan.changeAddress, Self.mainnetAddress) + XCTAssertEqual(plan.feeRateSatPerVbyte, 2) + XCTAssertEqual(plan.feeTargetBlocks, 2) + XCTAssertEqual(plan.sourceAddresses, [Self.mainnetAddress]) + XCTAssertEqual(plan.selectedUtxos.count, 1) + XCTAssertEqual(plan.feeSats, 282) + XCTAssertEqual(plan.changeSats, 49_718) + XCTAssertEqual(plan.absorbedDustSats, 0) + XCTAssertEqual(client.feeEstimateNetworks, [.mainnet]) + XCTAssertEqual(client.utxoAddressCalls, [Self.mainnetAddress]) + } + + func testUsesManualFeeRateWithoutFetchingFeeEstimates() async throws { + let client = FakeBitcoinIndexerClient( + utxosByAddress: [Self.mainnetAddress: [Self.utxo(valueSats: 50_110)]] + ) + let planner = BitcoinSendPlanner(client: client) + + let plan = try await planner.plan( + amountSats: 50_000, + sources: [BitcoinUtxoSource(address: Self.mainnetAddress)], + recipientAddress: Self.recipientAddress, + feeRateSatPerVbyte: 1 + ) + + XCTAssertEqual(plan.feeRateSatPerVbyte, 1) + XCTAssertNil(plan.feeTargetBlocks) + XCTAssertEqual(plan.feeSats, 110) + XCTAssertTrue(client.feeEstimateNetworks.isEmpty) + } + + func testRequiresExplicitOptInForUnconfirmedUtxos() async throws { + let client = FakeBitcoinIndexerClient( + utxosByAddress: [Self.mainnetAddress: [Self.utxo(valueSats: 100_000, confirmed: false)]] + ) + let planner = BitcoinSendPlanner(client: client) + + await assertPlannerError(.noSpendableUtxos) { + _ = try await planner.plan( + amountSats: 50_000, + sources: [BitcoinUtxoSource(address: Self.mainnetAddress)], + recipientAddress: Self.recipientAddress, + feeRateSatPerVbyte: 1 + ) + } + + let plan = try await planner.plan( + amountSats: 50_000, + sources: [BitcoinUtxoSource(address: Self.mainnetAddress)], + recipientAddress: Self.recipientAddress, + feeRateSatPerVbyte: 1, + includeUnconfirmed: true + ) + + XCTAssertTrue(plan.includeUnconfirmed) + XCTAssertEqual(plan.selectedUtxos.count, 1) + } + + func testRejectsInvalidPlannerInputsBeforeNetworkCalls() async { + let client = FakeBitcoinIndexerClient() + let planner = BitcoinSendPlanner(client: client) + + await assertPlannerError(.sourcesRequired) { + _ = try await planner.plan( + amountSats: 1_000, + sources: [], + recipientAddress: Self.recipientAddress, + feeRateSatPerVbyte: 1 + ) + } + await assertPlannerError(.tooManySources) { + _ = try await planner.plan( + amountSats: 1_000, + sources: (0...100).map { + BitcoinUtxoSource(address: "bc1q\(String(format: "%03d", $0))aaaaaaaaaaaaaa") + }, + recipientAddress: Self.recipientAddress, + feeRateSatPerVbyte: 1 + ) + } + await assertPlannerError(.invalidSourceAddress) { + _ = try await planner.plan( + amountSats: 1_000, + sources: [BitcoinUtxoSource(address: Self.testnetAddress)], + recipientAddress: Self.recipientAddress, + feeRateSatPerVbyte: 1 + ) + } + await assertPlannerError(.duplicateSourceAddress) { + _ = try await planner.plan( + amountSats: 1_000, + sources: [ + BitcoinUtxoSource(address: Self.mainnetAddress), + BitcoinUtxoSource(address: Self.mainnetAddress.uppercased()) + ], + recipientAddress: Self.recipientAddress, + feeRateSatPerVbyte: 1 + ) + } + await assertPlannerError(.invalidDerivationPath) { + _ = try await planner.plan( + amountSats: 1_000, + sources: [BitcoinUtxoSource(address: Self.mainnetAddress, derivationPath: "../bad")], + recipientAddress: Self.recipientAddress, + feeRateSatPerVbyte: 1 + ) + } + await assertPlannerError(.invalidRecipientAddress) { + _ = try await planner.plan( + amountSats: 1_000, + sources: [BitcoinUtxoSource(address: Self.mainnetAddress)], + recipientAddress: Self.testnetAddress, + feeRateSatPerVbyte: 1 + ) + } + await assertPlannerError(.invalidChangeAddress) { + _ = try await planner.plan( + amountSats: 1_000, + sources: [BitcoinUtxoSource(address: Self.mainnetAddress)], + recipientAddress: Self.recipientAddress, + changeAddress: Self.testnetAddress, + feeRateSatPerVbyte: 1 + ) + } + await assertPlannerError(.invalidFeeRate) { + _ = try await planner.plan( + amountSats: 1_000, + sources: [BitcoinUtxoSource(address: Self.mainnetAddress)], + recipientAddress: Self.recipientAddress, + feeRateSatPerVbyte: 0 + ) + } + XCTAssertTrue(client.utxoAddressCalls.isEmpty) + XCTAssertTrue(client.feeEstimateNetworks.isEmpty) + } + + func testPropagatesSelectionFailuresFromSpendPlanning() async { + let client = FakeBitcoinIndexerClient( + utxosByAddress: [Self.mainnetAddress: [Self.utxo(valueSats: 30_000)]] + ) + let planner = BitcoinSendPlanner(client: client) + + do { + _ = try await planner.plan( + amountSats: 50_000, + sources: [BitcoinUtxoSource(address: Self.mainnetAddress)], + recipientAddress: Self.recipientAddress, + feeRateSatPerVbyte: 1 + ) + XCTFail("Expected insufficient funds to be rejected") + } catch { + XCTAssertEqual(error as? BitcoinUtxoSelectionError, .insufficientFunds) + } + } + + private func assertPlannerError( + _ expected: BitcoinSendPlannerError, + file: StaticString = #filePath, + line: UInt = #line, + block: () async throws -> Void + ) async { + do { + try await block() + XCTFail("Expected Bitcoin send planner error \(expected)", file: file, line: line) + } catch { + XCTAssertEqual(error as? BitcoinSendPlannerError, expected, file: file, line: line) + } + } + + private final class FakeBitcoinIndexerClient: BitcoinIndexerClientProtocol { + private let estimates: [String: Double] + private let utxosByAddress: [String: [BitcoinEsploraUtxo]] + private(set) var feeEstimateNetworks: [BitcoinIndexerNetwork] = [] + private(set) var utxoAddressCalls: [String] = [] + + init( + estimates: [String: Double] = [:], + utxosByAddress: [String: [BitcoinEsploraUtxo]] = [:] + ) { + self.estimates = estimates + self.utxosByAddress = utxosByAddress + } + + func feeEstimates( + network: BitcoinIndexerNetwork, + baseURL: String? + ) async throws -> [String: Double] { + feeEstimateNetworks.append(network) + return estimates + } + + func utxos( + address: String, + network: BitcoinIndexerNetwork, + baseURL: String? + ) async throws -> [BitcoinEsploraUtxo] { + utxoAddressCalls.append(address) + return utxosByAddress[address] ?? [] + } + + func address( + address: String, + network: BitcoinIndexerNetwork, + baseURL: String? + ) async throws -> BitcoinEsploraAddress { + throw UnexpectedEndpointError() + } + + func transactions( + address: String, + network: BitcoinIndexerNetwork, + baseURL: String?, + lastSeenTxid: String?, + mempool: Bool + ) async throws -> [BitcoinEsploraTransaction] { + throw UnexpectedEndpointError() + } + + func broadcastTransaction( + txHex: String, + network: BitcoinIndexerNetwork, + baseURL: String? + ) async throws -> String { + throw UnexpectedEndpointError() + } + } + + private struct UnexpectedEndpointError: Error {} + + private static let mainnetAddress = "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu" + private static let recipientAddress = "bc1q6rz28mcfaxtmd6v789l9rrlrusdprr9pqcpvkl" + private static let testnetAddress = "tb1q6rz28mcfaxtmd6v789l9rrlrusdprr9pqcpvkl" + + private static func utxo( + valueSats: Int64, + confirmed: Bool = true, + txid: String = String(repeating: "11", count: 32) + ) -> BitcoinEsploraUtxo { + BitcoinEsploraUtxo( + txid: txid, + vout: 0, + value: valueSats, + status: BitcoinEsploraTxStatus( + confirmed: confirmed, + blockHash: nil, + blockHeight: nil, + blockTime: nil + ) + ) + } +} diff --git a/fearlessTests/BitcoinSendServiceTests.swift b/fearlessTests/BitcoinSendServiceTests.swift new file mode 100644 index 0000000000..040bf273a4 --- /dev/null +++ b/fearlessTests/BitcoinSendServiceTests.swift @@ -0,0 +1,298 @@ +import XCTest +import BigInt +import SSFModels +@testable import fearless + +final class BitcoinSendServiceTests: XCTestCase { + func testPreparesSignedTransactionFromSelectedUtxosWithoutBroadcasting() async throws { + let client = FakeBitcoinIndexerClient() + let prepared = try await BitcoinSendService(client: client).prepare(Self.request()) + + XCTAssertEqual(prepared.plan.feeSats, 282) + XCTAssertEqual(prepared.plan.changeSats, 49_718) + XCTAssertEqual(prepared.transaction.txid, Self.expectedTxid) + XCTAssertEqual(prepared.transaction.txHex, Self.expectedTxHex) + XCTAssertNil(client.lastBroadcastTxHex) + } + + func testSendsSignedTransactionAndRequiresMatchingBroadcastTxid() async throws { + let client = FakeBitcoinIndexerClient(broadcastResponse: Self.expectedTxid.uppercased()) + let result = try await BitcoinSendService(client: client).send(Self.request(baseURL: "https://bitcoin.example/api")) + + XCTAssertEqual(result.broadcastTxid, Self.expectedTxid) + XCTAssertEqual(result.prepared.transaction.txid, Self.expectedTxid) + XCTAssertEqual(client.lastBroadcastTxHex, Self.expectedTxHex) + XCTAssertEqual(client.lastBroadcastNetwork, .mainnet) + XCTAssertEqual(client.lastBroadcastBaseURL, "https://bitcoin.example/api") + } + + func testRejectsBroadcastTxidMismatches() async { + let client = FakeBitcoinIndexerClient(broadcastResponse: String(repeating: "22", count: 32)) + + do { + _ = try await BitcoinSendService(client: client).send(Self.request()) + XCTFail("Expected Bitcoin send service txid mismatch") + } catch { + XCTAssertEqual(error as? BitcoinSendServiceError, .broadcastTxidMismatch) + } + } + + func testBitcoinTransferServiceEstimatesFeeAndSendsFromUniversalWalletAccount() async throws { + let chain = Self.bitcoinChain(historyBaseURL: "https://bitcoin.example/api") + let wallet = try Self.walletWithBitcoinAccount( + chainId: UniversalWalletRegistry.bitcoinMainnet.id, + network: .mainnet + ) + let client = FakeBitcoinIndexerClient(broadcastResponse: Self.expectedTxid.uppercased()) + let service = BitcoinTransferService( + wallet: wallet, + chain: chain, + client: client, + mnemonicProvider: FakeBitcoinMnemonicProvider(mnemonic: Self.mnemonic) + ) + let transfer = Self.transfer(chain: chain) + + let fee = try await service.estimateFee(for: transfer) + let txid = try await service.submit(transfer: transfer) + + XCTAssertEqual(fee, BigUInt(282)) + XCTAssertEqual(txid, Self.expectedTxid) + XCTAssertEqual(client.lastFeeNetwork, .mainnet) + XCTAssertEqual(client.lastFeeBaseURL, "https://bitcoin.example/api") + XCTAssertEqual(client.lastUtxosAddress, Self.mainnetAddress) + XCTAssertEqual(client.lastUtxosNetwork, .mainnet) + XCTAssertEqual(client.lastUtxosBaseURL, "https://bitcoin.example/api") + XCTAssertEqual(client.lastBroadcastTxHex, Self.expectedTxHex) + XCTAssertEqual(client.lastBroadcastNetwork, .mainnet) + XCTAssertEqual(client.lastBroadcastBaseURL, "https://bitcoin.example/api") + } + + func testBitcoinTransferServiceRejectsMissingMnemonicRootMaterial() async throws { + let chain = Self.bitcoinChain() + let wallet = try Self.walletWithBitcoinAccount(chainId: UniversalWalletRegistry.bitcoinMainnet.chainId) + let client = FakeBitcoinIndexerClient() + let service = BitcoinTransferService( + wallet: wallet, + chain: chain, + client: client, + mnemonicProvider: FakeBitcoinMnemonicProvider(mnemonic: nil) + ) + + do { + _ = try await service.submit(transfer: Self.transfer(chain: chain)) + XCTFail("Expected Bitcoin transfer service to reject missing mnemonic material") + } catch TransferServiceError.transferFailed(let reason) { + XCTAssertTrue(reason.contains("mnemonic")) + } catch { + XCTFail("Unexpected error: \(error)") + } + XCTAssertNil(client.lastFeeNetwork) + XCTAssertNil(client.lastUtxosAddress) + XCTAssertNil(client.lastBroadcastTxHex) + } + + func testBitcoinTransferServiceRejectsMnemonicMismatchBeforeIndexerCalls() async throws { + let chain = Self.bitcoinChain(historyBaseURL: "https://bitcoin.example/api") + let wallet = try Self.walletWithBitcoinAccount(chainId: UniversalWalletRegistry.bitcoinMainnet.chainId) + let client = FakeBitcoinIndexerClient() + let service = BitcoinTransferService( + wallet: wallet, + chain: chain, + client: client, + mnemonicProvider: FakeBitcoinMnemonicProvider(mnemonic: Self.otherMnemonic) + ) + + do { + _ = try await service.submit(transfer: Self.transfer(chain: chain)) + XCTFail("Expected Bitcoin transfer service to reject mismatched mnemonic material") + } catch TransferServiceError.transferFailed(let reason) { + XCTAssertTrue(reason.contains("does not match selected wallet")) + } catch { + XCTFail("Unexpected error: \(error)") + } + XCTAssertNil(client.lastFeeNetwork) + XCTAssertNil(client.lastUtxosAddress) + XCTAssertNil(client.lastBroadcastTxHex) + } + + private final class FakeBitcoinIndexerClient: BitcoinIndexerClientProtocol { + private let broadcastResponse: String + private(set) var lastBroadcastTxHex: String? + private(set) var lastBroadcastNetwork: BitcoinIndexerNetwork? + private(set) var lastBroadcastBaseURL: String? + private(set) var lastFeeNetwork: BitcoinIndexerNetwork? + private(set) var lastFeeBaseURL: String? + private(set) var lastUtxosAddress: String? + private(set) var lastUtxosNetwork: BitcoinIndexerNetwork? + private(set) var lastUtxosBaseURL: String? + + init(broadcastResponse: String = BitcoinSendServiceTests.expectedTxid) { + self.broadcastResponse = broadcastResponse + } + + func address( + address: String, + network: BitcoinIndexerNetwork, + baseURL: String? + ) async throws -> BitcoinEsploraAddress { + fatalError("Not used") + } + + func utxos( + address: String, + network: BitcoinIndexerNetwork, + baseURL: String? + ) async throws -> [BitcoinEsploraUtxo] { + lastUtxosAddress = address + lastUtxosNetwork = network + lastUtxosBaseURL = baseURL + + return [ + BitcoinEsploraUtxo( + txid: BitcoinSendServiceTests.txid, + vout: 1, + value: 100_000, + status: BitcoinEsploraTxStatus(confirmed: true, blockHash: nil, blockHeight: nil, blockTime: nil) + ) + ] + } + + func transactions( + address: String, + network: BitcoinIndexerNetwork, + baseURL: String?, + lastSeenTxid: String?, + mempool: Bool + ) async throws -> [BitcoinEsploraTransaction] { + return [] + } + + func feeEstimates( + network: BitcoinIndexerNetwork, + baseURL: String? + ) async throws -> [String: Double] { + lastFeeNetwork = network + lastFeeBaseURL = baseURL + + return ["2": 2] + } + + func broadcastTransaction( + txHex: String, + network: BitcoinIndexerNetwork, + baseURL: String? + ) async throws -> String { + lastBroadcastTxHex = txHex + lastBroadcastNetwork = network + lastBroadcastBaseURL = baseURL + + return broadcastResponse + } + } + + private struct FakeBitcoinMnemonicProvider: BitcoinMnemonicProviding { + let mnemonic: String? + + func mnemonic(for _: MetaAccountModel, chain _: ChainModel) throws -> String? { + mnemonic + } + } + + private static let mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + private static let otherMnemonic = "legal winner thank year wave sausage worth useful legal winner thank yellow" + private static let mainnetAddress = "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu" + private static let mainnetRecipient = "bc1qslk39wvggqa0vl8nd6jckaz54dw3vk45c5w60m" + private static let txid = String(repeating: "11", count: 32) + private static let expectedTxid = "94c9b9d5070f24e06725b1000d9b1a0d46473d07b59088aca35e3d3da345023d" + private static let expectedTxHex = "0200000000010111111111111111111111111111111111111111111111111111111111111111110100000000ffffffff0250c300000000000016001487ed12b988403af67cf36ea58b7454ab5d165ab436c2000000000000160014c0cebcd6c3d3ca8c75dc5ec62ebe55330ef910e202473044022009ec0c24a20c4346c6516065723e2e83e6a7e4dd278fb66e27f36d9108d7ef4c022077f115bfbd68a2bc7a4c100766d9cceaf5300bcfcdc775be0248e7fb5a58216801210330d54fd0dd420a6e5f8d3624f5f3482cae350f79d5f0753bf5beef9c2d91af3c00000000" + private static let asset = AssetModel( + id: "BTC", + name: "Bitcoin", + symbol: "BTC", + precision: 8, + isUtility: true, + isNative: true + ) + + private static func request(baseURL: String? = nil) -> BitcoinSendRequest { + BitcoinSendRequest( + mnemonic: mnemonic, + amountSats: 50_000, + sources: [BitcoinUtxoSource(address: mainnetAddress)], + recipientAddress: mainnetRecipient, + changeAddress: mainnetAddress, + feeRateSatPerVbyte: 2, + baseURL: baseURL + ) + } + + private static func transfer(chain: ChainModel) -> Transfer { + Transfer( + chainAsset: ChainAsset(chain: chain, asset: asset), + amount: BigUInt(50_000), + receiver: mainnetRecipient, + tip: nil, + appId: nil + ) + } + + private static func walletWithBitcoinAccount( + chainId: String, + network: BitcoinKeyDerivation.Network = .mainnet + ) throws -> MetaAccountModel { + let account = try BitcoinKeyDerivation.deriveAccount(mnemonic: mnemonic, network: network) + let chainAccount = ChainAccountModel( + chainId: chainId, + accountId: account.publicKey, + publicKey: account.publicKey, + cryptoType: CryptoType.ecdsa.rawValue, + ethereumBased: false + ) + + return AccountGenerator.generateMetaAccount(with: [chainAccount]) + } + + private static func bitcoinChain( + chainId: String = UniversalWalletRegistry.bitcoinMainnet.chainId, + historyBaseURL: String? = nil + ) -> ChainModel { + let externalApi = historyBaseURL.map { + ChainModel.ExternalApiSet( + staking: nil, + history: ChainModel.BlockExplorer( + type: "subsquid", + url: URL(string: $0)! + ), + crowdloans: nil, + explorers: nil + ) + } + + return ChainModel( + rank: nil, + disabled: false, + chainId: chainId, + parentId: nil, + paraId: nil, + name: "Bitcoin", + assets: [asset], + xcm: nil, + nodes: [ + ChainNodeModel( + url: URL(string: "https://bitcoin.node.example")!, + name: "Bitcoin", + apikey: nil + ) + ], + addressPrefix: 0, + types: nil, + icon: nil, + options: nil, + externalApi: externalApi, + selectedNode: nil, + customNodes: nil, + iosMinAppVersion: nil, + identityChain: nil + ) + } +} diff --git a/fearlessTests/BitcoinTransactionBroadcasterTests.swift b/fearlessTests/BitcoinTransactionBroadcasterTests.swift new file mode 100644 index 0000000000..844a3764ad --- /dev/null +++ b/fearlessTests/BitcoinTransactionBroadcasterTests.swift @@ -0,0 +1,114 @@ +import XCTest +@testable import fearless + +final class BitcoinTransactionBroadcasterTests: XCTestCase { + func testNormalizesTransactionHexDelegatesSelectedNetworkAndValidatesTxidResponse() async throws { + let client = FakeBitcoinIndexerClient(broadcastResponse: Self.txid.uppercased()) + let broadcaster = BitcoinTransactionBroadcaster(client: client) + + let result = try await broadcaster.broadcast( + txHex: " 00AA ", + network: .testnet, + baseURL: "https://bitcoin.example/api" + ) + + XCTAssertEqual(result.network, .testnet) + XCTAssertEqual(result.txHex, "00aa") + XCTAssertEqual(result.txid, Self.txid) + XCTAssertEqual(client.lastTxHex, "00aa") + XCTAssertEqual(client.lastNetwork, .testnet) + XCTAssertEqual(client.lastBaseURL, "https://bitcoin.example/api") + } + + func testRejectsInvalidTransactionHexBeforeCallingIndexer() async { + let client = FakeBitcoinIndexerClient(broadcastResponse: Self.txid) + let broadcaster = BitcoinTransactionBroadcaster(client: client) + + await assertBroadcastError(.invalidTxHex) { + _ = try await broadcaster.broadcast(txHex: "00gg") + } + XCTAssertNil(client.lastTxHex) + } + + func testRejectsMalformedTxidReturnedByIndexer() async { + let broadcaster = BitcoinTransactionBroadcaster( + client: FakeBitcoinIndexerClient(broadcastResponse: "not-a-txid") + ) + + await assertBroadcastError(.invalidTxidResponse) { + _ = try await broadcaster.broadcast(txHex: "00aa") + } + } + + private func assertBroadcastError( + _ expected: BitcoinBroadcastError, + file: StaticString = #filePath, + line: UInt = #line, + block: () async throws -> Void + ) async { + do { + try await block() + XCTFail("Expected Bitcoin broadcast error \(expected)", file: file, line: line) + } catch { + XCTAssertEqual(error as? BitcoinBroadcastError, expected, file: file, line: line) + } + } + + private final class FakeBitcoinIndexerClient: BitcoinIndexerClientProtocol { + private let broadcastResponse: String + private(set) var lastTxHex: String? + private(set) var lastNetwork: BitcoinIndexerNetwork? + private(set) var lastBaseURL: String? + + init(broadcastResponse: String) { + self.broadcastResponse = broadcastResponse + } + + func address( + address: String, + network: BitcoinIndexerNetwork, + baseURL: String? + ) async throws -> BitcoinEsploraAddress { + fatalError("Not used") + } + + func utxos( + address: String, + network: BitcoinIndexerNetwork, + baseURL: String? + ) async throws -> [BitcoinEsploraUtxo] { + fatalError("Not used") + } + + func transactions( + address: String, + network: BitcoinIndexerNetwork, + baseURL: String?, + lastSeenTxid: String?, + mempool: Bool + ) async throws -> [BitcoinEsploraTransaction] { + fatalError("Not used") + } + + func feeEstimates( + network: BitcoinIndexerNetwork, + baseURL: String? + ) async throws -> [String: Double] { + fatalError("Not used") + } + + func broadcastTransaction( + txHex: String, + network: BitcoinIndexerNetwork, + baseURL: String? + ) async throws -> String { + lastTxHex = txHex + lastNetwork = network + lastBaseURL = baseURL + + return broadcastResponse + } + } + + private static let txid = String(repeating: "11", count: 32) +} diff --git a/fearlessTests/BitcoinTransactionBuilderTests.swift b/fearlessTests/BitcoinTransactionBuilderTests.swift new file mode 100644 index 0000000000..17923cb3f8 --- /dev/null +++ b/fearlessTests/BitcoinTransactionBuilderTests.swift @@ -0,0 +1,245 @@ +import XCTest +@testable import fearless + +final class BitcoinTransactionBuilderTests: XCTestCase { + func testBuildsAndSignsMainnetP2wpkhTransactionMatchingWebVector() throws { + let result = try BitcoinTransactionBuilder.buildP2wpkhTransaction( + mnemonic: Self.mnemonic, + inputs: [ + BitcoinSpendableUtxo( + address: Self.mainnetAddress, + txid: Self.txid, + valueSats: 100_000, + vout: 1 + ) + ], + outputs: [BitcoinPaymentOutput(address: Self.mainnetRecipient, valueSats: 50_000)], + changeAddress: Self.mainnetAddress, + feeRateSatPerVbyte: 2 + ) + + XCTAssertEqual(result.feeSats, 282) + XCTAssertEqual(result.changeSats, 49_718) + XCTAssertEqual(result.inputTotalSats, 100_000) + XCTAssertEqual(result.outputTotalSats, 50_000) + XCTAssertEqual(result.vsize, 141) + XCTAssertEqual(result.txid, Self.expectedTxid) + XCTAssertEqual(result.txHex, Self.expectedTxHex) + } + + func testSupportsExplicitFeeSpendWithNoChangeOutput() throws { + let result = try BitcoinTransactionBuilder.buildP2wpkhTransaction( + mnemonic: Self.mnemonic, + inputs: [ + BitcoinSpendableUtxo( + txid: String(repeating: "22", count: 32), + valueSats: 51_000, + vout: 0 + ) + ], + outputs: [BitcoinPaymentOutput(address: Self.mainnetRecipient, valueSats: 50_000)], + feeSats: 1_000 + ) + + XCTAssertEqual(result.changeSats, 0) + XCTAssertEqual(result.feeSats, 1_000) + XCTAssertEqual(result.outputTotalSats, 50_000) + } + + func testRejectsUnsafeUtxosOutputsFeesAndChangeHandlingBeforeSigning() { + let baseInput = BitcoinSpendableUtxo(txid: Self.txid, valueSats: 100_000, vout: 0) + let baseOutput = BitcoinPaymentOutput(address: Self.mainnetRecipient, valueSats: 50_000) + + assertTransactionError(.inputsRequired) { + _ = try BitcoinTransactionBuilder.buildP2wpkhTransaction( + mnemonic: Self.mnemonic, + inputs: [], + outputs: [baseOutput], + feeSats: 1_000 + ) + } + assertTransactionError(.invalidTxid) { + _ = try BitcoinTransactionBuilder.buildP2wpkhTransaction( + mnemonic: Self.mnemonic, + inputs: [BitcoinSpendableUtxo(txid: "zz", valueSats: 100_000, vout: 0)], + outputs: [baseOutput], + feeSats: 1_000 + ) + } + assertTransactionError(.duplicateUtxo) { + _ = try BitcoinTransactionBuilder.buildP2wpkhTransaction( + mnemonic: Self.mnemonic, + inputs: [baseInput, baseInput], + outputs: [baseOutput], + feeSats: 1_000 + ) + } + assertTransactionError(.satoshiOverflow) { + _ = try BitcoinTransactionBuilder.buildP2wpkhTransaction( + mnemonic: Self.mnemonic, + inputs: [ + BitcoinSpendableUtxo(txid: Self.txid, valueSats: BitcoinUtxoSelector.maxSatoshi, vout: 0), + BitcoinSpendableUtxo(txid: String(repeating: "22", count: 32), valueSats: 1, vout: 0) + ], + outputs: [baseOutput], + feeSats: 1_000 + ) + } + assertTransactionError(.invalidOutputAddress) { + _ = try BitcoinTransactionBuilder.buildP2wpkhTransaction( + mnemonic: Self.mnemonic, + inputs: [baseInput], + outputs: [BitcoinPaymentOutput(address: Self.testnetAddress, valueSats: 50_000)], + feeSats: 1_000 + ) + } + assertTransactionError(.satoshiOverflow) { + _ = try BitcoinTransactionBuilder.buildP2wpkhTransaction( + mnemonic: Self.mnemonic, + inputs: [BitcoinSpendableUtxo(txid: Self.txid, valueSats: BitcoinUtxoSelector.maxSatoshi, vout: 0)], + outputs: [ + BitcoinPaymentOutput(address: Self.mainnetRecipient, valueSats: BitcoinUtxoSelector.maxSatoshi), + BitcoinPaymentOutput(address: Self.mainnetAddress, valueSats: BitcoinUtxoSelector.bitcoinP2wpkhDustSats) + ], + feeSats: 1_000 + ) + } + assertTransactionError(.invalidOutputValue) { + _ = try BitcoinTransactionBuilder.buildP2wpkhTransaction( + mnemonic: Self.mnemonic, + inputs: [baseInput], + outputs: [ + BitcoinPaymentOutput( + address: Self.mainnetRecipient, + valueSats: BitcoinUtxoSelector.bitcoinP2wpkhDustSats - 1 + ) + ], + feeSats: 1_000 + ) + } + assertTransactionError(.invalidFee) { + _ = try BitcoinTransactionBuilder.buildP2wpkhTransaction( + mnemonic: Self.mnemonic, + inputs: [baseInput], + outputs: [baseOutput], + feeSats: 0 + ) + } + assertTransactionError(.invalidFeeRate) { + _ = try BitcoinTransactionBuilder.buildP2wpkhTransaction( + mnemonic: Self.mnemonic, + inputs: [baseInput], + outputs: [baseOutput], + feeRateSatPerVbyte: 0 + ) + } + assertTransactionError(.insufficientFunds) { + _ = try BitcoinTransactionBuilder.buildP2wpkhTransaction( + mnemonic: Self.mnemonic, + inputs: [BitcoinSpendableUtxo(txid: Self.txid, valueSats: 50_500, vout: 0)], + outputs: [baseOutput], + feeSats: 1_000 + ) + } + assertTransactionError(.changeAddressRequired) { + _ = try BitcoinTransactionBuilder.buildP2wpkhTransaction( + mnemonic: Self.mnemonic, + inputs: [BitcoinSpendableUtxo(txid: Self.txid, valueSats: 52_000, vout: 0)], + outputs: [baseOutput], + feeSats: 1_000 + ) + } + assertTransactionError(.changeBelowDust) { + _ = try BitcoinTransactionBuilder.buildP2wpkhTransaction( + mnemonic: Self.mnemonic, + inputs: [BitcoinSpendableUtxo(txid: Self.txid, valueSats: 50_900, vout: 0)], + outputs: [baseOutput], + changeAddress: Self.mainnetAddress, + feeSats: 800 + ) + } + } + + func testRejectsUtxosThatDoNotMatchDerivedBip84KeyOrWitnessScript() { + assertTransactionError(.utxoAddressMismatch) { + _ = try BitcoinTransactionBuilder.buildP2wpkhTransaction( + mnemonic: Self.mnemonic, + inputs: [ + BitcoinSpendableUtxo( + address: Self.mainnetRecipient, + txid: Self.txid, + valueSats: 51_000, + vout: 0 + ) + ], + outputs: [BitcoinPaymentOutput(address: Self.mainnetRecipient, valueSats: 50_000)], + feeSats: 1_000 + ) + } + + assertTransactionError(.utxoScriptMismatch) { + _ = try BitcoinTransactionBuilder.buildP2wpkhTransaction( + mnemonic: Self.mnemonic, + inputs: [ + BitcoinSpendableUtxo( + scriptPubKey: "0014\(String(repeating: "00", count: 20))", + txid: Self.txid, + valueSats: 51_000, + vout: 0 + ) + ], + outputs: [BitcoinPaymentOutput(address: Self.mainnetRecipient, valueSats: 50_000)], + feeSats: 1_000 + ) + } + + assertTransactionError(.invalidDerivationPath) { + _ = try BitcoinTransactionBuilder.buildP2wpkhTransaction( + mnemonic: Self.mnemonic, + inputs: [ + BitcoinSpendableUtxo( + derivationPath: "m/84'/2147483648'/0'/0/0", + txid: Self.txid, + valueSats: 51_000, + vout: 0 + ) + ], + outputs: [BitcoinPaymentOutput(address: Self.mainnetRecipient, valueSats: 50_000)], + feeSats: 1_000 + ) + } + } + + func testRejectsEmptyMnemonicBeforeSigning() { + assertTransactionError(.invalidMnemonic) { + _ = try BitcoinTransactionBuilder.buildP2wpkhTransaction( + mnemonic: "", + inputs: [BitcoinSpendableUtxo(txid: Self.txid, valueSats: 51_000, vout: 0)], + outputs: [BitcoinPaymentOutput(address: Self.mainnetRecipient, valueSats: 50_000)], + feeSats: 1_000 + ) + } + } + + private func assertTransactionError( + _ expected: BitcoinTransactionError, + file: StaticString = #filePath, + line: UInt = #line, + block: () throws -> Void + ) { + do { + try block() + XCTFail("Expected Bitcoin transaction error \(expected)", file: file, line: line) + } catch { + XCTAssertEqual(error as? BitcoinTransactionError, expected, file: file, line: line) + } + } + + private static let mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + private static let mainnetAddress = "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu" + private static let mainnetRecipient = "bc1qslk39wvggqa0vl8nd6jckaz54dw3vk45c5w60m" + private static let testnetAddress = "tb1q6rz28mcfaxtmd6v789l9rrlrusdprr9pqcpvkl" + private static let txid = String(repeating: "11", count: 32) + private static let expectedTxid = "94c9b9d5070f24e06725b1000d9b1a0d46473d07b59088aca35e3d3da345023d" + private static let expectedTxHex = "0200000000010111111111111111111111111111111111111111111111111111111111111111110100000000ffffffff0250c300000000000016001487ed12b988403af67cf36ea58b7454ab5d165ab436c2000000000000160014c0cebcd6c3d3ca8c75dc5ec62ebe55330ef910e202473044022009ec0c24a20c4346c6516065723e2e83e6a7e4dd278fb66e27f36d9108d7ef4c022077f115bfbd68a2bc7a4c100766d9cceaf5300bcfcdc775be0248e7fb5a58216801210330d54fd0dd420a6e5f8d3624f5f3482cae350f79d5f0753bf5beef9c2d91af3c00000000" +} diff --git a/fearlessTests/BitcoinTransactionHistorySyncTests.swift b/fearlessTests/BitcoinTransactionHistorySyncTests.swift new file mode 100644 index 0000000000..c06231fc8e --- /dev/null +++ b/fearlessTests/BitcoinTransactionHistorySyncTests.swift @@ -0,0 +1,302 @@ +import XCTest +@testable import fearless + +final class BitcoinTransactionHistorySyncTests: XCTestCase { + func testNormalizesOutgoingAndIncomingTransactionsFromEsplora() async throws { + let client = FakeBitcoinIndexerClient( + transactions: [ + Self.transaction( + txid: Self.txid1, + fee: 141, + status: BitcoinEsploraTxStatus( + confirmed: true, + blockHash: Self.blockHash, + blockHeight: 100, + blockTime: 1_710_000_000 + ), + vin: [Self.input(Self.mainnetAddress, 100_000)], + vout: [Self.output(Self.counterparty, 60_000), Self.output(Self.mainnetAddress, 39_859)] + ), + Self.transaction( + txid: Self.txid2, + fee: 200, + status: BitcoinEsploraTxStatus(confirmed: false, blockHash: nil, blockHeight: nil, blockTime: nil), + vin: [Self.input(Self.counterparty, 75_200)], + vout: [Self.output(Self.mainnetAddress, 75_000)] + ), + Self.transaction( + txid: Self.txid3, + vin: [Self.input(Self.counterparty, 10_000)], + vout: [Self.output(Self.counterparty, 9_900)] + ) + ] + ) + + let history = try await BitcoinTransactionHistorySync(client: client).history( + address: Self.mainnetAddress, + baseURL: "https://bitcoin.example/api", + lastSeenTxid: Self.txid0.uppercased() + ) + + XCTAssertEqual(history.address, Self.mainnetAddress) + XCTAssertEqual(history.network, .mainnet) + XCTAssertEqual(history.nextLastSeenTxid, Self.txid3) + XCTAssertEqual(client.lastAddress, Self.mainnetAddress) + XCTAssertEqual(client.lastBaseURL, "https://bitcoin.example/api") + XCTAssertEqual(client.lastSeenTxid, Self.txid0.uppercased()) + XCTAssertEqual(history.entries.count, 2) + + let outgoing = history.entries[0] + XCTAssertEqual(outgoing.txid, Self.txid1) + XCTAssertEqual(outgoing.blockHash, Self.blockHash) + XCTAssertEqual(outgoing.blockHeight, 100) + XCTAssertEqual(outgoing.timestamp, 1_710_000_000) + XCTAssertEqual(outgoing.amountSats, 60_000) + XCTAssertEqual(outgoing.feeSats, 141) + XCTAssertEqual(outgoing.from, Self.mainnetAddress) + XCTAssertEqual(outgoing.to, Self.counterparty) + XCTAssertTrue(outgoing.outgoing) + XCTAssertTrue(outgoing.confirmed) + + let incoming = history.entries[1] + XCTAssertEqual(incoming.txid, Self.txid2) + XCTAssertEqual(incoming.blockHash, Self.txid2) + XCTAssertEqual(incoming.amountSats, 75_000) + XCTAssertEqual(incoming.feeSats, 0) + XCTAssertEqual(incoming.from, Self.counterparty) + XCTAssertEqual(incoming.to, Self.mainnetAddress) + XCTAssertFalse(incoming.outgoing) + XCTAssertFalse(incoming.confirmed) + } + + func testFiltersMalformedNoMovementAndNonPositiveAmountTransactions() async throws { + let client = FakeBitcoinIndexerClient( + transactions: [ + Self.transaction( + txid: Self.txid1, + vin: [BitcoinEsploraTransactionInput(prevout: BitcoinEsploraTransactionOutput(scriptPubKeyAddress: Self.mainnetAddress, value: nil))], + vout: [Self.output(Self.mainnetAddress, 900)] + ), + Self.transaction( + txid: Self.txid2, + fee: 141, + vin: [Self.input(Self.mainnetAddress, 1_000)], + vout: [Self.output(Self.mainnetAddress, 859)] + ), + Self.transaction( + txid: "not-a-txid", + vin: [Self.input(Self.counterparty, 2_000)], + vout: [Self.output(Self.mainnetAddress, 1_000)] + ) + ] + ) + + let history = try await BitcoinTransactionHistorySync(client: client).history(address: Self.mainnetAddress) + + XCTAssertEqual(history.entries.count, 0) + XCTAssertEqual(history.nextLastSeenTxid, Self.txid2) + } + + func testRejectsWrongNetworkAddressBeforeFetchingHistory() async { + let client = FakeBitcoinIndexerClient(transactions: []) + + do { + _ = try await BitcoinTransactionHistorySync(client: client).history(address: Self.testnetAddress) + XCTFail("Expected wrong-network Bitcoin address to be rejected") + } catch { + XCTAssertEqual(error as? BitcoinTransactionHistoryError, .invalidAddress) + XCTAssertEqual(client.calls, 0) + } + + let windowClient = FakeBitcoinIndexerClient() + do { + _ = try await BitcoinTransactionHistorySync(client: windowClient).historyWindow(address: Self.testnetAddress) + XCTFail("Expected wrong-network Bitcoin address to be rejected") + } catch { + XCTAssertEqual(error as? BitcoinTransactionHistoryError, .invalidAddress) + XCTAssertEqual(windowClient.calls, 0) + } + } + + func testHistoryWindowFollowsEsploraChainPagesUntilShortPage() async throws { + let transactions = (1...27).map { Self.outgoingTransaction(txid: Self.txid(at: $0)) } + let pageOneCursor = transactions[24].txid + let client = FakeBitcoinIndexerClient( + transactionProvider: { cursor in + if cursor == nil { + return Array(transactions.prefix(25)) + } + if cursor == pageOneCursor { + return Array(transactions.dropFirst(25)) + } + + return [] + } + ) + + let history = try await BitcoinTransactionHistorySync(client: client).historyWindow( + address: Self.mainnetAddress, + baseURL: "https://bitcoin.example/api" + ) + + XCTAssertEqual(client.calls, 2) + XCTAssertEqual(client.lastSeenTxids, [nil, pageOneCursor]) + XCTAssertEqual(history.entries.count, 27) + XCTAssertEqual(history.nextLastSeenTxid, transactions.last?.txid) + } + + func testHistoryWindowStopsWhenPageRepeatsSameCursorAndEntries() async throws { + let transactions = (1...25).map { Self.outgoingTransaction(txid: Self.txid(at: $0)) } + let repeatedCursor = transactions.last?.txid + let client = FakeBitcoinIndexerClient( + transactionProvider: { cursor in + if cursor == nil { + return transactions + } + if cursor == repeatedCursor { + return transactions + } + + return [] + } + ) + + let history = try await BitcoinTransactionHistorySync(client: client).historyWindow(address: Self.mainnetAddress) + + XCTAssertEqual(client.calls, 2) + XCTAssertEqual(client.lastSeenTxids, [nil, repeatedCursor]) + XCTAssertEqual(history.entries.count, 25) + XCTAssertEqual(history.nextLastSeenTxid, repeatedCursor) + } + + func testHistoryWindowCapsPaginationBeforeUnboundedEsploraTraversal() async throws { + let transactions = (1...325).map { Self.outgoingTransaction(txid: Self.txid(at: $0)) } + let client = FakeBitcoinIndexerClient( + transactionProvider: { cursor in + let startIndex = cursor.flatMap { previous in + transactions.firstIndex { $0.txid == previous }.map { $0 + 1 } + } ?? 0 + + return Array(transactions.dropFirst(startIndex).prefix(25)) + } + ) + + let history = try await BitcoinTransactionHistorySync(client: client).historyWindow(address: Self.mainnetAddress) + + XCTAssertEqual(client.calls, 12) + XCTAssertEqual(history.entries.count, 300) + XCTAssertEqual(history.nextLastSeenTxid, transactions[299].txid) + } + + private final class FakeBitcoinIndexerClient: BitcoinIndexerClientProtocol { + private let transactionsResponse: [BitcoinEsploraTransaction] + private let transactionProvider: ((String?) -> [BitcoinEsploraTransaction])? + private(set) var calls = 0 + private(set) var lastAddress: String? + private(set) var lastBaseURL: String? + private(set) var lastSeenTxid: String? + private(set) var lastSeenTxids: [String?] = [] + + init( + transactions: [BitcoinEsploraTransaction] = [], + transactionProvider: ((String?) -> [BitcoinEsploraTransaction])? = nil + ) { + transactionsResponse = transactions + self.transactionProvider = transactionProvider + } + + func address( + address: String, + network: BitcoinIndexerNetwork, + baseURL: String? + ) async throws -> BitcoinEsploraAddress { + fatalError("Not used") + } + + func utxos( + address: String, + network: BitcoinIndexerNetwork, + baseURL: String? + ) async throws -> [BitcoinEsploraUtxo] { + return [] + } + + func transactions( + address: String, + network: BitcoinIndexerNetwork, + baseURL: String?, + lastSeenTxid: String?, + mempool: Bool + ) async throws -> [BitcoinEsploraTransaction] { + calls += 1 + lastAddress = address + lastBaseURL = baseURL + self.lastSeenTxid = lastSeenTxid + lastSeenTxids.append(lastSeenTxid) + + return transactionProvider?(lastSeenTxid) ?? transactionsResponse + } + + func feeEstimates( + network: BitcoinIndexerNetwork, + baseURL: String? + ) async throws -> [String: Double] { + return [:] + } + + func broadcastTransaction( + txHex: String, + network: BitcoinIndexerNetwork, + baseURL: String? + ) async throws -> String { + return BitcoinTransactionHistorySyncTests.txid1 + } + } + + private static let mainnetAddress = "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu" + private static let testnetAddress = "tb1q6rz28mcfaxtmd6v789l9rrlrusdprr9pqcpvkl" + private static let counterparty = "bc1q6rz28mcfaxtmd6v789l9rrlrusdprr9pkv76kj" + private static let blockHash = String(repeating: "aa", count: 32) + private static let txid0 = String(repeating: "00", count: 32) + private static let txid1 = String(repeating: "11", count: 32) + private static let txid2 = String(repeating: "22", count: 32) + private static let txid3 = String(repeating: "33", count: 32) + + private static func txid(at index: Int) -> String { + let hex = String(index, radix: 16) + return String(repeating: "0", count: 64 - hex.count) + hex + } + + private static func outgoingTransaction(txid: String) -> BitcoinEsploraTransaction { + transaction( + txid: txid, + fee: 100, + vin: [input(mainnetAddress, 1_000)], + vout: [output(counterparty, 800), output(mainnetAddress, 100)] + ) + } + + private static func transaction( + txid: String, + fee: Int64? = nil, + status: BitcoinEsploraTxStatus = BitcoinEsploraTxStatus(confirmed: true, blockHash: nil, blockHeight: nil, blockTime: nil), + vin: [BitcoinEsploraTransactionInput], + vout: [BitcoinEsploraTransactionOutput] + ) -> BitcoinEsploraTransaction { + BitcoinEsploraTransaction( + txid: txid, + status: status, + fee: fee, + vin: vin, + vout: vout + ) + } + + private static func input(_ address: String, _ value: Int64) -> BitcoinEsploraTransactionInput { + BitcoinEsploraTransactionInput(prevout: output(address, value)) + } + + private static func output(_ address: String, _ value: Int64) -> BitcoinEsploraTransactionOutput { + BitcoinEsploraTransactionOutput(scriptPubKeyAddress: address, value: value) + } +} diff --git a/fearlessTests/BitcoinUtxoSelectorTests.swift b/fearlessTests/BitcoinUtxoSelectorTests.swift new file mode 100644 index 0000000000..8c55e2dcde --- /dev/null +++ b/fearlessTests/BitcoinUtxoSelectorTests.swift @@ -0,0 +1,234 @@ +import XCTest +@testable import fearless + +final class BitcoinUtxoSelectorTests: XCTestCase { + func testSelectsDeterministicHighestValueUtxosAndComputesChangeFee() throws { + let result = try Self.selector.select( + amountSats: 50_000, + changeAddress: Self.mainnetAddress, + feeRateSatPerVbyte: 2, + utxos: [ + Self.spendable(txid: String(repeating: "55", count: 32), valueSats: 80_000, vout: 1), + Self.spendable(txid: String(repeating: "11", count: 32), valueSats: 100_000, vout: 0) + ] + ) + + XCTAssertEqual(result.selectedUtxos.map(\.txid), [String(repeating: "11", count: 32)]) + XCTAssertEqual(result.inputTotalSats, 100_000) + XCTAssertEqual(result.feeSats, 282) + XCTAssertEqual(result.changeAddress, Self.mainnetAddress) + XCTAssertEqual(result.changeSats, 49_718) + XCTAssertEqual(result.absorbedDustSats, 0) + } + + func testAbsorbsUneconomicalDustRemainderIntoFee() throws { + let result = try Self.selector.select( + amountSats: 50_000, + changeAddress: Self.mainnetAddress, + feeRateSatPerVbyte: 1, + utxos: [Self.spendable(valueSats: 50_210)] + ) + + XCTAssertEqual(result.feeSats, 210) + XCTAssertEqual(result.absorbedDustSats, 100) + XCTAssertEqual(result.changeSats, 0) + XCTAssertNil(result.changeAddress) + } + + func testFiltersUnconfirmedEsploraUtxosUnlessExplicitlyIncluded() throws { + let utxos = [ + Self.esploraUtxo(valueSats: 1_000, confirmed: true), + Self.esploraUtxo(valueSats: 2_000, confirmed: false, txid: String(repeating: "22", count: 32)) + ] + + XCTAssertEqual( + try Self.selector.spendableUtxos( + source: BitcoinUtxoSource(address: Self.mainnetAddress, derivationPath: "m/84'/0'/0'/0/0"), + utxos: utxos + ).count, + 1 + ) + XCTAssertEqual( + try Self.selector.spendableUtxos( + source: BitcoinUtxoSource(address: Self.mainnetAddress, derivationPath: "m/84'/0'/0'/0/0"), + utxos: utxos, + includeUnconfirmed: true + ).count, + 2 + ) + } + + func testRejectsInsufficientFundsAndTooManyRequiredInputs() { + assertSelectionError(.insufficientFunds) { + _ = try Self.selector.select( + amountSats: 50_000, + changeAddress: Self.mainnetAddress, + feeRateSatPerVbyte: 1, + utxos: [Self.spendable(valueSats: 30_000)] + ) + } + assertSelectionError(.tooManyInputsRequired) { + _ = try Self.selector.select( + amountSats: 90_000, + changeAddress: Self.mainnetAddress, + feeRateSatPerVbyte: 1, + maxInputs: 1, + utxos: [ + Self.spendable(txid: String(repeating: "11", count: 32), valueSats: 40_000, vout: 0), + Self.spendable(txid: String(repeating: "22", count: 32), valueSats: 40_000, vout: 1), + Self.spendable(txid: String(repeating: "33", count: 32), valueSats: 40_000, vout: 2) + ] + ) + } + } + + func testRejectsMalformedSendSelectionInputsBeforeReturningPlan() { + assertSelectionError(.amountBelowDust) { + _ = try Self.selector.select( + amountSats: 1, + changeAddress: Self.mainnetAddress, + feeRateSatPerVbyte: 1, + utxos: [Self.spendable(valueSats: 1_000)] + ) + } + assertSelectionError(.invalidChangeAddress) { + _ = try Self.selector.select( + amountSats: 1_000, + changeAddress: Self.testnetAddress, + feeRateSatPerVbyte: 1, + utxos: [Self.spendable(valueSats: 2_000)] + ) + } + assertSelectionError(.invalidFeeRate) { + _ = try Self.selector.select( + amountSats: 1_000, + changeAddress: Self.mainnetAddress, + feeRateSatPerVbyte: 0, + utxos: [Self.spendable(valueSats: 2_000)] + ) + } + assertSelectionError(.invalidMaxInputs) { + _ = try Self.selector.select( + amountSats: 1_000, + changeAddress: Self.mainnetAddress, + feeRateSatPerVbyte: 1, + maxInputs: 0, + utxos: [Self.spendable(valueSats: 2_000)] + ) + } + assertSelectionError(.utxosRequired) { + _ = try Self.selector.select( + amountSats: 1_000, + changeAddress: Self.mainnetAddress, + feeRateSatPerVbyte: 1, + utxos: [] + ) + } + } + + func testRejectsAdversarialUtxoPayloads() { + assertSelectionError(.invalidTxid) { + try selectSingle(Self.spendable(txid: "../bad", valueSats: 2_000)) + } + assertSelectionError(.invalidVout) { + try selectSingle(Self.spendable(valueSats: 2_000, vout: -1)) + } + assertSelectionError(.invalidUtxoValue) { + try selectSingle(Self.spendable(valueSats: 0)) + } + assertSelectionError(.invalidScriptPubKey) { + try selectSingle(Self.spendable(valueSats: 2_000, scriptPubKey: "00gg")) + } + assertSelectionError(.invalidDerivationPath) { + try selectSingle(Self.spendable(valueSats: 2_000, derivationPath: "../bad")) + } + assertSelectionError(.duplicateUtxo) { + _ = try Self.selector.select( + amountSats: 1_000, + changeAddress: Self.mainnetAddress, + feeRateSatPerVbyte: 1, + utxos: [ + Self.spendable(valueSats: 2_000), + Self.spendable(valueSats: 2_000) + ] + ) + } + } + + func testRejectsMalformedSourceAddressesAndPaths() { + assertSelectionError(.invalidSourceAddress) { + _ = try Self.selector.spendableUtxos( + source: BitcoinUtxoSource(address: Self.testnetAddress), + utxos: [Self.esploraUtxo()] + ) + } + assertSelectionError(.invalidDerivationPath) { + _ = try Self.selector.spendableUtxos( + source: BitcoinUtxoSource(address: Self.mainnetAddress, derivationPath: "../bad"), + utxos: [Self.esploraUtxo()] + ) + } + } + + private func selectSingle(_ utxo: BitcoinSpendableUtxo) throws { + _ = try Self.selector.select( + amountSats: 1_000, + changeAddress: Self.mainnetAddress, + feeRateSatPerVbyte: 1, + utxos: [utxo] + ) + } + + private func assertSelectionError( + _ expected: BitcoinUtxoSelectionError, + file: StaticString = #filePath, + line: UInt = #line, + block: () throws -> Void + ) { + do { + try block() + XCTFail("Expected Bitcoin UTXO selection error \(expected)", file: file, line: line) + } catch { + XCTAssertEqual(error as? BitcoinUtxoSelectionError, expected, file: file, line: line) + } + } + + private static let selector = BitcoinUtxoSelector() + private static let mainnetAddress = "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu" + private static let testnetAddress = "tb1q6rz28mcfaxtmd6v789l9rrlrusdprr9pqcpvkl" + + private static func spendable( + txid: String = String(repeating: "11", count: 32), + valueSats: Int64, + vout: Int64 = 0, + derivationPath: String? = nil, + scriptPubKey: String? = nil + ) -> BitcoinSpendableUtxo { + BitcoinSpendableUtxo( + address: mainnetAddress, + derivationPath: derivationPath, + scriptPubKey: scriptPubKey, + txid: txid, + valueSats: valueSats, + vout: vout + ) + } + + private static func esploraUtxo( + valueSats: Int64 = 1_000, + confirmed: Bool = true, + txid: String = String(repeating: "11", count: 32) + ) -> BitcoinEsploraUtxo { + BitcoinEsploraUtxo( + txid: txid, + vout: 0, + value: valueSats, + status: BitcoinEsploraTxStatus( + confirmed: confirmed, + blockHash: nil, + blockHeight: nil, + blockTime: nil + ) + ) + } +} diff --git a/fearlessTests/IrohaAddressCodecTests.swift b/fearlessTests/IrohaAddressCodecTests.swift new file mode 100644 index 0000000000..992a3be7ee --- /dev/null +++ b/fearlessTests/IrohaAddressCodecTests.swift @@ -0,0 +1,201 @@ +import XCTest +@testable import fearless + +final class IrohaAddressCodecTests: XCTestCase { + func testEncodesAndParsesTairaAndNexusGoldenVectors() throws { + for vector in try loadVectors() { + let iroha = try dictionary(vector["expected"])["iroha"].flatMap(dictionary) ?? [:] + let taira = try XCTUnwrap(iroha["taira"].flatMap(dictionary)) + let nexus = try XCTUnwrap(iroha["nexus"].flatMap(dictionary)) + + XCTAssertEqual(try IrohaAddressCodec.canonicalHex(publicKeyHex: string(taira["publicKeyHex"])), string(taira["canonicalHex"])) + XCTAssertEqual(try IrohaAddressCodec.canonicalHex(publicKeyHex: string(nexus["publicKeyHex"])), string(nexus["canonicalHex"])) + XCTAssertEqual( + try IrohaAddressCodec.encode( + publicKeyHex: string(taira["publicKeyHex"]), + chainDiscriminant: UniversalWalletRegistry.taira.chainDiscriminant + ), + string(taira["i105"]) + ) + XCTAssertEqual( + try IrohaAddressCodec.encode( + publicKeyHex: string(nexus["publicKeyHex"]), + chainDiscriminant: UniversalWalletRegistry.nexus.chainDiscriminant + ), + string(nexus["i105"]) + ) + + XCTAssertEqual( + try IrohaAddressCodec.parse( + string(taira["i105"]), + expectedDiscriminant: UniversalWalletRegistry.taira.chainDiscriminant + ), + IrohaAddressDetails( + chainDiscriminant: UniversalWalletRegistry.taira.chainDiscriminant, + network: .taira, + canonicalHex: string(taira["canonicalHex"]), + publicKeyHex: string(taira["publicKeyHex"]), + i105: string(taira["i105"]) + ) + ) + XCTAssertEqual( + try IrohaAddressCodec.parse( + string(nexus["i105"]), + expectedDiscriminant: UniversalWalletRegistry.nexus.chainDiscriminant + ), + IrohaAddressDetails( + chainDiscriminant: UniversalWalletRegistry.nexus.chainDiscriminant, + network: .nexus, + canonicalHex: string(nexus["canonicalHex"]), + publicKeyHex: string(nexus["publicKeyHex"]), + i105: string(nexus["i105"]) + ) + ) + } + } + + func testRejectsNetworkMismatchesAndMalformedI105Literals() throws { + let vectors = try loadVectors() + let iroha = try dictionary(vectors[0]["expected"])["iroha"].flatMap(dictionary) ?? [:] + let taira = try XCTUnwrap(iroha["taira"].flatMap(dictionary)) + let nexus = try XCTUnwrap(iroha["nexus"].flatMap(dictionary)) + let tairaAddress = string(taira["i105"]) + let nexusAddress = string(nexus["i105"]) + + assertError(.unexpectedNetworkPrefix) { + try IrohaAddressCodec.parse(tairaAddress, expectedDiscriminant: UniversalWalletRegistry.nexus.chainDiscriminant) + } + assertError(.unexpectedNetworkPrefix) { + try IrohaAddressCodec.parse(nexusAddress, expectedDiscriminant: UniversalWalletRegistry.taira.chainDiscriminant) + } + assertError(.checksumMismatch) { + try IrohaAddressCodec.parse(tamperLastSymbol(tairaAddress), expectedDiscriminant: UniversalWalletRegistry.taira.chainDiscriminant) + } + assertError(.invalidI105Char) { + try IrohaAddressCodec.parse("\(tairaAddress.prefix(8))!\(tairaAddress.dropFirst(9))") + } + assertError(.unsupportedAddressFormat) { + try IrohaAddressCodec.parse(" \(tairaAddress)") + } + assertError(.unsupportedAddressFormat) { + try IrohaAddressCodec.parse(string(taira["canonicalHex"])) + } + assertError(.missingI105Sentinel) { + try IrohaAddressCodec.parse(nexusAddress.replacingOccurrences(of: "sora", with: "\u{ff53}\u{ff4f}\u{ff52}\u{ff41}", options: [.anchored])) + } + assertError(.invalidI105Char) { + try IrohaAddressCodec.parse(nexusAddress.replacingOccurrences(of: "\u{ff9b}", with: "\u{30ed}", options: [.literal])) + } + + XCTAssertFalse(IrohaAddressCodec.isValid(tamperLastSymbol(tairaAddress), expectedDiscriminant: UniversalWalletRegistry.taira.chainDiscriminant)) + XCTAssertNil(IrohaAddressCodec.networkKind(string(taira["canonicalHex"]))) + } + + func testSupportsCanonicalCustomNumericPrefixesOnly() throws { + let vectors = try loadVectors() + let iroha = try dictionary(vectors[0]["expected"])["iroha"].flatMap(dictionary) ?? [:] + let publicKeyHex = string(try XCTUnwrap(iroha["taira"].flatMap(dictionary))["publicKeyHex"]) + let custom = try IrohaAddressCodec.encode(publicKeyHex: publicKeyHex, chainDiscriminant: 42) + + XCTAssertTrue(custom.hasPrefix("n42")) + XCTAssertEqual(try IrohaAddressCodec.parse(custom, expectedDiscriminant: 42).network, .custom) + assertError(.unexpectedNetworkPrefix) { + try IrohaAddressCodec.parse(custom, expectedDiscriminant: UniversalWalletRegistry.taira.chainDiscriminant) + } + assertError(.unsupportedAddressFormat) { + try IrohaAddressCodec.parse(custom.replacingOccurrences(of: "n42", with: "n00042", options: [.anchored]), expectedDiscriminant: 42) + } + } + + func testRejectsInvalidPublicKeysAndDiscriminantsBeforeEncoding() throws { + let vectors = try loadVectors() + let iroha = try dictionary(vectors[0]["expected"])["iroha"].flatMap(dictionary) ?? [:] + let publicKeyHex = string(try XCTUnwrap(iroha["taira"].flatMap(dictionary))["publicKeyHex"]) + + assertError(.invalidLength) { + try IrohaAddressCodec.encode(publicKeyHex: "abcd", chainDiscriminant: UniversalWalletRegistry.taira.chainDiscriminant) + } + assertError(.invalidHexAddress) { + try IrohaAddressCodec.encode( + publicKeyHex: "\(publicKeyHex.dropLast())z", + chainDiscriminant: UniversalWalletRegistry.taira.chainDiscriminant + ) + } + assertError(.invalidI105Prefix) { + try IrohaAddressCodec.encode(publicKeyHex: publicKeyHex, chainDiscriminant: -1) + } + assertError(.invalidI105Prefix) { + try IrohaAddressCodec.encode(publicKeyHex: publicKeyHex, chainDiscriminant: 0x4000) + } + } + + func testRejectsChecksumValidPayloadsOutsideSingleKeyEd25519Shape() throws { + let vectors = try loadVectors() + let iroha = try dictionary(vectors[0]["expected"])["iroha"].flatMap(dictionary) ?? [:] + let canonicalHex = string(try XCTUnwrap(iroha["taira"].flatMap(dictionary))["canonicalHex"]) + let discriminant = UniversalWalletRegistry.taira.chainDiscriminant + + assertError(.invalidHeaderVersion) { + try IrohaAddressCodec.parse(IrohaAddressCodec.encodeCanonicalHex("0x22\(canonicalHex.dropFirst(4))", chainDiscriminant: discriminant)) + } + assertError(.invalidNormVersion) { + try IrohaAddressCodec.parse(IrohaAddressCodec.encodeCanonicalHex("0x00\(canonicalHex.dropFirst(4))", chainDiscriminant: discriminant)) + } + assertError(.unknownAddressClass) { + try IrohaAddressCodec.parse(IrohaAddressCodec.encodeCanonicalHex("0x12\(canonicalHex.dropFirst(4))", chainDiscriminant: discriminant)) + } + assertError(.unexpectedExtensionFlag) { + try IrohaAddressCodec.parse(IrohaAddressCodec.encodeCanonicalHex("0x03\(canonicalHex.dropFirst(4))", chainDiscriminant: discriminant)) + } + assertError(.unknownControllerTag) { + try IrohaAddressCodec.parse(IrohaAddressCodec.encodeCanonicalHex("\(canonicalHex.prefix(4))01\(canonicalHex.dropFirst(6))", chainDiscriminant: discriminant)) + } + assertError(.unknownCurve) { + try IrohaAddressCodec.parse(IrohaAddressCodec.encodeCanonicalHex("\(canonicalHex.prefix(6))02\(canonicalHex.dropFirst(8))", chainDiscriminant: discriminant)) + } + assertError(.invalidLength) { + try IrohaAddressCodec.parse(IrohaAddressCodec.encodeCanonicalHex("\(canonicalHex.prefix(8))1f\(canonicalHex.dropFirst(10))", chainDiscriminant: discriminant)) + } + assertError(.unexpectedTrailingBytes) { + try IrohaAddressCodec.parse(IrohaAddressCodec.encodeCanonicalHex("\(canonicalHex)00", chainDiscriminant: discriminant)) + } + } + + private func loadVectors() throws -> [[String: Any]] { + let repoRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + let url = repoRoot.appendingPathComponent("docs/universal-wallet-v2-vectors.json") + let data = try Data(contentsOf: url) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + return try XCTUnwrap(json["vectors"] as? [[String: Any]]) + } + + private func dictionary(_ value: Any?) throws -> [String: Any] { + try XCTUnwrap(value as? [String: Any]) + } + + private func string(_ value: Any?) -> String { + value as? String ?? "" + } + + private func assertError( + _ expected: IrohaAddressErrorCode, + file: StaticString = #filePath, + line: UInt = #line, + _ action: () throws -> Void + ) { + do { + try action() + XCTFail("Expected IrohaAddressError", file: file, line: line) + } catch let error as IrohaAddressError { + XCTAssertEqual(error.code, expected, file: file, line: line) + } catch { + XCTFail("Expected IrohaAddressError, got \(error)", file: file, line: line) + } + } + + private func tamperLastSymbol(_ address: String) -> String { + "\(address.dropLast())\(address.hasSuffix("1") ? "2" : "1")" + } +} diff --git a/fearlessTests/IrohaHistoryOperationFactoryTests.swift b/fearlessTests/IrohaHistoryOperationFactoryTests.swift new file mode 100644 index 0000000000..7c63ed30cc --- /dev/null +++ b/fearlessTests/IrohaHistoryOperationFactoryTests.swift @@ -0,0 +1,355 @@ +import RobinHood +import SSFModels +import XCTest +@testable import fearless + +final class IrohaHistoryOperationFactoryTests: XCTestCase { + func testAssemblyRoutesIrohaRegistryChainsToIrohaHistoryFactory() { + let txStorage: CoreDataRepository = + SubstrateDataStorageFacade.shared.createRepository() + let repository = AnyDataProviderRepository(txStorage) + + let tairaFactory = HistoryOperationFactoriesAssembly.createOperationFactory( + chain: Self.irohaChain(), + txStorage: repository + ) + let tairaRegistryFactory = HistoryOperationFactoriesAssembly.createOperationFactory( + chain: Self.irohaChain(chainId: UniversalWalletRegistry.taira.id), + txStorage: repository + ) + let nexusFactory = HistoryOperationFactoriesAssembly.createOperationFactory( + chain: Self.irohaChain(chainId: UniversalWalletRegistry.nexus.chainId), + txStorage: repository + ) + let nexusRegistryFactory = HistoryOperationFactoriesAssembly.createOperationFactory( + chain: Self.irohaChain(chainId: UniversalWalletRegistry.nexus.id), + txStorage: repository + ) + + XCTAssertTrue(tairaFactory is IrohaHistoryOperationFactory) + XCTAssertTrue(tairaRegistryFactory is IrohaHistoryOperationFactory) + XCTAssertTrue(nexusFactory is IrohaHistoryOperationFactory) + XCTAssertTrue(nexusRegistryFactory is IrohaHistoryOperationFactory) + } + + func testMapsTairaToriiInstructionsIntoWalletTransactionPage() throws { + let client = FakeIrohaToriiClient( + response: Self.mcpResponse([ + Self.instruction( + value: [ + "source": .string("\(Self.assetId)#\(Self.address)"), + "destination": .string(Self.counterparty), + "object": .string("1230000000000000000") + ] + ) + ]) + ) + + let result = try execute( + IrohaHistoryOperationFactory(client: client).fetchTransactionHistoryOperation( + asset: Self.irohaAsset, + chain: Self.irohaChain(), + address: Self.address, + filters: [WalletTransactionHistoryFilter(type: .transfer, selected: true)], + pagination: Pagination(count: 10) + ) + ) + + XCTAssertEqual(client.mcpCalls, 1) + XCTAssertEqual(client.lastNetwork, UniversalWalletRegistry.taira) + XCTAssertEqual(client.lastBaseURL, UniversalWalletRegistry.taira.toriiBaseURL?.absoluteString) + XCTAssertEqual(client.lastRequest?.method, "tools/call") + XCTAssertEqual(client.lastRequest?.params?["name"], .string("iroha.instructions.list")) + + let arguments = try XCTUnwrap(client.lastRequest?.params?["arguments"]?.objectValue) + XCTAssertEqual(arguments["account"], .string(Self.address)) + XCTAssertEqual(arguments["asset_id"], .string(Self.assetId)) + XCTAssertEqual(arguments["kind"], .string("Transfer")) + XCTAssertEqual(arguments["page"], .int(0)) + XCTAssertEqual(arguments["per_page"], .int(10)) + XCTAssertEqual(arguments["transaction_status"], .string("committed")) + + let page = try XCTUnwrap(result) + XCTAssertNil(page.context) + XCTAssertEqual(page.transactions.count, 1) + + let transaction = page.transactions[0] + XCTAssertEqual(transaction.transactionId, Self.txHash) + XCTAssertEqual(transaction.status, .commited) + XCTAssertEqual(transaction.assetId, Self.assetId) + XCTAssertEqual(transaction.peerId, Self.counterparty) + XCTAssertEqual(transaction.peerName, Self.counterparty) + XCTAssertEqual(transaction.amount.decimalValue, Decimal(string: "1.23")) + XCTAssertTrue(transaction.fees.isEmpty) + XCTAssertEqual(transaction.timestamp, 1_704_067_200) + XCTAssertEqual(transaction.type, TransactionType.outgoing.rawValue) + } + + func testUsesPaginationPageAndCapsMcpPageSize() throws { + let client = FakeIrohaToriiClient(response: Self.mcpResponse([Self.instruction()])) + + _ = try execute( + IrohaHistoryOperationFactory(client: client).fetchTransactionHistoryOperation( + asset: Self.irohaAsset, + chain: Self.irohaChain(), + address: Self.address, + filters: [], + pagination: Pagination( + count: IrohaToriiRoutes.maxLimit + 100, + context: [IrohaHistoryOperationFactory.paginationPageKey: "3"] + ) + ) + ) + + let arguments = try XCTUnwrap(client.lastRequest?.params?["arguments"]?.objectValue) + XCTAssertEqual(arguments["page"], .int(3)) + XCTAssertEqual(arguments["per_page"], .int(Int64(IrohaToriiRoutes.maxLimit))) + } + + func testGuardPathsAvoidToriiCalls() throws { + let rewardFilter = [WalletTransactionHistoryFilter(type: .reward, selected: true)] + + for (pagination, filters, address) in [ + (Pagination(count: 10), rewardFilter, Self.address), + (Pagination(count: 0), [WalletTransactionHistoryFilter(type: .transfer, selected: true)], Self.address), + (Pagination(count: 10), [WalletTransactionHistoryFilter(type: .transfer, selected: true)], "../bad") + ] { + let client = FakeIrohaToriiClient() + let result = try execute( + IrohaHistoryOperationFactory(client: client).fetchTransactionHistoryOperation( + asset: Self.irohaAsset, + chain: Self.irohaChain(), + address: address, + filters: filters, + pagination: pagination + ) + ) + + XCTAssertEqual(client.mcpCalls, 0) + XCTAssertEqual(result?.transactions, []) + XCTAssertNil(result?.context) + } + } + + func testMcpErrorReturnsEmptyPage() throws { + let client = FakeIrohaToriiClient( + response: IrohaMcpJsonRPCResponse( + jsonrpc: "2.0", + id: .string("history-0"), + result: nil, + error: IrohaMcpJsonRPCError(code: -32000, message: "index unavailable", data: nil) + ) + ) + + let result = try execute( + IrohaHistoryOperationFactory(client: client).fetchTransactionHistoryOperation( + asset: Self.irohaAsset, + chain: Self.irohaChain(), + address: Self.address, + filters: [WalletTransactionHistoryFilter(type: .transfer, selected: true)], + pagination: Pagination(count: 10) + ) + ) + + XCTAssertEqual(client.mcpCalls, 1) + XCTAssertEqual(result?.transactions, []) + XCTAssertNil(result?.context) + } + + private func execute( + _ wrapper: CompoundOperationWrapper + ) throws -> AssetTransactionPageData? { + OperationQueue().addOperations(wrapper.allOperations, waitUntilFinished: true) + return try wrapper.targetOperation.extractResultData(throwing: BaseOperationError.parentOperationCancelled) + } + + private final class FakeIrohaToriiClient: IrohaToriiClientProtocol { + private let response: IrohaMcpJsonRPCResponse + + private(set) var mcpCalls = 0 + private(set) var lastRequest: IrohaMcpJsonRPCRequest? + private(set) var lastNetwork: UniversalWalletRegistry.IrohaNetwork? + private(set) var lastBaseURL: String? + + init(response: IrohaMcpJsonRPCResponse = IrohaHistoryOperationFactoryTests.mcpResponse([])) { + self.response = response + } + + func health(baseURL _: String?) async throws -> Data { + throw TestError.unexpectedEndpoint + } + + func accounts( + baseURL _: String?, + limit _: Int?, + offset _: Int64?, + countMode _: IrohaToriiCountMode? + ) async throws -> IrohaAccountListResponse { + throw TestError.unexpectedEndpoint + } + + func account( + accountID _: String, + baseURL _: String?, + network _: UniversalWalletRegistry.IrohaNetwork + ) async throws -> IrohaAccountListItem { + throw TestError.unexpectedEndpoint + } + + func accountAssets( + accountID _: String, + baseURL _: String?, + limit _: Int?, + offset _: Int64?, + countMode _: IrohaToriiCountMode?, + asset _: String?, + scope _: String?, + network _: UniversalWalletRegistry.IrohaNetwork + ) async throws -> IrohaAccountAssetListResponse { + throw TestError.unexpectedEndpoint + } + + func assetDefinitions(baseURL _: String?) async throws -> IrohaAssetDefinitionListResponse { + throw TestError.unexpectedEndpoint + } + + func submitTransaction(noritoBytes _: Data, baseURL _: String?) async throws -> IrohaTransactionSubmissionReceipt { + throw TestError.unexpectedEndpoint + } + + func transactionStatus( + hash _: String, + baseURL _: String?, + scope _: IrohaTransactionStatusScope + ) async throws -> IrohaPipelineTransactionStatusResponse { + throw TestError.unexpectedEndpoint + } + + func mcpCapabilities( + network _: UniversalWalletRegistry.IrohaNetwork, + baseURL _: String? + ) async throws -> Data { + throw TestError.unexpectedEndpoint + } + + func mcpJSONRPC( + _ request: IrohaMcpJsonRPCRequest, + network: UniversalWalletRegistry.IrohaNetwork, + baseURL: String? + ) async throws -> IrohaMcpJsonRPCResponse { + mcpCalls += 1 + lastRequest = request + lastNetwork = network + lastBaseURL = baseURL + + return response + } + } + + private enum TestError: Error { + case unexpectedEndpoint + } + + private static let mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + private static let assetId = "xor#sora" + private static let txHash = String(repeating: "aa", count: 32) + private static let counterparty = "i105counterparty" + private static var address: String { + try! IrohaKeyDerivation.deriveAddress( + mnemonic: mnemonic, + chainDiscriminant: UniversalWalletRegistry.taira.chainDiscriminant + ).i105 + } + + private static let irohaAsset = AssetModel( + id: assetId, + name: "XOR", + symbol: "XOR", + precision: 18, + isUtility: true, + isNative: true + ) + + private static func irohaChain( + chainId: String = UniversalWalletRegistry.taira.chainId, + historyBaseURL: String? = UniversalWalletRegistry.taira.toriiBaseURL?.absoluteString + ) -> ChainModel { + let node = ChainNodeModel( + url: URL(string: "https://taira.sora.org")!, + name: "Taira", + apikey: nil + ) + let externalApi = historyBaseURL.map { + ChainModel.ExternalApiSet( + staking: nil, + history: ChainModel.BlockExplorer( + type: "subquery", + url: URL(string: $0)! + ), + explorers: nil + ) + } + + return ChainModel( + rank: nil, + disabled: false, + chainId: chainId, + parentId: nil, + paraId: nil, + name: "Taira Testnet", + assets: [irohaAsset], + xcm: nil, + nodes: [node], + addressPrefix: 0, + icon: nil, + options: [.testnet], + externalApi: externalApi, + customNodes: nil, + iosMinAppVersion: nil, + identityChain: nil + ) + } + + private static func mcpResponse(_ instructions: [[String: IrohaJSONValue]]) -> IrohaMcpJsonRPCResponse { + IrohaMcpJsonRPCResponse( + jsonrpc: "2.0", + id: .string("history-0"), + result: .object([ + "body": .object([ + "items": .array(instructions.map { .object($0) }) + ]) + ]), + error: nil + ) + } + + private static func instruction(value: [String: IrohaJSONValue]? = nil) -> [String: IrohaJSONValue] { + [ + "transaction_hash": .string(txHash), + "created_at": .string("2024-01-01T00:00:00Z"), + "transaction_status": .string("Committed"), + "box": .object([ + "json": .object([ + "payload": .object([ + "variant": .string("Asset"), + "value": .object(value ?? [ + "source": .string("\(assetId)#\(address)"), + "destination": .string(counterparty), + "object": .string("1230000000000000000") + ]) + ]) + ]) + ]) + ] + } +} + +private extension IrohaJSONValue { + var objectValue: [String: IrohaJSONValue]? { + if case let .object(value) = self { + return value + } + + return nil + } +} diff --git a/fearlessTests/IrohaToriiClientTests.swift b/fearlessTests/IrohaToriiClientTests.swift new file mode 100644 index 0000000000..fca5b52b2a --- /dev/null +++ b/fearlessTests/IrohaToriiClientTests.swift @@ -0,0 +1,185 @@ +import XCTest +@testable import fearless + +final class IrohaToriiClientTests: XCTestCase { + func testDelegatesReadEndpointsThroughValidatedToriiRoutes() async throws { + let transport = FakeIrohaToriiTransport() + let client = IrohaToriiClient(transport: transport) + + transport.enqueue(Self.accountListJSON) + _ = try await client.accounts(limit: 50, offset: 10, countMode: .exact) + XCTAssertEqual( + transport.lastRequest?.url?.absoluteString, + "https://taira.sora.org/v1/accounts?limit=50&offset=10&count_mode=exact" + ) + XCTAssertEqual(transport.lastRequest?.httpMethod, "GET") + + transport.enqueue(Self.accountAssetsJSON) + _ = try await client.accountAssets( + accountID: Self.account, + limit: 25, + countMode: .bounded, + asset: "xor#sora", + scope: "global" + ) + XCTAssertEqual( + transport.lastRequest?.url?.absoluteString, + "https://taira.sora.org/v1/accounts/\(Self.encodedAccount)/assets?limit=25&count_mode=bounded&asset=xor%23sora&scope=global" + ) + + transport.enqueue(Self.transactionStatusJSON) + _ = try await client.transactionStatus(hash: Self.hash, scope: .global) + XCTAssertEqual( + transport.lastRequest?.url?.absoluteString, + "https://taira.sora.org/v1/pipeline/transactions/status?hash=\(Self.hash)&scope=global" + ) + } + + func testDelegatesMCPAndTransactionWritesWithExplicitTransportContracts() async throws { + let transport = FakeIrohaToriiTransport() + let client = IrohaToriiClient(transport: transport) + + transport.enqueue(Self.transactionReceiptJSON) + _ = try await client.submitTransaction(noritoBytes: Data([1, 2, 3])) + XCTAssertEqual(transport.lastRequest?.url?.absoluteString, "https://taira.sora.org/v1/pipeline/transactions") + XCTAssertEqual(transport.lastRequest?.httpMethod, "POST") + XCTAssertEqual(transport.lastRequest?.value(forHTTPHeaderField: "Content-Type"), "application/x-norito") + XCTAssertEqual(transport.lastRequest?.value(forHTTPHeaderField: "Accept"), "application/json") + XCTAssertEqual(transport.lastRequest?.httpBody, Data([1, 2, 3])) + + transport.enqueue(Self.mcpResponseJSON) + _ = try await client.mcpJSONRPC( + try IrohaToriiRoutes.mcpJSONRPCRequest(method: "tools/list", id: "1") + ) + XCTAssertEqual(transport.lastRequest?.url?.absoluteString, "https://taira.sora.org/v1/mcp") + XCTAssertEqual(transport.lastRequest?.httpMethod, "POST") + XCTAssertEqual(transport.lastRequest?.value(forHTTPHeaderField: "Content-Type"), "application/json") + XCTAssertEqual(transport.lastRequest?.value(forHTTPHeaderField: "Accept"), "application/json") + XCTAssertNotNil(transport.lastRequest?.httpBody) + } + + func testUsesMinamotoForNexusByDefaultAndAllowsRuntimeOverride() async throws { + let transport = FakeIrohaToriiTransport() + let client = IrohaToriiClient(transport: transport) + + transport.enqueue(Data("{}".utf8)) + _ = try await client.mcpCapabilities(network: UniversalWalletRegistry.nexus) + XCTAssertEqual(transport.lastRequest?.url?.absoluteString, "https://minamoto.sora.org/v1/mcp") + + transport.enqueue(Data("{}".utf8)) + _ = try await client.mcpCapabilities( + network: UniversalWalletRegistry.nexus, + baseURL: "https://nexus.example" + ) + XCTAssertEqual(transport.lastRequest?.url?.absoluteString, "https://nexus.example/v1/mcp") + } + + func testRejectsWrongNetworkIrohaAccountIDsBeforeFetch() async throws { + let transport = FakeIrohaToriiTransport() + let client = IrohaToriiClient(transport: transport) + + do { + _ = try await client.account(accountID: Self.nexusAccount) + XCTFail("Expected Nexus account to be rejected for default Taira reads") + } catch { + XCTAssertEqual(error as? IrohaAddressError, IrohaAddressError(code: .unexpectedNetworkPrefix)) + XCTAssertNil(transport.lastRequest) + } + + transport.enqueue(Self.accountListItemJSON(account: Self.nexusAccount)) + _ = try await client.account( + accountID: Self.nexusAccount, + baseURL: "https://nexus.example", + network: UniversalWalletRegistry.nexus + ) + XCTAssertEqual( + transport.lastRequest?.url?.absoluteString, + "https://nexus.example/v1/accounts/\(Self.encodedNexusAccount)" + ) + } + + private final class FakeIrohaToriiTransport: UniversalWalletHTTPTransport { + private var queuedResponses: [Data] = [] + private(set) var lastRequest: URLRequest? + + func enqueue(_ data: Data) { + queuedResponses.append(data) + } + + func perform(_ request: URLRequest) async throws -> Data { + lastRequest = request + return queuedResponses.isEmpty ? Data("{}".utf8) : queuedResponses.removeFirst() + } + } + + private static let account = "testuロ1Pcナ2ラtノaリLユス2MヱミホウモヱキニイMメSマアヱキJヱFmJヌMs6YN687Y" + private static let encodedAccount = "testu%EF%BE%9B1Pc%EF%BE%852%EF%BE%97t%EF%BE%89a%EF%BE%98L%EF%BE%95%EF%BD%BD2M%E3%83%B1%EF%BE%90%EF%BE%8E%EF%BD%B3%EF%BE%93%E3%83%B1%EF%BD%B7%EF%BE%86%EF%BD%B2M%EF%BE%92S%EF%BE%8F%EF%BD%B1%E3%83%B1%EF%BD%B7J%E3%83%B1FmJ%EF%BE%87Ms6YN687Y" + private static let nexusAccount = "sorauロ1Pcナ2ラtノaリLユス2MヱミホウモヱキニイMメSマアヱキJヱFmJヌMs6YN687Y" + private static let encodedNexusAccount = "sorau%EF%BE%9B1Pc%EF%BE%852%EF%BE%97t%EF%BE%89a%EF%BE%98L%EF%BE%95%EF%BD%BD2M%E3%83%B1%EF%BE%90%EF%BE%8E%EF%BD%B3%EF%BE%93%E3%83%B1%EF%BD%B7%EF%BE%86%EF%BD%B2M%EF%BE%92S%EF%BE%8F%EF%BD%B1%E3%83%B1%EF%BD%B7J%E3%83%B1FmJ%EF%BE%87Ms6YN687Y" + private static let hash = String(repeating: "a", count: 64) + + private static let accountListJSON = Data(""" + { + "items": [{ "id": "\(account)" }], + "has_more": false, + "count_mode": "exact", + "total": 1 + } + """.utf8) + + private static func accountListItemJSON(account: String) -> Data { + Data(""" + { + "id": "\(account)" + } + """.utf8) + } + + private static let accountAssetsJSON = Data(""" + { + "items": [ + { + "account_id": "\(account)", + "asset": "xor#sora", + "quantity": "340282366920938463463374607431768211455", + "scope": "global" + } + ], + "has_more": false, + "count_mode": "bounded" + } + """.utf8) + + private static let transactionStatusJSON = Data(""" + { + "hash": "\(hash)", + "status": { + "kind": "committed", + "block_height": 42 + }, + "scope": "global", + "resolved_from": "pipeline" + } + """.utf8) + + private static let transactionReceiptJSON = Data(""" + { + "payload": { + "tx_hash": "\(hash)", + "entrypoint_hash": "\(hash)", + "submitted_at_ms": 1, + "submitted_at_height": 1 + } + } + """.utf8) + + private static let mcpResponseJSON = Data(""" + { + "jsonrpc": "2.0", + "id": "1", + "result": { + "ok": true + } + } + """.utf8) +} diff --git a/fearlessTests/IrohaToriiContractTests.swift b/fearlessTests/IrohaToriiContractTests.swift new file mode 100644 index 0000000000..743f3561ec --- /dev/null +++ b/fearlessTests/IrohaToriiContractTests.swift @@ -0,0 +1,144 @@ +import XCTest +@testable import fearless + +final class IrohaToriiContractTests: XCTestCase { + func testBuildsTairaToriiEndpointURLsWithValidatedParameters() throws { + XCTAssertEqual(try IrohaToriiRoutes.healthURL().absoluteString, "https://taira.sora.org/health") + XCTAssertEqual(try IrohaToriiRoutes.mcpURL().absoluteString, "https://taira.sora.org/v1/mcp") + XCTAssertEqual( + try IrohaToriiRoutes.accountsURL(limit: 50, offset: 10, countMode: .exact).absoluteString, + "https://taira.sora.org/v1/accounts?limit=50&offset=10&count_mode=exact" + ) + XCTAssertEqual( + try IrohaToriiRoutes.accountURL(accountID: Self.account).absoluteString, + "https://taira.sora.org/v1/accounts/\(Self.encodedAccount)" + ) + XCTAssertEqual( + try IrohaToriiRoutes.accountAssetsURL( + accountID: Self.account, + limit: 25, + countMode: .bounded, + asset: "xor#sora", + scope: "global" + ).absoluteString, + "https://taira.sora.org/v1/accounts/\(Self.encodedAccount)/assets?limit=25&count_mode=bounded&asset=xor%23sora&scope=global" + ) + XCTAssertEqual( + try IrohaToriiRoutes.assetDefinitionsURL().absoluteString, + "https://taira.sora.org/v1/assets/definitions" + ) + XCTAssertEqual( + try IrohaToriiRoutes.submitTransactionURL().absoluteString, + "https://taira.sora.org/v1/pipeline/transactions" + ) + XCTAssertEqual( + try IrohaToriiRoutes.transactionStatusURL(hash: "0x\(Self.hash)", scope: .global).absoluteString, + "https://taira.sora.org/v1/pipeline/transactions/status?hash=\(Self.hash)&scope=global" + ) + XCTAssertEqual( + try IrohaToriiRoutes.mcpJSONRPCRequest(method: " tools/list ", id: "1").method, + "tools/list" + ) + } + + func testAllowsLocalHTTPBaseURLsButRejectsNonlocalInsecureBaseURLs() throws { + XCTAssertEqual(try IrohaToriiRoutes.normalizeBaseURL("http://localhost:8080/"), "http://localhost:8080") + XCTAssertEqual(try IrohaToriiRoutes.normalizeBaseURL("http://127.0.0.1:8080/"), "http://127.0.0.1:8080") + + XCTAssertThrowsError(try IrohaToriiRoutes.normalizeBaseURL("http://taira.sora.org")) { error in + XCTAssertEqual(error as? IrohaToriiRouteError, .invalidBaseURL) + } + XCTAssertThrowsError(try IrohaToriiRoutes.normalizeBaseURL("not a url")) { error in + XCTAssertEqual(error as? IrohaToriiRouteError, .invalidBaseURL) + } + XCTAssertEqual( + try IrohaToriiRoutes.requireToriiBaseURL(UniversalWalletRegistry.nexus), + "https://minamoto.sora.org" + ) + } + + func testRejectsMalformedIrohaRouteInputsBeforeNetworkCalls() throws { + XCTAssertThrowsError(try IrohaToriiRoutes.accountURL(accountID: "../bad")) { error in + XCTAssertEqual(error as? IrohaToriiRouteError, .invalidAccountID) + } + XCTAssertThrowsError(try IrohaToriiRoutes.accountAssetsURL(accountID: Self.account, asset: "../bad")) { error in + XCTAssertEqual(error as? IrohaToriiRouteError, .invalidAsset) + } + XCTAssertThrowsError(try IrohaToriiRoutes.accountAssetsURL(accountID: Self.account, scope: "bad/scope")) { error in + XCTAssertEqual(error as? IrohaToriiRouteError, .invalidScope) + } + XCTAssertThrowsError(try IrohaToriiRoutes.accountsURL(limit: 0)) { error in + XCTAssertEqual(error as? IrohaToriiRouteError, .invalidLimit) + } + XCTAssertThrowsError(try IrohaToriiRoutes.accountsURL(limit: 501)) { error in + XCTAssertEqual(error as? IrohaToriiRouteError, .invalidLimit) + } + XCTAssertThrowsError(try IrohaToriiRoutes.accountsURL(offset: -1)) { error in + XCTAssertEqual(error as? IrohaToriiRouteError, .invalidOffset) + } + XCTAssertThrowsError(try IrohaToriiRoutes.transactionStatusURL(hash: "not-a-hash")) { error in + XCTAssertEqual(error as? IrohaToriiRouteError, .invalidHash) + } + XCTAssertThrowsError(try IrohaToriiRoutes.mcpJSONRPCRequest(method: "tools/list", id: "../bad")) { error in + XCTAssertEqual(error as? IrohaToriiRouteError, .invalidJSONRPCID) + } + XCTAssertThrowsError(try IrohaToriiRoutes.mcpJSONRPCRequest(method: "../bad", id: "1")) { error in + XCTAssertEqual(error as? IrohaToriiRouteError, .invalidMCPMethod) + } + } + + func testParsesAccountAssetQuantitiesAsStrings() throws { + let json = """ + { + "items": [ + { + "account_id": "\(Self.account)", + "asset": "xor#sora", + "quantity": "340282366920938463463374607431768211455", + "scope": "global" + } + ], + "has_more": false, + "count_mode": "exact", + "total": 1 + } + """ + let response = try JSONDecoder().decode(IrohaAccountAssetListResponse.self, from: Data(json.utf8)) + + XCTAssertEqual(response.items.first?.quantity, "340282366920938463463374607431768211455") + XCTAssertEqual(response.countMode, "exact") + XCTAssertEqual(response.total, 1) + } + + func testParsesTransactionStatusAndMCPJSON() throws { + let statusJSON = """ + { + "hash": "\(Self.hash)", + "status": { + "kind": "committed", + "block_height": 42 + }, + "scope": "global", + "resolved_from": "pipeline" + } + """ + let status = try JSONDecoder().decode(IrohaPipelineTransactionStatusResponse.self, from: Data(statusJSON.utf8)) + XCTAssertEqual(status.status.kind, "committed") + XCTAssertEqual(status.status.blockHeight, 42) + + let request = try IrohaToriiRoutes.mcpJSONRPCRequest( + method: "tools/call", + id: "req-1", + params: ["name": .string("accounts_list")] + ) + let encoded = try JSONEncoder().encode(request) + let decoded = try JSONDecoder().decode(IrohaMcpJsonRPCRequest.self, from: encoded) + + XCTAssertEqual(decoded.jsonrpc, "2.0") + XCTAssertEqual(decoded.params?["name"], .string("accounts_list")) + } + + private static let account = "testuロ1Pcナ2ラtノaリLユス2MヱミホウモヱヌニイMメSマムヱヌJヱFmJヌMs6YN687Y" + private static let encodedAccount = "testu%EF%BE%9B1Pc%EF%BE%852%EF%BE%97t%EF%BE%89a%EF%BE%98L%EF%BE%95%EF%BD%BD2M%E3%83%B1%EF%BE%90%EF%BE%8E%EF%BD%B3%EF%BE%93%E3%83%B1%EF%BE%87%EF%BE%86%EF%BD%B2M%EF%BE%92S%EF%BE%8F%EF%BE%91%E3%83%B1%EF%BE%87J%E3%83%B1FmJ%EF%BE%87Ms6YN687Y" + private static let hash = String(repeating: "a", count: 64) +} diff --git a/fearlessTests/Modules/AccountCreate/AccountCreateTests.swift b/fearlessTests/Modules/AccountCreate/AccountCreateTests.swift index 14749d7309..c079cc94dd 100644 --- a/fearlessTests/Modules/AccountCreate/AccountCreateTests.swift +++ b/fearlessTests/Modules/AccountCreate/AccountCreateTests.swift @@ -71,4 +71,24 @@ class AccountCreateTests: XCTestCase { XCTAssertEqual(receivedRequest?.username, usernameSetup.username) } + + func testSetupGeneratesTwentyFourWordMnemonic() { + let presenter = MockAccountCreateInteractorOutputProtocol() + let interactor = AccountCreateInteractor(mnemonicCreator: IRMnemonicCreator()) + interactor.presenter = presenter + + let expectation = XCTestExpectation() + + stub(presenter) { stub in + when(stub.didReceive(mnemonic: any([String].self))).then { mnemonic in + XCTAssertEqual(mnemonic.count, 24) + expectation.fulfill() + } + when(stub.didReceiveMnemonicGeneration(error: any(Error.self))).thenDoNothing() + } + + interactor.setup() + + wait(for: [expectation], timeout: Constants.defaultExpectationDuration) + } } diff --git a/fearlessTests/SolanaBalanceSyncTests.swift b/fearlessTests/SolanaBalanceSyncTests.swift new file mode 100644 index 0000000000..6008f4690f --- /dev/null +++ b/fearlessTests/SolanaBalanceSyncTests.swift @@ -0,0 +1,359 @@ +import XCTest +@testable import fearless + +final class SolanaBalanceSyncTests: XCTestCase { + func testNormalizesNativeSOLAndTokenBalancesFromSIIntoSharedAssetBalances() async throws { + let client = FakeSolanaIndexerClient( + response: Self.balancesResponse( + lamports: "2500000000", + uiAmountString: "2.5", + tokens: [ + Self.tokenBalance( + mint: Self.splMint, + accountAddress: Self.tokenAccount, + amount: "12500000", + uiAmountString: "12.5", + decimals: 6, + program: "spl-token" + ), + Self.tokenBalance( + mint: Self.token2022Mint, + accountAddress: Self.token2022Account, + amount: "3000000001", + uiAmountString: "3.000000001", + decimals: 9, + program: "token-2022" + ) + ] + ), + metadata: [ + Self.tokenMetadata(mint: Self.splMint, symbol: "USDC", name: "USD Coin", program: "spl-token"), + Self.tokenMetadata(mint: Self.token2022Mint, symbol: "T22", name: "Token 2022 Asset", program: "token-2022") + ] + ) + + let result = try await SolanaBalanceSync(client: client).balances(wallet: Self.wallet) + + XCTAssertEqual(client.verifiedBaseURLs, ["https://si.soramitsu.io"]) + XCTAssertEqual(client.verifiedExpectedChainIds, ["solana:mainnet"]) + XCTAssertEqual(result.networkId, "solana-mainnet") + XCTAssertEqual(result.chainId, "solana:mainnet") + XCTAssertEqual(result.balances.count, 3) + XCTAssertEqual(result.nativeBalance.accountId, "solana-mainnet") + XCTAssertEqual(result.nativeBalance.assetId, "SOL") + XCTAssertEqual(result.nativeBalance.amount, "2500000000") + XCTAssertEqual(result.nativeBalance.uiAmountString, "2.5") + XCTAssertTrue(result.nativeBalance.isNative) + + let spl = try XCTUnwrap(result.tokenBalances.first) + XCTAssertEqual(spl.accountId, "solana-mainnet") + XCTAssertEqual(spl.assetId, Self.splMint) + XCTAssertEqual(spl.amount, "12500000") + XCTAssertEqual(spl.uiAmountString, "12.5") + XCTAssertEqual(spl.decimals, 6) + XCTAssertEqual(spl.symbol, "USDC") + XCTAssertEqual(spl.name, "USD Coin") + XCTAssertEqual(spl.tokenAccountId, Self.tokenAccount) + XCTAssertEqual(spl.contractAddress, Self.splMint) + XCTAssertEqual(spl.tokenProgram, "spl-token") + + let token2022 = result.tokenBalances[1] + XCTAssertEqual(token2022.assetId, Self.token2022Mint) + XCTAssertEqual(token2022.symbol, "T22") + XCTAssertEqual(token2022.name, "Token 2022 Asset") + XCTAssertEqual(token2022.tokenProgram, "token-2022") + XCTAssertEqual(client.metadataBatchCalls, [[Self.token2022Mint, Self.splMint].sorted()]) + } + + func testKeepsTokenBalancesWhenMetadataLookupFails() async throws { + let client = FakeSolanaIndexerClient( + response: Self.balancesResponse( + lamports: "1", + uiAmountString: "0.000000001", + tokens: [ + Self.tokenBalance( + mint: Self.splMint, + accountAddress: Self.tokenAccount, + amount: "7", + uiAmountString: "7", + decimals: 6, + program: "spl-token" + ) + ] + ), + metadataError: TestError.metadataUnavailable + ) + + let result = try await SolanaBalanceSync(client: client).balances(wallet: Self.wallet) + + XCTAssertEqual(result.tokenBalances.count, 1) + XCTAssertEqual(result.tokenBalances.first?.symbol, "So11...1112") + XCTAssertEqual(result.tokenBalances.first?.name, Self.splMint) + } + + func testSkipsMalformedTokenBalancesWithoutHidingNativeSOL() async throws { + let invalidOwner = Self.tokenBalance( + mint: Self.splMint, + accountAddress: Self.tokenAccount, + amount: "7", + uiAmountString: "7", + decimals: 6, + program: "spl-token" + ).copy(owner: "11111111111111111111111111111111") + let validToken = Self.tokenBalance( + mint: Self.token2022Mint, + accountAddress: Self.token2022Account, + amount: "3", + uiAmountString: "3", + decimals: 9, + program: "token-2022" + ) + let client = FakeSolanaIndexerClient( + response: Self.balancesResponse( + lamports: "1", + uiAmountString: "0.000000001", + tokens: [invalidOwner, validToken] + ) + ) + + let result = try await SolanaBalanceSync(client: client).balances(wallet: Self.wallet) + + XCTAssertEqual(result.nativeBalance.amount, "1") + XCTAssertEqual(result.tokenBalances.map(\.assetId), [Self.token2022Mint]) + } + + func testRejectsMismatchedWalletAndMalformedNativeBalances() async throws { + do { + _ = try await SolanaBalanceSync( + client: FakeSolanaIndexerClient(response: Self.balancesResponse(wallet: "11111111111111111111111111111111")) + ).balances(wallet: Self.wallet) + XCTFail("Expected wallet mismatch to be rejected") + } catch { + XCTAssertEqual(error as? SolanaBalanceSyncError, .walletMismatch) + } + + do { + _ = try await SolanaBalanceSync( + client: FakeSolanaIndexerClient(response: Self.balancesResponse(lamports: "-1")) + ).balances(wallet: Self.wallet) + XCTFail("Expected malformed native balance to be rejected") + } catch { + XCTAssertEqual(error as? SolanaBalanceSyncError, .invalidNativeBalance) + } + } + + func testRejectsMalformedWalletBeforeNetworkCalls() async throws { + let client = FakeSolanaIndexerClient() + + do { + _ = try await SolanaBalanceSync(client: client).balances(wallet: "../bad") + XCTFail("Expected malformed Solana wallet to be rejected") + } catch { + XCTAssertEqual(error as? SolanaIndexerRouteError, .invalidWallet) + } + + XCTAssertTrue(client.verifiedBaseURLs.isEmpty) + } + + func testVerifiesDevnetBalancesAgainstDevnetServiceIdentity() async throws { + let client = FakeSolanaIndexerClient( + response: Self.balancesResponse(lamports: "1", uiAmountString: "0.000000001") + ) + + let result = try await SolanaBalanceSync(client: client).balances( + wallet: Self.wallet, + network: UniversalWalletRegistry.solanaDevnet, + includeTokenMetadata: false + ) + + XCTAssertEqual(client.verifiedBaseURLs, ["https://si.soramitsu.io"]) + XCTAssertEqual(client.verifiedExpectedChainIds, ["solana:devnet"]) + XCTAssertEqual(result.networkId, "solana-devnet") + XCTAssertEqual(result.chainId, "solana:devnet") + } + + private final class FakeSolanaIndexerClient: SolanaIndexerClientProtocol { + private let response: SolanaWalletBalancesResponse + private let metadata: [SolanaTokenMetadata] + private let metadataError: Error? + private(set) var verifiedBaseURLs: [String] = [] + private(set) var verifiedExpectedChainIds: [String] = [] + private(set) var metadataBatchCalls: [[String]] = [] + + init( + response: SolanaWalletBalancesResponse = SolanaBalanceSyncTests.balancesResponse(), + metadata: [SolanaTokenMetadata] = [], + metadataError: Error? = nil + ) { + self.response = response + self.metadata = metadata + self.metadataError = metadataError + } + + func serviceInfo(baseURL: String?) async throws -> SolanaIndexerServiceInfo { + throw TestError.unexpectedEndpoint + } + + func verifyServiceInfo(baseURL: String?, expectedChainId: String) async throws -> SolanaIndexerServiceInfo { + verifiedBaseURLs.append(baseURL ?? "") + verifiedExpectedChainIds.append(expectedChainId) + return SolanaIndexerServiceInfo( + schemaVersion: 1, + serviceId: "si.soramitsu.io", + serviceName: "Solswap Indexer", + ecosystem: "solana", + chainId: expectedChainId, + network: expectedChainId.replacingOccurrences(of: "solana:", with: ""), + publicBaseUrl: "https://si.soramitsu.io", + readOnly: true, + capabilities: [], + endpoints: [:] + ) + } + + func balances(wallet: String, baseURL: String?) async throws -> SolanaWalletBalancesResponse { + XCTAssertEqual(wallet, SolanaBalanceSyncTests.wallet) + XCTAssertEqual(baseURL, UniversalWalletRegistry.solanaIndexerBaseURL.absoluteString) + return response + } + + func assets(wallet: String, baseURL: String?) async throws -> SolanaWalletAssetsResponse { + throw TestError.unexpectedEndpoint + } + + func state(wallet: String, baseURL: String?) async throws -> SolanaWalletStateResponse { + throw TestError.unexpectedEndpoint + } + + func transactions( + wallet: String, + baseURL: String?, + before: String?, + limit: Int + ) async throws -> SolanaWalletTransactionsResponse { + throw TestError.unexpectedEndpoint + } + + func tokenMetadata(mint: String, baseURL: String?) async throws -> SolanaTokenMetadata { + throw TestError.unexpectedEndpoint + } + + func tokenMetadataBatch(mints: [String], baseURL: String?) async throws -> SolanaTokenMetadataBatchResponse { + if let metadataError { + throw metadataError + } + metadataBatchCalls.append(mints) + return SolanaTokenMetadataBatchResponse( + total: metadata.count, + syncedAt: 1, + tokens: metadata.filter { mints.contains($0.mint) } + ) + } + } + + private enum TestError: Error { + case metadataUnavailable + case unexpectedEndpoint + } + + private static let wallet = "HAgk14JpMQLgt6rVgv7cBQFJWFto5Dqxi472uT3DKpqk" + private static let splMint = "So11111111111111111111111111111111111111112" + private static let token2022Mint = "5Pobwp6d9ihN9Nz38f87gVCEBFMgipFiSM2VtUhVit6w" + private static let tokenAccount = "9xQeWvG816bUx9EPfQ4vF5xXw4wa9VFeTuzA7h4sFnH" + private static let token2022Account = "7YttLkHDoYJk5HWB7wZWTsxjZCmMCbU6Uf17dYxcqYND" + + private static func balancesResponse( + wallet: String = wallet, + lamports: String = "0", + uiAmountString: String = "0", + tokens: [SolanaTokenBalance] = [] + ) -> SolanaWalletBalancesResponse { + SolanaWalletBalancesResponse( + wallet: wallet, + native: SolanaNativeBalance( + type: "native", + mint: "SOL", + lamports: lamports, + decimals: 9, + uiAmountString: uiAmountString + ), + tokens: tokens, + total: tokens.count + 1, + syncedAt: 1_710_000_000_000 + ) + } + + private static func tokenBalance( + mint: String, + accountAddress: String, + amount: String, + uiAmountString: String, + decimals: Int, + program: String + ) -> SolanaTokenBalance { + SolanaTokenBalance( + type: "token", + accountAddress: accountAddress, + mint: mint, + owner: wallet, + program: program, + programId: program == "token-2022" + ? "TokenzQdY11111111111111111111111111111111111" + : "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + amount: amount, + decimals: decimals, + uiAmountString: uiAmountString, + state: "initialized", + isNative: false, + delegatedAmount: nil, + rentExemptReserve: nil + ) + } + + private static func tokenMetadata( + mint: String, + symbol: String, + name: String, + program: String + ) -> SolanaTokenMetadata { + SolanaTokenMetadata( + mint: mint, + exists: true, + program: program, + programId: nil, + extensions: nil, + transferFeeConfig: nil, + transferHook: nil, + decimals: 6, + supply: "100000000", + uiSupplyString: "100", + mintAuthority: nil, + freezeAuthority: nil, + isInitialized: true, + name: name, + symbol: symbol, + uri: nil, + syncedAt: 1 + ) + } +} + +private extension SolanaTokenBalance { + func copy(owner: String) -> SolanaTokenBalance { + SolanaTokenBalance( + type: type, + accountAddress: accountAddress, + mint: mint, + owner: owner, + program: program, + programId: programId, + amount: amount, + decimals: decimals, + uiAmountString: uiAmountString, + state: state, + isNative: isNative, + delegatedAmount: delegatedAmount, + rentExemptReserve: rentExemptReserve + ) + } +} diff --git a/fearlessTests/SolanaHistoryOperationFactoryTests.swift b/fearlessTests/SolanaHistoryOperationFactoryTests.swift new file mode 100644 index 0000000000..49c0b394bc --- /dev/null +++ b/fearlessTests/SolanaHistoryOperationFactoryTests.swift @@ -0,0 +1,494 @@ +import RobinHood +import SSFModels +import XCTest +@testable import fearless + +final class SolanaHistoryOperationFactoryTests: XCTestCase { + func testAssemblyRoutesSolanaRegistryChainsToSolanaHistoryFactory() { + let txStorage: CoreDataRepository = + SubstrateDataStorageFacade.shared.createRepository() + let repository = AnyDataProviderRepository(txStorage) + + let mainnetFactory = HistoryOperationFactoriesAssembly.createOperationFactory( + chain: Self.solanaChain(), + txStorage: repository + ) + let devnetFactory = HistoryOperationFactoriesAssembly.createOperationFactory( + chain: Self.solanaChain(chainId: UniversalWalletRegistry.solanaDevnet.chainId), + txStorage: repository + ) + let registryMainnetFactory = HistoryOperationFactoriesAssembly.createOperationFactory( + chain: Self.solanaChain(chainId: UniversalWalletRegistry.solanaMainnet.id), + txStorage: repository + ) + let registryDevnetFactory = HistoryOperationFactoriesAssembly.createOperationFactory( + chain: Self.solanaChain(chainId: UniversalWalletRegistry.solanaDevnet.id), + txStorage: repository + ) + + XCTAssertTrue(mainnetFactory is SolanaHistoryOperationFactory) + XCTAssertTrue(registryMainnetFactory is SolanaHistoryOperationFactory) + XCTAssertTrue(devnetFactory is SolanaHistoryOperationFactory) + XCTAssertTrue(registryDevnetFactory is SolanaHistoryOperationFactory) + } + + func testMapsNativeSolHistoryIntoWalletTransactionPage() throws { + let client = FakeSolanaIndexerClient( + response: Self.transactionsResponse( + [ + Self.transaction(signature: Self.signature1, nativeDelta: "-1005000"), + Self.transaction(signature: Self.signature2, nativeDelta: "750000", feeLamports: "0") + ], + nextBefore: Self.signature2 + ) + ) + + let result = try execute( + SolanaHistoryOperationFactory(client: client).fetchTransactionHistoryOperation( + asset: Self.solAsset, + chain: Self.solanaChain(), + address: Self.wallet, + filters: [WalletTransactionHistoryFilter(type: .transfer, selected: true)], + pagination: Pagination(count: 10) + ) + ) + + let page = try XCTUnwrap(result) + XCTAssertEqual(client.verifiedBaseURLs, ["https://si.soramitsu.io"]) + XCTAssertEqual(client.lastWallet, Self.wallet) + XCTAssertEqual(client.lastBefore, nil) + XCTAssertEqual(client.lastLimit, 10) + XCTAssertEqual(page.context?[SolanaHistoryOperationFactory.paginationCursorKey], Self.signature2) + XCTAssertEqual(page.transactions.count, 2) + + let outgoing = page.transactions[0] + XCTAssertEqual(outgoing.transactionId, Self.signature1) + XCTAssertEqual(outgoing.status, .commited) + XCTAssertEqual(outgoing.assetId, "SOL") + XCTAssertEqual(outgoing.amount.decimalValue, Decimal(string: "0.001005")) + XCTAssertEqual(outgoing.fees.first?.amount.decimalValue, Decimal(string: "0.000005")) + XCTAssertEqual(outgoing.timestamp, 1_710_000_000) + XCTAssertEqual(outgoing.type, TransactionType.outgoing.rawValue) + + let incoming = page.transactions[1] + XCTAssertEqual(incoming.transactionId, Self.signature2) + XCTAssertEqual(incoming.amount.decimalValue, Decimal(string: "0.00075")) + XCTAssertTrue(incoming.fees.isEmpty) + XCTAssertEqual(incoming.type, TransactionType.incoming.rawValue) + } + + func testMapsTokenHistoryIntoWalletTransactionPage() throws { + let client = FakeSolanaIndexerClient( + response: Self.transactionsResponse( + [ + Self.transaction( + signature: Self.signature1, + nativeDelta: nil, + feeLamports: "5000", + tokenBalanceChanges: [ + Self.tokenChange(mint: Self.usdcMint, amountDelta: "-1500") + ] + ), + Self.transaction( + signature: Self.signature2, + nativeDelta: nil, + tokenBalanceChanges: [ + Self.tokenChange(mint: Self.otherTokenMint, amountDelta: "999") + ] + ) + ] + ) + ) + + let result = try execute( + SolanaHistoryOperationFactory(client: client).fetchTransactionHistoryOperation( + asset: Self.usdcAsset, + chain: Self.solanaChain(), + address: Self.wallet, + filters: [WalletTransactionHistoryFilter(type: .transfer, selected: true)], + pagination: Pagination(count: 10) + ) + ) + + let page = try XCTUnwrap(result) + XCTAssertEqual(client.verifiedBaseURLs, ["https://si.soramitsu.io"]) + XCTAssertEqual(client.transactionCalls, 1) + XCTAssertEqual(page.transactions.count, 1) + + let outgoing = page.transactions[0] + XCTAssertEqual(outgoing.transactionId, Self.signature1) + XCTAssertEqual(outgoing.status, .commited) + XCTAssertEqual(outgoing.assetId, Self.usdcMint) + XCTAssertEqual(outgoing.amount.decimalValue, Decimal(string: "0.0015")) + XCTAssertTrue(outgoing.fees.isEmpty) + XCTAssertEqual(outgoing.type, TransactionType.outgoing.rawValue) + } + + func testUsesPaginationCursorAndCustomBaseURL() throws { + let client = FakeSolanaIndexerClient( + response: Self.transactionsResponse([Self.transaction(signature: Self.signature2)]) + ) + + _ = try execute( + SolanaHistoryOperationFactory(client: client).fetchTransactionHistoryOperation( + asset: Self.solAsset, + chain: Self.solanaChain(historyBaseURL: "https://si.soramitsu.io/"), + address: Self.wallet, + filters: [], + pagination: Pagination( + count: 25, + context: [SolanaHistoryOperationFactory.paginationCursorKey: Self.signature1] + ) + ) + ) + + XCTAssertEqual(client.lastBaseURL, "https://si.soramitsu.io") + XCTAssertEqual(client.lastBefore, Self.signature1) + XCTAssertEqual(client.lastLimit, 25) + } + + func testRegistryIdMainnetUsesSolanaMainnetHistory() throws { + let client = FakeSolanaIndexerClient( + response: Self.transactionsResponse([Self.transaction(signature: Self.signature1)]) + ) + + let result = try execute( + SolanaHistoryOperationFactory(client: client).fetchTransactionHistoryOperation( + asset: Self.solAsset, + chain: Self.solanaChain(chainId: UniversalWalletRegistry.solanaMainnet.id), + address: Self.wallet, + filters: [WalletTransactionHistoryFilter(type: .transfer, selected: true)], + pagination: Pagination(count: 10) + ) + ) + + XCTAssertEqual(client.verifiedBaseURLs, ["https://si.soramitsu.io"]) + XCTAssertEqual(client.transactionCalls, 1) + XCTAssertEqual(result?.transactions.first?.transactionId, Self.signature1) + } + + func testRegistryIdDevnetUsesSolanaDevnetHistory() throws { + let client = FakeSolanaIndexerClient( + response: Self.transactionsResponse([Self.transaction(signature: Self.signature1)]) + ) + + let result = try execute( + SolanaHistoryOperationFactory(client: client).fetchTransactionHistoryOperation( + asset: Self.solAsset, + chain: Self.solanaChain(chainId: UniversalWalletRegistry.solanaDevnet.id), + address: Self.wallet, + filters: [WalletTransactionHistoryFilter(type: .transfer, selected: true)], + pagination: Pagination(count: 10) + ) + ) + + XCTAssertEqual(client.verifiedBaseURLs, ["https://si.soramitsu.io"]) + XCTAssertEqual(client.transactionCalls, 1) + XCTAssertEqual(result?.transactions.first?.transactionId, Self.signature1) + XCTAssertEqual(result?.transactions.first?.assetId, UniversalWalletRegistry.solanaDevnet.nativeAsset.id) + } + + func testGuardPathsAvoidIndexerCalls() throws { + let unsupportedAsset = AssetModel( + id: "USDC", + name: "USD Coin", + symbol: "USDC", + precision: 6, + isUtility: false, + isNative: false + ) + + for (asset, pagination, filterSet) in [ + (unsupportedAsset, Pagination(count: 10), [WalletTransactionHistoryFilter(type: .transfer, selected: true)]), + (Self.solAsset, Pagination(count: 0), [WalletTransactionHistoryFilter(type: .transfer, selected: true)]), + (Self.solAsset, Pagination(count: 10), [WalletTransactionHistoryFilter(type: .reward, selected: true)]) + ] { + let client = FakeSolanaIndexerClient() + let result = try execute( + SolanaHistoryOperationFactory(client: client).fetchTransactionHistoryOperation( + asset: asset, + chain: Self.solanaChain(), + address: Self.wallet, + filters: filterSet, + pagination: pagination + ) + ) + + XCTAssertEqual(client.transactionCalls, 0) + XCTAssertEqual(result?.transactions, []) + XCTAssertNil(result?.context) + } + } + + func testMalformedAddressReturnsEmptyPageBeforeNetworkCall() throws { + let client = FakeSolanaIndexerClient() + + let result = try execute( + SolanaHistoryOperationFactory(client: client).fetchTransactionHistoryOperation( + asset: Self.solAsset, + chain: Self.solanaChain(), + address: "../bad", + filters: [WalletTransactionHistoryFilter(type: .transfer, selected: true)], + pagination: Pagination(count: 10) + ) + ) + + XCTAssertEqual(client.transactionCalls, 0) + XCTAssertEqual(result?.transactions, []) + XCTAssertNil(result?.context) + } + + func testIndexerOutageReturnsEmptyPage() throws { + let client = FakeSolanaIndexerClient(error: TestError.indexerUnavailable) + + let result = try execute( + SolanaHistoryOperationFactory(client: client).fetchTransactionHistoryOperation( + asset: Self.solAsset, + chain: Self.solanaChain(), + address: Self.wallet, + filters: [WalletTransactionHistoryFilter(type: .transfer, selected: true)], + pagination: Pagination(count: 10) + ) + ) + + XCTAssertEqual(client.transactionCalls, 0) + XCTAssertEqual(client.verifiedBaseURLs, ["https://si.soramitsu.io"]) + XCTAssertEqual(result?.transactions, []) + XCTAssertNil(result?.context) + } + + func testDropsSwapSelfAndUnknownDirectionRowsUntilWalletShapesExist() throws { + let client = FakeSolanaIndexerClient( + response: Self.transactionsResponse([ + Self.transaction(signature: Self.signature1, nativeDelta: "-1000", solswapRoute: "route"), + Self.transaction(signature: Self.signature2, nativeDelta: "0") + ]) + ) + + let result = try execute( + SolanaHistoryOperationFactory(client: client).fetchTransactionHistoryOperation( + asset: Self.solAsset, + chain: Self.solanaChain(), + address: Self.wallet, + filters: [WalletTransactionHistoryFilter(type: .transfer, selected: true)], + pagination: Pagination(count: 10) + ) + ) + + XCTAssertEqual(result?.transactions, []) + } + + private func execute( + _ wrapper: CompoundOperationWrapper + ) throws -> AssetTransactionPageData? { + OperationQueue().addOperations(wrapper.allOperations, waitUntilFinished: true) + return try wrapper.targetOperation.extractResultData(throwing: BaseOperationError.parentOperationCancelled) + } + + private final class FakeSolanaIndexerClient: SolanaIndexerClientProtocol { + private let response: SolanaWalletTransactionsResponse + private let error: Error? + + private(set) var verifiedBaseURLs: [String] = [] + private(set) var verifiedExpectedChainIds: [String] = [] + private(set) var transactionCalls = 0 + private(set) var lastWallet: String? + private(set) var lastBaseURL: String? + private(set) var lastBefore: String? + private(set) var lastLimit: Int? + + init( + response: SolanaWalletTransactionsResponse = SolanaHistoryOperationFactoryTests.transactionsResponse([]), + error: Error? = nil + ) { + self.response = response + self.error = error + } + + func serviceInfo(baseURL _: String?) async throws -> SolanaIndexerServiceInfo { + throw TestError.unexpectedEndpoint + } + + func verifyServiceInfo(baseURL: String?, expectedChainId: String) async throws -> SolanaIndexerServiceInfo { + verifiedBaseURLs.append(baseURL ?? "") + verifiedExpectedChainIds.append(expectedChainId) + if let error { + throw error + } + + return SolanaIndexerServiceInfo( + schemaVersion: 1, + serviceId: "si.soramitsu.io", + serviceName: "Solswap Indexer", + ecosystem: "solana", + chainId: expectedChainId, + network: expectedChainId.replacingOccurrences(of: "solana:", with: ""), + publicBaseUrl: "https://si.soramitsu.io", + readOnly: true, + capabilities: [], + endpoints: [:] + ) + } + + func balances(wallet _: String, baseURL _: String?) async throws -> SolanaWalletBalancesResponse { + throw TestError.unexpectedEndpoint + } + + func assets(wallet _: String, baseURL _: String?) async throws -> SolanaWalletAssetsResponse { + throw TestError.unexpectedEndpoint + } + + func state(wallet _: String, baseURL _: String?) async throws -> SolanaWalletStateResponse { + throw TestError.unexpectedEndpoint + } + + func transactions( + wallet: String, + baseURL: String?, + before: String?, + limit: Int + ) async throws -> SolanaWalletTransactionsResponse { + transactionCalls += 1 + lastWallet = wallet + lastBaseURL = baseURL + lastBefore = before + lastLimit = limit + return response + } + + func tokenMetadata(mint _: String, baseURL _: String?) async throws -> SolanaTokenMetadata { + throw TestError.unexpectedEndpoint + } + + func tokenMetadataBatch(mints _: [String], baseURL _: String?) async throws -> SolanaTokenMetadataBatchResponse { + throw TestError.unexpectedEndpoint + } + } + + private enum TestError: Error { + case indexerUnavailable + case unexpectedEndpoint + } + + private static let wallet = "HAgk14JpMQLgt6rVgv7cBQFJWFto5Dqxi472uT3DKpqk" + private static let signature1 = String(repeating: "2", count: 88) + private static let signature2 = String(repeating: "3", count: 88) + private static let usdcMint = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" + private static let otherTokenMint = "Es9vMFrzaCERmJfrF4H2FYD4KCoNkYur5nC8LqgxmVZ1" + + private static let solAsset = AssetModel( + id: "SOL", + name: "Solana", + symbol: "SOL", + precision: 9, + isUtility: true, + isNative: true + ) + private static let usdcAsset = AssetModel( + id: usdcMint, + name: "USD Coin", + symbol: "USDC", + precision: 6, + isUtility: false, + isNative: false + ) + + private static func solanaChain( + chainId: String = UniversalWalletRegistry.solanaMainnet.chainId, + historyBaseURL: String? = nil + ) -> ChainModel { + let node = ChainNodeModel( + url: URL(string: "https://api.mainnet-beta.solana.com")!, + name: "Solana", + apikey: nil + ) + + let externalApi = historyBaseURL.map { + ChainModel.ExternalApiSet( + staking: nil, + history: ChainModel.BlockExplorer( + type: "subquery", + url: URL(string: $0)! + ), + explorers: nil + ) + } + + return ChainModel( + rank: nil, + disabled: false, + chainId: chainId, + parentId: nil, + paraId: nil, + name: Self.isSolanaDevnet(chainId) ? "Solana Devnet" : "Solana", + assets: [solAsset], + xcm: nil, + nodes: [node], + addressPrefix: 0, + icon: nil, + options: Self.isSolanaDevnet(chainId) ? [.testnet] : nil, + externalApi: externalApi, + customNodes: nil, + iosMinAppVersion: nil, + identityChain: nil + ) + } + + private static func isSolanaDevnet(_ chainId: String) -> Bool { + [ + UniversalWalletRegistry.solanaDevnet.chainId, + UniversalWalletRegistry.solanaDevnet.id + ].contains(chainId) + } + + private static func transactionsResponse( + _ transactions: [SolanaWalletTransactionRecord], + nextBefore: String? = nil + ) -> SolanaWalletTransactionsResponse { + SolanaWalletTransactionsResponse( + wallet: wallet, + before: nil, + nextBefore: nextBefore, + limit: 25, + total: transactions.count, + syncedAt: 1_710_000_000_000, + transactions: transactions + ) + } + + private static func transaction( + signature: String, + status: String = "success", + nativeDelta: String? = "-1000", + feeLamports: String? = "5000", + tokenBalanceChanges: [SolanaTokenBalanceChange] = [], + solswapRoute: String? = nil + ) -> SolanaWalletTransactionRecord { + SolanaWalletTransactionRecord( + signature: signature, + slot: 100, + timestamp: 1_710_000_000, + status: status, + feeLamports: feeLamports, + nativeBalanceChangeLamports: nativeDelta, + tokenBalanceChanges: tokenBalanceChanges, + programIds: [], + solswapRoute: solswapRoute + ) + } + + private static func tokenChange( + mint: String = usdcMint, + amountDelta: String = "-1500", + decimals: Int = 6 + ) -> SolanaTokenBalanceChange { + SolanaTokenBalanceChange( + mint: mint, + preAmount: "2000", + postAmount: "500", + amountDelta: amountDelta, + decimals: decimals, + uiAmountDeltaString: "-0.0015" + ) + } +} diff --git a/fearlessTests/SolanaIndexerClientTests.swift b/fearlessTests/SolanaIndexerClientTests.swift new file mode 100644 index 0000000000..1003226c3c --- /dev/null +++ b/fearlessTests/SolanaIndexerClientTests.swift @@ -0,0 +1,204 @@ +import XCTest +@testable import fearless + +final class SolanaIndexerClientTests: XCTestCase { + func testDelegatesWalletEndpointsThroughValidatedSIRoutes() async throws { + let transport = FakeSolanaIndexerTransport() + let client = SolanaIndexerClient(transport: transport) + + transport.enqueue(Self.serviceInfoJSON) + _ = try await client.verifyServiceInfo() + XCTAssertEqual(transport.lastRequest?.url?.absoluteString, "https://si.soramitsu.io/api/indexer/v1/service-info") + XCTAssertEqual(transport.lastRequest?.httpMethod, "GET") + + transport.enqueue(Self.balancesJSON) + _ = try await client.balances(wallet: Self.wallet) + XCTAssertEqual( + transport.lastRequest?.url?.absoluteString, + "https://si.soramitsu.io/api/indexer/v1/accounts/\(Self.wallet)/balances" + ) + XCTAssertEqual(transport.lastRequest?.httpMethod, "GET") + + transport.enqueue(Self.transactionsJSON) + _ = try await client.transactions(wallet: Self.wallet, before: Self.signature, limit: 25) + XCTAssertEqual( + transport.lastRequest?.url?.absoluteString, + "https://si.soramitsu.io/api/indexer/v1/accounts/\(Self.wallet)/txs?limit=25&before=\(Self.signature)" + ) + + transport.enqueue(Self.metadataJSON) + let metadata = try await client.tokenMetadata(mint: Self.mint) + XCTAssertEqual( + transport.lastRequest?.url?.absoluteString, + "https://si.soramitsu.io/api/indexer/v1/tokens/\(Self.mint)/metadata" + ) + XCTAssertEqual(metadata.extensions, ["transferFeeConfig", "transferHook"]) + XCTAssertEqual(metadata.transferFeeConfig?.withheldAmount, "0") + XCTAssertEqual(metadata.transferHook?.programId, Self.wallet) + XCTAssertEqual(metadata.transferHook?.extraAccountMetasAddress, Self.wallet) + } + + func testDelegatesMetadataBatchWithNormalizedRequestBody() async throws { + let transport = FakeSolanaIndexerTransport() + let client = SolanaIndexerClient(transport: transport) + + transport.enqueue(Self.metadataBatchJSON) + _ = try await client.tokenMetadataBatch(mints: [Self.mint]) + XCTAssertEqual(transport.lastRequest?.url?.absoluteString, "https://si.soramitsu.io/api/indexer/v1/tokens/metadata") + XCTAssertEqual(transport.lastRequest?.httpMethod, "POST") + XCTAssertEqual(transport.lastRequest?.value(forHTTPHeaderField: "Content-Type"), "application/json") + XCTAssertEqual(transport.lastRequest?.value(forHTTPHeaderField: "Accept"), "application/json") + XCTAssertNotNil(transport.lastRequest?.httpBody) + + do { + _ = try await client.tokenMetadataBatch(mints: []) + XCTFail("Expected empty metadata batch to be rejected") + } catch { + XCTAssertEqual(error as? SolanaIndexerRouteError, .invalidMints) + } + } + + func testVerifyServiceInfoRejectsMisroutedSolanaIndexer() async throws { + let transport = FakeSolanaIndexerTransport() + let client = SolanaIndexerClient(transport: transport) + + transport.enqueue(Self.misroutedServiceInfoJSON) + + do { + _ = try await client.verifyServiceInfo() + XCTFail("Expected misrouted SI service info to be rejected") + } catch { + XCTAssertEqual( + error as? SolanaIndexerClientError, + .unexpectedServiceInfo(Self.misroutedServiceInfo) + ) + XCTAssertEqual( + transport.lastRequest?.url?.absoluteString, + "https://si.soramitsu.io/api/indexer/v1/service-info" + ) + } + } + + private final class FakeSolanaIndexerTransport: UniversalWalletHTTPTransport { + private var queuedResponses: [Data] = [] + private(set) var lastRequest: URLRequest? + + func enqueue(_ data: Data) { + queuedResponses.append(data) + } + + func perform(_ request: URLRequest) async throws -> Data { + lastRequest = request + return queuedResponses.isEmpty ? Data("{}".utf8) : queuedResponses.removeFirst() + } + } + + private static let wallet = "HAgk14JpMQLgt6rVgv7cBQFJWFto5Dqxi472uT3DKpqk" + private static let mint = "So11111111111111111111111111111111111111112" + private static let signature = String(repeating: "1", count: 88) + + private static let serviceInfoJSON = Data(""" + { + "schemaVersion": 1, + "serviceId": "si.soramitsu.io", + "serviceName": "Solswap Indexer", + "ecosystem": "solana", + "chainId": "solana:mainnet", + "network": "mainnet", + "publicBaseUrl": "https://si.soramitsu.io", + "readOnly": true, + "capabilities": ["wallet-transactions"], + "endpoints": { + "transactions": "/api/indexer/v1/accounts/{wallet}/txs" + } + } + """.utf8) + + private static let misroutedServiceInfoJSON = Data(""" + { + "schemaVersion": 1, + "serviceId": "ti.soramitsu.io", + "serviceName": "TON Indexer", + "ecosystem": "ton", + "chainId": "ton:mainnet", + "network": "mainnet", + "publicBaseUrl": "https://ti.soramitsu.io", + "readOnly": true, + "capabilities": ["account-transactions"], + "endpoints": { + "transactions": "/api/indexer/v1/accounts/{addr}/txs" + } + } + """.utf8) + + private static let misroutedServiceInfo = SolanaIndexerServiceInfo( + schemaVersion: 1, + serviceId: "ti.soramitsu.io", + serviceName: "TON Indexer", + ecosystem: "ton", + chainId: "ton:mainnet", + network: "mainnet", + publicBaseUrl: "https://ti.soramitsu.io", + readOnly: true, + capabilities: ["account-transactions"], + endpoints: ["transactions": "/api/indexer/v1/accounts/{addr}/txs"] + ) + + private static let balancesJSON = Data(""" + { + "wallet": "\(wallet)", + "native": { + "type": "native", + "mint": "SOL", + "lamports": "0", + "decimals": 9, + "uiAmountString": "0" + }, + "tokens": [], + "total": 1, + "syncedAt": 1 + } + """.utf8) + + private static let transactionsJSON = Data(""" + { + "wallet": "\(wallet)", + "before": null, + "nextBefore": null, + "limit": 25, + "total": 0, + "syncedAt": 1, + "transactions": [] + } + """.utf8) + + private static let metadataJSON = Data(""" + { + "mint": "\(mint)", + "exists": true, + "program": "spl-token", + "extensions": ["transferFeeConfig", "transferHook"], + "transferFeeConfig": { + "transferFeeConfigAuthority": null, + "withdrawWithheldAuthority": null, + "withheldAmount": "0", + "olderTransferFee": null, + "newerTransferFee": null + }, + "transferHook": { + "authority": null, + "programId": "\(wallet)", + "extraAccountMetasAddress": "\(wallet)" + }, + "syncedAt": 1 + } + """.utf8) + + private static let metadataBatchJSON = Data(""" + { + "total": 1, + "syncedAt": 1, + "tokens": [] + } + """.utf8) +} diff --git a/fearlessTests/SolanaIndexerContractTests.swift b/fearlessTests/SolanaIndexerContractTests.swift new file mode 100644 index 0000000000..b20a289712 --- /dev/null +++ b/fearlessTests/SolanaIndexerContractTests.swift @@ -0,0 +1,191 @@ +import XCTest +@testable import fearless + +final class SolanaIndexerContractTests: XCTestCase { + func testBuildsSIWalletEndpointURLsWithValidatedParameters() throws { + XCTAssertEqual( + try SolanaIndexerRoutes.serviceInfoURL().absoluteString, + "\(UniversalWalletRegistry.solanaIndexerBaseURL.absoluteString)/api/indexer/v1/service-info" + ) + XCTAssertEqual( + try SolanaIndexerRoutes.balancesURL(wallet: Self.wallet).absoluteString, + "\(UniversalWalletRegistry.solanaIndexerBaseURL.absoluteString)/api/indexer/v1/accounts/\(Self.wallet)/balances" + ) + XCTAssertEqual( + try SolanaIndexerRoutes.assetsURL(wallet: Self.wallet, baseURL: "https://si.soramitsu.io/").absoluteString, + "https://si.soramitsu.io/api/indexer/v1/accounts/\(Self.wallet)/assets" + ) + XCTAssertEqual( + try SolanaIndexerRoutes.stateURL(wallet: Self.wallet, baseURL: "https://si.soramitsu.io/").absoluteString, + "https://si.soramitsu.io/api/indexer/v1/accounts/\(Self.wallet)/state" + ) + XCTAssertEqual( + try SolanaIndexerRoutes.transactionsURL( + wallet: Self.wallet, + baseURL: "https://si.soramitsu.io/", + before: Self.signature, + limit: 25 + ).absoluteString, + "https://si.soramitsu.io/api/indexer/v1/accounts/\(Self.wallet)/txs?limit=25&before=\(Self.signature)" + ) + XCTAssertEqual( + try SolanaIndexerRoutes.tokenMetadataURL(mint: Self.mint, baseURL: "https://si.soramitsu.io/").absoluteString, + "https://si.soramitsu.io/api/indexer/v1/tokens/\(Self.mint)/metadata" + ) + XCTAssertEqual( + try SolanaIndexerRoutes.tokenMetadataBatchURL(baseURL: "https://si.soramitsu.io/").absoluteString, + "https://si.soramitsu.io/api/indexer/v1/tokens/metadata" + ) + XCTAssertEqual(try SolanaIndexerRoutes.tokenMetadataBatchRequest(mints: [Self.mint]).mints, [Self.mint]) + } + + func testAllowsLocalHTTPBaseURLsButRejectsNonlocalInsecureBaseURLs() throws { + XCTAssertEqual(try SolanaIndexerRoutes.normalizeBaseURL("http://localhost:3000/"), "http://localhost:3000") + XCTAssertEqual(try SolanaIndexerRoutes.normalizeBaseURL("http://127.0.0.1:3000/"), "http://127.0.0.1:3000") + + assertRouteError(.invalidBaseURL) { + try SolanaIndexerRoutes.normalizeBaseURL("http://si.soramitsu.io") + } + assertRouteError(.invalidBaseURL) { + try SolanaIndexerRoutes.normalizeBaseURL("not a url") + } + } + + func testRejectsMalformedWalletTxAndTokenMetadataInputs() { + assertRouteError(.invalidWallet) { + _ = try SolanaIndexerRoutes.balancesURL(wallet: "../bad") + } + assertRouteError(.invalidMint) { + _ = try SolanaIndexerRoutes.tokenMetadataURL(mint: "../bad") + } + assertRouteError(.invalidLimit) { + _ = try SolanaIndexerRoutes.transactionsURL(wallet: Self.wallet, limit: 0) + } + assertRouteError(.invalidLimit) { + _ = try SolanaIndexerRoutes.transactionsURL(wallet: Self.wallet, limit: 251) + } + assertRouteError(.invalidBefore) { + _ = try SolanaIndexerRoutes.transactionsURL(wallet: Self.wallet, before: "../../../bad") + } + assertRouteError(.invalidMints) { + _ = try SolanaIndexerRoutes.tokenMetadataBatchRequest(mints: []) + } + assertRouteError(.invalidMints) { + _ = try SolanaIndexerRoutes.tokenMetadataBatchRequest(mints: Array(repeating: Self.mint, count: 101)) + } + } + + func testParsesSIBalancesWithoutLosingLargeIntegerPrecision() throws { + let data = """ + { + "wallet": "\(Self.wallet)", + "native": { + "type": "native", + "mint": "SOL", + "lamports": "1234567890", + "decimals": 9, + "uiAmountString": "1.234567890" + }, + "tokens": [ + { + "type": "token", + "accountAddress": "\(Self.tokenAccount)", + "mint": "\(Self.mint)", + "owner": "\(Self.wallet)", + "program": "spl-token", + "programId": "\(Self.tokenProgram)", + "amount": "18446744073709551616", + "decimals": 6, + "uiAmountString": "18446744073709.551616", + "state": "initialized", + "isNative": false, + "delegatedAmount": null, + "rentExemptReserve": null + } + ], + "total": 2, + "syncedAt": 1710000000000 + } + """.data(using: .utf8)! + + let response = try JSONDecoder().decode(SolanaWalletBalancesResponse.self, from: data) + + XCTAssertEqual(response.wallet, Self.wallet) + XCTAssertEqual(response.native.lamports, "1234567890") + XCTAssertEqual(response.tokens.first?.amount, "18446744073709551616") + XCTAssertEqual(response.tokens.first?.program, "spl-token") + XCTAssertEqual(response.total, 2) + } + + func testVerifiesSIServiceIdentityAndRejectsMisroutedServiceInfo() throws { + let data = """ + { + "schemaVersion": 1, + "serviceId": "si.soramitsu.io", + "serviceName": "Solswap Indexer", + "ecosystem": "solana", + "chainId": "solana:mainnet", + "network": "mainnet", + "publicBaseUrl": "https://si.soramitsu.io", + "readOnly": true, + "capabilities": ["wallet-transactions"], + "endpoints": { + "transactions": "/api/indexer/v1/accounts/{wallet}/txs" + } + } + """.data(using: .utf8)! + let response = try JSONDecoder().decode(SolanaIndexerServiceInfo.self, from: data) + + XCTAssertTrue(response.isExpectedSIServiceInfo()) + XCTAssertFalse(response.isExpectedSIServiceInfo(expectedChainId: "solana:devnet")) + XCTAssertFalse( + SolanaIndexerServiceInfo( + schemaVersion: response.schemaVersion, + serviceId: "ti.soramitsu.io", + serviceName: response.serviceName, + ecosystem: response.ecosystem, + chainId: response.chainId, + network: response.network, + publicBaseUrl: response.publicBaseUrl, + readOnly: response.readOnly, + capabilities: response.capabilities, + endpoints: response.endpoints + ).isExpectedSIServiceInfo() + ) + } + + func testServiceIdentityValidationIsNetworkAware() throws { + let devnetInfo = SolanaIndexerServiceInfo( + schemaVersion: 1, + serviceId: "si.soramitsu.io", + serviceName: "Solswap Indexer", + ecosystem: "solana", + chainId: "solana:devnet", + network: "devnet", + publicBaseUrl: "https://si.soramitsu.io", + readOnly: true, + capabilities: ["wallet-transactions"], + endpoints: [:] + ) + + XCTAssertTrue(devnetInfo.isExpectedSIServiceInfo(expectedChainId: "solana:devnet")) + XCTAssertFalse(devnetInfo.isExpectedSIServiceInfo(expectedChainId: "solana:mainnet")) + } + + private func assertRouteError( + _ expected: SolanaIndexerRouteError, + file: StaticString = #filePath, + line: UInt = #line, + block: () throws -> Void + ) { + XCTAssertThrowsError(try block(), file: file, line: line) { error in + XCTAssertEqual(error as? SolanaIndexerRouteError, expected, file: file, line: line) + } + } + + private static let wallet = "HAgk14JpMQLgt6rVgv7cBQFJWFto5Dqxi472uT3DKpqk" + private static let mint = "So11111111111111111111111111111111111111112" + private static let tokenAccount = "9xQeWvG816bUx9EPfQ4vF5xXw4wa9VFeTuzA7h4sFnH" + private static let tokenProgram = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + private static let signature = String(repeating: "1", count: 88) +} diff --git a/fearlessTests/SolanaRpcClientTests.swift b/fearlessTests/SolanaRpcClientTests.swift new file mode 100644 index 0000000000..99ad6129c8 --- /dev/null +++ b/fearlessTests/SolanaRpcClientTests.swift @@ -0,0 +1,223 @@ +import XCTest +@testable import fearless + +final class SolanaRpcClientTests: XCTestCase { + func testPostsValidatedJSONRPCRequestsToConfiguredSolanaRPCEndpoints() async throws { + let transport = FakeSolanaRpcTransport() + let client = try SolanaRpcClient(transport: transport, defaultRPCURL: Self.devnetRPC) + + let blockhash = try await client.latestBlockhash(commitment: .finalized) + let fee = try await client.feeForMessage(Self.transaction, commitment: .confirmed) + let rent = try await client.minimumBalanceForRentExemption(dataLength: 165, commitment: .processed) + let accountExists = try await client.accountExists(address: Self.accountAddress, commitment: .finalized) + let simulation = try await client.simulateTransaction( + Self.transaction, + options: SolanaSimulationOptions(commitment: .processed) + ) + let signature = try await client.sendRawTransaction( + Self.transaction, + options: SolanaBroadcastOptions(maxRetries: 3, preflightCommitment: .confirmed, skipPreflight: true) + ) + + XCTAssertEqual(blockhash.value.blockhash, Self.blockhash) + XCTAssertEqual(blockhash.value.lastValidBlockHeight, 456) + XCTAssertEqual(fee.value, 5000) + XCTAssertEqual(rent, 890_880) + XCTAssertTrue(accountExists) + XCTAssertEqual(simulation.value.logs, ["Program log: ok"]) + XCTAssertEqual(simulation.value.replacementBlockhash?.blockhash, Self.blockhash) + XCTAssertEqual(simulation.value.unitsConsumed, 321) + XCTAssertEqual(signature, Self.signature) + XCTAssertEqual( + transport.requestBodies.map { $0["method"] as? String }, + [ + "getLatestBlockhash", + "getFeeForMessage", + "getMinimumBalanceForRentExemption", + "getAccountInfo", + "simulateTransaction", + "sendTransaction" + ] + ) + XCTAssertEqual(transport.requests.compactMap(\.url?.absoluteString), Array(repeating: Self.devnetRPC, count: 6)) + XCTAssertEqual((transport.requestBodies[0]["params"] as? [[String: Any]])?.first?["commitment"] as? String, "finalized") + XCTAssertEqual((transport.requestBodies[1]["params"] as? [Any])?.first as? String, Self.transaction) + XCTAssertEqual((transport.requestBodies[2]["params"] as? [Any])?.first as? Int, 165) + XCTAssertEqual((transport.requestBodies[3]["params"] as? [Any])?.first as? String, Self.accountAddress) + XCTAssertEqual(((transport.requestBodies[3]["params"] as? [Any])?[1] as? [String: Any])?["encoding"] as? String, "base64") + XCTAssertEqual(((transport.requestBodies[4]["params"] as? [Any])?[1] as? [String: Any])?["sigVerify"] as? Bool, false) + XCTAssertEqual(((transport.requestBodies[5]["params"] as? [Any])?[1] as? [String: Any])?["maxRetries"] as? Int, 3) + } + + func testRejectsUnsafeURLsMalformedPayloadsAndInvalidOptionsBeforeFetch() async throws { + XCTAssertThrowsError(try SolanaRpcRoutes.normalizeRPCURL("http://api.mainnet-beta.solana.com")) { error in + XCTAssertEqual(error as? SolanaRpcClientError, .invalidRPCURL) + } + XCTAssertEqual( + try SolanaRpcRoutes.normalizeRPCURL("http://localhost:8899/?api-key=secret#frag"), + "http://localhost:8899" + ) + + let transport = FakeSolanaRpcTransport() + let client = try SolanaRpcClient(transport: transport) + + await assertRpcError(.invalidTransaction) { + _ = try await client.feeForMessage("not base64") + } + await assertRpcError(.invalidDataLength) { + _ = try await client.minimumBalanceForRentExemption(dataLength: -1) + } + await assertRpcError(.invalidDataLength) { + _ = try await client.minimumBalanceForRentExemption(dataLength: 10_000_001) + } + await assertRpcError(.invalidAccountAddress) { + _ = try await client.accountExists(address: "not-base58") + } + await assertRpcError(.invalidSimulationOptions) { + _ = try await client.simulateTransaction( + Self.transaction, + options: SolanaSimulationOptions(replaceRecentBlockhash: true, sigVerify: true) + ) + } + await assertRpcError(.invalidMaxRetries) { + _ = try await client.sendRawTransaction(Self.transaction, options: SolanaBroadcastOptions(maxRetries: 11)) + } + XCTAssertTrue(transport.requests.isEmpty) + } + + func testMapsRPCErrorsMalformedEnvelopesAndBadResultShapesToTypedErrors() async throws { + let rpcErrorTransport = FakeSolanaRpcTransport( + fixedResponse: Self.data(""" + {"jsonrpc":"2.0","id":1,"error":{"code":-32002,"message":"simulation failed","data":{"logs":[]}}} + """) + ) + await assertRpcError(.rpcError(SolanaJsonRpcErrorPayload(code: -32002, message: "simulation failed", dataJSON: "{\"logs\":[]}"))) { + _ = try await SolanaRpcClient(transport: rpcErrorTransport).latestBlockhash() + } + + let mismatchedId = FakeSolanaRpcTransport(fixedResponse: Self.data(#"{"jsonrpc":"2.0","id":2,"result":{}}"#)) + await assertRpcError(.invalidRPCResponse) { + _ = try await SolanaRpcClient(transport: mismatchedId).latestBlockhash() + } + + let badSimulation = FakeSolanaRpcTransport( + fixedResponse: Self.rpcResponse(id: 1, result: #"{"context":{"slot":1},"value":{"err":null,"logs":[1]}}"#) + ) + await assertRpcError(.invalidSimulationResponse) { + _ = try await SolanaRpcClient(transport: badSimulation).simulateTransaction(Self.transaction) + } + + let badFee = FakeSolanaRpcTransport( + fixedResponse: Self.rpcResponse(id: 1, result: #"{"context":{"slot":1},"value":-1}"#) + ) + await assertRpcError(.invalidFeeResponse) { + _ = try await SolanaRpcClient(transport: badFee).feeForMessage(Self.transaction) + } + + let unavailableFee = FakeSolanaRpcTransport( + fixedResponse: Self.rpcResponse(id: 1, result: #"{"context":{"slot":1},"value":null}"#) + ) + let fee = try await SolanaRpcClient(transport: unavailableFee).feeForMessage(Self.transaction) + XCTAssertNil(fee.value) + + let badRent = FakeSolanaRpcTransport(fixedResponse: Self.rpcResponse(id: 1, result: #""890880""#)) + await assertRpcError(.invalidRentResponse) { + _ = try await SolanaRpcClient(transport: badRent).minimumBalanceForRentExemption(dataLength: 165) + } + + let missingAccount = FakeSolanaRpcTransport( + fixedResponse: Self.rpcResponse(id: 1, result: #"{"context":{"slot":1},"value":null}"#) + ) + let accountExists = try await SolanaRpcClient(transport: missingAccount).accountExists(address: Self.accountAddress) + XCTAssertFalse(accountExists) + + let badAccountInfo = FakeSolanaRpcTransport( + fixedResponse: Self.rpcResponse(id: 1, result: #"{"context":{"slot":1},"value":[]}"#) + ) + await assertRpcError(.invalidAccountInfoResponse) { + _ = try await SolanaRpcClient(transport: badAccountInfo).accountExists(address: Self.accountAddress) + } + + let badSignature = FakeSolanaRpcTransport(fixedResponse: Self.rpcResponse(id: 1, result: #""not-a-signature""#)) + await assertRpcError(.invalidSignatureResponse) { + _ = try await SolanaRpcClient(transport: badSignature).sendRawTransaction(Self.transaction) + } + } + + private final class FakeSolanaRpcTransport: UniversalWalletHTTPTransport { + private let fixedResponse: Data? + private(set) var requests: [URLRequest] = [] + private(set) var requestBodies: [[String: Any]] = [] + + init(fixedResponse: Data? = nil) { + self.fixedResponse = fixedResponse + } + + func perform(_ request: URLRequest) async throws -> Data { + requests.append(request) + if let body = request.httpBody, + let object = try JSONSerialization.jsonObject(with: body) as? [String: Any] { + requestBodies.append(object) + } + if let fixedResponse { + return fixedResponse + } + + let body = requestBodies.last ?? [:] + let id = body["id"] as? Int ?? 0 + switch body["method"] as? String { + case "getLatestBlockhash": + return SolanaRpcClientTests.rpcResponse( + id: id, + result: #"{"context":{"apiVersion":"2.0.0","slot":123},"value":{"blockhash":"\#(SolanaRpcClientTests.blockhash)","lastValidBlockHeight":456}}"# + ) + case "getFeeForMessage": + return SolanaRpcClientTests.rpcResponse(id: id, result: #"{"context":{"slot":124},"value":5000}"#) + case "getMinimumBalanceForRentExemption": + return SolanaRpcClientTests.rpcResponse(id: id, result: "890880") + case "getAccountInfo": + return SolanaRpcClientTests.rpcResponse( + id: id, + result: #"{"context":{"slot":124},"value":{"lamports":2039280,"owner":"TokenzQdBNbLqP5VEkvhsuKq2G6Z1Lx8pGMyrEKHKzJ"}}"# + ) + case "simulateTransaction": + return SolanaRpcClientTests.rpcResponse( + id: id, + result: #"{"context":{"slot":124},"value":{"err":null,"logs":["Program log: ok"],"replacementBlockhash":{"blockhash":"\#(SolanaRpcClientTests.blockhash)","lastValidBlockHeight":789},"unitsConsumed":321}}"# + ) + case "sendTransaction": + return SolanaRpcClientTests.rpcResponse(id: id, result: #""\#(SolanaRpcClientTests.signature)""#) + default: + return SolanaRpcClientTests.data(#"{"jsonrpc":"2.0","id":\#(id),"result":null}"#) + } + } + } + + private func assertRpcError( + _ expected: SolanaRpcClientError, + file: StaticString = #filePath, + line: UInt = #line, + block: () async throws -> Void + ) async { + do { + try await block() + XCTFail("Expected Solana RPC error", file: file, line: line) + } catch { + XCTAssertEqual(error as? SolanaRpcClientError, expected, file: file, line: line) + } + } + + private static let devnetRPC = "https://api.devnet.solana.com" + private static let transaction = "AQIDBA==" + private static let accountAddress = "So11111111111111111111111111111111111111112" + private static let blockhash = "7GjNiPun3AzEazTZoFEjZgcBMeuaXdpjHq2raZTmTrfs" + private static let signature = "5NfHnqDyzT9qyfxZDq2sSskAMGuFZ3VRqW4EQxghKqrKYdKq6cZNW1J34w7qE6nGx1eDQe5s2eKxB2ZtE1xU9qgN" + + private static func rpcResponse(id: Int, result: String) -> Data { + data(#"{"jsonrpc":"2.0","id":\#(id),"result":\#(result)}"#) + } + + private static func data(_ value: String) -> Data { + Data(value.utf8) + } +} diff --git a/fearlessTests/SolanaSendServiceTests.swift b/fearlessTests/SolanaSendServiceTests.swift new file mode 100644 index 0000000000..9a2f36f9ea --- /dev/null +++ b/fearlessTests/SolanaSendServiceTests.swift @@ -0,0 +1,994 @@ +import XCTest +@testable import fearless +import BigInt +import SSFModels +import SSFUtils + +final class SolanaSendServiceTests: XCTestCase { + func testPreparesSignedTransferWithFeeAndSimulationWithoutBroadcasting() async throws { + let rpc = FakeSolanaRpcClient() + let prepared = try await SolanaSendService(rpcClient: rpc).prepare(Self.request(rpcURL: Self.devnetRPC)) + + XCTAssertEqual(prepared.feeLamports, 5_000) + XCTAssertEqual(prepared.unsigned.senderAddress, Self.sender) + XCTAssertEqual(prepared.unsigned.recipientAddress, Self.recipient) + XCTAssertEqual(prepared.unsigned.recentBlockhash, Self.blockhash) + XCTAssertEqual(prepared.signed.signer, Self.sender) + XCTAssertEqual(rpc.feeMessages, [prepared.unsigned.messageBase64]) + XCTAssertEqual(rpc.simulatedTransactions, [prepared.signed.signedTransactionBase64]) + XCTAssertEqual(prepared.simulation?.value.unitsConsumed, 42) + XCTAssertNil(rpc.sentTransaction) + XCTAssertEqual(rpc.urls, Array(repeating: Self.devnetRPC, count: 3)) + } + + func testSendsSignedTransferAndRequiresMatchingBroadcastSignature() async throws { + let rpc = FakeSolanaRpcClient() + let sent = try await SolanaSendService(rpcClient: rpc).send(Self.request(rpcURL: Self.devnetRPC)) + + XCTAssertEqual(sent.signature, sent.prepared.signed.signatureBase58) + XCTAssertEqual(rpc.sentTransaction, sent.prepared.signed.signedTransactionBase64) + XCTAssertEqual(rpc.broadcastOptions?.preflightCommitment, .finalized) + XCTAssertEqual(rpc.broadcastOptions?.maxRetries, 3) + } + + func testSolanaTransferServiceEstimatesFeeAndSendsNativeSOL() async throws { + let chain = Self.solanaChain(historyBaseURL: "https://si.soramitsu.io/") + let wallet = try Self.walletWithSolanaAccount(chainId: chain.chainId) + let rpc = FakeSolanaRpcClient() + let balanceSync = FakeSolanaTransferBalanceSync(nativeLamports: "2000000000") + let service = try SolanaTransferService( + wallet: wallet, + chain: chain, + rpcClient: rpc, + balanceSync: balanceSync, + mnemonicProvider: FakeUniversalWalletMnemonicProvider(mnemonic: Self.mnemonic) + ) + let transfer = Self.nativeTransfer(chain: chain) + + let fee = try await service.estimateFee(for: transfer) + let signature = try await service.submit(transfer: transfer) + + XCTAssertEqual(fee, BigUInt(5_000)) + XCTAssertEqual(signature, rpc.sentSignature) + XCTAssertEqual(rpc.urls, Array(repeating: Self.mainnetRPC, count: 6)) + XCTAssertEqual(balanceSync.invocations, [ + FakeSolanaTransferBalanceSync.Invocation( + wallet: Self.sender, + network: UniversalWalletRegistry.solanaMainnet, + baseURL: "https://si.soramitsu.io/", + includeTokenMetadata: false + ) + ]) + } + + func testSolanaTransferServiceUsesDevnetRegistryRPCWhenNoNodeExists() async throws { + let chain = Self.solanaChain(network: UniversalWalletRegistry.solanaDevnet, nodes: []) + let wallet = try Self.walletWithSolanaAccount(chainId: chain.chainId) + let rpc = FakeSolanaRpcClient() + let service = try SolanaTransferService( + wallet: wallet, + chain: chain, + rpcClient: rpc, + balanceSync: FakeSolanaTransferBalanceSync(nativeLamports: "2000000000"), + mnemonicProvider: FakeUniversalWalletMnemonicProvider(mnemonic: Self.mnemonic) + ) + + _ = try await service.estimateFee(for: Self.nativeTransfer(chain: chain)) + + XCTAssertEqual(rpc.urls, Array(repeating: Self.devnetRPC, count: 2)) + } + + func testSolanaTransferServiceEstimatesFeeAndSendsStandardSPLToken() async throws { + let chain = Self.solanaChain(assets: [Self.solAsset, Self.usdcAsset]) + let wallet = try Self.walletWithSolanaAccount(chainId: chain.chainId) + let rpc = FakeSolanaRpcClient() + let balanceSync = FakeSolanaTransferBalanceSync( + nativeLamports: "2000000000", + tokenBalances: [Self.tokenIndexedBalance(amount: "5000000")] + ) + let service = try SolanaTransferService( + wallet: wallet, + chain: chain, + rpcClient: rpc, + balanceSync: balanceSync, + mnemonicProvider: FakeUniversalWalletMnemonicProvider(mnemonic: Self.mnemonic) + ) + let transfer = Transfer( + chainAsset: ChainAsset(chain: chain, asset: Self.usdcAsset), + amount: BigUInt(1_250_000), + receiver: Self.recipient, + tip: nil, + appId: nil + ) + + let fee = try await service.estimateFee(for: transfer) + let signature = try await service.submit(transfer: transfer) + let destinationTokenAccount = try SolanaTransferTransactionBuilder.deriveAssociatedTokenAccountAddress( + walletAddress: Self.recipient, + mintAddress: Self.mint, + tokenProgram: .splToken + ) + + XCTAssertEqual(fee, BigUInt(5_000)) + XCTAssertEqual(signature, rpc.sentSignature) + XCTAssertEqual(rpc.accountExistsRequests, [destinationTokenAccount, destinationTokenAccount]) + XCTAssertEqual(rpc.urls, Array(repeating: Self.mainnetRPC, count: 9)) + XCTAssertEqual(balanceSync.invocations, Array( + repeating: FakeSolanaTransferBalanceSync.Invocation( + wallet: Self.sender, + network: UniversalWalletRegistry.solanaMainnet, + baseURL: nil, + includeTokenMetadata: false + ), + count: 3 + )) + } + + func testSolanaTransferServiceRejectsToken2022UntilPolicyUIIsSupported() async throws { + let chain = Self.solanaChain(assets: [Self.solAsset, Self.usdcAsset]) + let wallet = try Self.walletWithSolanaAccount(chainId: chain.chainId) + let rpc = FakeSolanaRpcClient() + let service = try SolanaTransferService( + wallet: wallet, + chain: chain, + rpcClient: rpc, + balanceSync: FakeSolanaTransferBalanceSync( + nativeLamports: "2000000000", + tokenBalances: [ + Self.tokenIndexedBalance( + amount: "5000000", + tokenProgram: "token-2022" + ) + ] + ), + mnemonicProvider: FakeUniversalWalletMnemonicProvider(mnemonic: Self.mnemonic) + ) + let transfer = Transfer( + chainAsset: ChainAsset(chain: chain, asset: Self.usdcAsset), + amount: BigUInt(1_250_000), + receiver: Self.recipient, + tip: nil, + appId: nil + ) + + do { + _ = try await service.estimateFee(for: transfer) + XCTFail("Expected Token-2022 transfer fee rejection") + } catch let TransferServiceError.cannotEstimateFee(reason) { + XCTAssertTrue(reason.contains("SPL Token")) + } catch { + XCTFail("Unexpected Token-2022 transfer fee error: \(error)") + } + XCTAssertEqual(rpc.calls, 0) + } + + func testSolanaTransferServiceRejectsMissingMnemonicRootMaterial() async throws { + let chain = Self.solanaChain() + let wallet = try Self.walletWithSolanaAccount(chainId: chain.chainId) + let service = try SolanaTransferService( + wallet: wallet, + chain: chain, + rpcClient: FakeSolanaRpcClient(), + balanceSync: FakeSolanaTransferBalanceSync(nativeLamports: "2000000000"), + mnemonicProvider: FakeUniversalWalletMnemonicProvider(mnemonic: nil) + ) + + do { + _ = try await service.submit(transfer: Self.nativeTransfer(chain: chain)) + XCTFail("Expected missing mnemonic rejection") + } catch let TransferServiceError.transferFailed(reason) { + XCTAssertTrue(reason.contains("mnemonic")) + } catch { + XCTFail("Unexpected missing mnemonic error: \(error)") + } + } + + func testSolanaTransferServiceRejectsMnemonicMismatchBeforeRPCOrIndexerCalls() async throws { + let chain = Self.solanaChain(historyBaseURL: "https://si.soramitsu.io/") + let wallet = try Self.walletWithSolanaAccount(chainId: chain.chainId) + let rpc = FakeSolanaRpcClient() + let balanceSync = FakeSolanaTransferBalanceSync(nativeLamports: "2000000000") + let service = try SolanaTransferService( + wallet: wallet, + chain: chain, + rpcClient: rpc, + balanceSync: balanceSync, + mnemonicProvider: FakeUniversalWalletMnemonicProvider(mnemonic: Self.otherMnemonic) + ) + + do { + _ = try await service.submit(transfer: Self.nativeTransfer(chain: chain)) + XCTFail("Expected Solana transfer service to reject mismatched mnemonic material") + } catch let TransferServiceError.transferFailed(reason) { + XCTAssertTrue(reason.contains("does not match selected wallet")) + } catch { + XCTFail("Unexpected mismatched mnemonic error: \(error)") + } + XCTAssertEqual(rpc.calls, 0) + XCTAssertTrue(balanceSync.invocations.isEmpty) + } + + func testPreparesSignedTokenTransferWithFeeAndSimulationWithoutBroadcasting() async throws { + let rpc = FakeSolanaRpcClient() + let prepared = try await SolanaSendService(rpcClient: rpc).prepareTokenTransfer(Self.tokenRequest(rpcURL: Self.devnetRPC)) + let parsed = try SolanaTransactionSigner.parseSerializedTransaction(prepared.unsigned.transaction) + + XCTAssertEqual(prepared.feeLamports, 5_000) + XCTAssertEqual(prepared.unsigned.ownerAddress, Self.sender) + XCTAssertEqual(prepared.unsigned.sourceTokenAccount, Self.sourceTokenAccount) + XCTAssertEqual(prepared.unsigned.destinationTokenAccount, Self.destinationTokenAccount) + XCTAssertEqual(prepared.unsigned.mintAddress, Self.mint) + XCTAssertFalse(prepared.unsigned.createsDestinationAssociatedTokenAccount) + XCTAssertEqual(parsed.instructionCount, 1) + XCTAssertEqual(prepared.signed.signer, Self.sender) + XCTAssertEqual(rpc.feeMessages, [prepared.unsigned.messageBase64]) + XCTAssertEqual(rpc.simulatedTransactions, [prepared.signed.signedTransactionBase64]) + XCTAssertEqual(prepared.simulation?.value.unitsConsumed, 42) + XCTAssertNil(rpc.sentTransaction) + XCTAssertEqual(rpc.urls, Array(repeating: Self.devnetRPC, count: 3)) + } + + func testSendsTokenTransferWithAssociatedTokenAccountCreateAndRequiresMatchingSignature() async throws { + let rpc = FakeSolanaRpcClient() + let sent = try await SolanaSendService(rpcClient: rpc).sendTokenTransfer( + Self.tokenRequest( + destinationTokenAccount: nil, + destinationWalletAddress: Self.destinationWallet, + rpcURL: Self.devnetRPC, + tokenProgram: .token2022, + extraAccounts: [ + SolanaTokenTransferExtraAccount(address: Self.extraReadonlyAccount), + SolanaTokenTransferExtraAccount(address: Self.extraWritableAccount, isWritable: true) + ] + ) + ) + let parsed = try SolanaTransactionSigner.parseSerializedTransaction(sent.prepared.unsigned.transaction) + + XCTAssertEqual(sent.signature, sent.prepared.signed.signatureBase58) + XCTAssertTrue(sent.prepared.unsigned.createsDestinationAssociatedTokenAccount) + XCTAssertEqual(sent.prepared.unsigned.destinationWalletAddress, Self.destinationWallet) + XCTAssertEqual(sent.prepared.unsigned.destinationTokenAccount, Self.destinationWalletToken2022AssociatedTokenAccount) + XCTAssertEqual(sent.prepared.unsigned.tokenProgram, .token2022) + XCTAssertEqual(parsed.instructionCount, 2) + XCTAssertEqual(rpc.accountExistsRequests, [Self.destinationWalletToken2022AssociatedTokenAccount]) + XCTAssertEqual(rpc.sentTransaction, sent.prepared.signed.signedTransactionBase64) + XCTAssertEqual(rpc.broadcastOptions?.preflightCommitment, .finalized) + XCTAssertEqual(rpc.broadcastOptions?.maxRetries, 3) + } + + func testPreparesTokenTransferWithoutAssociatedTokenAccountCreateWhenDestinationAccountExists() async throws { + let rpc = FakeSolanaRpcClient(existingAccounts: [Self.destinationWalletToken2022AssociatedTokenAccount]) + let prepared = try await SolanaSendService(rpcClient: rpc).prepareTokenTransfer( + Self.tokenRequest( + destinationTokenAccount: nil, + destinationWalletAddress: Self.destinationWallet, + tokenProgram: .token2022 + ) + ) + let parsed = try SolanaTransactionSigner.parseSerializedTransaction(prepared.unsigned.transaction) + + XCTAssertFalse(prepared.unsigned.createsDestinationAssociatedTokenAccount) + XCTAssertNil(prepared.unsigned.destinationWalletAddress) + XCTAssertEqual(prepared.unsigned.destinationTokenAccount, Self.destinationWalletToken2022AssociatedTokenAccount) + XCTAssertEqual(parsed.instructionCount, 1) + XCTAssertEqual(rpc.accountExistsRequests, [Self.destinationWalletToken2022AssociatedTokenAccount]) + } + + func testRejectsInvalidRequestParametersBeforeRPCCalls() async { + let rpc = FakeSolanaRpcClient() + await assertTransferError(.invalidLamports) { + _ = try await SolanaSendService(rpcClient: rpc).prepare(Self.request(lamports: 0)) + } + await assertTransferError(.invalidRecipient) { + _ = try await SolanaSendService(rpcClient: rpc).prepare(Self.request(recipientAddress: "not-base58")) + } + XCTAssertEqual(rpc.calls, 0) + } + + func testRejectsInvalidTokenRequestParametersAndAccountDerivationFailures() async { + let rpc = FakeSolanaRpcClient() + + await assertSendError(.invalidAccount) { + _ = try await SolanaSendService(rpcClient: rpc).prepareTokenTransfer( + Self.tokenRequest(derivationPath: "m/44'/501'/0/0'") + ) + } + XCTAssertEqual(rpc.calls, 0) + + await assertTransferError(.invalidTokenAmount) { + _ = try await SolanaSendService(rpcClient: rpc).prepareTokenTransfer(Self.tokenRequest(rawAmount: 0)) + } + await assertTransferError(.invalidWallet) { + _ = try await SolanaSendService(rpcClient: rpc).prepareTokenTransfer( + Self.tokenRequest(destinationWalletAddress: "not-base58") + ) + } + await assertTransferError(.invalidAssociatedTokenAccount) { + _ = try await SolanaSendService(rpcClient: rpc).prepareTokenTransfer( + Self.tokenRequest( + destinationTokenAccount: Self.destinationTokenAccount, + destinationWalletAddress: Self.destinationWallet, + tokenProgram: .token2022 + ) + ) + } + XCTAssertTrue(rpc.accountExistsRequests.isEmpty) + } + + func testRejectsUnsafeToken2022ExtensionMetadataBeforeRPCCalls() async { + await assertSendError(.tokenMetadataMismatch) { + _ = try await SolanaSendService(rpcClient: FakeSolanaRpcClient()).prepareTokenTransfer( + Self.tokenRequest( + tokenProgram: .token2022, + tokenMetadata: Self.tokenMetadata(mint: Self.destinationTokenAccount) + ) + ) + } + await assertSendError(.unsupportedToken2022Extension) { + _ = try await SolanaSendService(rpcClient: FakeSolanaRpcClient()).prepareTokenTransfer( + Self.tokenRequest( + tokenProgram: .token2022, + tokenMetadata: Self.tokenMetadata(extensions: ["nonTransferable"]) + ) + ) + } + await assertSendError(.token2022TransferFeeNotAcknowledged) { + _ = try await SolanaSendService(rpcClient: FakeSolanaRpcClient()).prepareTokenTransfer( + Self.tokenRequest( + tokenProgram: .token2022, + tokenMetadata: Self.tokenMetadata( + extensions: ["transferFeeConfig"], + transferFeeConfig: SolanaTokenTransferFeeConfig( + transferFeeConfigAuthority: nil, + withdrawWithheldAuthority: nil, + withheldAmount: "0", + olderTransferFee: nil, + newerTransferFee: nil + ) + ) + ) + ) + } + let hookRpc = FakeSolanaRpcClient() + await assertSendError(.token2022TransferHookAccountsMissing) { + _ = try await SolanaSendService(rpcClient: hookRpc).prepareTokenTransfer( + Self.tokenRequest( + tokenProgram: .token2022, + tokenMetadata: Self.tokenMetadata( + extensions: ["transferHook"], + transferHook: SolanaTokenTransferHook( + authority: nil, + programId: Self.extraReadonlyAccount, + extraAccountMetasAddress: nil + ) + ) + ) + ) + } + + XCTAssertEqual(hookRpc.calls, 0) + } + + func testAllowsToken2022TransferFeeAfterAcknowledgementAndHookWithExtraAccounts() async throws { + let feePrepared = try await SolanaSendService(rpcClient: FakeSolanaRpcClient()).prepareTokenTransfer( + Self.tokenRequest( + tokenProgram: .token2022, + tokenMetadata: Self.tokenMetadata( + extensions: ["transferFeeConfig"], + transferFeeConfig: SolanaTokenTransferFeeConfig( + transferFeeConfigAuthority: nil, + withdrawWithheldAuthority: nil, + withheldAmount: "0", + olderTransferFee: nil, + newerTransferFee: nil + ) + ), + acknowledgeToken2022TransferFee: true + ) + ) + let hookPrepared = try await SolanaSendService(rpcClient: FakeSolanaRpcClient()).prepareTokenTransfer( + Self.tokenRequest( + tokenProgram: .token2022, + extraAccounts: [SolanaTokenTransferExtraAccount(address: Self.extraReadonlyAccount)], + tokenMetadata: Self.tokenMetadata( + extensions: ["transferHook"], + transferHook: SolanaTokenTransferHook( + authority: nil, + programId: Self.extraReadonlyAccount, + extraAccountMetasAddress: nil + ) + ) + ) + ) + + XCTAssertEqual(feePrepared.unsigned.tokenProgram, .token2022) + XCTAssertEqual(hookPrepared.unsigned.tokenProgram, .token2022) + XCTAssertEqual(hookPrepared.unsigned.extraAccounts, [SolanaTokenTransferExtraAccount(address: Self.extraReadonlyAccount)]) + } + + func testRejectsNativeSendWhenSOLBalanceCannotCoverAmountPlusFee() async { + let rpc = FakeSolanaRpcClient() + let balances = FakeSolanaSendBalanceProvider(nativeLamports: "1004999") + + await assertSendError(.insufficientSolBalance) { + _ = try await SolanaSendService(rpcClient: rpc, balanceProvider: balances).prepare(Self.request()) + } + + XCTAssertEqual(balances.wallets, [Self.sender]) + XCTAssertTrue(rpc.simulatedTransactions.isEmpty) + XCTAssertNil(rpc.sentTransaction) + } + + func testRejectsTokenSendWhenSourceTokenBalanceIsMissingOrTooSmall() async { + let rpc = FakeSolanaRpcClient() + let missingTokenBalances = FakeSolanaSendBalanceProvider(nativeLamports: "9999999999") + + await assertSendError(.insufficientTokenBalance) { + _ = try await SolanaSendService( + rpcClient: rpc, + balanceProvider: missingTokenBalances + ).prepareTokenTransfer(Self.tokenRequest()) + } + + let tooSmallTokenBalances = FakeSolanaSendBalanceProvider( + nativeLamports: "9999999999", + tokenBalances: [Self.tokenIndexedBalance(amount: "999999")] + ) + await assertSendError(.insufficientTokenBalance) { + _ = try await SolanaSendService( + rpcClient: FakeSolanaRpcClient(), + balanceProvider: tooSmallTokenBalances + ).prepareTokenTransfer(Self.tokenRequest()) + } + } + + func testIncludesAssociatedTokenAccountRentInTokenSendSOLBalanceCheck() async { + let rent: Int64 = 2_039_280 + let rpc = FakeSolanaRpcClient(rentLamports: rent) + let balances = FakeSolanaSendBalanceProvider( + nativeLamports: String(5_000 + rent - 1), + tokenBalances: [Self.tokenIndexedBalance(amount: "1000000")] + ) + + await assertSendError(.insufficientSolBalance) { + _ = try await SolanaSendService(rpcClient: rpc, balanceProvider: balances).prepareTokenTransfer( + Self.tokenRequest(destinationTokenAccount: nil, destinationWalletAddress: Self.destinationWallet) + ) + } + + XCTAssertEqual(rpc.rentDataLengthRequests, [165]) + XCTAssertEqual(rpc.accountExistsRequests, [Self.destinationWalletAssociatedTokenAccount]) + XCTAssertTrue(rpc.simulatedTransactions.isEmpty) + } + + func testSkipsAssociatedTokenAccountRentWhenDestinationAccountExists() async throws { + let rpc = FakeSolanaRpcClient(existingAccounts: [Self.destinationWalletAssociatedTokenAccount]) + let balances = FakeSolanaSendBalanceProvider( + nativeLamports: "5000", + tokenBalances: [Self.tokenIndexedBalance(amount: "1000000")] + ) + let prepared = try await SolanaSendService(rpcClient: rpc, balanceProvider: balances).prepareTokenTransfer( + Self.tokenRequest(destinationTokenAccount: nil, destinationWalletAddress: Self.destinationWallet) + ) + + XCTAssertFalse(prepared.unsigned.createsDestinationAssociatedTokenAccount) + XCTAssertTrue(rpc.rentDataLengthRequests.isEmpty) + XCTAssertEqual(rpc.accountExistsRequests, [Self.destinationWalletAssociatedTokenAccount]) + } + + func testRejectsUnavailableFeesSimulationFailuresAndBroadcastMismatches() async { + await assertSendError(.feeUnavailable) { + _ = try await SolanaSendService(rpcClient: FakeSolanaRpcClient(fee: nil)).prepare(Self.request()) + } + await assertSendError(.simulationFailed) { + _ = try await SolanaSendService( + rpcClient: FakeSolanaRpcClient(simulationError: #"{"InstructionError":[0,"Custom"]}"#) + ).prepare(Self.request()) + } + await assertSendError(.broadcastSignatureMismatch) { + _ = try await SolanaSendService( + rpcClient: FakeSolanaRpcClient( + signatureOverride: "5NfHnqDyzT9qyfxZDq2sSskAMGuFZ3VRqW4EQxghKqrKYdKq6cZNW1J34w7qE6nGx1eDQe5s2eKxB2ZtE1xU9qgN" + ) + ).send(Self.request()) + } + } + + private final class FakeSolanaRpcClient: SolanaRpcClientProtocol { + private let fee: Int64? + private let simulationError: String? + private let signatureOverride: String? + private let rentLamports: Int64 + private let existingAccounts: Set + private(set) var feeMessages: [String] = [] + private(set) var simulatedTransactions: [String] = [] + private(set) var rentDataLengthRequests: [Int] = [] + private(set) var accountExistsRequests: [String] = [] + private(set) var urls: [String?] = [] + private(set) var broadcastOptions: SolanaBroadcastOptions? + private(set) var calls = 0 + private(set) var sentTransaction: String? + private(set) var sentSignature: String? + + init( + fee: Int64? = 5_000, + simulationError: String? = nil, + signatureOverride: String? = nil, + rentLamports: Int64 = 2_039_280, + existingAccounts: Set = [] + ) { + self.fee = fee + self.simulationError = simulationError + self.signatureOverride = signatureOverride + self.rentLamports = rentLamports + self.existingAccounts = existingAccounts + } + + func latestBlockhash( + commitment: SolanaRpcCommitment, + rpcURL: String? + ) async throws -> SolanaLatestBlockhashResponse { + calls += 1 + urls.append(rpcURL) + return SolanaLatestBlockhashResponse( + context: SolanaRpcContext(apiVersion: nil, slot: 1), + value: SolanaLatestBlockhash(blockhash: SolanaSendServiceTests.blockhash, lastValidBlockHeight: 99) + ) + } + + func feeForMessage( + _ messageBase64: String, + commitment: SolanaRpcCommitment, + rpcURL: String? + ) async throws -> SolanaFeeForMessageResponse { + calls += 1 + urls.append(rpcURL) + feeMessages.append(messageBase64) + return SolanaFeeForMessageResponse( + context: SolanaRpcContext(apiVersion: nil, slot: 2), + value: fee + ) + } + + func minimumBalanceForRentExemption( + dataLength: Int, + commitment: SolanaRpcCommitment, + rpcURL: String? + ) async throws -> Int64 { + calls += 1 + urls.append(rpcURL) + rentDataLengthRequests.append(dataLength) + return rentLamports + } + + func accountExists( + address: String, + commitment: SolanaRpcCommitment, + rpcURL: String? + ) async throws -> Bool { + calls += 1 + urls.append(rpcURL) + accountExistsRequests.append(address) + return existingAccounts.contains(address) + } + + func simulateTransaction( + _ transactionBase64: String, + options: SolanaSimulationOptions, + rpcURL: String? + ) async throws -> SolanaSimulationResponse { + calls += 1 + urls.append(rpcURL) + simulatedTransactions.append(transactionBase64) + return SolanaSimulationResponse( + context: SolanaRpcContext(apiVersion: nil, slot: 3), + value: SolanaSimulationValue( + errorJSON: simulationError, + logs: ["Program log: ok"], + replacementBlockhash: nil, + unitsConsumed: 42 + ) + ) + } + + func sendRawTransaction( + _ transactionBase64: String, + options: SolanaBroadcastOptions, + rpcURL: String? + ) async throws -> String { + calls += 1 + urls.append(rpcURL) + broadcastOptions = options + sentTransaction = transactionBase64 + + if let signatureOverride { + sentSignature = signatureOverride + return signatureOverride + } + + let signature = try Self.firstSignature(transactionBase64) + sentSignature = signature + return signature + } + + private static func firstSignature(_ transactionBase64: String) throws -> String { + let transaction = try XCTUnwrap(Data(base64Encoded: transactionBase64)) + let parsed = try SolanaTransactionSigner.parseSerializedTransaction(transaction) + return base58Encode(Array(transaction.subdata(in: parsed.signaturesOffset ..< parsed.signaturesOffset + 64))) + } + + private static func base58Encode(_ bytes: [UInt8]) -> String { + let alphabet = Array("123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz") + var digits: [Int] = [] + + for byte in bytes { + var carry = Int(byte) + + for index in digits.indices { + let value = digits[index] * 256 + carry + digits[index] = value % alphabet.count + carry = value / alphabet.count + } + + while carry > 0 { + digits.append(carry % alphabet.count) + carry /= alphabet.count + } + } + + var result = String(repeating: String(alphabet[0]), count: bytes.prefix { $0 == 0 }.count) + for digit in digits.reversed() { + result.append(alphabet[digit]) + } + + return result + } + } + + private final class FakeSolanaSendBalanceProvider: SolanaSendBalanceProvider { + private let nativeLamports: String + private let tokenBalances: [UniversalWalletIndexedAssetBalance] + private(set) var wallets: [String] = [] + + init( + nativeLamports: String, + tokenBalances: [UniversalWalletIndexedAssetBalance] = [] + ) { + self.nativeLamports = nativeLamports + self.tokenBalances = tokenBalances + } + + func balances(wallet: String) async throws -> SolanaBalanceSyncResult { + wallets.append(wallet) + let native = SolanaSendServiceTests.nativeIndexedBalance(amount: nativeLamports) + return SolanaBalanceSyncResult( + wallet: wallet, + networkId: "solana-mainnet", + chainId: "solana:mainnet", + syncedAtMillis: 1, + nativeBalance: native, + tokenBalances: tokenBalances, + balances: [native] + tokenBalances + ) + } + } + + private struct FakeUniversalWalletMnemonicProvider: UniversalWalletMnemonicProviding { + let mnemonic: String? + + func mnemonic(for _: MetaAccountModel, chain _: ChainModel) throws -> String? { + mnemonic + } + } + + private final class FakeSolanaTransferBalanceSync: SolanaBalanceSyncing { + struct Invocation: Equatable { + let wallet: String + let network: UniversalWalletRegistry.SolanaNetwork + let baseURL: String? + let includeTokenMetadata: Bool + } + + private let nativeLamports: String + private let tokenBalances: [UniversalWalletIndexedAssetBalance] + private(set) var invocations: [Invocation] = [] + + init( + nativeLamports: String, + tokenBalances: [UniversalWalletIndexedAssetBalance] = [] + ) { + self.nativeLamports = nativeLamports + self.tokenBalances = tokenBalances + } + + func balances( + wallet: String, + network: UniversalWalletRegistry.SolanaNetwork, + baseURL: String?, + includeTokenMetadata: Bool + ) async throws -> SolanaBalanceSyncResult { + invocations.append( + Invocation( + wallet: wallet, + network: network, + baseURL: baseURL, + includeTokenMetadata: includeTokenMetadata + ) + ) + let native = UniversalWalletIndexedAssetBalance( + accountId: network.id, + ecosystem: .solana, + chainId: network.chainId, + assetId: network.nativeAsset.id, + amount: nativeLamports, + decimals: network.nativeAsset.decimals, + isNative: true, + symbol: network.nativeAsset.symbol, + name: network.name, + uiAmountString: "2", + syncedAtMillis: 1 + ) + + return SolanaBalanceSyncResult( + wallet: wallet, + networkId: network.id, + chainId: network.chainId, + syncedAtMillis: 1, + nativeBalance: native, + tokenBalances: tokenBalances, + balances: [native] + tokenBalances + ) + } + } + + private func assertTransferError( + _ expected: SolanaTransferTransactionError, + file: StaticString = #filePath, + line: UInt = #line, + block: () async throws -> Void + ) async { + do { + try await block() + XCTFail("Expected Solana transfer error", file: file, line: line) + } catch { + XCTAssertEqual(error as? SolanaTransferTransactionError, expected, file: file, line: line) + } + } + + private func assertSendError( + _ expected: SolanaSendServiceError, + file: StaticString = #filePath, + line: UInt = #line, + block: () async throws -> Void + ) async { + do { + try await block() + XCTFail("Expected Solana send error", file: file, line: line) + } catch { + XCTAssertEqual(error as? SolanaSendServiceError, expected, file: file, line: line) + } + } + + private static func request( + mnemonic: String = SolanaSendServiceTests.mnemonic, + recipientAddress: String = SolanaSendServiceTests.recipient, + lamports: Int64 = 1_000_000, + rpcURL: String? = nil + ) -> SolanaSendRequest { + SolanaSendRequest( + mnemonic: mnemonic, + recipientAddress: recipientAddress, + lamports: lamports, + commitment: .finalized, + rpcURL: rpcURL, + broadcastOptions: SolanaBroadcastOptions(maxRetries: 3, preflightCommitment: .finalized) + ) + } + + private static func tokenRequest( + mnemonic: String = SolanaSendServiceTests.mnemonic, + sourceTokenAccount: String = SolanaSendServiceTests.sourceTokenAccount, + destinationTokenAccount: String? = SolanaSendServiceTests.destinationTokenAccount, + destinationWalletAddress: String? = nil, + mintAddress: String = SolanaSendServiceTests.mint, + rawAmount: Int64 = 1_000_000, + decimals: Int = 6, + derivationPath: String = UniversalWalletDerivationPaths.solanaDefault, + rpcURL: String? = nil, + tokenProgram: SolanaTokenProgram = .splToken, + extraAccounts: [SolanaTokenTransferExtraAccount] = [], + tokenMetadata: SolanaTokenMetadata? = nil, + acknowledgeToken2022TransferFee: Bool = false + ) -> SolanaTokenSendRequest { + SolanaTokenSendRequest( + mnemonic: mnemonic, + sourceTokenAccount: sourceTokenAccount, + destinationTokenAccount: destinationTokenAccount, + mintAddress: mintAddress, + rawAmount: rawAmount, + decimals: decimals, + derivationPath: derivationPath, + commitment: .finalized, + rpcURL: rpcURL, + tokenProgram: tokenProgram, + extraAccounts: extraAccounts, + tokenMetadata: tokenMetadata, + acknowledgeToken2022TransferFee: acknowledgeToken2022TransferFee, + destinationWalletAddress: destinationWalletAddress, + broadcastOptions: SolanaBroadcastOptions(maxRetries: 3, preflightCommitment: .finalized) + ) + } + + private static func nativeTransfer(chain: ChainModel) -> Transfer { + Transfer( + chainAsset: ChainAsset(chain: chain, asset: solAsset), + amount: BigUInt(1_000_000), + receiver: recipient, + tip: nil, + appId: nil + ) + } + + private static func walletWithSolanaAccount(chainId: String) throws -> MetaAccountModel { + let account = try SolanaKeyDerivation.deriveAccount(mnemonic: mnemonic) + let chainAccount = ChainAccountModel( + chainId: chainId, + accountId: account.publicKey, + publicKey: account.publicKey, + cryptoType: CryptoType.ed25519.rawValue, + ethereumBased: false + ) + + return AccountGenerator.generateMetaAccount(with: [chainAccount]) + } + + private static func solanaChain( + network: UniversalWalletRegistry.SolanaNetwork = UniversalWalletRegistry.solanaMainnet, + historyBaseURL: String? = nil, + nodes: [ChainNodeModel]? = nil, + assets: [AssetModel] = [SolanaSendServiceTests.solAsset] + ) -> ChainModel { + let resolvedNodes = nodes ?? [ + ChainNodeModel( + url: network.rpcURL, + name: network.name, + apikey: nil + ) + ] + let externalApi = historyBaseURL.map { + ChainModel.ExternalApiSet( + staking: nil, + history: ChainModel.BlockExplorer( + type: "subquery", + url: URL(string: $0)! + ), + explorers: nil + ) + } + + return ChainModel( + rank: nil, + disabled: false, + chainId: network.chainId, + parentId: nil, + paraId: nil, + name: network.name, + assets: Set(assets), + xcm: nil, + nodes: Set(resolvedNodes), + addressPrefix: 0, + types: nil, + icon: nil, + options: network == UniversalWalletRegistry.solanaDevnet ? [.testnet] : nil, + externalApi: externalApi, + selectedNode: nil, + customNodes: nil, + iosMinAppVersion: nil, + identityChain: nil + ) + } + + private static let mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + private static let otherMnemonic = "legal winner thank year wave sausage worth useful legal winner thank yellow" + private static let sender = try! SolanaKeyDerivation.deriveAccount(mnemonic: mnemonic).address + private static let sourceTokenAccount = try! deriveAddress(1) + private static let destinationTokenAccount = try! deriveAddress(2) + private static let mint = try! deriveAddress(3) + private static let extraReadonlyAccount = try! deriveAddress(4) + private static let extraWritableAccount = try! deriveAddress(5) + private static let destinationWallet = try! deriveAddress(7) + private static let destinationWalletAssociatedTokenAccount = "BNrmzq2GNxwxHf8dSiaEJdtUiQBg4VGCFi9ovhAK6WrK" + private static let destinationWalletToken2022AssociatedTokenAccount = "2bFPe5wRzpinUSyFT42imC5DFHtEFugZ6UhgBeWRRxhH" + private static let recipient = "So11111111111111111111111111111111111111112" + private static let blockhash = "7GjNiPun3AzEazTZoFEjZgcBMeuaXdpjHq2raZTmTrfs" + private static let mainnetRPC = "https://api.mainnet-beta.solana.com" + private static let devnetRPC = "https://api.devnet.solana.com" + private static let solAsset = AssetModel( + id: "SOL", + name: "Solana", + symbol: "SOL", + precision: 9, + isUtility: true, + isNative: true + ) + private static let usdcAsset = AssetModel( + id: mint, + name: "USD Coin", + symbol: "USDC", + precision: 6, + isUtility: false, + isNative: false + ) + + private static func deriveAddress(_ index: Int) throws -> String { + try SolanaKeyDerivation + .deriveAccount(mnemonic: mnemonic, derivationPath: "m/44'/501'/\(index)'/0'") + .address + } + + private static func nativeIndexedBalance(amount: String) -> UniversalWalletIndexedAssetBalance { + UniversalWalletIndexedAssetBalance( + accountId: "solana-mainnet", + ecosystem: .solana, + chainId: "solana:mainnet", + assetId: "SOL", + amount: amount, + decimals: 9, + isNative: true, + symbol: "SOL", + syncedAtMillis: 1 + ) + } + + private static func tokenIndexedBalance( + amount: String, + decimals: Int = 6, + tokenAccount: String = sourceTokenAccount, + mint: String = SolanaSendServiceTests.mint, + tokenProgram: String = "spl-token" + ) -> UniversalWalletIndexedAssetBalance { + UniversalWalletIndexedAssetBalance( + accountId: "solana-mainnet", + ecosystem: .solana, + chainId: "solana:mainnet", + assetId: mint, + amount: amount, + decimals: decimals, + isNative: false, + tokenAccountId: tokenAccount, + contractAddress: mint, + tokenProgram: tokenProgram, + syncedAtMillis: 1 + ) + } + + private static func tokenMetadata( + mint: String = SolanaSendServiceTests.mint, + exists: Bool = true, + program: String = "token-2022", + programId: String? = SolanaTransferTransactionBuilder.token2022ProgramAddress, + extensions: [String] = [], + transferFeeConfig: SolanaTokenTransferFeeConfig? = nil, + transferHook: SolanaTokenTransferHook? = nil + ) -> SolanaTokenMetadata { + SolanaTokenMetadata( + mint: mint, + exists: exists, + program: program, + programId: programId, + extensions: extensions, + transferFeeConfig: transferFeeConfig, + transferHook: transferHook, + decimals: 6, + supply: "100000000", + uiSupplyString: "100", + mintAuthority: nil, + freezeAuthority: nil, + isInitialized: true, + name: nil, + symbol: nil, + uri: nil, + syncedAt: 1 + ) + } +} diff --git a/fearlessTests/SolanaTransactionHistorySyncTests.swift b/fearlessTests/SolanaTransactionHistorySyncTests.swift new file mode 100644 index 0000000000..02b9332e1c --- /dev/null +++ b/fearlessTests/SolanaTransactionHistorySyncTests.swift @@ -0,0 +1,265 @@ +import XCTest +@testable import fearless + +final class SolanaTransactionHistorySyncTests: XCTestCase { + func testNormalizesNativeSOLTransactionHistoryFromSI() async throws { + let client = FakeSolanaIndexerClient( + response: Self.transactionsResponse( + [ + Self.transaction(signature: Self.signature1), + Self.transaction(signature: Self.signature2, nativeDelta: "0") + ], + nextBefore: Self.signature2 + ) + ) + + let page = try await SolanaTransactionHistorySync(client: client).history( + wallet: Self.wallet, + baseURL: "https://si.soramitsu.io/", + before: Self.signature0, + limit: 25 + ) + + XCTAssertEqual(client.verifiedBaseURLs, ["https://si.soramitsu.io"]) + XCTAssertEqual(client.verifiedExpectedChainIds, ["solana:mainnet"]) + XCTAssertEqual(client.lastWallet, Self.wallet) + XCTAssertEqual(client.lastBaseURL, "https://si.soramitsu.io") + XCTAssertEqual(client.lastBefore, Self.signature0) + XCTAssertEqual(client.lastLimit, 25) + XCTAssertEqual(page.pageInfo.nextCursor, Self.signature2) + XCTAssertEqual(page.transactions.count, 1) + + let transaction = try XCTUnwrap(page.transactions.first) + XCTAssertEqual(transaction.accountId, "solana-mainnet") + XCTAssertEqual(transaction.chainId, "solana:mainnet") + XCTAssertEqual(transaction.transactionId, Self.signature1) + XCTAssertEqual(transaction.status, .confirmed) + XCTAssertEqual(transaction.direction, .outgoing) + XCTAssertEqual(transaction.operationType, .transfer) + XCTAssertEqual(transaction.amount, "1005000") + XCTAssertEqual(transaction.assetId, "SOL") + XCTAssertEqual(transaction.feeAmount, "5000") + XCTAssertEqual(transaction.feeAssetId, "SOL") + XCTAssertEqual(transaction.blockNumber, "100") + XCTAssertEqual(transaction.timestampMillis, 1_710_000_000_000) + } + + func testAggregatesMatchingTokenDeltasAndMarksSolswapRoutesAsSwaps() async throws { + let client = FakeSolanaIndexerClient( + response: Self.transactionsResponse([ + Self.transaction( + tokenChanges: [ + Self.change(mint: Self.mint, amountDelta: "500"), + Self.change(mint: Self.mint, amountDelta: "-200"), + Self.change(mint: Self.otherMint, amountDelta: "999") + ], + solswapRoute: "batch" + ), + Self.transaction( + signature: Self.signature2, + tokenChanges: [Self.change(mint: Self.otherMint, amountDelta: "1")] + ) + ]) + ) + + let page = try await SolanaTransactionHistorySync(client: client).history( + wallet: Self.wallet, + assetId: Self.mint, + isNative: false + ) + + let transaction = try XCTUnwrap(page.transactions.first) + XCTAssertEqual(page.transactions.count, 1) + XCTAssertEqual(transaction.assetId, Self.mint) + XCTAssertEqual(transaction.amount, "300") + XCTAssertEqual(transaction.direction, .incoming) + XCTAssertEqual(transaction.operationType, .swap) + XCTAssertEqual(transaction.feeAmount, "5000") + } + + func testFiltersMalformedZeroAmountAndUnsupportedStatusTransactions() async throws { + let client = FakeSolanaIndexerClient( + response: Self.transactionsResponse( + [ + Self.transaction(signature: "not-a-signature"), + Self.transaction(signature: Self.signature1, nativeDelta: "0"), + Self.transaction(signature: Self.signature2, status: "pending"), + Self.transaction(signature: Self.signature3, feeLamports: "-1") + ], + nextBefore: "not-a-signature" + ) + ) + + let page = try await SolanaTransactionHistorySync(client: client).history(wallet: Self.wallet) + + XCTAssertTrue(page.transactions.isEmpty) + XCTAssertNil(page.pageInfo.nextCursor) + } + + func testRejectsInvalidWalletOrAssetBeforeFetchingHistory() async { + let client = FakeSolanaIndexerClient() + + do { + _ = try await SolanaTransactionHistorySync(client: client).history(wallet: "../bad") + XCTFail("Expected invalid wallet to be rejected") + } catch { + XCTAssertEqual(error as? SolanaTransactionHistoryError, .invalidInput) + } + + do { + _ = try await SolanaTransactionHistorySync(client: client).history( + wallet: Self.wallet, + assetId: "../bad", + isNative: false + ) + XCTFail("Expected invalid asset to be rejected") + } catch { + XCTAssertEqual(error as? SolanaTransactionHistoryError, .invalidInput) + } + + XCTAssertEqual(client.transactionCalls, 0) + } + + func testVerifiesDevnetHistoryAgainstDevnetServiceIdentity() async throws { + let client = FakeSolanaIndexerClient(response: Self.transactionsResponse([])) + + let page = try await SolanaTransactionHistorySync(client: client).history( + wallet: Self.wallet, + network: UniversalWalletRegistry.solanaDevnet + ) + + XCTAssertEqual(client.verifiedBaseURLs, ["https://si.soramitsu.io"]) + XCTAssertEqual(client.verifiedExpectedChainIds, ["solana:devnet"]) + XCTAssertEqual(page.networkId, "solana-devnet") + XCTAssertEqual(page.chainId, "solana:devnet") + } + + private final class FakeSolanaIndexerClient: SolanaIndexerClientProtocol { + private let response: SolanaWalletTransactionsResponse + private(set) var verifiedBaseURLs: [String] = [] + private(set) var verifiedExpectedChainIds: [String] = [] + private(set) var transactionCalls = 0 + private(set) var lastWallet: String? + private(set) var lastBaseURL: String? + private(set) var lastBefore: String? + private(set) var lastLimit: Int? + + init(response: SolanaWalletTransactionsResponse = SolanaTransactionHistorySyncTests.transactionsResponse([])) { + self.response = response + } + + func serviceInfo(baseURL: String?) async throws -> SolanaIndexerServiceInfo { + throw TestError.unexpectedEndpoint + } + + func verifyServiceInfo(baseURL: String?, expectedChainId: String) async throws -> SolanaIndexerServiceInfo { + verifiedBaseURLs.append(baseURL ?? "") + verifiedExpectedChainIds.append(expectedChainId) + return SolanaIndexerServiceInfo( + schemaVersion: 1, + serviceId: "si.soramitsu.io", + serviceName: "Solswap Indexer", + ecosystem: "solana", + chainId: expectedChainId, + network: expectedChainId.replacingOccurrences(of: "solana:", with: ""), + publicBaseUrl: "https://si.soramitsu.io", + readOnly: true, + capabilities: [], + endpoints: [:] + ) + } + + func balances(wallet: String, baseURL: String?) async throws -> SolanaWalletBalancesResponse { + throw TestError.unexpectedEndpoint + } + + func assets(wallet: String, baseURL: String?) async throws -> SolanaWalletAssetsResponse { + throw TestError.unexpectedEndpoint + } + + func state(wallet: String, baseURL: String?) async throws -> SolanaWalletStateResponse { + throw TestError.unexpectedEndpoint + } + + func transactions( + wallet: String, + baseURL: String?, + before: String?, + limit: Int + ) async throws -> SolanaWalletTransactionsResponse { + transactionCalls += 1 + lastWallet = wallet + lastBaseURL = baseURL + lastBefore = before + lastLimit = limit + return response + } + + func tokenMetadata(mint: String, baseURL: String?) async throws -> SolanaTokenMetadata { + throw TestError.unexpectedEndpoint + } + + func tokenMetadataBatch(mints: [String], baseURL: String?) async throws -> SolanaTokenMetadataBatchResponse { + throw TestError.unexpectedEndpoint + } + } + + private enum TestError: Error { + case unexpectedEndpoint + } + + private static let wallet = "HAgk14JpMQLgt6rVgv7cBQFJWFto5Dqxi472uT3DKpqk" + private static let mint = "So11111111111111111111111111111111111111112" + private static let otherMint = "5Pobwp6d9ihN9Nz38f87gVCEBFMgipFiSM2VtUhVit6w" + private static let signature0 = String(repeating: "1", count: 88) + private static let signature1 = String(repeating: "2", count: 88) + private static let signature2 = String(repeating: "3", count: 88) + private static let signature3 = String(repeating: "4", count: 88) + + private static func transactionsResponse( + _ transactions: [SolanaWalletTransactionRecord], + nextBefore: String? = nil + ) -> SolanaWalletTransactionsResponse { + SolanaWalletTransactionsResponse( + wallet: wallet, + before: nil, + nextBefore: nextBefore, + limit: 25, + total: transactions.count, + syncedAt: 1_710_000_000_000, + transactions: transactions + ) + } + + private static func transaction( + signature: String = signature1, + status: String = "success", + feeLamports: String? = "5000", + nativeDelta: String? = "-1005000", + tokenChanges: [SolanaTokenBalanceChange] = [], + solswapRoute: String? = nil + ) -> SolanaWalletTransactionRecord { + SolanaWalletTransactionRecord( + signature: signature, + slot: 100, + timestamp: 1_710_000_000, + status: status, + feeLamports: feeLamports, + nativeBalanceChangeLamports: nativeDelta, + tokenBalanceChanges: tokenChanges, + programIds: [], + solswapRoute: solswapRoute + ) + } + + private static func change(mint: String, amountDelta: String) -> SolanaTokenBalanceChange { + SolanaTokenBalanceChange( + mint: mint, + preAmount: "0", + postAmount: amountDelta, + amountDelta: amountDelta, + decimals: 6, + uiAmountDeltaString: amountDelta + ) + } +} diff --git a/fearlessTests/SolanaTransactionSignerTests.swift b/fearlessTests/SolanaTransactionSignerTests.swift new file mode 100644 index 0000000000..58d27219b6 --- /dev/null +++ b/fearlessTests/SolanaTransactionSignerTests.swift @@ -0,0 +1,289 @@ +import XCTest +@testable import fearless + +final class SolanaTransactionSignerTests: XCTestCase { + func testParsesAndSignsLegacySerializedSolanaTransactions() throws { + let fixture = try loadFixture() + let vector = try XCTUnwrap(fixture.vectors.first) + let expected = try XCTUnwrap(vector.expected["solana"] as? [String: Any]) + let raw = try XCTUnwrap(Data(base64Encoded: Self.legacyRawBase64)) + let expectedPublicKey = try XCTUnwrap(expected["publicKeyHex"] as? String).hexToData() + + let parsed = try SolanaTransactionSigner.parseSerializedTransaction(raw) + let signed = try SolanaTransactionSigner.signSerializedTransaction( + mnemonic: vector.mnemonic, + transaction: raw, + expectedSigner: try XCTUnwrap(expected["address"] as? String) + ) + + XCTAssertEqual(parsed.version, .legacy) + XCTAssertEqual(parsed.requiredSignatures, 1) + XCTAssertEqual(parsed.accountKeys, [try XCTUnwrap(expected["address"] as? String), Self.systemProgram]) + XCTAssertEqual(parsed.addressTableLookupCount, 0) + XCTAssertEqual(parsed.instructionCount, 1) + XCTAssertEqual(parsed.readonlySignedAccounts, 0) + XCTAssertEqual(parsed.readonlyUnsignedAccounts, 1) + XCTAssertEqual(signed.version, .legacy) + XCTAssertEqual(signed.signer, expected["address"] as? String) + XCTAssertFalse(signed.signatureBase58.isEmpty) + XCTAssertEqual(signed.signedTransactionBase64, signed.signedTransaction.base64EncodedString()) + XCTAssertEqual( + signed.signedTransaction.subdata(in: parsed.messageOffset ..< signed.signedTransaction.count), + parsed.messageBytes + ) + + let insertedSignature = signed.signedTransaction.subdata( + in: parsed.signaturesOffset ..< parsed.signaturesOffset + 64 + ) + XCTAssertEqual(insertedSignature.count, 64) + XCTAssertNotEqual(insertedSignature, Data(repeating: 0, count: 64)) + XCTAssertTrue(try SolanaSigner.verifyMessage( + publicKey: expectedPublicKey, + message: parsed.messageBytes, + signature: insertedSignature + )) + XCTAssertFalse(try SolanaSigner.verifyMessage( + publicKey: expectedPublicKey, + message: parsed.messageBytes + Data([0]), + signature: insertedSignature + )) + } + + func testParsesAndSignsV0SerializedSolanaTransactionsFromBase64() throws { + let vector = try XCTUnwrap(try loadFixture().vectors.first) + let expected = try XCTUnwrap(vector.expected["solana"] as? [String: Any]) + let expectedPublicKey = try XCTUnwrap(expected["publicKeyHex"] as? String).hexToData() + + let parsed = try SolanaTransactionSigner.parseSerializedTransaction(base64: Self.v0RawBase64) + let signed = try SolanaTransactionSigner.signSerializedTransaction( + mnemonic: vector.mnemonic, + transactionBase64: Self.v0RawBase64 + ) + + XCTAssertEqual(parsed.version, .v0) + XCTAssertEqual(parsed.addressTableLookupCount, 0) + XCTAssertEqual(parsed.instructionCount, 0) + XCTAssertEqual(signed.version, .v0) + XCTAssertEqual(signed.signer, expected["address"] as? String) + XCTAssertFalse(signed.signatureBase58.isEmpty) + XCTAssertEqual(signed.signedTransactionBase64, signed.signedTransaction.base64EncodedString()) + XCTAssertEqual( + signed.signedTransaction.subdata(in: parsed.messageOffset ..< signed.signedTransaction.count), + parsed.messageBytes + ) + + let insertedSignature = signed.signedTransaction.subdata( + in: parsed.signaturesOffset ..< parsed.signaturesOffset + 64 + ) + XCTAssertEqual(insertedSignature.count, 64) + XCTAssertNotEqual(insertedSignature, Data(repeating: 0, count: 64)) + XCTAssertTrue(try SolanaSigner.verifyMessage( + publicKey: expectedPublicKey, + message: parsed.messageBytes, + signature: insertedSignature + )) + XCTAssertFalse(try SolanaSigner.verifyMessage( + publicKey: expectedPublicKey, + message: parsed.messageBytes + Data([0]), + signature: insertedSignature + )) + } + + func testRejectsMalformedSolanaTransactionEnvelopesAndSignerStates() throws { + let fixture = try loadFixture() + let vector = try XCTUnwrap(fixture.vectors.first) + let otherVector = try XCTUnwrap(fixture.vectors.dropFirst().first) + let expected = try XCTUnwrap(vector.expected["solana"] as? [String: Any]) + let otherExpected = try XCTUnwrap(otherVector.expected["solana"] as? [String: Any]) + let expectedPublicKey = try XCTUnwrap(expected["publicKeyHex"] as? String).hexToData() + + assertTransactionError(.emptyTransaction) { + _ = try SolanaTransactionSigner.parseSerializedTransaction(Data()) + } + assertTransactionError(.invalidTransactionBase64) { + _ = try SolanaTransactionSigner.parseSerializedTransaction(base64: "*not-base64*") + } + assertTransactionError(.missingSignatureSlot) { + _ = try SolanaTransactionSigner.parseSerializedTransaction(Data([0])) + } + assertTransactionError(.truncatedSignatures) { + _ = try SolanaTransactionSigner.parseSerializedTransaction(Data([1, 2, 3])) + } + assertTransactionError(.unsupportedTransactionVersion) { + _ = try SolanaTransactionSigner.parseSerializedTransaction(Self.transaction(message: Data([0x81, 1, 0, 0]))) + } + assertTransactionError(.signerMismatch) { + _ = try SolanaTransactionSigner.signSerializedTransaction( + mnemonic: vector.mnemonic, + transaction: try XCTUnwrap(Data(base64Encoded: Self.legacyRawBase64)), + expectedSigner: try XCTUnwrap(otherExpected["address"] as? String) + ) + } + assertTransactionError(.signerNotFound) { + _ = try SolanaTransactionSigner.signSerializedTransaction( + mnemonic: otherVector.mnemonic, + transaction: try XCTUnwrap(Data(base64Encoded: Self.legacyRawBase64)) + ) + } + assertTransactionError(.signerNotRequired) { + _ = try SolanaTransactionSigner.signSerializedTransaction( + mnemonic: vector.mnemonic, + transaction: Self.transaction(message: Self.legacyMessage(accountKeys: [Data(repeating: 0, count: 32), expectedPublicKey])) + ) + } + } + + private func loadFixture() throws -> UniversalWalletFixture { + let repoRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + let url = repoRoot.appendingPathComponent("docs/universal-wallet-v2-vectors.json") + let data = try Data(contentsOf: url) + return try JSONDecoder().decode(UniversalWalletFixture.self, from: data) + } + + private func assertTransactionError( + _ expected: SolanaTransactionSignerError, + file: StaticString = #filePath, + line: UInt = #line, + block: () throws -> Void + ) { + XCTAssertThrowsError(try block(), file: file, line: line) { error in + XCTAssertEqual(error as? SolanaTransactionSignerError, expected, file: file, line: line) + } + } + + private static func transaction(message: Data) -> Data { + Data([1]) + Data(repeating: 0, count: 64) + message + } + + private static func legacyMessage(accountKeys: [Data], requiredSignatures: Int = 1) -> Data { + let readonlyUnsignedAccounts = accountKeys.count > requiredSignatures ? 1 : 0 + + var message = Data([UInt8(requiredSignatures), 0, UInt8(readonlyUnsignedAccounts)]) + message.append(compact(accountKeys.count)) + accountKeys.forEach { message.append($0) } + message.append(Data(repeating: 9, count: 32)) + message.append(compact(1)) + message.append(Data([1])) + message.append(compact(1)) + message.append(Data([0])) + message.append(compact(0)) + return message + } + + private static func compact(_ value: Int) -> Data { + var result = Data() + var next = value + + repeat { + var byte = next & 0x7f + next >>= 7 + if next > 0 { + byte |= 0x80 + } + result.append(UInt8(byte)) + } while next > 0 + + return result + } + + private static let systemProgram = "11111111111111111111111111111111" + private static let legacyRawBase64 = "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAEC8DYnYkanW53jNJ7UKxXiMvZRj8IPX81PHWToH5vSWPcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJAQEBAAA=" + private static let v0RawBase64 = "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAQABAvA2J2JGp1ud4zSe1CsV4jL2UY/CD1/NTx1k6B+b0lj3AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwAA" +} + +private extension String { + func hexToData() -> Data { + var output = Data() + var index = startIndex + + while index < endIndex { + let next = self.index(index, offsetBy: 2) + output.append(UInt8(self[index ..< next], radix: 16)!) + index = next + } + + return output + } +} + +private struct UniversalWalletFixture: Decodable { + let vectors: [UniversalWalletVector] +} + +private struct UniversalWalletVector: Decodable { + let mnemonic: String + let expected: [String: Any] + + private enum CodingKeys: String, CodingKey { + case mnemonic + case expected + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + mnemonic = try container.decode(String.self, forKey: .mnemonic) + expected = try container.decodeJSONDictionary(forKey: .expected) + } +} + +private extension KeyedDecodingContainer { + func decodeJSONDictionary(forKey key: Key) throws -> [String: Any] { + let data = try JSONSerialization.data(withJSONObject: decodeRawJSONObject(forKey: key), options: []) + return try JSONSerialization.jsonObject(with: data) as? [String: Any] ?? [:] + } + + func decodeRawJSONObject(forKey key: Key) throws -> Any { + let value = try decode(AnyDecodable.self, forKey: key) + return value.value + } +} + +private struct AnyDecodable: Decodable { + let value: Any + + init(from decoder: Decoder) throws { + if let container = try? decoder.container(keyedBy: DynamicCodingKey.self) { + var dictionary: [String: Any] = [:] + for key in container.allKeys { + dictionary[key.stringValue] = try container.decode(AnyDecodable.self, forKey: key).value + } + value = dictionary + } else if var container = try? decoder.unkeyedContainer() { + var array: [Any] = [] + while !container.isAtEnd { + array.append(try container.decode(AnyDecodable.self).value) + } + value = array + } else { + let container = try decoder.singleValueContainer() + if let value = try? container.decode(String.self) { + self.value = value + } else if let value = try? container.decode(Int.self) { + self.value = value + } else if let value = try? container.decode(Bool.self) { + self.value = value + } else if container.decodeNil() { + value = NSNull() + } else { + throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported JSON value") + } + } + } +} + +private struct DynamicCodingKey: CodingKey { + let stringValue: String + let intValue: Int? + + init?(stringValue: String) { + self.stringValue = stringValue + intValue = nil + } + + init?(intValue: Int) { + stringValue = "\(intValue)" + self.intValue = intValue + } +} diff --git a/fearlessTests/SolanaTransferTransactionBuilderTests.swift b/fearlessTests/SolanaTransferTransactionBuilderTests.swift new file mode 100644 index 0000000000..4d631f20f0 --- /dev/null +++ b/fearlessTests/SolanaTransferTransactionBuilderTests.swift @@ -0,0 +1,610 @@ +import XCTest +@testable import fearless + +final class SolanaTransferTransactionBuilderTests: XCTestCase { + func testBuildsCanonicalLegacySystemTransferTransactionEnvelope() throws { + let sender = try SolanaKeyDerivation.deriveAccount(mnemonic: Self.mnemonic).address + let unsigned = try SolanaTransferTransactionBuilder.buildNativeTransfer( + senderAddress: " \(sender) ", + recipientAddress: " \(Self.recipient) ", + lamports: 123_456_789, + recentBlockhash: " \(Self.blockhash) " + ) + let parsed = try SolanaTransactionSigner.parseSerializedTransaction(unsigned.transaction) + + XCTAssertEqual(unsigned.senderAddress, sender) + XCTAssertEqual(unsigned.recipientAddress, Self.recipient) + XCTAssertEqual(unsigned.recentBlockhash, Self.blockhash) + XCTAssertEqual(unsigned.messageBase64, parsed.messageBytes.base64EncodedString()) + XCTAssertEqual(parsed.requiredSignatures, 1) + XCTAssertEqual(parsed.readonlySignedAccounts, 0) + XCTAssertEqual(parsed.readonlyUnsignedAccounts, 1) + XCTAssertEqual(parsed.accountKeys, [sender, Self.recipient, SolanaTransferTransactionBuilder.systemProgramAddress]) + XCTAssertEqual(parsed.recentBlockhash, Self.blockhash) + XCTAssertEqual(parsed.instructionCount, 1) + XCTAssertEqual(parsed.signatureCount, 1) + XCTAssertEqual(unsigned.transaction.subdata(in: 1 ..< 65), Data(repeating: 0, count: 64)) + XCTAssertTransferInstruction(unsigned.message, lamports: 123_456_789) + } + + func testRejectsMalformedTransferParameters() { + assertTransferError(.invalidLamports) { + _ = try SolanaTransferTransactionBuilder.buildNativeTransfer( + senderAddress: Self.sender, + recipientAddress: Self.recipient, + lamports: 0, + recentBlockhash: Self.blockhash + ) + } + assertTransferError(.invalidSender) { + _ = try SolanaTransferTransactionBuilder.buildNativeTransfer( + senderAddress: "not-base58", + recipientAddress: Self.recipient, + lamports: 1, + recentBlockhash: Self.blockhash + ) + } + assertTransferError(.invalidRecipient) { + _ = try SolanaTransferTransactionBuilder.buildNativeTransfer( + senderAddress: Self.sender, + recipientAddress: "O0Il", + lamports: 1, + recentBlockhash: Self.blockhash + ) + } + assertTransferError(.invalidRecentBlockhash) { + _ = try SolanaTransferTransactionBuilder.buildNativeTransfer( + senderAddress: Self.sender, + recipientAddress: Self.recipient, + lamports: 1, + recentBlockhash: "111" + ) + } + } + + func testBuildsCanonicalSPLTokenTransferCheckedTransactionEnvelope() throws { + let unsigned = try SolanaTransferTransactionBuilder.buildTokenTransferChecked( + ownerAddress: Self.owner, + sourceTokenAccount: Self.sourceTokenAccount, + destinationTokenAccount: Self.destinationTokenAccount, + mintAddress: Self.mint, + rawAmount: 1_234_567, + decimals: 6, + recentBlockhash: Self.blockhash + ) + let parsed = try SolanaTransactionSigner.parseSerializedTransaction(unsigned.transaction) + + XCTAssertEqual(unsigned.ownerAddress, Self.owner) + XCTAssertEqual(unsigned.sourceTokenAccount, Self.sourceTokenAccount) + XCTAssertEqual(unsigned.destinationTokenAccount, Self.destinationTokenAccount) + XCTAssertEqual(unsigned.mintAddress, Self.mint) + XCTAssertEqual(unsigned.tokenProgram, .splToken) + XCTAssertEqual(parsed.requiredSignatures, 1) + XCTAssertEqual(parsed.readonlySignedAccounts, 0) + XCTAssertEqual(parsed.readonlyUnsignedAccounts, 2) + XCTAssertEqual( + parsed.accountKeys, + [ + Self.owner, + Self.sourceTokenAccount, + Self.destinationTokenAccount, + Self.mint, + SolanaTransferTransactionBuilder.splTokenProgramAddress + ] + ) + XCTAssertEqual(parsed.instructionCount, 1) + XCTAssertEqual(unsigned.transaction.subdata(in: 1 ..< 65), Data(repeating: 0, count: 64)) + XCTAssertTokenTransferCheckedInstruction( + unsigned.message, + accountCount: 5, + programIndex: 4, + accountIndexes: [1, 3, 2, 0], + amount: 1_234_567, + decimals: 6 + ) + } + + func testBuildsToken2022TransferCheckedWithExtensionExtraAccountsInInstructionOrder() throws { + let unsigned = try SolanaTransferTransactionBuilder.buildTokenTransferChecked( + ownerAddress: Self.owner, + sourceTokenAccount: Self.sourceTokenAccount, + destinationTokenAccount: Self.destinationTokenAccount, + mintAddress: Self.mint, + rawAmount: Int64.max, + decimals: 255, + recentBlockhash: Self.blockhash, + tokenProgram: .token2022, + extraAccounts: [ + SolanaTokenTransferExtraAccount(address: Self.extraReadonlyAccount), + SolanaTokenTransferExtraAccount(address: Self.extraWritableAccount, isWritable: true) + ] + ) + let parsed = try SolanaTransactionSigner.parseSerializedTransaction(unsigned.transaction) + + XCTAssertEqual(unsigned.tokenProgram, .token2022) + XCTAssertEqual(parsed.readonlySignedAccounts, 0) + XCTAssertEqual(parsed.readonlyUnsignedAccounts, 3) + XCTAssertEqual( + parsed.accountKeys, + [ + Self.owner, + Self.sourceTokenAccount, + Self.destinationTokenAccount, + Self.extraWritableAccount, + Self.mint, + SolanaTransferTransactionBuilder.token2022ProgramAddress, + Self.extraReadonlyAccount + ] + ) + XCTAssertTokenTransferCheckedInstruction( + unsigned.message, + accountCount: 7, + programIndex: 5, + accountIndexes: [1, 4, 2, 0, 6, 3], + amount: Int64.max, + decimals: 255 + ) + } + + func testBuildsIdempotentAssociatedTokenAccountCreateWithPayerWalletDeduplicated() throws { + let unsigned = try SolanaTransferTransactionBuilder.buildCreateAssociatedTokenAccount( + fundingAddress: Self.owner, + walletAddress: Self.owner, + mintAddress: Self.mint, + recentBlockhash: Self.blockhash + ) + let parsed = try SolanaTransactionSigner.parseSerializedTransaction(unsigned.transaction) + + XCTAssertEqual(unsigned.fundingAddress, Self.owner) + XCTAssertEqual(unsigned.walletAddress, Self.owner) + XCTAssertEqual(unsigned.associatedTokenAccount, Self.ownerSPLAssociatedTokenAccount) + XCTAssertEqual(unsigned.tokenProgram, .splToken) + XCTAssertEqual(parsed.requiredSignatures, 1) + XCTAssertEqual(parsed.readonlySignedAccounts, 0) + XCTAssertEqual(parsed.readonlyUnsignedAccounts, 4) + XCTAssertEqual( + parsed.accountKeys, + [ + Self.owner, + Self.ownerSPLAssociatedTokenAccount, + Self.mint, + SolanaTransferTransactionBuilder.systemProgramAddress, + SolanaTransferTransactionBuilder.splTokenProgramAddress, + SolanaTransferTransactionBuilder.associatedTokenProgramAddress + ] + ) + XCTAssertAssociatedTokenCreateInstruction( + unsigned.message, + accountCount: 6, + programIndex: 5, + accountIndexes: [0, 1, 0, 2, 3, 4], + discriminator: 1 + ) + } + + func testBuildsToken2022AssociatedTokenAccountCreateWithSeparatePayerAndCreateDiscriminator() throws { + let unsigned = try SolanaTransferTransactionBuilder.buildCreateAssociatedTokenAccount( + fundingAddress: Self.sender, + walletAddress: Self.owner, + associatedTokenAccount: Self.ownerToken2022AssociatedTokenAccount, + mintAddress: Self.mint, + recentBlockhash: Self.blockhash, + tokenProgram: .token2022, + instruction: .create + ) + let parsed = try SolanaTransactionSigner.parseSerializedTransaction(unsigned.transaction) + + XCTAssertEqual(unsigned.tokenProgram, .token2022) + XCTAssertEqual(parsed.readonlyUnsignedAccounts, 5) + XCTAssertEqual( + parsed.accountKeys, + [ + Self.sender, + Self.ownerToken2022AssociatedTokenAccount, + Self.owner, + Self.mint, + SolanaTransferTransactionBuilder.systemProgramAddress, + SolanaTransferTransactionBuilder.token2022ProgramAddress, + SolanaTransferTransactionBuilder.associatedTokenProgramAddress + ] + ) + XCTAssertAssociatedTokenCreateInstruction( + unsigned.message, + accountCount: 7, + programIndex: 6, + accountIndexes: [0, 1, 2, 3, 4, 5], + discriminator: 0 + ) + } + + func testBuildsTokenSendWithIdempotentAssociatedTokenAccountCreateThenTransferChecked() throws { + let unsigned = try SolanaTransferTransactionBuilder.buildTokenSendChecked( + ownerAddress: Self.owner, + sourceTokenAccount: Self.sourceTokenAccount, + mintAddress: Self.mint, + rawAmount: 99_000, + decimals: 8, + recentBlockhash: Self.blockhash, + tokenProgram: .token2022, + extraAccounts: [ + SolanaTokenTransferExtraAccount(address: Self.extraReadonlyAccount), + SolanaTokenTransferExtraAccount(address: Self.extraWritableAccount, isWritable: true) + ], + destinationWalletAddress: Self.destinationWallet + ) + let parsed = try SolanaTransactionSigner.parseSerializedTransaction(unsigned.transaction) + + XCTAssertTrue(unsigned.createsDestinationAssociatedTokenAccount) + XCTAssertEqual(unsigned.destinationWalletAddress, Self.destinationWallet) + XCTAssertEqual(unsigned.destinationTokenAccount, Self.destinationWalletToken2022AssociatedTokenAccount) + XCTAssertEqual(unsigned.tokenProgram, .token2022) + XCTAssertEqual(parsed.requiredSignatures, 1) + XCTAssertEqual(parsed.readonlySignedAccounts, 0) + XCTAssertEqual(parsed.readonlyUnsignedAccounts, 6) + XCTAssertEqual( + parsed.accountKeys, + [ + Self.owner, + Self.sourceTokenAccount, + Self.destinationWalletToken2022AssociatedTokenAccount, + Self.extraWritableAccount, + Self.destinationWallet, + Self.mint, + SolanaTransferTransactionBuilder.systemProgramAddress, + SolanaTransferTransactionBuilder.token2022ProgramAddress, + SolanaTransferTransactionBuilder.associatedTokenProgramAddress, + Self.extraReadonlyAccount + ] + ) + XCTAssertEqual(parsed.instructionCount, 2) + XCTAssertEqual(unsigned.transaction.subdata(in: 1 ..< 65), Data(repeating: 0, count: 64)) + XCTAssertTokenSendWithAssociatedAccountCreateInstructions( + unsigned.message, + accountCount: 10, + associatedProgramIndex: 8, + associatedAccountIndexes: [0, 2, 4, 5, 6, 7], + associatedDiscriminator: 1, + tokenProgramIndex: 7, + tokenAccountIndexes: [1, 5, 2, 0, 9, 3], + amount: 99_000, + decimals: 8 + ) + } + + func testDerivesAssociatedTokenAccountsForSPLTokenAndToken2022() throws { + XCTAssertEqual( + try SolanaTransferTransactionBuilder.deriveAssociatedTokenAccountAddress( + walletAddress: Self.destinationWallet, + mintAddress: Self.mint + ), + Self.destinationWalletSPLAssociatedTokenAccount + ) + XCTAssertEqual( + try SolanaTransferTransactionBuilder.deriveAssociatedTokenAccountAddress( + walletAddress: Self.destinationWallet, + mintAddress: Self.mint, + tokenProgram: .token2022 + ), + Self.destinationWalletToken2022AssociatedTokenAccount + ) + } + + func testRejectsMalformedTokenTransferParameters() { + assertTransferError(.invalidTokenAmount) { + _ = try SolanaTransferTransactionBuilder.buildTokenTransferChecked( + ownerAddress: Self.owner, + sourceTokenAccount: Self.sourceTokenAccount, + destinationTokenAccount: Self.destinationTokenAccount, + mintAddress: Self.mint, + rawAmount: 0, + decimals: 6, + recentBlockhash: Self.blockhash + ) + } + assertTransferError(.invalidTokenDecimals) { + _ = try SolanaTransferTransactionBuilder.buildTokenTransferChecked( + ownerAddress: Self.owner, + sourceTokenAccount: Self.sourceTokenAccount, + destinationTokenAccount: Self.destinationTokenAccount, + mintAddress: Self.mint, + rawAmount: 1, + decimals: 256, + recentBlockhash: Self.blockhash + ) + } + assertTransferError(.duplicateAccount) { + _ = try SolanaTransferTransactionBuilder.buildTokenTransferChecked( + ownerAddress: Self.owner, + sourceTokenAccount: Self.sourceTokenAccount, + destinationTokenAccount: Self.sourceTokenAccount, + mintAddress: Self.mint, + rawAmount: 1, + decimals: 6, + recentBlockhash: Self.blockhash + ) + } + assertTransferError(.invalidWallet) { + _ = try SolanaTransferTransactionBuilder.buildTokenSendChecked( + ownerAddress: Self.owner, + sourceTokenAccount: Self.sourceTokenAccount, + destinationTokenAccount: Self.destinationTokenAccount, + mintAddress: Self.mint, + rawAmount: 1, + decimals: 6, + recentBlockhash: Self.blockhash, + destinationWalletAddress: "not-base58" + ) + } + assertTransferError(.duplicateAccount) { + _ = try SolanaTransferTransactionBuilder.buildTokenSendChecked( + ownerAddress: Self.owner, + sourceTokenAccount: Self.sourceTokenAccount, + mintAddress: Self.mint, + rawAmount: 1, + decimals: 6, + recentBlockhash: Self.blockhash, + destinationWalletAddress: Self.sourceTokenAccount + ) + } + assertTransferError(.duplicateAccount) { + _ = try SolanaTransferTransactionBuilder.buildTokenSendChecked( + ownerAddress: Self.owner, + sourceTokenAccount: Self.sourceTokenAccount, + mintAddress: Self.mint, + rawAmount: 1, + decimals: 6, + recentBlockhash: Self.blockhash, + extraAccounts: [ + SolanaTokenTransferExtraAccount(address: SolanaTransferTransactionBuilder.associatedTokenProgramAddress) + ], + destinationWalletAddress: Self.destinationWallet + ) + } + assertTransferError(.invalidAssociatedTokenAccount) { + _ = try SolanaTransferTransactionBuilder.buildTokenSendChecked( + ownerAddress: Self.owner, + sourceTokenAccount: Self.sourceTokenAccount, + destinationTokenAccount: Self.destinationTokenAccount, + mintAddress: Self.mint, + rawAmount: 1, + decimals: 6, + recentBlockhash: Self.blockhash, + destinationWalletAddress: Self.destinationWallet + ) + } + assertTransferError(.invalidExtraAccount) { + _ = try SolanaTransferTransactionBuilder.buildTokenTransferChecked( + ownerAddress: Self.owner, + sourceTokenAccount: Self.sourceTokenAccount, + destinationTokenAccount: Self.destinationTokenAccount, + mintAddress: Self.mint, + rawAmount: 1, + decimals: 6, + recentBlockhash: Self.blockhash, + extraAccounts: [SolanaTokenTransferExtraAccount(address: "not-base58")] + ) + } + assertTransferError(.tooManyExtraAccounts) { + _ = try SolanaTransferTransactionBuilder.buildTokenTransferChecked( + ownerAddress: Self.owner, + sourceTokenAccount: Self.sourceTokenAccount, + destinationTokenAccount: Self.destinationTokenAccount, + mintAddress: Self.mint, + rawAmount: 1, + decimals: 6, + recentBlockhash: Self.blockhash, + extraAccounts: try (10 ..< 43).map { SolanaTokenTransferExtraAccount(address: try Self.deriveAddress($0)) } + ) + } + assertTransferError(.invalidAssociatedTokenAccount) { + _ = try SolanaTransferTransactionBuilder.buildCreateAssociatedTokenAccount( + fundingAddress: Self.owner, + walletAddress: Self.owner, + associatedTokenAccount: "not-base58", + mintAddress: Self.mint, + recentBlockhash: Self.blockhash + ) + } + assertTransferError(.duplicateAccount) { + _ = try SolanaTransferTransactionBuilder.buildCreateAssociatedTokenAccount( + fundingAddress: Self.owner, + walletAddress: Self.owner, + mintAddress: Self.owner, + recentBlockhash: Self.blockhash + ) + } + assertTransferError(.invalidAssociatedTokenAccount) { + _ = try SolanaTransferTransactionBuilder.buildCreateAssociatedTokenAccount( + fundingAddress: Self.owner, + walletAddress: Self.owner, + associatedTokenAccount: Self.owner, + mintAddress: Self.mint, + recentBlockhash: Self.blockhash + ) + } + } + + private func XCTAssertTransferInstruction( + _ message: Data, + lamports: Int64, + file: StaticString = #filePath, + line: UInt = #line + ) { + XCTAssertEqual(message.count, 150, file: file, line: line) + XCTAssertEqual(message.subdata(in: 0 ..< 3), Data([1, 0, 1]), file: file, line: line) + XCTAssertEqual(message[3], 3, file: file, line: line) + XCTAssertEqual(message[132], 1, file: file, line: line) + XCTAssertEqual(message[133], 2, file: file, line: line) + XCTAssertEqual(message[134], 2, file: file, line: line) + XCTAssertEqual(message.subdata(in: 135 ..< 137), Data([0, 1]), file: file, line: line) + XCTAssertEqual(message[137], 12, file: file, line: line) + XCTAssertEqual(message.subdata(in: 138 ..< 142), Data([2, 0, 0, 0]), file: file, line: line) + XCTAssertEqual(message.subdata(in: 142 ..< 150).littleEndianInt64(), lamports, file: file, line: line) + } + + private func XCTAssertTokenTransferCheckedInstruction( + _ message: Data, + accountCount: Int, + programIndex: Int, + accountIndexes: [Int], + amount: Int64, + decimals: Int, + file: StaticString = #filePath, + line: UInt = #line + ) { + var offset = 0 + offset += 3 + XCTAssertEqual(Int(message[offset]), accountCount, file: file, line: line) + offset += 1 + accountCount * 32 + offset += 32 + XCTAssertEqual(Int(message[offset]), 1, file: file, line: line) + offset += 1 + XCTAssertEqual(Int(message[offset]), programIndex, file: file, line: line) + offset += 1 + XCTAssertEqual(Int(message[offset]), accountIndexes.count, file: file, line: line) + offset += 1 + XCTAssertEqual(message.subdata(in: offset ..< offset + accountIndexes.count), Data(accountIndexes.map(UInt8.init)), file: file, line: line) + offset += accountIndexes.count + XCTAssertEqual(Int(message[offset]), 10, file: file, line: line) + offset += 1 + XCTAssertEqual(Int(message[offset]), 12, file: file, line: line) + offset += 1 + XCTAssertEqual(message.subdata(in: offset ..< offset + 8).littleEndianInt64(), amount, file: file, line: line) + offset += 8 + XCTAssertEqual(Int(message[offset]), decimals, file: file, line: line) + offset += 1 + XCTAssertEqual(offset, message.count, file: file, line: line) + } + + private func XCTAssertTokenSendWithAssociatedAccountCreateInstructions( + _ message: Data, + accountCount: Int, + associatedProgramIndex: Int, + associatedAccountIndexes: [Int], + associatedDiscriminator: Int, + tokenProgramIndex: Int, + tokenAccountIndexes: [Int], + amount: Int64, + decimals: Int, + file: StaticString = #filePath, + line: UInt = #line + ) { + var offset = 0 + offset += 3 + XCTAssertEqual(Int(message[offset]), accountCount, file: file, line: line) + offset += 1 + accountCount * 32 + offset += 32 + XCTAssertEqual(Int(message[offset]), 2, file: file, line: line) + offset += 1 + + XCTAssertEqual(Int(message[offset]), associatedProgramIndex, file: file, line: line) + offset += 1 + XCTAssertEqual(Int(message[offset]), associatedAccountIndexes.count, file: file, line: line) + offset += 1 + XCTAssertEqual( + message.subdata(in: offset ..< offset + associatedAccountIndexes.count), + Data(associatedAccountIndexes.map(UInt8.init)), + file: file, + line: line + ) + offset += associatedAccountIndexes.count + XCTAssertEqual(Int(message[offset]), 1, file: file, line: line) + offset += 1 + XCTAssertEqual(Int(message[offset]), associatedDiscriminator, file: file, line: line) + offset += 1 + + XCTAssertEqual(Int(message[offset]), tokenProgramIndex, file: file, line: line) + offset += 1 + XCTAssertEqual(Int(message[offset]), tokenAccountIndexes.count, file: file, line: line) + offset += 1 + XCTAssertEqual( + message.subdata(in: offset ..< offset + tokenAccountIndexes.count), + Data(tokenAccountIndexes.map(UInt8.init)), + file: file, + line: line + ) + offset += tokenAccountIndexes.count + XCTAssertEqual(Int(message[offset]), 10, file: file, line: line) + offset += 1 + XCTAssertEqual(Int(message[offset]), 12, file: file, line: line) + offset += 1 + XCTAssertEqual(message.subdata(in: offset ..< offset + 8).littleEndianInt64(), amount, file: file, line: line) + offset += 8 + XCTAssertEqual(Int(message[offset]), decimals, file: file, line: line) + offset += 1 + XCTAssertEqual(offset, message.count, file: file, line: line) + } + + private func XCTAssertAssociatedTokenCreateInstruction( + _ message: Data, + accountCount: Int, + programIndex: Int, + accountIndexes: [Int], + discriminator: Int, + file: StaticString = #filePath, + line: UInt = #line + ) { + var offset = 0 + offset += 3 + XCTAssertEqual(Int(message[offset]), accountCount, file: file, line: line) + offset += 1 + accountCount * 32 + offset += 32 + XCTAssertEqual(Int(message[offset]), 1, file: file, line: line) + offset += 1 + XCTAssertEqual(Int(message[offset]), programIndex, file: file, line: line) + offset += 1 + XCTAssertEqual(Int(message[offset]), accountIndexes.count, file: file, line: line) + offset += 1 + XCTAssertEqual(message.subdata(in: offset ..< offset + accountIndexes.count), Data(accountIndexes.map(UInt8.init)), file: file, line: line) + offset += accountIndexes.count + XCTAssertEqual(Int(message[offset]), 1, file: file, line: line) + offset += 1 + XCTAssertEqual(Int(message[offset]), discriminator, file: file, line: line) + offset += 1 + XCTAssertEqual(offset, message.count, file: file, line: line) + } + + private func assertTransferError( + _ expected: SolanaTransferTransactionError, + file: StaticString = #filePath, + line: UInt = #line, + block: () throws -> Void + ) { + XCTAssertThrowsError(try block(), file: file, line: line) { error in + XCTAssertEqual(error as? SolanaTransferTransactionError, expected, file: file, line: line) + } + } + + private static let mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + private static let sender = try! SolanaKeyDerivation.deriveAccount(mnemonic: mnemonic).address + private static let owner = try! deriveAddress(6) + private static let sourceTokenAccount = try! deriveAddress(1) + private static let destinationTokenAccount = try! deriveAddress(2) + private static let mint = try! deriveAddress(3) + private static let extraReadonlyAccount = try! deriveAddress(4) + private static let extraWritableAccount = try! deriveAddress(5) + private static let destinationWallet = try! deriveAddress(7) + private static let ownerSPLAssociatedTokenAccount = "J6SUEJJ82hffdpF15LHppEoRn3e7rzu4sf4dS1WWzBpA" + private static let ownerToken2022AssociatedTokenAccount = "Cozse4NYMaBEXmydu6nYwYsFD29LXwU7K6n1AiEeDkHw" + private static let destinationWalletSPLAssociatedTokenAccount = "BNrmzq2GNxwxHf8dSiaEJdtUiQBg4VGCFi9ovhAK6WrK" + private static let destinationWalletToken2022AssociatedTokenAccount = "2bFPe5wRzpinUSyFT42imC5DFHtEFugZ6UhgBeWRRxhH" + private static let recipient = "So11111111111111111111111111111111111111112" + private static let blockhash = "7GjNiPun3AzEazTZoFEjZgcBMeuaXdpjHq2raZTmTrfs" + + private static func deriveAddress(_ index: Int) throws -> String { + try SolanaKeyDerivation + .deriveAccount(mnemonic: mnemonic, derivationPath: "m/44'/501'/\(index)'/0'") + .address + } +} + +private extension Data { + func littleEndianInt64() -> Int64 { + var value: UInt64 = 0 + for (index, byte) in enumerated() { + value |= UInt64(byte) << UInt64(index * 8) + } + + return Int64(bitPattern: value) + } +} diff --git a/fearlessTests/TonIndexerClientTests.swift b/fearlessTests/TonIndexerClientTests.swift new file mode 100644 index 0000000000..ae1699f712 --- /dev/null +++ b/fearlessTests/TonIndexerClientTests.swift @@ -0,0 +1,231 @@ +import XCTest +@testable import fearless + +final class TonIndexerClientTests: XCTestCase { + func testDelegatesAccountEndpointsThroughValidatedTONIndexerRoutes() async throws { + let transport = FakeTonIndexerTransport() + let client = TonIndexerClient(transport: transport) + + transport.enqueue(Self.serviceInfoJSON) + _ = try await client.verifyServiceInfo() + XCTAssertEqual(transport.lastRequest?.url?.absoluteString, "https://ti.soramitsu.io/api/indexer/v1/service-info") + XCTAssertEqual(transport.lastRequest?.httpMethod, "GET") + + transport.enqueue(Self.balanceJSON) + _ = try await client.balance(address: Self.address) + XCTAssertEqual( + transport.lastRequest?.url?.absoluteString, + "https://ti.soramitsu.io/api/indexer/v1/accounts/\(Self.address)/balance" + ) + XCTAssertEqual(transport.lastRequest?.httpMethod, "GET") + + transport.enqueue(Self.transactionsJSON) + _ = try await client.transactions( + address: Self.address, + page: 2, + cursorLt: "10", + cursorHash: Self.hash + ) + XCTAssertEqual( + transport.lastRequest?.url?.absoluteString, + "https://ti.soramitsu.io/api/indexer/v1/accounts/\(Self.address)/txs?page=2&cursor_lt=10&cursor_hash=\(Self.encodedHash)" + ) + + transport.enqueue(Self.swapsJSON) + _ = try await client.swaps( + address: Self.address, + limit: 25, + fromUtime: 100, + toUtime: 200, + payToken: "TON", + receiveToken: "TST", + executionType: .market, + status: .success, + includeReverse: true + ) + XCTAssertEqual( + transport.lastRequest?.url?.absoluteString, + "https://ti.soramitsu.io/api/indexer/v1/accounts/\(Self.address)/swaps?limit=25&from_utime=100&to_utime=200&pay_token=TON&receive_token=TST&execution_type=market&status=success&include_reverse=true" + ) + + transport.enqueue(Self.payloadJSON) + _ = try await client.jettonTransferPayload(jetton: Self.address, owner: Self.rawAddress.uppercased()) + XCTAssertEqual( + transport.lastRequest?.url?.absoluteString, + "https://ti.soramitsu.io/api/indexer/v1/jettons/\(Self.address)/transfer/\(Self.rawAddress)/payload" + ) + } + + func testDelegatesGetterCallsWithNormalizedRequestBodies() async throws { + let transport = FakeTonIndexerTransport() + let client = TonIndexerClient(transport: transport) + + transport.enqueue(Self.runGetMethodJSON) + _ = try await client.runGetMethod(address: Self.address, method: " seqno ") + XCTAssertEqual(transport.lastRequest?.url?.absoluteString, "https://ti.soramitsu.io/api/indexer/v1/runGetMethod") + XCTAssertEqual(transport.lastRequest?.httpMethod, "POST") + XCTAssertEqual(transport.lastRequest?.value(forHTTPHeaderField: "Content-Type"), "application/json") + XCTAssertEqual(transport.lastRequest?.value(forHTTPHeaderField: "Accept"), "application/json") + XCTAssertNotNil(transport.lastRequest?.httpBody) + + let call = try TonIndexerRoutes.runGetMethodRequest(address: Self.address, method: "seqno") + transport.enqueue(Self.runGetMethodsJSON) + _ = try await client.runGetMethods(calls: [call]) + XCTAssertEqual(transport.lastRequest?.url?.absoluteString, "https://ti.soramitsu.io/api/indexer/v1/runGetMethods") + + do { + _ = try await client.runGetMethod(address: Self.address, method: "bad-method") + XCTFail("Expected invalid getter method to be rejected") + } catch { + XCTAssertEqual(error as? TonIndexerRouteError, .invalidMethod) + } + + do { + _ = try await client.runGetMethods(calls: []) + XCTFail("Expected empty getter batch to be rejected") + } catch { + XCTAssertEqual(error as? TonIndexerRouteError, .invalidCalls) + } + } + + func testVerifyServiceInfoRejectsMisroutedTONIndexer() async throws { + let transport = FakeTonIndexerTransport() + let client = TonIndexerClient(transport: transport) + + transport.enqueue(Self.misroutedServiceInfoJSON) + + do { + _ = try await client.verifyServiceInfo() + XCTFail("Expected misrouted TON service info to be rejected") + } catch { + XCTAssertEqual( + error as? TonIndexerClientError, + .unexpectedServiceInfo(Self.misroutedServiceInfo) + ) + XCTAssertEqual( + transport.lastRequest?.url?.absoluteString, + "https://ti.soramitsu.io/api/indexer/v1/service-info" + ) + } + } + + private final class FakeTonIndexerTransport: UniversalWalletHTTPTransport { + private var queuedResponses: [Data] = [] + private(set) var lastRequest: URLRequest? + + func enqueue(_ data: Data) { + queuedResponses.append(data) + } + + func perform(_ request: URLRequest) async throws -> Data { + lastRequest = request + return queuedResponses.isEmpty ? Data("{}".utf8) : queuedResponses.removeFirst() + } + } + + private static let address = "UQDxAUFadQXDd3EXGa3TLF_EF66gMc9h3_aZ0j0zXNoIYUCc" + private static let rawAddress = "0:f101415a7505c377711719add32c5fc417aea031cf61dff699d23d335cda0861" + private static let hash = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + private static let encodedHash = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA%3D" + + private static let serviceInfoJSON = Data(""" + { + "schemaVersion": 1, + "serviceId": "ti.soramitsu.io", + "serviceName": "TON Indexer", + "ecosystem": "ton", + "chainId": "ton:mainnet", + "network": "mainnet", + "publicBaseUrl": "https://ti.soramitsu.io", + "readOnly": true, + "capabilities": ["account-transactions"], + "endpoints": { + "transactions": "/api/indexer/v1/accounts/{addr}/txs" + } + } + """.utf8) + + private static let misroutedServiceInfoJSON = Data(""" + { + "schemaVersion": 1, + "serviceId": "si.soramitsu.io", + "serviceName": "Solswap Indexer", + "ecosystem": "solana", + "chainId": "solana:mainnet", + "network": "mainnet", + "publicBaseUrl": "https://si.soramitsu.io", + "readOnly": true, + "capabilities": ["wallet-transactions"], + "endpoints": { + "transactions": "/api/indexer/v1/accounts/{wallet}/txs" + } + } + """.utf8) + + private static let misroutedServiceInfo = TonIndexerServiceInfo( + schemaVersion: 1, + serviceId: "si.soramitsu.io", + serviceName: "Solswap Indexer", + ecosystem: "solana", + chainId: "solana:mainnet", + network: "mainnet", + publicBaseUrl: "https://si.soramitsu.io", + readOnly: true, + capabilities: ["wallet-transactions"], + endpoints: ["transactions": "/api/indexer/v1/accounts/{wallet}/txs"] + ) + + private static let balanceJSON = Data(""" + { + "ton": { + "balance": "0" + }, + "jettons": [], + "confirmed": true, + "updated_at": 1, + "network": "mainnet" + } + """.utf8) + + private static let transactionsJSON = Data(""" + { + "page": 1, + "page_size": 0, + "total_txs": 0, + "total_pages_min": 0, + "history_complete": true, + "txs": [], + "network": "mainnet" + } + """.utf8) + + private static let swapsJSON = Data(""" + { + "address": "\(address)", + "swaps": [], + "count": 0, + "network": "mainnet" + } + """.utf8) + + private static let payloadJSON = Data(""" + { + "custom_payload": null, + "state_init": null + } + """.utf8) + + private static let runGetMethodJSON = Data(""" + { + "exit_code": 0, + "gas_used": 0, + "stack": [] + } + """.utf8) + + private static let runGetMethodsJSON = Data(""" + { + "results": [] + } + """.utf8) +} diff --git a/fearlessTests/TonIndexerContractTests.swift b/fearlessTests/TonIndexerContractTests.swift new file mode 100644 index 0000000000..f3e56e0648 --- /dev/null +++ b/fearlessTests/TonIndexerContractTests.swift @@ -0,0 +1,205 @@ +import XCTest +@testable import fearless + +final class TonIndexerContractTests: XCTestCase { + func testBuildsTiEndpointURLsWithValidatedParameters() throws { + XCTAssertEqual( + try TonIndexerRoutes.healthURL().absoluteString, + "\(UniversalWalletRegistry.tonIndexerBaseURL.absoluteString)/api/indexer/v1/health" + ) + XCTAssertEqual( + try TonIndexerRoutes.contractsURL(baseURL: "https://ti.soramitsu.io/").absoluteString, + "https://ti.soramitsu.io/api/indexer/v1/contracts" + ) + XCTAssertEqual( + try TonIndexerRoutes.serviceInfoURL(baseURL: "https://ti.soramitsu.io/").absoluteString, + "https://ti.soramitsu.io/api/indexer/v1/service-info" + ) + XCTAssertEqual( + try TonIndexerRoutes.balanceURL(address: Self.address, baseURL: "https://ti.soramitsu.io/").absoluteString, + "https://ti.soramitsu.io/api/indexer/v1/accounts/\(Self.address)/balance" + ) + XCTAssertEqual( + try TonIndexerRoutes.balancesURL(address: Self.address, baseURL: "https://ti.soramitsu.io/").absoluteString, + "https://ti.soramitsu.io/api/indexer/v1/accounts/\(Self.address)/balances" + ) + XCTAssertEqual( + try TonIndexerRoutes.assetsURL(address: Self.address, baseURL: "https://ti.soramitsu.io/").absoluteString, + "https://ti.soramitsu.io/api/indexer/v1/accounts/\(Self.address)/assets" + ) + XCTAssertEqual( + try TonIndexerRoutes.stateURL(address: Self.address, baseURL: "https://ti.soramitsu.io/").absoluteString, + "https://ti.soramitsu.io/api/indexer/v1/accounts/\(Self.address)/state" + ) + XCTAssertEqual( + try TonIndexerRoutes.transactionsURL( + address: Self.address, + baseURL: "https://ti.soramitsu.io/", + page: 2, + cursorLt: "10", + cursorHash: Self.hash + ).absoluteString, + "https://ti.soramitsu.io/api/indexer/v1/accounts/\(Self.address)/txs?page=2&cursor_lt=10&cursor_hash=\(Self.encodedHash)" + ) + XCTAssertEqual( + try TonIndexerRoutes.swapsURL( + address: Self.address, + baseURL: "https://ti.soramitsu.io/", + limit: 25, + fromUtime: 100, + toUtime: 200, + payToken: "TON", + receiveToken: "TST", + executionType: .market, + status: .success, + includeReverse: true + ).absoluteString, + "https://ti.soramitsu.io/api/indexer/v1/accounts/\(Self.address)/swaps?limit=25&from_utime=100&to_utime=200&pay_token=TON&receive_token=TST&execution_type=market&status=success&include_reverse=true" + ) + XCTAssertEqual( + try TonIndexerRoutes.jettonTransferPayloadURL( + jetton: Self.address, + owner: Self.rawAddress.uppercased(), + baseURL: "https://ti.soramitsu.io/" + ).absoluteString, + "https://ti.soramitsu.io/api/indexer/v1/jettons/\(Self.address)/transfer/\(Self.rawAddress)/payload" + ) + XCTAssertEqual( + try TonIndexerRoutes.runGetMethodURL(baseURL: "https://ti.soramitsu.io/").absoluteString, + "https://ti.soramitsu.io/api/indexer/v1/runGetMethod" + ) + XCTAssertEqual( + try TonIndexerRoutes.runGetMethodRequest(address: Self.address, method: " seqno ").method, + "seqno" + ) + XCTAssertEqual( + try TonIndexerRoutes.runGetMethodsRequest(calls: [ + try TonIndexerRoutes.runGetMethodRequest(address: Self.address, method: "seqno") + ]).calls.count, + 1 + ) + } + + func testAllowsLocalHTTPBaseURLsButRejectsNonlocalInsecureBaseURLs() throws { + XCTAssertEqual(try TonIndexerRoutes.normalizeBaseURL("http://localhost:3000/"), "http://localhost:3000") + XCTAssertEqual(try TonIndexerRoutes.normalizeBaseURL("http://127.0.0.1:3000/"), "http://127.0.0.1:3000") + + XCTAssertThrowsError(try TonIndexerRoutes.normalizeBaseURL("http://ti.soramitsu.io")) { error in + XCTAssertEqual(error as? TonIndexerRouteError, .invalidBaseURL) + } + XCTAssertThrowsError(try TonIndexerRoutes.normalizeBaseURL("not a url")) { error in + XCTAssertEqual(error as? TonIndexerRouteError, .invalidBaseURL) + } + } + + func testRejectsMalformedTONRouteInputsBeforeNetworkCalls() throws { + XCTAssertThrowsError(try TonIndexerRoutes.balanceURL(address: "../bad")) { error in + XCTAssertEqual(error as? TonIndexerRouteError, .invalidAddress) + } + XCTAssertThrowsError(try TonIndexerRoutes.transactionsURL(address: Self.address, page: 0)) { error in + XCTAssertEqual(error as? TonIndexerRouteError, .invalidPage) + } + XCTAssertThrowsError(try TonIndexerRoutes.transactionsURL(address: Self.address, cursorLt: "1")) { error in + XCTAssertEqual(error as? TonIndexerRouteError, .cursorMismatch) + } + XCTAssertThrowsError(try TonIndexerRoutes.transactionsURL(address: Self.address, cursorLt: "bad", cursorHash: Self.hash)) { error in + XCTAssertEqual(error as? TonIndexerRouteError, .invalidCursor) + } + XCTAssertThrowsError(try TonIndexerRoutes.transactionsURL(address: Self.address, cursorLt: "1", cursorHash: "../../../bad")) { error in + XCTAssertEqual(error as? TonIndexerRouteError, .invalidCursor) + } + XCTAssertThrowsError(try TonIndexerRoutes.swapsURL(address: Self.address, limit: 0)) { error in + XCTAssertEqual(error as? TonIndexerRouteError, .invalidLimit) + } + XCTAssertThrowsError(try TonIndexerRoutes.swapsURL(address: Self.address, limit: 501)) { error in + XCTAssertEqual(error as? TonIndexerRouteError, .invalidLimit) + } + XCTAssertThrowsError(try TonIndexerRoutes.swapsURL(address: Self.address, fromUtime: 20, toUtime: 10)) { error in + XCTAssertEqual(error as? TonIndexerRouteError, .invalidUtimeRange) + } + XCTAssertThrowsError(try TonIndexerRoutes.swapsURL(address: Self.address, payToken: "../bad")) { error in + XCTAssertEqual(error as? TonIndexerRouteError, .invalidTokenFilter) + } + XCTAssertThrowsError(try TonIndexerRoutes.runGetMethodRequest(address: Self.address, method: "bad-method")) { error in + XCTAssertEqual(error as? TonIndexerRouteError, .invalidMethod) + } + XCTAssertThrowsError(try TonIndexerRoutes.runGetMethodsRequest(calls: [])) { error in + XCTAssertEqual(error as? TonIndexerRouteError, .invalidCalls) + } + let call = try TonIndexerRoutes.runGetMethodRequest(address: Self.address, method: "seqno") + XCTAssertThrowsError(try TonIndexerRoutes.runGetMethodsRequest(calls: Array(repeating: call, count: 65))) { error in + XCTAssertEqual(error as? TonIndexerRouteError, .invalidCalls) + } + } + + func testParsesTiBalanceWithoutLosingLargeIntegerPrecision() throws { + let json = """ + { + "ton": { + "balance": "340282366920938463463374607431768211455", + "last_tx_lt": "12345678901234567890", + "last_tx_hash": "\(Self.hash)" + }, + "jettons": [ + { + "master": "\(Self.address)", + "wallet": "\(Self.rawAddress)", + "balance": "18446744073709551616", + "decimals": 9, + "symbol": "TST" + } + ], + "confirmed": true, + "updated_at": 1710000000000, + "network": "mainnet" + } + """ + let response = try JSONDecoder().decode(TonBalanceResponse.self, from: Data(json.utf8)) + + XCTAssertEqual(response.ton.balance, "340282366920938463463374607431768211455") + XCTAssertEqual(response.ton.lastTxLt, "12345678901234567890") + XCTAssertEqual(response.jettons.first?.balance, "18446744073709551616") + XCTAssertEqual(response.network, "mainnet") + } + + func testVerifiesTIServiceIdentityAndRejectsMisroutedServiceInfo() throws { + let json = """ + { + "schemaVersion": 1, + "serviceId": "ti.soramitsu.io", + "serviceName": "TON Indexer", + "ecosystem": "ton", + "chainId": "ton:mainnet", + "network": "mainnet", + "publicBaseUrl": "https://ti.soramitsu.io", + "readOnly": true, + "capabilities": ["account-transactions"], + "endpoints": { + "transactions": "/api/indexer/v1/accounts/{addr}/txs" + } + } + """ + let response = try JSONDecoder().decode(TonIndexerServiceInfo.self, from: Data(json.utf8)) + + XCTAssertTrue(response.isExpectedTIServiceInfo) + XCTAssertFalse( + TonIndexerServiceInfo( + schemaVersion: response.schemaVersion, + serviceId: "si.soramitsu.io", + serviceName: response.serviceName, + ecosystem: response.ecosystem, + chainId: response.chainId, + network: response.network, + publicBaseUrl: response.publicBaseUrl, + readOnly: response.readOnly, + capabilities: response.capabilities, + endpoints: response.endpoints + ).isExpectedTIServiceInfo + ) + } + + private static let address = "UQDxAUFadQXDd3EXGa3TLF_EF66gMc9h3_aZ0j0zXNoIYUCc" + private static let rawAddress = "0:f101415a7505c377711719add32c5fc417aea031cf61dff699d23d335cda0861" + private static let hash = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + private static let encodedHash = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA%3D" +} diff --git a/fearlessTests/UniversalWalletAccountAddressResolverTests.swift b/fearlessTests/UniversalWalletAccountAddressResolverTests.swift new file mode 100644 index 0000000000..3e1e7e28c5 --- /dev/null +++ b/fearlessTests/UniversalWalletAccountAddressResolverTests.swift @@ -0,0 +1,385 @@ +import SSFModels +import XCTest +@testable import fearless + +final class UniversalWalletAccountAddressResolverTests: XCTestCase { + func testResolvesBitcoinMainnetAddressFromMatchingChainAccountPublicKey() throws { + let key = try BitcoinKeyDerivation.deriveKey( + mnemonic: Self.mnemonic, + derivationPath: UniversalWalletDerivationPaths.bitcoinMainnetFirstReceive, + network: .mainnet + ) + let wallet = walletWithBitcoinAccount( + chainId: UniversalWalletRegistry.bitcoinMainnet.chainId, + publicKey: key.publicKey + ) + + let address = UniversalWalletAccountAddressResolver.address( + for: Self.chain(UniversalWalletRegistry.bitcoinMainnet.chainId), + wallet: wallet + ) + + XCTAssertEqual(address, key.address) + XCTAssertTrue(address?.hasPrefix("bc1") == true) + } + + func testResolvesBitcoinTestnetAddressFromMatchingChainAccountPublicKey() throws { + let key = try BitcoinKeyDerivation.deriveKey( + mnemonic: Self.mnemonic, + derivationPath: UniversalWalletDerivationPaths.bitcoinTestnetFirstReceive, + network: .testnet + ) + let wallet = walletWithBitcoinAccount( + chainId: UniversalWalletRegistry.bitcoinTestnet.chainId, + publicKey: key.publicKey + ) + + let address = UniversalWalletAccountAddressResolver.address( + for: Self.chain(UniversalWalletRegistry.bitcoinTestnet.chainId), + wallet: wallet + ) + + XCTAssertEqual(address, key.address) + XCTAssertTrue(address?.hasPrefix("tb1") == true) + } + + func testBitcoinAddressResolutionFailsClosedWithoutMatchingChainAccount() { + let wallet = AccountGenerator.generateMetaAccount() + + let address = UniversalWalletAccountAddressResolver.address( + for: Self.chain(UniversalWalletRegistry.bitcoinMainnet.chainId), + wallet: wallet + ) + + XCTAssertNil(address) + } + + func testBitcoinAddressResolutionRejectsMalformedPublicKey() { + let wallet = walletWithBitcoinAccount( + chainId: UniversalWalletRegistry.bitcoinMainnet.chainId, + publicKey: Data(repeating: 0x01, count: 32) + ) + + let address = UniversalWalletAccountAddressResolver.address( + for: Self.chain(UniversalWalletRegistry.bitcoinMainnet.chainId), + wallet: wallet + ) + + XCTAssertNil(address) + } + + func testResolvesSolanaMainnetAddressFromMatchingChainAccountPublicKey() throws { + let account = try SolanaKeyDerivation.deriveAccount(mnemonic: Self.mnemonic) + let wallet = walletWithChainAccount( + chainId: UniversalWalletRegistry.solanaMainnet.chainId, + publicKey: account.publicKey, + cryptoType: CryptoType.ed25519.rawValue + ) + + let address = UniversalWalletAccountAddressResolver.address( + for: Self.chain(UniversalWalletRegistry.solanaMainnet.chainId), + wallet: wallet + ) + + XCTAssertEqual(address, account.address) + } + + func testResolvesSolanaDevnetAddressFromMatchingChainAccountPublicKey() throws { + let account = try SolanaKeyDerivation.deriveAccount(mnemonic: Self.mnemonic) + let wallet = walletWithChainAccount( + chainId: UniversalWalletRegistry.solanaDevnet.chainId, + publicKey: account.publicKey, + cryptoType: CryptoType.ed25519.rawValue + ) + + let address = UniversalWalletAccountAddressResolver.address( + for: Self.chain(UniversalWalletRegistry.solanaDevnet.chainId), + wallet: wallet + ) + + XCTAssertEqual(address, account.address) + } + + func testSolanaAddressResolutionFailsClosedWithoutMatchingChainAccount() { + let wallet = AccountGenerator.generateMetaAccount() + + let address = UniversalWalletAccountAddressResolver.address( + for: Self.chain(UniversalWalletRegistry.solanaMainnet.chainId), + wallet: wallet + ) + + XCTAssertNil(address) + } + + func testSolanaAddressResolutionRejectsMalformedPublicKey() { + let wallet = walletWithChainAccount( + chainId: UniversalWalletRegistry.solanaMainnet.chainId, + publicKey: Data(repeating: 0x01, count: 31), + cryptoType: CryptoType.ed25519.rawValue + ) + + let address = UniversalWalletAccountAddressResolver.address( + for: Self.chain(UniversalWalletRegistry.solanaMainnet.chainId), + wallet: wallet + ) + + XCTAssertNil(address) + } + + func testResolverMatchesCanonicalSolanaAccountWhenChainUsesRegistryId() throws { + let account = try SolanaKeyDerivation.deriveAccount(mnemonic: Self.mnemonic) + let wallet = walletWithChainAccount( + chainId: UniversalWalletRegistry.solanaMainnet.chainId, + publicKey: account.publicKey, + cryptoType: CryptoType.ed25519.rawValue + ) + + let address = UniversalWalletAccountAddressResolver.address( + for: Self.chain(UniversalWalletRegistry.solanaMainnet.id), + wallet: wallet + ) + + XCTAssertEqual(address, account.address) + } + + func testResolvesTairaI105AddressFromMatchingChainAccountPublicKey() throws { + let account = try IrohaKeyDerivation.deriveAccount(mnemonic: Self.mnemonic) + let wallet = walletWithChainAccount( + chainId: UniversalWalletRegistry.taira.chainId, + publicKey: account.publicKey, + cryptoType: CryptoType.ed25519.rawValue + ) + + let address = try XCTUnwrap( + UniversalWalletAccountAddressResolver.address( + for: Self.chain(UniversalWalletRegistry.taira.chainId), + wallet: wallet + ) + ) + + XCTAssertTrue(address.hasPrefix("test")) + XCTAssertEqual( + try IrohaAddressCodec.parse( + address, + expectedDiscriminant: UniversalWalletRegistry.taira.chainDiscriminant + ).publicKeyHex, + account.publicKeyHex + ) + } + + func testResolvesNexusI105AddressFromSamePublicKeyWithNexusDiscriminant() throws { + let account = try IrohaKeyDerivation.deriveAccount(mnemonic: Self.mnemonic) + let wallet = walletWithChainAccount( + chainId: UniversalWalletRegistry.nexus.chainId, + publicKey: account.publicKey, + cryptoType: CryptoType.ed25519.rawValue + ) + + let address = try XCTUnwrap( + UniversalWalletAccountAddressResolver.address( + for: Self.chain(UniversalWalletRegistry.nexus.chainId), + wallet: wallet + ) + ) + + XCTAssertTrue(address.hasPrefix("sora")) + XCTAssertEqual( + try IrohaAddressCodec.parse( + address, + expectedDiscriminant: UniversalWalletRegistry.nexus.chainDiscriminant + ).publicKeyHex, + account.publicKeyHex + ) + XCTAssertThrowsError( + try IrohaAddressCodec.parse( + address, + expectedDiscriminant: UniversalWalletRegistry.taira.chainDiscriminant + ) + ) + } + + func testIrohaAddressResolutionFailsClosedWithoutMatchingChainAccount() { + let wallet = AccountGenerator.generateMetaAccount() + + let address = UniversalWalletAccountAddressResolver.address( + for: Self.chain(UniversalWalletRegistry.taira.chainId), + wallet: wallet + ) + + XCTAssertNil(address) + } + + func testIrohaAddressResolutionRejectsMalformedPublicKey() { + let wallet = walletWithChainAccount( + chainId: UniversalWalletRegistry.taira.chainId, + publicKey: Data(repeating: 0x01, count: 31), + cryptoType: CryptoType.ed25519.rawValue + ) + + let address = UniversalWalletAccountAddressResolver.address( + for: Self.chain(UniversalWalletRegistry.taira.chainId), + wallet: wallet + ) + + XCTAssertNil(address) + } + + func testFetchFailsClosedForUniversalWalletChainWithoutMatchingChainAccount() { + let wallet = AccountGenerator.generateMetaAccount() + + XCTAssertNil(wallet.fetch(for: Self.chain(UniversalWalletRegistry.bitcoinMainnet.chainId).accountRequest())) + XCTAssertNil(wallet.fetch(for: Self.chain(UniversalWalletRegistry.solanaMainnet.chainId).accountRequest())) + XCTAssertNil(wallet.fetch(for: Self.chain(UniversalWalletRegistry.taira.chainId).accountRequest())) + } + + func testFetchMatchesRegistryIdToCanonicalUniversalWalletChainAccount() throws { + let account = try SolanaKeyDerivation.deriveAccount(mnemonic: Self.mnemonic) + let wallet = walletWithChainAccount( + chainId: UniversalWalletRegistry.solanaMainnet.chainId, + publicKey: account.publicKey, + cryptoType: CryptoType.ed25519.rawValue + ) + + let response = try XCTUnwrap( + wallet.fetch(for: Self.chain(UniversalWalletRegistry.solanaMainnet.id).accountRequest()) + ) + + XCTAssertEqual(response.chainId, UniversalWalletRegistry.solanaMainnet.id) + XCTAssertEqual(response.toAddress(), account.address) + } + + func testChainAccountResponseDerivesUniversalWalletNativeAddresses() throws { + let bitcoinKey = try BitcoinKeyDerivation.deriveKey( + mnemonic: Self.mnemonic, + derivationPath: UniversalWalletDerivationPaths.bitcoinMainnetFirstReceive, + network: .mainnet + ) + let bitcoinWallet = walletWithBitcoinAccount( + chainId: UniversalWalletRegistry.bitcoinMainnet.chainId, + publicKey: bitcoinKey.publicKey + ) + XCTAssertEqual( + bitcoinWallet.fetch(for: Self.chain(UniversalWalletRegistry.bitcoinMainnet.chainId).accountRequest())?.toAddress(), + bitcoinKey.address + ) + + let irohaAccount = try IrohaKeyDerivation.deriveAccount(mnemonic: Self.mnemonic) + let irohaWallet = walletWithChainAccount( + chainId: UniversalWalletRegistry.nexus.chainId, + publicKey: irohaAccount.publicKey, + cryptoType: CryptoType.ed25519.rawValue + ) + let irohaAddress = try XCTUnwrap( + irohaWallet.fetch(for: Self.chain(UniversalWalletRegistry.nexus.id).accountRequest())?.toAddress() + ) + XCTAssertEqual( + try IrohaAddressCodec.parse( + irohaAddress, + expectedDiscriminant: UniversalWalletRegistry.nexus.chainDiscriminant + ).publicKeyHex, + irohaAccount.publicKeyHex + ) + } + + func testAccountFetchingFindsUniversalWalletByNativeAddressOnlyOnMatchingChain() throws { + let account = try SolanaKeyDerivation.deriveAccount(mnemonic: Self.mnemonic) + let wallet = walletWithChainAccount( + chainId: UniversalWalletRegistry.solanaMainnet.chainId, + publicKey: account.publicKey, + cryptoType: CryptoType.ed25519.rawValue + ) + + var result: Result? + let found = AccountFetchingHarness().fetchChainAccountFor( + meta: wallet, + chain: Self.chain(UniversalWalletRegistry.solanaMainnet.id), + address: account.address + ) { result = $0 } + + XCTAssertTrue(found) + XCTAssertEqual(try result?.get()?.toAddress(), account.address) + } + + func testAccountFetchingDoesNotExposeUniversalWalletAccountAsSubstrate() throws { + let account = try SolanaKeyDerivation.deriveAccount(mnemonic: Self.mnemonic) + let wallet = walletWithChainAccount( + chainId: UniversalWalletRegistry.solanaMainnet.chainId, + publicKey: account.publicKey, + cryptoType: CryptoType.ed25519.rawValue + ) + let substrateChain = Self.chain(String(repeating: "a", count: 64)) + let substrateFormattedSolanaKey = try account.publicKey.toAddress( + using: .substrate(substrateChain.addressPrefix) + ) + + var result: Result? + let found = AccountFetchingHarness().fetchChainAccountFor( + meta: wallet, + chain: substrateChain, + address: substrateFormattedSolanaKey + ) { result = $0 } + + XCTAssertFalse(found) + XCTAssertThrowsError(try result?.get()) + } + + func testNonBitcoinChainsKeepExistingAddressResolution() { + let wallet = AccountGenerator.generateMetaAccount() + let chain = Self.chain(String(repeating: "0", count: 64)) + + let address = UniversalWalletAccountAddressResolver.address(for: chain, wallet: wallet) + + XCTAssertEqual(address, wallet.fetch(for: chain.accountRequest())?.toAddress()) + } + + private func walletWithBitcoinAccount(chainId: String, publicKey: Data) -> MetaAccountModel { + walletWithChainAccount( + chainId: chainId, + publicKey: publicKey, + cryptoType: CryptoType.ecdsa.rawValue + ) + } + + private func walletWithChainAccount(chainId: String, publicKey: Data, cryptoType: UInt8) -> MetaAccountModel { + let account = ChainAccountModel( + chainId: chainId, + accountId: publicKey, + publicKey: publicKey, + cryptoType: cryptoType, + ethereumBased: false + ) + + return AccountGenerator.generateMetaAccount(with: [account]) + } + + private static func chain(_ chainId: String) -> ChainModel { + ChainModel( + rank: nil, + disabled: false, + chainId: chainId, + parentId: nil, + paraId: nil, + name: "Test", + assets: [], + xcm: nil, + nodes: [ + ChainNodeModel( + url: URL(string: "https://node.example")!, + name: "Test", + apikey: nil + ) + ], + addressPrefix: 0, + icon: nil, + options: nil, + externalApi: nil, + customNodes: nil, + iosMinAppVersion: nil, + identityChain: nil + ) + } + + private static let mnemonic = "legal winner thank year wave sausage worth useful legal winner thank yellow" +} + +private final class AccountFetchingHarness: AccountFetching {} diff --git a/fearlessTests/UniversalWalletIdentityTests.swift b/fearlessTests/UniversalWalletIdentityTests.swift new file mode 100644 index 0000000000..45abeee397 --- /dev/null +++ b/fearlessTests/UniversalWalletIdentityTests.swift @@ -0,0 +1,176 @@ +import XCTest +@testable import fearless + +final class UniversalWalletIdentityTests: XCTestCase { + func testValidatesAndSerializesActiveUniversalWalletIdentity() throws { + let identity = activeIdentity() + + XCTAssertTrue(identity.validationErrors().isEmpty) + XCTAssertEqual(try identity.requireValid(), identity) + + let json = String(decoding: try JSONEncoder().encode(identity), as: UTF8.self) + XCTAssertTrue(json.contains(#""schemaVersion":2"#)) + XCTAssertTrue(json.contains(#""source":"created-24-word""#)) + XCTAssertTrue(json.contains(#""status":"active""#)) + XCTAssertTrue(json.contains(#""ecosystem":"bitcoin""#)) + } + + func testRejectsPartialActiveWalletsAndDuplicateAccounts() { + let partial = activeIdentity( + accounts: Self.defaultAccounts().filter { $0.ecosystem != UniversalWalletEcosystem.iroha.rawValue } + ) + let duplicate = activeIdentity( + accounts: Self.defaultAccounts() + [ + Self.defaultAccounts()[0] + ] + ) + + XCTAssertTrue(partial.validationErrors().contains(.missingActiveEcosystem)) + XCTAssertTrue(duplicate.validationErrors().contains(.duplicateAccountId)) + } + + func testRejectsMalformedIdentityAndAccountFields() { + let account = UniversalWalletIdentity.PublicAccount( + accountId: "../bad", + ecosystem: "unknown", + address: " bc1bad ", + chainId: "../bad", + derivationPath: "m/44'/0'/x", + publicKeyHex: "ABC" + ) + let identity = activeIdentity( + walletId: "bad", + displayName: "bad\u{0000}name", + accounts: [account], + createdAtMillis: 10, + updatedAtMillis: 9 + ) + + let errors = identity.validationErrors() + + XCTAssertTrue(errors.contains(.invalidWalletId)) + XCTAssertTrue(errors.contains(.invalidDisplayName)) + XCTAssertTrue(errors.contains(.invalidTimestamps)) + XCTAssertTrue(errors.contains(.invalidAccountId)) + XCTAssertTrue(errors.contains(.invalidEcosystem)) + XCTAssertTrue(errors.contains(.invalidAddress)) + XCTAssertTrue(errors.contains(.invalidChainId)) + XCTAssertTrue(errors.contains(.invalidDerivationPath)) + XCTAssertTrue(errors.contains(.invalidPublicKeyHex)) + XCTAssertThrowsError(try identity.requireValid()) + } + + func testEnforcesLegacyExportOnlySourceAndReason() { + let legacy = activeIdentity( + source: .legacyImport, + status: .legacyExportOnly, + accounts: [Self.defaultAccounts()[0]], + legacyExportOnlyReason: "pre-cutoff account export" + ) + let wrongSource = activeIdentity( + source: .created24Word, + status: .legacyExportOnly, + accounts: [Self.defaultAccounts()[0]], + legacyExportOnlyReason: "pre-cutoff account export" + ) + let missingReason = activeIdentity( + source: .legacyImport, + status: .legacyExportOnly, + accounts: [Self.defaultAccounts()[0]], + legacyExportOnlyReason: " " + ) + let activeWithReason = activeIdentity(legacyExportOnlyReason: "not allowed") + + XCTAssertTrue(legacy.validationErrors().isEmpty) + XCTAssertTrue(wrongSource.validationErrors().contains(.invalidLegacySource)) + XCTAssertTrue(missingReason.validationErrors().contains(.legacyReasonRequired)) + XCTAssertTrue(activeWithReason.validationErrors().contains(.legacyReasonNotAllowed)) + } + + func testAllowsMigrationRequiredIdentityToBePartialButNotEmpty() { + let migrating = activeIdentity( + status: .migrationRequired, + accounts: [Self.defaultAccounts()[0]] + ) + let emptyMigrating = activeIdentity(status: .migrationRequired, accounts: []) + + XCTAssertFalse(migrating.validationErrors().contains(.missingActiveEcosystem)) + XCTAssertTrue(migrating.validationErrors().isEmpty) + XCTAssertTrue(emptyMigrating.validationErrors().contains(.publicAccountsRequired)) + } + + private func activeIdentity( + walletId: String = "uw2_1234567890abcdef", + displayName: String = "Fearless Universal", + source: UniversalWalletIdentity.Source = .created24Word, + status: UniversalWalletIdentity.Status = .active, + accounts: [UniversalWalletIdentity.PublicAccount] = UniversalWalletIdentityTests.defaultAccounts(), + createdAtMillis: Int64 = 1_710_000_000_000, + updatedAtMillis: Int64? = nil, + legacyExportOnlyReason: String? = nil + ) -> UniversalWalletIdentity { + UniversalWalletIdentity( + walletId: walletId, + displayName: displayName, + source: source, + status: status, + publicAccounts: accounts, + createdAtMillis: createdAtMillis, + updatedAtMillis: updatedAtMillis, + legacyExportOnlyReason: legacyExportOnlyReason + ) + } + + private static func defaultAccounts() -> [UniversalWalletIdentity.PublicAccount] { + [ + UniversalWalletIdentity.PublicAccount( + accountId: "substrate-polkadot", + ecosystem: .substrate, + address: "15FKRtF6nX3SE4PaW4LX69XqYHq2oSep9JX5KF4cgN1XkZ4q", + chainId: "polkadot", + derivationPath: UniversalWalletDerivationPaths.substrateRoot, + publicKeyHex: hex32 + ), + UniversalWalletIdentity.PublicAccount( + accountId: "evm-default", + ecosystem: .evm, + address: "0x1111111111111111111111111111111111111111", + chainId: "eip155:1", + derivationPath: UniversalWalletDerivationPaths.evmDefault + ), + UniversalWalletIdentity.PublicAccount( + accountId: "bitcoin-mainnet", + ecosystem: .bitcoin, + address: "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu", + chainId: "bitcoin:mainnet", + derivationPath: UniversalWalletDerivationPaths.bitcoinMainnetFirstReceive + ), + UniversalWalletIdentity.PublicAccount( + accountId: "solana-mainnet", + ecosystem: .solana, + address: "HAgk14JpMQLgt6rVgv7cBQFJWFto5Dqxi472uT3DKpqk", + chainId: "solana:mainnet", + derivationPath: UniversalWalletDerivationPaths.solanaDefault, + publicKeyHex: hex32 + ), + UniversalWalletIdentity.PublicAccount( + accountId: "ton-mainnet", + ecosystem: .ton, + address: "UQDxAUFadQXDd3EXGa3TLF_EF66gMc9h3_aZ0j0zXNoIYUCc", + chainId: "ton:mainnet", + derivationPath: UniversalWalletDerivationPaths.tonDefault, + publicKeyHex: hex32 + ), + UniversalWalletIdentity.PublicAccount( + accountId: "iroha-taira", + ecosystem: .iroha, + address: "testuロ1Pcナ2ラtノaリLユス2MヱミホウモヱヌニイMメSマムヱヌJヱFmJヌMs6YN687Y", + chainId: "iroha3-taira", + derivationPath: UniversalWalletDerivationPaths.irohaDefault, + publicKeyHex: hex32 + ) + ] + } + + private static let hex32 = String(repeating: "11", count: 32) +} diff --git a/fearlessTests/UniversalWalletIndexerClientFactoryTests.swift b/fearlessTests/UniversalWalletIndexerClientFactoryTests.swift new file mode 100644 index 0000000000..4b9cebe3cc --- /dev/null +++ b/fearlessTests/UniversalWalletIndexerClientFactoryTests.swift @@ -0,0 +1,70 @@ +import XCTest +@testable import fearless + +final class UniversalWalletIndexerClientFactoryTests: XCTestCase { + func testCreatesClientsOverSharedTransport() async throws { + let transport = FakeUniversalWalletHTTPTransport() + let factory = UniversalWalletIndexerClientFactory(transport: transport) + + transport.enqueue(Self.bitcoinAddressJSON) + _ = try await factory.bitcoinClient().address(address: Self.bitcoinAddress) + + transport.enqueue(Self.solanaBalancesJSON) + _ = try await factory.solanaClient().balances(wallet: Self.solanaWallet) + + XCTAssertEqual(transport.requests.map { $0.url?.host }, ["blockstream.info", "si.soramitsu.io"]) + } + + private final class FakeUniversalWalletHTTPTransport: UniversalWalletHTTPTransport { + private var queuedResponses: [Data] = [] + private(set) var requests: [URLRequest] = [] + + func enqueue(_ data: Data) { + queuedResponses.append(data) + } + + func perform(_ request: URLRequest) async throws -> Data { + requests.append(request) + return queuedResponses.isEmpty ? Data("{}".utf8) : queuedResponses.removeFirst() + } + } + + private static let bitcoinAddress = "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu" + private static let solanaWallet = "HAgk14JpMQLgt6rVgv7cBQFJWFto5Dqxi472uT3DKpqk" + + private static let bitcoinAddressJSON = Data(""" + { + "address": "\(bitcoinAddress)", + "chain_stats": { + "funded_txo_count": 0, + "funded_txo_sum": 0, + "spent_txo_count": 0, + "spent_txo_sum": 0, + "tx_count": 0 + }, + "mempool_stats": { + "funded_txo_count": 0, + "funded_txo_sum": 0, + "spent_txo_count": 0, + "spent_txo_sum": 0, + "tx_count": 0 + } + } + """.utf8) + + private static let solanaBalancesJSON = Data(""" + { + "wallet": "\(solanaWallet)", + "native": { + "type": "native", + "mint": "SOL", + "lamports": "0", + "decimals": 9, + "uiAmountString": "0" + }, + "tokens": [], + "total": 1, + "syncedAt": 1 + } + """.utf8) +} diff --git a/fearlessTests/UniversalWalletIndexerContractTests.swift b/fearlessTests/UniversalWalletIndexerContractTests.swift new file mode 100644 index 0000000000..3042cd204d --- /dev/null +++ b/fearlessTests/UniversalWalletIndexerContractTests.swift @@ -0,0 +1,220 @@ +import XCTest +@testable import fearless + +final class UniversalWalletIndexerContractTests: XCTestCase { + func testValidatesAndSerializesNormalizedAssetBalances() throws { + let balance = assetBalance() + + XCTAssertTrue(balance.validationErrors().isEmpty) + + let json = String(decoding: try JSONEncoder().encode(balance), as: UTF8.self) + XCTAssertTrue(json.contains(#""ecosystem":"solana""#)) + XCTAssertTrue(json.contains(#""assetId":"SOL""#)) + XCTAssertTrue(json.contains(#""amount":"123456789""#)) + XCTAssertTrue(json.contains(#""tokenProgram":"spl-token""#)) + } + + func testRejectsMalformedAssetBalances() { + let balance = UniversalWalletIndexedAssetBalance( + accountId: "../bad", + ecosystem: "unknown", + chainId: "../bad", + assetId: " SOL ", + amount: "-1", + decimals: 256, + isNative: true, + symbol: "bad\u{0000}symbol", + name: "bad\u{0000}name", + uiAmountString: nil, + tokenAccountId: " token ", + contractAddress: " contract ", + tokenProgram: " token program ", + syncedAtMillis: 0 + ) + + let errors = balance.validationErrors() + + XCTAssertTrue(errors.contains(.invalidAccountId)) + XCTAssertTrue(errors.contains(.invalidEcosystem)) + XCTAssertTrue(errors.contains(.invalidChainId)) + XCTAssertTrue(errors.contains(.invalidAssetId)) + XCTAssertTrue(errors.contains(.invalidAmount)) + XCTAssertTrue(errors.contains(.invalidDecimals)) + XCTAssertTrue(errors.contains(.invalidSymbol)) + XCTAssertTrue(errors.contains(.invalidName)) + XCTAssertTrue(errors.contains(.invalidAddress)) + XCTAssertTrue(errors.contains(.invalidSyncedAt)) + } + + func testValidatesAndSerializesNormalizedTransactions() throws { + let transaction = indexedTransaction() + + XCTAssertTrue(transaction.validationErrors().isEmpty) + + let json = String(decoding: try JSONEncoder().encode(transaction), as: UTF8.self) + XCTAssertTrue(json.contains(#""status":"confirmed""#)) + XCTAssertTrue(json.contains(#""direction":"outgoing""#)) + XCTAssertTrue(json.contains(#""operationType":"transfer""#)) + } + + func testSerializesNexusOperationBuckets() throws { + let encoder = JSONEncoder() + + let offlineCash = indexedTransaction(operationType: .offlineCash) + let sccp = indexedTransaction(operationType: .sccp) + let governance = indexedTransaction(operationType: .governance) + + XCTAssertTrue( + String(decoding: try encoder.encode(offlineCash), as: UTF8.self) + .contains(#""operationType":"offline-cash""#) + ) + XCTAssertTrue( + String(decoding: try encoder.encode(sccp), as: UTF8.self) + .contains(#""operationType":"sccp""#) + ) + XCTAssertTrue( + String(decoding: try encoder.encode(governance), as: UTF8.self) + .contains(#""operationType":"governance""#) + ) + } + + func testRejectsMalformedNormalizedTransactions() { + let transaction = UniversalWalletIndexedTransaction( + accountId: "bad account", + ecosystem: "bad", + chainId: "bad chain", + transactionId: " tx ", + status: .failed, + direction: .unknown, + operationType: .unknown, + timestampMillis: -1, + amount: "01", + assetId: " asset ", + feeAmount: "1.2", + feeAssetId: " fee ", + counterpartyAddress: " counterparty ", + blockNumber: "-2", + cursor: " cursor ", + explorerUrl: "http://example.com/tx", + syncedAtMillis: 0 + ) + + let errors = transaction.validationErrors() + + XCTAssertTrue(errors.contains(.invalidAccountId)) + XCTAssertTrue(errors.contains(.invalidEcosystem)) + XCTAssertTrue(errors.contains(.invalidChainId)) + XCTAssertTrue(errors.contains(.invalidTransactionId)) + XCTAssertTrue(errors.contains(.invalidTimestamp)) + XCTAssertTrue(errors.contains(.invalidAmount)) + XCTAssertTrue(errors.contains(.invalidAssetId)) + XCTAssertTrue(errors.contains(.invalidAddress)) + XCTAssertTrue(errors.contains(.invalidBlockNumber)) + XCTAssertTrue(errors.contains(.invalidCursor)) + XCTAssertTrue(errors.contains(.invalidUrl)) + XCTAssertTrue(errors.contains(.invalidSyncedAt)) + } + + func testValidatesTokenMetadataAndPageInfoContracts() { + let metadata = UniversalWalletIndexedTokenMetadata( + ecosystem: UniversalWalletEcosystem.ton.rawValue, + chainId: "ton:mainnet", + assetId: "EQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAM9c", + decimals: 9, + symbol: "TON", + name: "Toncoin", + iconUrl: "ipfs://bafybeigdyrzt", + metadataUrl: "https://example.com/ton.json", + isVerified: true, + syncedAtMillis: 1_710_000_000_000 + ) + let pageInfo = UniversalWalletIndexerPageInfo( + nextCursor: "next-cursor", + limit: 100, + total: 1, + syncedAtMillis: 1_710_000_000_000 + ) + + XCTAssertTrue(metadata.validationErrors().isEmpty) + XCTAssertTrue(pageInfo.validationErrors().isEmpty) + } + + func testRejectsMalformedTokenMetadataAndPageInfoContracts() { + let metadata = UniversalWalletIndexedTokenMetadata( + ecosystem: "bad", + chainId: "bad chain", + assetId: " asset ", + decimals: -1, + symbol: "bad\u{0000}symbol", + name: "bad\u{0000}name", + iconUrl: "ftp://example.com/icon.png", + metadataUrl: "http://example.com/meta.json", + isVerified: false, + syncedAtMillis: 0 + ) + let pageInfo = UniversalWalletIndexerPageInfo( + nextCursor: " cursor ", + limit: 251, + total: -1, + syncedAtMillis: 0 + ) + + let metadataErrors = metadata.validationErrors() + let pageErrors = pageInfo.validationErrors() + + XCTAssertTrue(metadataErrors.contains(.invalidEcosystem)) + XCTAssertTrue(metadataErrors.contains(.invalidChainId)) + XCTAssertTrue(metadataErrors.contains(.invalidAssetId)) + XCTAssertTrue(metadataErrors.contains(.invalidDecimals)) + XCTAssertTrue(metadataErrors.contains(.invalidSymbol)) + XCTAssertTrue(metadataErrors.contains(.invalidName)) + XCTAssertTrue(metadataErrors.contains(.invalidUrl)) + XCTAssertTrue(metadataErrors.contains(.invalidSyncedAt)) + XCTAssertTrue(pageErrors.contains(.invalidCursor)) + XCTAssertTrue(pageErrors.contains(.invalidLimit)) + XCTAssertTrue(pageErrors.contains(.invalidTotal)) + XCTAssertTrue(pageErrors.contains(.invalidSyncedAt)) + } + + private func assetBalance() -> UniversalWalletIndexedAssetBalance { + UniversalWalletIndexedAssetBalance( + accountId: "solana-mainnet", + ecosystem: .solana, + chainId: "solana:mainnet", + assetId: "SOL", + amount: "123456789", + decimals: 9, + isNative: true, + symbol: "SOL", + name: "Solana", + uiAmountString: "0.123456789", + tokenProgram: "spl-token", + syncedAtMillis: 1_710_000_000_000 + ) + } + + private func indexedTransaction( + operationType: UniversalWalletIndexedOperationType = .transfer + ) -> UniversalWalletIndexedTransaction { + let txid = String(repeating: "a", count: 64) + return UniversalWalletIndexedTransaction( + accountId: "bitcoin-mainnet", + ecosystem: UniversalWalletEcosystem.bitcoin.rawValue, + chainId: "bitcoin:mainnet", + transactionId: txid, + status: .confirmed, + direction: .outgoing, + operationType: operationType, + timestampMillis: 1_710_000_000_000, + amount: "1000", + assetId: "BTC", + feeAmount: "100", + feeAssetId: "BTC", + counterpartyAddress: "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu", + blockNumber: "800000", + cursor: txid, + explorerUrl: "https://mempool.space/tx/\(txid)", + syncedAtMillis: 1_710_000_000_000 + ) + } +} diff --git a/fearlessTests/UniversalWalletMigrationContractTests.swift b/fearlessTests/UniversalWalletMigrationContractTests.swift new file mode 100644 index 0000000000..cb1d3e170f --- /dev/null +++ b/fearlessTests/UniversalWalletMigrationContractTests.swift @@ -0,0 +1,478 @@ +import XCTest +import SSFModels +@testable import fearless + +final class UniversalWalletMigrationContractTests: XCTestCase { + private let builder = IOSUniversalWalletMigrationSnapshotBuilder( + cutoffAtMillis: 1_710_000_000_000, + clockMillis: { 1_710_000_000_100 } + ) + + func testBlocksNormalAccessWhenLegacyVaultsExistWithoutUniversalWallet() throws { + let snapshot = migrationSnapshot(hasUniversalWallet: false, legacyVaults: [Self.legacyVault()]) + + XCTAssertTrue(snapshot.validationErrors().isEmpty) + XCTAssertEqual(snapshot.requiredAction(), UniversalWalletMigrationRequiredAction.migrateBeforeAccess) + XCTAssertFalse(snapshot.allowsNormalWalletAccess()) + XCTAssertTrue(snapshot.allowsLegacySecretExport()) + + let json = String(decoding: try JSONEncoder().encode(snapshot), as: UTF8.self) + XCTAssertTrue(json.contains(#""platform":"ios""#)) + XCTAssertTrue(json.contains(#""mode":"export-only""#)) + XCTAssertTrue(json.contains(#""canSignTransactions":false"#)) + } + + func testRequiresNewUniversalWalletWhenNoWalletMaterialExists() { + let snapshot = migrationSnapshot(hasUniversalWallet: false, legacyVaults: []) + + XCTAssertTrue(snapshot.validationErrors().isEmpty) + XCTAssertEqual(snapshot.requiredAction(), UniversalWalletMigrationRequiredAction.createUniversalWallet) + XCTAssertFalse(snapshot.allowsNormalWalletAccess()) + XCTAssertFalse(snapshot.allowsLegacySecretExport()) + } + + func testAllowsNormalAccessOnceUniversalWalletExistsWhileKeepingLegacyExportOnly() { + let snapshot = migrationSnapshot(hasUniversalWallet: true, legacyVaults: [Self.legacyVault()]) + + XCTAssertTrue(snapshot.validationErrors().isEmpty) + XCTAssertEqual(snapshot.requiredAction(), UniversalWalletMigrationRequiredAction.normalAccess) + XCTAssertTrue(snapshot.allowsNormalWalletAccess()) + XCTAssertTrue(snapshot.allowsLegacySecretExport()) + } + + func testRejectsMalformedMigrationSnapshotsAndLegacyVaults() { + let badVault = UniversalWalletLegacyVaultDescriptor( + vaultId: "bad", + accountId: "../bad", + ecosystem: "unknown", + address: " address ", + displayName: "bad\u{0000}name", + exportOnlyReason: " ", + canExportSecrets: false, + canSignTransactions: true, + discoveredAtMillis: 10, + lastExportedAtMillis: 9 + ) + let snapshot = migrationSnapshot( + schemaVersion: 99, + legacyVaults: [badVault, badVault], + cutoffAtMillis: 0, + evaluatedAtMillis: 0 + ) + + let errors = snapshot.validationErrors() + + XCTAssertTrue(errors.contains(.invalidSchemaVersion)) + XCTAssertTrue(errors.contains(.invalidTimestamp)) + XCTAssertTrue(errors.contains(.invalidVaultId)) + XCTAssertTrue(errors.contains(.duplicateVaultId)) + XCTAssertTrue(errors.contains(.invalidAccountId)) + XCTAssertTrue(errors.contains(.invalidEcosystem)) + XCTAssertTrue(errors.contains(.invalidAddress)) + XCTAssertTrue(errors.contains(.invalidDisplayName)) + XCTAssertTrue(errors.contains(.invalidExportReason)) + XCTAssertTrue(errors.contains(.exportDisabled)) + XCTAssertTrue(errors.contains(.legacySigningEnabled)) + } + + func testBuilderRequiresUniversalWalletCreationForEmptyInstall() { + let snapshot = builder.build(accounts: []) + + XCTAssertFalse(snapshot.hasUniversalWallet) + XCTAssertTrue(snapshot.legacyVaults.isEmpty) + XCTAssertEqual(snapshot.requiredAction(), .createUniversalWallet) + XCTAssertTrue(snapshot.validationErrors().isEmpty) + } + + func testBuilderCreatesExportOnlyDescriptorsForLegacyRoots() { + let snapshot = builder.build(accounts: [Self.wallet()]) + + XCTAssertFalse(snapshot.hasUniversalWallet) + XCTAssertEqual(snapshot.requiredAction(), .migrateBeforeAccess) + XCTAssertEqual( + snapshot.legacyVaults.map(\.ecosystem), + [UniversalWalletEcosystem.substrate.rawValue, UniversalWalletEcosystem.evm.rawValue] + ) + XCTAssertTrue(snapshot.legacyVaults.allSatisfy(\.canExportSecrets)) + XCTAssertTrue(snapshot.legacyVaults.allSatisfy { !$0.canSignTransactions }) + XCTAssertEqual(Set(snapshot.legacyVaults.map(\.vaultId)).count, snapshot.legacyVaults.count) + XCTAssertTrue(snapshot.validationErrors().isEmpty) + } + + func testBuilderAllowsNormalAccessForCompleteUniversalWallet() throws { + let bitcoin = try BitcoinKeyDerivation.deriveAccount(mnemonic: Self.mnemonic, network: .mainnet) + let solana = try SolanaKeyDerivation.deriveAccount(mnemonic: Self.mnemonic) + let iroha = try IrohaKeyDerivation.deriveAccount(mnemonic: Self.mnemonic) + let ton = try TonKeyDerivation.deriveAccount(mnemonic: Self.mnemonic) + + let snapshot = builder.build( + accounts: [ + Self.wallet( + chainAccounts: [ + Self.chainAccount( + chainId: UniversalWalletRegistry.bitcoinMainnet.chainId, + publicKey: bitcoin.publicKey, + cryptoType: CryptoType.ecdsa.rawValue + ), + Self.chainAccount( + chainId: UniversalWalletRegistry.solanaMainnet.chainId, + publicKey: solana.publicKey + ), + Self.chainAccount( + chainId: UniversalWalletRegistry.taira.chainId, + publicKey: iroha.publicKey + ), + Self.chainAccount( + chainId: "ton:mainnet", + publicKey: ton.publicKey + ) + ] + ) + ] + ) + + XCTAssertTrue(snapshot.hasUniversalWallet) + XCTAssertTrue(snapshot.legacyVaults.isEmpty) + XCTAssertEqual(snapshot.requiredAction(), .normalAccess) + XCTAssertTrue(snapshot.validationErrors().isEmpty) + } + + func testBuilderKeepsPartialUniversalWalletBlockedWithLegacyExports() throws { + let solana = try SolanaKeyDerivation.deriveAccount(mnemonic: Self.mnemonic) + let snapshot = builder.build( + accounts: [ + Self.wallet( + chainAccounts: [ + Self.chainAccount( + chainId: UniversalWalletRegistry.solanaMainnet.chainId, + publicKey: solana.publicKey + ) + ] + ) + ] + ) + + XCTAssertFalse(snapshot.hasUniversalWallet) + XCTAssertEqual(snapshot.requiredAction(), .migrateBeforeAccess) + XCTAssertEqual( + snapshot.legacyVaults.map(\.ecosystem), + [UniversalWalletEcosystem.substrate.rawValue, UniversalWalletEcosystem.evm.rawValue] + ) + XCTAssertTrue(snapshot.validationErrors().isEmpty) + } + + func testBuilderKeepsMalformedLegacyDataFailClosedWithValidDescriptor() { + let snapshot = builder.build( + accounts: [ + Self.wallet( + name: " Bad\u{0000}Name That Is Far Too Long For The Shared Human Text Contract And Must Be Trimmed ", + substrateAccountId: Data([1]), + ethereumAddress: nil, + ethereumPublicKey: Data(repeating: 4, count: 33) + ) + ] + ) + let descriptor = snapshot.legacyVaults[0] + + XCTAssertFalse(snapshot.hasUniversalWallet) + XCTAssertEqual(snapshot.requiredAction(), .migrateBeforeAccess) + XCTAssertEqual( + snapshot.legacyVaults.map(\.ecosystem), + [UniversalWalletEcosystem.substrate.rawValue, UniversalWalletEcosystem.evm.rawValue] + ) + XCTAssertEqual(descriptor.address, "unavailable:ios:wallet-12345678:substrate") + XCTAssertEqual(snapshot.legacyVaults[1].address, "unavailable:ios:wallet-12345678:evm") + XCTAssertFalse(descriptor.displayName?.contains("\u{0000}") ?? true) + XCTAssertLessThanOrEqual(descriptor.displayName?.count ?? 0, 64) + XCTAssertTrue(snapshot.validationErrors().isEmpty) + } + + func testProviderMapsFetchedAccountsToSnapshot() throws { + let provider = IOSUniversalWalletMigrationSnapshotProvider(builder: builder) { completion in + completion(.success([Self.wallet()])) + } + var result: Result? + + provider.fetchSnapshot { snapshotResult in + result = snapshotResult + } + + let snapshot = try XCTUnwrap(result).get() + XCTAssertEqual(snapshot.requiredAction(), .migrateBeforeAccess) + XCTAssertEqual(snapshot.evaluatedAtMillis, 1_710_000_000_100) + XCTAssertTrue(snapshot.validationErrors().isEmpty) + } + + func testProviderUsesDefaultCutoffForRuntimeFetches() throws { + let provider = IOSUniversalWalletMigrationSnapshotProvider { completion in + completion(.success([])) + } + var result: Result? + + provider.fetchSnapshot { snapshotResult in + result = snapshotResult + } + + let snapshot = try XCTUnwrap(result).get() + XCTAssertEqual(snapshot.cutoffAtMillis, UniversalWalletMigrationCutoff.defaultAtMillis) + XCTAssertEqual(snapshot.requiredAction(), .createUniversalWallet) + XCTAssertTrue(snapshot.validationErrors().isEmpty) + } + + func testProviderPropagatesFetchFailure() throws { + let provider = IOSUniversalWalletMigrationSnapshotProvider(builder: builder) { completion in + completion(.failure(TestFetchError.failed)) + } + var result: Result? + + provider.fetchSnapshot { snapshotResult in + result = snapshotResult + } + + switch try XCTUnwrap(result) { + case .success: + XCTFail("Expected provider to propagate account fetch failure") + case let .failure(error): + XCTAssertEqual(error as? TestFetchError, .failed) + } + } + + func testLegacyExportResolverResolvesSubstrateAndEvmDescriptors() throws { + let wallet = Self.wallet() + let substrateDescriptor = try legacyDescriptor(for: .substrate, wallet: wallet) + let evmDescriptor = try legacyDescriptor(for: .evm, wallet: wallet) + + let substrateTarget = try XCTUnwrap( + IOSUniversalWalletLegacyExportResolver.resolve( + descriptor: substrateDescriptor, + accounts: [wallet] + ) + ) + let evmTarget = try XCTUnwrap( + IOSUniversalWalletLegacyExportResolver.resolve( + descriptor: evmDescriptor, + accounts: [wallet] + ) + ) + + XCTAssertEqual(substrateTarget.wallet.metaId, wallet.metaId) + XCTAssertEqual(substrateTarget.chainId, Chain.polkadot.genesisHash) + XCTAssertNil(substrateTarget.accountId) + XCTAssertFalse(substrateTarget.isEthereumBased) + XCTAssertEqual(substrateTarget.address, substrateDescriptor.address) + XCTAssertEqual(substrateTarget.ecosystem, .substrate) + + XCTAssertEqual(evmTarget.wallet.metaId, wallet.metaId) + XCTAssertEqual(evmTarget.chainId, Chain.moonriver.genesisHash) + XCTAssertNil(evmTarget.accountId) + XCTAssertTrue(evmTarget.isEthereumBased) + XCTAssertEqual(evmTarget.address, evmDescriptor.address) + XCTAssertEqual(evmTarget.ecosystem, .evm) + } + + func testLegacyExportResolverRejectsUnsafeDescriptors() throws { + let wallet = Self.wallet() + let descriptor = try legacyDescriptor(for: .substrate, wallet: wallet) + + XCTAssertNil( + IOSUniversalWalletLegacyExportResolver.resolve( + descriptor: Self.copy(descriptor, canExportSecrets: false), + accounts: [wallet] + ) + ) + XCTAssertNil( + IOSUniversalWalletLegacyExportResolver.resolve( + descriptor: Self.copy(descriptor, canSignTransactions: true), + accounts: [wallet] + ) + ) + } + + func testLegacyExportResolverRejectsTamperedDescriptorIdentity() throws { + let wallet = Self.wallet() + let descriptor = try legacyDescriptor(for: .substrate, wallet: wallet) + + XCTAssertNil( + IOSUniversalWalletLegacyExportResolver.resolve( + descriptor: Self.copy(descriptor, vaultId: "legacy_ios_other_substrate"), + accounts: [wallet] + ) + ) + XCTAssertNil( + IOSUniversalWalletLegacyExportResolver.resolve( + descriptor: Self.copy(descriptor, accountId: "ios-wallet-12345678-other"), + accounts: [wallet] + ) + ) + XCTAssertNil( + IOSUniversalWalletLegacyExportResolver.resolve( + descriptor: Self.copy(descriptor, address: "unavailable:ios:other:substrate"), + accounts: [wallet] + ) + ) + } + + func testLegacyExportResolverRejectsUnsupportedEcosystems() { + let wallet = Self.wallet() + + [UniversalWalletEcosystem.ton, .bitcoin, .solana, .iroha].forEach { ecosystem in + XCTAssertNil( + IOSUniversalWalletLegacyExportResolver.resolve( + descriptor: Self.legacyVault(ecosystem: ecosystem), + accounts: [wallet] + ) + ) + } + } + + func testLegacyExportResolverRejectsDescriptorsWithoutRootMaterial() { + let substrateWallet = Self.wallet(substrateAccountId: Data(), substratePublicKey: Data()) + XCTAssertNil( + IOSUniversalWalletLegacyExportResolver.resolve( + descriptor: Self.legacyVault( + ecosystem: .substrate, + address: "unavailable:ios:wallet-12345678:substrate" + ), + accounts: [substrateWallet] + ) + ) + + let evmWallet = Self.wallet(ethereumAddress: nil, ethereumPublicKey: nil) + XCTAssertNil( + IOSUniversalWalletLegacyExportResolver.resolve( + descriptor: Self.legacyVault( + ecosystem: .evm, + address: "unavailable:ios:wallet-12345678:evm" + ), + accounts: [evmWallet] + ) + ) + } + + private func migrationSnapshot( + schemaVersion: Int = UniversalWalletMigrationSnapshot.schemaVersionValue, + hasUniversalWallet: Bool = false, + legacyVaults: [UniversalWalletLegacyVaultDescriptor] = [UniversalWalletMigrationContractTests.legacyVault()], + cutoffAtMillis: Int64 = 1_710_000_000_000, + evaluatedAtMillis: Int64 = 1_710_000_000_100 + ) -> UniversalWalletMigrationSnapshot { + UniversalWalletMigrationSnapshot( + schemaVersion: schemaVersion, + platform: .ios, + hasUniversalWallet: hasUniversalWallet, + legacyVaults: legacyVaults, + cutoffAtMillis: cutoffAtMillis, + evaluatedAtMillis: evaluatedAtMillis + ) + } + + private func legacyDescriptor( + for ecosystem: UniversalWalletEcosystem, + wallet: MetaAccountModel = UniversalWalletMigrationContractTests.wallet() + ) throws -> UniversalWalletLegacyVaultDescriptor { + let snapshot = builder.build(accounts: [wallet]) + + return try XCTUnwrap(snapshot.legacyVaults.first { $0.ecosystem == ecosystem.rawValue }) + } + + private static func legacyVault() -> UniversalWalletLegacyVaultDescriptor { + UniversalWalletLegacyVaultDescriptor( + vaultId: "legacy_12345678", + accountId: "substrate-legacy", + ecosystem: .substrate, + address: "15FKRtF6nX3SE4PaW4LX69XqYHq2oSep9JX5KF4cgN1XkZ4q", + displayName: "Legacy DOT", + exportOnlyReason: "pre-cutoff account export", + discoveredAtMillis: 1_700_000_000_000, + lastExportedAtMillis: 1_700_000_000_100 + ) + } + + private static func legacyVault( + ecosystem: UniversalWalletEcosystem, + address: String? = nil + ) -> UniversalWalletLegacyVaultDescriptor { + UniversalWalletLegacyVaultDescriptor( + vaultId: "legacy_ios_wallet-12345678_\(ecosystem.rawValue)", + accountId: "ios-wallet-12345678-\(ecosystem.rawValue)", + ecosystem: ecosystem, + address: address ?? "unavailable:ios:wallet-12345678:\(ecosystem.rawValue)", + displayName: "Wallet", + exportOnlyReason: "pre-cutoff account export", + discoveredAtMillis: 1_710_000_000_000 + ) + } + + private static func copy( + _ descriptor: UniversalWalletLegacyVaultDescriptor, + vaultId: String? = nil, + accountId: String? = nil, + ecosystem: String? = nil, + address: String? = nil, + canExportSecrets: Bool? = nil, + canSignTransactions: Bool? = nil + ) -> UniversalWalletLegacyVaultDescriptor { + UniversalWalletLegacyVaultDescriptor( + vaultId: vaultId ?? descriptor.vaultId, + accountId: accountId ?? descriptor.accountId, + ecosystem: ecosystem ?? descriptor.ecosystem, + address: address ?? descriptor.address, + displayName: descriptor.displayName, + mode: descriptor.mode, + exportOnlyReason: descriptor.exportOnlyReason, + canExportSecrets: canExportSecrets ?? descriptor.canExportSecrets, + canSignTransactions: canSignTransactions ?? descriptor.canSignTransactions, + discoveredAtMillis: descriptor.discoveredAtMillis, + lastExportedAtMillis: descriptor.lastExportedAtMillis + ) + } + + private static func chainAccount( + chainId: String, + publicKey: Data, + cryptoType: UInt8 = CryptoType.ed25519.rawValue + ) -> ChainAccountModel { + ChainAccountModel( + chainId: chainId, + accountId: publicKey, + publicKey: publicKey, + cryptoType: cryptoType, + ethereumBased: false + ) + } + + private static func wallet( + metaId: String = "wallet-12345678", + name: String = "Wallet", + substrateAccountId: Data = Data(repeating: 1, count: 32), + substratePublicKey: Data = Data(repeating: 2, count: 32), + ethereumAddress: Data? = Data(repeating: 3, count: 20), + ethereumPublicKey: Data? = Data(repeating: 4, count: 33), + chainAccounts: Set = [] + ) -> MetaAccountModel { + MetaAccountModel( + metaId: metaId, + name: name, + substrateAccountId: substrateAccountId, + substrateCryptoType: CryptoType.ed25519.rawValue, + substratePublicKey: substratePublicKey, + ethereumAddress: ethereumAddress, + ethereumPublicKey: ethereumPublicKey, + chainAccounts: chainAccounts, + assetKeysOrder: nil, + canExportEthereumMnemonic: true, + unusedChainIds: nil, + selectedCurrency: Currency.defaultCurrency(), + networkManagmentFilter: nil, + assetsVisibility: [], + hasBackup: true, + favouriteChainIds: [] + ) + } + + private static let mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + + private enum TestFetchError: Error, Equatable { + case failed + } +} diff --git a/fearlessTests/UniversalWalletRegistryContractTests.swift b/fearlessTests/UniversalWalletRegistryContractTests.swift new file mode 100644 index 0000000000..043428539e --- /dev/null +++ b/fearlessTests/UniversalWalletRegistryContractTests.swift @@ -0,0 +1,219 @@ +import XCTest +@testable import fearless + +final class UniversalWalletRegistryContractTests: XCTestCase { + func testValidatesAndSerializesPublicChainRegistryEntries() throws { + let registry = UniversalWalletChainRegistry(chains: [solanaMainnet(), taira()]) + + XCTAssertTrue(registry.validationErrors().isEmpty) + + let json = String(decoding: try JSONEncoder().encode(registry), as: UTF8.self) + XCTAssertTrue(json.contains(#""ecosystem":"solana""#)) + XCTAssertTrue(json.contains(#""kind":"indexer""#)) + XCTAssertTrue(json.contains(#""kind":"torii-mcp""#)) + XCTAssertTrue(json.contains(#""readOnly":true"#)) + XCTAssertTrue(json.contains(#""features":["transfer"]"#)) + } + + func testAllowsDisabledGatedNetworksWithoutEndpoints() { + let nexus = UniversalWalletChainRegistryEntry( + id: "sora-nexus-mainnet", + ecosystem: .iroha, + chainId: "sora:nexus:global", + displayName: "SORA Nexus", + enabledByDefault: false, + features: ["transfer", "offline-cash", "sccp", "governance"], + endpoints: [] + ) + + XCTAssertTrue(nexus.validationErrors().isEmpty) + } + + func testActualDefaultRegistryEntriesValidateAndExposeExpectedPublicEndpoints() throws { + let registry = UniversalWalletRegistry.chainRegistry + + XCTAssertTrue(registry.validationErrors().isEmpty) + let ids = Set(registry.chains.map(\.id)) + XCTAssertTrue( + [ + "bitcoin-mainnet", + "bitcoin-testnet", + "ton-mainnet", + "solana-mainnet", + "solana-devnet", + "taira-testnet", + "sora-nexus-mainnet" + ].allSatisfy(ids.contains) + ) + + let solana = try XCTUnwrap(registry.chains.first { $0.id == "solana-mainnet" }) + XCTAssertTrue( + solana.endpoints.contains { + $0.kind == .indexer && + $0.url == "https://si.soramitsu.io" && + $0.readOnly + } + ) + XCTAssertTrue( + solana.endpoints.contains { + $0.kind == .rpc && + $0.url == "https://api.mainnet-beta.solana.com" && + !$0.readOnly + } + ) + + let ton = try XCTUnwrap(registry.chains.first { $0.id == "ton-mainnet" }) + XCTAssertTrue( + ton.endpoints.contains { + $0.kind == .indexer && + $0.url == "https://ti.soramitsu.io" && + $0.readOnly + } + ) + + let taira = try XCTUnwrap(registry.chains.first { $0.id == "taira-testnet" }) + XCTAssertEqual(taira.features, ["transfer"]) + XCTAssertTrue( + taira.endpoints.contains { + $0.kind == .toriiMcp && + $0.url == "https://taira.sora.org/v1/mcp" && + !$0.readOnly + } + ) + + let nexus = try XCTUnwrap(registry.chains.first { $0.id == "sora-nexus-mainnet" }) + XCTAssertFalse(nexus.enabledByDefault) + XCTAssertEqual(nexus.features, ["transfer", "offline-cash", "sccp", "governance"]) + XCTAssertTrue( + nexus.endpoints.contains { + $0.kind == .toriiMcp && + $0.url == "https://minamoto.sora.org/v1/mcp" && + !$0.readOnly + } + ) + } + + func testRejectsMalformedRegistryEntriesAndPublicWriteIndexers() { + let chain = UniversalWalletChainRegistryEntry( + id: "../bad", + ecosystem: "unknown", + chainId: "bad chain", + displayName: "bad\u{0000}name", + enabledByDefault: true, + nativeAsset: UniversalWalletRegistryAsset( + id: "bad id", + symbol: "sol", + decimals: 256, + name: "bad\u{0000}name" + ), + derivationPath: "m/44'/x", + slip44CoinType: -1, + features: ["governance", "governance", "bad feature"], + endpoints: [ + UniversalWalletRegistryEndpoint( + id: "bad endpoint", + kind: .indexer, + url: "http://si.soramitsu.io", + readOnly: false, + priority: -1 + ) + ] + ) + + let errors = chain.validationErrors() + + XCTAssertTrue(errors.contains(.invalidId)) + XCTAssertTrue(errors.contains(.invalidEcosystem)) + XCTAssertTrue(errors.contains(.invalidChainId)) + XCTAssertTrue(errors.contains(.invalidDisplayName)) + XCTAssertTrue(errors.contains(.invalidAssetId)) + XCTAssertTrue(errors.contains(.invalidAssetSymbol)) + XCTAssertTrue(errors.contains(.invalidAssetDecimals)) + XCTAssertTrue(errors.contains(.invalidAssetName)) + XCTAssertTrue(errors.contains(.invalidDerivationPath)) + XCTAssertTrue(errors.contains(.invalidSlip44CoinType)) + XCTAssertTrue(errors.contains(.duplicateFeatureId)) + XCTAssertTrue(errors.contains(.invalidFeatureId)) + XCTAssertTrue(errors.contains(.invalidEndpointId)) + XCTAssertTrue(errors.contains(.invalidEndpointUrl)) + XCTAssertTrue(errors.contains(.invalidEndpointPriority)) + XCTAssertTrue(errors.contains(.publicWriteIndexer)) + } + + func testRejectsDuplicateChainAndEndpointIdentifiers() { + let duplicateEndpoint = UniversalWalletChainRegistryEntry( + id: "solana-mainnet", + ecosystem: .solana, + chainId: "solana:mainnet", + displayName: "Solana", + enabledByDefault: true, + endpoints: [indexer(), indexer()] + ) + let registry = UniversalWalletChainRegistry( + schemaVersion: 99, + chains: [solanaMainnet(), solanaMainnet(), duplicateEndpoint] + ) + + let errors = registry.validationErrors() + + XCTAssertTrue(errors.contains(.invalidSchemaVersion)) + XCTAssertTrue(errors.contains(.duplicateChainId)) + XCTAssertTrue(errors.contains(.duplicateEndpointId)) + } + + private func solanaMainnet() -> UniversalWalletChainRegistryEntry { + UniversalWalletChainRegistryEntry( + id: "solana-mainnet", + ecosystem: .solana, + chainId: "solana:mainnet", + displayName: "Solana", + enabledByDefault: true, + nativeAsset: UniversalWalletRegistryAsset( + id: "SOL", + symbol: "SOL", + decimals: 9, + name: "Solana" + ), + derivationPath: "m/44'/501'/0'/0'", + slip44CoinType: 501, + endpoints: [ + indexer(), + UniversalWalletRegistryEndpoint( + id: "solana-mainnet-rpc", + kind: .rpc, + url: "https://api.mainnet-beta.solana.com", + readOnly: false, + priority: 1 + ) + ] + ) + } + + private func taira() -> UniversalWalletChainRegistryEntry { + UniversalWalletChainRegistryEntry( + id: "taira-testnet", + ecosystem: .iroha, + chainId: "iroha3-taira", + displayName: "Taira Testnet", + enabledByDefault: true, + features: ["transfer"], + endpoints: [ + UniversalWalletRegistryEndpoint( + id: "taira-torii-mcp", + kind: .toriiMcp, + url: "https://taira.sora.org/v1/mcp", + readOnly: false + ) + ] + ) + } + + private func indexer() -> UniversalWalletRegistryEndpoint { + UniversalWalletRegistryEndpoint( + id: "solana-mainnet-indexer", + kind: .indexer, + url: "https://si.soramitsu.io", + readOnly: true + ) + } +} diff --git a/fearlessTests/UniversalWalletSigningContractTests.swift b/fearlessTests/UniversalWalletSigningContractTests.swift new file mode 100644 index 0000000000..50522cd464 --- /dev/null +++ b/fearlessTests/UniversalWalletSigningContractTests.swift @@ -0,0 +1,247 @@ +import XCTest +@testable import fearless + +final class UniversalWalletSigningContractTests: XCTestCase { + func testValidatesAndSerializesMessageSigningRequestsAndResults() throws { + let request = messageRequest() + let result = approvedMessageResult() + + XCTAssertTrue(request.validationErrors().isEmpty) + XCTAssertTrue(result.validationErrors().isEmpty) + + let requestJson = String(decoding: try JSONEncoder().encode(request), as: UTF8.self) + let resultJson = String(decoding: try JSONEncoder().encode(result), as: UTF8.self) + XCTAssertTrue(requestJson.contains(#""method":"sign-message""#)) + XCTAssertTrue(requestJson.contains(#""encoding":"base64""#)) + XCTAssertTrue(resultJson.contains(#""status":"approved""#)) + XCTAssertTrue(resultJson.contains(#""signatureHex":"\#(String(repeating: "ab", count: 64))""#)) + } + + func testRejectsMalformedMessageSigningRequests() { + let request = UniversalWalletSigningRequest( + requestId: "bad", + accountId: "../bad", + ecosystem: "unknown", + chainId: "bad chain", + origin: " https://dapp.example ", + method: .signMessage, + message: UniversalWalletSigningPayload(encoding: .base64, value: "*not-base64*", display: .utf8), + transactionBase64: "AQID", + transactionsBase64: [], + createdAtMillis: 10, + expiresAtMillis: 9 + ) + + let errors = request.validationErrors() + + XCTAssertTrue(errors.contains(.invalidRequestId)) + XCTAssertTrue(errors.contains(.invalidAccountId)) + XCTAssertTrue(errors.contains(.invalidEcosystem)) + XCTAssertTrue(errors.contains(.invalidChainId)) + XCTAssertTrue(errors.contains(.invalidOrigin)) + XCTAssertTrue(errors.contains(.invalidBase64)) + XCTAssertTrue(errors.contains(.payloadNotAllowed)) + XCTAssertTrue(errors.contains(.invalidTimestamp)) + } + + func testValidatesTransactionAndBatchSigningRequests() { + let transaction = UniversalWalletSigningRequest( + requestId: "sign_1234567890abcdef", + accountId: "solana-mainnet", + ecosystem: "solana", + chainId: "solana:mainnet", + origin: "https://dapp.example", + method: .signTransaction, + message: nil, + transactionBase64: "AQIDBA==", + transactionsBase64: [], + createdAtMillis: 1_710_000_000_000, + expiresAtMillis: 1_710_000_060_000 + ) + let signAndSend = UniversalWalletSigningRequest( + requestId: transaction.requestId, + accountId: transaction.accountId, + ecosystem: transaction.ecosystem, + chainId: transaction.chainId, + origin: transaction.origin, + method: .signAndSendTransaction, + message: nil, + transactionBase64: transaction.transactionBase64, + transactionsBase64: [], + createdAtMillis: transaction.createdAtMillis, + expiresAtMillis: transaction.expiresAtMillis + ) + let batch = UniversalWalletSigningRequest( + requestId: transaction.requestId, + accountId: transaction.accountId, + ecosystem: transaction.ecosystem, + chainId: transaction.chainId, + origin: transaction.origin, + method: .signAllTransactions, + message: nil, + transactionBase64: nil, + transactionsBase64: ["AQIDBA==", "BQYHCA=="], + createdAtMillis: transaction.createdAtMillis, + expiresAtMillis: transaction.expiresAtMillis + ) + + XCTAssertTrue(transaction.validationErrors().isEmpty) + XCTAssertTrue(signAndSend.validationErrors().isEmpty) + XCTAssertTrue(batch.validationErrors().isEmpty) + } + + func testRejectsMalformedTransactionAndBatchSigningRequests() { + let transaction = UniversalWalletSigningRequest( + requestId: "sign_1234567890abcdef", + accountId: "solana-mainnet", + ecosystem: "solana", + chainId: "solana:mainnet", + origin: "https://dapp.example", + method: .signTransaction, + message: nil, + transactionBase64: "*bad*", + transactionsBase64: [], + createdAtMillis: 1_710_000_000_000, + expiresAtMillis: nil + ) + let batch = UniversalWalletSigningRequest( + requestId: transaction.requestId, + accountId: transaction.accountId, + ecosystem: transaction.ecosystem, + chainId: transaction.chainId, + origin: transaction.origin, + method: .signAllTransactions, + message: nil, + transactionBase64: nil, + transactionsBase64: Array(repeating: "AQIDBA==", count: 17), + createdAtMillis: transaction.createdAtMillis, + expiresAtMillis: nil + ) + + XCTAssertTrue(transaction.validationErrors().contains(.transactionRequired)) + XCTAssertTrue(batch.validationErrors().contains(.invalidBatch)) + } + + func testValidatesApprovedTransactionAndRejectedSigningResults() { + let txHash = "5NfHnqDyzT9qyfxZDq2sSskAMGuFZ3VRqW4EQxghKqrKYdKq6cZNW1J34w7qE6nGx1eDQe5s2eKxB2ZtE1xU9qgN" + let transaction = UniversalWalletSigningResult( + requestId: "sign_1234567890abcdef", + accountId: "solana-mainnet", + ecosystem: "solana", + chainId: "solana:mainnet", + method: .signAndSendTransaction, + status: .approved, + publicKey: "HAgk14JpMQLgt6rVgv7cBQFJWFto5Dqxi472uT3DKpqk", + signatureHex: nil, + signatureBase64: nil, + signatureBase58: nil, + signedTransactionBase64: "AQIDBA==", + signedTransactionsBase64: [], + transactionHash: txHash, + errorCode: nil, + signedAtMillis: 1_710_000_000_100 + ) + let rejected = UniversalWalletSigningResult( + requestId: transaction.requestId, + accountId: transaction.accountId, + ecosystem: transaction.ecosystem, + chainId: transaction.chainId, + method: .signMessage, + status: .rejected, + publicKey: nil, + signatureHex: nil, + signatureBase64: nil, + signatureBase58: nil, + signedTransactionBase64: nil, + signedTransactionsBase64: [], + transactionHash: nil, + errorCode: "user_rejected", + signedAtMillis: transaction.signedAtMillis + ) + + XCTAssertTrue(transaction.validationErrors().isEmpty) + XCTAssertTrue(rejected.validationErrors().isEmpty) + } + + func testRejectsInconsistentSigningResults() { + let approved = UniversalWalletSigningResult( + requestId: "sign_1234567890abcdef", + accountId: "solana-mainnet", + ecosystem: "solana", + chainId: "solana:mainnet", + method: .signMessage, + status: .approved, + publicKey: nil, + signatureHex: "ABC", + signatureBase64: nil, + signatureBase58: nil, + signedTransactionBase64: nil, + signedTransactionsBase64: [], + transactionHash: nil, + errorCode: "not_allowed", + signedAtMillis: 1_710_000_000_100 + ) + let failed = UniversalWalletSigningResult( + requestId: approved.requestId, + accountId: approved.accountId, + ecosystem: approved.ecosystem, + chainId: approved.chainId, + method: approved.method, + status: .failed, + publicKey: "HAgk14JpMQLgt6rVgv7cBQFJWFto5Dqxi472uT3DKpqk", + signatureHex: String(repeating: "ab", count: 64), + signatureBase64: nil, + signatureBase58: nil, + signedTransactionBase64: nil, + signedTransactionsBase64: [], + transactionHash: nil, + errorCode: nil, + signedAtMillis: approved.signedAtMillis + ) + + let approvedErrors = approved.validationErrors() + let failedErrors = failed.validationErrors() + + XCTAssertTrue(approvedErrors.contains(.publicKeyRequired)) + XCTAssertTrue(approvedErrors.contains(.invalidSignature)) + XCTAssertTrue(approvedErrors.contains(.errorNotAllowed)) + XCTAssertTrue(failedErrors.contains(.errorCodeRequired)) + XCTAssertTrue(failedErrors.contains(.signatureNotAllowed)) + } + + private func messageRequest() -> UniversalWalletSigningRequest { + UniversalWalletSigningRequest( + requestId: "sign_1234567890abcdef", + accountId: "solana-mainnet", + ecosystem: "solana", + chainId: "solana:mainnet", + origin: "https://dapp.example", + method: .signMessage, + message: UniversalWalletSigningPayload(encoding: .base64, value: "ZmVhcmxlc3M=", display: .utf8), + transactionBase64: nil, + transactionsBase64: [], + createdAtMillis: 1_710_000_000_000, + expiresAtMillis: 1_710_000_060_000 + ) + } + + private func approvedMessageResult() -> UniversalWalletSigningResult { + UniversalWalletSigningResult( + requestId: "sign_1234567890abcdef", + accountId: "solana-mainnet", + ecosystem: "solana", + chainId: "solana:mainnet", + method: .signMessage, + status: .approved, + publicKey: "HAgk14JpMQLgt6rVgv7cBQFJWFto5Dqxi472uT3DKpqk", + signatureHex: String(repeating: "ab", count: 64), + signatureBase64: nil, + signatureBase58: nil, + signedTransactionBase64: nil, + signedTransactionsBase64: [], + transactionHash: nil, + errorCode: nil, + signedAtMillis: 1_710_000_000_100 + ) + } +} diff --git a/fearlessTests/UniversalWalletV2VectorsTests.swift b/fearlessTests/UniversalWalletV2VectorsTests.swift new file mode 100644 index 0000000000..c6a74bc817 --- /dev/null +++ b/fearlessTests/UniversalWalletV2VectorsTests.swift @@ -0,0 +1,615 @@ +import XCTest +@testable import fearless + +final class UniversalWalletV2VectorsTests: XCTestCase { + func testGoldenVectorsDefineEveryUniversalWalletEcosystem() throws { + let fixture = try loadFixture() + + XCTAssertEqual(fixture.version, 1) + XCTAssertEqual(fixture.vectors.map(\.id), ["import12", "default24"]) + XCTAssertEqual( + Set(fixture.negativeCases.map(\.id)), + ["bitcoin-wrong-purpose", "solana-wrong-path", "iroha-wrong-discriminant", "ton-testnet-flag"] + ) + XCTAssertEqual( + Set(UniversalWalletEcosystem.allCases.map(\.rawValue)), + ["substrate", "evm", "ton", "bitcoin", "solana", "iroha"] + ) + + for vector in fixture.vectors { + XCTAssertEqual(vector.mnemonic.split(separator: " ").count, vector.wordCount) + XCTAssertTrue([12, 24].contains(vector.wordCount)) + XCTAssertEqual(Set(vector.expected.keys), ["substrate", "evm", "bitcoin", "solana", "ton", "iroha"]) + + let substrate = try XCTUnwrap(vector.expected["substrate"] as? [String: Any]) + XCTAssertEqual(substrate["cryptoType"] as? String, "sr25519") + XCTAssertEqual(substrate["ss58Prefix"] as? Int, 0) + assertHex(try XCTUnwrap(substrate["publicKeyHex"] as? String), byteLength: 32) + XCTAssertTrue(try XCTUnwrap(substrate["polkadotAddress"] as? String).hasPrefix("1")) + + let evm = try XCTUnwrap(vector.expected["evm"] as? [String: Any]) + XCTAssertEqual(evm["derivationPath"] as? String, "m/44'/60'/0'/0/0") + assertMatches(try XCTUnwrap(evm["address"] as? String), pattern: "^0x[0-9a-fA-F]{40}$") + + try assertBitcoin(try XCTUnwrap(vector.expected["bitcoin"] as? [String: Any])) + try assertSolana(try XCTUnwrap(vector.expected["solana"] as? [String: Any])) + try assertTon(try XCTUnwrap(vector.expected["ton"] as? [String: Any])) + try assertIroha(try XCTUnwrap(vector.expected["iroha"] as? [String: Any])) + } + } + + func testNetworkSpecificFixtureValuesCannotBeSilentlyReused() throws { + for vector in try loadFixture().vectors { + let bitcoin = try XCTUnwrap(vector.expected["bitcoin"] as? [String: Any]) + let mainnet = try XCTUnwrap(bitcoin["mainnet"] as? [String: Any]) + let testnet = try XCTUnwrap(bitcoin["testnet"] as? [String: Any]) + let mainnetAddress = try XCTUnwrap(mainnet["firstReceiveAddress"] as? String) + let testnetAddress = try XCTUnwrap(testnet["firstReceiveAddress"] as? String) + XCTAssertTrue(mainnetAddress.hasPrefix("bc1q")) + XCTAssertTrue(testnetAddress.hasPrefix("tb1q")) + XCTAssertNotEqual(mainnetAddress, testnetAddress) + + let ton = try XCTUnwrap(vector.expected["ton"] as? [String: Any]) + let tonMainnet = try XCTUnwrap(ton["addressNonBounceable"] as? String) + let tonTestnet = try XCTUnwrap(ton["testnetNonBounceable"] as? String) + XCTAssertTrue(tonMainnet.hasPrefix("UQ")) + XCTAssertTrue(tonTestnet.hasPrefix("0Q")) + XCTAssertNotEqual(tonMainnet, tonTestnet) + + let iroha = try XCTUnwrap(vector.expected["iroha"] as? [String: Any]) + let taira = try XCTUnwrap(iroha["taira"] as? [String: Any]) + let nexus = try XCTUnwrap(iroha["nexus"] as? [String: Any]) + XCTAssertEqual(taira["canonicalHex"] as? String, nexus["canonicalHex"] as? String) + XCTAssertEqual(taira["chainDiscriminant"] as? Int, 369) + XCTAssertEqual(nexus["chainDiscriminant"] as? Int, 753) + XCTAssertTrue(try XCTUnwrap(taira["i105"] as? String).hasPrefix("test")) + XCTAssertTrue(try XCTUnwrap(nexus["i105"] as? String).hasPrefix("sora")) + XCTAssertNotEqual(taira["i105"] as? String, nexus["i105"] as? String) + } + } + + func testRegistryDefinesPublicIndexersAndGatedIrohaNetworks() { + XCTAssertEqual(UniversalWalletRegistry.tonIndexerBaseURL.absoluteString, "https://ti.soramitsu.io") + XCTAssertEqual(UniversalWalletRegistry.solanaIndexerBaseURL.absoluteString, "https://si.soramitsu.io") + XCTAssertEqual(UniversalWalletRegistry.bitcoinMainnetIndexerBaseURL.absoluteString, "https://blockstream.info/api") + XCTAssertEqual(UniversalWalletRegistry.bitcoinTestnetIndexerBaseURL.absoluteString, "https://blockstream.info/testnet/api") + + XCTAssertEqual(UniversalWalletRegistry.bitcoinMainnet.id, "bitcoin-mainnet") + XCTAssertEqual(UniversalWalletRegistry.bitcoinMainnet.chainId, "bitcoin:mainnet") + XCTAssertEqual(UniversalWalletRegistry.bitcoinMainnet.name, "Bitcoin") + XCTAssertEqual(UniversalWalletRegistry.bitcoinMainnet.slip44CoinType, 0) + XCTAssertEqual(UniversalWalletRegistry.bitcoinMainnet.addressHrp, "bc") + XCTAssertEqual(UniversalWalletRegistry.bitcoinMainnet.accountPath, "m/84'/0'/0'") + XCTAssertEqual(UniversalWalletRegistry.bitcoinMainnet.firstReceivePath, "m/84'/0'/0'/0/0") + XCTAssertEqual(UniversalWalletRegistry.bitcoinMainnet.indexerBaseURL.absoluteString, "https://blockstream.info/api") + XCTAssertEqual(UniversalWalletRegistry.bitcoinMainnet.defaultGapLimit, 20) + XCTAssertEqual(UniversalWalletRegistry.bitcoinMainnet.nativeAsset.id, "BTC") + XCTAssertEqual(UniversalWalletRegistry.bitcoinMainnet.nativeAsset.symbol, "BTC") + XCTAssertEqual(UniversalWalletRegistry.bitcoinMainnet.nativeAsset.decimals, 8) + XCTAssertTrue(UniversalWalletRegistry.bitcoinMainnet.enabledByDefault) + + XCTAssertEqual(UniversalWalletRegistry.bitcoinTestnet.id, "bitcoin-testnet") + XCTAssertEqual(UniversalWalletRegistry.bitcoinTestnet.chainId, "bitcoin:testnet") + XCTAssertEqual(UniversalWalletRegistry.bitcoinTestnet.name, "Bitcoin Testnet") + XCTAssertEqual(UniversalWalletRegistry.bitcoinTestnet.slip44CoinType, 1) + XCTAssertEqual(UniversalWalletRegistry.bitcoinTestnet.addressHrp, "tb") + XCTAssertEqual(UniversalWalletRegistry.bitcoinTestnet.accountPath, "m/84'/1'/0'") + XCTAssertEqual(UniversalWalletRegistry.bitcoinTestnet.firstReceivePath, "m/84'/1'/0'/0/0") + XCTAssertEqual(UniversalWalletRegistry.bitcoinTestnet.indexerBaseURL.absoluteString, "https://blockstream.info/testnet/api") + XCTAssertEqual(UniversalWalletRegistry.bitcoinTestnet.defaultGapLimit, 20) + XCTAssertEqual(UniversalWalletRegistry.bitcoinTestnet.nativeAsset.id, "BTC") + XCTAssertEqual(UniversalWalletRegistry.bitcoinTestnet.nativeAsset.symbol, "BTC") + XCTAssertEqual(UniversalWalletRegistry.bitcoinTestnet.nativeAsset.decimals, 8) + XCTAssertFalse(UniversalWalletRegistry.bitcoinTestnet.enabledByDefault) + + XCTAssertEqual(UniversalWalletRegistry.solanaMainnet.id, "solana-mainnet") + XCTAssertEqual(UniversalWalletRegistry.solanaMainnet.chainId, "solana:mainnet") + XCTAssertEqual(UniversalWalletRegistry.solanaMainnet.name, "Solana") + XCTAssertEqual(UniversalWalletRegistry.solanaMainnet.indexerBaseURL.absoluteString, "https://si.soramitsu.io") + XCTAssertEqual(UniversalWalletRegistry.solanaMainnet.rpcURL.absoluteString, "https://api.mainnet-beta.solana.com") + XCTAssertEqual(UniversalWalletRegistry.solanaMainnet.nativeAsset.id, "SOL") + XCTAssertEqual(UniversalWalletRegistry.solanaMainnet.nativeAsset.symbol, "SOL") + XCTAssertEqual(UniversalWalletRegistry.solanaMainnet.nativeAsset.decimals, 9) + XCTAssertTrue(UniversalWalletRegistry.solanaMainnet.enabledByDefault) + + XCTAssertEqual(UniversalWalletRegistry.solanaDevnet.id, "solana-devnet") + XCTAssertEqual(UniversalWalletRegistry.solanaDevnet.chainId, "solana:devnet") + XCTAssertEqual(UniversalWalletRegistry.solanaDevnet.name, "Solana Devnet") + XCTAssertEqual(UniversalWalletRegistry.solanaDevnet.indexerBaseURL.absoluteString, "https://si.soramitsu.io") + XCTAssertEqual(UniversalWalletRegistry.solanaDevnet.rpcURL.absoluteString, "https://api.devnet.solana.com") + XCTAssertEqual(UniversalWalletRegistry.solanaDevnet.nativeAsset.id, "SOL") + XCTAssertEqual(UniversalWalletRegistry.solanaDevnet.nativeAsset.symbol, "SOL") + XCTAssertEqual(UniversalWalletRegistry.solanaDevnet.nativeAsset.decimals, 9) + XCTAssertFalse(UniversalWalletRegistry.solanaDevnet.enabledByDefault) + + XCTAssertEqual(UniversalWalletRegistry.taira.id, "taira-testnet") + XCTAssertEqual(UniversalWalletRegistry.taira.chainId, "iroha3-taira") + XCTAssertEqual(UniversalWalletRegistry.taira.chainDiscriminant, 369) + XCTAssertEqual(UniversalWalletRegistry.taira.toriiBaseURL?.absoluteString, "https://taira.sora.org") + XCTAssertEqual(UniversalWalletRegistry.taira.mcpPath, "/v1/mcp") + XCTAssertEqual(UniversalWalletRegistry.taira.mcpEndpointURL?.absoluteString, "https://taira.sora.org/v1/mcp") + XCTAssertTrue(UniversalWalletRegistry.taira.enabledByDefault) + + XCTAssertEqual(UniversalWalletRegistry.nexus.id, "sora-nexus-mainnet") + XCTAssertEqual(UniversalWalletRegistry.nexus.chainId, "sora:nexus:global") + XCTAssertEqual(UniversalWalletRegistry.nexus.chainDiscriminant, 753) + XCTAssertEqual(UniversalWalletRegistry.nexus.toriiBaseURL?.absoluteString, "https://minamoto.sora.org") + XCTAssertEqual(UniversalWalletRegistry.nexus.mcpPath, "/v1/mcp") + XCTAssertEqual(UniversalWalletRegistry.nexus.mcpEndpointURL?.absoluteString, "https://minamoto.sora.org/v1/mcp") + XCTAssertFalse(UniversalWalletRegistry.nexus.enabledByDefault) + } + + func testDerivationConstantsMatchFixtureDefaults() throws { + let expected = try XCTUnwrap(try loadFixture().vectors.first?.expected) + let substrate = try XCTUnwrap(expected["substrate"] as? [String: Any]) + let evm = try XCTUnwrap(expected["evm"] as? [String: Any]) + let bitcoin = try XCTUnwrap(expected["bitcoin"] as? [String: Any]) + let bitcoinMainnet = try XCTUnwrap(bitcoin["mainnet"] as? [String: Any]) + let bitcoinTestnet = try XCTUnwrap(bitcoin["testnet"] as? [String: Any]) + let solana = try XCTUnwrap(expected["solana"] as? [String: Any]) + let ton = try XCTUnwrap(expected["ton"] as? [String: Any]) + let iroha = try XCTUnwrap(expected["iroha"] as? [String: Any]) + + XCTAssertEqual(substrate["derivationPath"] as? String, UniversalWalletDerivationPaths.substrateRoot) + XCTAssertEqual(evm["derivationPath"] as? String, UniversalWalletDerivationPaths.evmDefault) + XCTAssertEqual(bitcoinMainnet["accountPath"] as? String, UniversalWalletDerivationPaths.bitcoinMainnetAccount) + XCTAssertEqual(bitcoinMainnet["firstReceivePath"] as? String, UniversalWalletDerivationPaths.bitcoinMainnetFirstReceive) + XCTAssertEqual(bitcoinTestnet["accountPath"] as? String, UniversalWalletDerivationPaths.bitcoinTestnetAccount) + XCTAssertEqual(bitcoinTestnet["firstReceivePath"] as? String, UniversalWalletDerivationPaths.bitcoinTestnetFirstReceive) + XCTAssertEqual(solana["derivationPath"] as? String, UniversalWalletDerivationPaths.solanaDefault) + XCTAssertEqual(ton["derivationPath"] as? String, UniversalWalletDerivationPaths.tonDefault) + XCTAssertEqual(iroha["derivationPath"] as? String, UniversalWalletDerivationPaths.irohaDefault) + } + + func testBitcoinKeyDerivationMatchesGoldenVectors() throws { + for vector in try loadFixture().vectors { + let expected = try XCTUnwrap(vector.expected["bitcoin"] as? [String: Any]) + let expectedMainnet = try XCTUnwrap(expected["mainnet"] as? [String: Any]) + let expectedTestnet = try XCTUnwrap(expected["testnet"] as? [String: Any]) + let mainnet = try BitcoinKeyDerivation.deriveAccount(mnemonic: vector.mnemonic, network: .mainnet) + let testnet = try BitcoinKeyDerivation.deriveAccount(mnemonic: vector.mnemonic, network: .testnet) + + XCTAssertEqual(mainnet.network, .mainnet) + XCTAssertEqual(mainnet.accountPath, UniversalWalletDerivationPaths.bitcoinMainnetAccount) + XCTAssertEqual(mainnet.firstReceivePath, UniversalWalletDerivationPaths.bitcoinMainnetFirstReceive) + XCTAssertEqual(mainnet.accountXpub, expectedMainnet["accountXpub"] as? String) + XCTAssertEqual(mainnet.firstReceiveAddress, expectedMainnet["firstReceiveAddress"] as? String) + XCTAssertEqual(mainnet.privateKey.count, 32) + XCTAssertEqual(mainnet.chainCode.count, 32) + XCTAssertEqual(mainnet.publicKey.count, 33) + + XCTAssertEqual(testnet.network, .testnet) + XCTAssertEqual(testnet.accountPath, UniversalWalletDerivationPaths.bitcoinTestnetAccount) + XCTAssertEqual(testnet.firstReceivePath, UniversalWalletDerivationPaths.bitcoinTestnetFirstReceive) + XCTAssertEqual(testnet.accountXpub, expectedTestnet["accountXpub"] as? String) + XCTAssertEqual(testnet.firstReceiveAddress, expectedTestnet["firstReceiveAddress"] as? String) + XCTAssertEqual(testnet.privateKey.count, 32) + XCTAssertEqual(testnet.chainCode.count, 32) + XCTAssertEqual(testnet.publicKey.count, 33) + } + } + + func testBitcoinKeyDerivationNormalizesMnemonicWhitespace() throws { + let vector = try XCTUnwrap(try loadFixture().vectors.first) + let expected = try XCTUnwrap(vector.expected["bitcoin"] as? [String: Any]) + let expectedMainnet = try XCTUnwrap(expected["mainnet"] as? [String: Any]) + let paddedMnemonic = " \(vector.mnemonic.replacingOccurrences(of: " ", with: " \n\t")) " + + let account = try BitcoinKeyDerivation.deriveAccount(mnemonic: paddedMnemonic, network: .mainnet) + + XCTAssertEqual(account.accountXpub, expectedMainnet["accountXpub"] as? String) + XCTAssertEqual(account.firstReceiveAddress, expectedMainnet["firstReceiveAddress"] as? String) + } + + func testBitcoinKeyDerivationDoesNotReuseAddressesAcrossPathsOrPassphrases() throws { + let vector = try XCTUnwrap(try loadFixture().vectors.first) + let expected = try XCTUnwrap(vector.expected["bitcoin"] as? [String: Any]) + let expectedMainnet = try XCTUnwrap(expected["mainnet"] as? [String: Any]) + let wrongPurpose = try BitcoinKeyDerivation.deriveKey( + mnemonic: vector.mnemonic, + derivationPath: "m/44'/0'/0'/0/0", + network: .mainnet + ) + let withPassphrase = try BitcoinKeyDerivation.deriveAccount( + mnemonic: vector.mnemonic, + passphrase: "fearless", + network: .mainnet + ) + + XCTAssertNotEqual(wrongPurpose.address, expectedMainnet["firstReceiveAddress"] as? String) + XCTAssertNotEqual(withPassphrase.accountXpub, expectedMainnet["accountXpub"] as? String) + XCTAssertNotEqual(withPassphrase.firstReceiveAddress, expectedMainnet["firstReceiveAddress"] as? String) + } + + func testBitcoinKeyDerivationBuildsReceivePathsSafely() throws { + XCTAssertEqual(try BitcoinKeyDerivation.getReceivePath(network: .mainnet, index: 7), "m/84'/0'/0'/0/7") + XCTAssertEqual(try BitcoinKeyDerivation.getReceivePath(network: .testnet, index: 7), "m/84'/1'/0'/0/7") + XCTAssertEqual(try BitcoinKeyDerivation.getReceivePath(network: .mainnet, index: 2, change: 1), "m/84'/0'/0'/1/2") + + XCTAssertThrowsError(try BitcoinKeyDerivation.getReceivePath(network: .mainnet, index: UInt32.max)) + XCTAssertThrowsError(try BitcoinKeyDerivation.getReceivePath(network: .mainnet, index: 0, change: 2)) + } + + func testBitcoinKeyDerivationRejectsMalformedInputs() throws { + let seed = Data(repeating: 1, count: 64) + + XCTAssertThrowsError(try BitcoinKeyDerivation.deriveAccount(mnemonic: "")) + XCTAssertThrowsError(try BitcoinKeyDerivation.derivePrivateKey(seed: Data(), derivationPath: UniversalWalletDerivationPaths.bitcoinMainnetFirstReceive)) + XCTAssertThrowsError(try BitcoinKeyDerivation.derivePrivateKey(seed: seed, derivationPath: "")) + XCTAssertThrowsError(try BitcoinKeyDerivation.derivePrivateKey(seed: seed, derivationPath: "84'/0'/0'/0/0")) + XCTAssertThrowsError(try BitcoinKeyDerivation.derivePrivateKey(seed: seed, derivationPath: "m/84'//0'")) + XCTAssertThrowsError(try BitcoinKeyDerivation.derivePrivateKey(seed: seed, derivationPath: "m/84'/x'/0'")) + XCTAssertThrowsError(try BitcoinKeyDerivation.derivePrivateKey(seed: seed, derivationPath: "m/84'/2147483648'/0'")) + XCTAssertThrowsError(try BitcoinKeyDerivation.publicKey(fromPrivateKey: Data(repeating: 0, count: 31))) + XCTAssertThrowsError(try BitcoinKeyDerivation.publicKey(fromPrivateKey: Data(repeating: 0, count: 32))) + XCTAssertThrowsError(try BitcoinKeyDerivation.address(fromPublicKey: Data(repeating: 0, count: 32), network: .mainnet)) + } + + func testSolanaKeyDerivationMatchesGoldenVectors() throws { + for vector in try loadFixture().vectors { + let expected = try XCTUnwrap(vector.expected["solana"] as? [String: Any]) + let account = try SolanaKeyDerivation.deriveAccount(mnemonic: vector.mnemonic) + + XCTAssertEqual(account.derivationPath, UniversalWalletDerivationPaths.solanaDefault) + XCTAssertEqual(account.publicKey.hexString, expected["publicKeyHex"] as? String) + XCTAssertEqual(account.address, expected["address"] as? String) + XCTAssertEqual(account.privateKey.count, 32) + XCTAssertEqual(account.chainCode.count, 32) + } + } + + func testSolanaKeyDerivationNormalizesMnemonicWhitespace() throws { + let vector = try XCTUnwrap(try loadFixture().vectors.first) + let expected = try XCTUnwrap(vector.expected["solana"] as? [String: Any]) + let paddedMnemonic = " \(vector.mnemonic.replacingOccurrences(of: " ", with: " \n\t")) " + + let account = try SolanaKeyDerivation.deriveAccount(mnemonic: paddedMnemonic) + + XCTAssertEqual(account.publicKey.hexString, expected["publicKeyHex"] as? String) + XCTAssertEqual(account.address, expected["address"] as? String) + } + + func testSolanaKeyDerivationDoesNotReuseAddressesAcrossPathsOrPassphrases() throws { + let vector = try XCTUnwrap(try loadFixture().vectors.first) + let expected = try XCTUnwrap(vector.expected["solana"] as? [String: Any]) + let wrongPath = try SolanaKeyDerivation.deriveAccount( + mnemonic: vector.mnemonic, + derivationPath: "m/44'/501'/1'/0'" + ) + let withPassphrase = try SolanaKeyDerivation.deriveAccount( + mnemonic: vector.mnemonic, + passphrase: "fearless" + ) + + XCTAssertNotEqual(wrongPath.publicKey.hexString, expected["publicKeyHex"] as? String) + XCTAssertNotEqual(wrongPath.address, expected["address"] as? String) + XCTAssertNotEqual(withPassphrase.publicKey.hexString, expected["publicKeyHex"] as? String) + XCTAssertNotEqual(withPassphrase.address, expected["address"] as? String) + } + + func testSolanaKeyDerivationRejectsMalformedInputs() throws { + XCTAssertThrowsError(try SolanaKeyDerivation.deriveAccount(mnemonic: "")) + XCTAssertThrowsError(try SolanaKeyDerivation.deriveAccount(mnemonic: "abandon abandon abandon", derivationPath: "")) + XCTAssertThrowsError(try SolanaKeyDerivation.deriveAccount(mnemonic: "abandon abandon abandon", derivationPath: "44'/501'/0'/0'")) + XCTAssertThrowsError(try SolanaKeyDerivation.deriveAccount(mnemonic: "abandon abandon abandon", derivationPath: "m/44'/501/0'/0'")) + XCTAssertThrowsError(try SolanaKeyDerivation.deriveAccount(mnemonic: "abandon abandon abandon", derivationPath: "m/44'/501'/x'/0'")) + XCTAssertThrowsError(try SolanaKeyDerivation.deriveAccount(mnemonic: "abandon abandon abandon", derivationPath: "m/44'/501'/2147483648'/0'")) + XCTAssertThrowsError(try SolanaKeyDerivation.derivePrivateKey(seed: Data(), derivationPath: UniversalWalletDerivationPaths.solanaDefault)) + XCTAssertThrowsError(try SolanaKeyDerivation.publicKey(fromPrivateKey: Data(repeating: 0, count: 31))) + XCTAssertThrowsError(try SolanaKeyDerivation.address(fromPublicKey: Data(repeating: 0, count: 31))) + XCTAssertFalse(try SolanaKeyDerivation.address(fromPublicKey: Data(repeating: 0, count: 32)).isEmpty) + } + + func testSolanaSignerProducesVerifiableSignatures() throws { + let vector = try XCTUnwrap(try loadFixture().vectors.first) + let expected = try XCTUnwrap(vector.expected["solana"] as? [String: Any]) + let message = try XCTUnwrap(Self.solanaSigningMessage.data(using: .utf8)) + + let signature = try SolanaSigner.signMessage(mnemonic: vector.mnemonic, message: message) + + XCTAssertEqual(signature.derivationPath, UniversalWalletDerivationPaths.solanaDefault) + XCTAssertEqual(signature.address, expected["address"] as? String) + XCTAssertEqual(signature.publicKey.hexString, expected["publicKeyHex"] as? String) + XCTAssertEqual(signature.signature.count, 64) + assertHex(signature.signatureHex, byteLength: 64) + XCTAssertFalse(signature.signatureBase58.isEmpty) + XCTAssertTrue(try SolanaSigner.verifyMessage(publicKey: signature.publicKey, message: message, signature: signature.signature)) + XCTAssertFalse(try SolanaSigner.verifyMessage( + publicKey: signature.publicKey, + message: try XCTUnwrap("tampered".data(using: .utf8)), + signature: signature.signature + )) + } + + func testSolanaSignerRejectsUnsafeInputs() throws { + let vector = try XCTUnwrap(try loadFixture().vectors.first) + let message = try XCTUnwrap(Self.solanaSigningMessage.data(using: .utf8)) + + XCTAssertThrowsError(try SolanaSigner.signMessage(privateKey: Data(repeating: 0, count: 31), message: message)) + XCTAssertThrowsError(try SolanaSigner.signMessage(mnemonic: vector.mnemonic, message: Data())) + XCTAssertThrowsError(try SolanaSigner.signMessage(mnemonic: vector.mnemonic, message: Data(repeating: 0, count: 64 * 1024 + 1))) + XCTAssertThrowsError(try SolanaSigner.verifyMessage(publicKey: Data(repeating: 0, count: 31), message: message, signature: Data(repeating: 0, count: 64))) + XCTAssertThrowsError(try SolanaSigner.verifyMessage(publicKey: Data(repeating: 0, count: 32), message: message, signature: Data(repeating: 0, count: 63))) + } + + func testTonKeyDerivationMatchesGoldenVectors() throws { + for vector in try loadFixture().vectors { + let expected = try XCTUnwrap(vector.expected["ton"] as? [String: Any]) + let account = try TonKeyDerivation.deriveAccount(mnemonic: vector.mnemonic) + + XCTAssertEqual(account.derivationPath, UniversalWalletDerivationPaths.tonDefault) + XCTAssertEqual(account.derivationPath, expected["derivationPath"] as? String) + XCTAssertEqual(account.publicKeyHex, expected["publicKeyHex"] as? String) + XCTAssertEqual(account.addressBounceable, expected["addressBounceable"] as? String) + XCTAssertEqual(account.addressNonBounceable, expected["addressNonBounceable"] as? String) + XCTAssertEqual(account.testnetNonBounceable, expected["testnetNonBounceable"] as? String) + XCTAssertEqual(account.privateKey.count, 32) + XCTAssertEqual(account.chainCode.count, 32) + XCTAssertEqual(account.publicKey.count, 32) + XCTAssertEqual(account.accountHash.count, 32) + } + } + + func testTonKeyDerivationNormalizesMnemonicWhitespace() throws { + let vector = try XCTUnwrap(try loadFixture().vectors.first) + let expected = try XCTUnwrap(vector.expected["ton"] as? [String: Any]) + let paddedMnemonic = " \(vector.mnemonic.replacingOccurrences(of: " ", with: " \n\t")) " + + let account = try TonKeyDerivation.deriveAccount(mnemonic: paddedMnemonic) + + XCTAssertEqual(account.publicKeyHex, expected["publicKeyHex"] as? String) + XCTAssertEqual(account.addressNonBounceable, expected["addressNonBounceable"] as? String) + } + + func testTonKeyDerivationDoesNotReuseVectorsAcrossPathsPassphrasesOrNetworkFlags() throws { + let vector = try XCTUnwrap(try loadFixture().vectors.first) + let expected = try XCTUnwrap(vector.expected["ton"] as? [String: Any]) + let account = try TonKeyDerivation.deriveAccount(mnemonic: vector.mnemonic) + let wrongPath = try TonKeyDerivation.deriveAccount( + mnemonic: vector.mnemonic, + derivationPath: "m/44'/607'/1'/0'/0'" + ) + let withPassphrase = try TonKeyDerivation.deriveAccount( + mnemonic: vector.mnemonic, + passphrase: "fearless" + ) + + XCTAssertNotEqual(wrongPath.publicKeyHex, expected["publicKeyHex"] as? String) + XCTAssertNotEqual(wrongPath.addressNonBounceable, expected["addressNonBounceable"] as? String) + XCTAssertNotEqual(withPassphrase.publicKeyHex, expected["publicKeyHex"] as? String) + XCTAssertNotEqual(withPassphrase.addressNonBounceable, expected["addressNonBounceable"] as? String) + XCTAssertNotEqual(account.addressNonBounceable, account.testnetNonBounceable) + XCTAssertEqual( + try TonAddressCodec.friendlyAddress(accountHash: account.accountHash, isTestnet: false, bounceable: true), + expected["addressBounceable"] as? String + ) + XCTAssertEqual( + try TonAddressCodec.friendlyAddress(accountHash: account.accountHash, isTestnet: true, bounceable: false), + expected["testnetNonBounceable"] as? String + ) + } + + func testTonKeyDerivationRejectsMalformedInputs() throws { + let mnemonic = try XCTUnwrap(try loadFixture().vectors.first?.mnemonic) + + XCTAssertThrowsError(try TonKeyDerivation.deriveAccount(mnemonic: "")) + XCTAssertThrowsError(try TonKeyDerivation.deriveAccount(mnemonic: mnemonic, derivationPath: "")) + XCTAssertThrowsError(try TonKeyDerivation.deriveAccount(mnemonic: mnemonic, derivationPath: "44'/607'/0'/0'/0'")) + XCTAssertThrowsError(try TonKeyDerivation.deriveAccount(mnemonic: mnemonic, derivationPath: "m/44'/607/0'/0'/0'")) + XCTAssertThrowsError(try TonAddressCodec.v4R2Addresses(publicKey: Data(repeating: 0, count: 31))) + XCTAssertThrowsError(try TonAddressCodec.friendlyAddress(accountHash: Data(repeating: 0, count: 31), isTestnet: false, bounceable: false)) + } + + func testIrohaKeyDerivationMatchesGoldenVectors() throws { + for vector in try loadFixture().vectors { + let iroha = try XCTUnwrap(vector.expected["iroha"] as? [String: Any]) + let taira = try XCTUnwrap(iroha["taira"] as? [String: Any]) + let nexus = try XCTUnwrap(iroha["nexus"] as? [String: Any]) + let account = try IrohaKeyDerivation.deriveAccount(mnemonic: vector.mnemonic) + let tairaAddress = try IrohaKeyDerivation.deriveAddress( + mnemonic: vector.mnemonic, + chainDiscriminant: UniversalWalletRegistry.taira.chainDiscriminant + ) + let nexusAddress = try IrohaKeyDerivation.deriveAddress( + mnemonic: vector.mnemonic, + chainDiscriminant: UniversalWalletRegistry.nexus.chainDiscriminant + ) + + XCTAssertEqual(account.derivationPath, UniversalWalletDerivationPaths.irohaDefault) + XCTAssertEqual(account.derivationPath, iroha["derivationPath"] as? String) + XCTAssertEqual(account.publicKeyHex, taira["publicKeyHex"] as? String) + XCTAssertEqual(account.canonicalHex, taira["canonicalHex"] as? String) + XCTAssertEqual(tairaAddress.i105, taira["i105"] as? String) + XCTAssertEqual(nexusAddress.i105, nexus["i105"] as? String) + XCTAssertEqual(taira["publicKeyHex"] as? String, nexus["publicKeyHex"] as? String) + XCTAssertEqual(taira["canonicalHex"] as? String, nexus["canonicalHex"] as? String) + } + } + + func testIrohaKeyDerivationDoesNotReuseVectorsAcrossPathsOrPassphrases() throws { + let vector = try XCTUnwrap(try loadFixture().vectors.first) + let iroha = try XCTUnwrap(vector.expected["iroha"] as? [String: Any]) + let taira = try XCTUnwrap(iroha["taira"] as? [String: Any]) + let wrongPath = try IrohaKeyDerivation.deriveAccount( + mnemonic: vector.mnemonic, + derivationPath: "m/44'/617'/1'/0'" + ) + let withPassphrase = try IrohaKeyDerivation.deriveAccount( + mnemonic: vector.mnemonic, + passphrase: "fearless" + ) + + XCTAssertNotEqual(wrongPath.publicKeyHex, taira["publicKeyHex"] as? String) + XCTAssertNotEqual(wrongPath.canonicalHex, taira["canonicalHex"] as? String) + XCTAssertNotEqual(withPassphrase.publicKeyHex, taira["publicKeyHex"] as? String) + XCTAssertNotEqual(withPassphrase.canonicalHex, taira["canonicalHex"] as? String) + } + + func testIrohaKeyDerivationRejectsMalformedInputs() throws { + let mnemonic = try XCTUnwrap(try loadFixture().vectors.first?.mnemonic) + + XCTAssertThrowsError(try IrohaKeyDerivation.deriveAccount(mnemonic: "")) + XCTAssertThrowsError(try IrohaKeyDerivation.deriveAccount(mnemonic: mnemonic, derivationPath: "")) + XCTAssertThrowsError(try IrohaKeyDerivation.deriveAccount(mnemonic: mnemonic, derivationPath: "44'/617'/0'/0'")) + XCTAssertThrowsError(try IrohaKeyDerivation.deriveAccount(mnemonic: mnemonic, derivationPath: "m/44'/617/0'/0'")) + XCTAssertThrowsError(try IrohaKeyDerivation.deriveAddress(mnemonic: mnemonic, chainDiscriminant: -1)) + } + + private func loadFixture() throws -> UniversalWalletFixture { + let repoRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + let url = repoRoot.appendingPathComponent("docs/universal-wallet-v2-vectors.json") + let data = try Data(contentsOf: url) + return try JSONDecoder().decode(UniversalWalletFixture.self, from: data) + } + + private func assertBitcoin(_ bitcoin: [String: Any]) throws { + let mainnet = try XCTUnwrap(bitcoin["mainnet"] as? [String: Any]) + let testnet = try XCTUnwrap(bitcoin["testnet"] as? [String: Any]) + + XCTAssertEqual(mainnet["accountPath"] as? String, "m/84'/0'/0'") + XCTAssertEqual(mainnet["firstReceivePath"] as? String, "m/84'/0'/0'/0/0") + XCTAssertTrue(try XCTUnwrap(mainnet["accountXpub"] as? String).hasPrefix("xpub")) + XCTAssertTrue(try XCTUnwrap(mainnet["firstReceiveAddress"] as? String).hasPrefix("bc1q")) + + XCTAssertEqual(testnet["accountPath"] as? String, "m/84'/1'/0'") + XCTAssertEqual(testnet["firstReceivePath"] as? String, "m/84'/1'/0'/0/0") + XCTAssertTrue(try XCTUnwrap(testnet["accountXpub"] as? String).hasPrefix("xpub")) + XCTAssertTrue(try XCTUnwrap(testnet["firstReceiveAddress"] as? String).hasPrefix("tb1q")) + } + + private func assertSolana(_ solana: [String: Any]) throws { + XCTAssertEqual(solana["derivationPath"] as? String, "m/44'/501'/0'/0'") + assertHex(try XCTUnwrap(solana["publicKeyHex"] as? String), byteLength: 32) + assertMatches(try XCTUnwrap(solana["address"] as? String), pattern: "^[1-9A-HJ-NP-Za-km-z]{32,44}$") + } + + private func assertTon(_ ton: [String: Any]) throws { + XCTAssertEqual(ton["derivationPath"] as? String, "m/44'/607'/0'/0'/0'") + XCTAssertEqual(ton["walletVersion"] as? String, "v4r2") + XCTAssertEqual(ton["workchain"] as? Int, 0) + assertHex(try XCTUnwrap(ton["publicKeyHex"] as? String), byteLength: 32) + XCTAssertTrue(try XCTUnwrap(ton["addressBounceable"] as? String).hasPrefix("EQ")) + XCTAssertTrue(try XCTUnwrap(ton["addressNonBounceable"] as? String).hasPrefix("UQ")) + XCTAssertTrue(try XCTUnwrap(ton["testnetNonBounceable"] as? String).hasPrefix("0Q")) + } + + private func assertIroha(_ iroha: [String: Any]) throws { + XCTAssertEqual(iroha["derivationPath"] as? String, "m/44'/617'/0'/0'") + for network in ["taira", "nexus"] { + let address = try XCTUnwrap(iroha[network] as? [String: Any]) + assertHex(try XCTUnwrap(address["publicKeyHex"] as? String), byteLength: 32) + XCTAssertTrue(try XCTUnwrap(address["canonicalHex"] as? String).hasPrefix("0x02000120")) + XCTAssertFalse(try XCTUnwrap(address["i105"] as? String).isEmpty) + } + } + + private func assertHex(_ value: String, byteLength: Int) { + assertMatches(value, pattern: "^[0-9a-f]{\(byteLength * 2)}$") + } + + private func assertMatches(_ value: String, pattern: String) { + XCTAssertNotNil(value.range(of: pattern, options: .regularExpression)) + } + + private static let solanaSigningMessage = "Fearless Solana sign-in challenge" +} + +private struct UniversalWalletFixture: Decodable { + let version: Int + let vectors: [UniversalWalletVector] + let negativeCases: [UniversalWalletNegativeCase] +} + +private struct UniversalWalletVector: Decodable { + let id: String + let mnemonic: String + let wordCount: Int + let expected: [String: Any] + + private enum CodingKeys: String, CodingKey { + case id + case mnemonic + case wordCount + case expected + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(String.self, forKey: .id) + mnemonic = try container.decode(String.self, forKey: .mnemonic) + wordCount = try container.decode(Int.self, forKey: .wordCount) + expected = try container.decodeJSONDictionary(forKey: .expected) + } +} + +private struct UniversalWalletNegativeCase: Decodable { + let id: String +} + +private extension Data { + var hexString: String { + map { String(format: "%02x", $0) }.joined() + } +} + +private extension KeyedDecodingContainer { + func decodeJSONDictionary(forKey key: Key) throws -> [String: Any] { + let data = try JSONSerialization.data(withJSONObject: decodeRawJSONObject(forKey: key), options: []) + return try JSONSerialization.jsonObject(with: data) as? [String: Any] ?? [:] + } + + func decodeRawJSONObject(forKey key: Key) throws -> Any { + let value = try decode(AnyDecodable.self, forKey: key) + return value.value + } +} + +private struct AnyDecodable: Decodable { + let value: Any + + init(from decoder: Decoder) throws { + if let container = try? decoder.container(keyedBy: DynamicCodingKey.self) { + var dictionary: [String: Any] = [:] + for key in container.allKeys { + dictionary[key.stringValue] = try container.decode(AnyDecodable.self, forKey: key).value + } + value = dictionary + } else if var container = try? decoder.unkeyedContainer() { + var array: [Any] = [] + while !container.isAtEnd { + array.append(try container.decode(AnyDecodable.self).value) + } + value = array + } else { + let container = try decoder.singleValueContainer() + if let value = try? container.decode(String.self) { + self.value = value + } else if let value = try? container.decode(Int.self) { + self.value = value + } else if let value = try? container.decode(Bool.self) { + self.value = value + } else if container.decodeNil() { + value = NSNull() + } else { + throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported JSON value") + } + } + } +} + +private struct DynamicCodingKey: CodingKey { + let stringValue: String + let intValue: Int? + + init?(stringValue: String) { + self.stringValue = stringValue + intValue = nil + } + + init?(intValue: Int) { + stringValue = "\(intValue)" + self.intValue = intValue + } +} diff --git a/scripts/audit-branch-flow.sh b/scripts/audit-branch-flow.sh new file mode 100755 index 0000000000..d40d7f3b2d --- /dev/null +++ b/scripts/audit-branch-flow.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="${1:-$(cd "$(dirname "$0")/.." && pwd)}" +FAILURES=0 + +record_failure() { + echo "[branch-flow-audit] ERROR: $*" >&2 + FAILURES=1 +} + +scan_for_staging() { + local file="$1" + local matches + matches="$(grep -nE '(^|[^[:alnum:]_])staging([^[:alnum:]_]|$)' "$file" || true)" + if [[ -n "$matches" ]]; then + record_failure "staging branch reference found in ${file#$ROOT_DIR/}:" + printf '%s\n' "$matches" >&2 + fi +} + +if [[ ! -d "$ROOT_DIR" ]]; then + record_failure "repo root does not exist: $ROOT_DIR" +fi + +JENKINSFILE="$ROOT_DIR/Jenkinsfile" +if [[ ! -f "$JENKINSFILE" ]]; then + record_failure "Jenkinsfile is missing" +else + scan_for_staging "$JENKINSFILE" + UPLOAD_LINE="$(grep -nE 'uploadToNexusFor[[:space:]]*:' "$JENKINSFILE" || true)" + if [[ -z "$UPLOAD_LINE" ]]; then + record_failure "Jenkinsfile must declare uploadToNexusFor for release upload branches" + elif [[ "$UPLOAD_LINE" != *master* || "$UPLOAD_LINE" != *develop* ]]; then + record_failure "Jenkinsfile uploadToNexusFor must include only releasable master and integration develop branches" + printf '%s\n' "$UPLOAD_LINE" >&2 + fi +fi + +WORKFLOW_DIR="$ROOT_DIR/.github/workflows" +if [[ ! -d "$WORKFLOW_DIR" ]]; then + record_failure ".github/workflows is missing" +else + WORKFLOW_COUNT=0 + while IFS= read -r -d '' workflow; do + WORKFLOW_COUNT=$((WORKFLOW_COUNT + 1)) + scan_for_staging "$workflow" + done < <(find "$WORKFLOW_DIR" -type f \( -name '*.yml' -o -name '*.yaml' \) -print0) + + if [[ "$WORKFLOW_COUNT" -eq 0 ]]; then + record_failure "no GitHub workflow files found" + fi + + CODECOV_WORKFLOW="$WORKFLOW_DIR/codecov.yml" + if [[ ! -f "$CODECOV_WORKFLOW" ]]; then + record_failure "Codecov workflow is missing" + elif ! awk ' + /^ [A-Za-z0-9_-]+:$/ { + if (in_job && has_name && has_needs && has_result_guard) { + found = 1 + } + in_job = 1 + has_name = 0 + has_needs = 0 + has_result_guard = 0 + next + } + in_job && /^[^[:space:]]/ { + if (has_name && has_needs && has_result_guard) { + found = 1 + } + in_job = 0 + } + in_job && /^[[:space:]]+name:[[:space:]]*continuous-integration\/jenkins\/pr-merge[[:space:]]*$/ { + has_name = 1 + } + in_job && /^[[:space:]]+needs:[[:space:]]*build[[:space:]]*$/ { + has_needs = 1 + } + in_job && index($0, "needs.build.result") > 0 { + has_result_guard = 1 + } + END { + if (in_job && has_name && has_needs && has_result_guard) { + found = 1 + } + exit(found ? 0 : 1) + } + ' "$CODECOV_WORKFLOW"; then + record_failure "Codecov workflow must publish continuous-integration/jenkins/pr-merge backed by the build job result" + fi +fi + +if [[ "$FAILURES" -ne 0 ]]; then + exit 1 +fi + +echo "[branch-flow-audit] Branch flow audit passed." diff --git a/scripts/audit-private-overlay-boundary.sh b/scripts/audit-private-overlay-boundary.sh new file mode 100755 index 0000000000..e5ec2d16f2 --- /dev/null +++ b/scripts/audit-private-overlay-boundary.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")/.." + +PUBLIC_REPO_DIR="${PUBLIC_REPO_DIR:-$PWD}" +PRIVATE_REPO_DIR="${PRIVATE_REPO_DIR:-../fearless-iOS-priv}" +MAX_REPORT_LINES="${MAX_REPORT_LINES:-120}" +PRIVATE_OVERLAY_REPORT="${PRIVATE_OVERLAY_REPORT:-$PUBLIC_REPO_DIR/build/reports/private-overlay-boundary.tsv}" + +fail() { + echo "[private-overlay-audit][error] $*" >&2 + exit 1 +} + +info() { + echo "[private-overlay-audit] $*" +} + +[[ -d "$PUBLIC_REPO_DIR/.git" ]] || fail "PUBLIC_REPO_DIR is not a Git checkout: $PUBLIC_REPO_DIR" +[[ -d "$PRIVATE_REPO_DIR/.git" ]] || fail "PRIVATE_REPO_DIR is not a Git checkout: $PRIVATE_REPO_DIR" + +is_allowed_overlay_path() { + case "$1" in + README.md|LICENSE|.gitignore|Jenkinsfile) + return 0 + ;; + .github/*|docs/release*|docs/rollback*|docs/private-overlay*|metadata/*|release/*|fastlane/*) + return 0 + ;; + fearless/CIKeys.stencil|fearless/fearless.entitlements|fearless/Configs/fearless.release.xcconfig) + return 0 + ;; + scripts/secrets/*|scripts/audit-private-overlay-boundary.sh) + return 0 + ;; + *) + return 1 + ;; + esac +} + +tmpdir="$(mktemp -d)" +trap 'rm -rf "$tmpdir"' EXIT + +private_files="$tmpdir/private-files" +unexpected="$tmpdir/unexpected" + +git -C "$PRIVATE_REPO_DIR" ls-files | sort > "$private_files" + +: > "$unexpected" + +while IFS= read -r path || [[ -n "$path" ]]; do + [[ -n "$path" ]] || continue + is_allowed_overlay_path "$path" && continue + + public_path="$PUBLIC_REPO_DIR/$path" + private_path="$PRIVATE_REPO_DIR/$path" + if [[ ! -f "$public_path" ]]; then + printf 'A\t%s\n' "$path" >> "$unexpected" + elif [[ ! -f "$private_path" ]]; then + printf 'A\t%s\n' "$path" >> "$unexpected" + elif ! cmp -s "$public_path" "$private_path"; then + printf 'M\t%s\n' "$path" >> "$unexpected" + else + printf 'T\t%s\n' "$path" >> "$unexpected" + fi +done < "$private_files" + +if [[ -s "$unexpected" ]]; then + count="$(wc -l < "$unexpected" | tr -d '[:space:]')" + mkdir -p "$(dirname "$PRIVATE_OVERLAY_REPORT")" + cp "$unexpected" "$PRIVATE_OVERLAY_REPORT" + echo "[private-overlay-audit][error] private iOS repo is not overlay-only." >&2 + echo "[private-overlay-audit][error] Unexpected added or modified paths: $count" >&2 + echo "[private-overlay-audit][error] Full report: $PRIVATE_OVERLAY_REPORT" >&2 + sed -n "1,${MAX_REPORT_LINES}p" "$unexpected" >&2 + if (( count > MAX_REPORT_LINES )); then + echo "[private-overlay-audit][error] Output truncated at $MAX_REPORT_LINES paths." >&2 + fi + fail "move product code and public config back to the public repo; keep only release overlays in private storage" +fi + +info "Private iOS overlay boundary passed." diff --git a/scripts/audit-public-artifacts.sh b/scripts/audit-public-artifacts.sh new file mode 100755 index 0000000000..6e80c052b0 --- /dev/null +++ b/scripts/audit-public-artifacts.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(git rev-parse --show-toplevel)" +cd "$ROOT_DIR" + +fail() { + printf 'Public artifact audit failed: %s\n' "$1" >&2 + exit 1 +} + +tracked_files="$(mktemp)" +tmp_matches="$(mktemp)" +trap 'rm -f "$tracked_files" "$tmp_matches"' EXIT + +git ls-files > "$tracked_files" + +if grep -E '(^|/)(DerivedData|build)(/|$)|\.(xcarchive|ipa|dSYM\.zip)$' "$tracked_files" > "$tmp_matches"; then + cat "$tmp_matches" >&2 + fail 'generated iOS build output is tracked' +fi + +if grep -E '(GoogleService-Info\.plist$|\.mobileprovision$|\.p12$|\.p8$|\.pem$|\.cer$|\.der$|\.key$)' "$tracked_files" > "$tmp_matches"; then + cat "$tmp_matches" >&2 + fail 'signing material, Firebase config, or private key artifacts are tracked' +fi + +if grep -E '(^|/)\.env($|\.|/)' "$tracked_files" | grep -Ev '^\.env\.example$' > "$tmp_matches"; then + cat "$tmp_matches" >&2 + fail 'unexpected env file is tracked' +fi + +if git grep -n -I -E -- '-----BEGIN (RSA |DSA |EC |OPENSSH |ENCRYPTED )?PRIVATE KEY-----|AIza[0-9A-Za-z_-]{35}|[0-9]+-[a-z0-9]+\.apps\.googleusercontent\.com' -- . ':!Podfile.lock' ':!*.xcmappingmodel/*' > "$tmp_matches"; then + cat "$tmp_matches" >&2 + fail 'tracked content contains private key material, Google API keys, or OAuth client IDs' +fi + +if [[ -f fearless/CIKeys.stencil ]]; then + if grep -nE 'static var [A-Za-z0-9_]+: String = "[^"{]' fearless/CIKeys.stencil > "$tmp_matches"; then + cat "$tmp_matches" >&2 + fail 'CIKeys.stencil contains a non-placeholder string value' + fi +fi + +printf 'Public artifact audit passed.\n' diff --git a/scripts/audit-todo-debt.sh b/scripts/audit-todo-debt.sh new file mode 100755 index 0000000000..113a9f101a --- /dev/null +++ b/scripts/audit-todo-debt.sh @@ -0,0 +1,142 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="${TODO_AUDIT_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}" +BASELINE_FILE="${TODO_AUDIT_BASELINE:-$ROOT_DIR/config/todo-debt-baseline.tsv}" + +log() { echo "[todo-audit] $*"; } +fail() { + echo "[todo-audit][error] $*" >&2 + exit 1 +} + +if [[ ! -d "$ROOT_DIR" ]]; then + fail "Root directory does not exist: $ROOT_DIR" +fi + +if [[ ! -f "$BASELINE_FILE" ]]; then + fail "Baseline file does not exist: $BASELINE_FILE" +fi + +tmp_dir="$(mktemp -d)" +trap 'rm -rf "$tmp_dir"' EXIT + +current="$tmp_dir/current.tsv" +baseline_raw="$tmp_dir/baseline-raw.tsv" +baseline="$tmp_dir/baseline.tsv" +duplicate_baseline="$tmp_dir/duplicate-baseline.tsv" +new_markers="$tmp_dir/new.tsv" +stale_markers="$tmp_dir/stale.tsv" +crashing_placeholders="$tmp_dir/crashing_placeholders.txt" + +grep_source_files() { + local pattern="$1" + + ( + cd "$ROOT_DIR" + find . \ + \( -path './Pods' -o -path './Pods/*' \ + -o -path './PrivatePods' -o -path './PrivatePods/*' \ + -o -path './SourcePackages' -o -path './SourcePackages/*' \ + -o -path './DerivedData' -o -path './DerivedData/*' \ + -o -path './build' -o -path './build/*' \ + -o -path './.build' -o -path './.build/*' \ + -o -path '*/.build' -o -path '*/.build/*' \ + -o -path './.git' -o -path './.git/*' \) -prune \ + -o -type f \ + \( -name '*.swift' -o -name '*.m' -o -name '*.mm' -o -name '*.h' -o -name '*.plist' -o -name '*.pbxproj' \) \ + -print0 | + xargs -0 grep -HInEi "$pattern" || true + ) +} + +scan_marker_debt() { + if command -v rg >/dev/null 2>&1; then + ( + cd "$ROOT_DIR" + rg -n --no-heading -i '\b(todo|fixme|stopship)\b' \ + --glob '*.swift' \ + --glob '*.m' \ + --glob '*.mm' \ + --glob '*.h' \ + --glob '*.plist' \ + --glob '*.pbxproj' \ + --glob '!Pods/**' \ + --glob '!PrivatePods/**' \ + --glob '!SourcePackages/**' \ + --glob '!DerivedData/**' \ + --glob '!build/**' \ + --glob '!.build/**' \ + --glob '!**/.build/**' \ + --glob '!**/.git/**' \ + . || true + ) + else + grep_source_files '(^|[^[:alnum:]_])(todo|fixme|stopship)([^[:alnum:]_]|$)' + fi | awk -F: ' + { + line = $0 + sub(/^[^:]+:[0-9]+:/, "", line) + path = $1 + sub(/^\.\//, "", path) + gsub(/^[[:space:]]+|[[:space:]]+$/, "", line) + print path "\t" line + } + ' | LC_ALL=C sort -u +} + +scan_crashing_placeholders() { + if command -v rg >/dev/null 2>&1; then + ( + cd "$ROOT_DIR" + rg -n --no-heading -i '(fatalError|preconditionFailure)[[:space:]]*\([^)]*(TODO|FIXME|STOPSHIP)' \ + --glob '*.swift' \ + --glob '!Pods/**' \ + --glob '!PrivatePods/**' \ + --glob '!SourcePackages/**' \ + --glob '!DerivedData/**' \ + --glob '!build/**' \ + --glob '!.build/**' \ + --glob '!**/.build/**' \ + --glob '!**/.git/**' \ + . || true + ) + else + grep_source_files '(fatalError|preconditionFailure)[[:space:]]*\([^)]*(TODO|FIXME|STOPSHIP)' + fi +} + +awk 'NF && $0 !~ /^#/' "$BASELINE_FILE" > "$baseline_raw" +LC_ALL=C sort "$baseline_raw" > "$baseline" +LC_ALL=C sort "$baseline_raw" | uniq -d > "$duplicate_baseline" +scan_marker_debt > "$current" +scan_crashing_placeholders > "$crashing_placeholders" + +if [[ -s "$duplicate_baseline" ]]; then + echo "Duplicate TODO debt baseline entries are forbidden:" >&2 + sed -n '1,40p' "$duplicate_baseline" >&2 + fail "Remove duplicate entries from config/todo-debt-baseline.tsv." +fi + +if [[ -s "$crashing_placeholders" ]]; then + echo "Crashing TODO/FIXME placeholders are forbidden:" >&2 + sed -n '1,40p' "$crashing_placeholders" >&2 + fail "Remove fatalError/preconditionFailure TODO placeholders instead of baselining them." +fi + +comm -23 "$current" "$baseline" > "$new_markers" +comm -13 "$current" "$baseline" > "$stale_markers" + +if [[ -s "$new_markers" ]]; then + echo "New TODO/FIXME/STOPSHIP markers found outside the baseline:" >&2 + sed -n '1,80p' "$new_markers" >&2 + fail "Resolve the markers or intentionally update config/todo-debt-baseline.tsv." +fi + +if [[ -s "$stale_markers" ]]; then + echo "Baseline entries no longer exist in source:" >&2 + sed -n '1,80p' "$stale_markers" >&2 + fail "Remove stale entries from config/todo-debt-baseline.tsv." +fi + +log "TODO/FIXME debt matches baseline." diff --git a/scripts/audit-transaction-builder-tests.sh b/scripts/audit-transaction-builder-tests.sh new file mode 100755 index 0000000000..d9ccb19f0b --- /dev/null +++ b/scripts/audit-transaction-builder-tests.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="${IOS_TX_BUILDER_AUDIT_ROOT:-$(git rev-parse --show-toplevel 2>/dev/null || (cd "$(dirname "$0")/.." && pwd))}" + +failures=() + +record_failure() { + failures+=("$1") + echo "[ios-transaction-builder-audit][warn] $1" >&2 +} + +require_file() { + local file="$1" + local description="$2" + [[ -f "$file" ]] || record_failure "$description missing: $file" +} + +require_pattern() { + local file="$1" + local pattern="$2" + local description="$3" + if [[ ! -f "$file" ]]; then + return + fi + if ! grep -Eq -- "$pattern" "$file"; then + record_failure "$description missing in $file" + fi +} + +bitcoin_tests="$ROOT_DIR/fearlessTests/BitcoinTransactionBuilderTests.swift" +solana_tests="$ROOT_DIR/fearlessTests/SolanaTransferTransactionBuilderTests.swift" +routing_tests="$ROOT_DIR/fearlessTests/ApplicationLayer/Services/FeatureToggle/TonChainSelectionTests.swift" +transfer_service="$ROOT_DIR/fearless/ApplicationLayer/Services/Transfer/Tokens/TransferService.swift" +send_container="$ROOT_DIR/fearless/Modules/Send/SendDependencyContainer.swift" + +require_file "$bitcoin_tests" "Bitcoin transaction builder tests" +require_pattern "$bitcoin_tests" 'final class BitcoinTransactionBuilderTests' "Bitcoin transaction builder test class" +require_pattern "$bitcoin_tests" 'testBuildsAndSignsMainnetP2wpkhTransactionMatchingWebVector' "Bitcoin canonical P2WPKH vector test" +require_pattern "$bitcoin_tests" 'testRejectsUnsafeUtxosOutputsFeesAndChangeHandlingBeforeSigning' "Bitcoin unsafe UTXO/output/fee adversarial test" +require_pattern "$bitcoin_tests" 'testRejectsUtxosThatDoNotMatchDerivedBip84KeyOrWitnessScript' "Bitcoin BIP84 witness mismatch adversarial test" +require_pattern "$bitcoin_tests" 'testRejectsEmptyMnemonicBeforeSigning' "Bitcoin empty mnemonic adversarial test" + +require_file "$solana_tests" "Solana transfer transaction builder tests" +require_pattern "$solana_tests" 'final class SolanaTransferTransactionBuilderTests' "Solana transaction builder test class" +require_pattern "$solana_tests" 'testBuildsCanonicalLegacySystemTransferTransactionEnvelope' "Solana native transfer envelope test" +require_pattern "$solana_tests" 'testRejectsMalformedTransferParameters' "Solana malformed native transfer adversarial test" +require_pattern "$solana_tests" 'testBuildsCanonicalSPLTokenTransferCheckedTransactionEnvelope' "Solana SPL Token transfer-checked test" +require_pattern "$solana_tests" 'testBuildsToken2022TransferCheckedWithExtensionExtraAccountsInInstructionOrder' "Solana Token-2022 extra-account ordering test" +require_pattern "$solana_tests" 'testBuildsTokenSendWithIdempotentAssociatedTokenAccountCreateThenTransferChecked' "Solana ATA create plus transfer builder test" + +require_file "$transfer_service" "Iroha transfer service signer seam" +require_pattern "$transfer_service" 'protocol IrohaTransferSigning' "Iroha transfer signer seam" +require_pattern "$transfer_service" 'struct IrohaTransferSigningRequest' "Iroha transfer signing request contract" +require_pattern "$transfer_service" 'struct IrohaSignedTransfer' "Iroha signed transfer contract" + +require_file "$routing_tests" "Universal wallet send routing tests" +require_pattern "$routing_tests" 'testIrohaTransferServiceBuildsSignerRequestAndSubmitsNorito' "Iroha signer request and Torii submission test" +require_pattern "$routing_tests" 'testIrohaTransferServiceDefaultSignerFailsClosedAfterValidation' "Iroha default signer fail-closed test" +require_pattern "$routing_tests" 'testIrohaTransferServiceRejectsMnemonicMismatchBeforeSignerOrToriiCalls' "Iroha mnemonic mismatch adversarial test" +require_pattern "$routing_tests" 'testPrepareDependenciesFailsClosedForTonCompatibilityTransferBeforeSubstrateRouting' "TON transfer fail-closed routing test" + +require_file "$send_container" "Send dependency container" +require_pattern "$send_container" 'isTonCompatibilityChain' "TON compatibility transfer guard" +require_pattern "$send_container" 'TON transfer not yet supported\.' "TON transfer explicit fail-closed error" + +if ((${#failures[@]} > 0)); then + echo "[ios-transaction-builder-audit][error] iOS transaction-builder coverage audit failed:" >&2 + printf ' - %s\n' "${failures[@]}" >&2 + exit 1 +fi + +echo "[ios-transaction-builder-audit] iOS transaction-builder coverage audit passed." diff --git a/scripts/check-iroha-mobile-sdk-release-assets.sh b/scripts/check-iroha-mobile-sdk-release-assets.sh new file mode 100755 index 0000000000..90e4cec6fc --- /dev/null +++ b/scripts/check-iroha-mobile-sdk-release-assets.sh @@ -0,0 +1,291 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'USAGE' +Usage: + scripts/check-iroha-mobile-sdk-release-assets.sh --release-dir [--version ] + scripts/check-iroha-mobile-sdk-release-assets.sh --download --tag [--repo ] + scripts/check-iroha-mobile-sdk-release-assets.sh --self-test + +Validates the Apple Iroha mobile SDK release assets produced by ../iroha: + - NoritoBridge-.xcframework.zip + - NoritoBridge-.artifacts.json + - SHA256SUMS-apple-.txt or SHA256SUMS-all-.txt + - mobile-sdk-apple-.artifacts.json or mobile-sdk-all-.artifacts.json + +The XCFramework zip must contain iOS device, iOS simulator, and macOS slices +with libNoritoBridge.a, headers, and module maps. +USAGE +} + +RELEASE_DIR="" +VERSION="" +DOWNLOAD=0 +SELF_TEST=0 +REPO="${IROHA_MOBILE_SDK_RELEASE_REPO:-hyperledger/iroha}" +TAG="${IROHA_MOBILE_SDK_RELEASE_TAG:-}" + +while [[ $# -gt 0 ]]; do + case "$1" in + --release-dir) + shift + RELEASE_DIR="${1:-}" + ;; + --release-dir=*) + RELEASE_DIR="${1#*=}" + ;; + --version) + shift + VERSION="${1:-}" + ;; + --version=*) + VERSION="${1#*=}" + ;; + --download) + DOWNLOAD=1 + ;; + --tag) + shift + TAG="${1:-}" + ;; + --tag=*) + TAG="${1#*=}" + ;; + --repo) + shift + REPO="${1:-}" + ;; + --repo=*) + REPO="${1#*=}" + ;; + --self-test) + SELF_TEST=1 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "[iroha-sdk-assets] ERROR: unexpected argument: $1" >&2 + usage >&2 + exit 64 + ;; + esac + shift +done + +fail() { + echo "[iroha-sdk-assets] ERROR: $*" >&2 + exit 1 +} + +hash_file() { + local path="$1" + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$path" | awk '{print $1}' + elif command -v sha256sum >/dev/null 2>&1; then + sha256sum "$path" | awk '{print $1}' + else + fail "shasum or sha256sum is required" + fi +} + +require_tool() { + command -v "$1" >/dev/null 2>&1 || fail "$1 is required" +} + +zip_entries() { + unzip -Z1 "$1" 2>/dev/null || fail "not a readable zip archive: $1" +} + +require_zip_entry() { + local archive="$1" + local pattern="$2" + local label="$3" + local entries + entries="$(zip_entries "$archive")" + grep -Eq "$pattern" <<<"$entries" || fail "missing $label in $(basename "$archive")" +} + +infer_version() { + local dir="$1" + local files=() + local file base + while IFS= read -r file; do + files+=("$file") + done < <(find "$dir" -maxdepth 1 -type f -name 'NoritoBridge-*.xcframework.zip' | sort) + + [[ ${#files[@]} -eq 1 ]] || fail "expected exactly one NoritoBridge-*.xcframework.zip in $dir, found ${#files[@]}" + base="$(basename "${files[0]}")" + base="${base#NoritoBridge-}" + printf '%s' "${base%.xcframework.zip}" +} + +download_release_assets() { + [[ -n "$TAG" ]] || fail "--download requires --tag or IROHA_MOBILE_SDK_RELEASE_TAG" + require_tool gh + RELEASE_DIR="$(mktemp -d "${TMPDIR:-/tmp}/iroha-apple-sdk-assets.XXXXXX")" + VERSION="$TAG" + gh release download "$TAG" \ + --repo "$REPO" \ + --dir "$RELEASE_DIR" \ + --pattern "NoritoBridge-${TAG}.xcframework.zip" \ + --pattern "NoritoBridge-${TAG}.artifacts.json" \ + --pattern "SHA256SUMS-apple-${TAG}.txt" \ + --pattern "mobile-sdk-apple-${TAG}.artifacts.json" +} + +validate_release_dir() { + require_tool unzip + + [[ -n "$RELEASE_DIR" ]] || fail "--release-dir is required unless --download is used" + [[ -d "$RELEASE_DIR" ]] || fail "release dir does not exist: $RELEASE_DIR" + + if [[ -z "$VERSION" ]]; then + VERSION="$(infer_version "$RELEASE_DIR")" + fi + + local apple_zip="$RELEASE_DIR/NoritoBridge-${VERSION}.xcframework.zip" + local bridge_manifest="$RELEASE_DIR/NoritoBridge-${VERSION}.artifacts.json" + local checksums="$RELEASE_DIR/SHA256SUMS-apple-${VERSION}.txt" + local manifest="$RELEASE_DIR/mobile-sdk-apple-${VERSION}.artifacts.json" + local sha slice + + [[ -f "$apple_zip" ]] || fail "missing Apple XCFramework zip: $apple_zip" + [[ -f "$bridge_manifest" ]] || fail "missing NoritoBridge artifact manifest: $bridge_manifest" + if [[ ! -f "$checksums" ]]; then + checksums="$RELEASE_DIR/SHA256SUMS-all-${VERSION}.txt" + fi + [[ -f "$checksums" ]] || fail "missing checksum file for version $VERSION" + if [[ ! -f "$manifest" ]]; then + manifest="$RELEASE_DIR/mobile-sdk-all-${VERSION}.artifacts.json" + fi + [[ -f "$manifest" ]] || fail "missing mobile SDK artifact manifest for version $VERSION" + + sha="$(hash_file "$apple_zip")" + grep -F "$(basename "$apple_zip")" "$checksums" | grep -Fq "$sha" || + fail "checksum file does not match $(basename "$apple_zip")" + grep -Fq "\"version\": \"$VERSION\"" "$manifest" || fail "mobile SDK manifest version mismatch" + grep -Fq "$(basename "$apple_zip")" "$manifest" || fail "mobile SDK manifest does not list Apple XCFramework zip" + grep -Fq "\"version\": " "$bridge_manifest" || fail "bridge manifest missing version" + + for slice in ios-arm64 ios-arm64_x86_64-simulator macos-arm64; do + grep -Eq "\"$slice\"[[:space:]]*:[[:space:]]*\"[[:xdigit:]]{64}\"" "$bridge_manifest" || + fail "bridge manifest missing SHA-256 hash for $slice" + require_zip_entry "$apple_zip" "^NoritoBridge\\.xcframework/$slice/libNoritoBridge\\.a$" "$slice static library" + require_zip_entry "$apple_zip" "^NoritoBridge\\.xcframework/$slice/Headers/NoritoBridge\\.h$" "$slice umbrella header" + require_zip_entry "$apple_zip" "^NoritoBridge\\.xcframework/$slice/Headers/connect_norito_bridge\\.h$" "$slice bridge header" + require_zip_entry "$apple_zip" "^NoritoBridge\\.xcframework/$slice/Headers/module\\.modulemap$" "$slice module map" + done + require_zip_entry "$apple_zip" '^NoritoBridge\.xcframework/Info\.plist$' "XCFramework Info.plist" + + echo "[iroha-sdk-assets] Apple Iroha SDK assets validated for version $VERSION" +} + +make_fixture() { + local dir="$1" + local version="$2" + local omit_sim="${3:-0}" + local omit_manifest_hash="${4:-0}" + local stage="$dir/NoritoBridge.xcframework" + local zip_path="$dir/NoritoBridge-${version}.xcframework.zip" + local sha slice + + mkdir -p "$stage" + printf 'AvailableLibraries\n' > "$stage/Info.plist" + for slice in ios-arm64 ios-arm64_x86_64-simulator macos-arm64; do + if [[ "$omit_sim" == "1" && "$slice" == "ios-arm64_x86_64-simulator" ]]; then + continue + fi + mkdir -p "$stage/$slice/Headers" + printf 'lib\n' > "$stage/$slice/libNoritoBridge.a" + printf 'header\n' > "$stage/$slice/Headers/NoritoBridge.h" + printf 'header\n' > "$stage/$slice/Headers/connect_norito_bridge.h" + printf 'module NoritoBridge {}\n' > "$stage/$slice/Headers/module.modulemap" + done + + (cd "$dir" && zip -qr "$(basename "$zip_path")" NoritoBridge.xcframework) + sha="$(hash_file "$zip_path")" + printf '%s %s\n' "$sha" "$zip_path" > "$dir/SHA256SUMS-apple-${version}.txt" + if [[ "$omit_manifest_hash" == "1" ]]; then + cat > "$dir/NoritoBridge-${version}.artifacts.json" < "$dir/NoritoBridge-${version}.artifacts.json" < "$dir/mobile-sdk-apple-${version}.artifacts.json" </dev/null + + missing_slice="$tmp/missing-slice" + mkdir -p "$missing_slice" + make_fixture "$missing_slice" "v0.1.0" 1 + if bash "$0" --release-dir "$missing_slice" --version "v0.1.0" >/dev/null 2>&1; then + fail "self-test expected missing simulator slice validation to fail" + fi + + missing_hash="$tmp/missing-hash" + mkdir -p "$missing_hash" + make_fixture "$missing_hash" "v0.1.0" 0 1 + if bash "$0" --release-dir "$missing_hash" --version "v0.1.0" >/dev/null 2>&1; then + fail "self-test expected missing bridge manifest hash validation to fail" + fi + + missing_checksums="$tmp/missing-checksums" + mkdir -p "$missing_checksums" + make_fixture "$missing_checksums" "v0.1.0" + rm -f "$missing_checksums/SHA256SUMS-apple-v0.1.0.txt" + if bash "$0" --release-dir "$missing_checksums" --version "v0.1.0" >/dev/null 2>&1; then + fail "self-test expected missing checksum validation to fail" + fi + + echo "[iroha-sdk-assets-test] Apple release asset checks passed" +} + +if [[ "$SELF_TEST" == "1" ]]; then + run_self_test + exit 0 +fi + +if [[ "$DOWNLOAD" == "1" ]]; then + download_release_assets +fi + +validate_release_dir diff --git a/scripts/ci/bootstrap.sh b/scripts/ci/bootstrap.sh old mode 100644 new mode 100755 index 766d0957a2..6aa08193ca --- a/scripts/ci/bootstrap.sh +++ b/scripts/ci/bootstrap.sh @@ -8,9 +8,36 @@ echo "[bootstrap] Starting CI bootstrap" # Ensure UTF-8 locale for Ruby/CocoaPods export LANG=${LANG:-en_US.UTF-8} export LC_ALL=${LC_ALL:-en_US.UTF-8} +if [[ " ${RUBYOPT:-} " != *" -rlogger "* ]]; then + export RUBYOPT="${RUBYOPT:+$RUBYOPT }-rlogger" +fi WORKSPACE_DIR=${WORKSPACE:-$(pwd)} pushd "$WORKSPACE_DIR" >/dev/null +RESTORE_CI_PODFILE_ON_EXIT_WAS_SET="${RESTORE_CI_PODFILE_ON_EXIT+x}" +RESTORE_CI_PODFILE_ON_EXIT="${RESTORE_CI_PODFILE_ON_EXIT:-1}" + +restore_ci_podfile() { + if [[ "$RESTORE_CI_PODFILE_ON_EXIT" == "1" ]]; then + if [[ -f "$WORKSPACE_DIR/Podfile.ci.bak" ]]; then + mv -f "$WORKSPACE_DIR/Podfile.ci.bak" "$WORKSPACE_DIR/Podfile" + fi + if [[ -f "$WORKSPACE_DIR/Podfile.lock.ci.bak" ]]; then + mv -f "$WORKSPACE_DIR/Podfile.lock.ci.bak" "$WORKSPACE_DIR/Podfile.lock" + fi + if [[ -f "$WORKSPACE_DIR/Pods/Manifest.lock.ci.bak" ]]; then + mv -f "$WORKSPACE_DIR/Pods/Manifest.lock.ci.bak" "$WORKSPACE_DIR/Pods/Manifest.lock" + fi + else + rm -f "$WORKSPACE_DIR/Podfile.ci.bak" + rm -f "$WORKSPACE_DIR/Podfile.lock.ci.bak" + rm -f "$WORKSPACE_DIR/Pods/Manifest.lock.ci.bak" + fi + rm -f "$WORKSPACE_DIR/Podfile.ci.tmp" + rm -f "$WORKSPACE_DIR/Podfile.lock.ci.tmp" + rm -f "$WORKSPACE_DIR/Pods/Manifest.lock.ci.tmp" +} +trap restore_ci_podfile EXIT # 1) CocoaPods install (with fallbacks) if [[ -f Podfile ]]; then @@ -32,10 +59,16 @@ if [[ -f Podfile ]]; then fi if [[ "$SHOULD_DISABLE_KEYS" == "1" ]]; then - if /usr/bin/grep -q "pod 'FearlessKeys'" Podfile; then + if /usr/bin/grep -q "^[[:space:]]*pod 'FearlessKeys'" Podfile; then cp Podfile Podfile.ci.bak + if [[ -f Podfile.lock ]]; then cp Podfile.lock Podfile.lock.ci.bak; fi + if [[ -f Pods/Manifest.lock ]]; then cp Pods/Manifest.lock Pods/Manifest.lock.ci.bak; fi awk 'BEGIN{done=0} { if(done==0 && $0 ~ /^[[:space:]]*pod '\''FearlessKeys'\''/){ print "# CI: disabled private pod for PR build -> "$0; done=1 } else { print } }' Podfile > Podfile.ci.tmp && mv Podfile.ci.tmp Podfile echo "[bootstrap] Disabled FearlessKeys pod (no token available in CI)" + if [[ -z "$RESTORE_CI_PODFILE_ON_EXIT_WAS_SET" && ( -n "${GITHUB_ACTIONS:-}" || "$IS_JENKINS_PR" == "1" ) ]]; then + RESTORE_CI_PODFILE_ON_EXIT=0 + echo "[bootstrap] Keeping generated CI Podfile.lock/Pods Manifest.lock for this CI job" + fi fi else # Trusted branch or token provided: enable tokens for private repos @@ -69,7 +102,7 @@ if [[ -f Podfile ]]; then fi # Restore original Podfile if modified - if [[ -f Podfile.ci.bak ]]; then mv -f Podfile.ci.bak Podfile; fi + restore_ci_podfile # Verify Pods installed if [[ ! -f "Pods/Target Support Files/Pods-fearlessAll-fearless/Pods-fearlessAll-fearless.debug.xcconfig" ]]; then diff --git a/scripts/ci/run-pr.sh b/scripts/ci/run-pr.sh old mode 100644 new mode 100755 index 05453e2ec9..9ecdc79809 --- a/scripts/ci/run-pr.sh +++ b/scripts/ci/run-pr.sh @@ -6,6 +6,38 @@ echo "[run-pr] Running PR build (simulator build + tests, with device fallback)" WORKSPACE_DIR=${WORKSPACE:-$(pwd)} SP_DIR="$WORKSPACE_DIR/SourcePackages" +if [[ -f "$WORKSPACE_DIR/scripts/audit-branch-flow.sh" ]]; then + echo "[run-pr] Running branch flow audit" + bash "$WORKSPACE_DIR/scripts/audit-branch-flow.sh" + bash "$WORKSPACE_DIR/scripts/test-branch-flow-audit.sh" +fi + +if [[ -f "$WORKSPACE_DIR/scripts/audit-public-artifacts.sh" ]]; then + echo "[run-pr] Running public artifact audit" + bash "$WORKSPACE_DIR/scripts/audit-public-artifacts.sh" +fi + +if [[ -f "$WORKSPACE_DIR/scripts/audit-todo-debt.sh" ]]; then + echo "[run-pr] Running TODO debt audit" + bash "$WORKSPACE_DIR/scripts/test-todo-debt-audit.sh" + bash "$WORKSPACE_DIR/scripts/audit-todo-debt.sh" +fi + +if [[ -f "$WORKSPACE_DIR/scripts/check-iroha-mobile-sdk-release-assets.sh" ]]; then + echo "[run-pr] Checking Iroha mobile SDK release asset contract" + bash "$WORKSPACE_DIR/scripts/check-iroha-mobile-sdk-release-assets.sh" --self-test + if [[ -n "${IROHA_MOBILE_SDK_RELEASE_TAG:-}" ]]; then + bash "$WORKSPACE_DIR/scripts/check-iroha-mobile-sdk-release-assets.sh" --download --tag "$IROHA_MOBILE_SDK_RELEASE_TAG" + else + echo "[run-pr] IROHA_MOBILE_SDK_RELEASE_TAG is not set; skipping real release asset validation" + fi +fi + +if [[ -f "$WORKSPACE_DIR/scripts/test-private-overlay-boundary.sh" ]]; then + echo "[run-pr] Testing private overlay boundary guard" + bash "$WORKSPACE_DIR/scripts/test-private-overlay-boundary.sh" +fi + if [[ -f "$WORKSPACE_DIR/fearless.xcworkspace/contents.xcworkspacedata" ]]; then if [[ -x "$WORKSPACE_DIR/scripts/deps/restore-swiftpm-contract-files.sh" ]]; then "$WORKSPACE_DIR/scripts/deps/restore-swiftpm-contract-files.sh" "$WORKSPACE_DIR" "[run-pr]" @@ -57,6 +89,7 @@ if xcodebuild -workspace "$WORKSPACE_DIR/fearless.xcworkspace" \ -configuration Debug \ -destination "$SIM_DEST" \ -clonedSourcePackagesDirPath "$SP_DIR" \ + -disableAutomaticPackageResolution \ clean build; then echo "[run-pr] Running unit tests on iOS Simulator (scheme: fearless.tests)" @@ -64,6 +97,7 @@ if xcodebuild -workspace "$WORKSPACE_DIR/fearless.xcworkspace" \ -scheme fearless.tests \ -destination "$SIM_DEST" \ -clonedSourcePackagesDirPath "$SP_DIR" \ + -disableAutomaticPackageResolution \ test else echo "[run-pr] Simulator build failed; falling back to device build (signing disabled)" @@ -72,6 +106,7 @@ else -configuration Debug \ -destination 'generic/platform=iOS' \ -clonedSourcePackagesDirPath "$SP_DIR" \ + -disableAutomaticPackageResolution \ CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO \ clean build fi diff --git a/scripts/deps/apply-native-crypto-modulemap-contract.sh b/scripts/deps/apply-native-crypto-modulemap-contract.sh old mode 100644 new mode 100755 diff --git a/scripts/deps/apply-native-crypto-package-contract.sh b/scripts/deps/apply-native-crypto-package-contract.sh old mode 100644 new mode 100755 diff --git a/scripts/deps/audit-shared-features-delta-report.sh b/scripts/deps/audit-shared-features-delta-report.sh new file mode 100755 index 0000000000..0e2052021f --- /dev/null +++ b/scripts/deps/audit-shared-features-delta-report.sh @@ -0,0 +1,304 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="${1:-$(pwd)}" +REPORT_FILE="" + +usage() { + cat <<'USAGE' +Usage: scripts/deps/audit-shared-features-delta-report.sh [ROOT] [--write-report ] + +Validates the repo-owned shared-features-spm compatibility delta and optionally +writes a JSON report for CI/release review. This does not mutate SwiftPM +checkouts; it records why the current checkout-mutation scripts still exist and +what upstream/source changes are needed to remove them. +USAGE +} + +while (($#)); do + case "$1" in + --write-report) + [[ $# -ge 2 ]] || { echo "[shared-features-delta][error] --write-report requires a path" >&2; exit 2; } + REPORT_FILE="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + if [[ "$1" == --* ]]; then + echo "[shared-features-delta][error] Unknown argument: $1" >&2 + usage >&2 + exit 2 + fi + ROOT="$1" + shift + ;; + esac +done + +failures=() + +record_failure() { + failures+=("$1") + echo "[shared-features-delta][warn] $1" >&2 +} + +require_file() { + local file="$1" + local label="$2" + [[ -f "$file" ]] || record_failure "$label missing: $file" +} + +require_pattern() { + local file="$1" + local pattern="$2" + local label="$3" + + [[ -f "$file" ]] || return 0 + /usr/bin/grep -Fq "$pattern" "$file" || record_failure "$label missing in $file" +} + +json_escape() { + /usr/bin/perl -pe 's/\\/\\\\/g; s/"/\\"/g; s/\n//g' +} + +sha256_file() { + local file="$1" + + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$file" | awk '{print $1}' + elif command -v openssl >/dev/null 2>&1; then + openssl dgst -sha256 "$file" | awk '{print $NF}' + else + record_failure "No SHA-256 tool available for $file" + printf 'missing-sha256-tool' + fi +} + +extract_shared_features_revision() { + local resolved="$1" + awk ' + /"identity"[[:space:]]*:[[:space:]]*"shared-features-spm"/ { in_pkg=1 } + in_pkg && /"revision"[[:space:]]*:/ { + match($0, /"revision"[[:space:]]*:[[:space:]]*"[^"]+"/) + if (RSTART > 0) { + value = substr($0, RSTART, RLENGTH) + gsub(/.*"revision"[[:space:]]*:[[:space:]]*"/, "", value) + gsub(/".*/, "", value) + print value + exit + } + } + ' "$resolved" +} + +SPM_FIXES="$ROOT/scripts/spm-shared-features-fixes.sh" +STABILITY_DOC="$ROOT/docs/SSFStability.md" +UPSTREAM_DELTA_DOC="$ROOT/docs/SSFNativeCryptoUpstreamDelta.md" +WORKSPACE_RESOLVED="$ROOT/fearless.xcworkspace/xcshareddata/swiftpm/Package.resolved" +MODULEMAP_TEMPLATE="$ROOT/scripts/deps/templates/IrohaCrypto.module.modulemap" +UMBRELLA_TEMPLATE="$ROOT/scripts/deps/templates/IrohaCrypto-umbrella.h" +LINKER_TEMPLATE="$ROOT/scripts/deps/templates/IrohaCrypto.linker-settings.swiftfrag" +EXPORT_SCRIPT="$ROOT/scripts/deps/export-native-crypto-upstream-delta.sh" +HANDOFF_EXPORT_SCRIPT="$ROOT/scripts/deps/export-shared-features-upstream-delta.sh" + +require_file "$SPM_FIXES" "shared-features compatibility patch script" +require_file "$STABILITY_DOC" "SSF stability documentation" +require_file "$UPSTREAM_DELTA_DOC" "native crypto upstream delta documentation" +require_file "$WORKSPACE_RESOLVED" "workspace Package.resolved" +require_file "$MODULEMAP_TEMPLATE" "IrohaCrypto modulemap template" +require_file "$UMBRELLA_TEMPLATE" "IrohaCrypto umbrella template" +require_file "$LINKER_TEMPLATE" "IrohaCrypto linker-settings template" +require_file "$EXPORT_SCRIPT" "native crypto delta export script" +require_file "$HANDOFF_EXPORT_SCRIPT" "shared-features upstream handoff export script" + +DELTA_IDS=( + "web3-mirror-normalization" + "ssfmodels-explicit-dependencies" + "ssfpolkaswap-explicit-dependencies" + "sorakeystore-runtime-namespace" + "ethereum-private-key-data-array" + "addressfactory-compatibility-cleanup" + "scrypt-simulator-arch-guard" + "native-crypto-sidecar-pruning" + "ssfpools-public-initializers" + "native-crypto-contract-reapply" + "strict-required-patch-accounting" +) + +DELTA_LABELS=( + "Web3 mirror normalization" + "SSFModels explicit BigInt and RobinHood dependencies" + "SSFPolkaswap explicit build dependencies" + "SoraKeystore Objective-C runtime namespace" + "EthereumPrivateKey Data to Array conversion" + "AddressFactory compatibility shim cleanup" + "scrypt simulator architecture guard" + "native crypto framework sidecar pruning" + "SSFPools public initializer export" + "native crypto package/modulemap contract reapply" + "strict required patch accounting" +) + +DELTA_PATTERNS=( + "https://github.com/soramitsu/web3-swift" + "BigInt.git" + "Updated SSFPolkaswap dependencies" + "SSFSoraKeystoreKeychain" + "EthereumPrivateKey(privateKey: Array" + "AddressFactory compatibility shims" + "__SSSE3__" + "libsr25519crust.a" + "PooledAssetInfo" + "apply_native_crypto_contracts" + "No shared-features-spm checkout was available to patch" +) + +validate_delta_metadata_contract() { + local expected_count="${#DELTA_IDS[@]}" + if [[ "${#DELTA_LABELS[@]}" -ne "$expected_count" || "${#DELTA_PATTERNS[@]}" -ne "$expected_count" ]]; then + record_failure "carried delta metadata array length mismatch" + return 1 + fi + + local i + local j + for ((i = 0; i < expected_count; i++)); do + if [[ -z "${DELTA_IDS[$i]}" ]]; then + record_failure "carried delta id must not be blank" + fi + if [[ -z "${DELTA_LABELS[$i]}" ]]; then + record_failure "carried delta label must not be blank for ${DELTA_IDS[$i]}" + fi + if [[ -z "${DELTA_PATTERNS[$i]}" ]]; then + record_failure "carried delta evidence pattern must not be blank for ${DELTA_IDS[$i]}" + fi + for ((j = i + 1; j < expected_count; j++)); do + if [[ "${DELTA_IDS[$i]}" == "${DELTA_IDS[$j]}" ]]; then + record_failure "duplicate shared-features carried delta id: ${DELTA_IDS[$i]}" + fi + done + done +} + +REMOVAL_READINESS_STATUS="blocked" +REMOVAL_REQUIRED_ACTION="Upstream or vendor every carriedDeltas entry into the pinned shared-features-spm source, then remove post-resolution checkout mutation from CI and release scripts." +REMOVAL_VERIFICATION_COMMAND='bash scripts/deps/test-shared-features-delta-report.sh && bash scripts/deps/audit-shared-features-delta-report.sh "$PWD" --write-report build/reports/shared-features-delta-report.json' +REMOVAL_BLOCKERS=( + "Pinned shared-features-spm source does not yet contain every carriedDeltas entry." + "CI still runs STRICT_REQUIRED_PATCHES=1 bash scripts/spm-shared-features-fixes.sh after package resolution." + "Native crypto package/modulemap contract reapply is still required for the pinned source." +) +REMOVAL_REQUIRED_ABSENT_MARKERS=( + "STRICT_REQUIRED_PATCHES=1 bash scripts/spm-shared-features-fixes.sh" + "scripts/deps/prepare-native-crypto-checkout.sh after package resolution" + "scripts/deps/apply-native-crypto-package-contract.sh against a resolved checkout" + "scripts/deps/apply-native-crypto-modulemap-contract.sh against a resolved checkout" +) + +if validate_delta_metadata_contract; then + for index in "${!DELTA_IDS[@]}"; do + require_pattern "$SPM_FIXES" "${DELTA_PATTERNS[$index]}" "shared-features carried delta ${DELTA_IDS[$index]} (${DELTA_LABELS[$index]})" + done +fi + +require_pattern "$SPM_FIXES" "STRICT_REQUIRED_PATCHES" "shared-features strict patch mode" +require_pattern "$SPM_FIXES" "REQUIRED_PATCH_COUNT" "shared-features required patch accounting" +require_pattern "$UPSTREAM_DELTA_DOC" "Exit condition for Milestone 3" "native crypto upstream delta exit condition" +require_pattern "$UPSTREAM_DELTA_DOC" "source already contains this delta" "native crypto upstream completion rule" +require_pattern "$UPSTREAM_DELTA_DOC" "export-native-crypto-upstream-delta.sh" "native crypto export handoff docs" +require_pattern "$UPSTREAM_DELTA_DOC" "export-shared-features-upstream-delta.sh" "shared-features upstream handoff docs" +require_pattern "$STABILITY_DOC" "scripts/deps/audit-shared-features-delta-report.sh" "SSF stability delta-report audit docs" +require_pattern "$STABILITY_DOC" "build/reports/shared-features-delta-report.json" "SSF stability delta-report artifact docs" +require_pattern "$STABILITY_DOC" "scripts/deps/export-shared-features-upstream-delta.sh" "SSF stability upstream handoff export docs" + +REVISION="" +if [[ -f "$WORKSPACE_RESOLVED" ]]; then + REVISION="$(extract_shared_features_revision "$WORKSPACE_RESOLVED")" + [[ -n "$REVISION" ]] || record_failure "Could not extract shared-features-spm revision from $WORKSPACE_RESOLVED" +fi + +write_report() { + local report_file="$1" + local report_dir + report_dir="$(dirname "$report_file")" + + if ! mkdir -p "$report_dir" 2>/dev/null; then + echo "[shared-features-delta][error] Failed to write shared-features delta report: cannot create $report_dir" >&2 + exit 1 + fi + + local tmp="${report_file}.tmp" + { + echo "{" + echo " \"schemaVersion\": 1," + echo " \"sharedFeaturesRevision\": \"$(printf '%s' "$REVISION" | json_escape)\"," + echo " \"mutatesResolvedCheckout\": true," + echo " \"exitCondition\": \"Pinned shared-features-spm source contains all carried deltas and CI no longer runs checkout mutation scripts after package resolution.\"," + echo " \"removalReadiness\": {" + echo " \"status\": \"$(printf '%s' "$REMOVAL_READINESS_STATUS" | json_escape)\"," + echo " \"requiredAction\": \"$(printf '%s' "$REMOVAL_REQUIRED_ACTION" | json_escape)\"," + echo " \"verificationCommand\": \"$(printf '%s' "$REMOVAL_VERIFICATION_COMMAND" | json_escape)\"," + echo " \"blockers\": [" + for index in "${!REMOVAL_BLOCKERS[@]}"; do + local comma="," + [[ "$index" == "$((${#REMOVAL_BLOCKERS[@]} - 1))" ]] && comma="" + echo " \"$(printf '%s' "${REMOVAL_BLOCKERS[$index]}" | json_escape)\"${comma}" + done + echo " ]," + echo " \"requiredAbsentMarkersBeforeResolved\": [" + for index in "${!REMOVAL_REQUIRED_ABSENT_MARKERS[@]}"; do + local comma="," + [[ "$index" == "$((${#REMOVAL_REQUIRED_ABSENT_MARKERS[@]} - 1))" ]] && comma="" + echo " \"$(printf '%s' "${REMOVAL_REQUIRED_ABSENT_MARKERS[$index]}" | json_escape)\"${comma}" + done + echo " ]" + echo " }," + echo " \"nativeCryptoTemplates\": [" + local template_paths=("$MODULEMAP_TEMPLATE" "$UMBRELLA_TEMPLATE" "$LINKER_TEMPLATE") + local template_names=("IrohaCrypto.module.modulemap" "IrohaCrypto-umbrella.h" "IrohaCrypto.linker-settings.swiftfrag") + for index in "${!template_paths[@]}"; do + local comma="," + [[ "$index" == "$((${#template_paths[@]} - 1))" ]] && comma="" + echo " { \"name\": \"${template_names[$index]}\", \"path\": \"$(printf '%s' "${template_paths[$index]#$ROOT/}" | json_escape)\", \"sha256\": \"$(sha256_file "${template_paths[$index]}")\" }${comma}" + done + echo " ]," + echo " \"carriedDeltas\": [" + for index in "${!DELTA_IDS[@]}"; do + local comma="," + [[ "$index" == "$((${#DELTA_IDS[@]} - 1))" ]] && comma="" + echo " { \"id\": \"${DELTA_IDS[$index]}\", \"label\": \"$(printf '%s' "${DELTA_LABELS[$index]}" | json_escape)\", \"evidence\": \"$(printf '%s' "${DELTA_PATTERNS[$index]}" | json_escape)\" }${comma}" + done + echo " ]" + echo "}" + } > "$tmp" || { + rm -f "$tmp" + echo "[shared-features-delta][error] Failed to write shared-features delta report: $report_file" >&2 + exit 1 + } + + if ! mv "$tmp" "$report_file" 2>/dev/null; then + rm -f "$tmp" + echo "[shared-features-delta][error] Failed to write shared-features delta report: $report_file" >&2 + exit 1 + fi +} + +if ((${#failures[@]} > 0)); then + echo "[shared-features-delta][error] Shared-features delta audit failed:" >&2 + for failure in "${failures[@]}"; do + echo " - $failure" >&2 + done + exit 1 +fi + +if [[ -n "$REPORT_FILE" ]]; then + write_report "$REPORT_FILE" +fi + +echo "[shared-features-delta] Audit passed: revision=${REVISION}, carriedDeltas=${#DELTA_IDS[@]}" +if [[ -n "$REPORT_FILE" ]]; then + echo "[shared-features-delta] Wrote report: $REPORT_FILE" +fi diff --git a/scripts/deps/export-native-crypto-upstream-delta.sh b/scripts/deps/export-native-crypto-upstream-delta.sh old mode 100644 new mode 100755 diff --git a/scripts/deps/export-shared-features-upstream-delta.sh b/scripts/deps/export-shared-features-upstream-delta.sh new file mode 100755 index 0000000000..67ff7470e5 --- /dev/null +++ b/scripts/deps/export-shared-features-upstream-delta.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="${1:-$(pwd)}" +OUT_DIR="${2:-$ROOT/build/shared-features-upstream-delta}" + +AUDIT_SCRIPT="$ROOT/scripts/deps/audit-shared-features-delta-report.sh" +NATIVE_EXPORT_SCRIPT="$ROOT/scripts/deps/export-native-crypto-upstream-delta.sh" + +fail() { + echo "[export-shared-features-upstream-delta] $1" >&2 + exit 1 +} + +[[ -x "$AUDIT_SCRIPT" ]] || fail "Missing executable delta report audit: $AUDIT_SCRIPT" +[[ -x "$NATIVE_EXPORT_SCRIPT" ]] || fail "Missing executable native crypto export script: $NATIVE_EXPORT_SCRIPT" + +rm -rf "$OUT_DIR" +mkdir -p "$OUT_DIR" + +REPORT_FILE="$OUT_DIR/shared-features-delta-report.json" +NATIVE_OUT_DIR="$OUT_DIR/native-crypto" + +"$AUDIT_SCRIPT" "$ROOT" --write-report "$REPORT_FILE" >/dev/null +"$NATIVE_EXPORT_SCRIPT" "$ROOT" "$NATIVE_OUT_DIR" >/dev/null + +node - "$REPORT_FILE" "$OUT_DIR/README.md" "$OUT_DIR/handoff-manifest.json" <<'NODE' +const fs = require('fs'); +const crypto = require('crypto'); + +const reportPath = process.argv[2]; +const readmePath = process.argv[3]; +const manifestPath = process.argv[4]; +const report = JSON.parse(fs.readFileSync(reportPath, 'utf8')); + +function sha256(path) { + return crypto.createHash('sha256').update(fs.readFileSync(path)).digest('hex'); +} + +function assert(condition, message) { + if (!condition) { + throw new Error(message); + } +} + +assert(report.schemaVersion === 1, 'delta report schemaVersion must be 1'); +assert(Array.isArray(report.carriedDeltas) && report.carriedDeltas.length > 0, 'delta report must contain carriedDeltas'); +assert(report.removalReadiness?.status === 'blocked', 'removalReadiness.status must remain blocked for handoff'); + +const deltaLines = report.carriedDeltas.map((item) => `- ${item.id}: ${item.label}`); +const blockerLines = report.removalReadiness.blockers.map((item) => `- ${item}`); + +fs.writeFileSync( + readmePath, + [ + '# Shared Features Upstream Delta', + '', + 'This directory is the release-review handoff for the pinned `shared-features-spm` delta still carried by the wallet repo.', + '', + `Pinned shared-features-spm revision: \`${report.sharedFeaturesRevision}\``, + '', + '## Contents', + '', + '- `shared-features-delta-report.json`: machine-readable inventory of every carried delta, removal blockers, and native crypto template hashes.', + '- `native-crypto/`: portable native-crypto package-state export for upstreaming or vendoring.', + '- `handoff-manifest.json`: deterministic checksum manifest for this handoff bundle.', + '', + '## Carried Deltas', + '', + ...deltaLines, + '', + '## Removal Blockers', + '', + ...blockerLines, + '', + '## Exit Condition', + '', + report.exitCondition, + '' + ].join('\n') +); + +const files = [ + 'shared-features-delta-report.json', + 'native-crypto/README.md', + 'native-crypto/Sources/IrohaCrypto/include/module.modulemap', + 'native-crypto/Sources/IrohaCrypto/include/IrohaCrypto-umbrella.h', + 'native-crypto/Sources/IrohaCrypto/IrohaCrypto-umbrella.h', + 'native-crypto/IrohaCrypto.linker-settings.swiftfrag', + 'README.md' +]; + +const baseDir = manifestPath.replace(/\/handoff-manifest\.json$/, ''); +const manifest = { + schemaVersion: 1, + sharedFeaturesRevision: report.sharedFeaturesRevision, + removalReadinessStatus: report.removalReadiness.status, + carriedDeltaCount: report.carriedDeltas.length, + files: files.map((relativePath) => { + const absolutePath = `${baseDir}/${relativePath}`; + assert(fs.existsSync(absolutePath), `missing handoff file: ${relativePath}`); + return { + path: relativePath, + sha256: sha256(absolutePath) + }; + }) +}; + +fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); +NODE + +echo "[export-shared-features-upstream-delta] Exported shared-features upstream delta to $OUT_DIR" diff --git a/scripts/deps/native-crypto-checkout-roots.sh b/scripts/deps/native-crypto-checkout-roots.sh index 82082d97bc..d64f00fd04 100644 --- a/scripts/deps/native-crypto-checkout-roots.sh +++ b/scripts/deps/native-crypto-checkout-roots.sh @@ -7,7 +7,7 @@ native_crypto_checkout_candidates() { local seen="|" local candidate - explicit_root="$(dirname "$source_packages_dir")/SourcePackages/checkouts/shared-features-spm" + explicit_root="$source_packages_dir/checkouts/shared-features-spm" emit_unique_existing() { local path="$1" diff --git a/scripts/deps/prepare-native-crypto-checkout.sh b/scripts/deps/prepare-native-crypto-checkout.sh old mode 100644 new mode 100755 diff --git a/scripts/deps/test-shared-features-delta-report.sh b/scripts/deps/test-shared-features-delta-report.sh new file mode 100755 index 0000000000..8ae348dde2 --- /dev/null +++ b/scripts/deps/test-shared-features-delta-report.sh @@ -0,0 +1,301 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +AUDIT_SCRIPT="$SCRIPT_DIR/audit-shared-features-delta-report.sh" + +fail() { + echo "[shared-features-delta-test][error] $*" >&2 + exit 1 +} + +write_file() { + local file="$1" + shift + mkdir -p "$(dirname "$file")" + printf '%s\n' "$@" > "$file" +} + +write_fixture() { + local root="$1" + mkdir -p \ + "$root/scripts/deps/templates" \ + "$root/fearless.xcworkspace/xcshareddata/swiftpm" \ + "$root/docs" + + write_file "$root/fearless.xcworkspace/xcshareddata/swiftpm/Package.resolved" \ + '{' \ + ' "pins" : [' \ + ' {' \ + ' "identity" : "shared-features-spm",' \ + ' "state" : {' \ + ' "revision" : "3ad0fe928333c9ac28972e3669ca733c6972f060"' \ + ' }' \ + ' }' \ + ' ]' \ + '}' + + write_file "$root/scripts/deps/templates/IrohaCrypto.module.modulemap" \ + 'framework module IrohaCrypto {' \ + ' umbrella "."' \ + ' export *' \ + ' module * { export * }' \ + '}' + + write_file "$root/scripts/deps/templates/IrohaCrypto-umbrella.h" \ + '// Temporary umbrella header to satisfy IrohaCrypto module.modulemap' \ + '#import ' + + write_file "$root/scripts/deps/templates/IrohaCrypto.linker-settings.swiftfrag" \ + ' linkerSettings: [' \ + ' .linkedFramework("sorawallet")' \ + ' ]' + + write_file "$root/scripts/deps/export-native-crypto-upstream-delta.sh" \ + '#!/usr/bin/env bash' \ + 'set -euo pipefail' \ + 'root="${1:?root required}"' \ + 'out="${2:?out required}"' \ + 'mkdir -p "$out/Sources/IrohaCrypto/include" "$out/Sources/IrohaCrypto"' \ + 'cp "$root/scripts/deps/templates/IrohaCrypto.module.modulemap" "$out/Sources/IrohaCrypto/include/module.modulemap"' \ + 'cp "$root/scripts/deps/templates/IrohaCrypto-umbrella.h" "$out/Sources/IrohaCrypto/include/IrohaCrypto-umbrella.h"' \ + 'cp "$root/scripts/deps/templates/IrohaCrypto-umbrella.h" "$out/Sources/IrohaCrypto/IrohaCrypto-umbrella.h"' \ + 'cp "$root/scripts/deps/templates/IrohaCrypto.linker-settings.swiftfrag" "$out/IrohaCrypto.linker-settings.swiftfrag"' \ + 'printf "%s\n" "# Native Crypto Upstream Delta" > "$out/README.md"' \ + 'echo export-native-crypto-upstream-delta.sh' + chmod +x "$root/scripts/deps/export-native-crypto-upstream-delta.sh" + + write_file "$root/scripts/deps/export-shared-features-upstream-delta.sh" \ + '#!/usr/bin/env bash' \ + 'echo export-shared-features-upstream-delta.sh' + chmod +x "$root/scripts/deps/export-shared-features-upstream-delta.sh" + + write_file "$root/docs/SSFNativeCryptoUpstreamDelta.md" \ + '# SSF Native Crypto Upstream Delta' \ + 'Use export-native-crypto-upstream-delta.sh for upstream handoff.' \ + 'Use export-shared-features-upstream-delta.sh for the full shared-features handoff.' \ + '## Exit condition for Milestone 3' \ + 'Milestone 3 is complete when the pinned shared-features-spm source already contains this delta.' + + write_file "$root/docs/SSFStability.md" \ + '# SSF Stability' \ + 'Run scripts/deps/audit-shared-features-delta-report.sh --write-report build/reports/shared-features-delta-report.json.' \ + 'Run scripts/deps/export-shared-features-upstream-delta.sh to create the full upstream handoff bundle.' \ + 'Review build/reports/shared-features-delta-report.json before release.' + + write_file "$root/scripts/spm-shared-features-fixes.sh" \ + '#!/usr/bin/env bash' \ + 'STRICT_REQUIRED_PATCHES="${STRICT_REQUIRED_PATCHES:-0}"' \ + 'REQUIRED_PATCH_COUNT=0' \ + 'echo "https://github.com/soramitsu/web3-swift"' \ + 'echo "BigInt.git"' \ + 'echo "Updated SSFPolkaswap dependencies"' \ + 'echo "SSFSoraKeystoreKeychain"' \ + 'echo "EthereumPrivateKey(privateKey: Array"' \ + 'echo "AddressFactory compatibility shims"' \ + 'echo "__SSSE3__"' \ + 'echo "libsr25519crust.a"' \ + 'echo "PooledAssetInfo"' \ + 'apply_native_crypto_contracts() { :; }' \ + 'echo "No shared-features-spm checkout was available to patch"' + chmod +x "$root/scripts/spm-shared-features-fixes.sh" + + cp "$AUDIT_SCRIPT" "$root/scripts/deps/audit-shared-features-delta-report.sh" + chmod +x "$root/scripts/deps/audit-shared-features-delta-report.sh" +} + +expect_failure() { + local name="$1" + local expected="$2" + shift 2 + + local output + set +e + output="$("$AUDIT_SCRIPT" "$@" 2>&1)" + local status=$? + set -e + + if [[ "$status" -eq 0 ]]; then + echo "$output" >&2 + fail "$name unexpectedly passed" + fi + + if [[ "$output" != *"$expected"* ]]; then + echo "$output" >&2 + fail "$name did not report expected text: $expected" + fi +} + +expect_script_failure() { + local name="$1" + local expected="$2" + local script="$3" + shift 3 + + local output + set +e + output="$("$script" "$@" 2>&1)" + local status=$? + set -e + + if [[ "$status" -eq 0 ]]; then + echo "$output" >&2 + fail "$name unexpectedly passed" + fi + + if [[ "$output" != *"$expected"* ]]; then + echo "$output" >&2 + fail "$name did not report expected text: $expected" + fi +} + +tmp_dir="$(mktemp -d)" +trap 'rm -rf "$tmp_dir"' EXIT + +fixture="$tmp_dir/repo" +write_fixture "$fixture" + +report="$tmp_dir/shared-features-delta-report.json" +"$AUDIT_SCRIPT" "$fixture" --write-report "$report" >/dev/null + +node - "$report" <<'NODE' +const fs = require('fs'); +const report = JSON.parse(fs.readFileSync(process.argv[2], 'utf8')); + +function assert(condition, message) { + if (!condition) { + throw new Error(message); + } +} + +assert(report.schemaVersion === 1, 'schemaVersion must be 1'); +assert(report.sharedFeaturesRevision === '3ad0fe928333c9ac28972e3669ca733c6972f060', 'revision must be reported'); +assert(report.mutatesResolvedCheckout === true, 'checkout mutation status must be explicit'); +assert(report.nativeCryptoTemplates.length === 3, 'native crypto template hashes must be reported'); +assert(report.nativeCryptoTemplates.every((item) => /^[0-9a-f]{64}$/.test(item.sha256)), 'template hashes must be SHA-256 values'); +assert(report.carriedDeltas.length === 11, 'all carried deltas must be reported'); +assert(new Set(report.carriedDeltas.map((item) => item.id)).size === report.carriedDeltas.length, 'carried delta ids must be unique'); +assert(report.carriedDeltas.some((item) => item.id === 'sorakeystore-runtime-namespace'), 'SoraKeystore delta must be reported'); +assert(report.exitCondition.includes('CI no longer runs checkout mutation scripts'), 'exit condition must describe mutation removal'); +assert(report.removalReadiness.status === 'blocked', 'removalReadiness status must remain blocked while checkout mutation is still required'); +assert(report.removalReadiness.requiredAction.includes('carriedDeltas'), 'removalReadiness required action must reference carried deltas'); +assert(report.removalReadiness.verificationCommand.includes('audit-shared-features-delta-report.sh'), 'removalReadiness verification command must name the audit'); +assert(report.removalReadiness.blockers.length >= 3, 'removalReadiness blockers must be explicit'); +assert(report.removalReadiness.requiredAbsentMarkersBeforeResolved.some((item) => item.includes('scripts/spm-shared-features-fixes.sh')), 'removalReadiness must name the mutation marker that has to disappear'); +NODE + +handoff_dir="$tmp_dir/shared-features-upstream-delta" +bash "$SCRIPT_DIR/export-shared-features-upstream-delta.sh" "$fixture" "$handoff_dir" >/dev/null + +node - "$handoff_dir" <<'NODE' +const fs = require('fs'); +const path = require('path'); + +const dir = process.argv[2]; +const manifest = JSON.parse(fs.readFileSync(path.join(dir, 'handoff-manifest.json'), 'utf8')); +const readme = fs.readFileSync(path.join(dir, 'README.md'), 'utf8'); + +function assert(condition, message) { + if (!condition) { + throw new Error(message); + } +} + +assert(manifest.schemaVersion === 1, 'handoff manifest schemaVersion must be 1'); +assert(manifest.sharedFeaturesRevision === '3ad0fe928333c9ac28972e3669ca733c6972f060', 'handoff manifest revision must match the report'); +assert(manifest.removalReadinessStatus === 'blocked', 'handoff manifest must preserve blocked removal readiness'); +assert(manifest.carriedDeltaCount === 11, 'handoff manifest must report all carried deltas'); +assert(manifest.files.some((item) => item.path === 'shared-features-delta-report.json'), 'handoff manifest must include the delta report'); +assert(manifest.files.some((item) => item.path === 'native-crypto/Sources/IrohaCrypto/include/module.modulemap'), 'handoff manifest must include native crypto modulemap'); +assert(manifest.files.every((item) => /^[0-9a-f]{64}$/.test(item.sha256)), 'handoff manifest hashes must be SHA-256 values'); +assert(readme.includes('sorakeystore-runtime-namespace'), 'handoff README must list carried deltas'); +assert(readme.includes('Pinned shared-features-spm revision'), 'handoff README must name the pinned revision'); +NODE + +missing_sorakeystore="$tmp_dir/missing-sorakeystore" +cp -R "$fixture" "$missing_sorakeystore" +perl -0pi -e 's/SSFSoraKeystoreKeychain/SoraKeystoreKeychain/' "$missing_sorakeystore/scripts/spm-shared-features-fixes.sh" +expect_failure \ + "missing SoraKeystore delta" \ + "sorakeystore-runtime-namespace" \ + "$missing_sorakeystore" + +missing_strict="$tmp_dir/missing-strict" +cp -R "$fixture" "$missing_strict" +perl -0pi -e 's/No shared-features-spm checkout was available to patch/no checkout marker removed/' "$missing_strict/scripts/spm-shared-features-fixes.sh" +expect_failure \ + "missing strict patch accounting" \ + "strict-required-patch-accounting" \ + "$missing_strict" + +duplicate_delta_id_script="$tmp_dir/audit-duplicate-delta-id.sh" +cp "$AUDIT_SCRIPT" "$duplicate_delta_id_script" +chmod +x "$duplicate_delta_id_script" +perl -0pi -e 's/"strict-required-patch-accounting"/"native-crypto-contract-reapply"/' "$duplicate_delta_id_script" +expect_script_failure \ + "duplicate carried delta id" \ + "duplicate shared-features carried delta id: native-crypto-contract-reapply" \ + "$duplicate_delta_id_script" \ + "$fixture" + +metadata_length_mismatch_script="$tmp_dir/audit-delta-metadata-length-mismatch.sh" +cp "$AUDIT_SCRIPT" "$metadata_length_mismatch_script" +chmod +x "$metadata_length_mismatch_script" +perl -0pi -e 's/"strict-required-patch-accounting"/"strict-required-patch-accounting"\n "unexpected-extra-delta-id"/' "$metadata_length_mismatch_script" +expect_script_failure \ + "carried delta metadata length mismatch" \ + "carried delta metadata array length mismatch" \ + "$metadata_length_mismatch_script" \ + "$fixture" + +missing_template="$tmp_dir/missing-template" +cp -R "$fixture" "$missing_template" +rm "$missing_template/scripts/deps/templates/IrohaCrypto.module.modulemap" +expect_failure \ + "missing native crypto template" \ + "IrohaCrypto modulemap template missing" \ + "$missing_template" + +missing_exit_doc="$tmp_dir/missing-exit-doc" +cp -R "$fixture" "$missing_exit_doc" +perl -0pi -e 's/Exit condition for Milestone 3/Completion rule removed/' "$missing_exit_doc/docs/SSFNativeCryptoUpstreamDelta.md" +expect_failure \ + "missing upstream exit condition" \ + "native crypto upstream delta exit condition" \ + "$missing_exit_doc" + +missing_handoff_doc="$tmp_dir/missing-handoff-doc" +cp -R "$fixture" "$missing_handoff_doc" +perl -0pi -e 's/Use export-shared-features-upstream-delta.sh for the full shared-features handoff.//' "$missing_handoff_doc/docs/SSFNativeCryptoUpstreamDelta.md" +expect_failure \ + "missing upstream handoff doc" \ + "shared-features upstream handoff docs" \ + "$missing_handoff_doc" + +missing_handoff_script="$tmp_dir/missing-handoff-script" +cp -R "$fixture" "$missing_handoff_script" +rm "$missing_handoff_script/scripts/deps/export-shared-features-upstream-delta.sh" +expect_failure \ + "missing upstream handoff export script" \ + "shared-features upstream handoff export script missing" \ + "$missing_handoff_script" + +missing_native_export="$tmp_dir/missing-native-export" +cp -R "$fixture" "$missing_native_export" +rm "$missing_native_export/scripts/deps/export-native-crypto-upstream-delta.sh" +expect_script_failure \ + "handoff export without native export" \ + "Missing executable native crypto export script" \ + "$SCRIPT_DIR/export-shared-features-upstream-delta.sh" \ + "$missing_native_export" \ + "$tmp_dir/missing-native-handoff" + +blocked_report_parent="$tmp_dir/blocked-report-parent" +printf locked > "$blocked_report_parent" +expect_failure \ + "unwritable report path" \ + "Failed to write shared-features delta report" \ + "$fixture" \ + --write-report "$blocked_report_parent/report.json" + +echo "[shared-features-delta-test] all tests passed" diff --git a/scripts/deps/verify-native-crypto-package-state.sh b/scripts/deps/verify-native-crypto-package-state.sh old mode 100644 new mode 100755 diff --git a/scripts/spm-shared-features-fixes.sh b/scripts/spm-shared-features-fixes.sh old mode 100644 new mode 100755 index 74e4f4f280..79e0ff4db3 --- a/scripts/spm-shared-features-fixes.sh +++ b/scripts/spm-shared-features-fixes.sh @@ -10,37 +10,46 @@ set -euo pipefail BASE_DIR="${1:-$(pwd)}" SOURCE_PACKAGES_DIR="${SOURCE_PACKAGES_DIR:-$BASE_DIR/SourcePackages}" -SOURCE_PACKAGES_BASE="$(dirname "$SOURCE_PACKAGES_DIR")" ALLOW_DERIVEDDATA_FALLBACK="${ALLOW_DERIVEDDATA_FALLBACK:-0}" STRICT_REQUIRED_PATCHES="${STRICT_REQUIRED_PATCHES:-0}" REQUIRED_PATCH_COUNT=0 CHECKOUT_HELPER="$BASE_DIR/scripts/deps/native-crypto-checkout-roots.sh" -each_checkout_base() { +each_checkout_root() { local package_root + local seen="|" local saw_any=0 - # Always include the caller-provided SourcePackages base first. - printf '%s\n' "$SOURCE_PACKAGES_BASE" + + emit_unique_checkout_root() { + local path="$1" + + [[ -d "$path" ]] || return 0 + [[ "$seen" != *"|$path|"* ]] || return 0 + + printf '%s\n' "$path" + seen="${seen}${path}|" + saw_any=1 + } + + emit_unique_checkout_root "$SOURCE_PACKAGES_DIR/checkouts/shared-features-spm" if [[ -f "$CHECKOUT_HELPER" ]]; then # shellcheck source=/dev/null source "$CHECKOUT_HELPER" while IFS= read -r package_root; do - saw_any=1 - printf '%s\n' "$(dirname "$(dirname "$(dirname "$package_root")")")" + emit_unique_checkout_root "$package_root" done < <(native_crypto_checkout_candidates "$BASE_DIR" "$SOURCE_PACKAGES_DIR") fi if [[ "$saw_any" == "0" && "$ALLOW_DERIVEDDATA_FALLBACK" == "1" ]]; then for dd in "$HOME/Library/Developer/Xcode/DerivedData"/* "$BASE_DIR/DerivedData"/*; do - [[ -d "$dd/SourcePackages/checkouts/shared-features-spm" ]] || continue - printf '%s\n' "$dd" + emit_unique_checkout_root "$dd/SourcePackages/checkouts/shared-features-spm" done fi } patch_manifest() { - local pkg_swift="$1/SourcePackages/checkouts/shared-features-spm/Package.swift" + local pkg_swift="$1/Package.swift" if [[ ! -f "$pkg_swift" ]]; then echo "[spm-fixes] Package.swift not found at $pkg_swift (skip)" return 0 @@ -128,11 +137,87 @@ patch_manifest() { } -while IFS= read -r checkout_base; do - patch_manifest "$checkout_base" -done < <(each_checkout_base) +while IFS= read -r checkout_root; do + patch_manifest "$checkout_root" +done < <(each_checkout_root) -echo "[spm-fixes] Completed shared-features-spm fixes" +# shared-features-spm vendors a SoraKeystore target while CocoaPods brings the +# real SoraKeystore framework through SoraFoundation. The Swift module names are +# intentionally left unchanged for source compatibility, but the vendored SPM +# classes need unique Objective-C runtime names to avoid duplicate-class loading +# in the app/test host. +patch_sora_keystore_runtime_names() { + local base="$1/Sources/SoraKeystore" + [[ -d "$base" ]] || return 0 + + echo "[spm-fixes] Namespacing bundled SoraKeystore Objective-C runtime classes under $base" + chmod -R u+w "$base" 2>/dev/null || true + + local keychain="$base/Classes/Keychain/Keychain.swift" + if [[ -f "$keychain" ]]; then + /usr/bin/perl -0pi -e ' + s/^\(SSFSoraKeystoreKeychain\)$/\@objc(SSFSoraKeystoreKeychain)/m; + s/public class Keychain: KeystoreProtocol/\@objc(SSFSoraKeystoreKeychain)\npublic class Keychain: NSObject, KeystoreProtocol/s; + s/public init\(\) \{\}/public override init() {}/s; + ' "$keychain" || true + if /usr/bin/grep -q "@objc(SSFSoraKeystoreKeychain)" "$keychain"; then + echo "[spm-fixes] Namespaced SoraKeystore Keychain runtime class" + fi + fi + + local keychain_manager="$base/Classes/Keychain/KeychainManager.swift" + if [[ -f "$keychain_manager" ]]; then + /usr/bin/perl -0pi -e ' + s/^\(SSFSoraKeystoreKeychainManager\)$/\@objc(SSFSoraKeystoreKeychainManager)/m; + s/public class KeychainManager \{/\@objc(SSFSoraKeystoreKeychainManager)\npublic class KeychainManager: NSObject {/s; + ' "$keychain_manager" || true + if /usr/bin/grep -q "@objc(SSFSoraKeystoreKeychainManager)" "$keychain_manager"; then + echo "[spm-fixes] Namespaced SoraKeystore KeychainManager runtime class" + fi + fi + + local in_memory_keychain="$base/Classes/Keychain/InMemoryKeychain.swift" + if [[ -f "$in_memory_keychain" ]]; then + /usr/bin/perl -0pi -e ' + s/^\(SSFSoraKeystoreInMemoryKeychain\)$/\@objc(SSFSoraKeystoreInMemoryKeychain)/m; + s/public final class InMemoryKeychain: KeystoreProtocol/\@objc(SSFSoraKeystoreInMemoryKeychain)\npublic final class InMemoryKeychain: NSObject, KeystoreProtocol/s; + s/public init\(\) \{\}/public override init() {}/s; + ' "$in_memory_keychain" || true + if /usr/bin/grep -q "@objc(SSFSoraKeystoreInMemoryKeychain)" "$in_memory_keychain"; then + echo "[spm-fixes] Namespaced SoraKeystore InMemoryKeychain runtime class" + fi + fi + + local settings_manager="$base/Classes/UserDefaults/SettingsManager.swift" + if [[ -f "$settings_manager" ]]; then + /usr/bin/perl -0pi -e ' + s/^\(SSFSoraKeystoreSettingsManager\)$/\@objc(SSFSoraKeystoreSettingsManager)/m; + s/public class SettingsManager: SettingsManagerProtocol/\@objc(SSFSoraKeystoreSettingsManager)\npublic class SettingsManager: NSObject, SettingsManagerProtocol/s; + s/private init\(\) \{\}/private override init() {}/s; + ' "$settings_manager" || true + if /usr/bin/grep -q "@objc(SSFSoraKeystoreSettingsManager)" "$settings_manager"; then + echo "[spm-fixes] Namespaced SoraKeystore SettingsManager runtime class" + fi + fi + + local in_memory_settings="$base/Classes/UserDefaults/InMemorySettingsManager.swift" + if [[ -f "$in_memory_settings" ]]; then + /usr/bin/perl -0pi -e ' + s/^\(SSFSoraKeystoreInMemorySettingsManager\)$/\@objc(SSFSoraKeystoreInMemorySettingsManager)/m; + s/public final class InMemorySettingsManager: SettingsManagerProtocol/\@objc(SSFSoraKeystoreInMemorySettingsManager)\npublic final class InMemorySettingsManager: NSObject, SettingsManagerProtocol/s; + s/public init\(\) \{\}/public override init() {}/s; + ' "$in_memory_settings" || true + if /usr/bin/grep -q "@objc(SSFSoraKeystoreInMemorySettingsManager)" "$in_memory_settings"; then + echo "[spm-fixes] Namespaced SoraKeystore InMemorySettingsManager runtime class" + fi + fi +} + +while IFS= read -r checkout_root; do + patch_sora_keystore_runtime_names "$checkout_root" +done < <(each_checkout_root) + +echo "[spm-fixes] Namespaced bundled SoraKeystore runtime classes" # 2) Patch Web3 EthereumPrivateKey initializers to accept Data as [UInt8] patch_private_key_calls() { @@ -166,15 +251,15 @@ patch_private_key_calls() { echo "[spm-fixes] Patched $patched file(s) under $root" } -while IFS= read -r checkout_base; do - patch_private_key_calls "$checkout_base" -done < <(each_checkout_base) +while IFS= read -r checkout_root; do + patch_private_key_calls "$checkout_root" +done < <(each_checkout_root) echo "[spm-fixes] Completed EthereumPrivateKey call patches (with verification)" # 3) Normalize SSFCrypto AddressFactory compatibility without inventing new types patch_address_factory_struct() { - local base_checkout="$1/SourcePackages/checkouts/shared-features-spm" + local base_checkout="$1" local file="$base_checkout/Sources/SSFCrypto/Classes/AddressConversion.swift" # Ensure sources are writable (avoid permission denied when editing) if [[ -d "$base_checkout/Sources" ]]; then @@ -193,15 +278,15 @@ patch_address_factory_struct() { # Leave enum-based AddressFactory definitions intact. Downstream rewrites handle call sites explicitly. } -while IFS= read -r checkout_base; do - patch_address_factory_struct "$checkout_base" -done < <(each_checkout_base) +while IFS= read -r checkout_root; do + patch_address_factory_struct "$checkout_root" +done < <(each_checkout_root) echo "[spm-fixes] Normalized SSFCrypto AddressFactory compatibility shims" # 4) Patch scrypt SIMD selection/headers to avoid simulator arch issues patch_scrypt_sse2_guard() { - local base="$1/SourcePackages/checkouts/shared-features-spm/Sources/scrypt" + local base="$1/Sources/scrypt" local file="$base/crypto_scrypt.c" local header="$base/include/scrypt.h" [[ -f "$file" ]] || return 0 @@ -217,9 +302,9 @@ patch_scrypt_sse2_guard() { fi } -while IFS= read -r checkout_base; do - patch_scrypt_sse2_guard "$checkout_base" -done < <(each_checkout_base) +while IFS= read -r checkout_root; do + patch_scrypt_sse2_guard "$checkout_root" +done < <(each_checkout_root) echo "[spm-fixes] Applied scrypt simulator arch guard patch" @@ -227,7 +312,7 @@ echo "[spm-fixes] Applied scrypt simulator arch guard patch" # Xcode's embed step bitcode-strips every file in these framework bundles. The extra # *.a payloads are not needed for app embedding and currently trip builtin-copy. prune_native_crypto_framework_sidecars() { - local binaries_root="$1/SourcePackages/checkouts/shared-features-spm/Binaries" + local binaries_root="$1/Binaries" local framework_dir [[ -d "$binaries_root" ]] || return 0 @@ -252,15 +337,15 @@ prune_native_crypto_framework_sidecars() { done } -while IFS= read -r checkout_base; do - prune_native_crypto_framework_sidecars "$checkout_base" -done < <(each_checkout_base) +while IFS= read -r checkout_root; do + prune_native_crypto_framework_sidecars "$checkout_root" +done < <(each_checkout_root) echo "[spm-fixes] Pruned native crypto framework sidecar archives" # 6) SSFPolkaswap: make addressFactory a type reference when used as a dependency token patch_polkaswap_addressfactory_usage() { - local base_checkout="$1/SourcePackages/checkouts/shared-features-spm/Sources/SSFPolkaswap" + local base_checkout="$1/Sources/SSFPolkaswap" [[ -d "$base_checkout" ]] || return 0 echo "[spm-fixes] Normalizing SSFPolkaswap addressFactory usage under $base_checkout" chmod -R u+w "$base_checkout" 2>/dev/null || true @@ -309,9 +394,9 @@ patch_polkaswap_addressfactory_usage() { done } -while IFS= read -r checkout_base; do - patch_polkaswap_addressfactory_usage "$checkout_base" -done < <(each_checkout_base) +while IFS= read -r checkout_root; do + patch_polkaswap_addressfactory_usage "$checkout_root" +done < <(each_checkout_root) echo "[spm-fixes] Patched SSFPolkaswap addressFactory usage (type tokens)" @@ -340,7 +425,7 @@ echo "[spm-fixes] Cleaned stale embedded native crypto frameworks" # Newer shared-features-spm revisions keep memberwise inits internal, which breaks # app-side construction and previously led to recursive compatibility shims. patch_ssfpools_public_initializers() { - local base_checkout="$1/SourcePackages/checkouts/shared-features-spm/Sources/SSFPools" + local base_checkout="$1/Sources/SSFPools" local pooled="$base_checkout/PooledAssetInfo.swift" local supply="$base_checkout/SupplyLiquidityInfo.swift" local remove="$base_checkout/RemoveLiquidityInfo.swift" @@ -370,9 +455,9 @@ patch_ssfpools_public_initializers() { fi } -while IFS= read -r checkout_base; do - patch_ssfpools_public_initializers "$checkout_base" -done < <(each_checkout_base) +while IFS= read -r checkout_root; do + patch_ssfpools_public_initializers "$checkout_root" +done < <(each_checkout_root) apply_native_crypto_contracts() { local package_contract="$BASE_DIR/scripts/deps/apply-native-crypto-package-contract.sh" @@ -414,3 +499,4 @@ if [[ "$STRICT_REQUIRED_PATCHES" == "1" && "$REQUIRED_PATCH_COUNT" -eq 0 ]]; the fi echo "[spm-fixes] Required checkout patch count: $REQUIRED_PATCH_COUNT" +echo "[spm-fixes] Completed shared-features-spm fixes" diff --git a/scripts/test-branch-flow-audit.sh b/scripts/test-branch-flow-audit.sh new file mode 100755 index 0000000000..2942834996 --- /dev/null +++ b/scripts/test-branch-flow-audit.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +AUDIT_SCRIPT="$SCRIPT_DIR/audit-branch-flow.sh" +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TMP_DIR"' EXIT + +write_valid_repo() { + local repo="$1" + mkdir -p "$repo/.github/workflows" + cat > "$repo/Jenkinsfile" <<'EOF' +def appPipeline = new org.ios.AppPipeline( + uploadToNexusFor: ['master','develop'] +) +EOF + cat > "$repo/.github/workflows/codecov.yml" <<'EOF' +name: Codecov Fearless +on: + push: + branches: + - develop + - master + pull_request: + branches: + - develop + - master +jobs: + build: + runs-on: macOS-latest + steps: + - run: echo build + jenkins-pr-merge: + name: continuous-integration/jenkins/pr-merge + runs-on: ubuntu-latest + needs: build + if: always() + steps: + - run: | + if [[ "${{ needs.build.result }}" != "success" ]]; then + exit 1 + fi +EOF +} + +expect_pass() { + local repo="$1" + bash "$AUDIT_SCRIPT" "$repo" >/dev/null +} + +expect_fail() { + local repo="$1" + local label="$2" + local output="$TMP_DIR/$label.out" + if bash "$AUDIT_SCRIPT" "$repo" >"$output" 2>&1; then + echo "[branch-flow-audit-test] ERROR: expected failure for $label" >&2 + exit 1 + fi + if ! grep -q "ERROR" "$output"; then + echo "[branch-flow-audit-test] ERROR: $label failed without an audit error" >&2 + cat "$output" >&2 + exit 1 + fi +} + +VALID_REPO="$TMP_DIR/valid" +write_valid_repo "$VALID_REPO" +expect_pass "$VALID_REPO" + +JENKINS_STAGING_REPO="$TMP_DIR/jenkins-staging" +write_valid_repo "$JENKINS_STAGING_REPO" +cat > "$JENKINS_STAGING_REPO/Jenkinsfile" <<'EOF' +def appPipeline = new org.ios.AppPipeline( + uploadToNexusFor: ['master','develop','staging'] +) +EOF +expect_fail "$JENKINS_STAGING_REPO" "jenkins-staging" + +WORKFLOW_STAGING_REPO="$TMP_DIR/workflow-staging" +write_valid_repo "$WORKFLOW_STAGING_REPO" +cat > "$WORKFLOW_STAGING_REPO/.github/workflows/codecov.yml" <<'EOF' +name: Codecov Fearless +on: + push: + branches: + - develop + - master + - staging +EOF +expect_fail "$WORKFLOW_STAGING_REPO" "workflow-staging" + +MISSING_UPLOAD_REPO="$TMP_DIR/missing-upload" +write_valid_repo "$MISSING_UPLOAD_REPO" +cat > "$MISSING_UPLOAD_REPO/Jenkinsfile" <<'EOF' +def appPipeline = new org.ios.AppPipeline( + appTests: false +) +EOF +expect_fail "$MISSING_UPLOAD_REPO" "missing-upload" + +MISSING_JENKINS_CONTEXT_REPO="$TMP_DIR/missing-jenkins-context" +write_valid_repo "$MISSING_JENKINS_CONTEXT_REPO" +perl -0pi -e 's/\n jenkins-pr-merge:.*//s' "$MISSING_JENKINS_CONTEXT_REPO/.github/workflows/codecov.yml" +expect_fail "$MISSING_JENKINS_CONTEXT_REPO" "missing-jenkins-context" + +DETACHED_JENKINS_CONTEXT_REPO="$TMP_DIR/detached-jenkins-context" +write_valid_repo "$DETACHED_JENKINS_CONTEXT_REPO" +perl -0pi -e 's/\n needs: build//' "$DETACHED_JENKINS_CONTEXT_REPO/.github/workflows/codecov.yml" +expect_fail "$DETACHED_JENKINS_CONTEXT_REPO" "detached-jenkins-context" + +UNGUARDED_JENKINS_CONTEXT_REPO="$TMP_DIR/unguarded-jenkins-context" +write_valid_repo "$UNGUARDED_JENKINS_CONTEXT_REPO" +perl -0pi -e 's/needs\.build\.result/build.result.removed/' "$UNGUARDED_JENKINS_CONTEXT_REPO/.github/workflows/codecov.yml" +expect_fail "$UNGUARDED_JENKINS_CONTEXT_REPO" "unguarded-jenkins-context" + +echo "[branch-flow-audit-test] Branch flow audit self-test passed." diff --git a/scripts/test-private-overlay-boundary.sh b/scripts/test-private-overlay-boundary.sh new file mode 100755 index 0000000000..fdaed2caef --- /dev/null +++ b/scripts/test-private-overlay-boundary.sh @@ -0,0 +1,193 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +AUDIT_SCRIPT="$ROOT_DIR/scripts/audit-private-overlay-boundary.sh" + +fail() { + echo "[private-overlay-audit-test][error] $*" >&2 + exit 1 +} + +write_file() { + local repo="$1" + local path="$2" + local content="$3" + + mkdir -p "$(dirname "$repo/$path")" + printf '%s\n' "$content" > "$repo/$path" +} + +init_repo() { + local repo="$1" + git init -q "$repo" + git -C "$repo" config user.email "overlay-audit@example.invalid" + git -C "$repo" config user.name "Overlay Audit Test" +} + +track_all() { + local repo="$1" + git -C "$repo" add . +} + +run_audit() { + local public_repo="$1" + local private_repo="$2" + PUBLIC_REPO_DIR="$public_repo" PRIVATE_REPO_DIR="$private_repo" bash "$AUDIT_SCRIPT" +} + +run_audit_with_report() { + local public_repo="$1" + local private_repo="$2" + local report="$3" + shift 3 + + PUBLIC_REPO_DIR="$public_repo" PRIVATE_REPO_DIR="$private_repo" PRIVATE_OVERLAY_REPORT="$report" "$@" bash "$AUDIT_SCRIPT" +} + +make_pair() { + local dir="$1" + local public_repo="$dir/public" + local private_repo="$dir/private" + + mkdir -p "$public_repo" "$private_repo" + init_repo "$public_repo" + init_repo "$private_repo" + + write_file "$public_repo" "fearless/Common/Model/PublicFeature.swift" "public feature" + write_file "$public_repo" "README.md" "public readme" + write_file "$private_repo" "README.md" "private release readme" + + track_all "$public_repo" + track_all "$private_repo" + + printf '%s\n' "$public_repo" + printf '%s\n' "$private_repo" +} + +test_allows_release_overlay_paths() { + local dir="$1/allowed" + local repos + repos="$(make_pair "$dir")" + local public_repo + public_repo="$(printf '%s\n' "$repos" | sed -n '1p')" + local private_repo + private_repo="$(printf '%s\n' "$repos" | sed -n '2p')" + + write_file "$private_repo" "fearless/CIKeys.stencil" "struct CIKeys {}" + write_file "$private_repo" "fearless/fearless.entitlements" "" + write_file "$private_repo" "fearless/Configs/fearless.release.xcconfig" "RELEASE=1" + write_file "$private_repo" "scripts/secrets/app-store-connect.json" '{"release":true}' + track_all "$private_repo" + + run_audit "$public_repo" "$private_repo" >/dev/null +} + +test_rejects_private_only_product_code() { + local dir="$1/private-only-product" + local repos + repos="$(make_pair "$dir")" + local public_repo + public_repo="$(printf '%s\n' "$repos" | sed -n '1p')" + local private_repo + private_repo="$(printf '%s\n' "$repos" | sed -n '2p')" + local output="$dir/output.txt" + + write_file "$private_repo" "fearless/Modules/Send/PrivateTransfer.swift" "private product code" + track_all "$private_repo" + + if run_audit "$public_repo" "$private_repo" > "$output" 2>&1; then + fail "expected private-only product code to fail" + fi + + grep -q $'A\tfearless/Modules/Send/PrivateTransfer.swift' "$output" || + fail "expected added product path in audit output" +} + +test_rejects_tracked_public_product_code() { + local dir="$1/tracked-public-product" + local repos + repos="$(make_pair "$dir")" + local public_repo + public_repo="$(printf '%s\n' "$repos" | sed -n '1p')" + local private_repo + private_repo="$(printf '%s\n' "$repos" | sed -n '2p')" + local output="$dir/output.txt" + + write_file "$private_repo" "fearless/Common/Model/PublicFeature.swift" "public feature" + track_all "$private_repo" + + if run_audit "$public_repo" "$private_repo" > "$output" 2>&1; then + fail "expected tracked public product code to fail" + fi + + grep -q $'T\tfearless/Common/Model/PublicFeature.swift' "$output" || + fail "expected tracked public product path in audit output" +} + +test_rejects_modified_public_product_code() { + local dir="$1/modified-product" + local repos + repos="$(make_pair "$dir")" + local public_repo + public_repo="$(printf '%s\n' "$repos" | sed -n '1p')" + local private_repo + private_repo="$(printf '%s\n' "$repos" | sed -n '2p')" + local output="$dir/output.txt" + + write_file "$private_repo" "fearless/Common/Model/PublicFeature.swift" "modified private feature" + track_all "$private_repo" + + if run_audit "$public_repo" "$private_repo" > "$output" 2>&1; then + fail "expected modified public product code to fail" + fi + + grep -q $'M\tfearless/Common/Model/PublicFeature.swift' "$output" || + fail "expected modified product path in audit output" +} + +test_writes_full_report_when_output_is_truncated() { + local dir="$1/full-report" + local repos + repos="$(make_pair "$dir")" + local public_repo + public_repo="$(printf '%s\n' "$repos" | sed -n '1p')" + local private_repo + private_repo="$(printf '%s\n' "$repos" | sed -n '2p')" + local output="$dir/output.txt" + local report="$dir/private-overlay-report.tsv" + + local i + for i in 1 2 3 4 5; do + write_file "$private_repo" "fearless/Modules/Send/PrivateTransfer$i.swift" "private product code $i" + done + track_all "$private_repo" + + if run_audit_with_report "$public_repo" "$private_repo" "$report" env MAX_REPORT_LINES=2 > "$output" 2>&1; then + fail "expected many private-only product paths to fail" + fi + + grep -q "Output truncated at 2 paths" "$output" || + fail "expected truncated console output notice" + grep -q "Full report: $report" "$output" || + fail "expected full report path in audit output" + [[ -f "$report" ]] || fail "expected full report file" + + local report_count + report_count="$(wc -l < "$report" | tr -d '[:space:]')" + [[ "$report_count" == "5" ]] || + fail "expected full report to include all 5 failures, got $report_count" + grep -q $'A\tfearless/Modules/Send/PrivateTransfer5.swift' "$report" || + fail "expected full report to include entries omitted from console output" +} + +tmpdir="$(mktemp -d)" +trap 'rm -rf "$tmpdir"' EXIT + +test_allows_release_overlay_paths "$tmpdir" +test_rejects_private_only_product_code "$tmpdir" +test_rejects_tracked_public_product_code "$tmpdir" +test_rejects_modified_public_product_code "$tmpdir" +test_writes_full_report_when_output_is_truncated "$tmpdir" + +echo "[private-overlay-audit-test] iOS overlay audit self-test passed." diff --git a/scripts/test-todo-debt-audit.sh b/scripts/test-todo-debt-audit.sh new file mode 100755 index 0000000000..477aa9cd6f --- /dev/null +++ b/scripts/test-todo-debt-audit.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +AUDIT_SCRIPT="$SCRIPT_DIR/audit-todo-debt.sh" + +fail() { + echo "[todo-audit-test][error] $*" >&2 + exit 1 +} + +make_fixture_root() { + local root="$1" + mkdir -p "$root/fearless" "$root/config" +} + +run_audit() { + local root="$1" + TODO_AUDIT_ROOT="$root" TODO_AUDIT_BASELINE="$root/config/todo-debt-baseline.tsv" bash "$AUDIT_SCRIPT" +} + +expect_failure() { + local name="$1" + local root="$2" + local expected="$3" + local output + + set +e + output="$(run_audit "$root" 2>&1)" + local status=$? + set -e + + if [[ "$status" -eq 0 ]]; then + echo "$output" >&2 + fail "$name unexpectedly passed" + fi + + if [[ "$output" != *"$expected"* ]]; then + echo "$output" >&2 + fail "$name did not report expected text: $expected" + fi +} + +tmp_dir="$(mktemp -d)" +trap 'rm -rf "$tmp_dir"' EXIT + +pass_root="$tmp_dir/pass" +make_fixture_root "$pass_root" +printf '%s\n' 'final class ExistingDebt { // TODO existing baseline marker' '}' > "$pass_root/fearless/ExistingDebt.swift" +printf '%s\t%s\n' 'fearless/ExistingDebt.swift' 'final class ExistingDebt { // TODO existing baseline marker' > "$pass_root/config/todo-debt-baseline.tsv" +run_audit "$pass_root" >/dev/null +PATH="/usr/bin:/bin:/usr/sbin:/sbin" run_audit "$pass_root" >/dev/null + +new_root="$tmp_dir/new-marker" +make_fixture_root "$new_root" +printf '%s\n' 'final class ExistingDebt { // TODO existing baseline marker' '}' > "$new_root/fearless/ExistingDebt.swift" +printf '%s\n' 'final class NewDebt { // FIXME new marker must fail' '}' > "$new_root/fearless/NewDebt.swift" +printf '%s\t%s\n' 'fearless/ExistingDebt.swift' 'final class ExistingDebt { // TODO existing baseline marker' > "$new_root/config/todo-debt-baseline.tsv" +expect_failure "new marker fixture" "$new_root" "New TODO/FIXME/STOPSHIP markers" + +stale_root="$tmp_dir/stale-baseline" +make_fixture_root "$stale_root" +printf '%s\n' 'final class CleanSource {}' > "$stale_root/fearless/CleanSource.swift" +printf '%s\t%s\n' 'fearless/OldDebt.swift' 'final class OldDebt { // TODO deleted marker' > "$stale_root/config/todo-debt-baseline.tsv" +expect_failure "stale baseline fixture" "$stale_root" "Baseline entries no longer exist" + +duplicate_root="$tmp_dir/duplicate-baseline" +make_fixture_root "$duplicate_root" +printf '%s\n' 'final class DuplicateDebt { // TODO duplicate baseline marker' 'final class DuplicateDebt { // TODO duplicate baseline marker' > "$duplicate_root/fearless/DuplicateDebt.swift" +printf '%s\t%s\n%s\t%s\n' \ + 'fearless/DuplicateDebt.swift' 'final class DuplicateDebt { // TODO duplicate baseline marker' \ + 'fearless/DuplicateDebt.swift' 'final class DuplicateDebt { // TODO duplicate baseline marker' \ + > "$duplicate_root/config/todo-debt-baseline.tsv" +expect_failure "duplicate baseline fixture" "$duplicate_root" "Duplicate TODO debt baseline entries are forbidden" + +crash_root="$tmp_dir/crashing-placeholder" +make_fixture_root "$crash_root" +printf '%s\n' 'final class Crashy {' ' func crash() { fatalError("TODO wire implementation") }' '}' > "$crash_root/fearless/Crashy.swift" +printf '%s\t%s\n' 'fearless/Crashy.swift' 'func crash() { fatalError("TODO wire implementation") }' > "$crash_root/config/todo-debt-baseline.tsv" +expect_failure "crashing placeholder fixture" "$crash_root" "Crashing TODO/FIXME placeholders are forbidden" + +echo "[todo-audit-test] all tests passed" diff --git a/scripts/test-transaction-builder-tests-audit.sh b/scripts/test-transaction-builder-tests-audit.sh new file mode 100755 index 0000000000..e9a7812948 --- /dev/null +++ b/scripts/test-transaction-builder-tests-audit.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +AUDIT_SCRIPT="$SCRIPT_DIR/audit-transaction-builder-tests.sh" + +fail() { + echo "[ios-transaction-builder-audit-test][error] $*" >&2 + exit 1 +} + +write_file() { + local path="$1" + shift + mkdir -p "$(dirname "$path")" + printf '%s\n' "$@" > "$path" +} + +write_valid_fixture() { + local root="$1" + + write_file "$root/fearlessTests/BitcoinTransactionBuilderTests.swift" \ + "final class BitcoinTransactionBuilderTests {" \ + " func testBuildsAndSignsMainnetP2wpkhTransactionMatchingWebVector() {}" \ + " func testRejectsUnsafeUtxosOutputsFeesAndChangeHandlingBeforeSigning() {}" \ + " func testRejectsUtxosThatDoNotMatchDerivedBip84KeyOrWitnessScript() {}" \ + " func testRejectsEmptyMnemonicBeforeSigning() {}" \ + "}" + + write_file "$root/fearlessTests/SolanaTransferTransactionBuilderTests.swift" \ + "final class SolanaTransferTransactionBuilderTests {" \ + " func testBuildsCanonicalLegacySystemTransferTransactionEnvelope() {}" \ + " func testRejectsMalformedTransferParameters() {}" \ + " func testBuildsCanonicalSPLTokenTransferCheckedTransactionEnvelope() {}" \ + " func testBuildsToken2022TransferCheckedWithExtensionExtraAccountsInInstructionOrder() {}" \ + " func testBuildsTokenSendWithIdempotentAssociatedTokenAccountCreateThenTransferChecked() {}" \ + "}" + + write_file "$root/fearless/ApplicationLayer/Services/Transfer/Tokens/TransferService.swift" \ + "protocol IrohaTransferSigning {}" \ + "struct IrohaTransferSigningRequest {}" \ + "struct IrohaSignedTransfer {}" + + write_file "$root/fearlessTests/ApplicationLayer/Services/FeatureToggle/TonChainSelectionTests.swift" \ + "final class SendDependencyContainerUniversalWalletRoutingTests {" \ + " func testIrohaTransferServiceBuildsSignerRequestAndSubmitsNorito() {}" \ + " func testIrohaTransferServiceDefaultSignerFailsClosedAfterValidation() {}" \ + " func testIrohaTransferServiceRejectsMnemonicMismatchBeforeSignerOrToriiCalls() {}" \ + " func testPrepareDependenciesFailsClosedForTonCompatibilityTransferBeforeSubstrateRouting() {}" \ + "}" + + write_file "$root/fearless/Modules/Send/SendDependencyContainer.swift" \ + "if chainAsset.chain.isTonCompatibilityChain {" \ + " throw ConvenienceError(error: \"TON transfer not yet supported.\")" \ + "}" +} + +run_audit() { + local root="$1" + IOS_TX_BUILDER_AUDIT_ROOT="$root" bash "$AUDIT_SCRIPT" +} + +expect_success() { + local root="$1" + if ! run_audit "$root" >/dev/null; then + fail "valid fixture unexpectedly failed" + fi +} + +expect_failure() { + local name="$1" + local root="$2" + local expected="$3" + local output + + set +e + output="$(run_audit "$root" 2>&1)" + local status=$? + set -e + + if [[ "$status" -eq 0 ]]; then + echo "$output" >&2 + fail "$name unexpectedly passed" + fi + + if [[ "$output" != *"$expected"* ]]; then + echo "$output" >&2 + fail "$name did not report expected text: $expected" + fi +} + +tmp_dir="$(mktemp -d)" +trap 'rm -rf "$tmp_dir"' EXIT + +valid="$tmp_dir/valid" +write_valid_fixture "$valid" +expect_success "$valid" + +missing_bitcoin="$tmp_dir/missing-bitcoin" +write_valid_fixture "$missing_bitcoin" +perl -0pi -e 's/testRejectsUnsafeUtxosOutputsFeesAndChangeHandlingBeforeSigning/testRejectsSomeBitcoinInput/' "$missing_bitcoin/fearlessTests/BitcoinTransactionBuilderTests.swift" +expect_failure "missing Bitcoin adversarial test" "$missing_bitcoin" "Bitcoin unsafe UTXO/output/fee adversarial test" + +missing_solana="$tmp_dir/missing-solana" +write_valid_fixture "$missing_solana" +perl -0pi -e 's/testBuildsToken2022TransferCheckedWithExtensionExtraAccountsInInstructionOrder/testBuildsToken2022Transfer/' "$missing_solana/fearlessTests/SolanaTransferTransactionBuilderTests.swift" +expect_failure "missing Solana Token-2022 test" "$missing_solana" "Solana Token-2022 extra-account ordering test" + +missing_iroha="$tmp_dir/missing-iroha" +write_valid_fixture "$missing_iroha" +perl -0pi -e 's/testIrohaTransferServiceDefaultSignerFailsClosedAfterValidation/testIrohaTransferServiceDefaultSigner/' "$missing_iroha/fearlessTests/ApplicationLayer/Services/FeatureToggle/TonChainSelectionTests.swift" +expect_failure "missing Iroha fail-closed test" "$missing_iroha" "Iroha default signer fail-closed test" + +missing_ton="$tmp_dir/missing-ton" +write_valid_fixture "$missing_ton" +perl -0pi -e 's/isTonCompatibilityChain/isSomeOtherChain/' "$missing_ton/fearless/Modules/Send/SendDependencyContainer.swift" +expect_failure "missing TON fail-closed guard" "$missing_ton" "TON compatibility transfer guard" + +echo "[ios-transaction-builder-audit-test] all tests passed"