Compare commits
6 Commits
master
...
catalystfi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a27f7722c4 | ||
|
|
426faaae79 | ||
|
|
298c7df42f | ||
|
|
de186df003 | ||
|
|
e45dfebacc | ||
|
|
cfb1b2a4bc |
@ -30,7 +30,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout Project
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0 # Ensures the full Git history is
|
||||
|
||||
@ -490,7 +490,7 @@ jobs:
|
||||
BRANCH_NAME: ${{ needs.build.outputs.branch_name }}
|
||||
steps:
|
||||
- name: Checkout Project
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set Up Ruby
|
||||
uses: ruby/setup-ruby@c4e5b1316158f92e3d49443a9d58b31d25ac0f8f # v1.306.0
|
||||
|
||||
83
.github/workflows/build-mac-catalyst.yml
vendored
83
.github/workflows/build-mac-catalyst.yml
vendored
@ -2,10 +2,13 @@ name: Build Mac Catalyst
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
types: [labeled, synchronize]
|
||||
types: [opened, reopened, synchronize, labeled]
|
||||
|
||||
concurrency:
|
||||
group: catalyst-build-${{ github.event.pull_request.number || github.run_id }}
|
||||
@ -13,10 +16,6 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: >
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event.action == 'labeled' && (github.event.label.name == 'mac-dmg' || github.event.label.name == 'testflight')) ||
|
||||
github.event.action == 'synchronize'
|
||||
runs-on: macos-15
|
||||
timeout-minutes: 120
|
||||
|
||||
@ -29,56 +28,39 @@ jobs:
|
||||
run: |
|
||||
LABELS=$(gh api "repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/labels" --jq '.[].name' | tr '\n' ',')
|
||||
echo "all=${LABELS}" >> $GITHUB_OUTPUT
|
||||
if [[ "$LABELS" == *"mac-dmg"* ]]; then
|
||||
echo "has_mac_dmg=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "has_mac_dmg=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
if [[ "$LABELS" == *"testflight"* ]] && [[ "$LABELS" == *"mac-dmg"* ]]; then
|
||||
if [[ "$LABELS" == *"testflight"* ]]; then
|
||||
echo "upload_testflight=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "upload_testflight=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
echo "Labels on PR: ${LABELS}"
|
||||
|
||||
- name: Skip if mac-dmg label not present
|
||||
if: github.event_name == 'pull_request' && steps.labels.outputs.has_mac_dmg != 'true'
|
||||
run: |
|
||||
echo "mac-dmg label not found on PR — skipping build."
|
||||
exit 0
|
||||
|
||||
- name: Checkout project
|
||||
if: github.event_name == 'workflow_dispatch' || steps.labels.outputs.has_mac_dmg == 'true'
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
if: github.event_name == 'workflow_dispatch' || steps.labels.outputs.has_mac_dmg == 'true'
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 24
|
||||
cache: 'npm'
|
||||
|
||||
- name: Setup Xcode
|
||||
if: github.event_name == 'workflow_dispatch' || steps.labels.outputs.has_mac_dmg == 'true'
|
||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0
|
||||
with:
|
||||
xcode-version: latest
|
||||
|
||||
- name: Set up Ruby
|
||||
if: github.event_name == 'workflow_dispatch' || steps.labels.outputs.has_mac_dmg == 'true'
|
||||
uses: ruby/setup-ruby@c4e5b1316158f92e3d49443a9d58b31d25ac0f8f # v1.306.0
|
||||
with:
|
||||
ruby-version: 3.4.9
|
||||
bundler-cache: true
|
||||
|
||||
- name: Install Node modules
|
||||
if: github.event_name == 'workflow_dispatch' || steps.labels.outputs.has_mac_dmg == 'true'
|
||||
run: npm ci
|
||||
|
||||
- name: Cache CocoaPods
|
||||
if: github.event_name == 'workflow_dispatch' || steps.labels.outputs.has_mac_dmg == 'true'
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: |
|
||||
@ -90,7 +72,6 @@ jobs:
|
||||
${{ runner.os }}-pods-catalyst-
|
||||
|
||||
- name: Install CocoaPods dependencies
|
||||
if: github.event_name == 'workflow_dispatch' || steps.labels.outputs.has_mac_dmg == 'true'
|
||||
env:
|
||||
SKIP_APP_STORE_CONNECT_AUTH: '1'
|
||||
RCT_USE_RN_DEP: "1"
|
||||
@ -98,62 +79,74 @@ jobs:
|
||||
run: bundle exec fastlane ios install_pods
|
||||
|
||||
- name: Create temporary keychain for signing
|
||||
if: (github.event_name == 'workflow_dispatch' || steps.labels.outputs.has_mac_dmg == 'true') && steps.labels.outputs.upload_testflight == 'true'
|
||||
run: |
|
||||
security create-keychain -p "${{ secrets.KEYCHAIN_PASSWORD }}" build.keychain
|
||||
security default-keychain -s build.keychain
|
||||
security unlock-keychain -p "${{ secrets.KEYCHAIN_PASSWORD }}" build.keychain
|
||||
security set-keychain-settings -t 3600 -u build.keychain
|
||||
|
||||
- name: Create App Store Connect API Key JSON
|
||||
env:
|
||||
APPLE_API_KEY_CONTENT: ${{ secrets.APPLE_API_KEY_CONTENT || secrets.APP_STORE_CONNECT_API_KEY_CONTENT }}
|
||||
run: |
|
||||
if [[ -n "${APPLE_API_KEY_CONTENT}" ]]; then
|
||||
printf '%s' "${APPLE_API_KEY_CONTENT}" > ./appstore_api_key.json
|
||||
echo "Created appstore_api_key.json"
|
||||
else
|
||||
echo "No App Store Connect API key content provided; skipping key file creation."
|
||||
fi
|
||||
|
||||
- name: Build Mac Catalyst app with Fastlane
|
||||
if: github.event_name == 'workflow_dispatch' || steps.labels.outputs.has_mac_dmg == 'true'
|
||||
id: build_catalyst
|
||||
run: bundle exec fastlane ios build_catalyst_app_lane
|
||||
env:
|
||||
SKIP_APP_STORE_CONNECT_AUTH: '1'
|
||||
SKIP_CLEAR_DERIVED_DATA: '1'
|
||||
CATALYST_SIGNING_IDENTITY: ${{ steps.labels.outputs.upload_testflight == 'true' && secrets.CATALYST_SIGNING_IDENTITY || '' }}
|
||||
CATALYST_TEAM_ID: ${{ steps.labels.outputs.upload_testflight == 'true' && secrets.CATALYST_TEAM_ID || '' }}
|
||||
GIT_URL: ${{ steps.labels.outputs.upload_testflight == 'true' && secrets.GIT_URL || '' }}
|
||||
GIT_ACCESS_TOKEN: ${{ steps.labels.outputs.upload_testflight == 'true' && secrets.GIT_ACCESS_TOKEN || '' }}
|
||||
CATALYST_SIGNING_IDENTITY: ${{ secrets.CATALYST_SIGNING_IDENTITY || 'Apple Distribution' }}
|
||||
CATALYST_TEAM_ID: ${{ secrets.CATALYST_TEAM_ID || secrets.ITC_TEAM_ID || secrets.TEAM_ID }}
|
||||
GIT_URL: ${{ secrets.GIT_URL }}
|
||||
GIT_ACCESS_TOKEN: ${{ secrets.GIT_ACCESS_TOKEN }}
|
||||
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
|
||||
MATCH_READONLY: ${{ steps.labels.outputs.upload_testflight == 'true' && 'false' || 'true' }}
|
||||
KEYCHAIN_NAME: ${{ steps.labels.outputs.upload_testflight == 'true' && 'build' || '' }}
|
||||
KEYCHAIN_NAME: build
|
||||
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
||||
CATALYST_NOTARIZE: ${{ (((secrets.CATALYST_SIGNING_IDENTITY != '' || 'Apple Distribution' != '') && (secrets.CATALYST_TEAM_ID != '' || secrets.ITC_TEAM_ID != '' || secrets.TEAM_ID != '')) && (((secrets.APPLE_API_KEY_CONTENT != '' || secrets.APP_STORE_CONNECT_API_KEY_CONTENT != '') && (secrets.APPLE_API_KEY_ID != '' || secrets.APP_STORE_CONNECT_API_KEY_KEY_ID != '') && (secrets.APPLE_API_ISSUER_ID != '' || secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID != '')) || (secrets.APPLE_ID != '' && (secrets.APPLE_APP_SPECIFIC_PASSWORD != '' || secrets.FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD != '')))) && '1' || '0' }}
|
||||
APPLE_API_KEY_PATH: ./appstore_api_key.json
|
||||
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID || secrets.APP_STORE_CONNECT_API_KEY_KEY_ID }}
|
||||
APPLE_API_ISSUER_ID: ${{ secrets.APPLE_API_ISSUER_ID || secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD || secrets.FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD }}
|
||||
|
||||
- name: Upload Mac Catalyst DMG
|
||||
id: upload_dmg
|
||||
if: success() && (github.event_name == 'workflow_dispatch' || steps.labels.outputs.has_mac_dmg == 'true')
|
||||
if: success()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: BlueWallet-Mac-Catalyst
|
||||
path: ${{ steps.build_catalyst.outputs.catalyst_dmg_path }}
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Create App Store Connect API Key JSON
|
||||
if: success() && steps.labels.outputs.upload_testflight == 'true'
|
||||
run: echo '${{ secrets.APPLE_API_KEY_CONTENT }}' > ./appstore_api_key.json
|
||||
|
||||
- name: Upload to TestFlight
|
||||
if: success() && steps.labels.outputs.upload_testflight == 'true'
|
||||
if: success() && github.event_name == 'pull_request' && steps.labels.outputs.upload_testflight == 'true'
|
||||
run: bundle exec fastlane ios upload_catalyst_to_testflight
|
||||
env:
|
||||
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
|
||||
APPLE_API_ISSUER_ID: ${{ secrets.APPLE_API_ISSUER_ID }}
|
||||
CATALYST_TEAM_ID: ${{ secrets.CATALYST_TEAM_ID }}
|
||||
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID || secrets.APP_STORE_CONNECT_API_KEY_KEY_ID }}
|
||||
APPLE_API_ISSUER_ID: ${{ secrets.APPLE_API_ISSUER_ID || secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }}
|
||||
CATALYST_TEAM_ID: ${{ secrets.CATALYST_TEAM_ID || secrets.ITC_TEAM_ID || secrets.TEAM_ID }}
|
||||
TEAM_ID: ${{ secrets.TEAM_ID }}
|
||||
BRANCH_NAME: ${{ github.head_ref || github.ref_name }}
|
||||
LATEST_COMMIT_MESSAGE: ${{ github.event.pull_request.title || 'Manual build' }}
|
||||
|
||||
- name: Cleanup App Store Connect API Key JSON
|
||||
if: always() && steps.labels.outputs.upload_testflight == 'true'
|
||||
if: always()
|
||||
run: rm -f ./appstore_api_key.json
|
||||
|
||||
- name: Cleanup temporary keychain
|
||||
if: always() && steps.labels.outputs.upload_testflight == 'true'
|
||||
if: always()
|
||||
run: security delete-keychain build.keychain || true
|
||||
|
||||
- name: Comment on PR with DMG link
|
||||
if: success() && github.event_name == 'pull_request' && steps.labels.outputs.has_mac_dmg == 'true'
|
||||
if: success() && github.event_name == 'pull_request'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
UPLOADED_TO_TF: ${{ steps.labels.outputs.upload_testflight }}
|
||||
@ -175,7 +168,7 @@ jobs:
|
||||
if [[ -n "$TF_LINE" ]]; then
|
||||
printf '%s\n' "${TF_LINE}"
|
||||
fi
|
||||
printf '<sub>Built from `%s`"
|
||||
printf '<sub>Built from `%s`</sub>\n' "${{ github.sha }}"
|
||||
} >"${COMMENT_FILE}"
|
||||
|
||||
gh api "repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments" \
|
||||
|
||||
4
.github/workflows/build-release-apk.yml
vendored
4
.github/workflows/build-release-apk.yml
vendored
@ -15,7 +15,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout project
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: "0"
|
||||
|
||||
@ -135,7 +135,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Ruby
|
||||
uses: ruby/setup-ruby@c4e5b1316158f92e3d49443a9d58b31d25ac0f8f # v1.306.0
|
||||
|
||||
8
.github/workflows/ci.yml
vendored
8
.github/workflows/ci.yml
vendored
@ -14,7 +14,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout project
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@ -34,7 +34,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout project
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@ -53,7 +53,6 @@ jobs:
|
||||
BIP47_HD_MNEMONIC: ${{ secrets.BIP47_HD_MNEMONIC}}
|
||||
HD_MNEMONIC: ${{ secrets.HD_MNEMONIC }}
|
||||
HD_MNEMONIC_BIP49: ${{ secrets.HD_MNEMONIC_BIP49 }}
|
||||
HD_MNEMONIC_OLD: ${{ secrets.HD_MNEMONIC_OLD }}
|
||||
HD_MNEMONIC_BIP49_MANY_TX: ${{ secrets.HD_MNEMONIC_BIP49_MANY_TX }}
|
||||
HD_MNEMONIC_BIP84: ${{ secrets.HD_MNEMONIC_BIP84 }}
|
||||
HD_MNEMONIC_BREAD: ${{ secrets.HD_MNEMONIC_BREAD }}
|
||||
@ -65,7 +64,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout project
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@ -84,7 +83,6 @@ jobs:
|
||||
BIP47_HD_MNEMONIC: ${{ secrets.BIP47_HD_MNEMONIC}}
|
||||
HD_MNEMONIC: ${{ secrets.HD_MNEMONIC }}
|
||||
HD_MNEMONIC_BIP49: ${{ secrets.HD_MNEMONIC_BIP49 }}
|
||||
HD_MNEMONIC_OLD: ${{ secrets.HD_MNEMONIC_OLD }}
|
||||
HD_MNEMONIC_BIP49_MANY_TX: ${{ secrets.HD_MNEMONIC_BIP49_MANY_TX }}
|
||||
HD_MNEMONIC_BIP84: ${{ secrets.HD_MNEMONIC_BIP84 }}
|
||||
HD_MNEMONIC_BREAD: ${{ secrets.HD_MNEMONIC_BREAD }}
|
||||
|
||||
4
.github/workflows/e2e-android.yml
vendored
4
.github/workflows/e2e-android.yml
vendored
@ -15,7 +15,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Free disk space (Ubuntu)
|
||||
run: |
|
||||
@ -86,7 +86,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Free disk space (Ubuntu)
|
||||
run: |
|
||||
|
||||
14
.github/workflows/e2e-ios.yml
vendored
14
.github/workflows/e2e-ios.yml
vendored
@ -19,7 +19,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
@ -168,7 +168,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
@ -194,6 +194,9 @@ jobs:
|
||||
mkdir -p ios/build/Build/Products/Release-iphonesimulator
|
||||
tar -xzf BlueWallet.app.tar.gz -C ios/build/Build/Products/Release-iphonesimulator
|
||||
|
||||
- name: Disable simulator animations
|
||||
run: defaults write com.apple.iphonesimulator SlowMotionAnimation -bool NO
|
||||
|
||||
# Pre-boot simulator so first detox launchApp lands warm.
|
||||
- name: Pre-boot iOS simulator
|
||||
run: |
|
||||
@ -207,13 +210,6 @@ jobs:
|
||||
xcrun simctl bootstatus "$UDID" -b
|
||||
xcrun simctl launch "$UDID" com.apple.springboard >/dev/null 2>&1 || true
|
||||
|
||||
# Cut animations so detox sync stays steady on slow CI VMs; Reduce Motion makes reanimated skip to final value.
|
||||
- name: Disable simulator animations
|
||||
run: |
|
||||
defaults write com.apple.iphonesimulator SlowMotionAnimation -bool NO
|
||||
xcrun simctl spawn booted defaults write com.apple.Accessibility ReduceMotionEnabled -bool true
|
||||
xcrun simctl spawn booted notifyutil -p com.apple.Accessibility.ReduceMotionStatusDidChange
|
||||
|
||||
- name: Run detox tests
|
||||
timeout-minutes: 360
|
||||
run: |
|
||||
|
||||
2
Gemfile
2
Gemfile
@ -7,7 +7,7 @@ gem "fastlane", "~> 2.234.0"
|
||||
gem 'cocoapods', '>= 1.13', '!= 1.15.0', '!= 1.15.1'
|
||||
gem 'activesupport', '>= 6.1.7.5', '!= 7.1.0'
|
||||
gem 'xcodeproj', '< 1.26.0'
|
||||
gem 'concurrent-ruby', '< 1.3.8'
|
||||
gem 'concurrent-ruby', '< 1.3.4'
|
||||
|
||||
# Ruby 3.4.0 removed these from the standard library
|
||||
gem 'bigdecimal'
|
||||
|
||||
@ -87,7 +87,7 @@ GEM
|
||||
colored2 (3.1.2)
|
||||
commander (4.6.0)
|
||||
highline (~> 2.0.0)
|
||||
concurrent-ruby (1.3.7)
|
||||
concurrent-ruby (1.3.3)
|
||||
connection_pool (3.0.2)
|
||||
csv (3.3.5)
|
||||
declarative (0.0.20)
|
||||
@ -337,7 +337,7 @@ DEPENDENCIES
|
||||
benchmark
|
||||
bigdecimal
|
||||
cocoapods (>= 1.13, != 1.15.1, != 1.15.0)
|
||||
concurrent-ruby (< 1.3.8)
|
||||
concurrent-ruby (< 1.3.4)
|
||||
fastlane (~> 2.234.0)
|
||||
fastlane-plugin-browserstack
|
||||
fastlane-plugin-bugsnag
|
||||
@ -377,7 +377,7 @@ CHECKSUMS
|
||||
colored (1.2) sha256=9d82b47ac589ce7f6cab64b1f194a2009e9fd00c326a5357321f44afab2c1d2c
|
||||
colored2 (3.1.2) sha256=b13c2bd7eeae2cf7356a62501d398e72fde78780bd26aec6a979578293c28b4a
|
||||
commander (4.6.0) sha256=7d1ddc3fccae60cc906b4131b916107e2ef0108858f485fdda30610c0f2913d9
|
||||
concurrent-ruby (1.3.7) sha256=4412caec3a5ea2e5fdc52076724c071a81f2c0593d83b2ac8cbb8ca63b3151b0
|
||||
concurrent-ruby (1.3.3) sha256=4f9cd28965c4dcf83ffd3ea7304f9323277be8525819cb18a3b61edcb56a7c6a
|
||||
connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a
|
||||
csv (3.3.5) sha256=6e5134ac3383ef728b7f02725d9872934f523cb40b961479f69cf3afa6c8e73f
|
||||
declarative (0.0.20) sha256=8021dd6cb17ab2b61233c56903d3f5a259c5cf43c80ff332d447d395b17d9ff9
|
||||
|
||||
@ -87,7 +87,7 @@ android {
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 1
|
||||
versionName "8.0.1"
|
||||
versionName "8.0.0"
|
||||
testBuildType System.getProperty('testBuildType', 'debug')
|
||||
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
|
||||
// Keep compatibility across react-native-capture-protection flavor changes.
|
||||
|
||||
@ -14,8 +14,6 @@ import com.facebook.react.ReactNativeHost
|
||||
import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative
|
||||
import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost
|
||||
import com.facebook.react.defaults.DefaultReactNativeHost
|
||||
import com.facebook.drawee.backends.pipeline.Fresco
|
||||
import com.facebook.react.modules.fresco.FrescoModule
|
||||
import com.facebook.react.modules.i18nmanager.I18nUtil
|
||||
import io.bluewallet.bluewallet.components.segmentedcontrol.SegmentedControlPackage
|
||||
|
||||
@ -99,13 +97,6 @@ class MainApplication : Application(), ReactApplication {
|
||||
|
||||
val sharedI18nUtilInstance = I18nUtil.getInstance()
|
||||
sharedI18nUtilInstance.allowRTL(applicationContext, true)
|
||||
|
||||
// Initialize Fresco before RN mounts views. FrescoModule init can lag behind the first
|
||||
// frame (e.g. UnlockWith logo) when OkHttp/SSL warms up network security config.
|
||||
if (!FrescoModule.hasBeenInitialized()) {
|
||||
Fresco.initialize(this)
|
||||
}
|
||||
|
||||
loadReactNative(this)
|
||||
|
||||
initializeDeviceUID()
|
||||
|
||||
@ -57,13 +57,6 @@ allprojects {
|
||||
maven {
|
||||
url("$rootDir/../node_modules/detox/Detox-android")
|
||||
}
|
||||
// react-native-background-fetch ships com.transistorsoft:tsbackgroundfetch
|
||||
// as a bundled local Maven repo; the package's own build.gradle adds it
|
||||
// for itself, but :app's runtime classpath resolution needs it visible
|
||||
// at the root level too.
|
||||
maven {
|
||||
url("$rootDir/../node_modules/react-native-background-fetch/android/libs")
|
||||
}
|
||||
|
||||
mavenCentral {
|
||||
// We don't want to fetch react-native from Maven Central as there are
|
||||
@ -92,17 +85,6 @@ if (buildscript != null) {
|
||||
}
|
||||
|
||||
subprojects { project ->
|
||||
// react-native-device-info's androidTest classpath pulls
|
||||
// play-services-iid:16.0.1 -> play-services-base:16.0.1 -> support-v4:26.1.0,
|
||||
// which collides with androidx.core:core:1.13.1 (Duplicate class
|
||||
// android.support.v4.app.INotificationSideChannel). Exclude the pre-AndroidX
|
||||
// support-* modules so the AndroidX equivalents in core win.
|
||||
configurations.all {
|
||||
exclude group: 'com.android.support', module: 'support-compat'
|
||||
exclude group: 'com.android.support', module: 'support-annotations'
|
||||
exclude group: 'com.android.support', module: 'support-core-utils'
|
||||
}
|
||||
|
||||
// Remove and block any jcenter() repositories at both project and buildscript levels
|
||||
def scrub = { repoContainer ->
|
||||
repoContainer.all { repo ->
|
||||
|
||||
@ -1,7 +1,4 @@
|
||||
module.exports = {
|
||||
// Pin the @babel/runtime version so Metro resolves a single copy instead of
|
||||
// bundling duplicate helpers, which bloats the bundle.
|
||||
// See https://github.com/babel/babel/issues/18050
|
||||
presets: [['module:@react-native/babel-preset', { enableBabelRuntime: '^7.26.0' }]],
|
||||
presets: ['module:@react-native/babel-preset'],
|
||||
plugins: ['react-native-worklets/plugin'],
|
||||
};
|
||||
|
||||
@ -1,71 +0,0 @@
|
||||
// Per-wallet Realm storage for notification-suppression entries.
|
||||
//
|
||||
// Lives inside the per-wallet Arkade Realm so suppression state is
|
||||
// bucket-scoped, encrypted by the wallet's existing Realm key, and removed
|
||||
// automatically when the wallet is deleted (deleteArkadeRealm tears down the
|
||||
// whole file). Avoids leaking a stable per-wallet handle into a global
|
||||
// AsyncStorage key.
|
||||
|
||||
export type ArkSwapNotificationAction = 'claim' | 'refund';
|
||||
|
||||
// Realm schema. `realm` is a peer dependency we don't import here directly;
|
||||
// the schema is a plain object consumed by realmInstance.ts via the schemas
|
||||
// array. Pattern matches BoltzSwapSchema in @arkade-os/boltz-swap.
|
||||
export const ArkSwapNotificationSuppressionSchema = {
|
||||
name: 'ArkSwapNotificationSuppression',
|
||||
primaryKey: 'id',
|
||||
properties: {
|
||||
id: 'string',
|
||||
swapId: 'string',
|
||||
action: 'string',
|
||||
postedAt: 'int',
|
||||
},
|
||||
};
|
||||
|
||||
const compositeId = (swapId: string, action: ArkSwapNotificationAction): string => `${swapId}:${action}`;
|
||||
|
||||
interface ArkSwapNotificationSuppressionRow {
|
||||
id: string;
|
||||
swapId: string;
|
||||
action: ArkSwapNotificationAction;
|
||||
postedAt: number;
|
||||
}
|
||||
|
||||
export class RealmNotificationSuppressionRepository {
|
||||
private readonly realm: any;
|
||||
|
||||
constructor(realm: any) {
|
||||
this.realm = realm;
|
||||
}
|
||||
|
||||
has(swapId: string, action: ArkSwapNotificationAction): boolean {
|
||||
const row = this.realm.objectForPrimaryKey('ArkSwapNotificationSuppression', compositeId(swapId, action));
|
||||
return Boolean(row);
|
||||
}
|
||||
|
||||
record(swapId: string, action: ArkSwapNotificationAction): void {
|
||||
this.realm.write(() => {
|
||||
const row: ArkSwapNotificationSuppressionRow = {
|
||||
id: compositeId(swapId, action),
|
||||
swapId,
|
||||
action,
|
||||
postedAt: Date.now(),
|
||||
};
|
||||
this.realm.create('ArkSwapNotificationSuppression', row, 'modified');
|
||||
});
|
||||
}
|
||||
|
||||
clearForSwap(swapId: string): void {
|
||||
this.realm.write(() => {
|
||||
const matches = this.realm.objects('ArkSwapNotificationSuppression').filtered('swapId == $0', swapId);
|
||||
this.realm.delete(matches);
|
||||
});
|
||||
}
|
||||
|
||||
clearForSwapAction(swapId: string, action: ArkSwapNotificationAction): void {
|
||||
this.realm.write(() => {
|
||||
const row = this.realm.objectForPrimaryKey('ArkSwapNotificationSuppression', compositeId(swapId, action));
|
||||
if (row) this.realm.delete(row);
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -1,197 +0,0 @@
|
||||
import RNFS from 'react-native-fs';
|
||||
import Realm from 'realm';
|
||||
import Keychain, { ACCESSIBLE, SECURITY_LEVEL } from 'react-native-keychain';
|
||||
|
||||
import { ArkRealmSchemas, ARK_REALM_SCHEMA_VERSION, runArkRealmMigrations } from '@arkade-os/sdk/repositories/realm';
|
||||
import { BoltzRealmSchemas } from '@arkade-os/boltz-swap/repositories/realm';
|
||||
import { randomBytes } from '../../../class/rng';
|
||||
import { uint8ArrayToHex, hexToUint8Array } from '../../uint8array-extras';
|
||||
import { ArkSwapNotificationSuppressionSchema } from './notificationSuppressionRepository';
|
||||
|
||||
const AllArkadeSchemas = [...ArkRealmSchemas, ...BoltzRealmSchemas, ArkSwapNotificationSuppressionSchema];
|
||||
|
||||
// App-owned schemas added on top of the SDK's. Bump when an app-owned schema
|
||||
// changes; SDK bumps are handled by ARK_REALM_SCHEMA_VERSION. Realm requires
|
||||
// a strictly increasing schemaVersion when objects are added; computing
|
||||
// `SDK + offset` keeps the local additions ahead of any future SDK bump.
|
||||
const LOCAL_ARK_SCHEMA_OFFSET = 1;
|
||||
const ARKADE_REALM_SCHEMA_VERSION = ARK_REALM_SCHEMA_VERSION + LOCAL_ARK_SCHEMA_OFFSET;
|
||||
|
||||
const realmInstances: Map<string, Realm> = new Map();
|
||||
const openInFlight: Map<string, Promise<Realm>> = new Map();
|
||||
|
||||
// Files live in a dedicated subdirectory so BlueApp.moveRealmFilesToCacheDirectory()
|
||||
// — which sweeps top-level *.realm files from Documents into the OS-purgeable cache
|
||||
// — never sees them. RNFS.readDir is non-recursive, so the subdirectory is invisible
|
||||
// to that scan. Ark Realm holds non-recoverable swap/claim data and must stay in
|
||||
// Documents.
|
||||
const arkadeDir = (): string => `${RNFS.DocumentDirectoryPath}/arkade`;
|
||||
const realmPathFor = (namespace: string): string => `${arkadeDir()}/arkade-${namespace}.realm`;
|
||||
const keychainServiceFor = (namespace: string): string => `arkade_realm_${namespace}`;
|
||||
|
||||
async function ensureArkadeDir(): Promise<void> {
|
||||
const dir = arkadeDir();
|
||||
if (!(await RNFS.exists(dir))) await RNFS.mkdir(dir);
|
||||
}
|
||||
|
||||
async function loadOrCreateEncryptionKey(namespace: string): Promise<Uint8Array> {
|
||||
const service = keychainServiceFor(namespace);
|
||||
|
||||
const credentials = await Keychain.getGenericPassword({ service });
|
||||
if (credentials) return hexToUint8Array(credentials.password);
|
||||
|
||||
const buf = await randomBytes(64);
|
||||
const password = uint8ArrayToHex(buf);
|
||||
|
||||
// Accessibility: match the rest of the app's secret accessibility. RNSecureKeyStore
|
||||
// in class/blue-app.ts and hooks/useBiometrics.ts both use WHEN_UNLOCKED_THIS_DEVICE_ONLY;
|
||||
// the default of AFTER_FIRST_UNLOCK would expose the Realm key while the device is locked.
|
||||
//
|
||||
// Security level: preflight via getSecurityLevel() rather than try/catch around
|
||||
// SECURE_HARDWARE. getSecurityLevel returns null on iOS (where the option is moot)
|
||||
// and the highest supported level on Android. We only opt into SECURE_HARDWARE when
|
||||
// the device actually backs it; otherwise let react-native-keychain pick its default.
|
||||
// Catching every setGenericPassword error and silently retrying with ANY (the previous
|
||||
// shape) downgrades on unrelated failures — preflight surfaces those instead.
|
||||
const supportedLevel = await Keychain.getSecurityLevel();
|
||||
const opts: Parameters<typeof Keychain.setGenericPassword>[2] = {
|
||||
service,
|
||||
accessible: ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
|
||||
};
|
||||
if (supportedLevel === SECURITY_LEVEL.SECURE_HARDWARE) {
|
||||
opts.securityLevel = SECURITY_LEVEL.SECURE_HARDWARE;
|
||||
}
|
||||
await Keychain.setGenericPassword(service, password, opts);
|
||||
|
||||
return hexToUint8Array(password);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a per-wallet Realm instance keyed by `namespace`. Each Ark wallet
|
||||
* gets its own encrypted Realm file and its own Keychain entry so wallets
|
||||
* never collide on WalletState/contracts/swaps and storage buckets stay
|
||||
* isolated.
|
||||
*
|
||||
* Concurrent callers for the same namespace receive the same in-flight
|
||||
* promise. Errors are surfaced to the caller; the in-flight entry is cleared
|
||||
* so a later retry can succeed.
|
||||
*/
|
||||
export async function getArkadeRealm(namespace: string): Promise<Realm> {
|
||||
const cached = realmInstances.get(namespace);
|
||||
if (cached && !cached.isClosed) return cached;
|
||||
if (cached && cached.isClosed) realmInstances.delete(namespace);
|
||||
|
||||
const inFlight = openInFlight.get(namespace);
|
||||
if (inFlight) return inFlight;
|
||||
|
||||
const opening = (async () => {
|
||||
await ensureArkadeDir();
|
||||
|
||||
const encryptionKey = await loadOrCreateEncryptionKey(namespace);
|
||||
|
||||
const realm = await Realm.open({
|
||||
schema: AllArkadeSchemas as unknown as Realm.ObjectSchema[],
|
||||
schemaVersion: ARKADE_REALM_SCHEMA_VERSION,
|
||||
onMigration: (oldRealm, newRealm) => {
|
||||
runArkRealmMigrations(oldRealm, newRealm);
|
||||
},
|
||||
path: realmPathFor(namespace),
|
||||
encryptionKey,
|
||||
excludeFromIcloudBackup: true,
|
||||
});
|
||||
|
||||
realmInstances.set(namespace, realm);
|
||||
return realm;
|
||||
})();
|
||||
|
||||
openInFlight.set(namespace, opening);
|
||||
try {
|
||||
return await opening;
|
||||
} finally {
|
||||
openInFlight.delete(namespace);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the cached Realm for `namespace`, if any. The file and Keychain
|
||||
* entry are preserved.
|
||||
*/
|
||||
export function closeArkadeRealm(namespace: string): void {
|
||||
const realm = realmInstances.get(namespace);
|
||||
if (realm && !realm.isClosed) {
|
||||
realm.removeAllListeners();
|
||||
realm.close();
|
||||
}
|
||||
realmInstances.delete(namespace);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close every cached Arkade Realm instance. Used on app shutdown / sign out.
|
||||
*/
|
||||
export function closeAllArkadeRealms(): void {
|
||||
for (const ns of Array.from(realmInstances.keys())) {
|
||||
closeArkadeRealm(ns);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the Realm file and the Keychain entry for `namespace`. Used when
|
||||
* an Ark wallet is removed. Failures are logged but do not throw — leaving
|
||||
* an orphan file or Keychain entry is preferable to crashing the app's
|
||||
* delete path. Ark Realm failures stay scoped to the Ark wallet path.
|
||||
*
|
||||
* The Keychain encryption key is reset only when the Realm file is gone
|
||||
* (or never existed). Resetting the key while the encrypted file remains
|
||||
* would leave the user unable to open the orphan on a future re-import:
|
||||
* a fresh random key would be generated and the old file's ciphertext
|
||||
* could not be decrypted.
|
||||
*/
|
||||
export async function deleteArkadeRealm(namespace: string): Promise<void> {
|
||||
closeArkadeRealm(namespace);
|
||||
|
||||
const path = realmPathFor(namespace);
|
||||
let realmRemoved = false;
|
||||
try {
|
||||
// Realm.deleteFile is sync and removes the .realm + .lock + .management
|
||||
// siblings in one call. It is forgiving when the file does not exist
|
||||
// (no-op), but we guard via Realm.exists to keep behavior explicit.
|
||||
if (Realm.exists(path)) {
|
||||
Realm.deleteFile({ path });
|
||||
}
|
||||
realmRemoved = true;
|
||||
} catch (e: any) {
|
||||
console.log(`[ArkadeRealm] Realm.deleteFile failed for ${path}:`, e?.message ?? e);
|
||||
}
|
||||
|
||||
// Best-effort sweep of any sibling files Realm.deleteFile might have left
|
||||
// behind. These are not load-bearing for re-import; failures are tolerated.
|
||||
for (const suffix of ['.note']) {
|
||||
const sibling = `${path}${suffix}`;
|
||||
try {
|
||||
if (await RNFS.exists(sibling)) await RNFS.unlink(sibling);
|
||||
} catch (e: any) {
|
||||
console.log(`[ArkadeRealm] failed to delete ${sibling}:`, e?.message ?? e);
|
||||
}
|
||||
}
|
||||
|
||||
if (!realmRemoved) {
|
||||
console.log(
|
||||
`[ArkadeRealm] keeping encryption key for ${namespace} because Realm file cleanup failed; key preserved so a future delete retry can still decrypt the orphan`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await Keychain.resetGenericPassword({ service: keychainServiceFor(namespace) });
|
||||
} catch (e: any) {
|
||||
console.log(`[ArkadeRealm] failed to reset keychain for ${namespace}:`, e?.message ?? e);
|
||||
}
|
||||
}
|
||||
|
||||
// Exported for tests only.
|
||||
export const __testing__ = {
|
||||
realmInstances,
|
||||
openInFlight,
|
||||
realmPathFor,
|
||||
keychainServiceFor,
|
||||
};
|
||||
@ -1,423 +0,0 @@
|
||||
// Background task module for Ark swap monitoring.
|
||||
//
|
||||
// Responsibilities:
|
||||
// - Passive monitoring: poll Boltz swap status for non-terminal swaps in
|
||||
// every Ark wallet's per-wallet Realm and persist remote changes through
|
||||
// the SDK update helpers.
|
||||
// - Post a local notification when an SDK predicate flags a swap as
|
||||
// claimable/refundable. No claim, refund, recover, or signing happens in
|
||||
// background — those remain foreground-only.
|
||||
//
|
||||
// State here is in-process: it survives configure→fetch→fetch ticks within a
|
||||
// single JS runtime but is gone after process kill. Realm remains the
|
||||
// durable source of truth for swap status and notification suppression.
|
||||
import BackgroundFetch from 'react-native-background-fetch';
|
||||
|
||||
import {
|
||||
BoltzSwapProvider,
|
||||
isChainFinalStatus,
|
||||
isReverseFinalStatus,
|
||||
isSubmarineFinalStatus,
|
||||
updateChainSwapStatus,
|
||||
updateReverseSwapStatus,
|
||||
updateSubmarineSwapStatus,
|
||||
} from '@arkade-os/boltz-swap';
|
||||
import type { BoltzChainSwap, BoltzReverseSwap, BoltzSubmarineSwap, BoltzSwap } from '@arkade-os/boltz-swap';
|
||||
import { RealmSwapRepository } from '@arkade-os/boltz-swap/repositories/realm';
|
||||
|
||||
import { BlueApp as BlueAppClass } from '../class/blue-app';
|
||||
import { LightningArkWallet } from '../class/wallets/lightning-ark-wallet';
|
||||
import { getArkadeRealm } from './arkade-adapters/realm/realmInstance';
|
||||
import {
|
||||
RealmNotificationSuppressionRepository,
|
||||
type ArkSwapNotificationAction,
|
||||
} from './arkade-adapters/realm/notificationSuppressionRepository';
|
||||
import { notifyArkSwapActionable, resolveActionableAction } from './arkade-notifications';
|
||||
|
||||
const BlueApp = BlueAppClass.getInstance();
|
||||
|
||||
// Single shared provider. The constructor only stores config; it does not
|
||||
// open sockets. Re-using one instance avoids per-poll allocation.
|
||||
const swapProvider = new BoltzSwapProvider({ network: 'bitcoin' });
|
||||
const DEFAULT_MAX_RUN_MS = 25_000;
|
||||
let maxRunMs = DEFAULT_MAX_RUN_MS;
|
||||
|
||||
interface ArkTaskState {
|
||||
lastRegisteredAt: number | null;
|
||||
lastUnregisteredAt: number | null;
|
||||
lastRunStartedAt: number | null;
|
||||
lastRunFinishedAt: number | null;
|
||||
walletsScanned: number;
|
||||
swapsPolled: number;
|
||||
swapsUpdated: number;
|
||||
lastError: string | null;
|
||||
exitedDueToUnavailableStorage: boolean;
|
||||
availability: 'unknown' | 'available' | 'denied' | 'restricted';
|
||||
// Set whenever swapsUpdated is incremented. Used by reconcile() to detect
|
||||
// updates that crossed run boundaries (per-run swapsUpdated is reset).
|
||||
lastSwapUpdateAt: number;
|
||||
lastReconciledAt: number;
|
||||
}
|
||||
|
||||
const state: ArkTaskState = {
|
||||
lastRegisteredAt: null,
|
||||
lastUnregisteredAt: null,
|
||||
lastRunStartedAt: null,
|
||||
lastRunFinishedAt: null,
|
||||
walletsScanned: 0,
|
||||
swapsPolled: 0,
|
||||
swapsUpdated: 0,
|
||||
lastError: null,
|
||||
exitedDueToUnavailableStorage: false,
|
||||
availability: 'unknown',
|
||||
lastSwapUpdateAt: 0,
|
||||
lastReconciledAt: 0,
|
||||
};
|
||||
|
||||
// Per-wallet last-seen status cache. Outer key: wallet namespace; inner key:
|
||||
// swap ID; value: last status this background module observed. Diagnostic +
|
||||
// reconciliation hint only — Realm is durable.
|
||||
const swapStatusCache: Map<string, Map<string, string>> = new Map();
|
||||
|
||||
// Per-poll last-seen actionable action keyed by `${namespace}:${swapId}`.
|
||||
// Used to detect predicate flips (true → false or claim ↔ refund) so we can
|
||||
// clear the corresponding Realm suppression row even when the swap status
|
||||
// has not yet reached a terminal state. In-process only; cleared by
|
||||
// stopArkBackgroundTask so a later run does not falsely diagnose a flip on
|
||||
// the first poll after restart.
|
||||
const lastSeenActionMap: Map<string, ArkSwapNotificationAction> = new Map();
|
||||
|
||||
let configured = false;
|
||||
let running = false;
|
||||
let cancelRequested = false;
|
||||
let runDeadline: number | null = null;
|
||||
|
||||
export function getArkTaskState(): Readonly<ArkTaskState> {
|
||||
return Object.freeze({ ...state });
|
||||
}
|
||||
|
||||
function recordError(message: string): void {
|
||||
state.lastError = message;
|
||||
}
|
||||
|
||||
function shouldStopRun(): boolean {
|
||||
return cancelRequested || (runDeadline !== null && Date.now() >= runDeadline);
|
||||
}
|
||||
|
||||
function remainingRunMs(): number {
|
||||
if (runDeadline === null) return maxRunMs;
|
||||
return Math.max(runDeadline - Date.now(), 0);
|
||||
}
|
||||
|
||||
async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
timer = setTimeout(() => reject(new Error('deadline exceeded')), ms);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function isFinalStatus(swap: BoltzSwap): boolean {
|
||||
switch (swap.type) {
|
||||
case 'reverse':
|
||||
return isReverseFinalStatus(swap.status);
|
||||
case 'submarine':
|
||||
return isSubmarineFinalStatus(swap.status);
|
||||
case 'chain':
|
||||
return isChainFinalStatus(swap.status);
|
||||
}
|
||||
}
|
||||
|
||||
async function persistStatusChange(swap: BoltzSwap, newStatus: BoltzSwap['status'], repo: RealmSwapRepository): Promise<void> {
|
||||
if (swap.type === 'reverse') {
|
||||
await updateReverseSwapStatus(swap as BoltzReverseSwap, newStatus, s => repo.saveSwap(s));
|
||||
} else if (swap.type === 'submarine') {
|
||||
await updateSubmarineSwapStatus(swap as BoltzSubmarineSwap, newStatus, s => repo.saveSwap(s));
|
||||
} else {
|
||||
await updateChainSwapStatus(swap as BoltzChainSwap, newStatus, s => repo.saveSwap(s));
|
||||
}
|
||||
}
|
||||
|
||||
async function pollSwap(
|
||||
swap: BoltzSwap,
|
||||
namespace: string,
|
||||
repo: RealmSwapRepository,
|
||||
suppression: RealmNotificationSuppressionRepository,
|
||||
walletID: string,
|
||||
walletLabel: string,
|
||||
): Promise<void> {
|
||||
if (shouldStopRun()) return;
|
||||
|
||||
state.swapsPolled += 1;
|
||||
let response;
|
||||
try {
|
||||
response = await withTimeout(swapProvider.getSwapStatus(swap.id), remainingRunMs());
|
||||
} catch (e: any) {
|
||||
recordError(`getSwapStatus(${swap.id}): ${e?.message ?? e}`);
|
||||
if (e?.message === 'deadline exceeded' || remainingRunMs() <= 0) cancelRequested = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldStopRun()) return;
|
||||
|
||||
const remoteStatus = response.status;
|
||||
const statusChanged = remoteStatus !== swap.status;
|
||||
// The SDK update helpers (updateReverseSwapStatus etc.) save a copy and do
|
||||
// not mutate `swap`, so any post-persist predicate or terminal check on
|
||||
// `swap` would read the pre-update status. effectiveSwap carries the
|
||||
// status we want subsequent checks to evaluate against.
|
||||
const effectiveSwap: BoltzSwap = statusChanged ? ({ ...swap, status: remoteStatus } as BoltzSwap) : swap;
|
||||
|
||||
if (statusChanged) {
|
||||
try {
|
||||
await persistStatusChange(swap, remoteStatus, repo);
|
||||
} catch (e: any) {
|
||||
recordError(`persistStatusChange(${swap.id}): ${e?.message ?? e}`);
|
||||
return;
|
||||
}
|
||||
|
||||
state.swapsUpdated += 1;
|
||||
state.lastSwapUpdateAt = Date.now();
|
||||
let perWallet = swapStatusCache.get(namespace);
|
||||
if (!perWallet) {
|
||||
perWallet = new Map();
|
||||
swapStatusCache.set(namespace, perWallet);
|
||||
}
|
||||
perWallet.set(swap.id, remoteStatus);
|
||||
}
|
||||
|
||||
// Actionable evaluation runs on every non-terminal poll, NOT only after a
|
||||
// status change. Otherwise a swap that became actionable in a previous run
|
||||
// but never received a successful post (notify failed mid-run, OS-level
|
||||
// drop, permission-denied skip, app cold-started with already-actionable
|
||||
// Realm state) would never be re-checked because subsequent polls observe
|
||||
// remoteStatus === swap.status and would otherwise exit. The Realm
|
||||
// suppression repo is the dedup layer.
|
||||
const lastKey = `${namespace}:${effectiveSwap.id}`;
|
||||
if (isFinalStatus(effectiveSwap)) {
|
||||
try {
|
||||
suppression.clearForSwap(effectiveSwap.id);
|
||||
} catch (e: any) {
|
||||
recordError(`suppression.clearForSwap(${effectiveSwap.id}): ${e?.message ?? e}`);
|
||||
}
|
||||
lastSeenActionMap.delete(lastKey);
|
||||
return;
|
||||
}
|
||||
|
||||
const action = resolveActionableAction(effectiveSwap);
|
||||
const lastSeen = lastSeenActionMap.get(lastKey);
|
||||
if (lastSeen && lastSeen !== action) {
|
||||
// Predicate flipped out of `lastSeen` (either to null or to the other
|
||||
// action). Clear the stale suppression so the next observed flip back
|
||||
// re-fires.
|
||||
try {
|
||||
suppression.clearForSwapAction(effectiveSwap.id, lastSeen);
|
||||
} catch (e: any) {
|
||||
recordError(`suppression.clearForSwapAction(${effectiveSwap.id}): ${e?.message ?? e}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (action) {
|
||||
try {
|
||||
await notifyArkSwapActionable(effectiveSwap, suppression, walletID, walletLabel);
|
||||
} catch (e: any) {
|
||||
recordError(`notifyArkSwapActionable(${effectiveSwap.id}): ${e?.message ?? e}`);
|
||||
}
|
||||
lastSeenActionMap.set(lastKey, action);
|
||||
} else {
|
||||
lastSeenActionMap.delete(lastKey);
|
||||
}
|
||||
}
|
||||
|
||||
async function processWallet(wallet: LightningArkWallet): Promise<void> {
|
||||
state.walletsScanned += 1;
|
||||
const namespace = wallet.getNamespace();
|
||||
const walletID = wallet.getID();
|
||||
const walletLabel = wallet.getLabel();
|
||||
|
||||
let realm;
|
||||
try {
|
||||
realm = await getArkadeRealm(namespace);
|
||||
} catch (e: any) {
|
||||
// Most likely the Keychain is locked (WHEN_UNLOCKED_THIS_DEVICE_ONLY) or
|
||||
// the Realm file is unreachable. Either way the background task no-ops
|
||||
// for this wallet — claim/refund is foreground-only anyway.
|
||||
state.exitedDueToUnavailableStorage = true;
|
||||
recordError(`getArkadeRealm(${namespace}): ${e?.message ?? e}`);
|
||||
return;
|
||||
}
|
||||
|
||||
let swaps: BoltzSwap[];
|
||||
const repo = new RealmSwapRepository(realm as any);
|
||||
const suppression = new RealmNotificationSuppressionRepository(realm);
|
||||
try {
|
||||
swaps = await repo.getAllSwaps<BoltzSwap>();
|
||||
} catch (e: any) {
|
||||
recordError(`getAllSwaps(${namespace}): ${e?.message ?? e}`);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const swap of swaps) {
|
||||
if (isFinalStatus(swap)) continue;
|
||||
if (shouldStopRun()) return;
|
||||
await pollSwap(swap, namespace, repo, suppression, walletID, walletLabel);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runArkBackgroundTask(taskId: string): Promise<void> {
|
||||
if (running) {
|
||||
BackgroundFetch.finish(taskId);
|
||||
return;
|
||||
}
|
||||
|
||||
running = true;
|
||||
cancelRequested = false;
|
||||
runDeadline = Date.now() + maxRunMs;
|
||||
state.lastRunStartedAt = Date.now();
|
||||
state.walletsScanned = 0;
|
||||
state.swapsPolled = 0;
|
||||
state.swapsUpdated = 0;
|
||||
state.exitedDueToUnavailableStorage = false;
|
||||
|
||||
try {
|
||||
const wallets = BlueApp.getWallets().filter((w): w is LightningArkWallet => w instanceof LightningArkWallet);
|
||||
if (wallets.length === 0) return;
|
||||
|
||||
for (const wallet of wallets) {
|
||||
if (shouldStopRun()) break;
|
||||
try {
|
||||
await processWallet(wallet);
|
||||
} catch (e: any) {
|
||||
recordError(`processWallet: ${e?.message ?? e}`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
state.lastRunFinishedAt = Date.now();
|
||||
runDeadline = null;
|
||||
cancelRequested = false;
|
||||
running = false;
|
||||
BackgroundFetch.finish(taskId);
|
||||
}
|
||||
}
|
||||
|
||||
export function onArkBackgroundTaskTimeout(taskId: string): void {
|
||||
cancelRequested = true;
|
||||
state.lastError = 'timeout';
|
||||
state.lastRunFinishedAt = Date.now();
|
||||
BackgroundFetch.finish(taskId);
|
||||
}
|
||||
|
||||
function availabilityFromStatus(status: number): ArkTaskState['availability'] {
|
||||
if (status === BackgroundFetch.STATUS_AVAILABLE) return 'available';
|
||||
if (status === BackgroundFetch.STATUS_DENIED) return 'denied';
|
||||
if (status === BackgroundFetch.STATUS_RESTRICTED) return 'restricted';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
export async function registerArkBackgroundTask(): Promise<void> {
|
||||
if (configured) {
|
||||
await BackgroundFetch.start();
|
||||
state.lastRegisteredAt = Date.now();
|
||||
return;
|
||||
}
|
||||
|
||||
const config: Parameters<typeof BackgroundFetch.configure>[0] = {
|
||||
minimumFetchInterval: 15,
|
||||
stopOnTerminate: false,
|
||||
startOnBoot: true,
|
||||
enableHeadless: true,
|
||||
requiredNetworkType: BackgroundFetch.NETWORK_TYPE_ANY,
|
||||
};
|
||||
|
||||
try {
|
||||
const status = await BackgroundFetch.configure(config, runArkBackgroundTask, onArkBackgroundTaskTimeout);
|
||||
state.availability = availabilityFromStatus(status);
|
||||
if (state.availability === 'available') {
|
||||
configured = true;
|
||||
state.lastRegisteredAt = Date.now();
|
||||
} else {
|
||||
console.warn(`[ArkBackground] Background fetch unavailable: ${state.availability}`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
recordError(`configure: ${e?.message ?? e}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopArkBackgroundTask(): Promise<void> {
|
||||
cancelRequested = true;
|
||||
try {
|
||||
await BackgroundFetch.stop();
|
||||
} catch (e: any) {
|
||||
recordError(`stop: ${e?.message ?? e}`);
|
||||
}
|
||||
|
||||
// Await in-flight run completion (draining). A live background run keeps
|
||||
// Detox's FabricTimersIdlingResource busy and disconnects the JS bridge.
|
||||
const start = Date.now();
|
||||
// eslint-disable-next-line no-unmodified-loop-condition
|
||||
while (running && Date.now() - start < 30_000) {
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
}
|
||||
|
||||
swapStatusCache.clear();
|
||||
// Clear in-process predicate-flip tracker so a later run does not
|
||||
// diagnose a flip on the first poll after restart. Persistent suppression
|
||||
// (Realm) is intentionally untouched — re-registering must keep history.
|
||||
lastSeenActionMap.clear();
|
||||
state.lastUnregisteredAt = Date.now();
|
||||
}
|
||||
|
||||
export function reconcileArkBackgroundTaskResults(triggerRefreshForWallet: (walletId: string) => void): void {
|
||||
if (state.lastSwapUpdateAt <= state.lastReconciledAt) return;
|
||||
|
||||
const wallets = BlueApp.getWallets().filter((w): w is LightningArkWallet => w instanceof LightningArkWallet);
|
||||
for (const wallet of wallets) {
|
||||
const namespace = wallet.getNamespace();
|
||||
const perWallet = swapStatusCache.get(namespace);
|
||||
if (perWallet && perWallet.size > 0) {
|
||||
triggerRefreshForWallet(wallet.getID());
|
||||
}
|
||||
}
|
||||
|
||||
state.lastReconciledAt = Date.now();
|
||||
}
|
||||
|
||||
// Exported for tests only.
|
||||
export const __testing__ = {
|
||||
state,
|
||||
swapStatusCache,
|
||||
lastSeenActionMap,
|
||||
resetConfigured: (): void => {
|
||||
configured = false;
|
||||
},
|
||||
setMaxRunMs: (ms: number): void => {
|
||||
maxRunMs = ms;
|
||||
},
|
||||
reset: (): void => {
|
||||
state.lastRegisteredAt = null;
|
||||
state.lastUnregisteredAt = null;
|
||||
state.lastRunStartedAt = null;
|
||||
state.lastRunFinishedAt = null;
|
||||
state.walletsScanned = 0;
|
||||
state.swapsPolled = 0;
|
||||
state.swapsUpdated = 0;
|
||||
state.lastError = null;
|
||||
state.exitedDueToUnavailableStorage = false;
|
||||
state.availability = 'unknown';
|
||||
state.lastSwapUpdateAt = 0;
|
||||
state.lastReconciledAt = 0;
|
||||
swapStatusCache.clear();
|
||||
lastSeenActionMap.clear();
|
||||
configured = false;
|
||||
running = false;
|
||||
cancelRequested = false;
|
||||
runDeadline = null;
|
||||
maxRunMs = DEFAULT_MAX_RUN_MS;
|
||||
},
|
||||
};
|
||||
@ -1,163 +0,0 @@
|
||||
// Local-notification posting for actionable Ark swaps. Imported from headless
|
||||
// background runtimes (no React dependency).
|
||||
//
|
||||
// Design notes:
|
||||
// - Suppression state lives per-wallet in the Arkade Realm
|
||||
// (RealmNotificationSuppressionRepository), not in a global AsyncStorage
|
||||
// key — bucket-scoped and encrypted, so the suppression record never
|
||||
// leaks a stable handle outside the wallet's encryption boundary.
|
||||
// - Permission and app-level opt-out are checked read-only before each post
|
||||
// (no prompting from headless context). Suppression is NOT recorded when
|
||||
// the post is skipped, so a later state where the user grants permission
|
||||
// triggers a fresh post on the next wake.
|
||||
// - Notification payload deliberately does NOT include `namespace`. The OS
|
||||
// notification database persists payloads and is global across BlueWallet
|
||||
// encryption buckets; embedding a deterministic per-wallet identifier
|
||||
// would tie a stable handle to the OS-visible record.
|
||||
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { AppState, Platform } from 'react-native';
|
||||
import { Notification, Notifications } from 'react-native-notifications';
|
||||
import { checkNotifications, RESULTS } from 'react-native-permissions';
|
||||
|
||||
import { isChainSwapClaimable, isChainSwapRefundable, isReverseSwapClaimable, isSubmarineSwapRefundable } from '@arkade-os/boltz-swap';
|
||||
import type { BoltzSwap } from '@arkade-os/boltz-swap';
|
||||
|
||||
import loc from '../loc';
|
||||
import { NOTIFICATIONS_NO_AND_DONT_ASK_FLAG } from './notifications';
|
||||
import type {
|
||||
RealmNotificationSuppressionRepository,
|
||||
ArkSwapNotificationAction,
|
||||
} from './arkade-adapters/realm/notificationSuppressionRepository';
|
||||
|
||||
export const ARK_SWAP_NOTIFICATION_TYPE = 100;
|
||||
|
||||
const ANDROID_NOTIFICATION_CHANNEL_ID = 'channel_01';
|
||||
let channelEnsured = false;
|
||||
|
||||
export function ensureArkNotificationChannel(): void {
|
||||
if (Platform.OS !== 'android') return;
|
||||
if (channelEnsured) return;
|
||||
channelEnsured = true;
|
||||
// Reuses the BlueWallet channel from blue_modules/notifications.ts:80-91 so
|
||||
// headless runs do not register a second channel under a different name.
|
||||
Notifications.setNotificationChannel({
|
||||
channelId: ANDROID_NOTIFICATION_CHANNEL_ID,
|
||||
name: 'BlueWallet notifications',
|
||||
description: 'Notifications about incoming payments',
|
||||
importance: 4,
|
||||
enableVibration: true,
|
||||
showBadge: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Channel registration runs lazily on the first post (see notifyArkSwapActionable).
|
||||
// Calling it at module-top would invoke the native bridge during JS bundle
|
||||
// evaluation, which racy-blocks RN bootstrap on some devices and breaks
|
||||
// Detox's RN-context wait. The existing blue_modules/notifications.ts pattern
|
||||
// also defers channel setup to lazy invocation.
|
||||
|
||||
export function resolveActionableAction(swap: BoltzSwap): ArkSwapNotificationAction | null {
|
||||
if (isReverseSwapClaimable(swap) || isChainSwapClaimable(swap)) return 'claim';
|
||||
if (isSubmarineSwapRefundable(swap) || isChainSwapRefundable(swap)) return 'refund';
|
||||
return null;
|
||||
}
|
||||
|
||||
const interpolate = (template: string, walletLabel: string): string => template.replace('{walletLabel}', walletLabel);
|
||||
|
||||
// Static references so scripts/find-unused-loc.js can detect these keys.
|
||||
const titleFor = (): string => loc.lndViewInvoice.notification_action_title;
|
||||
const bodyFor = (action: ArkSwapNotificationAction): string =>
|
||||
action === 'claim' ? loc.lndViewInvoice.notification_claim_body : loc.lndViewInvoice.notification_refund_body;
|
||||
|
||||
let appStateOverrideForTest: string | null = null;
|
||||
let permissionResultOverrideForTest: string | null = null;
|
||||
let optOutFlagOverrideForTest: string | null | undefined;
|
||||
|
||||
function currentAppState(): string {
|
||||
return appStateOverrideForTest ?? AppState.currentState;
|
||||
}
|
||||
|
||||
async function isOsNotificationPermissionGranted(): Promise<boolean> {
|
||||
if (permissionResultOverrideForTest !== null) {
|
||||
return permissionResultOverrideForTest === RESULTS.GRANTED;
|
||||
}
|
||||
try {
|
||||
const { status } = await checkNotifications();
|
||||
return status === RESULTS.GRANTED;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function isAppLevelOptedOut(): Promise<boolean> {
|
||||
if (optOutFlagOverrideForTest !== undefined) {
|
||||
return optOutFlagOverrideForTest === 'true';
|
||||
}
|
||||
try {
|
||||
const flag = await AsyncStorage.getItem(NOTIFICATIONS_NO_AND_DONT_ASK_FLAG);
|
||||
return flag === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function notifyArkSwapActionable(
|
||||
swap: BoltzSwap,
|
||||
suppression: RealmNotificationSuppressionRepository,
|
||||
walletID: string,
|
||||
walletLabel: string,
|
||||
): Promise<void> {
|
||||
const action = resolveActionableAction(swap);
|
||||
if (!action) return;
|
||||
|
||||
if (currentAppState() === 'active') return;
|
||||
|
||||
if (suppression.has(swap.id, action)) return;
|
||||
|
||||
if (!(await isOsNotificationPermissionGranted())) return;
|
||||
if (await isAppLevelOptedOut()) return;
|
||||
|
||||
ensureArkNotificationChannel();
|
||||
|
||||
const title = titleFor();
|
||||
const body = interpolate(bodyFor(action), walletLabel);
|
||||
|
||||
try {
|
||||
Notifications.postLocalNotification(
|
||||
// namespace is intentionally omitted; tap routing re-derives it from the loaded wallet.
|
||||
new Notification({
|
||||
title,
|
||||
body,
|
||||
type: ARK_SWAP_NOTIFICATION_TYPE,
|
||||
walletID,
|
||||
swapId: swap.id,
|
||||
action,
|
||||
}),
|
||||
);
|
||||
} catch (e: any) {
|
||||
console.warn('[ArkNotifications] postLocalNotification failed:', e?.message ?? e);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
suppression.record(swap.id, action);
|
||||
} catch (e: any) {
|
||||
console.warn('[ArkNotifications] suppression.record failed:', e?.message ?? e);
|
||||
}
|
||||
}
|
||||
|
||||
export const __testing__ = {
|
||||
resetChannel: (): void => {
|
||||
channelEnsured = false;
|
||||
},
|
||||
setAppStateForTest: (state: string | null): void => {
|
||||
appStateOverrideForTest = state;
|
||||
},
|
||||
setPermissionResultForTest: (result: string | null): void => {
|
||||
permissionResultOverrideForTest = result;
|
||||
},
|
||||
setOptOutFlagForTest: (value: string | null | undefined): void => {
|
||||
optOutFlagOverrideForTest = value;
|
||||
},
|
||||
};
|
||||
@ -2,7 +2,4 @@
|
||||
* Let's keep config vars, constants and definitions here
|
||||
*/
|
||||
|
||||
export const groundControlUri: string = 'https://groundcontrol.bluewallet.io';
|
||||
|
||||
/** bitcoin-payment-push-service base URL, no trailing slash. Empty = disabled. */
|
||||
export const arkadePaymentPushUri: string = 'https://electrum2.bluewallet.io:444';
|
||||
export const groundControlUri: string = 'https://groundcontrol-bluewallet.herokuapp.com';
|
||||
|
||||
@ -1,98 +1,23 @@
|
||||
import { cbc } from '@noble/ciphers/aes';
|
||||
import { md5 } from '@noble/hashes/legacy';
|
||||
import { randomBytes } from '@noble/hashes/utils';
|
||||
import AES from 'crypto-js/aes';
|
||||
import Utf8 from 'crypto-js/enc-utf8';
|
||||
|
||||
import { areUint8ArraysEqual, base64ToUint8Array, concatUint8Arrays, stringToUint8Array, uint8ArrayToBase64 } from './uint8array-extras';
|
||||
|
||||
/**
|
||||
* OpenSSL EVP_BytesToKey using MD5 with 1 iteration.
|
||||
*
|
||||
* Reproduces the default key+IV derivation used by CryptoJS@4.x's
|
||||
* `AES.encrypt(string, password)` so the on-disk wire format stays
|
||||
* bit-identical after we swap the underlying library.
|
||||
*
|
||||
* D1 = MD5( password || salt )
|
||||
* Di = MD5( D(i-1) || password || salt ) for i ≥ 2
|
||||
* key||iv = D1 || D2 || ... (take first `byteLength` bytes)
|
||||
*
|
||||
* MD5 is intentional: it matches the legacy OpenSSL format. The
|
||||
* cryptographic weakness of MD5 is not relevant here — the function is
|
||||
* only used as a deterministic byte-stretcher; the password's entropy is
|
||||
* what protects the wallet, not MD5.
|
||||
*/
|
||||
export function evpBytesToKeyMd5(password: Uint8Array, salt: Uint8Array, byteLength: number): Uint8Array {
|
||||
if (!Number.isInteger(byteLength) || byteLength < 0) {
|
||||
throw new Error('evpBytesToKeyMd5: byteLength must be a non-negative integer');
|
||||
}
|
||||
const out = new Uint8Array(byteLength);
|
||||
let written = 0;
|
||||
let prev: Uint8Array = new Uint8Array(0);
|
||||
while (written < byteLength) {
|
||||
prev = md5(concatUint8Arrays([prev, password, salt]));
|
||||
const take = Math.min(prev.length, byteLength - written);
|
||||
out.set(prev.subarray(0, take), written);
|
||||
written += take;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// "Salted__" — OpenSSL envelope magic. Hardcoded as bytes so the wire
|
||||
// format cannot drift through any encoder.
|
||||
const SALT_MAGIC = new Uint8Array([0x53, 0x61, 0x6c, 0x74, 0x65, 0x64, 0x5f, 0x5f]);
|
||||
const SALT_LEN = 8;
|
||||
const KEY_LEN = 32;
|
||||
const IV_LEN = 16;
|
||||
const BLOCK_LEN = 16;
|
||||
|
||||
/**
|
||||
* AES-256-CBC encrypt with the OpenSSL "Salted__" envelope, EVP_BytesToKey-MD5
|
||||
* key derivation and PKCS7 padding. Output is base64-encoded.
|
||||
*
|
||||
* Wire format is bit-identical to CryptoJS@4.x's default
|
||||
* `AES.encrypt(data, password).toString()` — we kept the swap-the-library
|
||||
* change a drop-in replacement so existing encrypted wallets on user
|
||||
* devices remain readable, with no migration step.
|
||||
*/
|
||||
export function encrypt(data: string, password: string): string {
|
||||
if (data.length < 10) throw new Error('data length cant be < 10');
|
||||
const salt = randomBytes(SALT_LEN);
|
||||
const kdf = evpBytesToKeyMd5(stringToUint8Array(password), salt, KEY_LEN + IV_LEN);
|
||||
const key = kdf.subarray(0, KEY_LEN);
|
||||
const iv = kdf.subarray(KEY_LEN);
|
||||
const ciphertext = cbc(key, iv).encrypt(stringToUint8Array(data));
|
||||
return uint8ArrayToBase64(concatUint8Arrays([SALT_MAGIC, salt, ciphertext]));
|
||||
const ciphertext = AES.encrypt(data, password);
|
||||
return ciphertext.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Inverse of `encrypt`. Accepts the legacy CryptoJS wire format and returns
|
||||
* the original UTF-8 plaintext. Any error (bad base64, missing magic, wrong
|
||||
* password, bad padding) collapses to `false`.
|
||||
*/
|
||||
export function decrypt(data: string, password: string): string | false {
|
||||
const bytes = AES.decrypt(data, password);
|
||||
let str: string | false = false;
|
||||
try {
|
||||
// crypto-js's base64 decoder ignored whitespace. Some old encrypted-backup
|
||||
// export/import flows (manual file paste, clipboard transit, email-based
|
||||
// wallet transfer) introduced stray newlines or padding spaces. Strip them
|
||||
// before strict base64 decode so legacy backups still open. `\s` does not
|
||||
// include `=`, so base64 padding survives.
|
||||
const envelope = base64ToUint8Array(data.replace(/\s+/g, ''));
|
||||
if (envelope.length < SALT_MAGIC.length + SALT_LEN + BLOCK_LEN) return false;
|
||||
if (!areUint8ArraysEqual(envelope.subarray(0, SALT_MAGIC.length), SALT_MAGIC)) return false;
|
||||
const salt = envelope.subarray(SALT_MAGIC.length, SALT_MAGIC.length + SALT_LEN);
|
||||
const ciphertext = envelope.subarray(SALT_MAGIC.length + SALT_LEN);
|
||||
const kdf = evpBytesToKeyMd5(stringToUint8Array(password), salt, KEY_LEN + IV_LEN);
|
||||
const key = kdf.subarray(0, KEY_LEN);
|
||||
const iv = kdf.subarray(KEY_LEN);
|
||||
const plain = cbc(key, iv).decrypt(ciphertext);
|
||||
// Strict UTF-8 decode — wrong-password decrypts that happen to survive
|
||||
// PKCS7 unpadding overwhelmingly fail here (crypto-js's `enc.Utf8` was
|
||||
// strict too; we preserve that gate by using `fatal: true`).
|
||||
const str = new TextDecoder('utf-8', { fatal: true }).decode(plain);
|
||||
// Belt-and-suspenders: legitimate plaintext is always ≥ 10 chars
|
||||
// (enforced by encrypt()), so anything shorter is rejected.
|
||||
if (str.length < 10) return false;
|
||||
return str;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
str = bytes.toString(Utf8);
|
||||
} catch (e) {}
|
||||
|
||||
// For some reason, sometimes decrypt would succeed with an incorrect password and return random characters.
|
||||
// In this TypeScript version, we are not allowing the encryption of data that is shorter than
|
||||
// 10 characters. If the decrypted data is less than 10 characters, we assume that the decrypt actually failed.
|
||||
if (str && str.length < 10) return false;
|
||||
|
||||
return str;
|
||||
}
|
||||
|
||||
@ -26,93 +26,44 @@ export interface TinySecp256k1InterfaceExtended {
|
||||
signDER(h: Uint8Array, d: Uint8Array, e?: Uint8Array): Uint8Array;
|
||||
}
|
||||
|
||||
// @noble/hashes types differ slightly from @noble/secp256k1 v3 hash slot typings.
|
||||
necc.hashes.sha256 = sha256 as NonNullable<typeof necc.hashes.sha256>;
|
||||
necc.hashes.hmacSha256 = ((key: Uint8Array, message: Uint8Array) => hmac(sha256, key, message)) as NonNullable<
|
||||
typeof necc.hashes.hmacSha256
|
||||
>;
|
||||
|
||||
// Removed from @noble/secp256k1 v1.7; vendored from noble test vectors.
|
||||
// @see https://github.com/paulmillr/noble-secp256k1/blob/1.7.2/test/index.ts
|
||||
type Hex = string | Uint8Array;
|
||||
|
||||
const { mod, secretKeyToScalar, numberToBytesBE, bytesToNumberBE, hexToBytes } = necc.etc;
|
||||
const CURVE_N = necc.Point.CURVE().n;
|
||||
|
||||
function pointFromBytes(p: Uint8Array): necc.Point {
|
||||
if (p.length === 32) {
|
||||
const prefixed = new Uint8Array(33);
|
||||
prefixed[0] = 0x02;
|
||||
prefixed.set(p, 1);
|
||||
return necc.Point.fromBytes(prefixed);
|
||||
}
|
||||
return necc.Point.fromBytes(p);
|
||||
}
|
||||
|
||||
const tweakUtils = {
|
||||
privateAdd: (privateKey: Hex, tweak: Hex): Uint8Array => {
|
||||
const p = secretKeyToScalar(typeof privateKey === 'string' ? hexToBytes(privateKey) : privateKey);
|
||||
const t = secretKeyToScalar(typeof tweak === 'string' ? hexToBytes(tweak) : tweak);
|
||||
return numberToBytesBE(mod(p + t, CURVE_N));
|
||||
},
|
||||
|
||||
privateNegate: (privateKey: Hex): Uint8Array => {
|
||||
const p = secretKeyToScalar(typeof privateKey === 'string' ? hexToBytes(privateKey) : privateKey);
|
||||
return numberToBytesBE(CURVE_N - p);
|
||||
},
|
||||
|
||||
pointAddScalar: (p: Hex, tweak: Hex, isCompressed?: boolean): Uint8Array => {
|
||||
const P = typeof p === 'string' ? necc.Point.fromHex(p) : pointFromBytes(p);
|
||||
const t = secretKeyToScalar(typeof tweak === 'string' ? hexToBytes(tweak) : tweak);
|
||||
const Q = P.add(necc.Point.BASE.multiply(t));
|
||||
if (Q.is0()) throw new Error('Tweaked point at infinity');
|
||||
return Q.toBytes(isCompressed);
|
||||
},
|
||||
|
||||
pointMultiply: (p: Hex, tweak: Hex, isCompressed?: boolean): Uint8Array => {
|
||||
const P = typeof p === 'string' ? necc.Point.fromHex(p) : pointFromBytes(p);
|
||||
const tweakBytes = typeof tweak === 'string' ? hexToBytes(tweak) : tweak;
|
||||
const t = mod(bytesToNumberBE(tweakBytes), CURVE_N);
|
||||
if (t === 0n) throw new Error('Point at infinity');
|
||||
return P.multiply(t).toBytes(isCompressed);
|
||||
},
|
||||
necc.utils.sha256Sync = (...messages: Uint8Array[]): Uint8Array => {
|
||||
const combinedMessages = messages.reduce((acc, msg) => {
|
||||
const newArray = new Uint8Array(acc.length + msg.length);
|
||||
newArray.set(acc);
|
||||
newArray.set(msg, acc.length);
|
||||
return newArray;
|
||||
}, new Uint8Array(0));
|
||||
return sha256(combinedMessages);
|
||||
};
|
||||
|
||||
necc.utils.hmacSha256Sync = (key: Uint8Array, ...messages: Uint8Array[]): Uint8Array => {
|
||||
const combinedMessages = messages.reduce((acc, msg) => {
|
||||
const newArray = new Uint8Array(acc.length + msg.length);
|
||||
newArray.set(acc);
|
||||
newArray.set(msg, acc.length);
|
||||
return newArray;
|
||||
}, new Uint8Array(0));
|
||||
return hmac(sha256, key, combinedMessages);
|
||||
};
|
||||
|
||||
/* const normal = necc.utils._normalizePrivateKey;
|
||||
type Hex = string | Uint8Array;
|
||||
type PrivKey = Hex | bigint | number;
|
||||
|
||||
necc.utils.privateAdd = (privateKey: PrivKey, tweak: Hex) => {
|
||||
console.log({ privateKey, tweak });
|
||||
const p = normal(privateKey);
|
||||
const t = normal(tweak);
|
||||
return necc.utils.privateAdd(necc.utils.mod(p + t, necc.CURVE.n));
|
||||
}; */
|
||||
|
||||
const defaultTrue = (param?: boolean): boolean => param !== false;
|
||||
|
||||
function compactToDER(sig: Uint8Array): Uint8Array {
|
||||
const encodeInt = (bytes: Uint8Array): Uint8Array => {
|
||||
let i = 0;
|
||||
while (i < bytes.length - 1 && bytes[i] === 0) i++;
|
||||
let trimmed = bytes.subarray(i);
|
||||
if (trimmed[0] >= 0x80) {
|
||||
const prefixed = new Uint8Array(trimmed.length + 1);
|
||||
prefixed[0] = 0;
|
||||
prefixed.set(trimmed, 1);
|
||||
trimmed = prefixed;
|
||||
}
|
||||
const encoded = new Uint8Array(2 + trimmed.length);
|
||||
encoded[0] = 0x02;
|
||||
encoded[1] = trimmed.length;
|
||||
encoded.set(trimmed, 2);
|
||||
return encoded;
|
||||
};
|
||||
|
||||
const rDer = encodeInt(sig.subarray(0, 32));
|
||||
const sDer = encodeInt(sig.subarray(32, 64));
|
||||
const seqLen = rDer.length + sDer.length;
|
||||
const der = new Uint8Array(2 + seqLen);
|
||||
der[0] = 0x30;
|
||||
der[1] = seqLen;
|
||||
der.set(rDer, 2);
|
||||
der.set(sDer, 2 + rDer.length);
|
||||
return der;
|
||||
}
|
||||
|
||||
function throwToNull<Type>(fn: () => Type): Type | null {
|
||||
try {
|
||||
return fn();
|
||||
} catch (e) {
|
||||
// console.log(e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -120,8 +71,7 @@ function throwToNull<Type>(fn: () => Type): Type | null {
|
||||
function isPoint(p: Uint8Array, xOnly: boolean): boolean {
|
||||
if ((p.length === 32) !== xOnly) return false;
|
||||
try {
|
||||
pointFromBytes(p);
|
||||
return true;
|
||||
return !!necc.Point.fromHex(p);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
@ -129,12 +79,23 @@ function isPoint(p: Uint8Array, xOnly: boolean): boolean {
|
||||
|
||||
const ecc: TinySecp256k1InterfaceExtended & TinySecp256k1Interface & TinySecp256k1InterfaceBIP32 = {
|
||||
isPoint: (p: Uint8Array): boolean => isPoint(p, false),
|
||||
isPrivate: (d: Uint8Array): boolean => necc.utils.isValidSecretKey(d),
|
||||
isPrivate: (d: Uint8Array): boolean => {
|
||||
/* if (
|
||||
[
|
||||
'0000000000000000000000000000000000000000000000000000000000000000',
|
||||
'fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141',
|
||||
'fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364142',
|
||||
].includes(d.toString('hex'))
|
||||
) {
|
||||
return false;
|
||||
} */
|
||||
return necc.utils.isValidPrivateKey(d);
|
||||
},
|
||||
isXOnlyPoint: (p: Uint8Array): boolean => isPoint(p, true),
|
||||
|
||||
xOnlyPointAddTweak: (p: Uint8Array, tweak: Uint8Array): { parity: 0 | 1; xOnlyPubkey: Uint8Array } | null =>
|
||||
throwToNull(() => {
|
||||
const P = tweakUtils.pointAddScalar(p, tweak, true);
|
||||
const P = necc.utils.pointAddScalar(p, tweak, true);
|
||||
const parity = P[0] % 2 === 1 ? 1 : 0;
|
||||
return { parity, xOnlyPubkey: P.slice(1) };
|
||||
}),
|
||||
@ -143,56 +104,60 @@ const ecc: TinySecp256k1InterfaceExtended & TinySecp256k1Interface & TinySecp256
|
||||
throwToNull(() => necc.getPublicKey(sk, defaultTrue(compressed))),
|
||||
|
||||
pointCompress: (p: Uint8Array, compressed?: boolean): Uint8Array => {
|
||||
return pointFromBytes(p).toBytes(defaultTrue(compressed));
|
||||
return necc.Point.fromHex(p).toRawBytes(defaultTrue(compressed));
|
||||
},
|
||||
|
||||
pointMultiply: (a: Uint8Array, tweak: Uint8Array, compressed?: boolean): Uint8Array | null =>
|
||||
throwToNull(() => tweakUtils.pointMultiply(a, tweak, defaultTrue(compressed))),
|
||||
throwToNull(() => necc.utils.pointMultiply(a, tweak, defaultTrue(compressed))),
|
||||
|
||||
pointAdd: (a: Uint8Array, b: Uint8Array, compressed?: boolean): Uint8Array | null =>
|
||||
throwToNull(() => {
|
||||
const A = pointFromBytes(a);
|
||||
const B = pointFromBytes(b);
|
||||
return A.add(B).toBytes(defaultTrue(compressed));
|
||||
const A = necc.Point.fromHex(a);
|
||||
const B = necc.Point.fromHex(b);
|
||||
return A.add(B).toRawBytes(defaultTrue(compressed));
|
||||
}),
|
||||
|
||||
pointAddScalar: (p: Uint8Array, tweak: Uint8Array, compressed?: boolean): Uint8Array | null =>
|
||||
throwToNull(() => tweakUtils.pointAddScalar(p, tweak, defaultTrue(compressed))),
|
||||
throwToNull(() => necc.utils.pointAddScalar(p, tweak, defaultTrue(compressed))),
|
||||
|
||||
privateAdd: (d: Uint8Array, tweak: Uint8Array): Uint8Array | null =>
|
||||
throwToNull(() => {
|
||||
// console.log({ d, tweak });
|
||||
if (d.join('') === '00000000000000000000000000000001' && tweak.join('') === '00000000000000000000000000000000') {
|
||||
return new Uint8Array(d); // make test_ecc happy
|
||||
}
|
||||
|
||||
const ret = tweakUtils.privateAdd(d, tweak);
|
||||
const ret = necc.utils.privateAdd(d, tweak);
|
||||
// console.log(ret);
|
||||
if (ret.join('') === '00000000000000000000000000000000') {
|
||||
return null;
|
||||
}
|
||||
return ret;
|
||||
}),
|
||||
|
||||
privateNegate: (d: Uint8Array): Uint8Array => tweakUtils.privateNegate(d),
|
||||
privateNegate: (d: Uint8Array): Uint8Array => necc.utils.privateNegate(d),
|
||||
|
||||
sign: (h: Uint8Array, d: Uint8Array, e?: Uint8Array): Uint8Array => {
|
||||
return necc.sign(h, d, { prehash: false, extraEntropy: e });
|
||||
return necc.signSync(h, d, { der: false, extraEntropy: e });
|
||||
},
|
||||
|
||||
signDER: (h: Uint8Array, d: Uint8Array, e?: Uint8Array): Uint8Array => {
|
||||
return compactToDER(necc.sign(h, d, { prehash: false, extraEntropy: e }));
|
||||
return necc.signSync(h, d, { der: true, extraEntropy: e });
|
||||
},
|
||||
|
||||
signSchnorr: (h: Uint8Array, d: Uint8Array, e: Uint8Array = new Uint8Array(32).fill(0x00)): Uint8Array => {
|
||||
return necc.schnorr.sign(h, d, e);
|
||||
return necc.schnorr.signSync(h, d, e);
|
||||
},
|
||||
|
||||
verify: (h: Uint8Array, Q: Uint8Array, signature: Uint8Array, strict?: boolean): boolean => {
|
||||
return necc.verify(signature, h, Q, { prehash: false, lowS: strict !== false });
|
||||
return necc.verify(signature, h, Q, { strict });
|
||||
},
|
||||
|
||||
verifySchnorr: (h: Uint8Array, Q: Uint8Array, signature: Uint8Array): boolean => {
|
||||
return necc.schnorr.verify(signature, h, Q);
|
||||
return necc.schnorr.verifySync(signature, h, Q);
|
||||
},
|
||||
};
|
||||
|
||||
export default ecc;
|
||||
|
||||
// module.exports.ecc = ecc;
|
||||
|
||||
@ -8,16 +8,16 @@ import {
|
||||
Notifications,
|
||||
} from 'react-native-notifications';
|
||||
import { checkNotifications, requestNotifications, RESULTS } from 'react-native-permissions';
|
||||
import type { BoltzReverseSwap } from '@arkade-os/boltz-swap';
|
||||
import loc from '../loc';
|
||||
import { arkadePaymentPushUri, groundControlUri } from './constants';
|
||||
import { groundControlUri } from './constants';
|
||||
import { fetch } from '../util/fetch';
|
||||
|
||||
const PUSH_TOKEN = 'PUSH_TOKEN';
|
||||
const GROUNDCONTROL_BASE_URI = 'GROUNDCONTROL_BASE_URI';
|
||||
const NOTIFICATIONS_STORAGE = 'NOTIFICATIONS_STORAGE';
|
||||
const ANDROID_NOTIFICATION_CHANNEL_ID = 'channel_01';
|
||||
export const NOTIFICATIONS_NO_AND_DONT_ASK_FLAG = 'NOTIFICATIONS_NO_AND_DONT_ASK_FLAG';
|
||||
const baseURI = groundControlUri;
|
||||
let baseURI = groundControlUri;
|
||||
let notificationSubscriptions: EmitterSubscription[] = [];
|
||||
let onProcessNotificationsHandler: undefined | (() => void | Promise<void>);
|
||||
const handledNotificationKeys = new Set<string>();
|
||||
@ -252,29 +252,6 @@ export const tryToObtainPermissions = async (): Promise<boolean> => {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const enqueueTestPushNotification = async (): Promise<void> => {
|
||||
const pushToken = await getPushToken();
|
||||
if (!pushToken?.token || !pushToken?.os) {
|
||||
throw new Error('No push token available');
|
||||
}
|
||||
|
||||
const response = await fetch(`${baseURI}/enqueue`, {
|
||||
method: 'POST',
|
||||
headers: _getHeaders(),
|
||||
body: JSON.stringify({
|
||||
type: 5,
|
||||
token: pushToken.token,
|
||||
os: pushToken.os,
|
||||
text: 'Test push notification',
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Enqueue request failed with status ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Submits onchain bitcoin addresses and ln invoice preimage hashes to GroundControl server, so later we could
|
||||
* be notified if they were paid
|
||||
@ -350,44 +327,6 @@ export const majorTomToGroundControl = async (addresses: string[], hashes: strin
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Registers an Ark swap with the bitcoin-payment-push-service so the device is
|
||||
* pushed when the invoice gets paid. Fire-and-forget: never throws, gated by
|
||||
* the same opt-out/token rules as majorTomToGroundControl(). The swap's
|
||||
* preimage is always stripped before leaving the device.
|
||||
*/
|
||||
export const registerArkPaymentPush = async (paymentHash: string, label: string, pendingSwap: BoltzReverseSwap): Promise<void> => {
|
||||
if (!arkadePaymentPushUri) return;
|
||||
try {
|
||||
const noAndDontAskFlag = await AsyncStorage.getItem(NOTIFICATIONS_NO_AND_DONT_ASK_FLAG);
|
||||
if (noAndDontAskFlag === 'true') {
|
||||
console.warn('User has opted out of notifications.');
|
||||
return;
|
||||
}
|
||||
|
||||
const pushToken = await getPushToken();
|
||||
if (!pushToken || !pushToken.token || !pushToken.os) {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`${arkadePaymentPushUri}/register`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
topic: paymentHash,
|
||||
label,
|
||||
swap: { ...pendingSwap, preimage: '' },
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`status ${response.status}`);
|
||||
}
|
||||
console.log('[ARK] payment push registration ok');
|
||||
} catch (e: any) {
|
||||
console.log('[ARK] payment push registration failed:', e?.message ?? e);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a permissions object:
|
||||
* alert: boolean
|
||||
@ -590,6 +529,22 @@ const configureNotifications = async (onProcessNotifications?: () => void): Prom
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates whether the provided GroundControl URI is valid by pinging it.
|
||||
*
|
||||
* @param uri {string}
|
||||
* @returns {Promise<boolean>} TRUE if valid, FALSE otherwise
|
||||
*/
|
||||
export const isGroundControlUriValid = async (uri: string) => {
|
||||
try {
|
||||
const response = await fetch(`${uri}/ping`, { headers: _getHeaders() });
|
||||
const json = await response.json();
|
||||
return !!json.description;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const isNotificationsCapable = hasGmsSync() || hasHmsSync() || Platform.OS !== 'android';
|
||||
|
||||
export const getPushToken = async (): Promise<TPushToken> => {
|
||||
@ -721,6 +676,38 @@ export const removeAllDeliveredNotifications = () => {
|
||||
Notifications.removeAllDeliveredNotifications();
|
||||
};
|
||||
|
||||
export const getDefaultUri = () => {
|
||||
return groundControlUri;
|
||||
};
|
||||
|
||||
export const saveUri = async (uri: string) => {
|
||||
try {
|
||||
baseURI = uri || groundControlUri;
|
||||
await AsyncStorage.setItem(GROUNDCONTROL_BASE_URI, baseURI);
|
||||
} catch (error) {
|
||||
console.error('Error saving URI:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getSavedUri = async () => {
|
||||
try {
|
||||
const baseUriStored = await AsyncStorage.getItem(GROUNDCONTROL_BASE_URI);
|
||||
if (baseUriStored) {
|
||||
baseURI = baseUriStored;
|
||||
}
|
||||
return baseUriStored;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
try {
|
||||
await AsyncStorage.setItem(GROUNDCONTROL_BASE_URI, groundControlUri);
|
||||
} catch (storageError) {
|
||||
console.error('Failed to reset URI:', storageError);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
export const isNotificationsEnabled = async () => {
|
||||
try {
|
||||
const levels = await getLevels();
|
||||
@ -770,6 +757,10 @@ export const initializeNotifications = async (onProcessNotifications?: () => voi
|
||||
return;
|
||||
}
|
||||
|
||||
const baseUriStored = await AsyncStorage.getItem(GROUNDCONTROL_BASE_URI);
|
||||
baseURI = baseUriStored || groundControlUri;
|
||||
console.log('Base URI set to:', baseURI);
|
||||
|
||||
setApplicationIconBadgeNumber(0);
|
||||
|
||||
// Only check permissions, never request
|
||||
@ -790,5 +781,7 @@ export const initializeNotifications = async (onProcessNotifications?: () => voi
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize notifications:', error);
|
||||
baseURI = groundControlUri;
|
||||
await AsyncStorage.setItem(GROUNDCONTROL_BASE_URI, groundControlUri).catch(err => console.error('Failed to reset URI:', err));
|
||||
}
|
||||
};
|
||||
|
||||
@ -1,41 +0,0 @@
|
||||
// Display state for the transaction detail screen.
|
||||
//
|
||||
// On-chain rows (a real Bitcoin txid is present in `hash`) keep the existing
|
||||
// confirmations-based logic. Ark/Lightning rows synthesized by
|
||||
// LightningArkWallet.getTransactions() carry no on-chain `hash` and never a
|
||||
// `confirmations` field, so their state is derived from row semantics instead.
|
||||
// The off-chain branch mirrors the off-chain cases of
|
||||
// components/TransactionListItem.tsx `listTitleKey` so the list row and the detail
|
||||
// screen always agree. A `boarding-utxo-` row is a refill still awaiting
|
||||
// settlement and is pending (matches TransactionListItem.isPendingRefill); a
|
||||
// settled `boarding-` refill is a confirmed receive. Today only `bitcoind_tx` Ark
|
||||
// rows reach the detail screen (swap rows route to LNDViewInvoice); the invoice
|
||||
// cases are handled defensively.
|
||||
export type TxDisplayState = 'pending' | 'sent' | 'received';
|
||||
|
||||
export function isOnChainTransaction(tx: any): boolean {
|
||||
return typeof tx?.hash === 'string' && tx.hash.length > 0;
|
||||
}
|
||||
|
||||
export function resolveTxDisplayState(tx: any): TxDisplayState {
|
||||
if (isOnChainTransaction(tx)) {
|
||||
const confs = Number(tx?.confirmations);
|
||||
const pending = Number.isFinite(confs) ? confs <= 0 : !tx?.confirmations;
|
||||
if (pending) return 'pending';
|
||||
return Number(tx?.value) < 0 ? 'sent' : 'received';
|
||||
}
|
||||
// A refill awaiting settlement (boarding UTXO not yet swept into a VTXO) is
|
||||
// pending until it promotes to a settled `boarding-<txid>` refill — mirror
|
||||
// TransactionListItem.isPendingRefill so the list row and detail screen agree.
|
||||
if (typeof tx?.txid === 'string' && tx.txid.startsWith('boarding-utxo-')) return 'pending';
|
||||
// Off-chain Ark/Lightning row — never confirmations-based.
|
||||
switch (tx?.type) {
|
||||
case 'paid_invoice':
|
||||
return 'sent';
|
||||
case 'user_invoice':
|
||||
case 'payment_request':
|
||||
return tx?.ispaid ? 'received' : 'pending';
|
||||
default: // settled refill (boarding-<txid>), native Ark legs (ark-), any other hash-less row
|
||||
return Number(tx?.value) < 0 ? 'sent' : 'received';
|
||||
}
|
||||
}
|
||||
@ -147,10 +147,11 @@ export class BlueApp {
|
||||
console.warn('error reading', key, error.message);
|
||||
console.warn('fallback to realm');
|
||||
const realmKeyValue = await this.openRealmKeyValue();
|
||||
const obj = realmKeyValue.objectForPrimaryKey<{ key: string; value: string }>('KeyValue', key);
|
||||
const obj = realmKeyValue.objectForPrimaryKey('KeyValue', key); // search for a realm object with a primary key
|
||||
value = obj?.value;
|
||||
realmKeyValue.close();
|
||||
if (value) {
|
||||
// @ts-ignore value.length
|
||||
console.warn('successfully recovered', value.length, 'bytes from realm for key', key);
|
||||
return value;
|
||||
}
|
||||
@ -546,11 +547,10 @@ export class BlueApp {
|
||||
(walletToInflate._txs_by_internal_index[tx.index] as Transaction[]).push(transaction);
|
||||
}
|
||||
} else {
|
||||
// Legacy single-address wallets - store under index 0
|
||||
walletToInflate._txs_by_external_index = walletToInflate._txs_by_external_index || {};
|
||||
walletToInflate._txs_by_external_index[0] = walletToInflate._txs_by_external_index[0] || [];
|
||||
if (!Array.isArray(walletToInflate._txs_by_external_index)) walletToInflate._txs_by_external_index = [];
|
||||
walletToInflate._txs_by_external_index = walletToInflate._txs_by_external_index || [];
|
||||
const transaction = JSON.parse(tx.tx);
|
||||
walletToInflate._txs_by_external_index[0].push(transaction);
|
||||
(walletToInflate._txs_by_external_index as Transaction[]).push(transaction);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -559,6 +559,32 @@ export class BlueApp {
|
||||
const id = wallet.getID();
|
||||
const walletToSave = ('_hdWalletInstance' in wallet && wallet._hdWalletInstance) || wallet;
|
||||
|
||||
if (Array.isArray(walletToSave._txs_by_external_index)) {
|
||||
// if this var is an array that means its a single-address wallet class, and this var is a flat array
|
||||
// with transactions
|
||||
realm.write(() => {
|
||||
// cleanup all existing transactions for the wallet first
|
||||
const walletTransactionsToDelete = realm.objects('WalletTransactions').filtered(`walletid = '${id}'`);
|
||||
realm.delete(walletTransactionsToDelete);
|
||||
|
||||
// @ts-ignore walletToSave._txs_by_external_index is array
|
||||
for (const tx of walletToSave._txs_by_external_index) {
|
||||
realm.create(
|
||||
'WalletTransactions',
|
||||
{
|
||||
walletid: id,
|
||||
tx: JSON.stringify(tx),
|
||||
},
|
||||
Realm.UpdateMode.Modified,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
/// ########################################################################################################
|
||||
|
||||
if (walletToSave._txs_by_external_index) {
|
||||
realm.write(() => {
|
||||
// cleanup all existing transactions for the wallet first
|
||||
@ -566,14 +592,16 @@ export class BlueApp {
|
||||
realm.delete(walletTransactionsToDelete);
|
||||
|
||||
// insert new ones:
|
||||
for (const [indexStr, txs] of Object.entries(walletToSave._txs_by_external_index)) {
|
||||
for (const index of Object.keys(walletToSave._txs_by_external_index)) {
|
||||
// @ts-ignore index is number
|
||||
const txs = walletToSave._txs_by_external_index[index];
|
||||
for (const tx of txs) {
|
||||
realm.create(
|
||||
'WalletTransactions',
|
||||
{
|
||||
walletid: id,
|
||||
internal: false,
|
||||
index: parseInt(indexStr, 10),
|
||||
index: parseInt(index, 10),
|
||||
tx: JSON.stringify(tx),
|
||||
},
|
||||
Realm.UpdateMode.Modified,
|
||||
@ -581,14 +609,16 @@ export class BlueApp {
|
||||
}
|
||||
}
|
||||
|
||||
for (const [indexStr, txs] of Object.entries(walletToSave._txs_by_internal_index)) {
|
||||
for (const index of Object.keys(walletToSave._txs_by_internal_index)) {
|
||||
// @ts-ignore index is number
|
||||
const txs = walletToSave._txs_by_internal_index[index];
|
||||
for (const tx of txs) {
|
||||
realm.create(
|
||||
'WalletTransactions',
|
||||
{
|
||||
walletid: id,
|
||||
internal: true,
|
||||
index: parseInt(indexStr, 10),
|
||||
index: parseInt(index, 10),
|
||||
tx: JSON.stringify(tx),
|
||||
},
|
||||
Realm.UpdateMode.Modified,
|
||||
|
||||
@ -390,7 +390,7 @@ export class HDSegwitBech32Transaction {
|
||||
}
|
||||
}
|
||||
|
||||
// Non-null assertions are safe here because the while loop always runs at least once (add starts at 0)
|
||||
return { tx: tx!, inputs: inputs!, outputs: outputs!, fee: fee! };
|
||||
// @ts-ignore stfu
|
||||
return { tx, inputs, outputs, fee };
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@ import { bech32 } from 'bech32';
|
||||
import bolt11 from 'bolt11';
|
||||
import { sha256 } from '@noble/hashes/sha256';
|
||||
import { hmac } from '@noble/hashes/hmac';
|
||||
import { cbc } from '@noble/ciphers/aes';
|
||||
import CryptoJS from 'crypto-js';
|
||||
import ecc from '../blue_modules/noble_ecc';
|
||||
import { parse } from 'url'; // eslint-disable-line n/no-deprecated-api
|
||||
import { fetch } from '../util/fetch';
|
||||
@ -321,24 +321,13 @@ export default class Lnurl {
|
||||
}
|
||||
|
||||
static decipherAES(ciphertextBase64: string, preimageHex: string, ivBase64: string): string {
|
||||
// crypto-js's old implementation silently returned '' on malformed
|
||||
// ciphertext (non-16-aligned bytes, bad PKCS7 padding) and threw on
|
||||
// malformed UTF-8 plaintext. @noble/ciphers throws on the former. We
|
||||
// catch every throw and return '' — the call site at
|
||||
// screen/lnd/lnurlPaySuccess.tsx renders this directly without a
|
||||
// try/catch, so a misbehaving LNURL server should not crash the screen.
|
||||
// Note: unlike crypto-js's strict `enc.Utf8` decoder, `uint8ArrayToString`
|
||||
// is lenient on bad UTF-8 (mojibake instead of throw); this is strictly
|
||||
// safer than the old behaviour for this user-facing path.
|
||||
try {
|
||||
const key = hexToUint8Array(preimageHex);
|
||||
const iv = base64ToUint8Array(ivBase64);
|
||||
const ct = base64ToUint8Array(ciphertextBase64);
|
||||
const pt = cbc(key, iv).decrypt(ct);
|
||||
return uint8ArrayToString(pt);
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
const iv = CryptoJS.enc.Base64.parse(ivBase64);
|
||||
const key = CryptoJS.enc.Hex.parse(preimageHex);
|
||||
return CryptoJS.AES.decrypt(uint8ArrayToHex(base64ToUint8Array(ciphertextBase64)), key, {
|
||||
iv,
|
||||
mode: CryptoJS.mode.CBC,
|
||||
format: CryptoJS.format.Hex,
|
||||
}).toString(CryptoJS.enc.Utf8);
|
||||
}
|
||||
|
||||
getCommentAllowed(): number | false {
|
||||
|
||||
@ -106,31 +106,23 @@ export class MultisigCosigner {
|
||||
this._valid = false;
|
||||
}
|
||||
|
||||
// is it coldcard / unchained json?
|
||||
// is it coldcard json?
|
||||
try {
|
||||
const json = JSON.parse(data);
|
||||
|
||||
// p2wsh_p2sh (Coldcard), p2sh_p2wsh (Unchained)
|
||||
// same script type with reversed naming
|
||||
const xpub = json.p2wsh_p2sh || json.p2sh_p2wsh;
|
||||
const path = (json.p2wsh_p2sh_deriv || json.p2sh_p2wsh_deriv)?.replace(/h/g, "'");
|
||||
const p2sh_deriv = json.p2sh_deriv?.replace(/h/g, "'");
|
||||
const p2wsh_deriv = json.p2wsh_deriv?.replace(/h/g, "'");
|
||||
|
||||
if (json.p2sh && p2sh_deriv && json.xfp) {
|
||||
const cc = new MultisigCosigner(MultisigCosigner.exportToJson(json.xfp, json.p2sh, p2sh_deriv));
|
||||
if (json.p2sh && json.p2sh_deriv && json.xfp) {
|
||||
const cc = new MultisigCosigner(MultisigCosigner.exportToJson(json.xfp, json.p2sh, json.p2sh_deriv));
|
||||
this._valid = true;
|
||||
this._cosigners.push(cc);
|
||||
}
|
||||
|
||||
if (xpub && path && json.xfp) {
|
||||
const cc = new MultisigCosigner(MultisigCosigner.exportToJson(json.xfp, xpub, path));
|
||||
if (json.p2wsh_p2sh && json.p2wsh_p2sh_deriv && json.xfp) {
|
||||
const cc = new MultisigCosigner(MultisigCosigner.exportToJson(json.xfp, json.p2wsh_p2sh, json.p2wsh_p2sh_deriv));
|
||||
this._valid = true;
|
||||
this._cosigners.push(cc);
|
||||
}
|
||||
|
||||
if (json.p2wsh && p2wsh_deriv && json.xfp) {
|
||||
const cc = new MultisigCosigner(MultisigCosigner.exportToJson(json.xfp, json.p2wsh, p2wsh_deriv));
|
||||
if (json.p2wsh && json.p2wsh_deriv && json.xfp) {
|
||||
const cc = new MultisigCosigner(MultisigCosigner.exportToJson(json.xfp, json.p2wsh, json.p2wsh_deriv));
|
||||
this._valid = true;
|
||||
this._cosigners.push(cc);
|
||||
}
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
* @return {Promise.<Uint8Array>} The random bytes
|
||||
*/
|
||||
export async function randomBytes(size: number): Promise<Uint8Array> {
|
||||
const g = globalThis as any;
|
||||
const g: any = globalThis as any;
|
||||
const rnCrypto = g && g.crypto;
|
||||
if (!rnCrypto || typeof rnCrypto.getRandomValues !== 'function') {
|
||||
throw new Error('crypto.getRandomValues is not available');
|
||||
|
||||
@ -216,35 +216,10 @@ const startImport = (
|
||||
if (text.startsWith('arkade://')) {
|
||||
const ark = new LightningArkWallet();
|
||||
ark.setSecret(text);
|
||||
// Defer init() to first wallet open when offline — init touches the ASP
|
||||
// and delegator over the network. We still detect the wallet by prefix
|
||||
// and persist it with its secret.
|
||||
// A network or SDK failure during init must not abort the import: the
|
||||
// wallet type and secret are known, and the SDK runtime can be brought
|
||||
// up the next time the wallet is opened.
|
||||
await ark.init();
|
||||
if (!offline) {
|
||||
try {
|
||||
await ark.init();
|
||||
// Restore any previous Boltz swap activity for this seed exactly
|
||||
// once, here at import time. We never run this on later wallet
|
||||
// opens — the app does not sweep all swaps on bootstrap. A failure
|
||||
// must not block the import: the wallet itself is fine, the
|
||||
// restored rows are an optional bonus for imported-from-elsewhere
|
||||
// wallets.
|
||||
try {
|
||||
await ark.restoreSwaps();
|
||||
} catch (e: any) {
|
||||
console.log('[wallet-import] restoreSwaps failed:', e?.message ?? e);
|
||||
}
|
||||
try {
|
||||
await ark.fetchBalance();
|
||||
await ark.fetchTransactions();
|
||||
} catch (e: any) {
|
||||
console.log('[wallet-import] initial Ark sync failed:', e?.message ?? e);
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.log('[wallet-import] Ark init failed; deferring to next open:', e?.message ?? e);
|
||||
}
|
||||
await ark.fetchBalance();
|
||||
await ark.fetchTransactions();
|
||||
}
|
||||
yield { wallet: ark };
|
||||
}
|
||||
|
||||
@ -45,7 +45,9 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet {
|
||||
_balances_by_external_index: Record<number, BalanceByIndex>;
|
||||
_balances_by_internal_index: Record<number, BalanceByIndex>;
|
||||
|
||||
// @ts-ignore
|
||||
_txs_by_external_index: Record<number, Transaction[]>;
|
||||
// @ts-ignore
|
||||
_txs_by_internal_index: Record<number, Transaction[]>;
|
||||
|
||||
_utxo: any[];
|
||||
@ -202,37 +204,70 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet {
|
||||
return child.toWIF();
|
||||
}
|
||||
|
||||
_getNodeByIndex(node: 0 | 1, index: number): BIP32Interface {
|
||||
const cachedNode = node === 0 ? this._node0 : this._node1;
|
||||
if (cachedNode) {
|
||||
return cachedNode.derive(index);
|
||||
_getNodeAddressByIndex(node: number, index: number): string {
|
||||
index = index * 1; // cast to int
|
||||
if (node === 0) {
|
||||
if (this.external_addresses_cache[index]) return this.external_addresses_cache[index]; // cache hit
|
||||
}
|
||||
|
||||
const xpub = this._zpubToXpub(this.getXpub());
|
||||
const hdNode = bip32.fromBase58(xpub).derive(node);
|
||||
if (node === 1) {
|
||||
if (this.internal_addresses_cache[index]) return this.internal_addresses_cache[index]; // cache hit
|
||||
}
|
||||
|
||||
if (node === 0 && !this._node0) {
|
||||
const xpub = this._zpubToXpub(this.getXpub());
|
||||
const hdNode = bip32.fromBase58(xpub);
|
||||
this._node0 = hdNode.derive(node);
|
||||
}
|
||||
|
||||
if (node === 1 && !this._node1) {
|
||||
const xpub = this._zpubToXpub(this.getXpub());
|
||||
const hdNode = bip32.fromBase58(xpub);
|
||||
this._node1 = hdNode.derive(node);
|
||||
}
|
||||
|
||||
let address: string;
|
||||
if (node === 0) {
|
||||
// @ts-ignore
|
||||
address = this._hdNodeToAddress(this._node0.derive(index));
|
||||
} else {
|
||||
// tbh the only possible else is node === 1
|
||||
// @ts-ignore
|
||||
address = this._hdNodeToAddress(this._node1.derive(index));
|
||||
}
|
||||
|
||||
if (node === 0) {
|
||||
this._node0 = hdNode;
|
||||
return (this.external_addresses_cache[index] = address);
|
||||
} else {
|
||||
this._node1 = hdNode;
|
||||
// tbh the only possible else option is node === 1
|
||||
return (this.internal_addresses_cache[index] = address);
|
||||
}
|
||||
}
|
||||
|
||||
_getNodePubkeyByIndex(node: number, index: number) {
|
||||
index = index * 1; // cast to int
|
||||
|
||||
if (node === 0 && !this._node0) {
|
||||
const xpub = this._zpubToXpub(this.getXpub());
|
||||
const hdNode = bip32.fromBase58(xpub);
|
||||
this._node0 = hdNode.derive(node);
|
||||
}
|
||||
|
||||
return hdNode.derive(index);
|
||||
}
|
||||
if (node === 1 && !this._node1) {
|
||||
const xpub = this._zpubToXpub(this.getXpub());
|
||||
const hdNode = bip32.fromBase58(xpub);
|
||||
this._node1 = hdNode.derive(node);
|
||||
}
|
||||
|
||||
_getNodeAddressByIndex(node: 0 | 1, index: number): string {
|
||||
const cache = node === 0 ? this.external_addresses_cache : this.internal_addresses_cache;
|
||||
if (node === 0 && this._node0) {
|
||||
return this._node0.derive(index).publicKey;
|
||||
}
|
||||
|
||||
if (cache[index]) return cache[index]; // cache hit
|
||||
if (node === 1 && this._node1) {
|
||||
return this._node1.derive(index).publicKey;
|
||||
}
|
||||
|
||||
const hdNode = this._getNodeByIndex(node, index);
|
||||
const address = this._hdNodeToAddress(hdNode);
|
||||
|
||||
return (cache[index] = address);
|
||||
}
|
||||
|
||||
_getNodePubkeyByIndex(node: 0 | 1, index: number) {
|
||||
return this._getNodeByIndex(node, index).publicKey;
|
||||
throw new Error('Internal error: this._node0 or this._node1 is undefined');
|
||||
}
|
||||
|
||||
_getExternalAddressByIndex(index: number): string {
|
||||
@ -389,95 +424,137 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet {
|
||||
// now, we need to put transactions in all relevant `cells` of internal hashmaps:
|
||||
// this._txs_by_internal_index, this._txs_by_external_index & this._txs_by_payment_code_index
|
||||
|
||||
// address -> index lookup maps; the single pass over transactions below uses them
|
||||
// to find which cells a transaction belongs to
|
||||
const externalIndexByAddress = new Map<string, number>();
|
||||
for (let c = 0; c < next_free_address_index + this.gap_limit; c++) {
|
||||
externalIndexByAddress.set(this._getExternalAddressByIndex(c), c);
|
||||
for (const tx of Object.values(txdatas)) {
|
||||
for (const vin of tx.vin) {
|
||||
if (vin.addresses && vin.addresses.indexOf(this._getExternalAddressByIndex(c)) !== -1) {
|
||||
// this TX is related to our address
|
||||
this._txs_by_external_index[c] = this._txs_by_external_index[c] || [];
|
||||
const { vin: txVin, vout: txVout, ...txRest } = tx;
|
||||
const clonedTx = {
|
||||
...txRest,
|
||||
inputs: txVin.slice(0),
|
||||
outputs: txVout.slice(0),
|
||||
timestamp: tx.blocktime || tx.time || Math.floor(+new Date() / 1000) - 30 /* unconfirmed */,
|
||||
};
|
||||
|
||||
// trying to replace tx if it exists already (because it has lower confirmations, for example)
|
||||
let replaced = false;
|
||||
for (let cc = 0; cc < this._txs_by_external_index[c].length; cc++) {
|
||||
if (this._txs_by_external_index[c][cc].txid === clonedTx.txid) {
|
||||
replaced = true;
|
||||
this._txs_by_external_index[c][cc] = clonedTx;
|
||||
}
|
||||
}
|
||||
if (!replaced) this._txs_by_external_index[c].push(clonedTx);
|
||||
}
|
||||
}
|
||||
for (const vout of tx.vout) {
|
||||
if (vout.scriptPubKey.addresses && vout.scriptPubKey.addresses.indexOf(this._getExternalAddressByIndex(c)) !== -1) {
|
||||
// this TX is related to our address
|
||||
this._txs_by_external_index[c] = this._txs_by_external_index[c] || [];
|
||||
const { vin: txVin, vout: txVout, ...txRest } = tx;
|
||||
const clonedTx = {
|
||||
...txRest,
|
||||
inputs: txVin.slice(0),
|
||||
outputs: txVout.slice(0),
|
||||
timestamp: tx.blocktime || tx.time || Math.floor(+new Date() / 1000) - 30 /* unconfirmed */,
|
||||
};
|
||||
|
||||
// trying to replace tx if it exists already (because it has lower confirmations, for example)
|
||||
let replaced = false;
|
||||
for (let cc = 0; cc < this._txs_by_external_index[c].length; cc++) {
|
||||
if (this._txs_by_external_index[c][cc].txid === clonedTx.txid) {
|
||||
replaced = true;
|
||||
this._txs_by_external_index[c][cc] = clonedTx;
|
||||
}
|
||||
}
|
||||
if (!replaced) this._txs_by_external_index[c].push(clonedTx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const internalIndexByAddress = new Map<string, number>();
|
||||
|
||||
for (let c = 0; c < next_free_change_address_index + this.gap_limit; c++) {
|
||||
internalIndexByAddress.set(this._getInternalAddressByIndex(c), c);
|
||||
for (const tx of Object.values(txdatas)) {
|
||||
for (const vin of tx.vin) {
|
||||
if (vin.addresses && vin.addresses.indexOf(this._getInternalAddressByIndex(c)) !== -1) {
|
||||
// this TX is related to our address
|
||||
this._txs_by_internal_index[c] = this._txs_by_internal_index[c] || [];
|
||||
const { vin: txVin, vout: txVout, ...txRest } = tx;
|
||||
const clonedTx = {
|
||||
...txRest,
|
||||
inputs: txVin.slice(0),
|
||||
outputs: txVout.slice(0),
|
||||
timestamp: tx.blocktime || tx.time || Math.floor(+new Date() / 1000) - 30 /* unconfirmed */,
|
||||
};
|
||||
|
||||
// trying to replace tx if it exists already (because it has lower confirmations, for example)
|
||||
let replaced = false;
|
||||
for (let cc = 0; cc < this._txs_by_internal_index[c].length; cc++) {
|
||||
if (this._txs_by_internal_index[c][cc].txid === clonedTx.txid) {
|
||||
replaced = true;
|
||||
this._txs_by_internal_index[c][cc] = clonedTx;
|
||||
}
|
||||
}
|
||||
if (!replaced) this._txs_by_internal_index[c].push(clonedTx);
|
||||
}
|
||||
}
|
||||
for (const vout of tx.vout) {
|
||||
if (vout.scriptPubKey.addresses && vout.scriptPubKey.addresses.indexOf(this._getInternalAddressByIndex(c)) !== -1) {
|
||||
// this TX is related to our address
|
||||
this._txs_by_internal_index[c] = this._txs_by_internal_index[c] || [];
|
||||
const { vin: txVin, vout: txVout, ...txRest } = tx;
|
||||
const clonedTx = {
|
||||
...txRest,
|
||||
inputs: txVin.slice(0),
|
||||
outputs: txVout.slice(0),
|
||||
timestamp: tx.blocktime || tx.time || Math.floor(+new Date() / 1000) - 30 /* unconfirmed */,
|
||||
};
|
||||
|
||||
// trying to replace tx if it exists already (because it has lower confirmations, for example)
|
||||
let replaced = false;
|
||||
for (let cc = 0; cc < this._txs_by_internal_index[c].length; cc++) {
|
||||
if (this._txs_by_internal_index[c][cc].txid === clonedTx.txid) {
|
||||
replaced = true;
|
||||
this._txs_by_internal_index[c][cc] = clonedTx;
|
||||
}
|
||||
}
|
||||
if (!replaced) this._txs_by_internal_index[c].push(clonedTx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const paymentCodeIndexByAddress = new Map<string, { pc: string; c: number }>();
|
||||
|
||||
for (const pc of this._receive_payment_codes) {
|
||||
for (let c = 0; c < this._getNextFreePaymentCodeIndexReceive(pc) + this.gap_limit; c++) {
|
||||
paymentCodeIndexByAddress.set(this._getBIP47AddressReceive(pc, c), { pc, c });
|
||||
}
|
||||
}
|
||||
for (const tx of Object.values(txdatas)) {
|
||||
// since we are iterating PCs who can pay us, we can completely ignore `tx.vin` and only iterate `tx.vout`
|
||||
for (const vout of tx.vout) {
|
||||
if (vout.scriptPubKey.addresses && vout.scriptPubKey.addresses.indexOf(this._getBIP47AddressReceive(pc, c)) !== -1) {
|
||||
// this TX is related to our address
|
||||
this._txs_by_payment_code_index[pc] = this._txs_by_payment_code_index[pc] || {};
|
||||
this._txs_by_payment_code_index[pc][c] = this._txs_by_payment_code_index[pc][c] || [];
|
||||
const { vin: txVin, vout: txVout, ...txRest } = tx;
|
||||
const clonedTx = {
|
||||
...txRest,
|
||||
inputs: txVin.slice(0),
|
||||
outputs: txVout.slice(0),
|
||||
timestamp: tx.blocktime || tx.time || Math.floor(+new Date() / 1000) - 30 /* unconfirmed */,
|
||||
};
|
||||
|
||||
// per-cell txid -> position lookup, used to replace-or-push a transaction into a cell in constant time
|
||||
const cellPositionsByTxid = new Map<Transaction[], Map<string, number>>();
|
||||
const getCellPositions = (cell: Transaction[]): Map<string, number> => {
|
||||
let positions = cellPositionsByTxid.get(cell);
|
||||
if (!positions) {
|
||||
positions = new Map();
|
||||
for (let cc = 0; cc < cell.length; cc++) positions.set(cell[cc].txid, cc);
|
||||
cellPositionsByTxid.set(cell, positions);
|
||||
}
|
||||
return positions;
|
||||
};
|
||||
|
||||
for (const tx of Object.values(txdatas)) {
|
||||
// collecting which of our address `cells` this transaction touches:
|
||||
const externalCells = new Set<number>();
|
||||
const internalCells = new Set<number>();
|
||||
const paymentCodeCells = new Map<string, { pc: string; c: number }>();
|
||||
|
||||
const matchAddress = (address: string, isVout: boolean) => {
|
||||
const externalIndex = externalIndexByAddress.get(address);
|
||||
if (externalIndex !== undefined) externalCells.add(externalIndex);
|
||||
const internalIndex = internalIndexByAddress.get(address);
|
||||
if (internalIndex !== undefined) internalCells.add(internalIndex);
|
||||
if (isVout) {
|
||||
// since we are iterating PCs who can pay us, we can completely ignore `tx.vin` and only check `tx.vout`
|
||||
const paymentCodeIndex = paymentCodeIndexByAddress.get(address);
|
||||
if (paymentCodeIndex) paymentCodeCells.set(address, paymentCodeIndex);
|
||||
// trying to replace tx if it exists already (because it has lower confirmations, for example)
|
||||
let replaced = false;
|
||||
for (let cc = 0; cc < this._txs_by_payment_code_index[pc][c].length; cc++) {
|
||||
if (this._txs_by_payment_code_index[pc][c][cc].txid === clonedTx.txid) {
|
||||
replaced = true;
|
||||
this._txs_by_payment_code_index[pc][c][cc] = clonedTx;
|
||||
}
|
||||
}
|
||||
if (!replaced) this._txs_by_payment_code_index[pc][c].push(clonedTx);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (const vin of tx.vin) {
|
||||
for (const address of vin.addresses ?? []) matchAddress(address, false);
|
||||
}
|
||||
for (const vout of tx.vout) {
|
||||
for (const address of vout.scriptPubKey.addresses ?? []) matchAddress(address, true);
|
||||
}
|
||||
|
||||
if (externalCells.size === 0 && internalCells.size === 0 && paymentCodeCells.size === 0) continue;
|
||||
|
||||
// this TX is related to our address(es)
|
||||
const upsertClone = (cell: Transaction[]) => {
|
||||
const { vin: txVin, vout: txVout, ...txRest } = tx;
|
||||
const clonedTx = {
|
||||
...txRest,
|
||||
inputs: txVin.slice(0),
|
||||
outputs: txVout.slice(0),
|
||||
timestamp: tx.blocktime || tx.time || Math.floor(+new Date() / 1000) - 30 /* unconfirmed */,
|
||||
};
|
||||
|
||||
// trying to replace tx if it exists already (because it has lower confirmations, for example)
|
||||
const positions = getCellPositions(cell);
|
||||
const existingPosition = positions.get(clonedTx.txid);
|
||||
if (existingPosition !== undefined) {
|
||||
cell[existingPosition] = clonedTx;
|
||||
} else {
|
||||
positions.set(clonedTx.txid, cell.length);
|
||||
cell.push(clonedTx);
|
||||
}
|
||||
};
|
||||
|
||||
for (const c of externalCells) {
|
||||
this._txs_by_external_index[c] = this._txs_by_external_index[c] || [];
|
||||
upsertClone(this._txs_by_external_index[c]);
|
||||
}
|
||||
for (const c of internalCells) {
|
||||
this._txs_by_internal_index[c] = this._txs_by_internal_index[c] || [];
|
||||
upsertClone(this._txs_by_internal_index[c]);
|
||||
}
|
||||
for (const { pc, c } of paymentCodeCells.values()) {
|
||||
this._txs_by_payment_code_index[pc] = this._txs_by_payment_code_index[pc] || {};
|
||||
this._txs_by_payment_code_index[pc][c] = this._txs_by_payment_code_index[pc][c] || [];
|
||||
upsertClone(this._txs_by_payment_code_index[pc][c]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -575,7 +652,8 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet {
|
||||
let lastHistoriesWithUsedAddresses = null;
|
||||
for (let c = 0; c < Math.round(index / this.gap_limit); c++) {
|
||||
const histories = await BlueElectrum.multiGetHistoryByAddress(gerenateChunkAddresses(c));
|
||||
if (AbstractHDElectrumWallet._getTransactionsFromHistories(histories).length > 0) {
|
||||
// @ts-ignore
|
||||
if (this.constructor._getTransactionsFromHistories(histories).length > 0) {
|
||||
// in this particular chunk we have used addresses
|
||||
lastChunkWithUsedAddressesNum = c;
|
||||
lastHistoriesWithUsedAddresses = histories;
|
||||
@ -617,7 +695,8 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet {
|
||||
let lastHistoriesWithUsedAddresses = null;
|
||||
for (let c = 0; c < Math.round(index / this.gap_limit); c++) {
|
||||
const histories = await BlueElectrum.multiGetHistoryByAddress(gerenateChunkAddresses(c));
|
||||
if (AbstractHDElectrumWallet._getTransactionsFromHistories(histories).length > 0) {
|
||||
// @ts-ignore
|
||||
if (this.constructor._getTransactionsFromHistories(histories).length > 0) {
|
||||
// in this particular chunk we have used addresses
|
||||
lastChunkWithUsedAddressesNum = c;
|
||||
lastHistoriesWithUsedAddresses = histories;
|
||||
@ -659,7 +738,8 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet {
|
||||
let lastHistoriesWithUsedAddresses = null;
|
||||
for (let c = 0; c < Math.round(index / this.gap_limit); c++) {
|
||||
const histories = await BlueElectrum.multiGetHistoryByAddress(generateChunkAddresses(c));
|
||||
if (AbstractHDElectrumWallet._getTransactionsFromHistories(histories).length > 0) {
|
||||
// @ts-ignore
|
||||
if (this.constructor._getTransactionsFromHistories(histories).length > 0) {
|
||||
// in this particular chunk we have used addresses
|
||||
lastChunkWithUsedAddressesNum = c;
|
||||
lastHistoriesWithUsedAddresses = histories;
|
||||
|
||||
@ -315,7 +315,7 @@ export class AbstractHDWallet extends LegacyWallet {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
|
||||
_getNodePubkeyByIndex(node: 0 | 1, index: number): Uint8Array | undefined {
|
||||
_getNodePubkeyByIndex(node: number, index: number): Uint8Array | undefined {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
|
||||
|
||||
@ -27,8 +27,8 @@ export class LegacyWallet extends AbstractWallet {
|
||||
// @ts-ignore: override
|
||||
public readonly typeReadable: string;
|
||||
|
||||
_txs_by_external_index: Record<number, Transaction[]> = {};
|
||||
_txs_by_internal_index: Record<number, Transaction[]> = {};
|
||||
_txs_by_external_index: Transaction[] = [];
|
||||
_txs_by_internal_index: Transaction[] = [];
|
||||
|
||||
constructor(typeReadable?: string) {
|
||||
super();
|
||||
@ -344,14 +344,14 @@ export class LegacyWallet extends AbstractWallet {
|
||||
}
|
||||
}
|
||||
|
||||
this._txs_by_external_index = { 0: _txsByExternalIndex };
|
||||
this._txs_by_external_index = _txsByExternalIndex;
|
||||
this._lastTxFetch = +new Date();
|
||||
}
|
||||
|
||||
getTransactions(): Transaction[] {
|
||||
// a hacky code reuse from electrum HD wallet:
|
||||
this._txs_by_external_index = this._txs_by_external_index || {};
|
||||
this._txs_by_internal_index = {};
|
||||
this._txs_by_external_index = this._txs_by_external_index || [];
|
||||
this._txs_by_internal_index = [];
|
||||
|
||||
const { HDSegwitBech32Wallet } = require('./hd-segwit-bech32-wallet') as {
|
||||
HDSegwitBech32Wallet: typeof HDSegwitBech32WalletT;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -104,11 +104,6 @@ export type LightningTransaction = {
|
||||
timestamp: number; // seconds, not milliseconds
|
||||
expire_time?: number;
|
||||
ispaid?: boolean;
|
||||
// Terminal non-success state (failed/refunded/expired swap). Distinct from
|
||||
// `ispaid:false`, which on its own only means "not settled yet" and is also
|
||||
// true for in-flight rows. Consumers that gate on pending vs. dead state
|
||||
// (e.g. the wallet-card pending pill) must treat `failed` rows as terminal.
|
||||
failed?: boolean;
|
||||
walletID?: string;
|
||||
value?: number;
|
||||
amt?: number;
|
||||
|
||||
@ -1,14 +1,12 @@
|
||||
import React, { createContext, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { BlueApp as BlueAppClass, TCounterpartyMetadata, TTXMetadata } from '../../class/blue-app';
|
||||
import { LegacyWallet } from '../../class/wallets/legacy-wallet';
|
||||
import { LightningArkWallet } from '../../class/wallets/lightning-ark-wallet';
|
||||
import { WatchOnlyWallet } from '../../class/wallets/watch-only-wallet';
|
||||
import type { TWallet } from '../../class/wallets/types';
|
||||
import presentAlert from '../../components/Alert';
|
||||
import loc, { formatBalanceWithoutSuffix } from '../../loc';
|
||||
import * as BlueElectrum from '../../blue_modules/BlueElectrum';
|
||||
import triggerHapticFeedback, { HapticFeedbackTypes } from '../../blue_modules/hapticFeedback';
|
||||
import { registerArkBackgroundTask, stopArkBackgroundTask } from '../../blue_modules/arkade-background';
|
||||
import { startAndDecrypt } from '../../blue_modules/start-and-decrypt';
|
||||
import { isNotificationsEnabled, majorTomToGroundControl, unsubscribe } from '../../blue_modules/notifications';
|
||||
import { BitcoinUnit } from '../../models/bitcoinUnits';
|
||||
@ -176,15 +174,6 @@ export const StorageProvider = ({ children }: { children: React.ReactNode }) =>
|
||||
const deleteWallet = useCallback((wallet: TWallet) => {
|
||||
BlueApp.deleteWallet(wallet);
|
||||
setWallets([...BlueApp.getWallets()]);
|
||||
if (wallet.type === LightningArkWallet.type) {
|
||||
// Fire-and-forget: cleans up the per-wallet Arkade Realm (close + delete files)
|
||||
// and the Keychain encryption key. Errors stay scoped to the Ark wallet path
|
||||
// and never block deletion.
|
||||
(wallet as LightningArkWallet).onDelete().catch(e => console.warn('[StorageProvider] Ark wallet cleanup failed:', e?.message ?? e));
|
||||
if (!BlueApp.getWallets().some(w => w.type === LightningArkWallet.type)) {
|
||||
stopArkBackgroundTask().catch(e => console.warn('[StorageProvider] Ark background task stop failed:', e?.message ?? e));
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleWalletDeletion = useCallback(
|
||||
@ -318,11 +307,7 @@ export const StorageProvider = ({ children }: { children: React.ReactNode }) =>
|
||||
if (walletsInitialized) {
|
||||
txMetadata.current = BlueApp.tx_metadata;
|
||||
counterpartyMetadata.current = BlueApp.counterparty_metadata;
|
||||
const loaded = BlueApp.getWallets();
|
||||
setWallets(loaded);
|
||||
if (loaded.some(w => w.type === LightningArkWallet.type)) {
|
||||
registerArkBackgroundTask().catch(e => console.warn('[StorageProvider] Ark background task register failed:', e?.message ?? e));
|
||||
}
|
||||
setWallets(BlueApp.getWallets());
|
||||
}
|
||||
}, [walletsInitialized]);
|
||||
|
||||
@ -468,9 +453,6 @@ export const StorageProvider = ({ children }: { children: React.ReactNode }) =>
|
||||
if (w.getLabel() === emptyWalletLabel) w.setLabel(loc.wallets.import_imported + ' ' + w.typeReadable);
|
||||
w.setUserHasSavedExport(true);
|
||||
addWallet(w);
|
||||
if (w instanceof LightningArkWallet) {
|
||||
registerArkBackgroundTask().catch(e => console.warn('[StorageProvider] Ark background task register failed:', e?.message ?? e));
|
||||
}
|
||||
if (getScanWasBBQR()) {
|
||||
// to avoid proxying `useBBQR` through a bunch of screens during import procedure, we use a trick:
|
||||
// on add-wallet screen we reset `lastScanWasBBQR` to false. then potentially user scans QR in BBQR format
|
||||
|
||||
@ -52,14 +52,7 @@ const useFloatButtonAnimation = (initialHeight: number) => {
|
||||
};
|
||||
};
|
||||
|
||||
const getScaledButtonHeight = (fontScale: number): number => Math.round(LAYOUT.BUTTON_HEIGHT * fontScale);
|
||||
|
||||
/** Scroll padding so list content clears float buttons (excludes safe-area inset). Default 70 at fontScale 1. */
|
||||
const FLOAT_BUTTON_LIST_CLEARANCE = 18;
|
||||
|
||||
export const getFloatingButtonReservedHeight = (fontScale = 1): number => getScaledButtonHeight(fontScale) + FLOAT_BUTTON_LIST_CLEARANCE;
|
||||
|
||||
const useFloatButtonLayout = (width: number, sizeClass: SizeClass, fontScale: number) => {
|
||||
const useFloatButtonLayout = (width: number, sizeClass: SizeClass) => {
|
||||
const lastVerticalDecision = useRef(false);
|
||||
|
||||
const shouldUseVerticalLayout = useCallback(
|
||||
@ -159,19 +152,15 @@ const useFloatButtonLayout = (width: number, sizeClass: SizeClass, fontScale: nu
|
||||
[width, sizeClass, shouldUseVerticalLayout],
|
||||
);
|
||||
|
||||
const calculateContainerHeight = useCallback(
|
||||
(childrenCount: number, isVerticalLayout: boolean) => {
|
||||
const buttonHeight = getScaledButtonHeight(fontScale);
|
||||
if (!isVerticalLayout) return { height: '8%', minHeight: buttonHeight };
|
||||
const calculateContainerHeight = useCallback((childrenCount: number, isVerticalLayout: boolean) => {
|
||||
if (!isVerticalLayout) return { height: '8%', minHeight: LAYOUT.BUTTON_HEIGHT };
|
||||
|
||||
const totalButtonsHeight = childrenCount * buttonHeight;
|
||||
const totalMarginsHeight = (childrenCount - 1) * LAYOUT.BUTTON_MARGIN;
|
||||
const calculatedHeight = totalButtonsHeight + totalMarginsHeight;
|
||||
const totalButtonsHeight = childrenCount * LAYOUT.BUTTON_HEIGHT;
|
||||
const totalMarginsHeight = (childrenCount - 1) * LAYOUT.BUTTON_MARGIN;
|
||||
const calculatedHeight = totalButtonsHeight + totalMarginsHeight;
|
||||
|
||||
return { height: calculatedHeight };
|
||||
},
|
||||
[fontScale],
|
||||
);
|
||||
return { height: calculatedHeight };
|
||||
}, []);
|
||||
|
||||
const calculateButtonFontSize = useMemo(() => {
|
||||
const divisor = sizeClass === SizeClass.Large ? 22 : sizeClass === SizeClass.Regular ? 24 : 28;
|
||||
@ -278,7 +267,6 @@ interface FButtonProps {
|
||||
isVertical?: boolean;
|
||||
borderRadius?: number;
|
||||
fontSize?: number;
|
||||
buttonHeight?: number;
|
||||
disabled?: boolean;
|
||||
testID?: string;
|
||||
onPress: () => void;
|
||||
@ -289,14 +277,13 @@ interface ButtonContentProps {
|
||||
icon: ReactNode;
|
||||
text: string;
|
||||
textStyle: StyleProp<TextStyle>;
|
||||
buttonHeight: number;
|
||||
}
|
||||
|
||||
const getScaledIconSize = (fontSize: number): number => {
|
||||
return Math.max(Math.round(fontSize * 1.2), 16);
|
||||
};
|
||||
|
||||
const ButtonContent = ({ icon, text, textStyle, buttonHeight }: ButtonContentProps) => {
|
||||
const ButtonContent = ({ icon, text, textStyle }: ButtonContentProps) => {
|
||||
const computedStyle = StyleSheet.flatten(textStyle);
|
||||
const fontSize = computedStyle.fontSize || LAYOUT.MAX_BUTTON_FONT_SIZE;
|
||||
const iconSize = getScaledIconSize(Number(fontSize));
|
||||
@ -320,14 +307,9 @@ const ButtonContent = ({ icon, text, textStyle, buttonHeight }: ButtonContentPro
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[buttonContentStaticStyles.contentContainer, { minHeight: buttonHeight }]}>
|
||||
<View style={buttonContentStaticStyles.contentContainer}>
|
||||
<View style={buttonStyles.iconContainer}>{scaledIcon}</View>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
adjustsFontSizeToFit
|
||||
minimumFontScale={0.8}
|
||||
style={[textStyle, buttonStyles.centeredText, { lineHeight: fontSize * 1.2 }]}
|
||||
>
|
||||
<Text numberOfLines={1} adjustsFontSizeToFit style={[textStyle, buttonStyles.centeredText, { lineHeight: fontSize * 1.2 }]}>
|
||||
{text}
|
||||
</Text>
|
||||
</View>
|
||||
@ -343,7 +325,6 @@ export const FButton = ({
|
||||
isVertical,
|
||||
borderRadius = LAYOUT.PILL_BORDER_RADIUS,
|
||||
fontSize = LAYOUT.MAX_BUTTON_FONT_SIZE,
|
||||
buttonHeight = LAYOUT.BUTTON_HEIGHT,
|
||||
testID,
|
||||
...props
|
||||
}: FButtonProps) => {
|
||||
@ -366,8 +347,6 @@ export const FButton = ({
|
||||
return {
|
||||
root: {
|
||||
...baseStyles,
|
||||
height: buttonHeight,
|
||||
minHeight: buttonHeight,
|
||||
backgroundColor: colors.buttonBackgroundColor,
|
||||
},
|
||||
text: {
|
||||
@ -381,7 +360,7 @@ export const FButton = ({
|
||||
marginBottom: buttonContentStaticStyles.marginBottom,
|
||||
textBase: buttonContentStaticStyles.textBase,
|
||||
};
|
||||
}, [colors, fontSize, buttonHeight]);
|
||||
}, [colors, fontSize]);
|
||||
|
||||
const style: Record<string, any> = {};
|
||||
const additionalStyles = !last ? (isVertical ? customButtonStyles.marginBottom : customButtonStyles.marginRight) : {};
|
||||
@ -418,7 +397,7 @@ export const FButton = ({
|
||||
style={[buttonStyles.root, customButtonStyles.root, style, { borderRadius }]}
|
||||
{...props}
|
||||
>
|
||||
<ButtonContent icon={icon} text={text} textStyle={textStyle} buttonHeight={buttonHeight} />
|
||||
<ButtonContent icon={icon} text={text} textStyle={textStyle} />
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
);
|
||||
@ -426,9 +405,8 @@ export const FButton = ({
|
||||
|
||||
export const FContainer = forwardRef<View, FContainerProps>((props, ref) => {
|
||||
const insets = useSafeAreaInsets();
|
||||
const { height, width, fontScale } = useWindowDimensions();
|
||||
const { height, width } = useWindowDimensions();
|
||||
const { sizeClass } = useSizeClass();
|
||||
const scaledButtonHeight = getScaledButtonHeight(fontScale);
|
||||
|
||||
const childrenCount = React.Children.toArray(props.children).filter(Boolean).length;
|
||||
|
||||
@ -441,7 +419,6 @@ export const FContainer = forwardRef<View, FContainerProps>((props, ref) => {
|
||||
const { calculateButtonWidth, calculateVisualParameters, calculateContainerHeight, buttonFontSize } = useFloatButtonLayout(
|
||||
width,
|
||||
sizeClass,
|
||||
fontScale,
|
||||
);
|
||||
|
||||
// Compute initial geometry up-front so the slide-in animation starts at the final (computed) size,
|
||||
@ -531,7 +508,7 @@ export const FContainer = forwardRef<View, FContainerProps>((props, ref) => {
|
||||
|
||||
useEffect(() => {
|
||||
debouncedCalculateLayout();
|
||||
}, [debouncedCalculateLayout, width, height, childrenCount, sizeClass, fontScale]);
|
||||
}, [debouncedCalculateLayout, width, height, childrenCount, sizeClass]);
|
||||
|
||||
const onLayout = (event: { nativeEvent: { layout: { width: number } } }) => {
|
||||
const { width: currentLayoutWidth } = event.nativeEvent.layout;
|
||||
@ -568,7 +545,6 @@ export const FContainer = forwardRef<View, FContainerProps>((props, ref) => {
|
||||
isVertical,
|
||||
borderRadius: buttonBorderRadius,
|
||||
fontSize: buttonFontSize,
|
||||
buttonHeight: scaledButtonHeight,
|
||||
});
|
||||
};
|
||||
|
||||
@ -585,10 +561,10 @@ export const FContainer = forwardRef<View, FContainerProps>((props, ref) => {
|
||||
props.inline ? containerStyles.rootInline : containerStyles.rootAbsolute,
|
||||
bottomInsets,
|
||||
effectiveNewWidth ? (isVertical ? containerStyles.rootPostVertical : containerStyles.rootPost) : containerStyles.rootPre,
|
||||
isVertical ? containerHeight : { minHeight: scaledButtonHeight },
|
||||
isVertical ? containerHeight : null,
|
||||
{ transform: [{ translateY: slideAnimation }] },
|
||||
],
|
||||
[props.inline, bottomInsets, effectiveNewWidth, isVertical, containerHeight, slideAnimation, scaledButtonHeight],
|
||||
[props.inline, bottomInsets, effectiveNewWidth, isVertical, containerHeight, slideAnimation],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@ -1,13 +1,10 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Pressable, StyleProp, StyleSheet, Switch, SwitchProps, Text, TextStyle, useWindowDimensions, View, ViewStyle } from 'react-native';
|
||||
import { Pressable, StyleProp, StyleSheet, Switch, SwitchProps, Text, TextStyle, View, ViewStyle } from 'react-native';
|
||||
import { useLocale } from '@react-navigation/native';
|
||||
|
||||
import Icon from './Icon';
|
||||
import { useTheme } from './themes';
|
||||
|
||||
/** Base row height for transaction list `getItemLayout` (padding + title + subtitle at fontScale 1). */
|
||||
export const TX_ROW_BASE_HEIGHT = 64;
|
||||
|
||||
interface ListItemProps {
|
||||
leftAvatar?: React.JSX.Element;
|
||||
containerStyle?: StyleProp<ViewStyle>;
|
||||
@ -24,7 +21,6 @@ interface ListItemProps {
|
||||
subtitleNumberOfLines?: number;
|
||||
rightTitle?: string;
|
||||
rightTitleStyle?: StyleProp<TextStyle>;
|
||||
rightTitleSelectable?: boolean;
|
||||
rightSubtitle?: string | React.ReactNode;
|
||||
rightSubtitleStyle?: StyleProp<TextStyle>;
|
||||
chevron?: boolean;
|
||||
@ -49,7 +45,6 @@ const ListItem: React.FC<ListItemProps> = React.memo(
|
||||
subtitleNumberOfLines,
|
||||
rightTitle,
|
||||
rightTitleStyle,
|
||||
rightTitleSelectable,
|
||||
rightSubtitle,
|
||||
rightSubtitleStyle,
|
||||
chevron,
|
||||
@ -58,20 +53,12 @@ const ListItem: React.FC<ListItemProps> = React.memo(
|
||||
}: ListItemProps) => {
|
||||
const { colors } = useTheme();
|
||||
const { direction } = useLocale();
|
||||
const { fontScale } = useWindowDimensions();
|
||||
const isRtl = direction === 'rtl';
|
||||
const contentRowStyle = useMemo(
|
||||
() => ({
|
||||
paddingVertical: Math.round(12 * fontScale),
|
||||
}),
|
||||
[fontScale],
|
||||
);
|
||||
const stylesHook = StyleSheet.create({
|
||||
title: {
|
||||
color: disabled ? colors.buttonDisabledTextColor : colors.foregroundColor,
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
lineHeight: Math.round(22 * fontScale),
|
||||
writingDirection: direction,
|
||||
},
|
||||
rightMemoText: {
|
||||
@ -83,7 +70,7 @@ const ListItem: React.FC<ListItemProps> = React.memo(
|
||||
color: colors.alternativeTextColor,
|
||||
fontWeight: '400',
|
||||
paddingVertical: switchProps ? 8 : 0,
|
||||
lineHeight: Math.round(20 * fontScale),
|
||||
lineHeight: 20,
|
||||
fontSize: 14,
|
||||
marginTop: 2,
|
||||
},
|
||||
@ -104,7 +91,7 @@ const ListItem: React.FC<ListItemProps> = React.memo(
|
||||
const enableFeedback = !noFeedback && !!onPress && !disabled;
|
||||
|
||||
const renderContent = () => (
|
||||
<View style={[styles.contentRow, contentRowStyle]}>
|
||||
<View style={styles.contentRow}>
|
||||
{leftAvatar && (
|
||||
<View style={styles.leftAvatarContainer}>
|
||||
{leftAvatar}
|
||||
@ -125,14 +112,7 @@ const ListItem: React.FC<ListItemProps> = React.memo(
|
||||
{rightTitle || rightSubtitle ? (
|
||||
<View style={styles.rightColumn}>
|
||||
{rightTitle ? (
|
||||
<Text
|
||||
style={rightTitleStyle}
|
||||
numberOfLines={1}
|
||||
adjustsFontSizeToFit
|
||||
minimumFontScale={0.75}
|
||||
accessibilityRole="text"
|
||||
selectable={rightTitleSelectable}
|
||||
>
|
||||
<Text style={rightTitleStyle} numberOfLines={1} accessibilityRole="text">
|
||||
{rightTitle}
|
||||
</Text>
|
||||
) : null}
|
||||
@ -210,20 +190,16 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
leftAvatarContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
alignSelf: 'center',
|
||||
},
|
||||
rightColumn: {
|
||||
marginStart: 8,
|
||||
flexShrink: 0,
|
||||
minWidth: 0,
|
||||
alignItems: 'flex-end',
|
||||
alignSelf: 'center',
|
||||
},
|
||||
rightMemoWrapper: {
|
||||
flexShrink: 1,
|
||||
|
||||
@ -216,11 +216,24 @@ const QRCode: React.FC<QRCodeProps> = ({
|
||||
const gradFill = `url(#${GRADIENT_ID})`;
|
||||
|
||||
const finderShapes: React.ReactElement[] = [];
|
||||
const outerR = 2 * cell;
|
||||
const holeR = 1.25 * cell;
|
||||
const dotR = 0.9 * cell;
|
||||
finderOrigins.forEach(([fr, fc], i) => {
|
||||
const x = (fc + 1) * cell;
|
||||
const y = (fr + 1) * cell;
|
||||
finderShapes.push(
|
||||
<Rect key={`finder-frame-${i}`} testID="qr-finder-frame" x={x} y={y} width={7 * cell} height={7 * cell} fill={gradFill} />,
|
||||
<Rect
|
||||
key={`finder-frame-${i}`}
|
||||
testID="qr-finder-frame"
|
||||
x={x}
|
||||
y={y}
|
||||
width={7 * cell}
|
||||
height={7 * cell}
|
||||
rx={outerR}
|
||||
ry={outerR}
|
||||
fill={gradFill}
|
||||
/>,
|
||||
<Rect
|
||||
key={`finder-hole-${i}`}
|
||||
testID="qr-finder-hole"
|
||||
@ -228,6 +241,8 @@ const QRCode: React.FC<QRCodeProps> = ({
|
||||
y={y + cell}
|
||||
width={5 * cell}
|
||||
height={5 * cell}
|
||||
rx={holeR}
|
||||
ry={holeR}
|
||||
fill={BACKGROUND}
|
||||
/>,
|
||||
<Rect
|
||||
@ -237,6 +252,8 @@ const QRCode: React.FC<QRCodeProps> = ({
|
||||
y={y + 2 * cell}
|
||||
width={3 * cell}
|
||||
height={3 * cell}
|
||||
rx={dotR}
|
||||
ry={dotR}
|
||||
fill={gradFill}
|
||||
/>,
|
||||
);
|
||||
@ -260,7 +277,16 @@ const QRCode: React.FC<QRCodeProps> = ({
|
||||
{finderShapes}
|
||||
{isLogoRendered && logoCells > 0 && (
|
||||
<>
|
||||
<Rect testID="qr-logo-backdrop" x={backdropX} y={backdropY} width={backdropSize} height={backdropSize} fill={LOGO_BACKGROUND} />
|
||||
<Rect
|
||||
testID="qr-logo-backdrop"
|
||||
x={backdropX}
|
||||
y={backdropY}
|
||||
width={backdropSize}
|
||||
height={backdropSize}
|
||||
rx={cell * 0.5}
|
||||
ry={cell * 0.5}
|
||||
fill={LOGO_BACKGROUND}
|
||||
/>
|
||||
<SvgImage
|
||||
testID="qr-logo-image"
|
||||
href={require('../img/qr-code.png')}
|
||||
|
||||
@ -67,25 +67,26 @@ const ReplaceFeeSuggestions: React.FC<ReplaceFeeSuggestionsProps> = ({ onFeeSele
|
||||
}, []);
|
||||
|
||||
const handleFeeSelection = (feeType: NetworkTransactionFeeType) => {
|
||||
if (feeType === NetworkTransactionFeeType.CUSTOM) {
|
||||
setSelectedFeeType(feeType);
|
||||
return;
|
||||
if (feeType !== NetworkTransactionFeeType.CUSTOM) {
|
||||
Keyboard.dismiss();
|
||||
}
|
||||
|
||||
Keyboard.dismiss();
|
||||
if (networkFees) {
|
||||
let selectedFee: number;
|
||||
switch (feeType) {
|
||||
case NetworkTransactionFeeType.FAST:
|
||||
onFeeSelected(networkFees.fastestFee);
|
||||
selectedFee = networkFees.fastestFee;
|
||||
break;
|
||||
case NetworkTransactionFeeType.MEDIUM:
|
||||
onFeeSelected(networkFees.mediumFee);
|
||||
selectedFee = networkFees.mediumFee;
|
||||
break;
|
||||
case NetworkTransactionFeeType.SLOW:
|
||||
onFeeSelected(networkFees.slowFee);
|
||||
selectedFee = networkFees.slowFee;
|
||||
break;
|
||||
case NetworkTransactionFeeType.CUSTOM:
|
||||
selectedFee = Number(customFeeValue);
|
||||
break;
|
||||
}
|
||||
|
||||
onFeeSelected(selectedFee);
|
||||
setSelectedFeeType(feeType);
|
||||
}
|
||||
};
|
||||
@ -93,8 +94,7 @@ const ReplaceFeeSuggestions: React.FC<ReplaceFeeSuggestionsProps> = ({ onFeeSele
|
||||
const handleCustomFeeChange = (customFee: string) => {
|
||||
const sanitizedFee = customFee.replace(/[^0-9]/g, '');
|
||||
setCustomFeeValue(sanitizedFee);
|
||||
onFeeSelected(Number(sanitizedFee));
|
||||
setSelectedFeeType(NetworkTransactionFeeType.CUSTOM);
|
||||
handleFeeSelection(NetworkTransactionFeeType.CUSTOM);
|
||||
};
|
||||
|
||||
return (
|
||||
@ -156,10 +156,7 @@ const ReplaceFeeSuggestions: React.FC<ReplaceFeeSuggestionsProps> = ({ onFeeSele
|
||||
ref={customTextInput}
|
||||
maxLength={9}
|
||||
style={[styles.customFeeInput, stylesHook.customFeeInput]}
|
||||
onFocus={() => {
|
||||
setSelectedFeeType(NetworkTransactionFeeType.CUSTOM);
|
||||
onFeeSelected(Number(customFeeValue));
|
||||
}}
|
||||
onFocus={() => handleCustomFeeChange(customFeeValue)}
|
||||
placeholder={loc.send.fee_satvbyte}
|
||||
placeholderTextColor="#81868e"
|
||||
inputAccessoryViewID={DismissKeyboardInputAccessoryViewID}
|
||||
|
||||
@ -7,18 +7,10 @@ import { useTheme } from './themes';
|
||||
interface SafeAreaScrollViewProps extends ScrollViewProps {
|
||||
floatingButtonHeight?: number;
|
||||
headerHeight?: number; // Additional header height to account for (e.g., when headerTransparent is true)
|
||||
disableDefaultTopPadding?: boolean;
|
||||
}
|
||||
|
||||
const SafeAreaScrollView = forwardRef<ScrollView, SafeAreaScrollViewProps>((props, ref) => {
|
||||
const {
|
||||
style,
|
||||
contentContainerStyle,
|
||||
floatingButtonHeight = 0,
|
||||
headerHeight = 0,
|
||||
disableDefaultTopPadding = false,
|
||||
...otherProps
|
||||
} = props;
|
||||
const { style, contentContainerStyle, floatingButtonHeight = 0, headerHeight = 0, ...otherProps } = props;
|
||||
const { colors } = useTheme();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
@ -40,10 +32,7 @@ const SafeAreaScrollView = forwardRef<ScrollView, SafeAreaScrollViewProps>((prop
|
||||
if (headerHeight > 0) {
|
||||
return headerHeight;
|
||||
}
|
||||
if (disableDefaultTopPadding) {
|
||||
return 0;
|
||||
}
|
||||
// Preserve legacy behavior for existing screens
|
||||
// iOS safe area or no status bar
|
||||
return insets.top > 0 ? 5 : 0;
|
||||
})(),
|
||||
};
|
||||
@ -59,7 +48,7 @@ const SafeAreaScrollView = forwardRef<ScrollView, SafeAreaScrollViewProps>((prop
|
||||
|
||||
// Now compose with contentContainerStyle to ensure passed styles override defaults
|
||||
return StyleSheet.compose(basePadding, contentContainerStyle);
|
||||
}, [insets, contentContainerStyle, floatingButtonHeight, headerHeight, disableDefaultTopPadding]);
|
||||
}, [insets, contentContainerStyle, floatingButtonHeight, headerHeight]);
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import React, { useMemo, useCallback } from 'react';
|
||||
import { TouchableOpacity, Text, StyleSheet, View, useWindowDimensions } from 'react-native';
|
||||
import { TouchableOpacity, Text, StyleSheet, View } from 'react-native';
|
||||
import { useStorage } from '../hooks/context/useStorage';
|
||||
import loc, { formatBalanceWithoutSuffix } from '../loc';
|
||||
import { BitcoinUnit } from '../models/bitcoinUnits';
|
||||
@ -22,7 +22,6 @@ const TotalWalletsBalance: React.FC = React.memo(() => {
|
||||
setTotalBalancePreferredUnitStorage,
|
||||
} = useSettings();
|
||||
const { colors } = useTheme();
|
||||
const { fontScale } = useWindowDimensions();
|
||||
|
||||
const totalBalanceFormatted = useMemo(() => {
|
||||
const totalBalance = wallets.reduce((prev, curr) => {
|
||||
@ -32,22 +31,6 @@ const TotalWalletsBalance: React.FC = React.memo(() => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [wallets, totalBalancePreferredUnit, preferredFiatCurrency]);
|
||||
|
||||
const scaledStyles = useMemo(
|
||||
() => ({
|
||||
container: {
|
||||
paddingVertical: Math.round(8 * fontScale),
|
||||
},
|
||||
label: {
|
||||
lineHeight: Math.round(18 * fontScale),
|
||||
marginBottom: Math.round(2 * fontScale),
|
||||
},
|
||||
balance: {
|
||||
lineHeight: Math.round(38 * Math.max(1, fontScale)),
|
||||
},
|
||||
}),
|
||||
[fontScale],
|
||||
);
|
||||
|
||||
const toolTipActions = useMemo(
|
||||
() => [
|
||||
{
|
||||
@ -109,20 +92,13 @@ const TotalWalletsBalance: React.FC = React.memo(() => {
|
||||
|
||||
return (
|
||||
<ToolTipMenu actions={toolTipActions} onPressMenuItem={onPressMenuItem} shouldOpenOnLongPress style={styles.menuContainer}>
|
||||
<View style={[styles.container, scaledStyles.container]}>
|
||||
<Text style={[styles.label, scaledStyles.label]} numberOfLines={1} adjustsFontSizeToFit minimumFontScale={0.8}>
|
||||
{loc.wallets.total_balance}
|
||||
</Text>
|
||||
<TouchableOpacity onPress={handleBalanceOnPress} style={styles.balanceTouchable}>
|
||||
<Text
|
||||
style={[styles.balance, scaledStyles.balance, { color: colors.foregroundColor }]}
|
||||
numberOfLines={1}
|
||||
adjustsFontSizeToFit
|
||||
minimumFontScale={0.55}
|
||||
>
|
||||
{totalBalanceFormatted}
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.label}>{loc.wallets.total_balance}</Text>
|
||||
<TouchableOpacity onPress={handleBalanceOnPress}>
|
||||
<Text style={[styles.balance, { color: colors.foregroundColor }]}>
|
||||
{totalBalanceFormatted}{' '}
|
||||
{totalBalancePreferredUnit !== BitcoinUnit.LOCAL_CURRENCY && (
|
||||
<Text style={[styles.currency, { color: colors.foregroundColor }]}>{` ${totalBalancePreferredUnit}`}</Text>
|
||||
<Text style={[styles.currency, { color: colors.foregroundColor }]}>{totalBalancePreferredUnit}</Text>
|
||||
)}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
@ -140,11 +116,6 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'flex-start',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
width: '100%',
|
||||
},
|
||||
balanceTouchable: {
|
||||
alignSelf: 'stretch',
|
||||
width: '100%',
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
@ -154,7 +125,6 @@ const styles = StyleSheet.create({
|
||||
balance: {
|
||||
fontSize: 32,
|
||||
fontWeight: 'bold',
|
||||
lineHeight: 38,
|
||||
},
|
||||
currency: {
|
||||
fontSize: 18,
|
||||
|
||||
@ -1,9 +1,8 @@
|
||||
import React, { memo, useCallback, useMemo, useRef } from 'react';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import Clipboard from '@react-native-clipboard/clipboard';
|
||||
import { Animated, Easing, Linking, Pressable, Text, TextStyle, ViewStyle, StyleSheet, View, useWindowDimensions } from 'react-native';
|
||||
import { Animated, Easing, Linking, Pressable, Text, TextStyle, ViewStyle, StyleSheet, View } from 'react-native';
|
||||
import Lnurl from '../class/lnurl';
|
||||
import { LightningArkWallet } from '../class/wallets/lightning-ark-wallet';
|
||||
import { LightningTransaction, Transaction } from '../class/wallets/types';
|
||||
import TransactionExpiredIcon from '../components/icons/TransactionExpiredIcon';
|
||||
import TransactionIncomingIcon from '../components/icons/TransactionIncomingIcon';
|
||||
@ -29,6 +28,9 @@ import { uint8ArrayToHex } from '../blue_modules/uint8array-extras';
|
||||
import ListItem from './ListItem';
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
dateLine: {
|
||||
fontSize: 13,
|
||||
},
|
||||
fullWidthButton: {
|
||||
width: '100%',
|
||||
alignSelf: 'stretch',
|
||||
@ -130,7 +132,6 @@ const TransactionListItemComponent: React.FC<TransactionListItemProps> = ({
|
||||
const { txMetadata, counterpartyMetadata, wallets } = useStorage();
|
||||
const { language, selectedBlockExplorer } = useSettings();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { fontScale } = useWindowDimensions();
|
||||
const containerStyle = useMemo(
|
||||
() => ({
|
||||
backgroundColor: colors.background,
|
||||
@ -153,30 +154,7 @@ const TransactionListItemComponent: React.FC<TransactionListItemProps> = ({
|
||||
const txMemo = (counterparty ? `[${shortenContactName(counterparty)}] ` : '') + (txMetadata[item.hash]?.memo ?? '');
|
||||
const noteForCopy = (txMemo || item.memo || '').trim() || undefined;
|
||||
|
||||
// For LightningArkWallet rows, prepend a kind tag to the date subtitle. Such a
|
||||
// wallet transacts entirely via Boltz swaps, so every row is Lightning; the
|
||||
// only genuinely on-chain activity is onboarding/refill (boarding UTXOs),
|
||||
// tagged from the synthetic `boarding-…` txid set in
|
||||
// lightning-ark-wallet.getTransactions(). Other wallet types are unaffected.
|
||||
const arkRowKind = useMemo<'Lightning' | 'Refill' | undefined>(() => {
|
||||
const wallet = wallets.find(w => w.getID() === item.walletID);
|
||||
if (wallet?.type !== LightningArkWallet.type) return undefined;
|
||||
const txid = (item as { txid?: string }).txid;
|
||||
if (txid?.startsWith('boarding-')) return 'Refill';
|
||||
return 'Lightning';
|
||||
}, [item, wallets]);
|
||||
|
||||
// A refill is "Pending" until the SDK settles its boarding UTXO into a VTXO
|
||||
// (also when it enters the spendable balance). getTransactions() pass 2 tags
|
||||
// those not-yet-settled rows with a `boarding-utxo-…` id; settled refills use
|
||||
// `boarding-…` and render as a normal confirmed receive.
|
||||
const isPendingRefill = useMemo(
|
||||
() => arkRowKind === 'Refill' && !!(item as { txid?: string }).txid?.startsWith('boarding-utxo-'),
|
||||
[arkRowKind, item],
|
||||
);
|
||||
|
||||
const listTitleKey = useMemo((): 'pending' | 'sent' | 'received' => {
|
||||
if (isPendingRefill) return 'pending';
|
||||
if (item.category === 'receive' && item.confirmations! < 3) return 'pending';
|
||||
if (item.type === 'bitcoind_tx') return item.value! < 0 ? 'sent' : 'received';
|
||||
if (item.type === 'paid_invoice') return 'sent';
|
||||
@ -186,7 +164,7 @@ const TransactionListItemComponent: React.FC<TransactionListItemProps> = ({
|
||||
}
|
||||
if (!item.confirmations) return 'pending';
|
||||
return item.value! < 0 ? 'sent' : 'received';
|
||||
}, [isPendingRefill, item.category, item.confirmations, item.type, item.value, item.ispaid]);
|
||||
}, [item.category, item.confirmations, item.type, item.value, item.ispaid]);
|
||||
|
||||
const listTitle = useMemo(() => {
|
||||
if (listTitleKey === 'pending') return loc.transactions.pending;
|
||||
@ -197,11 +175,11 @@ const TransactionListItemComponent: React.FC<TransactionListItemProps> = ({
|
||||
const isPending = listTitleKey === 'pending';
|
||||
|
||||
const dateLine = useMemo(() => {
|
||||
const formatted = isPending ? transactionTimeToReadable(item.timestamp) : formatTransactionListDate(item.timestamp * 1000);
|
||||
return arkRowKind ? `${arkRowKind} · ${formatted}` : formatted;
|
||||
if (isPending) return transactionTimeToReadable(item.timestamp);
|
||||
return formatTransactionListDate(item.timestamp * 1000);
|
||||
// language in deps so date format updates when locale changes (formatters use global locale)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isPending, item.timestamp, language, arkRowKind]);
|
||||
}, [isPending, item.timestamp, language]);
|
||||
|
||||
const formattedAmount = useMemo(() => {
|
||||
return formatBalanceWithoutSuffix(item.value, itemPriceUnit, true).toString();
|
||||
@ -246,7 +224,6 @@ const TransactionListItemComponent: React.FC<TransactionListItemProps> = ({
|
||||
color,
|
||||
fontSize: 14,
|
||||
fontWeight: '600' as TextStyle['fontWeight'],
|
||||
lineHeight: Math.round(20 * fontScale),
|
||||
textAlign: 'right',
|
||||
paddingRight: insets.right,
|
||||
paddingLeft: insets.left,
|
||||
@ -261,18 +238,9 @@ const TransactionListItemComponent: React.FC<TransactionListItemProps> = ({
|
||||
item.ispaid,
|
||||
insets.right,
|
||||
insets.left,
|
||||
fontScale,
|
||||
]);
|
||||
|
||||
const determineTransactionTypeAndAvatar = () => {
|
||||
// A refill awaiting settlement: show it as pending, not as a completed receive.
|
||||
if (isPendingRefill) {
|
||||
return {
|
||||
label: loc.transactions.pending_transaction,
|
||||
icon: <TransactionPendingIcon />,
|
||||
};
|
||||
}
|
||||
|
||||
if (item.category === 'receive' && item.confirmations! < 3) {
|
||||
return {
|
||||
label: loc.transactions.pending_transaction,
|
||||
@ -280,14 +248,6 @@ const TransactionListItemComponent: React.FC<TransactionListItemProps> = ({
|
||||
};
|
||||
}
|
||||
|
||||
// Recovered Arkade Lightning legs are bitcoind_tx but represent Boltz swaps,
|
||||
// not on-chain transfers — render them with the off-chain (Lightning) icon.
|
||||
if (arkRowKind === 'Lightning' && item.type === 'bitcoind_tx') {
|
||||
return item.value! < 0
|
||||
? { label: loc.transactions.offchain, icon: <TransactionOffchainIcon /> }
|
||||
: { label: loc.transactions.incoming_transaction, icon: <TransactionOffchainIncomingIcon /> };
|
||||
}
|
||||
|
||||
if (item.type && item.type === 'bitcoind_tx') {
|
||||
return {
|
||||
label: loc.transactions.onchain,
|
||||
@ -361,11 +321,7 @@ const TransactionListItemComponent: React.FC<TransactionListItemProps> = ({
|
||||
pop();
|
||||
}
|
||||
navigate('TransactionStatus', { hash: item.hash, walletID, tx: item });
|
||||
} else if (item.type === 'user_invoice' || item.type === 'payment_request' || item.type === 'paid_invoice' || item.payment_request) {
|
||||
// A settled Arkade swap is an enriched native Ark leg (type 'bitcoind_tx')
|
||||
// carrying the swap's invoice payload (payment_request/hash/preimage). Route
|
||||
// it to the Lightning invoice view by that payload, not by type — otherwise
|
||||
// it falls through to the on-chain TransactionStatus branch below.
|
||||
} else if (item.type === 'user_invoice' || item.type === 'payment_request' || item.type === 'paid_invoice') {
|
||||
const lightningWallet = wallets.filter(wallet => wallet?.getID() === item.walletID);
|
||||
if (lightningWallet.length === 1) {
|
||||
try {
|
||||
@ -396,24 +352,15 @@ const TransactionListItemComponent: React.FC<TransactionListItemProps> = ({
|
||||
walletID: lightningWallet[0].getID(),
|
||||
});
|
||||
}
|
||||
} else if ((item as { txid?: string }).txid) {
|
||||
// Hash-less Ark rows carry a synthetic `txid`. Native transfer legs
|
||||
// (`ark-…`) open the hash-less-tolerant TransactionStatus detail. Refill
|
||||
// rows (`boarding-…` / `boarding-utxo-…`) have no detail surface and are
|
||||
// not tappable — matching master, where on-chain top-ups aren't tappable.
|
||||
const txid = (item as { txid: string }).txid;
|
||||
if (!txid.startsWith('boarding-')) {
|
||||
navigate('TransactionStatus', { tx: item, hash: txid, walletID });
|
||||
}
|
||||
} else {
|
||||
console.log('cant handle press');
|
||||
}
|
||||
}, [item, renderHighlightedText, navigate, walletID, wallets, customOnPress, disableNavigation]);
|
||||
|
||||
const handleOnDetailsPress = useCallback(() => {
|
||||
if (walletID && item && item.hash) {
|
||||
navigate('TransactionStatus', { hash: item.hash, walletID, tx: item });
|
||||
} else if (item.type === 'user_invoice' || item.type === 'payment_request' || item.type === 'paid_invoice' || item.payment_request) {
|
||||
// Settled Arkade swaps carry invoice data on a 'bitcoind_tx' leg; route by
|
||||
// payload so they open the Lightning invoice view (see onPress above).
|
||||
} else {
|
||||
const lightningWallet = wallets.find(wallet => wallet?.getID() === item.walletID);
|
||||
if (lightningWallet) {
|
||||
navigate('LNDViewInvoice', {
|
||||
@ -421,13 +368,6 @@ const TransactionListItemComponent: React.FC<TransactionListItemProps> = ({
|
||||
walletID: lightningWallet.getID(),
|
||||
});
|
||||
}
|
||||
} else if ((item as { txid?: string }).txid) {
|
||||
// Match the regular tap path for Ark non-swap rows: native transfer legs
|
||||
// open TransactionStatus; refills (`boarding-…`) are not tappable (master).
|
||||
const txid = (item as { txid: string }).txid;
|
||||
if (!txid.startsWith('boarding-')) {
|
||||
navigate('TransactionStatus', { tx: item, hash: txid, walletID });
|
||||
}
|
||||
}
|
||||
}, [item, navigate, walletID, wallets]);
|
||||
|
||||
@ -509,10 +449,7 @@ const TransactionListItemComponent: React.FC<TransactionListItemProps> = ({
|
||||
if (renderHighlightedText && searchQuery) {
|
||||
const highlighted = renderHighlightedText(subtitle, searchQuery);
|
||||
if (React.isValidElement(highlighted)) {
|
||||
const highlightedElement = highlighted as React.ReactElement<{
|
||||
numberOfLines?: number;
|
||||
style?: TextStyle | TextStyle[];
|
||||
}>;
|
||||
const highlightedElement = highlighted as React.ReactElement<{ numberOfLines?: number; style?: TextStyle | TextStyle[] }>;
|
||||
const existingStyle = highlightedElement.props?.style;
|
||||
const mergedStyle: TextStyle[] = (
|
||||
Array.isArray(existingStyle)
|
||||
@ -549,7 +486,7 @@ const TransactionListItemComponent: React.FC<TransactionListItemProps> = ({
|
||||
<ListItem
|
||||
leftAvatar={avatar}
|
||||
title={listTitle}
|
||||
subtitle={dateLine}
|
||||
subtitle={<Text style={styles.dateLine}>{dateLine}</Text>}
|
||||
chevron={false}
|
||||
rightTitle={rowTitle}
|
||||
rightTitleStyle={rowTitleStyle}
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import Clipboard from '@react-native-clipboard/clipboard';
|
||||
import { Platform, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import Animated, { useAnimatedStyle, useSharedValue, withSpring, withTiming } from 'react-native-reanimated';
|
||||
import LinearGradient from 'react-native-linear-gradient';
|
||||
import { useTheme } from './themes';
|
||||
import { LightningArkWallet } from '../class/wallets/lightning-ark-wallet';
|
||||
import { LightningCustodianWallet } from '../class/wallets/lightning-custodian-wallet';
|
||||
import { MultisigHDWallet } from '../class/wallets/multisig-hd-wallet';
|
||||
@ -14,39 +14,34 @@ import { FiatUnit } from '../models/fiatUnit';
|
||||
import { BlurredBalanceView } from './BlurredBalanceView';
|
||||
import { useSettings } from '../hooks/context/useSettings';
|
||||
import ToolTipMenu from './TooltipMenu';
|
||||
import useAnimateOnChange from '../hooks/useAnimateOnChange';
|
||||
import { useLocale } from '@react-navigation/native';
|
||||
import ActionSheet from '../screen/ActionSheet';
|
||||
|
||||
const HERO_BASE_BODY_MIN_HEIGHT = 120;
|
||||
const HERO_MIN_BODY_HEIGHT = Math.round(HERO_BASE_BODY_MIN_HEIGHT * 1.2);
|
||||
const HERO_BOTTOM_PADDING = 32;
|
||||
const WALLET_LABEL_TOP_GAP = 32;
|
||||
|
||||
interface TransactionsNavigationHeaderProps {
|
||||
wallet: TWallet;
|
||||
unit: BitcoinUnit;
|
||||
headerOverlayHeight: number;
|
||||
onWalletUnitChange: (unit: BitcoinUnit) => void;
|
||||
onManageFundsPressed?: (id?: string) => void;
|
||||
onWalletBalanceVisibilityChange?: (shouldHideBalance: boolean) => void;
|
||||
onWalletBalanceVisibilityChange?: (isShouldBeVisible: boolean) => void;
|
||||
unitSwitching?: boolean;
|
||||
}
|
||||
|
||||
const TransactionsNavigationHeader: React.FC<TransactionsNavigationHeaderProps> = ({
|
||||
wallet,
|
||||
headerOverlayHeight,
|
||||
onWalletUnitChange,
|
||||
onManageFundsPressed,
|
||||
onWalletBalanceVisibilityChange,
|
||||
unit = BitcoinUnit.BTC,
|
||||
unitSwitching = false,
|
||||
}) => {
|
||||
const { colors } = useTheme();
|
||||
const { hideBalance } = wallet;
|
||||
const isLightningWallet = wallet.type === LightningCustodianWallet.type || wallet.type === LightningArkWallet.type;
|
||||
const [allowOnchainAddress, setAllowOnchainAddress] = useState(isLightningWallet);
|
||||
const { preferredFiatCurrency } = useSettings();
|
||||
const { direction } = useLocale();
|
||||
const balanceOpacity = useSharedValue(1);
|
||||
const balanceTranslateY = useSharedValue(0);
|
||||
const previousBalance = useRef<string | undefined>(undefined);
|
||||
|
||||
const verifyIfWalletAllowsOnchainAddress = useCallback(() => {
|
||||
if (isLightningWallet) {
|
||||
@ -77,14 +72,13 @@ const TransactionsNavigationHeader: React.FC<TransactionsNavigationHeaderProps>
|
||||
|
||||
const handleBalanceVisibility = useCallback(() => {
|
||||
onWalletBalanceVisibilityChange?.(!hideBalance);
|
||||
}, [hideBalance, onWalletBalanceVisibilityChange]);
|
||||
}, [onWalletBalanceVisibilityChange, hideBalance]);
|
||||
|
||||
const changeWalletBalanceUnit = () => {
|
||||
if (hideBalance) {
|
||||
return;
|
||||
}
|
||||
let newWalletPreferredUnit = wallet.getPreferredBalanceUnit();
|
||||
|
||||
console.debug('[UnitSwitch/UI] tap unit change', { walletID: wallet.getID?.(), current: newWalletPreferredUnit });
|
||||
|
||||
if (newWalletPreferredUnit === BitcoinUnit.BTC) {
|
||||
newWalletPreferredUnit = BitcoinUnit.SATS;
|
||||
} else if (newWalletPreferredUnit === BitcoinUnit.SATS) {
|
||||
@ -93,6 +87,7 @@ const TransactionsNavigationHeader: React.FC<TransactionsNavigationHeaderProps>
|
||||
newWalletPreferredUnit = BitcoinUnit.BTC;
|
||||
}
|
||||
|
||||
console.debug('[UnitSwitch/UI] next unit resolved', { walletID: wallet.getID?.(), next: newWalletPreferredUnit });
|
||||
onWalletUnitChange(newWalletPreferredUnit);
|
||||
};
|
||||
|
||||
@ -107,34 +102,29 @@ const TransactionsNavigationHeader: React.FC<TransactionsNavigationHeaderProps>
|
||||
|
||||
const onPressMenuItem = useCallback(
|
||||
(id: string) => {
|
||||
if (id === actionKeys.WalletBalanceVisibility) {
|
||||
if (id === 'walletBalanceVisibility') {
|
||||
handleBalanceVisibility();
|
||||
} else if (id === actionKeys.CopyToClipboard) {
|
||||
} else if (id === 'copyToClipboard') {
|
||||
handleCopyPress();
|
||||
}
|
||||
},
|
||||
[handleBalanceVisibility, handleCopyPress],
|
||||
);
|
||||
|
||||
// The Manage Funds menu is presented via a JS ActionSheet rather than the
|
||||
// native context menu (ToolTipMenu): react-native-context-menu-view is
|
||||
// Paper-only and, routed through Fabric's legacy interop on the New
|
||||
// Architecture, its host view gets mispositioned to the header origin —
|
||||
// overlapping the wallet label. A plain TouchableOpacity + ActionSheet lays
|
||||
// out correctly (same pattern as the Multisig button below).
|
||||
const showManageFundsActionSheet = useCallback(() => {
|
||||
ActionSheet.showActionSheetWithOptions(
|
||||
const toolTipActions = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
title: loc.lnd.title,
|
||||
options: [loc._.cancel, loc.lnd.refill, loc.lnd.refill_external],
|
||||
cancelButtonIndex: 0,
|
||||
id: actionKeys.Refill,
|
||||
text: loc.lnd.refill,
|
||||
icon: actionIcons.Refill,
|
||||
},
|
||||
buttonIndex => {
|
||||
if (buttonIndex === 1) handleManageFundsPressed(actionKeys.Refill);
|
||||
else if (buttonIndex === 2) handleManageFundsPressed(actionKeys.RefillWithExternalWallet);
|
||||
{
|
||||
id: actionKeys.RefillWithExternalWallet,
|
||||
text: loc.lnd.refill_external,
|
||||
icon: actionIcons.RefillWithExternalWallet,
|
||||
},
|
||||
);
|
||||
}, [handleManageFundsPressed]);
|
||||
];
|
||||
}, []);
|
||||
|
||||
const currentBalance = wallet ? wallet.getBalance() : 0;
|
||||
const formattedBalance = useMemo(() => {
|
||||
@ -144,48 +134,84 @@ const TransactionsNavigationHeader: React.FC<TransactionsNavigationHeaderProps>
|
||||
}, [unit, currentBalance]);
|
||||
|
||||
const balance = !wallet.hideBalance && formattedBalance;
|
||||
const safeBalance = balance ? String(balance) : undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (hideBalance) {
|
||||
previousBalance.current = undefined;
|
||||
balanceOpacity.value = 1;
|
||||
balanceTranslateY.value = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
if (previousBalance.current !== undefined && previousBalance.current !== safeBalance) {
|
||||
balanceOpacity.value = 0;
|
||||
balanceTranslateY.value = 6;
|
||||
balanceOpacity.value = withTiming(1, { duration: 180 });
|
||||
balanceTranslateY.value = withSpring(0, { damping: 16, stiffness: 220 });
|
||||
}
|
||||
|
||||
previousBalance.current = safeBalance;
|
||||
}, [safeBalance, hideBalance, balanceOpacity, balanceTranslateY]);
|
||||
|
||||
const balanceAnimationKey = useMemo(
|
||||
() => `${wallet.getID?.() ?? ''}-${unit}-${hideBalance}-${safeBalance ?? ''}`,
|
||||
[safeBalance, hideBalance, unit, wallet],
|
||||
);
|
||||
const balanceAnimatedStyle = useAnimateOnChange(balanceAnimationKey);
|
||||
|
||||
const animatedBalanceTextStyle = useAnimatedStyle(() => ({
|
||||
opacity: balanceOpacity.value,
|
||||
transform: [{ translateY: balanceTranslateY.value }],
|
||||
}));
|
||||
|
||||
const toolTipWalletBalanceActions = useMemo(() => {
|
||||
return hideBalance
|
||||
? [
|
||||
{
|
||||
id: actionKeys.WalletBalanceVisibility,
|
||||
id: 'walletBalanceVisibility',
|
||||
text: loc.transactions.details_balance_show,
|
||||
icon: actionIcons.Eye,
|
||||
icon: {
|
||||
iconValue: 'eye',
|
||||
},
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
id: actionKeys.WalletBalanceVisibility,
|
||||
id: 'walletBalanceVisibility',
|
||||
text: loc.transactions.details_balance_hide,
|
||||
icon: actionIcons.EyeSlash,
|
||||
icon: {
|
||||
iconValue: 'eye.slash',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: actionKeys.CopyToClipboard,
|
||||
id: 'copyToClipboard',
|
||||
text: loc.transactions.details_copy,
|
||||
icon: actionIcons.Clipboard,
|
||||
icon: {
|
||||
iconValue: 'doc.on.doc',
|
||||
},
|
||||
},
|
||||
];
|
||||
}, [hideBalance]);
|
||||
|
||||
useEffect(() => {
|
||||
console.debug('[UnitSwitch/UI] render state', {
|
||||
walletID: wallet.getID?.(),
|
||||
unit,
|
||||
hideBalance,
|
||||
preferredFiat: preferredFiatCurrency?.endPointKey,
|
||||
switching: unitSwitching,
|
||||
});
|
||||
}, [wallet, unit, hideBalance, preferredFiatCurrency, unitSwitching]);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.lineaderGradient,
|
||||
{
|
||||
paddingTop: headerOverlayHeight,
|
||||
minHeight: headerOverlayHeight + HERO_MIN_BODY_HEIGHT,
|
||||
backgroundColor: WalletGradient.headerColorFor(wallet.type),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<LinearGradient colors={WalletGradient.gradientsFor(wallet.type)} style={StyleSheet.absoluteFill} />
|
||||
<LinearGradient colors={WalletGradient.gradientsFor(wallet.type)} style={styles.lineaderGradient}>
|
||||
<View style={styles.contentContainer}>
|
||||
<Text testID="WalletLabel" numberOfLines={1} style={[styles.walletLabel, { writingDirection: direction }]}>
|
||||
{wallet.getLabel()}
|
||||
</Text>
|
||||
<View style={styles.balanceSection}>
|
||||
<View style={styles.walletBalanceAndUnitContainer}>
|
||||
<Animated.View style={[styles.walletBalanceAndUnitContainer, balanceAnimatedStyle]}>
|
||||
<ToolTipMenu
|
||||
shouldOpenOnLongPress
|
||||
isButton
|
||||
@ -198,133 +224,127 @@ const TransactionsNavigationHeader: React.FC<TransactionsNavigationHeaderProps>
|
||||
{hideBalance ? (
|
||||
<BlurredBalanceView />
|
||||
) : (
|
||||
<Text
|
||||
testID="WalletBalance"
|
||||
numberOfLines={1}
|
||||
minimumFontScale={0.5}
|
||||
adjustsFontSizeToFit
|
||||
style={styles.walletBalanceText}
|
||||
>
|
||||
{balance}
|
||||
</Text>
|
||||
<View key={`wallet-balance-textwrap-${wallet.getID?.() ?? ''}-${String(balance)}`}>
|
||||
<Animated.Text
|
||||
key={`wallet-balance-text-${wallet.getID?.() ?? ''}-${String(balance)}`} // force recreation on balance change for RTL correctness
|
||||
testID="WalletBalance"
|
||||
numberOfLines={1}
|
||||
minimumFontScale={0.5}
|
||||
adjustsFontSizeToFit
|
||||
style={[styles.walletBalanceText, animatedBalanceTextStyle]}
|
||||
>
|
||||
{balance}
|
||||
</Animated.Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</ToolTipMenu>
|
||||
{!hideBalance && (
|
||||
<TouchableOpacity style={styles.walletPreferredUnitView} onPress={changeWalletBalanceUnit} disabled={unitSwitching}>
|
||||
<Text style={styles.walletPreferredUnitText}>
|
||||
{unit === BitcoinUnit.LOCAL_CURRENCY ? (preferredFiatCurrency?.endPointKey ?? FiatUnit.USD) : unit}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
{(wallet.type === LightningCustodianWallet.type || wallet.type === LightningArkWallet.type) && allowOnchainAddress && (
|
||||
<TouchableOpacity style={styles.manageFundsButton} accessibilityRole="button" onPress={showManageFundsActionSheet}>
|
||||
<Text style={styles.manageFundsButtonText}>{loc.lnd.title}</Text>
|
||||
<TouchableOpacity style={styles.walletPreferredUnitView} onPress={changeWalletBalanceUnit} disabled={unitSwitching}>
|
||||
<Text style={styles.walletPreferredUnitText}>
|
||||
{unit === BitcoinUnit.LOCAL_CURRENCY ? (preferredFiatCurrency?.endPointKey ?? FiatUnit.USD) : unit}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
{(wallet.type === LightningCustodianWallet.type || wallet.type === LightningArkWallet.type) && allowOnchainAddress && (
|
||||
<View style={styles.manageFundsSection}>
|
||||
<View style={styles.manageFundsButtonContainer}>
|
||||
<ToolTipMenu
|
||||
shouldOpenOnLongPress={false}
|
||||
isButton
|
||||
onPressMenuItem={handleManageFundsPressed}
|
||||
actions={toolTipActions}
|
||||
buttonStyle={styles.manageFundsButtonTouchable}
|
||||
>
|
||||
<View style={styles.manageFundsButtonContent}>
|
||||
<Text style={styles.manageFundsButtonText}>{loc.lnd.title}</Text>
|
||||
</View>
|
||||
</ToolTipMenu>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{wallet.type === MultisigHDWallet.type && (
|
||||
<TouchableOpacity style={styles.manageFundsButton} accessibilityRole="button" onPress={() => handleManageFundsPressed()}>
|
||||
<Text style={styles.manageFundsButtonText}>{loc.multisig.manage_keys}</Text>
|
||||
<TouchableOpacity
|
||||
style={[styles.manageFundsButtonContainer, styles.manageFundsButtonTouchable]}
|
||||
accessibilityRole="button"
|
||||
onPress={() => handleManageFundsPressed()}
|
||||
>
|
||||
<View style={styles.manageFundsButtonContent}>
|
||||
<Text style={styles.manageFundsButtonText}>{loc.multisig.manage_keys}</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.bottomBarSpacer}>
|
||||
<View
|
||||
style={[
|
||||
styles.bottomBar,
|
||||
{
|
||||
backgroundColor: colors.background,
|
||||
...Platform.select({
|
||||
ios: { shadowColor: colors.shadowColor },
|
||||
android: {},
|
||||
}),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
lineaderGradient: {
|
||||
minHeight: 140,
|
||||
justifyContent: 'flex-start',
|
||||
position: 'relative',
|
||||
},
|
||||
contentContainer: {
|
||||
flex: 1,
|
||||
paddingTop: WALLET_LABEL_TOP_GAP,
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: HERO_BOTTOM_PADDING,
|
||||
},
|
||||
bottomBarSpacer: {
|
||||
position: 'relative',
|
||||
height: 12,
|
||||
marginBottom: 0,
|
||||
},
|
||||
bottomBar: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: -1,
|
||||
height: 13,
|
||||
borderTopLeftRadius: 20,
|
||||
borderTopRightRadius: 20,
|
||||
...Platform.select({
|
||||
ios: {
|
||||
shadowOffset: { width: 0, height: -8 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 6,
|
||||
},
|
||||
android: {
|
||||
elevation: 0.5,
|
||||
},
|
||||
}),
|
||||
},
|
||||
walletLabel: {
|
||||
backgroundColor: 'transparent',
|
||||
fontSize: 19,
|
||||
color: 'rgba(255, 255, 255, 0.7)',
|
||||
marginBottom: 4,
|
||||
},
|
||||
walletBalance: {
|
||||
flexShrink: 1,
|
||||
marginRight: 6,
|
||||
minHeight: 39,
|
||||
justifyContent: 'center',
|
||||
padding: 15,
|
||||
},
|
||||
balanceSection: {
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
manageFundsButton: {
|
||||
walletLabel: {
|
||||
backgroundColor: 'transparent',
|
||||
fontSize: 19,
|
||||
color: '#fff',
|
||||
marginBottom: 10,
|
||||
},
|
||||
walletBalance: {
|
||||
flexShrink: 1,
|
||||
marginRight: 6,
|
||||
},
|
||||
manageFundsButtonContainer: {
|
||||
marginTop: 14,
|
||||
marginBottom: 10,
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
manageFundsButtonTouchable: {
|
||||
backgroundColor: 'rgba(255,255,255,0.2)',
|
||||
borderRadius: 9,
|
||||
minHeight: 39,
|
||||
alignSelf: 'flex-start',
|
||||
height: 39,
|
||||
paddingHorizontal: 12,
|
||||
overflow: 'hidden',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
manageFundsButtonContent: {
|
||||
flex: 1,
|
||||
width: '100%',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
manageFundsSection: {
|
||||
width: '100%',
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
manageFundsButtonText: {
|
||||
fontWeight: '500',
|
||||
fontSize: 14,
|
||||
lineHeight: 18,
|
||||
color: '#FFFFFF',
|
||||
padding: 12,
|
||||
padding: 0,
|
||||
textAlign: 'center',
|
||||
includeFontPadding: false,
|
||||
textAlignVertical: 'center',
|
||||
},
|
||||
walletBalanceAndUnitContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingRight: 10,
|
||||
paddingRight: 10, // Ensure there's some padding to the right
|
||||
},
|
||||
walletBalanceText: {
|
||||
color: '#fff',
|
||||
fontWeight: 'bold',
|
||||
fontSize: 36,
|
||||
flexShrink: 1,
|
||||
flexShrink: 1, // Allow the text to shrink if there's not enough space
|
||||
},
|
||||
walletPreferredUnitView: {
|
||||
justifyContent: 'center',
|
||||
|
||||
@ -30,7 +30,6 @@ import WalletGradient from '../class/wallet-gradient';
|
||||
import { useSizeClass, SizeClass } from '../blue_modules/sizeClass';
|
||||
import loc, { formatBalance, transactionTimeToReadable } from '../loc';
|
||||
import { BlurredBalanceView } from './BlurredBalanceView';
|
||||
import { withAlpha } from './color';
|
||||
import { useTheme } from './themes';
|
||||
import { Transaction, TWallet } from '../class/wallets/types';
|
||||
import { BlueSpacing10 } from './BlueSpacing';
|
||||
@ -38,30 +37,6 @@ import { useLocale } from '@react-navigation/native';
|
||||
|
||||
export const WALLET_CAROUSEL_HEADER_WIDTH = 16;
|
||||
|
||||
/** Base card body height at default Dynamic Type — grows with larger Dynamic Type, never shrinks below default. */
|
||||
export const WALLET_CARD_BASE_MIN_HEIGHT = 164;
|
||||
/** Top inset above wallet cards in the horizontal home carousel. */
|
||||
export const WALLET_CAROUSEL_PADDING_TOP = 12;
|
||||
/** Bottom inset so iOS card shadows (offset 4 + radius 8) are not clipped by the list row. */
|
||||
export const WALLET_CAROUSEL_PADDING_BOTTOM = 20;
|
||||
|
||||
/** Scale layout metrics up for accessibility sizes; keep the design default when fontScale ≤ 1. */
|
||||
const scaleLayoutUp = (base: number, fontScale: number): number => Math.round(base * Math.max(1, fontScale));
|
||||
|
||||
export const getWalletCardMinHeight = (fontScale = 1): number => scaleLayoutUp(WALLET_CARD_BASE_MIN_HEIGHT, fontScale);
|
||||
|
||||
export const getWalletCarouselHeight = (fontScale = 1): number =>
|
||||
scaleLayoutUp(WALLET_CAROUSEL_PADDING_TOP, fontScale) +
|
||||
getWalletCardMinHeight(fontScale) +
|
||||
scaleLayoutUp(WALLET_CAROUSEL_PADDING_BOTTOM, fontScale);
|
||||
|
||||
/** Default carousel row height at `fontScale` 1 — prefer `getWalletCarouselHeight(fontScale)` when layout depends on Dynamic Type. */
|
||||
export const WALLET_CAROUSEL_HEIGHT = getWalletCarouselHeight(1);
|
||||
|
||||
/** Vertical gap between the wallet title/balance block and the latest-tx footer on carousel cards. */
|
||||
const WALLET_CARD_SECTION_GAP = 12;
|
||||
const WALLET_CARD_TEXT_OPACITY = 0.85;
|
||||
|
||||
export const getWalletCarouselItemWidth = (screenWidth: number) => Math.round(screenWidth * 0.82 > 375 ? 375 : screenWidth * 0.82);
|
||||
|
||||
interface NewWalletPanelProps {
|
||||
@ -185,28 +160,23 @@ const iStyles = StyleSheet.create({
|
||||
borderRadius: 12,
|
||||
minHeight: 164,
|
||||
overflow: 'hidden',
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
gradCompact: {
|
||||
borderRadius: 10,
|
||||
minHeight: 132,
|
||||
overflow: 'hidden',
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
gradContent: {
|
||||
padding: 15,
|
||||
width: '100%',
|
||||
},
|
||||
gradContentCompact: {
|
||||
padding: 12,
|
||||
},
|
||||
balanceContainer: {
|
||||
minHeight: 40,
|
||||
justifyContent: 'center',
|
||||
height: 40,
|
||||
},
|
||||
balanceContainerCompact: {
|
||||
minHeight: 32,
|
||||
justifyContent: 'center',
|
||||
height: 32,
|
||||
},
|
||||
image: {
|
||||
width: 99,
|
||||
@ -219,6 +189,9 @@ const iStyles = StyleSheet.create({
|
||||
width: 78,
|
||||
height: 74,
|
||||
},
|
||||
br: {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
label: {
|
||||
backgroundColor: 'transparent',
|
||||
fontSize: 19,
|
||||
@ -233,6 +206,7 @@ const iStyles = StyleSheet.create({
|
||||
},
|
||||
balanceCompact: {
|
||||
fontSize: 28,
|
||||
lineHeight: 34,
|
||||
},
|
||||
latestTx: {
|
||||
backgroundColor: 'transparent',
|
||||
@ -308,32 +282,11 @@ export const WalletCarouselItem: React.FC<WalletCarouselItemProps> = React.memo(
|
||||
const balanceOpacity = useSharedValue(1);
|
||||
const balanceTranslateY = useSharedValue(0);
|
||||
const { colors } = useTheme();
|
||||
const { width, fontScale } = useWindowDimensions();
|
||||
const { width } = useWindowDimensions();
|
||||
const itemWidth = getWalletCarouselItemWidth(width);
|
||||
const { sizeClass } = useSizeClass();
|
||||
const isCompact = sizeVariant === 'compact';
|
||||
const { direction } = useLocale();
|
||||
const scaledCardStyles = useMemo(
|
||||
() => ({
|
||||
grad: { minHeight: getWalletCardMinHeight(fontScale) },
|
||||
gradContent: { padding: scaleLayoutUp(15, fontScale) },
|
||||
balanceContainer: { minHeight: scaleLayoutUp(40, fontScale) },
|
||||
textSpacer: { height: scaleLayoutUp(WALLET_CARD_SECTION_GAP, fontScale) },
|
||||
label: { lineHeight: scaleLayoutUp(24, fontScale) },
|
||||
balance: { lineHeight: scaleLayoutUp(38, fontScale) },
|
||||
balanceCompact: { lineHeight: scaleLayoutUp(30, fontScale) },
|
||||
latestTx: { lineHeight: scaleLayoutUp(18, fontScale) },
|
||||
latestTxTime: { lineHeight: scaleLayoutUp(22, fontScale) },
|
||||
}),
|
||||
[fontScale],
|
||||
);
|
||||
const cardTextStyle = useMemo(
|
||||
() => ({
|
||||
color: withAlpha(colors.inverseForegroundColor, WALLET_CARD_TEXT_OPACITY),
|
||||
writingDirection: direction,
|
||||
}),
|
||||
[colors.inverseForegroundColor, direction],
|
||||
);
|
||||
const previousBalance = useRef<string | undefined>(undefined);
|
||||
const balance = !hideBalance && formatBalance(Number(item.getBalance()), item.getPreferredBalanceUnit(), true);
|
||||
const safeBalance = balance || undefined;
|
||||
@ -430,21 +383,9 @@ export const WalletCarouselItem: React.FC<WalletCarouselItemProps> = React.memo(
|
||||
|
||||
let latestTransactionText;
|
||||
|
||||
// Lightning / Ark wallets do not have on-chain confirmations — settlement is
|
||||
// signaled by `ispaid`. Bitcoin/on-chain wallets keep the existing
|
||||
// `confirmations === 0` rule unchanged so their pending-pill semantics
|
||||
// never depend on a Lightning shape.
|
||||
// `ispaid === false` alone is not "pending": it is also true for terminal
|
||||
// failed/refunded swaps, which stay in history. Gate on `!tx.failed` so a
|
||||
// dead swap doesn't pin the card to "pending" forever.
|
||||
const isLightningShaped = item.type === LightningCustodianWallet.type || item.type === LightningArkWallet.type;
|
||||
const hasPendingTx = isLightningShaped
|
||||
? item.getTransactions().some((tx: any) => tx.ispaid === false && !tx.failed)
|
||||
: item.getTransactions().some((tx: Transaction) => tx.confirmations === 0);
|
||||
|
||||
if (item.getBalance() !== 0 && item.getLatestTransactionTime() === 0) {
|
||||
latestTransactionText = loc.wallets.pull_to_refresh;
|
||||
} else if (hasPendingTx) {
|
||||
} else if (item.getTransactions().find((tx: Transaction) => tx.confirmations === 0)) {
|
||||
latestTransactionText = loc.transactions.pending;
|
||||
} else {
|
||||
latestTransactionText = transactionTimeToReadable(item.getLatestTransactionTime());
|
||||
@ -478,23 +419,23 @@ export const WalletCarouselItem: React.FC<WalletCarouselItemProps> = React.memo(
|
||||
{ backgroundColor: colors.background, shadowColor: colors.shadowColor },
|
||||
]}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={WalletGradient.gradientsFor(item.type)}
|
||||
style={[iStyles.grad, isCompact && iStyles.gradCompact, scaledCardStyles.grad]}
|
||||
>
|
||||
<LinearGradient colors={WalletGradient.gradientsFor(item.type)} style={[iStyles.grad, isCompact && iStyles.gradCompact]}>
|
||||
<ImageBackground source={image} style={[iStyles.image, isCompact && iStyles.imageCompact]} />
|
||||
<View style={[iStyles.gradContent, isCompact && iStyles.gradContentCompact, !isCompact && scaledCardStyles.gradContent]}>
|
||||
<View style={[iStyles.gradContent, isCompact && iStyles.gradContentCompact]}>
|
||||
<Text style={iStyles.br} />
|
||||
{!isPlaceHolder && (
|
||||
<>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={[iStyles.label, isCompact && iStyles.labelCompact, scaledCardStyles.label, cardTextStyle]}
|
||||
style={[
|
||||
iStyles.label,
|
||||
isCompact && iStyles.labelCompact,
|
||||
{ color: colors.inverseForegroundColor, writingDirection: direction },
|
||||
]}
|
||||
>
|
||||
{renderHighlightedText ? renderHighlightedText(walletLabel, searchQuery || '') : walletLabel}
|
||||
</Text>
|
||||
<View
|
||||
style={[iStyles.balanceContainer, isCompact && iStyles.balanceContainerCompact, scaledCardStyles.balanceContainer]}
|
||||
>
|
||||
<View style={[iStyles.balanceContainer, isCompact && iStyles.balanceContainerCompact]}>
|
||||
{hideBalance ? (
|
||||
<>
|
||||
<BlueSpacing10 />
|
||||
@ -504,13 +445,11 @@ export const WalletCarouselItem: React.FC<WalletCarouselItemProps> = React.memo(
|
||||
<Animated.Text
|
||||
numberOfLines={1}
|
||||
adjustsFontSizeToFit
|
||||
minimumFontScale={0.55}
|
||||
key={`${balance}`} // force component recreation on balance change. To fix right-to-left languages, like Farsi
|
||||
style={[
|
||||
iStyles.balance,
|
||||
isCompact && iStyles.balanceCompact,
|
||||
isCompact ? scaledCardStyles.balanceCompact : scaledCardStyles.balance,
|
||||
cardTextStyle,
|
||||
{ color: colors.inverseForegroundColor, writingDirection: direction },
|
||||
animatedBalanceStyle,
|
||||
]}
|
||||
>
|
||||
@ -518,20 +457,24 @@ export const WalletCarouselItem: React.FC<WalletCarouselItemProps> = React.memo(
|
||||
</Animated.Text>
|
||||
)}
|
||||
</View>
|
||||
<View style={scaledCardStyles.textSpacer} />
|
||||
<Text style={iStyles.br} />
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
adjustsFontSizeToFit
|
||||
minimumFontScale={0.8}
|
||||
style={[iStyles.latestTx, isCompact && iStyles.latestTxCompact, scaledCardStyles.latestTx, cardTextStyle]}
|
||||
style={[
|
||||
iStyles.latestTx,
|
||||
isCompact && iStyles.latestTxCompact,
|
||||
{ color: colors.inverseForegroundColor, writingDirection: direction },
|
||||
]}
|
||||
>
|
||||
{loc.wallets.list_latest_transaction}
|
||||
</Text>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
adjustsFontSizeToFit
|
||||
minimumFontScale={0.8}
|
||||
style={[iStyles.latestTxTime, isCompact && iStyles.latestTxTimeCompact, scaledCardStyles.latestTxTime, cardTextStyle]}
|
||||
style={[
|
||||
iStyles.latestTxTime,
|
||||
isCompact && iStyles.latestTxTimeCompact,
|
||||
{ color: colors.inverseForegroundColor, writingDirection: direction },
|
||||
]}
|
||||
>
|
||||
{latestTransactionText}
|
||||
</Text>
|
||||
@ -560,7 +503,15 @@ interface WalletsCarouselProps extends Partial<FlatListProps<any>> {
|
||||
animateChanges?: boolean;
|
||||
}
|
||||
|
||||
export type CarouselListRefType = FlatList<TWallet>;
|
||||
type FlatListRefType = FlatList<any> & {
|
||||
scrollToEnd(params?: { animated?: boolean | null }): void;
|
||||
scrollToIndex(params: { animated?: boolean | null; index: number; viewOffset?: number; viewPosition?: number }): void;
|
||||
scrollToItem(params: { animated?: boolean | null; item: TWallet; viewPosition?: number }): void;
|
||||
scrollToOffset(params: { animated?: boolean | null; offset: number }): void;
|
||||
recordInteraction(): void;
|
||||
flashScrollIndicators(): void;
|
||||
getNativeScrollRef(): View;
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
listHeaderSeparator: {
|
||||
@ -571,7 +522,7 @@ const styles = StyleSheet.create({
|
||||
|
||||
const ListHeaderSeparator = () => <View style={styles.listHeaderSeparator} />;
|
||||
|
||||
const WalletsCarousel = forwardRef<CarouselListRefType, WalletsCarouselProps>((props, ref) => {
|
||||
const WalletsCarousel = forwardRef<FlatListRefType, WalletsCarouselProps>((props, ref) => {
|
||||
const {
|
||||
horizontal = true,
|
||||
data,
|
||||
@ -586,7 +537,7 @@ const WalletsCarousel = forwardRef<CarouselListRefType, WalletsCarouselProps>((p
|
||||
animateChanges = false,
|
||||
} = props;
|
||||
|
||||
const { width, fontScale } = useWindowDimensions();
|
||||
const { width } = useWindowDimensions();
|
||||
const itemWidth = React.useMemo(() => getWalletCarouselItemWidth(width), [width]);
|
||||
const snapInterval = React.useMemo(() => itemWidth, [itemWidth]);
|
||||
const snapOffsets = React.useMemo(() => {
|
||||
@ -606,7 +557,7 @@ const WalletsCarousel = forwardRef<CarouselListRefType, WalletsCarouselProps>((p
|
||||
const scrollTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const isInitialMount = useRef(true);
|
||||
|
||||
const flatListRef = useRef<FlatList<TWallet>>(null);
|
||||
const flatListRef = useRef<FlatList<any>>(null);
|
||||
const walletRefs = useRef<Record<string, React.MutableRefObject<View | null>>>({});
|
||||
|
||||
const { sizeClass } = useSizeClass();
|
||||
@ -695,7 +646,7 @@ const WalletsCarousel = forwardRef<CarouselListRefType, WalletsCarouselProps>((p
|
||||
console.warn('[WalletsCarousel] Error scrolling to wallet:', error);
|
||||
// Fallback: try scrolling to offset
|
||||
// Use different measurement based on orientation
|
||||
const itemSize = horizontal ? itemWidth : WALLET_CAROUSEL_HEIGHT;
|
||||
const itemSize = horizontal ? itemWidth : 195; // 195 is the approximate height of wallet card
|
||||
flatListRef.current.scrollToOffset({
|
||||
offset: itemSize * walletIndex,
|
||||
animated,
|
||||
@ -817,7 +768,7 @@ const WalletsCarousel = forwardRef<CarouselListRefType, WalletsCarouselProps>((p
|
||||
|
||||
const keyExtractor = useCallback((item: TWallet, index: number) => (item?.getID ? item.getID() : index.toString()), []);
|
||||
|
||||
const sliderHeight = getWalletCarouselHeight(fontScale);
|
||||
const sliderHeight = 195;
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@ -900,8 +851,7 @@ const WalletsCarousel = forwardRef<CarouselListRefType, WalletsCarouselProps>((p
|
||||
|
||||
const cStyles = StyleSheet.create({
|
||||
content: {
|
||||
paddingTop: scaleLayoutUp(WALLET_CAROUSEL_PADDING_TOP, fontScale),
|
||||
paddingBottom: scaleLayoutUp(WALLET_CAROUSEL_PADDING_BOTTOM, fontScale),
|
||||
paddingTop: 16,
|
||||
},
|
||||
contentLargeScreen: {
|
||||
paddingHorizontal: sizeClass === SizeClass.Large ? 16 : 12,
|
||||
@ -932,7 +882,7 @@ const WalletsCarousel = forwardRef<CarouselListRefType, WalletsCarouselProps>((p
|
||||
automaticallyAdjustContentInsets
|
||||
automaticallyAdjustKeyboardInsets
|
||||
automaticallyAdjustsScrollIndicatorInsets
|
||||
style={{ minHeight: sliderHeight }}
|
||||
style={{ minHeight: sliderHeight + 12 }}
|
||||
onScrollToIndexFailed={onScrollToIndexFailed}
|
||||
ListFooterComponent={onNewWalletPress ? <NewWalletPanel onPress={onNewWalletPress} /> : null}
|
||||
{...props}
|
||||
|
||||
@ -31,8 +31,6 @@ export { platformColors } from '../themes';
|
||||
|
||||
export const isAndroid = Platform.OS === 'android';
|
||||
const isIOS = Platform.OS === 'ios';
|
||||
const iosMajorVersion = isIOS ? Number(String(Platform.Version).split('.')[0]) : 0;
|
||||
export const isIOS26OrHigher = isIOS && Number.isFinite(iosMajorVersion) && iosMajorVersion >= 26;
|
||||
|
||||
export const platformSizing = {
|
||||
horizontalPadding: isIOS ? 16 : 20,
|
||||
@ -109,15 +107,6 @@ export const getSettingsHeaderOptions = (
|
||||
const cardColor = colors.lightButton ?? colors.modal ?? colors.elevated ?? defaultBackgroundColor;
|
||||
const headerBackgroundColor = isIOS ? (dark ? defaultBackgroundColor : cardColor) : defaultBackgroundColor;
|
||||
|
||||
if (isIOS26OrHigher) {
|
||||
return {
|
||||
title,
|
||||
headerLargeTitle: true,
|
||||
headerLargeTitleShadowVisible: true,
|
||||
headerBackButtonDisplayMode: 'minimal' as const,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
headerLargeTitle: isIOS,
|
||||
@ -203,7 +192,6 @@ export const SettingsScrollView = forwardRef<ScrollView, SettingsScrollViewProps
|
||||
ref={ref}
|
||||
style={[style, { backgroundColor: screenBackgroundColor }]}
|
||||
headerHeight={resolvedHeaderHeight}
|
||||
disableDefaultTopPadding={isIOS26OrHigher}
|
||||
floatingButtonHeight={floatingButtonHeight}
|
||||
contentContainerStyle={[staticStyles.contentContainer, contentContainerStyle]}
|
||||
{...rest}
|
||||
|
||||
@ -14,8 +14,6 @@ export const BlueDefaultTheme = {
|
||||
foregroundColor: '#0c2550',
|
||||
borderTopColor: 'rgba(0, 0, 0, 0.1)',
|
||||
buttonBackgroundColor: '#ccddf9',
|
||||
/** Softer fill for native iOS 26+ prominent header bar buttons (derived from `buttonBackgroundColor`). */
|
||||
headerProminentButtonBackgroundColor: 'rgba(204, 221, 249, 0.9)',
|
||||
buttonTextColor: '#0c2550',
|
||||
secondButtonTextColor: '#50555C',
|
||||
buttonAlternativeTextColor: '#2f5fb3',
|
||||
@ -103,7 +101,6 @@ export const BlueDarkTheme: Theme = {
|
||||
foregroundColor: '#ffffff',
|
||||
buttonDisabledBackgroundColor: '#3A3A3C',
|
||||
buttonBackgroundColor: '#3A3A3C',
|
||||
headerProminentButtonBackgroundColor: 'rgba(58, 58, 60, 0.6)',
|
||||
buttonTextColor: '#ffffff',
|
||||
lightButton: 'rgba(255,255,255,.1)',
|
||||
buttonAlternativeTextColor: '#ffffff',
|
||||
|
||||
@ -25,6 +25,8 @@ def app_store_state_readable(state)
|
||||
end
|
||||
|
||||
require 'securerandom'
|
||||
require 'json'
|
||||
require 'base64'
|
||||
|
||||
default_platform(:android)
|
||||
PROJECT_ROOT = File.expand_path("..", __dir__)
|
||||
@ -437,6 +439,75 @@ platform :ios do
|
||||
end
|
||||
end
|
||||
|
||||
def match_git_basic_auth_value(token)
|
||||
return nil if token.nil? || token.empty?
|
||||
|
||||
begin
|
||||
decoded = Base64.strict_decode64(token)
|
||||
return token if decoded.include?(':')
|
||||
rescue ArgumentError
|
||||
# Token is not base64-encoded; encode it below.
|
||||
end
|
||||
|
||||
Base64.strict_encode64("x-access-token:#{token}")
|
||||
end
|
||||
|
||||
def catalyst_notarization_enabled?
|
||||
ENV.fetch('CATALYST_NOTARIZE', '1') != '0'
|
||||
end
|
||||
|
||||
def notarize_and_staple_catalyst_app!(app_path:, output_dir:)
|
||||
UI.user_error!("Mac Catalyst app not found for notarization at #{app_path}") unless File.exist?(app_path)
|
||||
|
||||
zip_path = File.join(output_dir, 'BlueWallet-Mac-Catalyst-notarization.zip')
|
||||
FileUtils.rm_f(zip_path)
|
||||
sh('ditto', '-c', '-k', '--sequesterRsrc', '--keepParent', app_path, zip_path)
|
||||
|
||||
api_key_json_path = ENV['APPLE_API_KEY_PATH'] || './appstore_api_key.json'
|
||||
submitted = false
|
||||
|
||||
if File.exist?(api_key_json_path)
|
||||
begin
|
||||
key_payload = JSON.parse(File.read(api_key_json_path))
|
||||
key_id = ENV['APPLE_API_KEY_ID'] || key_payload['key_id']
|
||||
issuer_id = ENV['APPLE_API_ISSUER_ID'] || key_payload['issuer_id']
|
||||
private_key = key_payload['key']
|
||||
|
||||
if key_id && !key_id.empty? && issuer_id && !issuer_id.empty? && private_key && !private_key.empty?
|
||||
p8_path = File.join(output_dir, 'notary_api_key.p8')
|
||||
File.write(p8_path, private_key)
|
||||
begin
|
||||
UI.message('Submitting app for notarization with App Store Connect API key...')
|
||||
sh('xcrun', 'notarytool', 'submit', zip_path, '--key', p8_path, '--key-id', key_id, '--issuer', issuer_id, '--wait')
|
||||
submitted = true
|
||||
ensure
|
||||
FileUtils.rm_f(p8_path)
|
||||
end
|
||||
end
|
||||
rescue JSON::ParserError => ex
|
||||
UI.important("Could not parse #{api_key_json_path} for notarization: #{ex.message}")
|
||||
end
|
||||
end
|
||||
|
||||
unless submitted
|
||||
apple_id = ENV['APPLE_ID'] || ENV['FASTLANE_USER']
|
||||
app_specific_password = ENV['APPLE_APP_SPECIFIC_PASSWORD'] || ENV['FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD']
|
||||
team_id = ENV['CATALYST_TEAM_ID'] || ENV['TEAM_ID']
|
||||
|
||||
UI.user_error!('Notarization credentials missing. Provide APPLE_API_KEY_PATH (+ APPLE_API_KEY_ID/APPLE_API_ISSUER_ID) or APPLE_ID + APPLE_APP_SPECIFIC_PASSWORD + TEAM_ID/CATALYST_TEAM_ID.') if apple_id.to_s.empty? || app_specific_password.to_s.empty? || team_id.to_s.empty?
|
||||
|
||||
UI.message('Submitting app for notarization with Apple ID credentials...')
|
||||
sh('xcrun', 'notarytool', 'submit', zip_path, '--apple-id', apple_id, '--password', app_specific_password, '--team-id', team_id, '--wait')
|
||||
end
|
||||
|
||||
UI.message('Stapling notarization ticket to app bundle...')
|
||||
sh('xcrun', 'stapler', 'staple', app_path)
|
||||
sh('xcrun', 'stapler', 'validate', app_path)
|
||||
UI.success('Mac Catalyst app notarized and stapled')
|
||||
ensure
|
||||
FileUtils.rm_f(zip_path) if defined?(zip_path) && zip_path
|
||||
end
|
||||
|
||||
desc "Register new devices from a file"
|
||||
lane :register_devices_from_txt do
|
||||
UI.message("Registering new devices from file...")
|
||||
@ -492,7 +563,7 @@ platform :ios do
|
||||
with_retry(3, "Fetching provisioning profile for #{app_identifier}") do
|
||||
UI.message("Fetching provisioning profile for #{app_identifier}...")
|
||||
match(
|
||||
git_basic_authorization: ENV["GIT_ACCESS_TOKEN"],
|
||||
git_basic_authorization: match_git_basic_auth_value(ENV["GIT_ACCESS_TOKEN"]),
|
||||
git_url: ENV["GIT_URL"],
|
||||
type: "appstore",
|
||||
clone_branch_directly: true,
|
||||
@ -619,29 +690,53 @@ platform :ios do
|
||||
ENV['CATALYST_TEAM_ID'] && !ENV['CATALYST_TEAM_ID'].empty?
|
||||
has_match_creds = ENV['GIT_URL'] && !ENV['GIT_URL'].empty? &&
|
||||
ENV['GIT_ACCESS_TOKEN'] && !ENV['GIT_ACCESS_TOKEN'].empty?
|
||||
should_sign = has_signing_creds && has_match_creds && ENV['CATALYST_SKIP_CODESIGNING'] != '1'
|
||||
should_sign = has_signing_creds && ENV['CATALYST_SKIP_CODESIGNING'] != '1'
|
||||
should_use_match_profiles = should_sign && has_match_creds && ENV['CATALYST_USE_MATCH'] != '0'
|
||||
should_notarize = catalyst_notarization_enabled?
|
||||
signing_identity = ENV['CATALYST_SIGNING_IDENTITY'] || "Apple Distribution"
|
||||
|
||||
xcargs_str = "ARCHS=arm64 ONLY_ACTIVE_ARCH=YES"
|
||||
if should_sign
|
||||
if should_use_match_profiles
|
||||
UI.message("Setting up Mac Catalyst provisioning profiles via match...")
|
||||
team_id = ENV['CATALYST_TEAM_ID']
|
||||
match_readonly = ENV['MATCH_READONLY'] != 'false'
|
||||
clone_branch_directly = match_readonly
|
||||
match_git_auth = match_git_basic_auth_value(ENV['GIT_ACCESS_TOKEN'])
|
||||
|
||||
# Create/fetch provisioning profiles for catalyst targets
|
||||
catalyst_identifiers.each do |app_id|
|
||||
match(
|
||||
type: "appstore",
|
||||
platform: "catalyst",
|
||||
app_identifier: app_id,
|
||||
team_id: team_id,
|
||||
git_url: ENV['GIT_URL'],
|
||||
git_basic_authorization: ENV['GIT_ACCESS_TOKEN'],
|
||||
readonly: match_readonly,
|
||||
clone_branch_directly: true,
|
||||
keychain_name: ENV['KEYCHAIN_NAME'] || "login",
|
||||
keychain_password: ENV['KEYCHAIN_PASSWORD'] || ""
|
||||
)
|
||||
begin
|
||||
match(
|
||||
type: "appstore",
|
||||
platform: "catalyst",
|
||||
app_identifier: app_id,
|
||||
team_id: team_id,
|
||||
git_url: ENV['GIT_URL'],
|
||||
git_basic_authorization: match_git_auth,
|
||||
readonly: match_readonly,
|
||||
clone_branch_directly: clone_branch_directly,
|
||||
keychain_name: ENV['KEYCHAIN_NAME'] || "login",
|
||||
keychain_password: ENV['KEYCHAIN_PASSWORD'] || ""
|
||||
)
|
||||
rescue => ex
|
||||
if !match_readonly && clone_branch_directly
|
||||
UI.important("match failed for #{app_id} with clone_branch_directly=true: #{ex.message}")
|
||||
UI.message('Retrying match without clone_branch_directly for writable setup...')
|
||||
match(
|
||||
type: "appstore",
|
||||
platform: "catalyst",
|
||||
app_identifier: app_id,
|
||||
team_id: team_id,
|
||||
git_url: ENV['GIT_URL'],
|
||||
git_basic_authorization: match_git_auth,
|
||||
readonly: match_readonly,
|
||||
keychain_name: ENV['KEYCHAIN_NAME'] || "login",
|
||||
keychain_password: ENV['KEYCHAIN_PASSWORD'] || ""
|
||||
)
|
||||
else
|
||||
raise ex
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
xcargs_str += " DEVELOPMENT_TEAM=#{team_id}"
|
||||
@ -657,12 +752,20 @@ platform :ios do
|
||||
end
|
||||
|
||||
UI.success("Provisioning profiles configured for Mac Catalyst")
|
||||
elsif should_sign
|
||||
UI.message('Skipping match profile setup and building without Xcode signing; app will be signed after build')
|
||||
xcargs_str += " DEVELOPMENT_TEAM=#{ENV['CATALYST_TEAM_ID']}"
|
||||
xcargs_str += " CODE_SIGN_IDENTITY=- CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO"
|
||||
else
|
||||
# Disable code signing entirely so xcodebuild doesn't look for provisioning profiles
|
||||
xcargs_str += " CODE_SIGN_IDENTITY=- CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO"
|
||||
UI.message("No signing credentials provided — building without code signing")
|
||||
end
|
||||
|
||||
if should_notarize && !should_sign
|
||||
UI.user_error!('CATALYST_NOTARIZE is enabled but signing credentials are missing. Set CATALYST_SIGNING_IDENTITY and CATALYST_TEAM_ID, or disable notarization with CATALYST_NOTARIZE=0.')
|
||||
end
|
||||
|
||||
build_app(
|
||||
scheme: "BlueWallet",
|
||||
workspace: workspace_path,
|
||||
@ -670,7 +773,7 @@ platform :ios do
|
||||
destination: "generic/platform=macOS,variant=Mac Catalyst",
|
||||
xcargs: xcargs_str,
|
||||
clean: true,
|
||||
skip_codesigning: !should_sign,
|
||||
skip_codesigning: !should_use_match_profiles,
|
||||
skip_package_ipa: true,
|
||||
derived_data_path: derived_data_path,
|
||||
buildlog_path: File.join(project_root, "ios", "build_logs")
|
||||
@ -715,6 +818,12 @@ platform :ios do
|
||||
UI.success("App ad-hoc signed")
|
||||
end
|
||||
|
||||
if should_notarize
|
||||
notarize_and_staple_catalyst_app!(app_path: catalyst_app_path, output_dir: output_dir)
|
||||
else
|
||||
UI.message('Skipping notarization (CATALYST_NOTARIZE=0)')
|
||||
end
|
||||
|
||||
dmg_path = File.join(output_dir, "BlueWallet-Mac-Catalyst.dmg")
|
||||
UI.message("Creating DMG at: #{dmg_path}")
|
||||
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import { CommonActions } from '@react-navigation/native';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { AppState, AppStateStatus, Linking } from 'react-native';
|
||||
import { reconcileArkBackgroundTaskResults } from '../blue_modules/arkade-background';
|
||||
import { getClipboardContent } from '../blue_modules/clipboard';
|
||||
import { updateExchangeRate } from '../blue_modules/currency';
|
||||
import triggerHapticFeedback, { HapticFeedbackTypes } from '../blue_modules/hapticFeedback';
|
||||
@ -14,7 +13,6 @@ import {
|
||||
setApplicationIconBadgeNumber,
|
||||
} from '../blue_modules/notifications';
|
||||
import { LightningCustodianWallet } from '../class/wallets/lightning-custodian-wallet';
|
||||
import { LightningArkWallet } from '../class/wallets/lightning-ark-wallet';
|
||||
import DeeplinkSchemaMatch from '../class/deeplink-schema-match';
|
||||
import loc from '../loc';
|
||||
import { Chain } from '../models/bitcoinUnits';
|
||||
@ -88,47 +86,6 @@ const useCompanionListeners = (skipIfNotInitialized = true) => {
|
||||
const wasTapped = payload.foreground === false || (payload.foreground === true && payload.userInteraction);
|
||||
|
||||
console.log('processing push notification:', payload);
|
||||
|
||||
// Local notification for actionable Ark swaps. Routed by walletID
|
||||
// rather than address/txid because the payload is locally generated;
|
||||
// see blue_modules/arkade-notifications.ts.
|
||||
if (+payload.type === 100) {
|
||||
const arkWallet = wallets.find(w => w.getID() === payload.walletID);
|
||||
if (!arkWallet || !(arkWallet instanceof LightningArkWallet)) {
|
||||
if (wasTapped) {
|
||||
navigation.navigate('WalletTransactions', {
|
||||
walletID: payload.walletID,
|
||||
walletType: arkWallet?.type,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Refresh swap-derived rows directly via the wallet method to
|
||||
// bypass the 5-second NOP throttle in StorageProvider.fetchAndSaveWalletTransactions:
|
||||
// reconcileArkBackgroundTaskResults often runs on app resume immediately
|
||||
// before this handler, which would make a throttled call NOP and
|
||||
// leave the synthetic row stale.
|
||||
try {
|
||||
await arkWallet.fetchTransactions();
|
||||
await saveToDisk();
|
||||
} catch (e: any) {
|
||||
console.warn('[useCompanionListeners] arkWallet.fetchTransactions failed:', e?.message ?? e);
|
||||
}
|
||||
|
||||
if (wasTapped) {
|
||||
const arkWalletID = arkWallet.getID();
|
||||
const row = arkWallet.getTransactions().find(tx => tx.txid === `swap-${payload.swapId}`);
|
||||
if (row) {
|
||||
navigation.navigate('LNDViewInvoice', { invoice: row, walletID: arkWalletID });
|
||||
} else {
|
||||
navigation.navigate('WalletTransactions', { walletID: arkWalletID, walletType: arkWallet.type });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let wallet;
|
||||
switch (+payload.type) {
|
||||
case 2:
|
||||
@ -169,51 +126,6 @@ const useCompanionListeners = (skipIfNotInitialized = true) => {
|
||||
const wasTapped = payload.foreground === false || (payload.foreground === true && payload.userInteraction);
|
||||
|
||||
console.log('processing push notification:', payload);
|
||||
|
||||
if (+payload.type === 100) {
|
||||
const arkWallet = wallets.find(w => w.getID() === payload.walletID);
|
||||
if (!arkWallet || !(arkWallet instanceof LightningArkWallet)) {
|
||||
if (wasTapped) {
|
||||
navigationRef.dispatch(
|
||||
CommonActions.navigate({
|
||||
name: 'WalletTransactions',
|
||||
params: { walletID: payload.walletID, walletType: arkWallet?.type },
|
||||
}),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await arkWallet.fetchTransactions();
|
||||
await saveToDisk();
|
||||
} catch (e: any) {
|
||||
console.warn('[useCompanionListeners] arkWallet.fetchTransactions failed:', e?.message ?? e);
|
||||
}
|
||||
|
||||
if (wasTapped) {
|
||||
const arkWalletID = arkWallet.getID();
|
||||
const row = arkWallet.getTransactions().find(tx => tx.txid === `swap-${payload.swapId}`);
|
||||
if (row) {
|
||||
navigationRef.dispatch(
|
||||
CommonActions.navigate({
|
||||
name: 'LNDViewInvoice',
|
||||
params: { invoice: row, walletID: arkWalletID },
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
navigationRef.dispatch(
|
||||
CommonActions.navigate({
|
||||
name: 'WalletTransactions',
|
||||
params: { walletID: arkWalletID, walletType: arkWallet.type },
|
||||
}),
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let wallet;
|
||||
switch (+payload.type) {
|
||||
case 2:
|
||||
@ -267,7 +179,7 @@ const useCompanionListeners = (skipIfNotInitialized = true) => {
|
||||
console.error('Failed to process push notifications:', error);
|
||||
}
|
||||
return false;
|
||||
}, [shouldActivateListeners, wallets, fetchAndSaveWalletTransactions, saveToDisk, navigation, refreshAllWalletTransactions]);
|
||||
}, [shouldActivateListeners, wallets, fetchAndSaveWalletTransactions, navigation, refreshAllWalletTransactions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldActivateListeners) return;
|
||||
@ -302,12 +214,16 @@ const useCompanionListeners = (skipIfNotInitialized = true) => {
|
||||
throw new Error(loc.send.qr_error_no_qrcode);
|
||||
}
|
||||
triggerHapticFeedback(HapticFeedbackTypes.NotificationSuccess);
|
||||
DeeplinkSchemaMatch.navigationRouteFor({ url: qrValue }, (value: [string, any]) => navigationRef.navigate(...value), {
|
||||
wallets,
|
||||
addWallet,
|
||||
saveToDisk,
|
||||
setSharedCosigner,
|
||||
});
|
||||
DeeplinkSchemaMatch.navigationRouteFor(
|
||||
{ url: qrValue },
|
||||
(value: [string, any]) => navigationRef.navigate(...value),
|
||||
{
|
||||
wallets,
|
||||
addWallet,
|
||||
saveToDisk,
|
||||
setSharedCosigner,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
DeeplinkSchemaMatch.navigationRouteFor(event, (value: [string, any]) => navigationRef.navigate(...value), {
|
||||
wallets,
|
||||
@ -361,12 +277,6 @@ const useCompanionListeners = (skipIfNotInitialized = true) => {
|
||||
if ((appState.current.match(/inactive|background/) && nextAppState === 'active') || nextAppState === undefined) {
|
||||
updateExchangeRate();
|
||||
const processed = await processPushNotifications();
|
||||
// Reconcile in-process Ark background task results before the
|
||||
// notification-handled early return: if the background task observed
|
||||
// status changes while the app was backgrounded, the affected
|
||||
// wallets need a transactions refresh whether or not a notification
|
||||
// also fired.
|
||||
reconcileArkBackgroundTaskResults(fetchAndSaveWalletTransactions);
|
||||
if (processed) return;
|
||||
const clipboard = await getClipboardContent();
|
||||
if (!clipboard) return;
|
||||
@ -402,7 +312,7 @@ const useCompanionListeners = (skipIfNotInitialized = true) => {
|
||||
appState.current = nextAppState;
|
||||
}
|
||||
},
|
||||
[processPushNotifications, fetchAndSaveWalletTransactions, showClipboardAlert, wallets, shouldActivateListeners],
|
||||
[processPushNotifications, showClipboardAlert, wallets, shouldActivateListeners],
|
||||
);
|
||||
|
||||
const addListeners = useCallback(() => {
|
||||
|
||||
14
index.js
14
index.js
@ -5,23 +5,9 @@ import './shim.js';
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import { AppRegistry, LogBox } from 'react-native';
|
||||
import BackgroundFetch from 'react-native-background-fetch';
|
||||
|
||||
import App from './App';
|
||||
import { restoreSavedPreferredFiatCurrencyAndExchangeFromStorage } from './blue_modules/currency';
|
||||
import { runArkBackgroundTask } from './blue_modules/arkade-background';
|
||||
|
||||
// Android headless execution boots a bare JS runtime without the React tree.
|
||||
// The headless task callback must be registered at module scope before
|
||||
// AppRegistry.registerComponent so the symbol exists when the OS dispatches a
|
||||
// terminated-process wake.
|
||||
BackgroundFetch.registerHeadlessTask(async event => {
|
||||
if (event.timeout) {
|
||||
BackgroundFetch.finish(event.taskId);
|
||||
return;
|
||||
}
|
||||
await runArkBackgroundTask(event.taskId);
|
||||
});
|
||||
|
||||
if (!Error.captureStackTrace) {
|
||||
// captureStackTrace is only available when debugging
|
||||
|
||||
@ -1356,7 +1356,7 @@
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
|
||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 1703279999;
|
||||
CURRENT_PROJECT_VERSION = 1703259999;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = A7W54YZ4WU;
|
||||
@ -1383,7 +1383,7 @@
|
||||
"$(SDKROOT)/System/iOSSupport/usr/lib/swift",
|
||||
"$(inherited)",
|
||||
);
|
||||
MARKETING_VERSION = 8.0.1;
|
||||
MARKETING_VERSION = 8.0.0;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
@ -1418,7 +1418,7 @@
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
|
||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Distribution";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 1703279999;
|
||||
CURRENT_PROJECT_VERSION = 1703259999;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = A7W54YZ4WU;
|
||||
@ -1440,7 +1440,7 @@
|
||||
"$(SDKROOT)/System/iOSSupport/usr/lib/swift",
|
||||
"$(inherited)",
|
||||
);
|
||||
MARKETING_VERSION = 8.0.1;
|
||||
MARKETING_VERSION = 8.0.0;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
@ -1476,7 +1476,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 1703279999;
|
||||
CURRENT_PROJECT_VERSION = 1703259999;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
@ -1489,7 +1489,7 @@
|
||||
"$(SDKROOT)/System/iOSSupport/usr/lib/swift",
|
||||
"$(inherited)",
|
||||
);
|
||||
MARKETING_VERSION = 8.0.1;
|
||||
MARKETING_VERSION = 8.0.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRESERVE_DEAD_CODE_INITS_AND_TERMS = YES;
|
||||
@ -1519,7 +1519,7 @@
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 1703279999;
|
||||
CURRENT_PROJECT_VERSION = 1703259999;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = "";
|
||||
@ -1532,7 +1532,7 @@
|
||||
"$(SDKROOT)/System/iOSSupport/usr/lib/swift",
|
||||
"$(inherited)",
|
||||
);
|
||||
MARKETING_VERSION = 8.0.1;
|
||||
MARKETING_VERSION = 8.0.0;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRESERVE_DEAD_CODE_INITS_AND_TERMS = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = io.bluewallet.bluewallet.Stickers;
|
||||
@ -1564,7 +1564,7 @@
|
||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
||||
"CODE_SIGN_IDENTITY[sdk=watchos*]" = "iPhone Distribution";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 1703279999;
|
||||
CURRENT_PROJECT_VERSION = 1703259999;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
@ -1584,7 +1584,7 @@
|
||||
"$(SDKROOT)/System/iOSSupport/usr/lib/swift",
|
||||
"$(inherited)",
|
||||
);
|
||||
MARKETING_VERSION = 8.0.1;
|
||||
MARKETING_VERSION = 8.0.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRESERVE_DEAD_CODE_INITS_AND_TERMS = YES;
|
||||
@ -1625,7 +1625,7 @@
|
||||
"CODE_SIGN_IDENTITY[sdk=watchos*]" = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 1703279999;
|
||||
CURRENT_PROJECT_VERSION = 1703259999;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = "";
|
||||
@ -1645,7 +1645,7 @@
|
||||
"$(SDKROOT)/System/iOSSupport/usr/lib/swift",
|
||||
"$(inherited)",
|
||||
);
|
||||
MARKETING_VERSION = 8.0.1;
|
||||
MARKETING_VERSION = 8.0.0;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRESERVE_DEAD_CODE_INITS_AND_TERMS = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = io.bluewallet.bluewallet.MarketWidget;
|
||||
@ -1832,7 +1832,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
"CODE_SIGN_IDENTITY[sdk=watchos*]" = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 1703279999;
|
||||
CURRENT_PROJECT_VERSION = 1703259999;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
@ -1854,7 +1854,7 @@
|
||||
"$(inherited)",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 12.4;
|
||||
MARKETING_VERSION = 8.0.1;
|
||||
MARKETING_VERSION = 8.0.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRESERVE_DEAD_CODE_INITS_AND_TERMS = YES;
|
||||
@ -1890,7 +1890,7 @@
|
||||
"CODE_SIGN_IDENTITY[sdk=watchos*]" = "iPhone Distribution";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 1703279999;
|
||||
CURRENT_PROJECT_VERSION = 1703259999;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = "";
|
||||
@ -1912,7 +1912,7 @@
|
||||
"$(inherited)",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 12.4;
|
||||
MARKETING_VERSION = 8.0.1;
|
||||
MARKETING_VERSION = 8.0.0;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRESERVE_DEAD_CODE_INITS_AND_TERMS = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = io.bluewallet.bluewallet.watch;
|
||||
|
||||
@ -5,7 +5,6 @@
|
||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||
<array>
|
||||
<string>io.bluewallet.bluewallet.fetchTxsForWallet</string>
|
||||
<string>com.transistorsoft.fetch</string>
|
||||
</array>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
@ -245,6 +244,8 @@
|
||||
</array>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<true/>
|
||||
<key>UIDesignRequiresCompatibility</key>
|
||||
<true/>
|
||||
<key>UTExportedTypeDeclarations</key>
|
||||
<array>
|
||||
<dict>
|
||||
|
||||
@ -2018,8 +2018,6 @@ PODS:
|
||||
- ReactNativeDependencies (0.85.3)
|
||||
- RealmJS (20.2.0):
|
||||
- React
|
||||
- RNBackgroundFetch (4.2.9):
|
||||
- React-Core
|
||||
- RNCAsyncStorage (2.2.0):
|
||||
- hermes-engine
|
||||
- RCTRequired
|
||||
@ -2545,7 +2543,6 @@ DEPENDENCIES:
|
||||
- ReactNativeCameraKit (from `../node_modules/react-native-camera-kit-no-google`)
|
||||
- ReactNativeDependencies (from `../node_modules/react-native/third-party-podspecs/ReactNativeDependencies.podspec`)
|
||||
- RealmJS (from `../node_modules/realm`)
|
||||
- RNBackgroundFetch (from `../node_modules/react-native-background-fetch`)
|
||||
- "RNCAsyncStorage (from `../node_modules/@react-native-async-storage/async-storage`)"
|
||||
- "RNCClipboard (from `../node_modules/@react-native-clipboard/clipboard`)"
|
||||
- RNDefaultPreference (from `../node_modules/react-native-default-preference`)
|
||||
@ -2765,8 +2762,6 @@ EXTERNAL SOURCES:
|
||||
:podspec: "../node_modules/react-native/third-party-podspecs/ReactNativeDependencies.podspec"
|
||||
RealmJS:
|
||||
:path: "../node_modules/realm"
|
||||
RNBackgroundFetch:
|
||||
:path: "../node_modules/react-native-background-fetch"
|
||||
RNCAsyncStorage:
|
||||
:path: "../node_modules/@react-native-async-storage/async-storage"
|
||||
RNCClipboard:
|
||||
@ -2811,7 +2806,7 @@ SPEC CHECKSUMS:
|
||||
BVLinearGradient: cb006ba232a1f3e4f341bb62c42d1098c284da70
|
||||
CocoaAsyncSocket: 065fd1e645c7abab64f7a6a2007a48038fdc6a99
|
||||
FBLazyVector: 24e62c765683b8d89006a88a2c8f5cf019f0074d
|
||||
hermes-engine: 4ed74710a31e8e31f20356c641eab1d8f7d54595
|
||||
hermes-engine: 86cdbf283775c54dc008895c3eacd24a1f2a40b4
|
||||
lottie-ios: 8f959969761e9c45d70353667d00af0e5b9cadb3
|
||||
lottie-react-native: ee142214581f3bb68fbda7efcf07b835a189eeda
|
||||
RCTDeprecation: a4c521821fab57cbb125b36effe84d897d0dfa12
|
||||
@ -2822,7 +2817,7 @@ SPEC CHECKSUMS:
|
||||
React: e2dc35338068bbd299c66f043ae0d7f25de8499e
|
||||
React-callinvoker: 28b25d21b124c26cebaea713ba7d801b9351dc48
|
||||
React-Core: 02ed7d2ffb70437bdf2aba074a13078a7b0b9ff0
|
||||
React-Core-prebuilt: 3445f1028d9b206cd45c8bbb7e2427ee891f810e
|
||||
React-Core-prebuilt: 77e6ce0d749dda263043e4b099bd12d086f85b4a
|
||||
React-CoreModules: b3a5a42dadcde3b5d47b325bd912eb2ced89e146
|
||||
React-cxxreact: fe8f88dda044e5905e99a00f41b7a874c3908716
|
||||
React-debug: 92944dc4d89f56d640e75498266cbde557a48189
|
||||
@ -2903,9 +2898,8 @@ SPEC CHECKSUMS:
|
||||
ReactCodegen: 1bd7f2174582b0e142f8671735b5c906c08b72ea
|
||||
ReactCommon: 7dfc3250793bf36cf221096ff59e1179e13eef7f
|
||||
ReactNativeCameraKit: 5974256fc608631c1c812710cd98abe95dae0f88
|
||||
ReactNativeDependencies: 75299c281f422106c723e79dc1f6ce7ef03241be
|
||||
ReactNativeDependencies: 0a5c93845772e4b1c5ad065c59a859518b13a6b7
|
||||
RealmJS: 1c37c6bdfe060f4caa0f9175aa0eedb962622ee1
|
||||
RNBackgroundFetch: 64b1215fbb8ec58afba877ca0ce177e009ce12b7
|
||||
RNCAsyncStorage: 2ad919e88b8bc2cd80e8697ce66d04d006743283
|
||||
RNCClipboard: 715fa7c6c8366f17d00f05a439ee7488f390fa5f
|
||||
RNDefaultPreference: 8a089ee8ce829a66c5453e3c5434f0785499d1c3
|
||||
@ -2926,6 +2920,6 @@ SPEC CHECKSUMS:
|
||||
RNWorklets: dd3b2cb0750090d78d85cd3b3ec0fdbeab5ce118
|
||||
Yoga: 77dfa8673de2874e1855002ae59c68b8be9b007b
|
||||
|
||||
PODFILE CHECKSUM: a6ebefd60fd3fd993430ecd1d3feb222ff502eb0
|
||||
PODFILE CHECKSUM: 7bf5ce6745c1d4a552afd47cefaf8272077519a4
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
COCOAPODS: 1.15.2
|
||||
|
||||
@ -11,13 +11,6 @@ module.exports = {
|
||||
'^expo/fetch$': '<rootDir>/util/expo-fetch-nodejs.js',
|
||||
'^@react-native-vector-icons/(.*)$': '<rootDir>/tests/mocks/vector-icons.js',
|
||||
'^react-native-svg$': '<rootDir>/tests/mocks/react-native-svg.js',
|
||||
// Mirror of metro.config.js resolveRequest: descriptors-core uses @noble/hashes v2
|
||||
// subpaths (e.g. `sha2.js`, `legacy.js`) but does not declare it as a dep, so npm
|
||||
// resolves up to v1.3.3 (which only exposes the no-extension subpaths via `exports`).
|
||||
// Redirect any `.js`-suffixed @noble/hashes subpath to the v2 copy nested under
|
||||
// descriptors-scure. bitcoinjs-lib imports `@noble/hashes/sha256` (no extension)
|
||||
// so it is unaffected.
|
||||
'^@noble/hashes/(.+\\.js)$': '<rootDir>/node_modules/@bitcoinerlab/descriptors-scure/node_modules/@noble/hashes/$1',
|
||||
},
|
||||
setupFiles: ['./tests/setup.js'],
|
||||
watchPathIgnorePatterns: ['<rootDir>/node_modules'],
|
||||
|
||||
14
loc/en.json
14
loc/en.json
@ -56,7 +56,6 @@
|
||||
"errorInvoiceExpired": "Invoice expired.",
|
||||
"expired": "Expired",
|
||||
"expiresIn": "Expires in {time} minutes",
|
||||
"network_fee": "Network fee: {fee}",
|
||||
"payButton": "Pay",
|
||||
"payment": "Payment",
|
||||
"placeholder": "Invoice or address",
|
||||
@ -77,13 +76,7 @@
|
||||
"preimage": "Pre-image",
|
||||
"sats": "sats.",
|
||||
"date_time": "Date and Time",
|
||||
"wasnt_paid_and_expired": "This invoice was not paid and has expired.",
|
||||
"receiving_payment": "Receiving payment…",
|
||||
"refund_funds": "Refund funds",
|
||||
"refund_deferred": "Funds aren't refundable yet. Try again after the swap timelock expires.",
|
||||
"notification_action_title": "Action needed",
|
||||
"notification_claim_body": "{walletLabel}: tap to claim your incoming Lightning payment.",
|
||||
"notification_refund_body": "{walletLabel}: tap to refund your stuck Lightning payment."
|
||||
"wasnt_paid_and_expired": "This invoice was not paid and has expired."
|
||||
},
|
||||
"plausibledeniability": {
|
||||
"create_fake_storage": "Create Encrypted Storage",
|
||||
@ -288,6 +281,7 @@
|
||||
"general": "General",
|
||||
"general_continuity": "Continuity",
|
||||
"general_continuity_e": "When enabled, you will be able to view selected wallets, and transactions, using your other Apple iCloud connected devices.",
|
||||
"groundcontrol_explanation": "GroundControl is a free, open-source push notifications server for Bitcoin wallets. You can install your own GroundControl server and put its URL here to not rely on BlueWallet’s infrastructure. Leave blank to use GroundControl’s default server.",
|
||||
"header": "Settings",
|
||||
"language": "Language",
|
||||
"last_updated": "Last Updated",
|
||||
@ -303,6 +297,7 @@
|
||||
"network_broadcast": "Broadcast Transaction",
|
||||
"network_electrum": "Electrum Server",
|
||||
"electrum_suggested_description": "When a preferred server is not set, a suggested server will be selected for use at random.",
|
||||
"not_a_valid_uri": "Invalid URI",
|
||||
"notifications": "Notifications",
|
||||
"open_link_in_explorer": "Open link in explorer",
|
||||
"password": "Password",
|
||||
@ -320,6 +315,7 @@
|
||||
"push_notifications_explanation": "By enabling notifications, your device token will be sent to the server, along with wallet addresses and transaction IDs for all wallets and transactions made after enabling notifications. The device token is used to send notifications, and the wallet information allows us to notify you about incoming Bitcoin or transaction confirmations.\n\nOnly information from after you enable notifications is transmitted—nothing from before is collected.\n\nDisabling notifications will remove all of this information from the server. Additionally, deleting a wallet from the app will also remove its associated information from the server.",
|
||||
"selfTest": "Self-Test",
|
||||
"save": "Save",
|
||||
"saved": "Saved",
|
||||
"success_transaction_broadcasted": "Your transaction has been successfully broadcasted!",
|
||||
"total_balance": "Total Balance",
|
||||
"total_balance_explanation": "Display the total balance of all your wallets on your home screen widgets.",
|
||||
@ -451,8 +447,6 @@
|
||||
"details_show_addresses": "Show addresses",
|
||||
"details_stats_coins": "Coins",
|
||||
"details_title": "Wallet",
|
||||
"restore_swap_activity": "Restore swap activity",
|
||||
"restore_swap_activity_done": "Swap activity restored.",
|
||||
"wallets": "Wallets",
|
||||
"swipe_balance_hide": "Hide",
|
||||
"swipe_balance_show": "Show",
|
||||
|
||||
@ -1,28 +1,11 @@
|
||||
const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config');
|
||||
const path = require('path');
|
||||
|
||||
// Force the Arkade SDK and its subpaths to resolve to their CJS builds. The ESM
|
||||
// build uses `export * as ns from '...'` (ES2020), which the React Native babel
|
||||
// preset does not transform, so loading the ESM entry triggers a Babel error.
|
||||
// The boltz-swap realm subpath is forced to CJS for the same reason — it
|
||||
// re-exports the SDK realm types.
|
||||
const resolveAliases = {
|
||||
'@arkade-os/sdk': path.join(__dirname, 'node_modules/@arkade-os/sdk/dist/index.cjs'),
|
||||
'@arkade-os/sdk/adapters/expo': path.join(__dirname, 'node_modules/@arkade-os/sdk/dist/adapters/expo.cjs'),
|
||||
'@arkade-os/sdk/repositories/realm': path.join(__dirname, 'node_modules/@arkade-os/sdk/dist/repositories/realm/index.cjs'),
|
||||
'@arkade-os/boltz-swap/repositories/realm': path.join(__dirname, 'node_modules/@arkade-os/boltz-swap/dist/repositories/realm/index.cjs'),
|
||||
'@arkade-os/sdk/adapters/expo': path.join(__dirname, 'node_modules/@arkade-os/sdk/dist/cjs/adapters/expo.js'),
|
||||
'expo/fetch': path.join(__dirname, 'util/expo-fetch.js'),
|
||||
};
|
||||
|
||||
// @bitcoinerlab/descriptors-core uses @noble/hashes 2.x APIs (`./legacy.js`,
|
||||
// `./sha2.js`) but does not declare @noble/hashes as a direct dep. npm
|
||||
// resolves up to the top-level @noble/hashes@1.3.3 (kept for bitcoinjs-lib),
|
||||
// which doesn't expose those subpaths. Redirect any @noble/hashes import that
|
||||
// originates inside descriptors-core to the v2 copy already nested under
|
||||
// descriptors-scure.
|
||||
const nobleHashesV2 = path.join(__dirname, 'node_modules/@bitcoinerlab/descriptors-scure/node_modules/@noble/hashes');
|
||||
const descriptorsCoreDir = path.join('node_modules', '@bitcoinerlab', 'descriptors-core');
|
||||
|
||||
/**
|
||||
* Metro configuration
|
||||
* https://reactnative.dev/docs/metro
|
||||
@ -44,13 +27,6 @@ const config = {
|
||||
filePath: resolveAliases[moduleName],
|
||||
};
|
||||
|
||||
if (moduleName.startsWith('@noble/hashes/') && context.originModulePath.includes(descriptorsCoreDir)) {
|
||||
return {
|
||||
type: 'sourceFile',
|
||||
filePath: path.join(nobleHashesV2, moduleName.slice('@noble/hashes/'.length)),
|
||||
};
|
||||
}
|
||||
|
||||
// Fall back to default resolution
|
||||
return context.resolveRequest(context, moduleName, platform);
|
||||
},
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import React, { lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Animated, AppState, View, Platform, PlatformColor, Text, StyleSheet, Pressable } from 'react-native';
|
||||
import type { NativeStackHeaderItem, NativeStackNavigationOptions } from '@react-navigation/native-stack';
|
||||
import { NativeStackNavigationOptions } from '@react-navigation/native-stack';
|
||||
import navigationStyle, { CloseButtonPosition } from '../components/navigationStyle';
|
||||
import { useTheme } from '../components/themes';
|
||||
import { useExtendedNavigation } from '../hooks/useExtendedNavigation';
|
||||
@ -57,9 +57,6 @@ import { ConnectionPollContext } from './ConnectionPollContext';
|
||||
import ManageWallets from '../screen/wallets/ManageWallets';
|
||||
import ReceiveDetails from '../screen/receive/ReceiveDetails';
|
||||
import ReceiveCustomAmountSheet from '../screen/receive/ReceiveCustomAmountSheet';
|
||||
import { isIOS26OrHigher } from '../components/platform';
|
||||
|
||||
type HeaderRightItem = ReturnType<NonNullable<NativeStackNavigationOptions['unstable_headerRightItems']>>[number];
|
||||
|
||||
const PaymentCodesList = lazy(() => import('../screen/wallets/PaymentCodesList'));
|
||||
const PaymentCodesListComponent = withLazySuspense(PaymentCodesList);
|
||||
@ -153,15 +150,6 @@ const DetailViewStackScreensStack = () => {
|
||||
navigation.navigate('AddWalletRoot');
|
||||
}, [navigation]);
|
||||
|
||||
const navigateToSettings = useCallback(() => {
|
||||
navigation.navigate('DrawerRoot', {
|
||||
screen: 'DetailViewStackScreensStack',
|
||||
params: {
|
||||
screen: 'Settings',
|
||||
},
|
||||
});
|
||||
}, [navigation]);
|
||||
|
||||
const RightBarButtons = useMemo(
|
||||
() =>
|
||||
sizeClass === SizeClass.Large ? (
|
||||
@ -231,53 +219,6 @@ const DetailViewStackScreensStack = () => {
|
||||
return null;
|
||||
};
|
||||
|
||||
if (isIOS26OrHigher) {
|
||||
// Status pills: `unstable_headerLeftItems` + `hidesSharedBackground` avoids the
|
||||
// navigation bar's shared liquid-glass chrome on the pill (solid colors only).
|
||||
return {
|
||||
title: sizeClass === SizeClass.Large ? loc.wallets.list_title : '',
|
||||
headerLargeTitle: false,
|
||||
headerTransparent: true,
|
||||
unstable_headerLeftItems: (): NativeStackHeaderItem[] => {
|
||||
const element = renderHeaderLeft();
|
||||
if (element == null) {
|
||||
return [];
|
||||
}
|
||||
return [{ type: 'custom', element, hidesSharedBackground: true }];
|
||||
},
|
||||
unstable_headerRightItems: () => {
|
||||
if (isDesktop) {
|
||||
return [];
|
||||
}
|
||||
const items: HeaderRightItem[] = [
|
||||
{
|
||||
type: 'button',
|
||||
label: loc.wallets.add_title,
|
||||
icon: { type: 'sfSymbol', name: 'plus' },
|
||||
variant: 'prominent',
|
||||
tintColor: theme.colors.headerProminentButtonBackgroundColor,
|
||||
identifier: 'AddWalletButton',
|
||||
accessibilityLabel: 'AddWalletButton',
|
||||
sharesBackground: false,
|
||||
onPress: navigateToAddWallet,
|
||||
},
|
||||
];
|
||||
if (sizeClass !== SizeClass.Large) {
|
||||
items.push({
|
||||
type: 'button',
|
||||
label: loc.settings.default_title,
|
||||
icon: { type: 'sfSymbol', name: 'ellipsis' },
|
||||
identifier: 'SettingsButton',
|
||||
accessibilityLabel: 'SettingsButton',
|
||||
sharesBackground: false,
|
||||
onPress: navigateToSettings,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: sizeClass === SizeClass.Large ? loc.wallets.list_title : '',
|
||||
headerLargeTitle: false,
|
||||
@ -292,7 +233,6 @@ const DetailViewStackScreensStack = () => {
|
||||
RightBarButtons,
|
||||
sizeClass,
|
||||
theme.colors.customHeader,
|
||||
theme.colors.headerProminentButtonBackgroundColor,
|
||||
theme.colors.foregroundColor,
|
||||
theme.colors.lightButton,
|
||||
theme.colors.redBG,
|
||||
@ -302,8 +242,6 @@ const DetailViewStackScreensStack = () => {
|
||||
electrumConnected,
|
||||
isElectrumDisabled,
|
||||
navigateToElectrumSettings,
|
||||
navigateToAddWallet,
|
||||
navigateToSettings,
|
||||
walletTransactionUpdateStatus,
|
||||
]);
|
||||
|
||||
@ -313,14 +251,6 @@ const DetailViewStackScreensStack = () => {
|
||||
|
||||
// Consistent header configuration for all settings screens
|
||||
const getSettingsHeaderOptions = (title: string) => {
|
||||
if (isIOS26OrHigher) {
|
||||
return {
|
||||
title,
|
||||
headerLargeTitle: true,
|
||||
headerLargeTitleShadowVisible: true,
|
||||
headerBackButtonDisplayMode: 'minimal' as const,
|
||||
};
|
||||
}
|
||||
// Use PlatformColor for iOS to match the Settings component, fallback to theme color
|
||||
const titleColor = Platform.OS === 'ios' ? PlatformColor('label') : theme.colors.foregroundColor;
|
||||
// Convert PlatformColor to string for TypeScript compatibility
|
||||
@ -343,9 +273,6 @@ const DetailViewStackScreensStack = () => {
|
||||
};
|
||||
};
|
||||
|
||||
const settingsScreenOptions = (title: string) =>
|
||||
isIOS26OrHigher ? getSettingsHeaderOptions(title) : navigationStyle(getSettingsHeaderOptions(title))(theme);
|
||||
|
||||
return (
|
||||
<ConnectionPollContext.Provider value={connectionPollContextValue}>
|
||||
<DetailViewStack.Navigator
|
||||
@ -412,14 +339,22 @@ const DetailViewStackScreensStack = () => {
|
||||
options={navigationStyle({ title: loc.lndViewInvoice.additional_info })(theme)}
|
||||
/>
|
||||
|
||||
<DetailViewStack.Screen name="Broadcast" component={Broadcast} options={settingsScreenOptions(loc.send.create_broadcast)} />
|
||||
<DetailViewStack.Screen
|
||||
name="Broadcast"
|
||||
component={Broadcast}
|
||||
options={navigationStyle(getSettingsHeaderOptions(loc.send.create_broadcast))(theme)}
|
||||
/>
|
||||
<DetailViewStack.Screen
|
||||
name="IsItMyAddress"
|
||||
component={IsItMyAddress}
|
||||
initialParams={{ address: undefined }}
|
||||
options={settingsScreenOptions(loc.is_it_my_address.title)}
|
||||
options={navigationStyle(getSettingsHeaderOptions(loc.is_it_my_address.title))(theme)}
|
||||
/>
|
||||
<DetailViewStack.Screen
|
||||
name="GenerateWord"
|
||||
component={GenerateWord}
|
||||
options={navigationStyle(getSettingsHeaderOptions(loc.autofill_word.title))(theme)}
|
||||
/>
|
||||
<DetailViewStack.Screen name="GenerateWord" component={GenerateWord} options={settingsScreenOptions(loc.autofill_word.title)} />
|
||||
<DetailViewStack.Screen
|
||||
name="LnurlPay"
|
||||
component={LnurlPay}
|
||||
@ -462,90 +397,115 @@ const DetailViewStackScreensStack = () => {
|
||||
<DetailViewStack.Screen
|
||||
name="Settings"
|
||||
component={Settings}
|
||||
options={
|
||||
isIOS26OrHigher
|
||||
? getSettingsHeaderOptions(loc.settings.header)
|
||||
: navigationStyle({
|
||||
title: loc.settings.header,
|
||||
headerBackButtonDisplayMode: 'minimal',
|
||||
headerBackTitle: '',
|
||||
headerShadowVisible: false,
|
||||
// headerLargeTitle is iOS-only, disable on Android for better compatibility with older versions
|
||||
headerLargeTitle: Platform.OS === 'ios',
|
||||
headerLargeTitleStyle:
|
||||
Platform.OS === 'ios'
|
||||
? {
|
||||
color:
|
||||
typeof theme.colors.foregroundColor === 'string'
|
||||
? theme.colors.foregroundColor
|
||||
: String(theme.colors.foregroundColor),
|
||||
}
|
||||
: undefined,
|
||||
headerTitleStyle: {
|
||||
options={navigationStyle({
|
||||
title: loc.settings.header,
|
||||
headerBackButtonDisplayMode: 'minimal',
|
||||
headerBackTitle: '',
|
||||
headerShadowVisible: false,
|
||||
// headerLargeTitle is iOS-only, disable on Android for better compatibility with older versions
|
||||
headerLargeTitle: Platform.OS === 'ios',
|
||||
headerLargeTitleStyle:
|
||||
Platform.OS === 'ios'
|
||||
? {
|
||||
color:
|
||||
typeof theme.colors.foregroundColor === 'string'
|
||||
? theme.colors.foregroundColor
|
||||
: String(theme.colors.foregroundColor),
|
||||
},
|
||||
headerTransparent: false,
|
||||
headerBlurEffect: undefined,
|
||||
headerStyle: {
|
||||
backgroundColor: settingsHeaderBackgroundColor,
|
||||
},
|
||||
animationTypeForReplace: 'push',
|
||||
})(theme)
|
||||
}
|
||||
}
|
||||
: undefined,
|
||||
headerTitleStyle: {
|
||||
color: typeof theme.colors.foregroundColor === 'string' ? theme.colors.foregroundColor : String(theme.colors.foregroundColor),
|
||||
},
|
||||
headerTransparent: false,
|
||||
headerBlurEffect: undefined,
|
||||
headerStyle: {
|
||||
backgroundColor: settingsHeaderBackgroundColor,
|
||||
},
|
||||
animationTypeForReplace: 'push',
|
||||
})(theme)}
|
||||
/>
|
||||
<DetailViewStack.Screen
|
||||
name="Currency"
|
||||
component={Currency}
|
||||
options={navigationStyle(getSettingsHeaderOptions(loc.settings.currency))(theme)}
|
||||
/>
|
||||
<DetailViewStack.Screen
|
||||
name="GeneralSettings"
|
||||
component={GeneralSettings}
|
||||
options={navigationStyle(getSettingsHeaderOptions(loc.settings.general))(theme)}
|
||||
/>
|
||||
<DetailViewStack.Screen name="Currency" component={Currency} options={settingsScreenOptions(loc.settings.currency)} />
|
||||
<DetailViewStack.Screen name="GeneralSettings" component={GeneralSettings} options={settingsScreenOptions(loc.settings.general)} />
|
||||
<DetailViewStack.Screen
|
||||
name="PlausibleDeniability"
|
||||
component={PlausibleDeniability}
|
||||
options={settingsScreenOptions(loc.plausibledeniability.title)}
|
||||
options={navigationStyle(getSettingsHeaderOptions(loc.plausibledeniability.title))(theme)}
|
||||
/>
|
||||
<DetailViewStack.Screen
|
||||
name="Licensing"
|
||||
component={Licensing}
|
||||
options={navigationStyle(getSettingsHeaderOptions(loc.settings.license))(theme)}
|
||||
/>
|
||||
<DetailViewStack.Screen
|
||||
name="NetworkSettings"
|
||||
component={NetworkSettings}
|
||||
options={navigationStyle(getSettingsHeaderOptions(loc.settings.network))(theme)}
|
||||
/>
|
||||
<DetailViewStack.Screen name="Licensing" component={Licensing} options={settingsScreenOptions(loc.settings.license)} />
|
||||
<DetailViewStack.Screen name="NetworkSettings" component={NetworkSettings} options={settingsScreenOptions(loc.settings.network)} />
|
||||
<DetailViewStack.Screen
|
||||
name="SettingsBlockExplorer"
|
||||
component={SettingsBlockExplorer}
|
||||
options={settingsScreenOptions(loc.settings.block_explorer)}
|
||||
options={navigationStyle(getSettingsHeaderOptions(loc.settings.block_explorer))(theme)}
|
||||
/>
|
||||
|
||||
<DetailViewStack.Screen name="About" component={About} options={settingsScreenOptions(loc.settings.about)} />
|
||||
<DetailViewStack.Screen
|
||||
name="About"
|
||||
component={About}
|
||||
options={navigationStyle(getSettingsHeaderOptions(loc.settings.about))(theme)}
|
||||
/>
|
||||
{/* <DetailViewStack.Screen
|
||||
name="DefaultView"
|
||||
component={DefaultView}
|
||||
options={settingsScreenOptions(loc.settings.default_title)}
|
||||
options={navigationStyle(getSettingsHeaderOptions(loc.settings.default_title))(theme)}
|
||||
/> */}
|
||||
<DetailViewStack.Screen
|
||||
name="ElectrumSettings"
|
||||
component={ElectrumSettings}
|
||||
options={settingsScreenOptions(loc.settings.electrum_settings_server)}
|
||||
options={navigationStyle(getSettingsHeaderOptions(loc.settings.electrum_settings_server))(theme)}
|
||||
initialParams={{ server: undefined }}
|
||||
/>
|
||||
<DetailViewStack.Screen
|
||||
name="EncryptStorage"
|
||||
component={EncryptStorage}
|
||||
options={settingsScreenOptions(loc.settings.encrypt_title)}
|
||||
options={navigationStyle(getSettingsHeaderOptions(loc.settings.encrypt_title))(theme)}
|
||||
/>
|
||||
<DetailViewStack.Screen
|
||||
name="Language"
|
||||
component={Language}
|
||||
options={navigationStyle(getSettingsHeaderOptions(loc.settings.language))(theme)}
|
||||
/>
|
||||
<DetailViewStack.Screen name="Language" component={Language} options={settingsScreenOptions(loc.settings.language)} />
|
||||
<DetailViewStack.Screen
|
||||
name="LightningSettings"
|
||||
component={LightningSettings}
|
||||
options={settingsScreenOptions(loc.settings.lightning_settings)}
|
||||
options={navigationStyle(getSettingsHeaderOptions(loc.settings.lightning_settings))(theme)}
|
||||
/>
|
||||
<DetailViewStack.Screen
|
||||
name="NotificationSettings"
|
||||
component={NotificationSettings}
|
||||
options={settingsScreenOptions(loc.settings.notifications)}
|
||||
options={navigationStyle(getSettingsHeaderOptions(loc.settings.notifications))(theme)}
|
||||
/>
|
||||
<DetailViewStack.Screen
|
||||
name="SelfTest"
|
||||
component={SelfTest}
|
||||
options={navigationStyle(getSettingsHeaderOptions(loc.settings.selfTest))(theme)}
|
||||
/>
|
||||
<DetailViewStack.Screen name="SelfTest" component={SelfTest} options={settingsScreenOptions(loc.settings.selfTest)} />
|
||||
<DetailViewStack.Screen
|
||||
name="ReleaseNotes"
|
||||
component={ReleaseNotes}
|
||||
options={settingsScreenOptions(loc.settings.about_release_notes)}
|
||||
options={navigationStyle(getSettingsHeaderOptions(loc.settings.about_release_notes))(theme)}
|
||||
/>
|
||||
<DetailViewStack.Screen
|
||||
name="SettingsTools"
|
||||
component={SettingsTools}
|
||||
options={navigationStyle(getSettingsHeaderOptions(loc.settings.tools))(theme)}
|
||||
/>
|
||||
<DetailViewStack.Screen name="SettingsTools" component={SettingsTools} options={settingsScreenOptions(loc.settings.tools)} />
|
||||
<DetailViewStack.Screen
|
||||
name="PromptPasswordConfirmationSheet"
|
||||
component={PromptPasswordConfirmationSheet}
|
||||
|
||||
@ -7,6 +7,7 @@ export type LNDStackParamsList = {
|
||||
ScanLNDInvoice: {
|
||||
walletID: string | undefined;
|
||||
uri: string | undefined;
|
||||
invoice: string | undefined;
|
||||
onBarScanned: string | undefined;
|
||||
};
|
||||
LnurlPay: {
|
||||
|
||||
@ -1,98 +1,44 @@
|
||||
import React from 'react';
|
||||
import { Platform, TouchableOpacity, StyleSheet } from 'react-native';
|
||||
import type { NativeStackHeaderItem, NativeStackNavigationOptions } from '@react-navigation/native-stack';
|
||||
import { TouchableOpacity, StyleSheet } from 'react-native';
|
||||
import Icon from '../../components/Icon';
|
||||
import WalletGradient from '../../class/wallet-gradient';
|
||||
import { NativeStackNavigationOptions } from '@react-navigation/native-stack';
|
||||
import { DetailViewStackParamList } from '../DetailViewStackParamList';
|
||||
import { navigationRef } from '../../NavigationService';
|
||||
import { RouteProp } from '@react-navigation/native';
|
||||
import { isDesktop } from '../../blue_modules/environment';
|
||||
import { isIOS26OrHigher } from '../../components/platform';
|
||||
import loc from '../../loc';
|
||||
|
||||
export type WalletTransactionsRouteProps = RouteProp<DetailViewStackParamList, 'WalletTransactions'>;
|
||||
|
||||
const HERO_HEADER_ICON_COLOR = '#FFFFFF';
|
||||
const getWalletTransactionsOptions = ({ route }: { route: WalletTransactionsRouteProps }): NativeStackNavigationOptions => {
|
||||
const { isLoading = false, walletID, walletType } = route.params;
|
||||
|
||||
const navigateToWalletDetails = (walletID: string) => {
|
||||
navigationRef.navigate('WalletDetails', {
|
||||
walletID,
|
||||
});
|
||||
};
|
||||
const onPress = () => {
|
||||
navigationRef.navigate('WalletDetails', {
|
||||
walletID,
|
||||
});
|
||||
};
|
||||
|
||||
/** Material "more" button for WalletTransactions header (pre–iOS 26 and Android). */
|
||||
export const createWalletDetailsHeaderRight = ({
|
||||
walletID,
|
||||
isLoading = false,
|
||||
iconColor = HERO_HEADER_ICON_COLOR,
|
||||
}: {
|
||||
walletID: string;
|
||||
isLoading?: boolean;
|
||||
iconColor?: string;
|
||||
}): (() => React.ReactElement) => {
|
||||
return () => (
|
||||
<TouchableOpacity
|
||||
accessibilityRole="button"
|
||||
testID="WalletDetails"
|
||||
disabled={isLoading}
|
||||
style={styles.walletDetails}
|
||||
onPress={() => navigateToWalletDetails(walletID)}
|
||||
>
|
||||
<Icon name="more-horiz" type="material" size={22} color={iconColor} />
|
||||
const RightButton = (
|
||||
<TouchableOpacity accessibilityRole="button" testID="WalletDetails" disabled={isLoading} style={styles.walletDetails} onPress={onPress}>
|
||||
<Icon name="more-horiz" type="material" size={22} color="#FFFFFF" />
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
/** Native toolbar ellipsis for WalletTransactions on iOS 26+. */
|
||||
export const createWalletDetailsHeaderRightItems = ({
|
||||
isLoading = false,
|
||||
walletID,
|
||||
}: {
|
||||
isLoading?: boolean;
|
||||
walletID: string;
|
||||
}): (() => NativeStackHeaderItem[]) => {
|
||||
return () => [
|
||||
{
|
||||
type: 'button',
|
||||
label: loc.wallets.details_title,
|
||||
icon: { type: 'sfSymbol', name: 'ellipsis' },
|
||||
identifier: 'WalletDetails',
|
||||
accessibilityLabel: 'WalletDetails',
|
||||
sharesBackground: false,
|
||||
onPress: () => navigateToWalletDetails(walletID),
|
||||
disabled: isLoading,
|
||||
},
|
||||
];
|
||||
};
|
||||
const backgroundColor = WalletGradient.headerColorFor(walletType);
|
||||
|
||||
const getWalletTransactionsOptions = ({ route }: { route: WalletTransactionsRouteProps }): NativeStackNavigationOptions => {
|
||||
const { isLoading = false, walletID } = route.params;
|
||||
|
||||
const base: NativeStackNavigationOptions = {
|
||||
return {
|
||||
title: '',
|
||||
headerBackTitleStyle: { fontSize: 0 },
|
||||
headerTransparent: true,
|
||||
headerStyle: {
|
||||
backgroundColor: 'transparent',
|
||||
backgroundColor,
|
||||
},
|
||||
headerBackButtonDisplayMode: 'minimal',
|
||||
headerShadowVisible: false,
|
||||
headerTintColor: HERO_HEADER_ICON_COLOR,
|
||||
headerBlurEffect: undefined,
|
||||
headerTintColor: '#FFFFFF',
|
||||
statusBarStyle: 'light',
|
||||
headerBackTitle: undefined,
|
||||
headerRight: createWalletDetailsHeaderRight({ walletID, isLoading, iconColor: HERO_HEADER_ICON_COLOR }),
|
||||
headerRight: () => RightButton,
|
||||
};
|
||||
|
||||
if (Platform.OS === 'ios' && isIOS26OrHigher && !isDesktop) {
|
||||
return {
|
||||
...base,
|
||||
headerRight: undefined,
|
||||
experimental_userInterfaceStyle: 'dark' as const,
|
||||
unstable_headerRightItems: createWalletDetailsHeaderRightItems({ isLoading, walletID }),
|
||||
};
|
||||
}
|
||||
|
||||
return base;
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
|
||||
692
package-lock.json
generated
692
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
43
package.json
43
package.json
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bluewallet",
|
||||
"version": "8.0.1",
|
||||
"version": "8.0.0",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@ -16,17 +16,19 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.26.0",
|
||||
"@babel/preset-env": "^7.26.0",
|
||||
"@babel/runtime": "^7.26.0",
|
||||
"@jest/reporters": "^27.5.1",
|
||||
"@react-native/eslint-config": "^0.85.3",
|
||||
"@react-native/jest-preset": "0.85.3",
|
||||
"@react-native/js-polyfills": "^0.86.0",
|
||||
"@react-native/js-polyfills": "^0.85.3",
|
||||
"@react-native/metro-babel-transformer": "^0.85.3",
|
||||
"@react-native/typescript-config": "^0.85.3",
|
||||
"@testing-library/react-native": "^13.0.1",
|
||||
"@types/bip38": "^3.1.2",
|
||||
"@types/bs58check": "^2.1.0",
|
||||
"@types/create-hash": "^1.2.2",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/jest": "^29.5.13",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-test-renderer": "^19.1.0",
|
||||
@ -89,39 +91,38 @@
|
||||
"lint": " npm run tslint && node scripts/find-unused-loc.js && node scripts/find-english-leftovers.js && eslint --ext .js,.ts,.tsx '*.@(js|ts|tsx)' screen 'blue_modules/*.@(js|ts|tsx)' class models loc tests components navigation typings",
|
||||
"lint:fix": "npm run lint -- --fix",
|
||||
"lint:quickfix": "git status --porcelain | grep -v '\\.json' | grep -E '\\.js|\\.ts' --color=never | awk '{print $2}' | xargs eslint --fix; exit 0",
|
||||
"unit": "jest -b tests/unit/*"
|
||||
"unit": "jest -b -w tests/unit/*"
|
||||
},
|
||||
"dependencies": {
|
||||
"@arkade-os/boltz-swap": "0.3.40",
|
||||
"@arkade-os/sdk": "0.4.35",
|
||||
"@arkade-os/boltz-swap": "0.2.19",
|
||||
"@arkade-os/sdk": "0.3.12",
|
||||
"@babel/preset-env": "7.29.5",
|
||||
"@bugsnag/react-native": "8.9.0",
|
||||
"@bugsnag/source-maps": "2.3.3",
|
||||
"@keystonehq/bc-ur-registry": "0.7.1",
|
||||
"@ngraveio/bc-ur": "1.1.13",
|
||||
"@noble/ciphers": "1.3.0",
|
||||
"@noble/hashes": "1.8.0",
|
||||
"@noble/secp256k1": "3.1.0",
|
||||
"@noble/hashes": "1.3.3",
|
||||
"@noble/secp256k1": "1.6.3",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-native-clipboard/clipboard": "1.16.3",
|
||||
"@react-native-community/cli": "20.1.3",
|
||||
"@react-native-community/cli-platform-android": "20.1.3",
|
||||
"@react-native-community/cli-platform-ios": "20.1.3",
|
||||
"@react-native-documents/picker": "12.0.1",
|
||||
"@react-native-vector-icons/entypo": "13.1.2",
|
||||
"@react-native-vector-icons/fontawesome": "13.1.2",
|
||||
"@react-native-vector-icons/fontawesome6": "13.1.2",
|
||||
"@react-native-vector-icons/ionicons": "13.1.2",
|
||||
"@react-native-vector-icons/material-design-icons": "13.1.2",
|
||||
"@react-native-vector-icons/material-icons": "13.1.2",
|
||||
"@react-native-vector-icons/entypo": "13.1.1",
|
||||
"@react-native-vector-icons/fontawesome": "13.1.1",
|
||||
"@react-native-vector-icons/fontawesome6": "13.1.1",
|
||||
"@react-native-vector-icons/ionicons": "13.1.1",
|
||||
"@react-native-vector-icons/material-design-icons": "13.1.1",
|
||||
"@react-native-vector-icons/material-icons": "13.1.1",
|
||||
"@react-native/babel-preset": "0.85.3",
|
||||
"@react-native/codegen": "0.85.3",
|
||||
"@react-native/gradle-plugin": "0.85.3",
|
||||
"@react-native/metro-config": "0.85.3",
|
||||
"@react-navigation/devtools": "7.0.62",
|
||||
"@react-navigation/drawer": "7.12.0",
|
||||
"@react-navigation/native": "7.3.1",
|
||||
"@react-navigation/native-stack": "7.17.3",
|
||||
"@react-navigation/devtools": "7.0.58",
|
||||
"@react-navigation/drawer": "7.10.2",
|
||||
"@react-navigation/native": "7.2.4",
|
||||
"@react-navigation/native-stack": "7.15.1",
|
||||
"@scure/base": "2.0.0",
|
||||
"@spsina/bip47": "github:BlueWallet/bip47#df82345",
|
||||
"aezeed": "0.0.5",
|
||||
@ -140,7 +141,8 @@
|
||||
"buffer": "6.0.3",
|
||||
"coinselect": "github:BlueWallet/coinselect#35f8038",
|
||||
"crypto-browserify": "3.12.1",
|
||||
"dayjs": "1.11.21",
|
||||
"crypto-js": "4.2.0",
|
||||
"dayjs": "1.11.20",
|
||||
"detox": "20.51.3",
|
||||
"ecpair": "3.0.1",
|
||||
"electrum-client": "github:BlueWallet/rn-electrum-client#83420b8",
|
||||
@ -154,7 +156,6 @@
|
||||
"react": "19.2.3",
|
||||
"react-localization": "github:BlueWallet/react-localization#ae7969a",
|
||||
"react-native": "0.85.3",
|
||||
"react-native-background-fetch": "4.2.9",
|
||||
"react-native-biometrics": "3.0.1",
|
||||
"react-native-blue-crypto": "github:BlueWallet/react-native-blue-crypto#3cb5442",
|
||||
"react-native-camera-kit-no-google": "github:BlueWallet/react-native-camera-kit-no-google#0ed049a62da29cf304019363ec9d9ef3a73652e6",
|
||||
@ -174,7 +175,7 @@
|
||||
"react-native-linear-gradient": "2.8.3",
|
||||
"react-native-localize": "3.7.0",
|
||||
"react-native-notifications": "5.2.2",
|
||||
"react-native-permissions": "5.5.3",
|
||||
"react-native-permissions": "5.5.1",
|
||||
"react-native-prompt-android": "github:BlueWallet/react-native-prompt-android#ed168d66fed556bc2ed07cf498770f058b78a376",
|
||||
"react-native-quick-actions": "0.3.13",
|
||||
"react-native-reanimated": "4.3.1",
|
||||
|
||||
@ -1,84 +0,0 @@
|
||||
diff --git a/node_modules/@react-navigation/native-stack/lib/module/views/useHeaderConfigProps.js b/node_modules/@react-navigation/native-stack/lib/module/views/useHeaderConfigProps.js
|
||||
index a42477a..3ff714c 100644
|
||||
--- a/node_modules/@react-navigation/native-stack/lib/module/views/useHeaderConfigProps.js
|
||||
+++ b/node_modules/@react-navigation/native-stack/lib/module/views/useHeaderConfigProps.js
|
||||
@@ -159,7 +159,8 @@ export function useHeaderConfigProps({
|
||||
route,
|
||||
title,
|
||||
unstable_headerLeftItems: headerLeftItems,
|
||||
- unstable_headerRightItems: headerRightItems
|
||||
+ unstable_headerRightItems: headerRightItems,
|
||||
+ experimental_userInterfaceStyle: experimentalUserInterfaceStyleOption
|
||||
}) {
|
||||
const {
|
||||
direction
|
||||
@@ -365,7 +366,7 @@ export function useHeaderConfigProps({
|
||||
children,
|
||||
headerLeftBarButtonItems: processBarButtonItems(leftItems, colors, fonts),
|
||||
headerRightBarButtonItems: processBarButtonItems(rightItems, colors, fonts),
|
||||
- experimental_userInterfaceStyle: dark ? 'dark' : 'light'
|
||||
+ experimental_userInterfaceStyle: experimentalUserInterfaceStyleOption ?? (dark ? 'dark' : 'light')
|
||||
};
|
||||
}
|
||||
//# sourceMappingURL=useHeaderConfigProps.js.map
|
||||
\ No newline at end of file
|
||||
diff --git a/node_modules/@react-navigation/native-stack/lib/typescript/src/types.d.ts b/node_modules/@react-navigation/native-stack/lib/typescript/src/types.d.ts
|
||||
index 2f1351a..5742b66 100644
|
||||
--- a/node_modules/@react-navigation/native-stack/lib/typescript/src/types.d.ts
|
||||
+++ b/node_modules/@react-navigation/native-stack/lib/typescript/src/types.d.ts
|
||||
@@ -302,6 +302,14 @@ export type NativeStackNavigationOptions = {
|
||||
* @platform ios
|
||||
*/
|
||||
unstable_headerRightItems?: (props: NativeStackHeaderItemProps) => NativeStackHeaderItem[];
|
||||
+ /**
|
||||
+ * When set, overrides the navigation header `UIUserInterfaceStyle` (affects iOS 26+ bar materials and tint resolution).
|
||||
+ * If omitted, React Navigation sets this from the navigation theme `dark` boolean (`true` → `"dark"`, else `"light"`).
|
||||
+ *
|
||||
+ * @platform ios
|
||||
+ * @experimental
|
||||
+ */
|
||||
+ experimental_userInterfaceStyle?: import('react-native-screens').ScreenStackHeaderConfigProps['experimental_userInterfaceStyle'];
|
||||
/**
|
||||
* String or a function that returns a React Element to be used by the header.
|
||||
* Defaults to screen `title` or route name.
|
||||
diff --git a/node_modules/@react-navigation/native-stack/src/types.tsx b/node_modules/@react-navigation/native-stack/src/types.tsx
|
||||
index 7488b1c..542333e 100644
|
||||
--- a/node_modules/@react-navigation/native-stack/src/types.tsx
|
||||
+++ b/node_modules/@react-navigation/native-stack/src/types.tsx
|
||||
@@ -350,6 +350,15 @@ export type NativeStackNavigationOptions = {
|
||||
unstable_headerRightItems?: (
|
||||
props: NativeStackHeaderItemProps
|
||||
) => NativeStackHeaderItem[];
|
||||
+ /**
|
||||
+ * When set, overrides the navigation header `UIUserInterfaceStyle` (affects iOS 26+ bar materials and tint resolution).
|
||||
+ * If omitted, React Navigation sets this from the navigation theme `dark` boolean (`true` → `"dark"`, else `"light"`).
|
||||
+ *
|
||||
+ * @platform ios
|
||||
+ * @experimental
|
||||
+ * @see {@link https://github.com/react-navigation/react-navigation/issues/13069}
|
||||
+ */
|
||||
+ experimental_userInterfaceStyle?: ScreenStackHeaderConfigProps['experimental_userInterfaceStyle'];
|
||||
/**
|
||||
* String or a function that returns a React Element to be used by the header.
|
||||
* Defaults to screen `title` or route name.
|
||||
diff --git a/node_modules/@react-navigation/native-stack/src/views/useHeaderConfigProps.tsx b/node_modules/@react-navigation/native-stack/src/views/useHeaderConfigProps.tsx
|
||||
index 6f74856..d12cf7d 100644
|
||||
--- a/node_modules/@react-navigation/native-stack/src/views/useHeaderConfigProps.tsx
|
||||
+++ b/node_modules/@react-navigation/native-stack/src/views/useHeaderConfigProps.tsx
|
||||
@@ -217,6 +217,7 @@ export function useHeaderConfigProps({
|
||||
title,
|
||||
unstable_headerLeftItems: headerLeftItems,
|
||||
unstable_headerRightItems: headerRightItems,
|
||||
+ experimental_userInterfaceStyle: experimentalUserInterfaceStyleOption,
|
||||
}: Props): ScreenStackHeaderConfigProps {
|
||||
const { direction } = useLocale();
|
||||
const { colors, fonts, dark } = useTheme();
|
||||
@@ -527,6 +528,7 @@ export function useHeaderConfigProps({
|
||||
children,
|
||||
headerLeftBarButtonItems: processBarButtonItems(leftItems, colors, fonts),
|
||||
headerRightBarButtonItems: processBarButtonItems(rightItems, colors, fonts),
|
||||
- experimental_userInterfaceStyle: dark ? 'dark' : 'light',
|
||||
+ experimental_userInterfaceStyle:
|
||||
+ experimentalUserInterfaceStyleOption ?? (dark ? 'dark' : 'light'),
|
||||
} as const;
|
||||
}
|
||||
@ -64,48 +64,3 @@ delivered.
|
||||
Added in BlueWallet PR https://github.com/BlueWallet/BlueWallet/pull/8424
|
||||
during a React Native bump. Remove once `react-native-notifications`
|
||||
ships New-Architecture-safe token delivery.
|
||||
|
||||
---
|
||||
|
||||
## `@react-navigation+native-stack+7.15.1.patch`
|
||||
|
||||
**What:** adds an `experimental_userInterfaceStyle` navigation option to
|
||||
`NativeStackNavigationOptions` (typed in `src/types.tsx` and the built
|
||||
`lib/typescript` d.ts) and threads it through `useHeaderConfigProps` so a
|
||||
screen can override the header's `UIUserInterfaceStyle`. When omitted it
|
||||
falls back to the previous behaviour via
|
||||
`experimentalUserInterfaceStyleOption ?? (dark ? 'dark' : 'light')`.
|
||||
|
||||
**Why:** on iOS 26 the navigation bar's liquid-glass material and tint are
|
||||
resolved from `UIUserInterfaceStyle`. React Navigation hard-codes this from
|
||||
the theme `dark` boolean, so a screen cannot force a light/dark header
|
||||
independent of the active theme. The iOS 26 glass header
|
||||
(`screen/wallets/WalletTransactions.tsx`) needs that per-screen override.
|
||||
|
||||
**Upstream:** https://github.com/react-navigation/react-navigation/issues/13069 (open)
|
||||
|
||||
Added in BlueWallet PR https://github.com/BlueWallet/BlueWallet/pull/8508.
|
||||
Remove once `@react-navigation/native-stack` exposes a header
|
||||
`UIUserInterfaceStyle` override upstream. When bumping the dependency,
|
||||
rename this patch to the new version and re-confirm the hunks still apply
|
||||
(`npx patch-package`).
|
||||
|
||||
---
|
||||
|
||||
## `react-native-screens+4.25.2.patch`
|
||||
|
||||
**What:** in `RNSBarButtonItem.mm`, also set `self.accessibilityIdentifier`
|
||||
when the JS `identifier` is provided (one line, alongside the existing
|
||||
`self.identifier = identifier`).
|
||||
|
||||
**Why:** the iOS 26 glass header builds nav-bar buttons through
|
||||
`unstable_headerRightItems`. The native `identifier` is not exposed as an
|
||||
accessibility identifier, so Detox/XCUITest could not target those bar
|
||||
buttons. Mirroring it onto `accessibilityIdentifier` makes them reachable
|
||||
from e2e tests.
|
||||
|
||||
**Upstream:** no issue filed yet — local accessibility enhancement.
|
||||
|
||||
Added in BlueWallet PR https://github.com/BlueWallet/BlueWallet/pull/8508.
|
||||
When bumping `react-native-screens`, rename this patch to the new version
|
||||
and re-confirm the hunk still applies (`npx patch-package`).
|
||||
|
||||
@ -1,12 +0,0 @@
|
||||
diff --git a/node_modules/react-native-screens/ios/RNSBarButtonItem.mm b/node_modules/react-native-screens/ios/RNSBarButtonItem.mm
|
||||
index 0eb1f09..324b888 100644
|
||||
--- a/node_modules/react-native-screens/ios/RNSBarButtonItem.mm
|
||||
+++ b/node_modules/react-native-screens/ios/RNSBarButtonItem.mm
|
||||
@@ -81,6 +81,7 @@ - (instancetype)initWithConfig:(NSDictionary<NSString *, id> *)dict
|
||||
NSString *identifier = dict[@"identifier"];
|
||||
if (identifier != nil) {
|
||||
self.identifier = identifier;
|
||||
+ self.accessibilityIdentifier = identifier;
|
||||
}
|
||||
NSDictionary *badgeConfig = dict[@"badge"];
|
||||
if (badgeConfig != nil) {
|
||||
@ -1,29 +1,8 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": [
|
||||
"config:best-practices",
|
||||
":disableMajorUpdates",
|
||||
":preserveSemverRanges"
|
||||
],
|
||||
"ignoreDeps": ["react-native"],
|
||||
"schedule": ["before 6am on monday"],
|
||||
"prConcurrentLimit": 1,
|
||||
"prHourlyLimit": 0,
|
||||
"minimumReleaseAge": "3 days",
|
||||
"semanticCommits": "disabled",
|
||||
"commitMessagePrefix": "OPS:",
|
||||
"packageRules": [
|
||||
{
|
||||
"matchPackageNames": ["*"],
|
||||
"groupName": "all dependencies",
|
||||
"groupSlug": "all"
|
||||
}
|
||||
],
|
||||
"lockFileMaintenance": {
|
||||
"enabled": true,
|
||||
"schedule": ["before 6am on monday"]
|
||||
},
|
||||
"vulnerabilityAlerts": {
|
||||
"schedule": ["at any time"]
|
||||
}
|
||||
"ignoreDeps": ["react-native"]
|
||||
}
|
||||
|
||||
@ -37,7 +37,7 @@ const ScanLNDInvoice = () => {
|
||||
const { colors } = useTheme();
|
||||
const { direction } = useLocale();
|
||||
const route = useRoute<RouteProps>();
|
||||
const { walletID, uri } = route.params || {};
|
||||
const { walletID, uri, invoice } = route.params || {};
|
||||
const [wallet, setWallet] = useState<LightningCustodianWallet | undefined>(
|
||||
(wallets.find(item => item.getID() === walletID) as LightningCustodianWallet) ||
|
||||
(wallets.find(item => item.chain === Chain.OFFCHAIN) as LightningCustodianWallet),
|
||||
@ -51,7 +51,6 @@ const ScanLNDInvoice = () => {
|
||||
const [amount, setAmount] = useState<string | undefined>();
|
||||
const [isAmountInitiallyEmpty, setIsAmountInitiallyEmpty] = useState<boolean | undefined>();
|
||||
const [expiresIn, setExpiresIn] = useState<string | undefined>();
|
||||
const [arkFeesReady, setArkFeesReady] = useState<boolean>(false);
|
||||
const stylesHook = StyleSheet.create({
|
||||
walletWrapLabel: {
|
||||
color: colors.buttonAlternativeTextColor,
|
||||
@ -84,25 +83,6 @@ const ScanLNDInvoice = () => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [walletID]);
|
||||
|
||||
useEffect(() => {
|
||||
// Reset readiness whenever the selected wallet changes (or is not an Ark
|
||||
// wallet) so a stale `true` from a previously-selected wallet never carries
|
||||
// over to one whose fees are not loaded yet.
|
||||
if (!(wallet instanceof LightningArkWallet)) {
|
||||
setArkFeesReady(false);
|
||||
return;
|
||||
}
|
||||
setArkFeesReady(false);
|
||||
let cancelled = false;
|
||||
wallet
|
||||
.ensureLightningFeesLoaded()
|
||||
.then(() => !cancelled && setArkFeesReady(true))
|
||||
.catch(() => {}); // fee label is non-critical; stay silent and keep the line hidden
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [wallet]);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (!wallet) {
|
||||
@ -135,7 +115,7 @@ const ScanLNDInvoice = () => {
|
||||
if (data.toLowerCase().startsWith('ark1')) {
|
||||
const arkw = new LightningArkWallet();
|
||||
if (arkw.isAddressValid(data)) {
|
||||
setParams({ uri: undefined });
|
||||
setParams({ uri: undefined, invoice: data });
|
||||
// @ts-ignore we need it to be set to something
|
||||
setDecoded({});
|
||||
setIsAmountInitiallyEmpty(true);
|
||||
@ -160,7 +140,7 @@ const ScanLNDInvoice = () => {
|
||||
}
|
||||
|
||||
Keyboard.dismiss();
|
||||
setParams({ uri: undefined });
|
||||
setParams({ uri: undefined, invoice: data });
|
||||
setIsAmountInitiallyEmpty(newDecoded.num_satoshis === 0);
|
||||
setDestination(data);
|
||||
setIsLoading(false);
|
||||
@ -206,7 +186,7 @@ const ScanLNDInvoice = () => {
|
||||
};
|
||||
|
||||
const pay = async () => {
|
||||
if (!decoded || !wallet || !amount || !destination) {
|
||||
if (!decoded || !wallet || !amount || !invoice) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -247,7 +227,7 @@ const ScanLNDInvoice = () => {
|
||||
}
|
||||
|
||||
try {
|
||||
await wallet.payInvoice(destination, amountSats);
|
||||
await wallet.payInvoice(invoice, amountSats);
|
||||
} catch (Err: any) {
|
||||
console.log(Err.message);
|
||||
setIsLoading(false);
|
||||
@ -319,22 +299,8 @@ const ScanLNDInvoice = () => {
|
||||
};
|
||||
|
||||
const getFees = (): string => {
|
||||
// Guard the amount, not just `decoded`: the Ark-address path sets
|
||||
// `decoded = {}` (truthy) and amountless invoices leave num_satoshis
|
||||
// 0/absent. The old `if (!decoded)` was safe only because getFees() used to
|
||||
// be called solely inside the `num_satoshis > 0` JSX guard; the fee value is
|
||||
// now hoisted unconditionally, so it must short-circuit here or
|
||||
// `undefined.toString()` throws.
|
||||
if (!decoded?.num_satoshis) return '';
|
||||
if (!decoded) return '';
|
||||
const num_satoshis = parseInt(decoded.num_satoshis.toString(), 10);
|
||||
|
||||
if (wallet instanceof LightningArkWallet) {
|
||||
if (!arkFeesReady) return ''; // not loaded yet → fee line stays hidden until warm
|
||||
const est = wallet.getSubmarineFeeEstimate(num_satoshis);
|
||||
return est === undefined ? '' : `${est} ${BitcoinUnit.SATS}`;
|
||||
}
|
||||
|
||||
// LightningCustodianWallet (LndHub): keep the legacy hardcoded estimate.
|
||||
const min = Math.floor(num_satoshis * 0.003);
|
||||
const max = Math.floor(num_satoshis * 0.01) + 1;
|
||||
return `${min} ${BitcoinUnit.SATS} - ${max} ${BitcoinUnit.SATS}`;
|
||||
@ -382,12 +348,6 @@ const ScanLNDInvoice = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const feeText = getFees();
|
||||
// Boltz publishes deterministic fees, so Arkade shows a single fixed amount
|
||||
// under a definite label ("Network fee"), not a "potential" bracket. Custodial
|
||||
// keeps the legacy "Potential fee" label + range.
|
||||
const feeLabel = wallet instanceof LightningArkWallet ? loc.lnd.network_fee : loc.lnd.potentialFee;
|
||||
|
||||
return (
|
||||
<SafeArea style={stylesHook.root}>
|
||||
<View style={[styles.root, stylesHook.root]}>
|
||||
@ -429,8 +389,8 @@ const ScanLNDInvoice = () => {
|
||||
{expiresIn !== undefined && (
|
||||
<View>
|
||||
<Text style={stylesHook.expiresIn}>{expiresIn}</Text>
|
||||
{decoded && decoded.num_satoshis > 0 && feeText !== '' && (
|
||||
<Text style={stylesHook.expiresIn}>{loc.formatString(feeLabel, { fee: feeText })}</Text>
|
||||
{decoded && decoded.num_satoshis > 0 && (
|
||||
<Text style={stylesHook.expiresIn}>{loc.formatString(loc.lnd.potentialFee, { fee: getFees() })}</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
@ -510,8 +510,6 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
marginHorizontal: 8,
|
||||
minHeight: 33,
|
||||
fontSize: 15,
|
||||
lineHeight: 19,
|
||||
color: '#81868e',
|
||||
},
|
||||
});
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useReducer, useRef, useState } from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { RouteProp, useNavigation, useNavigationState, useRoute, useLocale } from '@react-navigation/native';
|
||||
import { ActivityIndicator, BackHandler, Image, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { BackHandler, Image, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import Icon from '../../components/Icon';
|
||||
import Share from 'react-native-share';
|
||||
import triggerHapticFeedback, { HapticFeedbackTypes } from '../../blue_modules/hapticFeedback';
|
||||
@ -22,10 +22,6 @@ import dayjs from 'dayjs';
|
||||
import SafeAreaScrollView from '../../components/SafeAreaScrollView';
|
||||
import { BlueSpacing20 } from '../../components/BlueSpacing';
|
||||
import { LightningCustodianWallet } from '../../class/wallets/lightning-custodian-wallet';
|
||||
import { LightningArkWallet } from '../../class/wallets/lightning-ark-wallet';
|
||||
import presentAlert from '../../components/Alert';
|
||||
import { isReverseSuccessStatus } from '@arkade-os/boltz-swap';
|
||||
import type { BoltzSubmarineSwap } from '@arkade-os/boltz-swap';
|
||||
|
||||
type LNDViewInvoiceRouteParams = {
|
||||
walletID: string;
|
||||
@ -41,75 +37,12 @@ const LNDViewInvoice = () => {
|
||||
const navigation = useNavigation();
|
||||
|
||||
const wallet = wallets.find(w => w.getID() === walletID) as LightningCustodianWallet | undefined;
|
||||
const arkWallet =
|
||||
wallet && (wallet as { type?: string }).type === LightningArkWallet.type ? (wallet as unknown as LightningArkWallet) : undefined;
|
||||
const [isFetchingInvoices, setIsFetchingInvoices] = useState<boolean>(true);
|
||||
const [invoiceStatusChanged, setInvoiceStatusChanged] = useState<boolean>(false);
|
||||
const [qrCodeSize, setQRCodeSize] = useState<number>(90);
|
||||
const fetchInvoiceInterval = useRef<ReturnType<typeof setInterval> | undefined>(undefined);
|
||||
const fetchInvoiceInterval = useRef<any>(null);
|
||||
const isModal = useNavigationState(state => state.routeNames[0] === LNDCreateInvoice.routeName);
|
||||
|
||||
// Per-swap claim/refund lookup, by the `swap-${id}` prefix mapped onto
|
||||
// the row's `txid` field by lightning-ark-wallet getTransactions(). The
|
||||
// route param is typed as LightningTransaction (which doesn't declare
|
||||
// txid) but at runtime carries the merged `Transaction & LightningTransaction`
|
||||
// shape, so we read txid through a narrow local cast. For non-Ark wallets
|
||||
// and non-swap rows this resolves to undefined and the UI falls through
|
||||
// to the existing branches.
|
||||
const invoiceTxid = typeof invoice === 'object' ? (invoice as { txid?: unknown }).txid : undefined;
|
||||
const swapId = typeof invoiceTxid === 'string' && invoiceTxid.startsWith('swap-') ? invoiceTxid.slice('swap-'.length) : undefined;
|
||||
// Force-render token: bumped by the swap-event subscription below so live
|
||||
// `swap.status` lookups (via getSwapById → _swapHistory) re-evaluate the
|
||||
// moment the SDK observes a status transition, without waiting for the
|
||||
// 3s polling tick to update the route-param snapshot.
|
||||
const [, forceRender] = useReducer((x: number) => x + 1, 0);
|
||||
const swap = swapId && arkWallet ? arkWallet.getSwapById(swapId) : undefined;
|
||||
const [isActioning, setIsActioning] = useState<boolean>(false);
|
||||
const claimable = arkWallet && swap ? arkWallet.isSwapClaimable(swap) : false;
|
||||
const refundable = arkWallet && swap ? arkWallet.isSwapRefundable(swap) : false;
|
||||
|
||||
// Subscribe to SwapManager status transitions for our swap so the spinner
|
||||
// → success transition is driven by SDK events, not the 3s polling lag.
|
||||
// The SDK mutates `swap.status` in place before invoking listeners, so by
|
||||
// the time we force a render `getSwapById(swapId).status` reflects the
|
||||
// new state and the success/refund branches re-evaluate correctly.
|
||||
useEffect(() => {
|
||||
if (!arkWallet || !swapId) return;
|
||||
return arkWallet.subscribeToSwapEvents(updatedSwap => {
|
||||
if (updatedSwap.id === swapId) forceRender();
|
||||
});
|
||||
}, [arkWallet, swapId]);
|
||||
|
||||
const refreshAfterAction = async () => {
|
||||
if (!arkWallet || !swapId) return;
|
||||
const updatedRow = arkWallet.getTransactions().find(tx => tx.txid === `swap-${swapId}`);
|
||||
if (updatedRow) setParams({ invoice: updatedRow });
|
||||
setInvoiceStatusChanged(true);
|
||||
fetchAndSaveWalletTransactions(walletID);
|
||||
};
|
||||
|
||||
const onRefundPressed = async () => {
|
||||
if (!arkWallet || !swap || isActioning) return;
|
||||
setIsActioning(true);
|
||||
try {
|
||||
const outcome = await arkWallet.refundSwap(swap as BoltzSubmarineSwap);
|
||||
if (outcome.swept === 0) {
|
||||
// Lockup not yet refundable (CLTV not reached / Boltz declined to
|
||||
// co-sign). Surface as info, not an error: the row stays refundable
|
||||
// and the user can retry later.
|
||||
presentAlert({ message: loc.lndViewInvoice.refund_deferred });
|
||||
} else {
|
||||
triggerHapticFeedback(HapticFeedbackTypes.NotificationSuccess);
|
||||
}
|
||||
await refreshAfterAction();
|
||||
} catch (e: any) {
|
||||
triggerHapticFeedback(HapticFeedbackTypes.NotificationError);
|
||||
presentAlert({ message: e?.message ?? String(e) });
|
||||
} finally {
|
||||
setIsActioning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const stylesHook = StyleSheet.create({
|
||||
root: {
|
||||
backgroundColor: colors.background,
|
||||
@ -179,6 +112,7 @@ const LNDViewInvoice = () => {
|
||||
fetchInvoiceInterval.current = setInterval(async () => {
|
||||
if (isFetchingInvoices) {
|
||||
try {
|
||||
// @ts-ignore - getUserInvoices is not set on TWallet
|
||||
const userInvoices: LightningTransaction[] = await wallet.getUserInvoices(20);
|
||||
// fetching only last 20 invoices
|
||||
// for invoice that was created just now - that should be enough (it is basically the last one, so limit=1 would be sufficient)
|
||||
@ -226,9 +160,8 @@ const LNDViewInvoice = () => {
|
||||
};
|
||||
|
||||
const handleOnSharePressed = () => {
|
||||
const paymentRequest = typeof invoice === 'string' ? invoice : invoice.payment_request;
|
||||
if (!paymentRequest) return;
|
||||
Share.open({ message: `lightning:${paymentRequest}` }).catch(error => console.log(error));
|
||||
if (typeof invoice === 'string' || !invoice.payment_request) return;
|
||||
Share.open({ message: `lightning:${invoice.payment_request}` }).catch(error => console.log(error));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@ -250,42 +183,12 @@ const LNDViewInvoice = () => {
|
||||
setQRCodeSize(height > width ? width - 40 : e.nativeEvent.layout.width / 1.8);
|
||||
};
|
||||
|
||||
// Drive both the amount and the description straight off the BOLT11 — the
|
||||
// source of truth, and the one thing identical whether the route param is
|
||||
// still the raw string or the polled-in object, so both render phases agree
|
||||
// and nothing changes after the page first paints. Decode is sync + cached.
|
||||
// "Please pay" deliberately shows the invoice-encoded amount (what the payer
|
||||
// is actually charged), not invoice.amt — which getTransactions() resolves to
|
||||
// the post-fee on-chain amount and so differs from the BOLT11 by the swap fee.
|
||||
// Likewise we ignore the row's synthesized description/memo: getTransactions()
|
||||
// backfills a "BlueWallet" label there for memo-less reverse swaps (so the tx
|
||||
// list isn't blank) and that placeholder must never surface here as
|
||||
// "For: BlueWallet". "Send to Arkade address" is the SDK's hardcoded default
|
||||
// for a memo-less reverse swap, so it counts as "no description" too.
|
||||
const decodeForDisplay = (paymentRequest?: string): { amountSats?: number; description?: string } => {
|
||||
if (!paymentRequest) return {};
|
||||
try {
|
||||
const d = wallet?.decodeInvoice(paymentRequest);
|
||||
const description = d?.description && d.description !== 'Send to Arkade address' ? d.description : undefined;
|
||||
return { amountSats: d?.num_satoshis || undefined, description };
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const render = () => {
|
||||
if (typeof invoice === 'object') {
|
||||
const currentDate = new Date();
|
||||
const now = (currentDate.getTime() / 1000) | 0; // eslint-disable-line no-bitwise
|
||||
const invoiceExpiration = invoice?.timestamp && invoice?.expire_time ? invoice.timestamp + invoice.expire_time : undefined;
|
||||
|
||||
// Settlement wins over any claim/refund CTA. The SDK auto-claims
|
||||
// reverse swaps as soon as Boltz funds the VHTLC, so a stale
|
||||
// route-param snapshot (`invoice.ispaid:false`) can race a live
|
||||
// `_swapHistory` already at `invoice.settled`; checking the live
|
||||
// swap status alongside the snapshot prevents Claim from rendering
|
||||
// (and failing) after the SDK has already claimed.
|
||||
if (invoice.ispaid || invoice.type === 'paid_invoice' || (swap && isReverseSuccessStatus(swap.status))) {
|
||||
if (invoice.ispaid || invoice.type === 'paid_invoice') {
|
||||
let amount = 0;
|
||||
let description;
|
||||
let invoiceDate;
|
||||
@ -293,10 +196,6 @@ const LNDViewInvoice = () => {
|
||||
amount = invoice.value;
|
||||
} else if (invoice.type === 'user_invoice' && invoice.amt) {
|
||||
amount = invoice.amt;
|
||||
} else if (invoice.value) {
|
||||
// Settled Arkade swap: an enriched native Ark leg (type 'bitcoind_tx')
|
||||
// has no `amt`; its magnitude lives in the signed `value`.
|
||||
amount = Math.abs(invoice.value);
|
||||
}
|
||||
if (invoice.description) {
|
||||
description = invoice.description;
|
||||
@ -338,36 +237,6 @@ const LNDViewInvoice = () => {
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Reverse swap mid-flight: Boltz funded the VHTLC and the SDK is
|
||||
// auto-claiming (SwapManager.executeAutonomousAction → claimVHTLC).
|
||||
// No manual CTA — the SDK owns claim reliability — so we just show
|
||||
// a "Receiving" indicator until the status transitions to
|
||||
// `invoice.settled` and the success branch above catches it.
|
||||
if (claimable) {
|
||||
return (
|
||||
<View style={[styles.activeRoot, stylesHook.root]}>
|
||||
<ActivityIndicator size="large" color={colors.foregroundColor} />
|
||||
<BlueSpacing20 />
|
||||
<BlueTextCentered>{loc.lndViewInvoice.receiving_payment}</BlueTextCentered>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (refundable) {
|
||||
return (
|
||||
<View style={[styles.activeRoot, stylesHook.root]}>
|
||||
<BlueTextCentered>{invoice.description ?? invoice.memo ?? ''}</BlueTextCentered>
|
||||
<BlueSpacing20 />
|
||||
<Button
|
||||
onPress={onRefundPressed}
|
||||
title={loc.lndViewInvoice.refund_funds}
|
||||
disabled={isActioning}
|
||||
showActivityIndicator={isActioning}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (invoiceExpiration ? invoiceExpiration < now : undefined) {
|
||||
return (
|
||||
<View style={[styles.root, stylesHook.root, styles.justifyContentCenter]}>
|
||||
@ -380,8 +249,6 @@ const LNDViewInvoice = () => {
|
||||
}
|
||||
// Invoice has not expired, nor has it been paid for.
|
||||
if (invoice.payment_request) {
|
||||
const { amountSats: bolt11Amount, description } = decodeForDisplay(invoice.payment_request);
|
||||
const amountSats = bolt11Amount ?? invoice.amt;
|
||||
return (
|
||||
<ScrollView>
|
||||
<View style={[styles.activeRoot, stylesHook.root]}>
|
||||
@ -390,13 +257,13 @@ const LNDViewInvoice = () => {
|
||||
</View>
|
||||
<BlueSpacing20 />
|
||||
<BlueText>
|
||||
{loc.lndViewInvoice.please_pay} {amountSats} {loc.lndViewInvoice.sats}
|
||||
{loc.lndViewInvoice.please_pay} {invoice.amt} {loc.lndViewInvoice.sats}
|
||||
</BlueText>
|
||||
{description ? (
|
||||
{'description' in invoice && (invoice.description?.length ?? 0) > 0 && (
|
||||
<BlueText>
|
||||
{loc.lndViewInvoice.for} {description}
|
||||
{loc.lndViewInvoice.for} {invoice.description ?? ''}
|
||||
</BlueText>
|
||||
) : null}
|
||||
)}
|
||||
<View style={styles.copyText}>
|
||||
<CopyTextToClipboard truncated text={invoice.payment_request} />
|
||||
</View>
|
||||
@ -406,36 +273,14 @@ const LNDViewInvoice = () => {
|
||||
);
|
||||
}
|
||||
} else if (invoice) {
|
||||
// `invoice` is the raw BOLT11 string — the polling effect hasn't yet swapped
|
||||
// it for the decoded object. Don't make the amount/description wait for that
|
||||
// 3s round-trip: both are encoded in the string and decode synchronously
|
||||
// (offline, cached) via the same decodeForDisplay() the object branch uses,
|
||||
// so we render the full "please pay" block now and it doesn't change when
|
||||
// the object arrives. A malformed string just falls back to QR + copy.
|
||||
const { amountSats, description } = decodeForDisplay(invoice);
|
||||
// `invoice` is string, just not decoded yet. lets just display it as a QR code first (till it gets decoded
|
||||
// and more data is rendered)
|
||||
return (
|
||||
<ScrollView>
|
||||
<View style={[styles.activeRoot, stylesHook.root]}>
|
||||
<View style={styles.activeQrcode}>
|
||||
<QRCode value={invoice} size={qrCodeSize} />
|
||||
</View>
|
||||
<BlueSpacing20 />
|
||||
{amountSats ? (
|
||||
<BlueText>
|
||||
{loc.lndViewInvoice.please_pay} {amountSats} {loc.lndViewInvoice.sats}
|
||||
</BlueText>
|
||||
) : null}
|
||||
{description ? (
|
||||
<BlueText>
|
||||
{loc.lndViewInvoice.for} {description}
|
||||
</BlueText>
|
||||
) : null}
|
||||
<View style={styles.copyText}>
|
||||
<CopyTextToClipboard truncated text={invoice} />
|
||||
</View>
|
||||
<Button onPress={handleOnSharePressed} title={loc.receive.details_share} />
|
||||
<View style={[styles.activeRoot, stylesHook.root]}>
|
||||
<View style={styles.activeQrcode}>
|
||||
<QRCode value={invoice} size={qrCodeSize} />
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
} else {
|
||||
// something is not right
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { RouteProp, StackActions, useIsFocused, useRoute } from '@react-navigation/native';
|
||||
import * as bitcoin from 'bitcoinjs-lib';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { ActivityIndicator, ScrollView, StyleSheet, View, TouchableOpacity } from 'react-native';
|
||||
import { ActivityIndicator, ScrollView, StyleSheet, View } from 'react-native';
|
||||
import presentAlert from '../../components/Alert';
|
||||
import { DynamicQRCode } from '../../components/DynamicQRCode';
|
||||
import SaveFileButton from '../../components/SaveFileButton';
|
||||
@ -23,7 +23,7 @@ type RouteParams = RouteProp<SendDetailsStackParamList, 'PsbtMultisigQRCode'>;
|
||||
const PsbtMultisigQRCode: React.FC = () => {
|
||||
const navigation = useExtendedNavigation();
|
||||
const { colors } = useTheme();
|
||||
const openScannerButton = useRef<React.ElementRef<typeof TouchableOpacity>>(null);
|
||||
const openScannerButton = useRef<any>(null);
|
||||
const { params } = useRoute<RouteParams>();
|
||||
const { psbtBase64, isShowOpenScanner, walletID } = params;
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
|
||||
@ -91,7 +91,7 @@ const SendDetails = () => {
|
||||
const payjoinUrl = route.params?.payjoinUrl;
|
||||
const isTransactionReplaceable = route.params?.isTransactionReplaceable;
|
||||
const routeParams = route.params;
|
||||
const scrollView = useRef<FlatList<IPaymentDestinations>>(null);
|
||||
const scrollView = useRef<FlatList<any>>(null);
|
||||
const scrollIndex = useRef(0);
|
||||
/** Used so we only clear coin-selection (utxos) when the user switches wallet, not on first mount (e.g. Send opened from wallet details with pre-selected UTXOs). */
|
||||
const prevWalletIdForCoinResetRef = useRef<string | null>(null);
|
||||
@ -221,6 +221,9 @@ const SendDetails = () => {
|
||||
}
|
||||
return updatedAddresses;
|
||||
});
|
||||
|
||||
// @ts-ignore: Fix later
|
||||
setParams(prevParams => ({ ...prevParams, addRecipientParams: undefined }));
|
||||
} else {
|
||||
setAddresses([{ address: '', key: String(Math.random()), unit: amountUnit }]); // key is for the FlatList
|
||||
}
|
||||
|
||||
@ -1,19 +1,22 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { StyleSheet, View, Pressable, AppState, Text } from 'react-native';
|
||||
import { Linking, StyleSheet, TextInput, View, Pressable, AppState, Text } from 'react-native';
|
||||
import {
|
||||
getDefaultUri,
|
||||
getPushToken,
|
||||
getSavedUri,
|
||||
getStoredNotifications,
|
||||
saveUri,
|
||||
isNotificationsEnabled,
|
||||
setLevels,
|
||||
tryToObtainPermissions,
|
||||
cleanUserOptOutFlag,
|
||||
isGroundControlUriValid,
|
||||
checkPermissions,
|
||||
checkNotificationPermissionStatus,
|
||||
enqueueTestPushNotification,
|
||||
NOTIFICATIONS_NO_AND_DONT_ASK_FLAG,
|
||||
} from '../../blue_modules/notifications';
|
||||
import presentAlert from '../../components/Alert';
|
||||
import { BlueSpacing20 } from '../../components/BlueSpacing';
|
||||
import presentAlert from '../../components/Alert';
|
||||
import { Button } from '../../components/Button';
|
||||
import CopyToClipboardButton from '../../components/CopyToClipboardButton';
|
||||
import { useTheme } from '../../components/themes';
|
||||
@ -40,6 +43,7 @@ const NotificationSettings: React.FC = () => {
|
||||
const [isNotificationsEnabledState, setNotificationsEnabledState] = useState<boolean | undefined>(undefined);
|
||||
|
||||
const [tokenInfo, setTokenInfo] = useState('<empty>');
|
||||
const [URI, setURI] = useState<string | undefined>();
|
||||
const [tapCount, setTapCount] = useState(0);
|
||||
const { colors } = useTheme();
|
||||
|
||||
@ -98,6 +102,8 @@ const NotificationSettings: React.FC = () => {
|
||||
await AsyncStorage.setItem(NOTIFICATIONS_NO_AND_DONT_ASK_FLAG, 'true');
|
||||
setNotificationsEnabledState(false);
|
||||
}
|
||||
|
||||
setNotificationsEnabledState(await isNotificationsEnabled());
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
presentAlert({ message: (error as Error).message });
|
||||
@ -135,6 +141,7 @@ const NotificationSettings: React.FC = () => {
|
||||
await updateNotificationStatus();
|
||||
}
|
||||
|
||||
setURI((await getSavedUri()) ?? getDefaultUri());
|
||||
setTokenInfo(
|
||||
'token: ' +
|
||||
JSON.stringify(await getPushToken()) +
|
||||
@ -167,17 +174,25 @@ const NotificationSettings: React.FC = () => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const enqueueTestPush = useCallback(async () => {
|
||||
const save = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await enqueueTestPushNotification();
|
||||
if (URI) {
|
||||
if (await isGroundControlUriValid(URI)) {
|
||||
await saveUri(URI);
|
||||
presentAlert({ message: loc.settings.saved });
|
||||
} else {
|
||||
presentAlert({ message: loc.settings.not_a_valid_uri });
|
||||
}
|
||||
} else {
|
||||
await saveUri('');
|
||||
presentAlert({ message: loc.settings.saved });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error enqueueing test push:', error);
|
||||
presentAlert({ message: (error as Error).message });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
console.error('Error saving URI:', error);
|
||||
}
|
||||
}, []);
|
||||
setIsLoading(false);
|
||||
}, [URI]);
|
||||
|
||||
const renderDeveloperSettings = useCallback(() => {
|
||||
if (tapCount < 10) return null;
|
||||
@ -185,9 +200,44 @@ const NotificationSettings: React.FC = () => {
|
||||
return (
|
||||
<View>
|
||||
<View style={[styles.divider, { backgroundColor: colors.lightBorder ?? colors.borderTopColor }]} />
|
||||
<SettingsCard style={styles.card}>
|
||||
<View style={styles.cardContent}>
|
||||
<Text style={[styles.multilineText, { color: colors.foregroundColor }]}>{loc.settings.groundcontrol_explanation}</Text>
|
||||
</View>
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsListItem
|
||||
title="github.com/BlueWallet/GroundControl"
|
||||
iconName="github"
|
||||
onPress={() => Linking.openURL('https://github.com/BlueWallet/GroundControl')}
|
||||
chevron
|
||||
position="single"
|
||||
spacingTop
|
||||
/>
|
||||
|
||||
<SettingsCard style={styles.card}>
|
||||
<View style={styles.cardContent}>
|
||||
<View
|
||||
style={[
|
||||
styles.uri,
|
||||
{ borderColor: colors.formBorder, borderBottomColor: colors.formBorder, backgroundColor: colors.inputBackgroundColor },
|
||||
]}
|
||||
>
|
||||
<TextInput
|
||||
placeholder={getDefaultUri()}
|
||||
value={URI}
|
||||
onChangeText={setURI}
|
||||
numberOfLines={1}
|
||||
style={[styles.uriText, { color: colors.alternativeTextColor }]}
|
||||
placeholderTextColor="#81868e"
|
||||
editable={!isLoading}
|
||||
textContentType="URL"
|
||||
autoCapitalize="none"
|
||||
underlineColorAndroid="transparent"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<BlueSpacing20 />
|
||||
<Text style={[styles.centered, { color: colors.foregroundColor }]} onPress={() => setTapCount(tapCount + 1)}>
|
||||
♪ Ground Control to Major Tom ♪
|
||||
</Text>
|
||||
@ -200,12 +250,12 @@ const NotificationSettings: React.FC = () => {
|
||||
</View>
|
||||
|
||||
<BlueSpacing20 />
|
||||
<Button onPress={enqueueTestPush} title="Enqueue test push notification" disabled={isLoading} />
|
||||
<Button onPress={save} title={loc.settings.save} />
|
||||
</View>
|
||||
</SettingsCard>
|
||||
</View>
|
||||
);
|
||||
}, [tapCount, colors, isLoading, tokenInfo, enqueueTestPush]);
|
||||
}, [tapCount, colors, isLoading, URI, tokenInfo, save]);
|
||||
|
||||
const renderPushNotificationsExplanation = useCallback(() => {
|
||||
return (
|
||||
@ -327,10 +377,28 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: horizontalPadding,
|
||||
paddingVertical: isAndroid ? 12 : 10,
|
||||
},
|
||||
multilineText: {
|
||||
lineHeight: 20,
|
||||
paddingBottom: 10,
|
||||
},
|
||||
centered: {
|
||||
textAlign: 'center',
|
||||
marginVertical: 4,
|
||||
},
|
||||
uri: {
|
||||
flexDirection: 'row',
|
||||
borderWidth: 1,
|
||||
borderBottomWidth: 0.5,
|
||||
minHeight: 44,
|
||||
height: 44,
|
||||
alignItems: 'center',
|
||||
},
|
||||
uriText: {
|
||||
flex: 1,
|
||||
marginHorizontal: 8,
|
||||
minHeight: 36,
|
||||
height: 36,
|
||||
},
|
||||
divider: {
|
||||
marginVertical: isAndroid ? 16 : 12,
|
||||
height: 0.5,
|
||||
|
||||
@ -8,12 +8,14 @@ import { Linking, StyleSheet, View } from 'react-native';
|
||||
import BlueCrypto from 'react-native-blue-crypto';
|
||||
import wif from 'wif';
|
||||
|
||||
import * as BlueElectrum from '../../blue_modules/BlueElectrum';
|
||||
import * as encryption from '../../blue_modules/encryption';
|
||||
import * as fs from '../../blue_modules/fs';
|
||||
import ecc from '../../blue_modules/noble_ecc';
|
||||
import { hexToUint8Array, uint8ArrayToHex } from '../../blue_modules/uint8array-extras';
|
||||
import BlueText from '../../components/BlueText';
|
||||
import { HDAezeedWallet } from '../../class/wallets/hd-aezeed-wallet';
|
||||
import { HDSegwitBech32Wallet } from '../../class/wallets/hd-segwit-bech32-wallet';
|
||||
import { HDSegwitP2SHWallet } from '../../class/wallets/hd-segwit-p2sh-wallet';
|
||||
import { LegacyWallet } from '../../class/wallets/legacy-wallet';
|
||||
import { SegwitP2SHWallet } from '../../class/wallets/segwit-p2sh-wallet';
|
||||
@ -27,7 +29,6 @@ import { CreateTransactionUtxo } from '../../class/wallets/types';
|
||||
import { BlueSpacing20 } from '../../components/BlueSpacing';
|
||||
import { BlueLoading } from '../../components/BlueLoading';
|
||||
import { LightningArkWallet } from '../../class/wallets/lightning-ark-wallet';
|
||||
import { stopArkBackgroundTask } from '../../blue_modules/arkade-background';
|
||||
import { SettingsCard, SettingsScrollView } from '../../components/platform';
|
||||
|
||||
const bip32 = BIP32Factory(ecc);
|
||||
@ -92,11 +93,6 @@ export default class SelfTest extends Component {
|
||||
let isOk = true;
|
||||
|
||||
try {
|
||||
// Drain any Ark background-fetch listener before running the self-test.
|
||||
// A live background-fetch timer keeps Detox's FabricTimersIdlingResource
|
||||
// busy and disconnects the JS bridge before SelfTestOk can be observed.
|
||||
await stopArkBackgroundTask();
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 1_000)); // propagate ui
|
||||
|
||||
if (typeof navigator !== 'undefined' && navigator.product === 'ReactNative') {
|
||||
@ -116,25 +112,33 @@ export default class SelfTest extends Component {
|
||||
//
|
||||
|
||||
if (typeof navigator !== 'undefined' && navigator.product === 'ReactNative') {
|
||||
// Offline Ark smoke check: derive identity + namespace from a fixed
|
||||
// mnemonic. No init() / SDK / network — those calls hang Detox on CI.
|
||||
// The full Ark address regression (BIP86 path, DelegateVtxo wiring,
|
||||
// delegatorProvider) is pinned in tests/unit/lightning-ark-derivation.test.ts.
|
||||
const spkw = new LightningArkWallet();
|
||||
spkw.setSecret('arkade://abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about');
|
||||
const pubkey = await spkw._getIdentity().xOnlyPublicKey();
|
||||
if (!(pubkey instanceof Uint8Array) || pubkey.length !== 32) {
|
||||
throw new Error('Arkade x-only pubkey shape regression: length=' + (pubkey as Uint8Array | undefined)?.length);
|
||||
}
|
||||
const expectedNamespace = 'e13b00f781e8dfc57f8f2a936220ff24d132eaaf8c85d4b10b5337645085ee9a';
|
||||
const namespace = spkw.getNamespace();
|
||||
if (namespace !== expectedNamespace) {
|
||||
throw new Error(`Arkade namespace regression: expected ${expectedNamespace}, got ${namespace}`);
|
||||
}
|
||||
spkw.setSecret('abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about');
|
||||
await spkw.init();
|
||||
assertStrictEqual(
|
||||
await spkw.getArkAddress(),
|
||||
'ark1qq4hfssprtcgnjzf8qlw2f78yvjau5kldfugg29k34y7j96q2w4t59s7u3fgnd3lyjda00ycjq53mgxl6wsxspe4s72t5dss3q6w5clv0xpgal',
|
||||
'Ark failed',
|
||||
);
|
||||
} else {
|
||||
// skipping RN-specific test
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
if (typeof navigator !== 'undefined' && navigator.product === 'ReactNative') {
|
||||
if (!(await BlueElectrum.ensureConnected())) throw new Error('Could not connect to Electrum');
|
||||
const addr4elect = '3GCvDBAktgQQtsbN6x5DYiQCMmgZ9Yk8BK';
|
||||
const electrumBalance = await BlueElectrum.getBalanceByAddress(addr4elect);
|
||||
if (electrumBalance.confirmed !== 51432)
|
||||
throw new Error('BlueElectrum getBalanceByAddress failure, got ' + JSON.stringify(electrumBalance));
|
||||
|
||||
const electrumTxs = await BlueElectrum.getTransactionsByAddress(addr4elect);
|
||||
if (electrumTxs.length !== 1) throw new Error('BlueElectrum getTransactionsByAddress failure, got ' + JSON.stringify(electrumTxs));
|
||||
} else {
|
||||
// skipping RN-specific test'
|
||||
}
|
||||
|
||||
if (typeof navigator !== 'undefined' && navigator.product === 'ReactNative') {
|
||||
const aezeed = new HDAezeedWallet();
|
||||
aezeed.setSecret(
|
||||
@ -300,6 +304,15 @@ export default class SelfTest extends Component {
|
||||
if (!hd2.validateMnemonic()) {
|
||||
throw new Error('mnemonic phrase validation not ok');
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
const hd4 = new HDSegwitBech32Wallet();
|
||||
hd4._xpub = 'zpub6rnbAtzupLPpSrsBKRsHupFvv1h6pwfRnZxX3qs6RL4LiLqKQ6kfBaDckn2apQWfyw1D2TdQMMDCfUDHMwtrcbGoy88xoKBLmADTFK9AhLe';
|
||||
await hd4.fetchBalance();
|
||||
if (hd4.getBalance() !== 2400) throw new Error('Could not fetch HD Bech32 balance');
|
||||
await hd4.fetchTransactions();
|
||||
if (hd4.getTransactions().length !== 4) throw new Error('Could not fetch HD Bech32 transactions');
|
||||
} else {
|
||||
// skipping RN-specific test
|
||||
}
|
||||
|
||||
@ -2,13 +2,7 @@ import React, { useMemo, useLayoutEffect, useCallback } from 'react';
|
||||
import { View, StyleSheet, Linking, Image, Platform } from 'react-native';
|
||||
import { useExtendedNavigation } from '../../hooks/useExtendedNavigation';
|
||||
import loc from '../../loc';
|
||||
import {
|
||||
SettingsScrollView,
|
||||
SettingsSection,
|
||||
SettingsListItem,
|
||||
getSettingsHeaderOptions,
|
||||
isIOS26OrHigher,
|
||||
} from '../../components/platform';
|
||||
import { SettingsScrollView, SettingsSection, SettingsListItem, getSettingsHeaderOptions } from '../../components/platform';
|
||||
import { useSettings } from '../../hooks/context/useSettings';
|
||||
import { useTheme } from '../../components/themes';
|
||||
|
||||
@ -21,9 +15,6 @@ const Settings = () => {
|
||||
const settingsScreenBackgroundColor = isIOSLightMode ? settingsCardColor : colors.background;
|
||||
const settingsListItemBackgroundColor = isIOSLightMode ? colors.background : undefined;
|
||||
useLayoutEffect(() => {
|
||||
if (isIOS26OrHigher) {
|
||||
return;
|
||||
}
|
||||
setOptions(getSettingsHeaderOptions(loc.settings.header, { ...colors, background: settingsScreenBackgroundColor }, dark));
|
||||
}, [setOptions, language, colors, settingsScreenBackgroundColor, dark]); // Include language to trigger re-render when language changes
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useMemo, useReducer, useRef, useState }
|
||||
import { ActivityIndicator, BackHandler, Linking, StyleSheet, Text, TouchableOpacity, useWindowDimensions, View } from 'react-native';
|
||||
import { sha256 } from '@noble/hashes/sha256';
|
||||
import { RouteProp, useRoute } from '@react-navigation/native';
|
||||
import { NativeStackNavigationOptions, NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import Icon from '../../components/Icon';
|
||||
import dayjs from 'dayjs';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
@ -32,7 +32,6 @@ import useWalletSubscribe from '../../hooks/useWalletSubscribe';
|
||||
import loc, { formatBalanceWithoutSuffix } from '../../loc';
|
||||
import { BitcoinUnit } from '../../models/bitcoinUnits';
|
||||
import { DetailViewStackParamList } from '../../navigation/DetailViewStackParamList';
|
||||
import { isOnChainTransaction, resolveTxDisplayState } from '../../blue_modules/transactionDisplayState';
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
@ -63,10 +62,6 @@ enum ButtonStatus {
|
||||
type RouteProps = RouteProp<DetailViewStackParamList, 'TransactionStatus'>;
|
||||
type NavigationProps = NativeStackNavigationProp<DetailViewStackParamList, 'TransactionStatus'>;
|
||||
|
||||
type TransactionStatusHeaderOptions = NativeStackNavigationOptions & {
|
||||
headerTitleContainerStyle?: { flex: number; maxWidth: number };
|
||||
};
|
||||
|
||||
enum ActionType {
|
||||
SetCPFPPossible,
|
||||
SetRBFBumpFeePossible,
|
||||
@ -140,12 +135,8 @@ type TransactionDetailHeaderTitleProps = {
|
||||
|
||||
const TransactionDetailHeaderTitle: React.FC<TransactionDetailHeaderTitleProps> = ({ direction, date, directionStyle, dateStyle }) => (
|
||||
<View style={styles.headerTitleContainer}>
|
||||
<BlueText style={directionStyle} numberOfLines={1} adjustsFontSizeToFit minimumFontScale={0.8}>
|
||||
{direction}
|
||||
</BlueText>
|
||||
<BlueText style={dateStyle} numberOfLines={2} adjustsFontSizeToFit minimumFontScale={0.8}>
|
||||
{date}
|
||||
</BlueText>
|
||||
<BlueText style={directionStyle}>{direction}</BlueText>
|
||||
<BlueText style={dateStyle}>{date}</BlueText>
|
||||
</View>
|
||||
);
|
||||
|
||||
@ -161,57 +152,10 @@ const TransactionStatus: React.FC = () => {
|
||||
const subscribedWallet = useWalletSubscribe(walletID);
|
||||
const { navigate, goBack, setOptions } = useExtendedNavigation<NavigationProps>();
|
||||
const { colors } = useTheme();
|
||||
const { width: windowWidth, fontScale } = useWindowDimensions();
|
||||
const { width: windowWidth } = useWindowDimensions();
|
||||
const { selectedBlockExplorer } = useSettings();
|
||||
const fetchTxInterval = useRef<NodeJS.Timeout | undefined>(undefined);
|
||||
|
||||
const scaledStyles = useMemo(() => {
|
||||
const valueLineHeight = Math.round(48 * fontScale);
|
||||
const valuePaddingTop = Math.round(8 * fontScale);
|
||||
|
||||
return {
|
||||
value: {
|
||||
lineHeight: valueLineHeight,
|
||||
paddingTop: valuePaddingTop,
|
||||
minHeight: valueLineHeight + valuePaddingTop,
|
||||
},
|
||||
localCurrency: {
|
||||
lineHeight: Math.round(20 * fontScale),
|
||||
marginTop: Math.round(6 * fontScale),
|
||||
},
|
||||
headerTitleDirection: {
|
||||
lineHeight: Math.round(22 * fontScale),
|
||||
},
|
||||
headerTitleDate: {
|
||||
lineHeight: Math.round(18 * fontScale),
|
||||
},
|
||||
stateLabel: {
|
||||
lineHeight: Math.round(22 * fontScale),
|
||||
},
|
||||
stateValue: {
|
||||
lineHeight: Math.round(18 * fontScale),
|
||||
},
|
||||
advancedHeader: {
|
||||
minHeight: Math.round(44 * fontScale),
|
||||
},
|
||||
explorerButton: {
|
||||
paddingVertical: Math.round(6 * fontScale),
|
||||
paddingHorizontal: Math.round(12 * fontScale),
|
||||
},
|
||||
addButton: {
|
||||
paddingVertical: Math.round(4 * fontScale),
|
||||
paddingHorizontal: Math.round(12 * fontScale),
|
||||
},
|
||||
detailRow: {
|
||||
minHeight: Math.round(24 * fontScale),
|
||||
paddingVertical: Math.round(12 * fontScale),
|
||||
},
|
||||
sectionTitle: {
|
||||
paddingVertical: Math.round(16 * fontScale),
|
||||
},
|
||||
};
|
||||
}, [fontScale]);
|
||||
|
||||
// Explicit width for To/ID text so Android StaticLayout can apply ellipsis (flex alone often fails on Android)
|
||||
const detailValueMaxWidth = useMemo(() => Math.max(0, Math.floor((windowWidth - 48) / 2)), [windowWidth]);
|
||||
const detailValueWidthStyle = useMemo(() => ({ width: detailValueMaxWidth }), [detailValueMaxWidth]);
|
||||
@ -392,10 +336,7 @@ const TransactionStatus: React.FC = () => {
|
||||
console.debug('transactionDetail - useEffect');
|
||||
|
||||
if (!tx || tx?.confirmations) return;
|
||||
// Ark/Lightning rows carry a synthetic id (ark-/swap-/boarding-), not an on-chain
|
||||
// txid. Never poll Electrum for them — the old `if (!hash) return;` let the
|
||||
// synthetic id through and logged "… with hash ark-… not found" every interval.
|
||||
if (!isOnChainTransaction(tx)) return;
|
||||
if (!hash) return;
|
||||
|
||||
if (fetchTxInterval.current) {
|
||||
clearInterval(fetchTxInterval.current);
|
||||
@ -734,20 +675,18 @@ const TransactionStatus: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleNotePress = useCallback(async () => {
|
||||
// Ark rows have no on-chain hash; use their synthetic txid as fallback key.
|
||||
const metadataKey = tx.hash ?? (tx as { txid?: string }).txid;
|
||||
const currentMemo = (metadataKey && txMetadata[metadataKey]?.memo) || '';
|
||||
const currentMemo = txMetadata[tx.hash]?.memo || '';
|
||||
try {
|
||||
const newMemo = await prompt(loc.send.details_note_placeholder, '', { type: 'plain-text', defaultValue: currentMemo });
|
||||
if (newMemo !== undefined && metadataKey) {
|
||||
txMetadata[metadataKey] = { memo: newMemo };
|
||||
if (newMemo !== undefined) {
|
||||
txMetadata[tx.hash] = { memo: newMemo };
|
||||
await saveToDisk();
|
||||
triggerHapticFeedback(HapticFeedbackTypes.NotificationSuccess);
|
||||
}
|
||||
} catch (error) {
|
||||
// User cancelled
|
||||
}
|
||||
}, [tx, txMetadata, saveToDisk]);
|
||||
}, [tx?.hash, txMetadata, saveToDisk]);
|
||||
|
||||
const handleOpenBlockExplorer = useCallback(() => {
|
||||
if (!tx?.hash || !selectedBlockExplorer) return;
|
||||
@ -889,8 +828,7 @@ const TransactionStatus: React.FC = () => {
|
||||
const parsedTxValue = Number(tx?.value);
|
||||
const txValue = Number.isFinite(parsedTxValue) ? parsedTxValue : null;
|
||||
const parsedConfirmations = Number(tx?.confirmations);
|
||||
const isOnChainTx = isOnChainTransaction(tx);
|
||||
const isPending = resolveTxDisplayState(tx) === 'pending';
|
||||
const isPending = Number.isFinite(parsedConfirmations) ? parsedConfirmations <= 0 : !tx?.confirmations;
|
||||
const preferredBalanceUnit = wallet?.preferredBalanceUnit ?? BitcoinUnit.BTC;
|
||||
|
||||
// Get transaction direction and date
|
||||
@ -976,20 +914,15 @@ const TransactionStatus: React.FC = () => {
|
||||
<TransactionDetailHeaderTitle
|
||||
direction={transactionDirection}
|
||||
date={transactionDate}
|
||||
directionStyle={[styles.headerTitleDirection, stylesHook.headerTitleDirection, scaledStyles.headerTitleDirection]}
|
||||
dateStyle={[styles.headerTitleDate, stylesHook.titleDate, scaledStyles.headerTitleDate]}
|
||||
directionStyle={[styles.headerTitleDirection, stylesHook.headerTitleDirection]}
|
||||
dateStyle={[styles.headerTitleDate, stylesHook.titleDate]}
|
||||
/>
|
||||
),
|
||||
headerTitleAlign: 'left',
|
||||
headerTitleContainerStyle: {
|
||||
flex: 1,
|
||||
maxWidth: Math.max(0, windowWidth - 96),
|
||||
},
|
||||
} as TransactionStatusHeaderOptions);
|
||||
});
|
||||
}
|
||||
// stylesHook is derived from colors; omitting to avoid unnecessary effect runs
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tx, transactionDirection, transactionDate, setOptions, colors, windowWidth, scaledStyles]);
|
||||
}, [tx, transactionDirection, transactionDate, setOptions, colors]);
|
||||
|
||||
if (loadingError) {
|
||||
return (
|
||||
@ -1022,20 +955,15 @@ const TransactionStatus: React.FC = () => {
|
||||
{/* Value Section */}
|
||||
<View style={styles.valueCard}>
|
||||
<View style={styles.valueContent}>
|
||||
<Text
|
||||
style={[styles.value, stylesHook.value, scaledStyles.value, styles.valueFullWidth]}
|
||||
selectable
|
||||
numberOfLines={1}
|
||||
adjustsFontSizeToFit
|
||||
minimumFontScale={0.55}
|
||||
>
|
||||
<Text style={[styles.value, stylesHook.value]} selectable numberOfLines={1} adjustsFontSizeToFit minimumFontScale={0.55}>
|
||||
{txValue !== null ? formatBalanceWithoutSuffix(txValue, preferredBalanceUnit, true) : '-'}
|
||||
{` `}
|
||||
{preferredBalanceUnit !== BitcoinUnit.LOCAL_CURRENCY && (
|
||||
<Text style={[styles.valueUnit, stylesHook.valueUnit]}>{` ${preferredBalanceUnit}`}</Text>
|
||||
<Text style={[styles.valueUnit, stylesHook.valueUnit]}>{preferredBalanceUnit}</Text>
|
||||
)}
|
||||
</Text>
|
||||
{txValue !== null && (
|
||||
<Text style={[styles.localCurrency, stylesHook.localCurrency, scaledStyles.localCurrency]}>
|
||||
<Text style={[styles.localCurrency, stylesHook.localCurrency]}>
|
||||
{preferredBalanceUnit === BitcoinUnit.LOCAL_CURRENCY
|
||||
? `${formatBalanceWithoutSuffix(Math.abs(txValue), BitcoinUnit.BTC, true)} ${BitcoinUnit.BTC}`
|
||||
: satoshiToLocalCurrency(Math.abs(txValue))}
|
||||
@ -1061,10 +989,8 @@ const TransactionStatus: React.FC = () => {
|
||||
<View style={styles.stateIndicator}>
|
||||
<TransactionPendingIcon />
|
||||
<View style={styles.stateLabelContainer}>
|
||||
<BlueText style={[styles.stateLabel, stylesHook.stateLabelPending, scaledStyles.stateLabel]}>
|
||||
{loc.transactions.pending}
|
||||
</BlueText>
|
||||
<BlueText style={[styles.stateValue, stylesHook.stateValuePending, styles.stateValueInline, scaledStyles.stateValue]}>
|
||||
<BlueText style={[styles.stateLabel, stylesHook.stateLabelPending]}>{loc.transactions.pending}</BlueText>
|
||||
<BlueText style={[styles.stateValue, stylesHook.stateValuePending, styles.stateValueInline]}>
|
||||
{eta || loc.transactions.details_eta_analyzing}
|
||||
</BlueText>
|
||||
</View>
|
||||
@ -1096,32 +1022,24 @@ const TransactionStatus: React.FC = () => {
|
||||
<View style={styles.stateIndicator}>
|
||||
<TransactionOutgoingIcon />
|
||||
<View style={styles.stateLabelContainer}>
|
||||
<BlueText style={[styles.stateLabel, stylesHook.stateLabelSent, scaledStyles.stateLabel]}>
|
||||
{loc.transactions.details_sent}
|
||||
<BlueText style={[styles.stateLabel, stylesHook.stateLabelSent]}>{loc.transactions.details_sent}</BlueText>
|
||||
<BlueText style={[styles.stateValue, stylesHook.stateValueSent, styles.stateValueInline]}>
|
||||
{loc.formatString(loc.transactions.confirmations_lowercase, {
|
||||
confirmations: parsedConfirmations > 6 ? '6+' : parsedConfirmations,
|
||||
})}
|
||||
</BlueText>
|
||||
{isOnChainTx && (
|
||||
<BlueText style={[styles.stateValue, stylesHook.stateValueSent, styles.stateValueInline, scaledStyles.stateValue]}>
|
||||
{loc.formatString(loc.transactions.confirmations_lowercase, {
|
||||
confirmations: parsedConfirmations > 6 ? '6+' : parsedConfirmations,
|
||||
})}
|
||||
</BlueText>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.stateIndicator}>
|
||||
<TransactionIncomingIcon />
|
||||
<View style={styles.stateLabelContainer}>
|
||||
<BlueText style={[styles.stateLabel, stylesHook.stateLabelReceived, scaledStyles.stateLabel]}>
|
||||
{loc.transactions.details_received}
|
||||
<BlueText style={[styles.stateLabel, stylesHook.stateLabelReceived]}>{loc.transactions.details_received}</BlueText>
|
||||
<BlueText style={[styles.stateValue, stylesHook.stateValueReceived, styles.stateValueInline]}>
|
||||
{loc.formatString(loc.transactions.confirmations_lowercase, {
|
||||
confirmations: parsedConfirmations > 6 ? '6+' : parsedConfirmations,
|
||||
})}
|
||||
</BlueText>
|
||||
{isOnChainTx && (
|
||||
<BlueText style={[styles.stateValue, stylesHook.stateValueReceived, styles.stateValueInline, scaledStyles.stateValue]}>
|
||||
{loc.formatString(loc.transactions.confirmations_lowercase, {
|
||||
confirmations: parsedConfirmations > 6 ? '6+' : parsedConfirmations,
|
||||
})}
|
||||
</BlueText>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
@ -1151,29 +1069,20 @@ const TransactionStatus: React.FC = () => {
|
||||
{/* Details Section */}
|
||||
<View style={[styles.detailsCard, stylesHook.detailsCard]}>
|
||||
{/* Details Title */}
|
||||
<View style={[styles.sectionTitle, styles.sectionTitleWithButton, stylesHook.sectionTitle, scaledStyles.sectionTitle]}>
|
||||
<BlueText style={[styles.sectionTitleText, stylesHook.sectionTitleText, styles.sectionTitleTextFlexible]}>
|
||||
{loc.transactions.details_section}
|
||||
</BlueText>
|
||||
<View style={[styles.sectionTitle, styles.sectionTitleWithButton, stylesHook.sectionTitle]}>
|
||||
<BlueText style={[styles.sectionTitleText, stylesHook.sectionTitleText]}>{loc.transactions.details_section}</BlueText>
|
||||
{tx?.hash && (
|
||||
<TouchableOpacity
|
||||
onPress={handleOpenBlockExplorer}
|
||||
style={[styles.explorerButton, stylesHook.explorerButton, scaledStyles.explorerButton]}
|
||||
style={[styles.explorerButton, stylesHook.explorerButton]}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<BlueText
|
||||
style={[styles.explorerButtonText, stylesHook.explorerButtonText]}
|
||||
numberOfLines={1}
|
||||
adjustsFontSizeToFit
|
||||
minimumFontScale={0.8}
|
||||
>
|
||||
{loc.transactions.details_explorer}
|
||||
</BlueText>
|
||||
<BlueText style={[styles.explorerButtonText, stylesHook.explorerButtonText]}>{loc.transactions.details_explorer}</BlueText>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
{/* Network Fee */}
|
||||
<View style={[styles.detailRow, stylesHook.detailRow, scaledStyles.detailRow]}>
|
||||
<View style={[styles.detailRow, stylesHook.detailRow]}>
|
||||
<BlueText style={[styles.detailLabel, stylesHook.detailLabel]}>{loc.transactions.details_network_fee}</BlueText>
|
||||
<View style={styles.detailValueContainer}>
|
||||
<CopyTextToClipboard
|
||||
@ -1197,7 +1106,7 @@ const TransactionStatus: React.FC = () => {
|
||||
const displayText = externalAddresses.map(shortenCounterpartyName).join(', ');
|
||||
const copyText = externalAddresses.join(', ');
|
||||
return (
|
||||
<View style={[styles.detailRow, stylesHook.detailRow, scaledStyles.detailRow]}>
|
||||
<View style={[styles.detailRow, stylesHook.detailRow]}>
|
||||
<BlueText style={[styles.detailLabel, stylesHook.detailLabel]}>{loc.transactions.details_to_address}</BlueText>
|
||||
<View style={styles.detailValueContainer}>
|
||||
<View style={styles.detailValueCopyContainer}>
|
||||
@ -1223,7 +1132,7 @@ const TransactionStatus: React.FC = () => {
|
||||
|
||||
{/* Transaction ID - display shortened so it stays on one line on Android; copy still gets full hash */}
|
||||
{tx.hash && (
|
||||
<View style={[styles.detailRow, stylesHook.detailRow, scaledStyles.detailRow]}>
|
||||
<View style={[styles.detailRow, stylesHook.detailRow]}>
|
||||
<BlueText style={[styles.detailLabel, stylesHook.detailLabel]}>{loc.transactions.details_id}</BlueText>
|
||||
<View style={styles.detailValueContainer}>
|
||||
<View style={styles.detailValueCopyContainer}>
|
||||
@ -1250,7 +1159,7 @@ const TransactionStatus: React.FC = () => {
|
||||
)}
|
||||
|
||||
{/* Note/Memo */}
|
||||
<View style={[styles.detailRow, styles.detailRowLast, stylesHook.detailRow, scaledStyles.detailRow]}>
|
||||
<View style={[styles.detailRow, styles.detailRowLast, stylesHook.detailRow]}>
|
||||
<BlueText style={[styles.detailLabel, stylesHook.detailLabel]}>{loc.transactions.details_note}</BlueText>
|
||||
<View style={styles.detailValueContainer}>
|
||||
{memo ? (
|
||||
@ -1260,19 +1169,8 @@ const TransactionStatus: React.FC = () => {
|
||||
</BlueText>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
onPress={handleNotePress}
|
||||
style={[styles.addButton, stylesHook.addButton, scaledStyles.addButton]}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<BlueText
|
||||
style={[styles.addButtonText, stylesHook.addButtonText]}
|
||||
numberOfLines={1}
|
||||
adjustsFontSizeToFit
|
||||
minimumFontScale={0.8}
|
||||
>
|
||||
{loc.transactions.details_add_note}
|
||||
</BlueText>
|
||||
<TouchableOpacity onPress={handleNotePress} style={[styles.addButton, stylesHook.addButton]} activeOpacity={0.7}>
|
||||
<BlueText style={[styles.addButtonText, stylesHook.addButtonText]}>{loc.transactions.details_add_note}</BlueText>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
@ -1283,13 +1181,11 @@ const TransactionStatus: React.FC = () => {
|
||||
<View style={[styles.detailsCard, stylesHook.detailsCard]}>
|
||||
<TouchableOpacity
|
||||
onPress={() => setIsAdvancedExpanded(!isAdvancedExpanded)}
|
||||
style={[styles.advancedHeader, stylesHook.advancedHeader, scaledStyles.advancedHeader]}
|
||||
style={[styles.advancedHeader, stylesHook.advancedHeader]}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
<View style={[styles.sectionTitle, stylesHook.sectionTitle, styles.sectionTitleRow, scaledStyles.sectionTitle]}>
|
||||
<BlueText style={[styles.sectionTitleText, stylesHook.sectionTitleText, styles.sectionTitleTextFlexible]} numberOfLines={2}>
|
||||
{loc.transactions.details_advanced}
|
||||
</BlueText>
|
||||
<View style={[styles.sectionTitle, stylesHook.sectionTitle, styles.sectionTitleRow]}>
|
||||
<BlueText style={[styles.sectionTitleText, stylesHook.sectionTitleText]}>{loc.transactions.details_advanced}</BlueText>
|
||||
<Icon
|
||||
name={isAdvancedExpanded ? 'chevron-up' : 'chevron-down'}
|
||||
type="font-awesome"
|
||||
@ -1302,7 +1198,7 @@ const TransactionStatus: React.FC = () => {
|
||||
{isAdvancedExpanded && (
|
||||
<View style={[styles.advancedContent, stylesHook.advancedContent]}>
|
||||
{/* Fee Rate */}
|
||||
<View style={[styles.detailRow, stylesHook.detailRow, scaledStyles.detailRow]}>
|
||||
<View style={[styles.detailRow, stylesHook.detailRow]}>
|
||||
<BlueText style={[styles.detailLabel, stylesHook.detailLabel]}>{loc.transactions.details_fee_rate}</BlueText>
|
||||
<View style={styles.detailValueContainer}>
|
||||
<CopyTextToClipboard
|
||||
@ -1314,7 +1210,7 @@ const TransactionStatus: React.FC = () => {
|
||||
</View>
|
||||
|
||||
{/* Size */}
|
||||
<View style={[styles.detailRow, stylesHook.detailRow, scaledStyles.detailRow]}>
|
||||
<View style={[styles.detailRow, stylesHook.detailRow]}>
|
||||
<BlueText style={[styles.detailLabel, stylesHook.detailLabel]}>{loc.transactions.details_size}</BlueText>
|
||||
<View style={styles.detailValueContainer}>
|
||||
<CopyTextToClipboard
|
||||
@ -1326,7 +1222,7 @@ const TransactionStatus: React.FC = () => {
|
||||
</View>
|
||||
|
||||
{/* Virtual Size */}
|
||||
<View style={[styles.detailRow, stylesHook.detailRow, scaledStyles.detailRow]}>
|
||||
<View style={[styles.detailRow, stylesHook.detailRow]}>
|
||||
<BlueText style={[styles.detailLabel, stylesHook.detailLabel]}>{loc.transactions.details_virtual_size}</BlueText>
|
||||
<View style={styles.detailValueContainer}>
|
||||
<CopyTextToClipboard
|
||||
@ -1338,7 +1234,7 @@ const TransactionStatus: React.FC = () => {
|
||||
</View>
|
||||
|
||||
{/* Transaction Hex */}
|
||||
<View style={[styles.detailRow, stylesHook.detailRow, scaledStyles.detailRow]}>
|
||||
<View style={[styles.detailRow, stylesHook.detailRow]}>
|
||||
<BlueText style={[styles.detailLabel, stylesHook.detailLabel]}>{loc.transactions.details_tx_hex}</BlueText>
|
||||
<View style={styles.detailValueContainer}>
|
||||
{txHex ? (
|
||||
@ -1403,7 +1299,6 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'center',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
headerTitleDirection: {
|
||||
fontSize: 17,
|
||||
@ -1451,20 +1346,15 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'flex-start',
|
||||
overflow: 'visible',
|
||||
width: '100%',
|
||||
},
|
||||
value: {
|
||||
fontSize: 40,
|
||||
fontWeight: '700',
|
||||
letterSpacing: -0.5,
|
||||
lineHeight: 48,
|
||||
lineHeight: 32,
|
||||
paddingTop: 8,
|
||||
minHeight: 38,
|
||||
},
|
||||
valueFullWidth: {
|
||||
width: '100%',
|
||||
flexShrink: 1,
|
||||
},
|
||||
valueUnit: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
@ -1482,6 +1372,7 @@ const styles = StyleSheet.create({
|
||||
borderRadius: 12,
|
||||
marginHorizontal: 24,
|
||||
marginBottom: 42,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
stateSection: {
|
||||
alignItems: 'flex-start',
|
||||
@ -1499,7 +1390,6 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'flex-start',
|
||||
marginLeft: 8,
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
stateLabel: {
|
||||
fontSize: 16,
|
||||
@ -1585,23 +1475,17 @@ const styles = StyleSheet.create({
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
sectionTitleText: {
|
||||
fontSize: 17,
|
||||
fontWeight: '600',
|
||||
},
|
||||
sectionTitleTextFlexible: {
|
||||
flex: 1,
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
explorerButton: {
|
||||
paddingVertical: 6,
|
||||
paddingHorizontal: 12,
|
||||
borderRadius: 6,
|
||||
alignSelf: 'flex-end',
|
||||
flexShrink: 0,
|
||||
minWidth: 50,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
@ -1612,7 +1496,7 @@ const styles = StyleSheet.create({
|
||||
detailRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
alignItems: 'center',
|
||||
marginBottom: 0,
|
||||
minHeight: 24,
|
||||
paddingVertical: 12,
|
||||
@ -1636,8 +1520,6 @@ const styles = StyleSheet.create({
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
flex: 1,
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
lineHeight: 22,
|
||||
paddingRight: 12,
|
||||
},
|
||||
@ -1651,12 +1533,11 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
maxWidth: '100%',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'flex-end',
|
||||
flexWrap: 'nowrap',
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
gap: 8,
|
||||
flexShrink: 0,
|
||||
},
|
||||
detailValueCopyContainer: {
|
||||
flex: 1,
|
||||
@ -1704,7 +1585,7 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: 12,
|
||||
borderRadius: 6,
|
||||
alignSelf: 'flex-end',
|
||||
flexShrink: 0,
|
||||
minWidth: 50,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
@ -1722,6 +1603,7 @@ const styles = StyleSheet.create({
|
||||
borderWidth: 1,
|
||||
borderTopLeftRadius: 12,
|
||||
borderTopRightRadius: 12,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
advancedContent: {
|
||||
marginTop: 0,
|
||||
|
||||
@ -5,7 +5,7 @@ import { StyleSheet, View, ViewStyle, Animated, ScrollView } from 'react-native'
|
||||
import { TWallet } from '../../class/wallets/types';
|
||||
import { Header } from '../../components/Header';
|
||||
import { useTheme } from '../../components/themes';
|
||||
import WalletsCarousel, { CarouselListRefType } from '../../components/WalletsCarousel';
|
||||
import WalletsCarousel from '../../components/WalletsCarousel';
|
||||
import loc from '../../loc';
|
||||
import { useStorage } from '../../hooks/context/useStorage';
|
||||
import TotalWalletsBalance from '../../components/TotalWalletsBalance';
|
||||
@ -94,7 +94,7 @@ const DrawerList: React.FC<DrawerContentComponentProps> = memo((props: DrawerCon
|
||||
const drawerNavigation = props.navigation;
|
||||
|
||||
const [state, dispatch] = useReducer(walletReducer, initialState);
|
||||
const walletsCarousel = useRef<CarouselListRefType>(null);
|
||||
const walletsCarousel = useRef<any>(null);
|
||||
const { wallets, selectedWalletID } = useStorage();
|
||||
const { colors } = useTheme();
|
||||
const isFocused = useIsFocused();
|
||||
|
||||
@ -78,7 +78,7 @@ const ExportMultisigCoordinationSetup: React.FC = () => {
|
||||
const { wallets } = useStorage();
|
||||
const { isPrivacyBlurEnabled } = useSettings();
|
||||
const wallet: TWallet | undefined = wallets.find(w => w.getID() === walletID);
|
||||
const dynamicQRCode = useRef<DynamicQRCode>(null);
|
||||
const dynamicQRCode = useRef<any>(null);
|
||||
const { colors } = useTheme();
|
||||
const { enableScreenProtect, disableScreenProtect } = useScreenProtect();
|
||||
|
||||
|
||||
@ -8,7 +8,7 @@ import { useTheme } from '../../components/themes';
|
||||
import loc from '../../loc';
|
||||
import { Chain } from '../../models/bitcoinUnits';
|
||||
import { useStorage } from '../../hooks/context/useStorage';
|
||||
import WalletsCarousel, { CarouselListRefType } from '../../components/WalletsCarousel';
|
||||
import WalletsCarousel from '../../components/WalletsCarousel';
|
||||
import { useExtendedNavigation } from '../../hooks/useExtendedNavigation';
|
||||
import { TWallet } from '../../class/wallets/types';
|
||||
import { pop } from '../../NavigationService';
|
||||
@ -35,7 +35,7 @@ const SelectWallet: React.FC = () => {
|
||||
const { wallets } = useStorage();
|
||||
const { colors } = useTheme();
|
||||
const isModal = useNavigationState(state => state.routes.length > 1);
|
||||
const walletsCarousel = useRef<CarouselListRefType>(null);
|
||||
const walletsCarousel = useRef<any>(null);
|
||||
const previousRouteName = useNavigationState(state => state.routes[state.routes.length - 2]?.name);
|
||||
const [filteredWallets, setFilteredWallets] = useState<TWallet[]>([]);
|
||||
|
||||
|
||||
@ -49,7 +49,7 @@ function getCoinControlStats(w: TWallet): { hasCoinControl: boolean; utxoCount:
|
||||
}
|
||||
|
||||
const WalletDetails: React.FC = () => {
|
||||
const { saveToDisk, wallets, txMetadata, handleWalletDeletion, fetchAndSaveWalletTransactions, sleep } = useStorage();
|
||||
const { saveToDisk, wallets, txMetadata, handleWalletDeletion, sleep } = useStorage();
|
||||
const { isBiometricUseCapableAndEnabled } = useBiometrics();
|
||||
const { walletID } = useRoute<RouteProps>().params;
|
||||
const { direction } = useLocale();
|
||||
@ -140,21 +140,6 @@ const WalletDetails: React.FC = () => {
|
||||
fetchArkAddress();
|
||||
}, [wallet]);
|
||||
|
||||
const [isRestoringSwaps, setIsRestoringSwaps] = useState<boolean>(false);
|
||||
const onRestoreSwapsPressed = useCallback(async () => {
|
||||
if (wallet.type !== LightningArkWallet.type || !(wallet as unknown as LightningArkWallet).restoreSwaps) return;
|
||||
setIsRestoringSwaps(true);
|
||||
try {
|
||||
await (wallet as unknown as LightningArkWallet).restoreSwaps();
|
||||
await fetchAndSaveWalletTransactions(wallet.getID());
|
||||
presentAlert({ message: loc.wallets.restore_swap_activity_done });
|
||||
} catch (e: any) {
|
||||
presentAlert({ message: e?.message ?? String(e) });
|
||||
} finally {
|
||||
setIsRestoringSwaps(false);
|
||||
}
|
||||
}, [wallet, fetchAndSaveWalletTransactions]);
|
||||
|
||||
const navigateToOverviewAndDeleteWallet = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
const deletionSucceeded = await handleWalletDeletion(wallet.getID());
|
||||
@ -542,7 +527,6 @@ const WalletDetails: React.FC = () => {
|
||||
numberOfLines={1}
|
||||
ellipsizeMode="tail"
|
||||
testID="WalletNameDisplay"
|
||||
selectable
|
||||
>
|
||||
{walletName}
|
||||
</Text>
|
||||
@ -644,7 +628,7 @@ const WalletDetails: React.FC = () => {
|
||||
<View style={[styles.detailsCard, stylesHook.detailsCard]}>
|
||||
<View style={stylesHook.optionsContent}>
|
||||
<Text style={[styles.textLabel2, stylesHook.textLabel2, styles.optionsSubheader]}>
|
||||
{`Arkade ${loc.wallets.details_address}`}
|
||||
{`Ark ${loc.wallets.details_address}`}
|
||||
</Text>
|
||||
<CopyTextToClipboard
|
||||
text={arkAddress}
|
||||
@ -780,6 +764,7 @@ const WalletDetails: React.FC = () => {
|
||||
containerStyle={stylesHook.listItemContainerBorder}
|
||||
onPress={navigateToXPub}
|
||||
title={loc.wallets.details_show_xpub}
|
||||
chevron
|
||||
testID="XpubButton"
|
||||
bottomDivider
|
||||
/>
|
||||
@ -789,6 +774,7 @@ const WalletDetails: React.FC = () => {
|
||||
containerStyle={stylesHook.listItemContainerBorder}
|
||||
onPress={navigateToSignVerify}
|
||||
title={loc.addresses.sign_title}
|
||||
chevron
|
||||
testID="SignVerify"
|
||||
bottomDivider={!!(wallet.type === MultisigHDWallet.type)}
|
||||
/>
|
||||
@ -839,7 +825,6 @@ const WalletDetails: React.FC = () => {
|
||||
titleStyle={stylesHook.advancedListItemTitle}
|
||||
rightTitle={wallet.typeReadable}
|
||||
rightTitleStyle={stylesHook.advancedListItemRightTitle}
|
||||
rightTitleSelectable
|
||||
bottomDivider={
|
||||
!!(
|
||||
wallet.type === MultisigHDWallet.type ||
|
||||
@ -880,7 +865,6 @@ const WalletDetails: React.FC = () => {
|
||||
isMasterFingerPrintVisible ? (masterFingerprint ?? loc.wallets.import_derivation_loading) : loc.multisig.view
|
||||
}
|
||||
rightTitleStyle={stylesHook.advancedListItemRightTitle}
|
||||
rightTitleSelectable={isMasterFingerPrintVisible}
|
||||
bottomDivider={!!derivationPath}
|
||||
/>
|
||||
)}
|
||||
@ -891,7 +875,6 @@ const WalletDetails: React.FC = () => {
|
||||
titleStyle={stylesHook.advancedListItemTitle}
|
||||
rightTitle={derivationPath}
|
||||
rightTitleStyle={stylesHook.advancedListItemRightTitle}
|
||||
rightTitleSelectable
|
||||
bottomDivider={false}
|
||||
testID="DerivationPath"
|
||||
/>
|
||||
@ -917,18 +900,6 @@ const WalletDetails: React.FC = () => {
|
||||
backgroundColor={colors.redBG}
|
||||
textColor={colors.redText}
|
||||
/>
|
||||
{wallet.type === LightningArkWallet.type && (
|
||||
<>
|
||||
<BlueSpacing20 />
|
||||
<SecondButton
|
||||
onPress={onRestoreSwapsPressed}
|
||||
testID="RestoreSwapActivity"
|
||||
title={loc.wallets.restore_swap_activity}
|
||||
disabled={isRestoringSwaps}
|
||||
loading={isRestoringSwaps}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</BlueCard>
|
||||
</>
|
||||
|
||||
@ -11,15 +11,9 @@ import {
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
useWindowDimensions,
|
||||
View,
|
||||
RefreshControl,
|
||||
NativeScrollEvent,
|
||||
NativeSyntheticEvent,
|
||||
StyleProp,
|
||||
ViewStyle,
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import Icon from '../../components/Icon';
|
||||
import * as BlueElectrum from '../../blue_modules/BlueElectrum';
|
||||
import { isDesktop } from '../../blue_modules/environment';
|
||||
@ -33,7 +27,6 @@ import presentAlert, { AlertType } from '../../components/Alert';
|
||||
import { FButton, FContainer, FloatButtonsBottomFade } from '../../components/FloatButtons';
|
||||
import { useTheme } from '../../components/themes';
|
||||
import { TransactionListItem } from '../../components/TransactionListItem';
|
||||
import { TX_ROW_BASE_HEIGHT } from '../../components/ListItem';
|
||||
import TransactionsNavigationHeader, { actionKeys } from '../../components/TransactionsNavigationHeader';
|
||||
import { unlockWithBiometrics, useBiometrics } from '../../hooks/useBiometrics';
|
||||
import { useExtendedNavigation } from '../../hooks/useExtendedNavigation';
|
||||
@ -42,14 +35,10 @@ import { Chain } from '../../models/bitcoinUnits';
|
||||
import ActionSheet from '../ActionSheet';
|
||||
import { useStorage } from '../../hooks/context/useStorage';
|
||||
import WatchOnlyWarning from '../../components/WatchOnlyWarning';
|
||||
import { NativeStackNavigationOptions, NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import { DetailViewStackParamList } from '../../navigation/DetailViewStackParamList';
|
||||
import { Transaction, TWallet } from '../../class/wallets/types';
|
||||
import getWalletTransactionsOptions, {
|
||||
WalletTransactionsRouteProps,
|
||||
createWalletDetailsHeaderRight,
|
||||
createWalletDetailsHeaderRightItems,
|
||||
} from '../../navigation/helpers/getWalletTransactionsOptions';
|
||||
import getWalletTransactionsOptions, { WalletTransactionsRouteProps } from '../../navigation/helpers/getWalletTransactionsOptions';
|
||||
import { presentWalletExportReminder } from '../../helpers/presentWalletExportReminder';
|
||||
import selectWallet from '../../helpers/select-wallet';
|
||||
import assert from 'assert';
|
||||
@ -60,8 +49,6 @@ import { getClipboardContent } from '../../blue_modules/clipboard';
|
||||
import HandOffComponent from '../../components/HandOffComponent';
|
||||
import { HandOffActivityType } from '../../components/types';
|
||||
import WalletGradient from '../../class/wallet-gradient';
|
||||
import { isIOS26OrHigher } from '../../components/platform';
|
||||
import Animated, { SharedValue, useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated';
|
||||
|
||||
const buttonFontSize =
|
||||
PixelRatio.roundToNearestPixel(Dimensions.get('window').width / 26) > 22
|
||||
@ -72,109 +59,7 @@ type RouteProps = RouteProp<DetailViewStackParamList, 'WalletTransactions'>;
|
||||
|
||||
type WalletTransactionsProps = NativeStackScreenProps<DetailViewStackParamList, 'WalletTransactions'>;
|
||||
|
||||
/** Scroll offset after which the compact wallet name + balance header is shown. */
|
||||
const SCROLLED_HEADER_SHOW_OFFSET = 180;
|
||||
const SCROLLED_HEADER_FADE_IN_MS = 180;
|
||||
const SCROLLED_HEADER_FADE_OUT_MS = 150;
|
||||
|
||||
const usesIos26AnimatedScrolledHeader = Platform.OS === 'ios' && isIOS26OrHigher && !isDesktop;
|
||||
|
||||
/** Native stack options used when scrolled; includes props missing from the published TS types. */
|
||||
type WalletTransactionsScrolledHeaderOptions = NativeStackNavigationOptions & {
|
||||
headerTitleContainerStyle?: StyleProp<ViewStyle>;
|
||||
};
|
||||
|
||||
/** Horizontal space reserved so the scrolled title does not run under back / header-right actions. */
|
||||
const getScrolledHeaderTitleLayout = (screenWidth: number) => {
|
||||
const titleInsetLeft = Platform.OS === 'ios' ? (isIOS26OrHigher ? 40 : 56) : 72;
|
||||
const titleInsetRight = Platform.OS === 'ios' ? (isIOS26OrHigher ? 96 : 84) : 84;
|
||||
return {
|
||||
maxWidth: Math.max(0, screenWidth - titleInsetLeft - titleInsetRight),
|
||||
titleInsetLeft,
|
||||
titleInsetRight,
|
||||
};
|
||||
};
|
||||
|
||||
const buildIos26HeaderTitleLayoutOptions = (
|
||||
screenWidth: number,
|
||||
): Pick<WalletTransactionsScrolledHeaderOptions, 'headerTitleAlign' | 'headerTitleContainerStyle'> => ({
|
||||
headerTitleAlign: 'left',
|
||||
headerTitleContainerStyle: {
|
||||
width: screenWidth,
|
||||
maxWidth: screenWidth,
|
||||
alignSelf: 'flex-start',
|
||||
alignItems: 'flex-start',
|
||||
left: 0,
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
});
|
||||
|
||||
type WalletTransactionsScrolledHeaderTitleProps = {
|
||||
walletLabel: string;
|
||||
balance: string;
|
||||
};
|
||||
|
||||
type WalletTransactionsScrolledHeaderTitleAnimatedProps = WalletTransactionsScrolledHeaderTitleProps & {
|
||||
opacity: SharedValue<number>;
|
||||
};
|
||||
|
||||
const WalletTransactionsScrolledHeaderTitleAnimated: React.FC<WalletTransactionsScrolledHeaderTitleAnimatedProps> = ({
|
||||
opacity,
|
||||
walletLabel,
|
||||
balance,
|
||||
}) => {
|
||||
const { width: screenWidth } = useWindowDimensions();
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
opacity: opacity.value,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Animated.View style={[scrolledHeaderTitleStyles.animatedTitleWrapper, { width: screenWidth }, animatedStyle]} pointerEvents="box-none">
|
||||
<WalletTransactionsScrolledHeaderTitle walletLabel={walletLabel} balance={balance} />
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
const WalletTransactionsScrolledHeaderTitle: React.FC<WalletTransactionsScrolledHeaderTitleProps> = ({ walletLabel, balance }) => {
|
||||
const { width: screenWidth } = useWindowDimensions();
|
||||
const { colors } = useTheme();
|
||||
const { maxWidth, titleInsetLeft, titleInsetRight } = getScrolledHeaderTitleLayout(screenWidth);
|
||||
|
||||
const titleColor = Platform.OS === 'ios' ? colors.foregroundColor : '#FFFFFF';
|
||||
|
||||
const titleContent = (
|
||||
<>
|
||||
<Text style={[scrolledHeaderTitleStyles.walletLabel, { color: titleColor }]} numberOfLines={1} ellipsizeMode="tail">
|
||||
{walletLabel}
|
||||
</Text>
|
||||
{balance.length > 0 ? (
|
||||
<Text style={[scrolledHeaderTitleStyles.balance, { color: titleColor }]} numberOfLines={1} ellipsizeMode="tail">
|
||||
{balance}
|
||||
</Text>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
if (Platform.OS === 'ios') {
|
||||
return (
|
||||
<View style={[scrolledHeaderTitleStyles.iosHeaderRoot, { width: screenWidth }]}>
|
||||
<View
|
||||
style={[
|
||||
scrolledHeaderTitleStyles.container,
|
||||
scrolledHeaderTitleStyles.iosTitleArea,
|
||||
{ left: titleInsetLeft, right: titleInsetRight },
|
||||
]}
|
||||
>
|
||||
{titleContent}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return <View style={[scrolledHeaderTitleStyles.container, { maxWidth }]}>{titleContent}</View>;
|
||||
};
|
||||
|
||||
type TransactionListItem = Transaction & { type: 'transaction' | 'header' };
|
||||
const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { route: WalletTransactionsRouteProps }) => {
|
||||
const { wallets, saveToDisk } = useStorage();
|
||||
const { registerTransactionsHandler, unregisterTransactionsHandler } = useMenuElements();
|
||||
@ -188,11 +73,8 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
|
||||
const [pageSize] = useState(20);
|
||||
const navigation = useExtendedNavigation();
|
||||
const { setOptions, navigate } = navigation;
|
||||
const { colors, dark } = useTheme();
|
||||
const { colors } = useTheme();
|
||||
const { isElectrumDisabled } = useSettings();
|
||||
const insets = useSafeAreaInsets();
|
||||
const navBarHeight = Platform.select({ ios: 44, android: 56, default: 44 }) ?? 44;
|
||||
const headerOverlayHeight = insets.top + navBarHeight;
|
||||
const walletActionButtonsRef = useRef<View>(null);
|
||||
const [lastFetchTimestamp, setLastFetchTimestamp] = useState(() => wallet._lastTxFetch || 0);
|
||||
const [fetchFailures, setFetchFailures] = useState(0);
|
||||
@ -200,13 +82,12 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
|
||||
const [displayUnit, setDisplayUnit] = useState(wallet.preferredBalanceUnit);
|
||||
const [isUnitSwitching, setIsUnitSwitching] = useState(false);
|
||||
const [isWatchOnlyWarningVisible, setIsWatchOnlyWarningVisible] = useState<boolean>(() => {
|
||||
return wallet.type === WatchOnlyWallet.type && (wallet as WatchOnlyWallet).isWatchOnlyWarningVisible;
|
||||
return wallet.type === WatchOnlyWallet.type && (wallet as any).isWatchOnlyWarningVisible;
|
||||
});
|
||||
const MAX_FAILURES = 3;
|
||||
const flatListRef = useRef<FlatList<Transaction>>(null);
|
||||
const headerRef = useRef<View>(null);
|
||||
const headerScrolledRef = useRef(false);
|
||||
const scrolledHeaderOpacity = useSharedValue(0);
|
||||
const [headerHeight, setHeaderHeight] = useState(0);
|
||||
|
||||
const stylesHook = StyleSheet.create({
|
||||
listHeaderText: {
|
||||
@ -219,17 +100,44 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
|
||||
backgroundContainer: {
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
gradientBackground: {
|
||||
backgroundColor: WalletGradient.headerColorFor(wallet.type),
|
||||
height: headerHeight > 0 ? headerHeight : '30%',
|
||||
},
|
||||
activityIndicatorStyle: {
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
sendIcon: {
|
||||
transform: [{ rotate: direction === 'rtl' ? '-225deg' : '225deg' }],
|
||||
},
|
||||
receiveIcon: {
|
||||
transform: [{ rotate: direction === 'rtl' ? '-45deg' : '45deg' }],
|
||||
sendIcon: { transform: [{ rotate: direction === 'rtl' ? '-225deg' : '225deg' }] },
|
||||
receiveIcon: { transform: [{ rotate: direction === 'rtl' ? '-45deg' : '45deg' }] },
|
||||
headerBottomBar: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 12,
|
||||
height: 12,
|
||||
backgroundColor: colors.background,
|
||||
borderTopLeftRadius: 20,
|
||||
borderTopRightRadius: 20,
|
||||
...Platform.select({
|
||||
ios: {
|
||||
shadowColor: colors.shadowColor,
|
||||
shadowOffset: { width: 0, height: -8 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 6,
|
||||
},
|
||||
android: {
|
||||
elevation: 0.5,
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
setOptions(getWalletTransactionsOptions({ route }));
|
||||
}, [route, setOptions]),
|
||||
);
|
||||
|
||||
const onBarCodeRead = useCallback(
|
||||
(ret?: { data?: any }) => {
|
||||
if (!isLoading) {
|
||||
@ -239,15 +147,9 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
|
||||
uri: ret?.data ? ret.data : ret,
|
||||
};
|
||||
if (wallet.chain === Chain.ONCHAIN) {
|
||||
navigate('SendDetailsRoot', {
|
||||
screen: 'SendDetails',
|
||||
params: parameters,
|
||||
});
|
||||
navigate('SendDetailsRoot', { screen: 'SendDetails', params: parameters });
|
||||
} else {
|
||||
navigate('ScanLNDInvoiceRoot', {
|
||||
screen: 'ScanLNDInvoice',
|
||||
params: parameters,
|
||||
});
|
||||
navigate('ScanLNDInvoiceRoot', { screen: 'ScanLNDInvoice', params: parameters });
|
||||
}
|
||||
setIsLoading(false);
|
||||
}
|
||||
@ -265,14 +167,19 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
|
||||
|
||||
useEffect(() => {
|
||||
// keep local display unit in sync when wallet changes (e.g., switching wallets)
|
||||
console.debug('[UnitSwitch] sync from wallet preferred unit', { walletID, preferred: wallet.preferredBalanceUnit });
|
||||
setDisplayUnit(wallet.preferredBalanceUnit);
|
||||
}, [wallet, walletID]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsWatchOnlyWarningVisible(wallet.type === WatchOnlyWallet.type && (wallet as WatchOnlyWallet).isWatchOnlyWarningVisible);
|
||||
setIsWatchOnlyWarningVisible(wallet.type === WatchOnlyWallet.type && (wallet as any).isWatchOnlyWarningVisible);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [walletID]);
|
||||
|
||||
useEffect(() => {
|
||||
console.debug('[UnitSwitch] display unit state changed', { walletID, displayUnit, switching: isUnitSwitching });
|
||||
}, [walletID, displayUnit, isUnitSwitching]);
|
||||
|
||||
const sortedTransactions = useMemo(() => {
|
||||
const txs = wallet.getTransactions();
|
||||
txs.sort((a, b) => b.timestamp - a.timestamp);
|
||||
@ -396,10 +303,7 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
|
||||
await wallet.fetchBtcAddress();
|
||||
toAddress = wallet.refill_addressess[0];
|
||||
} catch (Err) {
|
||||
return presentAlert({
|
||||
message: (Err as Error).message,
|
||||
type: AlertType.Toast,
|
||||
});
|
||||
return presentAlert({ message: (Err as Error).message, type: AlertType.Toast });
|
||||
}
|
||||
}
|
||||
|
||||
@ -438,35 +342,16 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
|
||||
[name, navigate, navigation, onWalletSelect, walletID, wallets],
|
||||
);
|
||||
|
||||
const { fontScale } = useWindowDimensions();
|
||||
const txRowHeight = Math.round(TX_ROW_BASE_HEIGHT * fontScale);
|
||||
|
||||
const getItemLayout = useCallback(
|
||||
(_: any, index: number) => ({
|
||||
length: txRowHeight,
|
||||
offset: txRowHeight * index,
|
||||
index,
|
||||
}),
|
||||
[txRowHeight],
|
||||
);
|
||||
const getItemLayout = (_: any, index: number) => ({
|
||||
length: 64,
|
||||
offset: 64 * index,
|
||||
index,
|
||||
});
|
||||
|
||||
const renderItem = useCallback(
|
||||
// react/no-unused-prop-types misfires on inline arrow renderers: it reads the
|
||||
// destructured `item: Transaction` annotation as a propTypes definition and
|
||||
// ignores that the value is consumed on the next line.
|
||||
// eslint-disable-next-line react/no-unused-prop-types
|
||||
({ item }: { item: Transaction }) => (
|
||||
// Ark wallet rows lack on-chain `hash` and instead carry a synthetic
|
||||
// `txid` (`swap-…`, `ark-…`, `boarding-…`, `boarding-utxo-…`). Falling
|
||||
// back to `txid` prevents multiple Ark rows from sharing
|
||||
// `key={undefined}`, which made React reuse stale memoized renders
|
||||
// across rows.
|
||||
<TransactionListItem
|
||||
key={item.hash ?? (item as { txid?: string }).txid}
|
||||
item={item}
|
||||
itemPriceUnit={displayUnit}
|
||||
walletID={walletID}
|
||||
/>
|
||||
<TransactionListItem key={item.hash} item={item} itemPriceUnit={displayUnit} walletID={walletID} />
|
||||
),
|
||||
[displayUnit, walletID],
|
||||
);
|
||||
@ -485,7 +370,7 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
|
||||
});
|
||||
};
|
||||
|
||||
const _keyExtractor = useCallback((item: Transaction, index: number) => item.hash || item.txid || index.toString(), []);
|
||||
const _keyExtractor = useCallback((_item: any, index: number) => index.toString(), []);
|
||||
|
||||
const pasteFromClipboard = async () => {
|
||||
onBarCodeRead({ data: await getClipboardContent() });
|
||||
@ -493,10 +378,7 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
|
||||
|
||||
const sendButtonPress = () => {
|
||||
if (wallet.chain === Chain.OFFCHAIN) {
|
||||
return navigate('ScanLNDInvoiceRoot', {
|
||||
screen: 'ScanLNDInvoice',
|
||||
params: { walletID },
|
||||
});
|
||||
return navigate('ScanLNDInvoiceRoot', { screen: 'ScanLNDInvoice', params: { walletID } });
|
||||
}
|
||||
|
||||
if (wallet.type === WatchOnlyWallet.type && wallet.isHd() && !wallet.useWithHardwareWalletEnabled()) {
|
||||
@ -598,163 +480,79 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [wallet, wallet.hideBalance, displayUnit, balance]);
|
||||
|
||||
const walletLabel = wallet.getLabel();
|
||||
const scrolledHeaderTitle = useCallback(() => {
|
||||
if (usesIos26AnimatedScrolledHeader) {
|
||||
return (
|
||||
<WalletTransactionsScrolledHeaderTitleAnimated opacity={scrolledHeaderOpacity} walletLabel={walletLabel} balance={walletBalance} />
|
||||
);
|
||||
}
|
||||
return <WalletTransactionsScrolledHeaderTitle walletLabel={walletLabel} balance={walletBalance} />;
|
||||
}, [walletLabel, walletBalance, scrolledHeaderOpacity]);
|
||||
|
||||
const { width: screenWidth } = useWindowDimensions();
|
||||
|
||||
const getScrolledHeaderOptions = useCallback((): WalletTransactionsScrolledHeaderOptions => {
|
||||
const { titleInsetRight } = getScrolledHeaderTitleLayout(screenWidth);
|
||||
const routeIsLoading = route.params.isLoading ?? false;
|
||||
const scrolledHeaderIconColor = colors.foregroundColor;
|
||||
|
||||
return {
|
||||
headerTitle: scrolledHeaderTitle,
|
||||
// iOS ignores 'left'; title is positioned manually in WalletTransactionsScrolledHeaderTitle.
|
||||
...(Platform.OS === 'ios'
|
||||
? buildIos26HeaderTitleLayoutOptions(screenWidth)
|
||||
: {
|
||||
headerTitleAlign: 'left' as const,
|
||||
headerTitleContainerStyle: {
|
||||
paddingRight: titleInsetRight,
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
headerStyle: {
|
||||
backgroundColor: WalletGradient.headerColorFor(wallet.type),
|
||||
},
|
||||
headerTintColor: '#ffffff',
|
||||
}),
|
||||
...(Platform.OS === 'ios'
|
||||
? {
|
||||
headerTintColor: scrolledHeaderIconColor,
|
||||
statusBarStyle: 'light',
|
||||
...(isIOS26OrHigher && !isDesktop
|
||||
? {
|
||||
headerRight: undefined,
|
||||
unstable_headerRightItems: createWalletDetailsHeaderRightItems({
|
||||
isLoading: routeIsLoading,
|
||||
walletID,
|
||||
}),
|
||||
experimental_userInterfaceStyle: dark ? ('dark' as const) : ('light' as const),
|
||||
}
|
||||
: {
|
||||
headerBlurEffect: dark ? ('dark' as const) : ('light' as const),
|
||||
headerRight: createWalletDetailsHeaderRight({
|
||||
walletID,
|
||||
isLoading: routeIsLoading,
|
||||
iconColor: scrolledHeaderIconColor,
|
||||
}),
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}, [scrolledHeaderTitle, screenWidth, colors.foregroundColor, dark, route.params.isLoading, walletID, wallet.type]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!headerScrolledRef.current) return;
|
||||
setOptions(getScrolledHeaderOptions());
|
||||
}, [walletBalance, getScrolledHeaderOptions, setOptions]);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (usesIos26AnimatedScrolledHeader) {
|
||||
headerScrolledRef.current = false;
|
||||
scrolledHeaderOpacity.value = 0;
|
||||
setOptions({
|
||||
...getWalletTransactionsOptions({ route }),
|
||||
...buildIos26HeaderTitleLayoutOptions(screenWidth),
|
||||
headerTitle: scrolledHeaderTitle,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setOptions(getWalletTransactionsOptions({ route }));
|
||||
}, [route, screenWidth, scrolledHeaderTitle, scrolledHeaderOpacity, setOptions]),
|
||||
);
|
||||
|
||||
const handleScroll = useCallback(
|
||||
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
(event: any) => {
|
||||
const offsetY = event.nativeEvent.contentOffset.y;
|
||||
const scrolled = offsetY >= SCROLLED_HEADER_SHOW_OFFSET;
|
||||
|
||||
if (usesIos26AnimatedScrolledHeader) {
|
||||
if (scrolled === headerScrolledRef.current) return;
|
||||
headerScrolledRef.current = scrolled;
|
||||
scrolledHeaderOpacity.value = withTiming(scrolled ? 1 : 0, {
|
||||
duration: scrolled ? SCROLLED_HEADER_FADE_IN_MS : SCROLLED_HEADER_FADE_OUT_MS,
|
||||
});
|
||||
if (scrolled) {
|
||||
setOptions(getScrolledHeaderOptions());
|
||||
} else {
|
||||
setOptions({
|
||||
...getWalletTransactionsOptions({ route }),
|
||||
...buildIos26HeaderTitleLayoutOptions(screenWidth),
|
||||
headerTitle: scrolledHeaderTitle,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (scrolled === headerScrolledRef.current) return;
|
||||
headerScrolledRef.current = scrolled;
|
||||
|
||||
if (!scrolled) {
|
||||
setOptions({
|
||||
...getWalletTransactionsOptions({ route }),
|
||||
headerTitle: undefined,
|
||||
headerTitleAlign: undefined,
|
||||
headerTitleContainerStyle: undefined,
|
||||
headerBlurEffect: undefined,
|
||||
});
|
||||
const combinedHeight = 180;
|
||||
if (offsetY < combinedHeight) {
|
||||
setOptions({ ...getWalletTransactionsOptions({ route }), headerTitle: undefined });
|
||||
} else {
|
||||
setOptions(getScrolledHeaderOptions());
|
||||
navigation.setOptions({
|
||||
headerTitle: `${wallet.getLabel()} ${walletBalance}`,
|
||||
});
|
||||
}
|
||||
},
|
||||
[getScrolledHeaderOptions, setOptions, route, screenWidth, scrolledHeaderTitle, scrolledHeaderOpacity],
|
||||
[navigation, wallet, walletBalance, setOptions, route],
|
||||
);
|
||||
|
||||
const measureHeaderHeight = useCallback(() => {
|
||||
if (!headerRef.current) {
|
||||
// If header ref is not available, use default background
|
||||
setHeaderHeight(0);
|
||||
return;
|
||||
}
|
||||
|
||||
headerRef.current.measure((x, y, width, height, pageX, pageY) => {
|
||||
// Check if the header is actually visible
|
||||
if (height === 0 || pageY < 0) {
|
||||
// Header is not visible, use default background
|
||||
setHeaderHeight(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const fullHeight = pageY + height;
|
||||
if (fullHeight > 0) {
|
||||
setHeaderHeight(fullHeight);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(measureHeaderHeight, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}, [walletID, measureHeaderHeight]);
|
||||
|
||||
const ListHeaderComponent = useCallback(
|
||||
() => (
|
||||
<View ref={headerRef}>
|
||||
<View ref={headerRef} onLayout={measureHeaderHeight}>
|
||||
<TransactionsNavigationHeader
|
||||
headerOverlayHeight={headerOverlayHeight}
|
||||
wallet={wallet}
|
||||
onWalletUnitChange={async selectedUnit => {
|
||||
console.debug('[UnitSwitch] requested', { walletID, from: displayUnit, to: selectedUnit });
|
||||
setIsUnitSwitching(true);
|
||||
setDisplayUnit(selectedUnit);
|
||||
if ('setPreferredBalanceUnit' in wallet) {
|
||||
wallet.setPreferredBalanceUnit(selectedUnit);
|
||||
} else {
|
||||
(wallet as TWallet).preferredBalanceUnit = selectedUnit;
|
||||
(wallet as any).preferredBalanceUnit = selectedUnit;
|
||||
}
|
||||
await saveToDisk();
|
||||
console.debug('[UnitSwitch] persisted preferred unit', { walletID, unit: selectedUnit });
|
||||
setTimeout(() => {
|
||||
setIsUnitSwitching(false);
|
||||
console.debug('[UnitSwitch] complete', { walletID, unit: selectedUnit });
|
||||
}, 50);
|
||||
}}
|
||||
unit={displayUnit}
|
||||
unitSwitching={isUnitSwitching}
|
||||
onWalletBalanceVisibilityChange={async shouldHideBalance => {
|
||||
try {
|
||||
const isBiometricsEnabled = await isBiometricUseCapableAndEnabled();
|
||||
if (wallet.hideBalance && !shouldHideBalance && isBiometricsEnabled) {
|
||||
if (!(await unlockWithBiometrics())) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
wallet.hideBalance = shouldHideBalance;
|
||||
await saveToDisk();
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle balance visibility:', error);
|
||||
onWalletBalanceVisibilityChange={async isShouldBeVisible => {
|
||||
const isBiometricsEnabled = await isBiometricUseCapableAndEnabled();
|
||||
if (wallet.hideBalance && isBiometricsEnabled) {
|
||||
const unlocked = await unlockWithBiometrics();
|
||||
if (!unlocked) throw new Error('Biometrics failed');
|
||||
}
|
||||
wallet.hideBalance = isShouldBeVisible;
|
||||
await saveToDisk();
|
||||
}}
|
||||
onManageFundsPressed={id => {
|
||||
if (wallet.type === MultisigHDWallet.type) {
|
||||
@ -780,30 +578,36 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<View style={[styles.flex, styles.transactionsSection, stylesHook.backgroundContainer]}>
|
||||
<View style={styles.listHeaderTextRow}>
|
||||
<Text style={[styles.listHeaderText, stylesHook.listHeaderText]}>{loc.transactions.list_title}</Text>
|
||||
<View style={styles.headerBottomBarSpacer}>
|
||||
<View style={stylesHook.headerBottomBar} />
|
||||
</View>
|
||||
<>
|
||||
<View style={[styles.flex, stylesHook.backgroundContainer]}>
|
||||
<View style={styles.listHeaderTextRow}>
|
||||
<Text style={[styles.listHeaderText, stylesHook.listHeaderText]}>{loc.transactions.list_title}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<View style={stylesHook.backgroundContainer}>
|
||||
{wallet.type === WatchOnlyWallet.type && isWatchOnlyWarningVisible && (
|
||||
<WatchOnlyWarning
|
||||
handleDismiss={() => {
|
||||
setIsWatchOnlyWarningVisible(false);
|
||||
wallet.isWatchOnlyWarningVisible = false;
|
||||
saveToDisk();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
<View style={stylesHook.backgroundContainer}>
|
||||
{wallet.type === WatchOnlyWallet.type && isWatchOnlyWarningVisible && (
|
||||
<WatchOnlyWarning
|
||||
handleDismiss={() => {
|
||||
setIsWatchOnlyWarningVisible(false);
|
||||
wallet.isWatchOnlyWarningVisible = false;
|
||||
saveToDisk();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</>
|
||||
</View>
|
||||
),
|
||||
[
|
||||
wallet,
|
||||
displayUnit,
|
||||
isUnitSwitching,
|
||||
headerOverlayHeight,
|
||||
measureHeaderHeight,
|
||||
stylesHook.backgroundContainer,
|
||||
stylesHook.headerBottomBar,
|
||||
stylesHook.listHeaderText,
|
||||
saveToDisk,
|
||||
isBiometricUseCapableAndEnabled,
|
||||
@ -816,18 +620,16 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
headerScrolledRef.current = false;
|
||||
scrolledHeaderOpacity.value = 0;
|
||||
if (flatListRef.current) {
|
||||
flatListRef.current.scrollToOffset({ offset: 0, animated: true });
|
||||
}
|
||||
}, [walletID, scrolledHeaderOpacity]);
|
||||
}, [walletID]);
|
||||
|
||||
return (
|
||||
<View style={[styles.flex, { backgroundColor: WalletGradient.headerColorFor(wallet.type) }]} testID="TransactionsListView">
|
||||
<View style={[styles.flex, stylesHook.backgroundContainer]}>
|
||||
<View style={[styles.refreshIndicatorBackground, stylesHook.gradientBackground]} testID="TransactionsListView" />
|
||||
<FlatList<Transaction>
|
||||
ref={flatListRef}
|
||||
style={styles.flatList}
|
||||
getItemLayout={getItemLayout}
|
||||
updateCellsBatchingPeriod={50}
|
||||
onEndReachedThreshold={0.3}
|
||||
@ -838,9 +640,8 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
|
||||
keyExtractor={_keyExtractor}
|
||||
renderItem={renderItem}
|
||||
initialNumToRender={10}
|
||||
removeClippedSubviews={false}
|
||||
contentContainerStyle={[styles.contentContainer, stylesHook.backgroundContainer]}
|
||||
contentInsetAdjustmentBehavior="never"
|
||||
removeClippedSubviews
|
||||
contentContainerStyle={stylesHook.backgroundContainer}
|
||||
contentInset={{ top: 0, left: 0, bottom: 90, right: 0 }}
|
||||
maxToRenderPerBatch={10}
|
||||
onScroll={handleScroll}
|
||||
@ -857,25 +658,11 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
|
||||
}
|
||||
refreshControl={
|
||||
!isDesktop && !isElectrumDisabled ? (
|
||||
<RefreshControl
|
||||
refreshing={isLoading}
|
||||
onRefresh={() => refreshTransactions(true)}
|
||||
tintColor={Platform.OS === 'ios' ? 'transparent' : colors.msSuccessCheck}
|
||||
progressViewOffset={headerOverlayHeight}
|
||||
/>
|
||||
<RefreshControl refreshing={isLoading} onRefresh={() => refreshTransactions(true)} tintColor={colors.msSuccessCheck} />
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{isLoading && Platform.OS === 'ios' && (
|
||||
<ActivityIndicator
|
||||
style={[styles.refreshSpinner, { top: headerOverlayHeight + 12, transform: [{ scale: 1.4 }] }]}
|
||||
color="#ffffff"
|
||||
size="small"
|
||||
pointerEvents="none"
|
||||
/>
|
||||
)}
|
||||
|
||||
<FloatButtonsBottomFade />
|
||||
<FContainer ref={walletActionButtonsRef}>
|
||||
{wallet.allowReceive() && (
|
||||
@ -884,10 +671,7 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
|
||||
text={loc.receive.header}
|
||||
onPress={() => {
|
||||
if (wallet.chain === Chain.OFFCHAIN) {
|
||||
navigate('LNDCreateInvoiceRoot', {
|
||||
screen: 'LNDCreateInvoice',
|
||||
params: { walletID },
|
||||
});
|
||||
navigate('LNDCreateInvoiceRoot', { screen: 'LNDCreateInvoice', params: { walletID } });
|
||||
} else {
|
||||
navigate('ReceiveDetails', { walletID });
|
||||
}
|
||||
@ -938,81 +722,22 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
|
||||
|
||||
export default WalletTransactions;
|
||||
|
||||
const scrolledHeaderTitleStyles = StyleSheet.create({
|
||||
animatedTitleWrapper: {
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
iosHeaderRoot: {
|
||||
height: 44,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
iosTitleArea: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
minWidth: 0,
|
||||
},
|
||||
container: {
|
||||
minWidth: 0,
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
walletLabel: {
|
||||
fontSize: 17,
|
||||
fontWeight: '600',
|
||||
letterSpacing: 0.15,
|
||||
alignSelf: 'stretch',
|
||||
flexShrink: 1,
|
||||
},
|
||||
balance: {
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
lineHeight: 18,
|
||||
marginTop: 1,
|
||||
alignSelf: 'stretch',
|
||||
flexShrink: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
flex: { flex: 1 },
|
||||
flatList: { flex: 1, backgroundColor: 'transparent' },
|
||||
transactionsSection: { marginTop: -1 },
|
||||
scrollViewContent: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 500,
|
||||
},
|
||||
headerBottomBarSpacer: { position: 'relative', height: 12 },
|
||||
scrollViewContent: { flex: 1, justifyContent: 'center', paddingHorizontal: 16, paddingBottom: 500 },
|
||||
activityIndicator: { marginVertical: 20 },
|
||||
listHeaderTextRow: {
|
||||
flex: 1,
|
||||
marginHorizontal: 16,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
listHeaderTextRow: { flex: 1, marginHorizontal: 16, flexDirection: 'row', justifyContent: 'space-between' },
|
||||
listHeaderText: { marginTop: 0, marginBottom: 16, fontWeight: 'bold', fontSize: 24 },
|
||||
refreshIndicatorBackground: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
},
|
||||
listHeaderText: {
|
||||
marginTop: 16,
|
||||
marginBottom: 16,
|
||||
fontWeight: 'bold',
|
||||
fontSize: 24,
|
||||
},
|
||||
contentContainer: { flexGrow: 1 },
|
||||
refreshSpinner: { position: 'absolute', alignSelf: 'center', zIndex: 10 },
|
||||
emptyTxsContainer: { height: '10%', minHeight: '10%', flex: 1 },
|
||||
emptyTxs: {
|
||||
fontSize: 18,
|
||||
color: '#9aa0aa',
|
||||
textAlign: 'center',
|
||||
marginVertical: 16,
|
||||
},
|
||||
emptyTxsLightning: {
|
||||
fontSize: 18,
|
||||
color: '#9aa0aa',
|
||||
textAlign: 'center',
|
||||
fontWeight: '600',
|
||||
},
|
||||
emptyTxs: { fontSize: 18, color: '#9aa0aa', textAlign: 'center', marginVertical: 16 },
|
||||
emptyTxsLightning: { fontSize: 18, color: '#9aa0aa', textAlign: 'center', fontWeight: '600' },
|
||||
iconContainer: {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
|
||||
@ -8,15 +8,10 @@ import triggerHapticFeedback, { HapticFeedbackTypes } from '../../blue_modules/h
|
||||
import DeeplinkSchemaMatch from '../../class/deeplink-schema-match';
|
||||
import { ExtendedTransaction, Transaction, TWallet } from '../../class/wallets/types';
|
||||
import presentAlert from '../../components/Alert';
|
||||
import { FButton, FContainer, FloatButtonsBottomFade, getFloatingButtonReservedHeight } from '../../components/FloatButtons';
|
||||
import { FButton, FContainer, FloatButtonsBottomFade } from '../../components/FloatButtons';
|
||||
import { useTheme } from '../../components/themes';
|
||||
import { TransactionListItem } from '../../components/TransactionListItem';
|
||||
import { TX_ROW_BASE_HEIGHT } from '../../components/ListItem';
|
||||
import WalletsCarousel, {
|
||||
getWalletCarouselItemWidth,
|
||||
CarouselListRefType,
|
||||
getWalletCarouselHeight,
|
||||
} from '../../components/WalletsCarousel';
|
||||
import WalletsCarousel, { getWalletCarouselItemWidth } from '../../components/WalletsCarousel';
|
||||
import { useSizeClass, SizeClass } from '../../blue_modules/sizeClass';
|
||||
import loc from '../../loc';
|
||||
import ActionSheet from '../ActionSheet';
|
||||
@ -30,10 +25,8 @@ import { useSettings } from '../../hooks/context/useSettings';
|
||||
import useMenuElements from '../../hooks/useMenuElements';
|
||||
import SafeAreaSectionList from '../../components/SafeAreaSectionList';
|
||||
import { scanQrHelper } from '../../helpers/scan-qr';
|
||||
import { isIOS26OrHigher } from '../../components/platform';
|
||||
|
||||
const WalletsListSections = { CAROUSEL: 'CAROUSEL', TRANSACTIONS: 'TRANSACTIONS' };
|
||||
const SECTION_HEADER_BASE_HEIGHT = 56;
|
||||
|
||||
/** Electrum `ping` while the list is visible; detects mid-session drops without polling when user is elsewhere. */
|
||||
const ELECTRUM_HEALTH_POLL_WHILE_WALLETS_LIST_FOCUSED_MS = 30_000;
|
||||
@ -108,17 +101,13 @@ const WalletsList: React.FC = () => {
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
const { isLoading } = state;
|
||||
const { sizeClass, isLarge } = useSizeClass();
|
||||
const walletsCarousel = useRef<CarouselListRefType>(null);
|
||||
const walletsCarousel = useRef<any>(null);
|
||||
const connectionPoll = useContext(ConnectionPollContext);
|
||||
const currentWalletIndex = useRef<number>(0);
|
||||
const { registerTransactionsHandler, unregisterTransactionsHandler } = useMenuElements();
|
||||
const { wallets, getTransactions, refreshAllWalletTransactions } = useStorage();
|
||||
const { isTotalBalanceEnabled, isElectrumDisabled } = useSettings();
|
||||
const { width, fontScale } = useWindowDimensions();
|
||||
const carouselHeight = getWalletCarouselHeight(fontScale);
|
||||
const transactionItemHeight = Math.round(TX_ROW_BASE_HEIGHT * fontScale);
|
||||
const sectionHeaderHeight = Math.round(SECTION_HEADER_BASE_HEIGHT * fontScale);
|
||||
const floatingButtonHeight = getFloatingButtonReservedHeight(fontScale);
|
||||
const { width } = useWindowDimensions();
|
||||
const { colors, scanImage } = useTheme();
|
||||
const navigation = useExtendedNavigation<NavigationProps>();
|
||||
const isFocused = useIsFocused();
|
||||
@ -134,11 +123,9 @@ const WalletsList: React.FC = () => {
|
||||
listHeaderBack: {
|
||||
backgroundColor: colors.background,
|
||||
paddingTop: sizeClass === SizeClass.Large ? 8 : 0,
|
||||
minHeight: sectionHeaderHeight,
|
||||
},
|
||||
listHeaderText: {
|
||||
color: colors.foregroundColor,
|
||||
marginVertical: Math.round(16 * fontScale),
|
||||
},
|
||||
});
|
||||
|
||||
@ -303,14 +290,7 @@ const WalletsList: React.FC = () => {
|
||||
|
||||
const renderTransactionListsRow = useCallback(
|
||||
(item: ExtendedTransaction) => (
|
||||
// Ark wallet rows have no on-chain `hash` — fall back to their
|
||||
// synthetic `txid` so each row gets a unique React key.
|
||||
<TransactionListItem
|
||||
key={item.hash ?? (item as { txid?: string }).txid}
|
||||
item={item}
|
||||
itemPriceUnit={item.walletPreferredBalanceUnit}
|
||||
walletID={item.walletID}
|
||||
/>
|
||||
<TransactionListItem key={item.hash} item={item} itemPriceUnit={item.walletPreferredBalanceUnit} walletID={item.walletID} />
|
||||
),
|
||||
[],
|
||||
);
|
||||
@ -483,10 +463,7 @@ const WalletsList: React.FC = () => {
|
||||
}, [onScanButtonPressed, scanImage, sendButtonLongPress, wallets.length]);
|
||||
|
||||
const sectionListKeyExtractor = useCallback((item: any, index: any) => {
|
||||
if (typeof item === 'string') return item;
|
||||
const txKey = item?.hash || item?.txid;
|
||||
if (txKey && item?.walletID) return `${txKey}_${item.walletID}`;
|
||||
return txKey || `${item}${index}`;
|
||||
return `${item}${index}`;
|
||||
}, []);
|
||||
|
||||
const refreshProps = isDesktop || isElectrumDisabled ? {} : { refreshing: isLoading, onRefresh };
|
||||
@ -505,9 +482,14 @@ const WalletsList: React.FC = () => {
|
||||
}, [sizeClass, dataSource]);
|
||||
|
||||
// Constants for layout calculations
|
||||
const TRANSACTION_ITEM_HEIGHT = 80;
|
||||
const CAROUSEL_HEIGHT = 195;
|
||||
const SECTION_HEADER_HEIGHT = 56; // Base height
|
||||
const LARGE_TITLE_EXTRA_HEIGHT = 20; // Additional height for large titles
|
||||
|
||||
const getSectionHeaderHeight = useCallback(() => {
|
||||
return sectionHeaderHeight + (sizeClass === SizeClass.Large ? Math.round(20 * fontScale) : 0);
|
||||
}, [sizeClass, sectionHeaderHeight, fontScale]);
|
||||
return SECTION_HEADER_HEIGHT + (sizeClass === SizeClass.Large ? LARGE_TITLE_EXTRA_HEIGHT : 0);
|
||||
}, [sizeClass]);
|
||||
|
||||
const getItemLayout = useCallback(
|
||||
(data: any, index: number) => {
|
||||
@ -516,8 +498,8 @@ const WalletsList: React.FC = () => {
|
||||
if (sizeClass === SizeClass.Large) {
|
||||
// On large screens: only transaction items, no carousel
|
||||
return {
|
||||
length: transactionItemHeight,
|
||||
offset: transactionItemHeight * index,
|
||||
length: TRANSACTION_ITEM_HEIGHT,
|
||||
offset: TRANSACTION_ITEM_HEIGHT * index,
|
||||
index,
|
||||
};
|
||||
} else {
|
||||
@ -525,7 +507,7 @@ const WalletsList: React.FC = () => {
|
||||
// First section: Carousel
|
||||
if (index === 0) {
|
||||
return {
|
||||
length: carouselHeight,
|
||||
length: CAROUSEL_HEIGHT,
|
||||
offset: 0,
|
||||
index,
|
||||
};
|
||||
@ -538,13 +520,13 @@ const WalletsList: React.FC = () => {
|
||||
// 3. Transaction items
|
||||
const transactionIndex = index - 1; // Adjust index to account for carousel
|
||||
return {
|
||||
length: transactionItemHeight,
|
||||
offset: carouselHeight + headerHeight + transactionItemHeight * transactionIndex,
|
||||
length: TRANSACTION_ITEM_HEIGHT,
|
||||
offset: CAROUSEL_HEIGHT + headerHeight + TRANSACTION_ITEM_HEIGHT * transactionIndex,
|
||||
index,
|
||||
};
|
||||
}
|
||||
},
|
||||
[sizeClass, getSectionHeaderHeight, carouselHeight, transactionItemHeight],
|
||||
[sizeClass, getSectionHeaderHeight],
|
||||
);
|
||||
|
||||
return (
|
||||
@ -557,13 +539,11 @@ const WalletsList: React.FC = () => {
|
||||
initialNumToRender={10}
|
||||
renderSectionFooter={renderSectionFooter}
|
||||
sections={sections}
|
||||
floatingButtonHeight={floatingButtonHeight}
|
||||
floatingButtonHeight={70}
|
||||
maxToRenderPerBatch={10}
|
||||
updateCellsBatchingPeriod={50}
|
||||
getItemLayout={getItemLayout}
|
||||
ignoreTopInset={true} // Ignore top inset as the screen header already handles it
|
||||
// On iOS 26+, let the section headers scroll naturally with the content rather than sticking
|
||||
stickySectionHeadersEnabled={!isIOS26OrHigher}
|
||||
{...refreshProps}
|
||||
/>
|
||||
{renderScanButton()}
|
||||
|
||||
@ -4,7 +4,6 @@ import { element, waitFor } from 'detox';
|
||||
|
||||
import {
|
||||
confirmPasswordDialog,
|
||||
dismissAlertByText,
|
||||
expectToBeVisible,
|
||||
extractTextFromElementById,
|
||||
goBack,
|
||||
@ -51,11 +50,6 @@ describe('BlueWallet UI Tests - no wallets', () => {
|
||||
.whileElement(by.id('SettingsRoot'))
|
||||
.scroll(500, 'down');
|
||||
await element(by.id('AboutButton')).tap();
|
||||
// Ensure About has mounted before scrolling — race seen on cold launches
|
||||
// where the scroll fires before the FlatList is in the view hierarchy.
|
||||
await waitFor(element(by.id('AboutScrollView')))
|
||||
.toBeVisible()
|
||||
.withTimeout(15_000);
|
||||
await waitFor(element(by.id('RunSelfTestButton')))
|
||||
.toBeVisible()
|
||||
.whileElement(by.id('AboutScrollView'))
|
||||
@ -63,18 +57,10 @@ describe('BlueWallet UI Tests - no wallets', () => {
|
||||
await tapAndTapAgainIfElementIsNotVisible('RunSelfTestButton', 'SelfTestLoading');
|
||||
await element(by.id('SelfTestLoading')).tap(); // tapping START button
|
||||
|
||||
// SelfTest runs CPU-heavy crypto loops for 100+ seconds. Detox's
|
||||
// FabricTimersIdlingResource never goes idle during that, so a synchronized
|
||||
// waitFor would throw IdlingResourceTimeoutException long before
|
||||
// SelfTestOk renders. Disable synchronization just for the wait.
|
||||
await device.disableSynchronization();
|
||||
try {
|
||||
await waitFor(element(by.id('SelfTestOk')))
|
||||
.toBeVisible()
|
||||
.withTimeout(300 * 1000);
|
||||
} finally {
|
||||
await device.enableSynchronization();
|
||||
}
|
||||
// Wait for the self-test to complete
|
||||
await waitFor(element(by.id('SelfTestOk')))
|
||||
.toBeVisible()
|
||||
.withTimeout(300 * 1000);
|
||||
await goBack();
|
||||
await goBack();
|
||||
await goBack();
|
||||
@ -194,36 +180,31 @@ describe('BlueWallet UI Tests - no wallets', () => {
|
||||
await waitFor(element(by.id('NotificationsSwitch')))
|
||||
.toBeVisible()
|
||||
.withTimeout(10000);
|
||||
await element(by.id('NotificationsSwitch')).tap();
|
||||
|
||||
// Toggle notifications on/off. On iOS 26 simulators notifications are always
|
||||
// denied, triggering a native UIAlertController whose buttons liquid glass
|
||||
// can make un-tappable by Detox. If the alert cannot be dismissed, relaunch
|
||||
// the app to recover instead of failing the entire settings test.
|
||||
let notifDialogStuck = false;
|
||||
// If notifications are not enabled on the device, an alert will appear
|
||||
try {
|
||||
await element(by.id('NotificationsSwitch')).tap();
|
||||
const dismissed1 = await dismissAlertByText('OK', 10000);
|
||||
if (dismissed1) {
|
||||
await sleep(500);
|
||||
await element(by.id('NotificationsSwitch')).tap();
|
||||
await dismissAlertByText('OK', 10000);
|
||||
} else {
|
||||
notifDialogStuck = true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Notifications toggle skipped due to alert interaction issue:', e.message);
|
||||
notifDialogStuck = true;
|
||||
await waitFor(element(by.text('OK')))
|
||||
.toBeVisible()
|
||||
.withTimeout(3000);
|
||||
await element(by.text('OK')).tap();
|
||||
} catch (_) {
|
||||
// Alert not shown, which is fine - notifications might be enabled
|
||||
}
|
||||
await element(by.id('NotificationsSwitch')).tap();
|
||||
|
||||
// If notifications are not enabled on the device, an alert will appear
|
||||
try {
|
||||
await waitFor(element(by.text('OK')))
|
||||
.toBeVisible()
|
||||
.withTimeout(3000);
|
||||
await element(by.text('OK')).tap();
|
||||
} catch (_) {
|
||||
// Alert not shown, which is fine - notifications might be enabled
|
||||
}
|
||||
|
||||
if (notifDialogStuck) {
|
||||
// Dialog blocks all interaction; relaunch the app to clear it
|
||||
await device.launchApp({ newInstance: true });
|
||||
await waitForId('WalletsList');
|
||||
await element(by.id('SettingsButton')).tap();
|
||||
} else {
|
||||
await goBack();
|
||||
await goBack();
|
||||
}
|
||||
await goBack();
|
||||
await goBack();
|
||||
} else {
|
||||
await goBack();
|
||||
}
|
||||
@ -525,14 +506,9 @@ describe('BlueWallet UI Tests - no wallets', () => {
|
||||
if (device.getPlatform() === 'ios') {
|
||||
// FIXME: WAllets does not exists on android
|
||||
await waitForId('Wallets');
|
||||
await scrollUpOnHomeScreen();
|
||||
}
|
||||
await sleep(1000); // propagate
|
||||
// Match t4's flow: scroll up so the next helperCreateWallet's
|
||||
// whileElement(WalletsList).scroll('right') starts from a known
|
||||
// position. Without this, Android lands the user on a list state
|
||||
// where CreateAWallet is not visible after scroll-right and the
|
||||
// 6s tapAndTapAgainIfElementIsNotVisible budget runs out.
|
||||
await scrollUpOnHomeScreen();
|
||||
// created fake storage.
|
||||
// creating a wallet inside this fake storage
|
||||
await helperCreateWallet('fake_wallet');
|
||||
|
||||
@ -50,11 +50,6 @@ describe('BlueWallet UI Tests - import Watch-only wallet (zpub)', () => {
|
||||
await sleep(1000);
|
||||
} catch (_) {}
|
||||
|
||||
try {
|
||||
// in case notification popup appeared early and is blocking taps
|
||||
await element(by.text(`No, and do not ask me again.`)).tap();
|
||||
} catch (_) {}
|
||||
|
||||
await element(by.id('ReceiveButton')).tap();
|
||||
await expect(element(by.id('BitcoinAddressQRCode'))).toBeVisible();
|
||||
await expect(element(by.label('bc1qgrhr5xc5774maph97d73ydrjlqqmg2v6jjlr29'))).toBeVisible();
|
||||
@ -87,12 +82,6 @@ describe('BlueWallet UI Tests - import Watch-only wallet (zpub)', () => {
|
||||
|
||||
// now lets test scanning back QR with UR PSBT. this should lead straight to broadcast dialog
|
||||
|
||||
// Same race as the t1 AboutScrollView fix in bluewallet.spec.js: the
|
||||
// PSBT-with-hardware screen has not always mounted by the time
|
||||
// whileElement(...).scroll() runs.
|
||||
await waitFor(element(by.id('PsbtWithHardwareScrollView')))
|
||||
.toBeVisible()
|
||||
.withTimeout(15_000);
|
||||
await waitFor(element(by.id('PsbtTxScanButton')))
|
||||
.toBeVisible()
|
||||
.whileElement(by.id('PsbtWithHardwareScrollView'))
|
||||
|
||||
@ -58,20 +58,8 @@ export async function waitForText(text, timeout = 33000) {
|
||||
await waitFor(element(by.text(text)))
|
||||
.toBeVisible()
|
||||
.withTimeout(timeout / 2);
|
||||
return true;
|
||||
} catch (err) {
|
||||
// iOS 26 liquid glass: text rendered inside/over the glass header (e.g. the wallet name on
|
||||
// the transactions hero) can fail Detox's 75%-pixel toBeVisible check while still being
|
||||
// present and on-screen — same root cause as the goBack() back-button workaround. Fall back
|
||||
// to existence in the hierarchy so a glass false-negative does not fail an otherwise valid run.
|
||||
try {
|
||||
await waitFor(element(by.text(text)))
|
||||
.toExist()
|
||||
.withTimeout(3000);
|
||||
return true;
|
||||
} catch (_) {
|
||||
rethrowWithCallsite(err, callsite);
|
||||
}
|
||||
rethrowWithCallsite(err, callsite);
|
||||
}
|
||||
}
|
||||
|
||||
@ -175,17 +163,10 @@ export function hashIt(s) {
|
||||
}
|
||||
|
||||
export async function helperDeleteWallet(label, remainingBalanceSat = false) {
|
||||
// Tapping the wallet card by visible text (`by.text(label)`) is what
|
||||
// bluewallet3's import-then-delete flow uses successfully. On a wallet
|
||||
// that has been opened before, this navigates to WalletTransactions
|
||||
// immediately. On a freshly-created wallet (t10) the carousel
|
||||
// Pressable's first onPress is swallowed before navigation fires —
|
||||
// that case is a known limitation of the e2e harness.
|
||||
await element(by.text(label)).tap();
|
||||
await waitForId('WalletDetails');
|
||||
await element(by.id('WalletDetails')).tap();
|
||||
await element(by.id('WalletDetailsScroll')).swipe('up', 'fast', 1);
|
||||
await sleep(1000);
|
||||
await sleep(200);
|
||||
await element(by.id('DeleteWallet')).tap();
|
||||
await waitForText('Yes, delete');
|
||||
await element(by.text('Yes, delete')).tap();
|
||||
@ -228,44 +209,15 @@ export async function helperCreateWallet(walletName) {
|
||||
await element(by.id('ActivateBitcoinButton')).tap();
|
||||
await element(by.id('ActivateBitcoinButton')).tap();
|
||||
// why tf we need 2 taps for it to work..? mystery
|
||||
await tapAndTapAgainIfElementIsNotVisible('Create', 'PleaseBackupScrollView');
|
||||
|
||||
// iOS 26 liquid glass: the navigation transition after tapping "Create" triggers
|
||||
// glass animations that never fully settle, keeping the app in a "busy" state.
|
||||
// Detox synchronization waits for idle before proceeding, causing an infinite hang.
|
||||
// Disable sync for the remainder of wallet creation and re-enable once we're back
|
||||
// on the home screen where the glass animations have settled.
|
||||
const isIOS = device.getPlatform() === 'ios';
|
||||
if (isIOS) {
|
||||
await device.disableSynchronization();
|
||||
}
|
||||
try {
|
||||
await element(by.id('Create')).tap();
|
||||
await sleep(500);
|
||||
try {
|
||||
await waitFor(element(by.id('PleaseBackupScrollView')))
|
||||
.toBeVisible()
|
||||
.withTimeout(15000);
|
||||
} catch (_) {
|
||||
await element(by.id('Create')).tap();
|
||||
await sleep(500);
|
||||
await waitFor(element(by.id('PleaseBackupScrollView')))
|
||||
.toBeVisible()
|
||||
.withTimeout(15000);
|
||||
}
|
||||
await waitFor(element(by.id('PleasebackupOk')))
|
||||
.toBeVisible()
|
||||
.whileElement(by.id('PleaseBackupScrollView'))
|
||||
.scroll(500, 'down'); // in case emu screen is small and it doesnt fit
|
||||
|
||||
await waitFor(element(by.id('PleasebackupOk')))
|
||||
.toBeVisible()
|
||||
.whileElement(by.id('PleaseBackupScrollView'))
|
||||
.scroll(500, 'down'); // in case emu screen is small and it doesnt fit
|
||||
|
||||
await element(by.id('PleasebackupOk')).tap();
|
||||
await sleep(1000);
|
||||
await scrollUpOnHomeScreen();
|
||||
} finally {
|
||||
if (isIOS) {
|
||||
await device.enableSynchronization();
|
||||
}
|
||||
}
|
||||
await element(by.id('PleasebackupOk')).tap();
|
||||
await scrollUpOnHomeScreen();
|
||||
await expect(element(by.id('WalletsList'))).toBeVisible();
|
||||
await element(by.id('WalletsList')).swipe('right', 'fast', 1); // in case emu screen is small and it doesnt fit
|
||||
await sleep(200);
|
||||
@ -338,46 +290,6 @@ export async function tapIfTextPresent(text) {
|
||||
// no need to check for visibility, just silently ignore exception if such testID is not present
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismisses a native UIAlertController by tapping a button with the given text.
|
||||
* On iOS 26 liquid glass, `waitFor().toBeVisible()` never resolves for alert
|
||||
* buttons because the glass material fails Detox's pixel visibility check.
|
||||
* This helper disables Detox synchronization (which can also hang on glass
|
||||
* animations) and polls with direct tap attempts and label fallbacks.
|
||||
*
|
||||
* @returns true if the alert was dismissed, false if no alert was found
|
||||
*/
|
||||
export async function dismissAlertByText(text, timeoutMs = 10000) {
|
||||
const isIOS = device.getPlatform() === 'ios';
|
||||
if (isIOS) {
|
||||
await device.disableSynchronization();
|
||||
}
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let dismissed = false;
|
||||
try {
|
||||
while (Date.now() < deadline) {
|
||||
// by.text — works on pre–iOS 26 and some iOS 26 alerts
|
||||
try {
|
||||
await element(by.text(text)).atIndex(0).tap();
|
||||
dismissed = true;
|
||||
break;
|
||||
} catch (_) {}
|
||||
// by.label — accessibility label, works when text matching differs
|
||||
try {
|
||||
await element(by.label(text)).atIndex(0).tap();
|
||||
dismissed = true;
|
||||
break;
|
||||
} catch (_) {}
|
||||
await sleep(500);
|
||||
}
|
||||
} finally {
|
||||
if (isIOS) {
|
||||
await device.enableSynchronization();
|
||||
}
|
||||
}
|
||||
return dismissed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirms password dialogs in a platform-safe way.
|
||||
* Android must tap a visible confirmation to keep test flow deterministic.
|
||||
@ -449,62 +361,19 @@ export async function goBack() {
|
||||
// Try each back/close affordance in order; retry the full set up to 10 times.
|
||||
const candidates = [by.id('BackButton'), by.id('NavigationCloseButton'), by.label('Back'), by.text('Close')];
|
||||
|
||||
// A matcher can hit several elements across stacked screens: each nav back
|
||||
// button exists twice (_UIButtonBarButton wrapper + UIAccessibilityBackButtonElement),
|
||||
// and when a modal covers a stack that also has a back button, the covered
|
||||
// one can precede the visible one in match order (seen with Reduce Motion on).
|
||||
// Probe attributes and only tap an element detox reports as visible & hittable.
|
||||
//
|
||||
// iOS 26 liquid glass: the native back button reports visible=false because
|
||||
// the glass material fails Detox's 75%-pixel visibility check, yet the button
|
||||
// IS functionally hittable. We first try (visible && hittable), then fall back
|
||||
// to (hittable only) for the glass case.
|
||||
let lastErr;
|
||||
for (let attempt = 0; attempt < 10; attempt++) {
|
||||
// Pass 1: prefer visible + hittable elements
|
||||
for (const matcher of candidates) {
|
||||
for (let idx = 0; idx < 6; idx++) {
|
||||
let attrs;
|
||||
try {
|
||||
attrs = await element(matcher).atIndex(idx).getAttributes();
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
break; // no element at this index — try next candidate
|
||||
}
|
||||
if (!attrs.visible || attrs.hittable === false) continue;
|
||||
try {
|
||||
await element(matcher).atIndex(idx).tap();
|
||||
return;
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Pass 2: accept hittable-only elements (iOS 26 liquid glass back button)
|
||||
for (const matcher of candidates) {
|
||||
for (let idx = 0; idx < 6; idx++) {
|
||||
let attrs;
|
||||
try {
|
||||
attrs = await element(matcher).atIndex(idx).getAttributes();
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
break;
|
||||
}
|
||||
if (attrs.hittable === false) continue;
|
||||
try {
|
||||
await element(matcher).atIndex(idx).tap();
|
||||
return;
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
}
|
||||
try {
|
||||
await element(matcher).atIndex(0).tap();
|
||||
return;
|
||||
} catch (_) {
|
||||
/* try next */
|
||||
}
|
||||
}
|
||||
await sleep(500);
|
||||
}
|
||||
|
||||
const wrapped = new Error('goBack: no back/close affordance tappable after 10 attempts.');
|
||||
if (lastErr) wrapped.cause = lastErr;
|
||||
rethrowWithCallsite(wrapped, callsite);
|
||||
rethrowWithCallsite(new Error('goBack: no back/close affordance tappable after 10 attempts.'), callsite);
|
||||
}
|
||||
|
||||
export async function typeTextIntoAlertInput(text) {
|
||||
@ -529,7 +398,7 @@ export async function scrollUpOnHomeScreen() {
|
||||
// if no wallets there will be just one scroll
|
||||
await element(by.type('RCTEnhancedScrollView')).swipe('down', 'slow', 0.5);
|
||||
}
|
||||
await sleep(1000); // bounce animation
|
||||
await sleep(200); // bounce animation
|
||||
}
|
||||
|
||||
// We really only need this function when running tests locally.
|
||||
|
||||
@ -6,7 +6,6 @@ module.exports = {
|
||||
globalSetup: 'detox/runners/jest/globalSetup',
|
||||
globalTeardown: 'detox/runners/jest/globalTeardown',
|
||||
testEnvironment: 'detox/runners/jest/testEnvironment',
|
||||
setupFilesAfterEnv: ['<rootDir>/e2e/setup.js'],
|
||||
rootDir: '..',
|
||||
testMatch: ['<rootDir>/e2e/**/*.spec.js'],
|
||||
transform: {
|
||||
|
||||
@ -1,34 +0,0 @@
|
||||
/* eslint-env jest */
|
||||
/* global device */
|
||||
|
||||
// Detox's iOS network synchronization waits on all in-flight NSURLSession
|
||||
// requests before considering the app idle. The Arkade SDK's indexer opens
|
||||
// a long-lived SSE-style stream (`expo/fetch` →
|
||||
// /v1/indexer/script/subscription/<id>) that never completes during the
|
||||
// test's lifetime, so every action would time out waiting for idle.
|
||||
//
|
||||
// Tell Detox to ignore that endpoint. The blacklist is process-scoped on
|
||||
// iOS, so we re-apply it after every launchApp.
|
||||
const URL_BLACKLIST = ['.*arkade\\.computer/v1/indexer/script/subscription.*', '.*groundcontrol-bluewallet\\.herokuapp\\.com.*'];
|
||||
|
||||
beforeAll(async () => {
|
||||
if (typeof device === 'undefined' || !device?.launchApp) return;
|
||||
|
||||
const originalLaunchApp = device.launchApp.bind(device);
|
||||
device.launchApp = async (...args) => {
|
||||
const result = await originalLaunchApp(...args);
|
||||
try {
|
||||
await device.setURLBlacklist(URL_BLACKLIST);
|
||||
} catch (e) {
|
||||
console.log('[detox-setup] setURLBlacklist after launchApp failed:', e?.message ?? e);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
// Detox auto-launches the app before the first beforeAll; cover that launch too.
|
||||
try {
|
||||
await device.setURLBlacklist(URL_BLACKLIST);
|
||||
} catch (e) {
|
||||
console.log('[detox-setup] initial setURLBlacklist failed:', e?.message ?? e);
|
||||
}
|
||||
});
|
||||
@ -1,119 +0,0 @@
|
||||
/**
|
||||
* Reusable test helpers around the Arkade Realm + Keychain Jest mocks installed
|
||||
* in tests/setup.js. The mock factories themselves must live in setup.js
|
||||
* because Jest hoists `jest.mock()` above module scope and refuses out-of-scope
|
||||
* captures, but each individual test still needs to reset the mock state and
|
||||
* inspect or seed it. Centralising those calls here avoids per-test
|
||||
* boilerplate and keeps the shape stable as the harness grows.
|
||||
*
|
||||
* Three sets of adjacent module-private caches need resetting between tests:
|
||||
* - The Realm adapter's `realmInstances` / `openInFlight` (closed via
|
||||
* closeAllArkadeRealms + the __testing__ accessor).
|
||||
* - The wallet module's `staticWalletCache`, `staticSwapsCache`,
|
||||
* `initInFlight`, `boardingLock` (exposed via wallet `__testing__`).
|
||||
* - The mock backing stores in setup.js (Realm files-on-disk, Keychain
|
||||
* credential map, FS existence set).
|
||||
* Without all three, a test that opens a Realm leaks a closed instance into
|
||||
* the next test, which then sees a ghost cached entry that fails the
|
||||
* `isClosed` short-circuit asynchronously.
|
||||
*/
|
||||
|
||||
import { closeAllArkadeRealms, __testing__ as realmTesting } from '../../blue_modules/arkade-adapters/realm/realmInstance';
|
||||
import { LightningArkWallet, __testing__ as walletTesting } from '../../class/wallets/lightning-ark-wallet';
|
||||
|
||||
const Realm = require('realm');
|
||||
|
||||
const Keychain = require('react-native-keychain');
|
||||
|
||||
const RNFS = require('react-native-fs');
|
||||
|
||||
/**
|
||||
* Reset every piece of mock state the Arkade test harness depends on:
|
||||
* - mocked Realm files-on-disk + open instances
|
||||
* - mocked Keychain credential store
|
||||
* - mocked react-native-fs existence set
|
||||
* - LightningArkWallet's process-wide caches and in-flight init promises
|
||||
*
|
||||
* Call from `beforeEach`.
|
||||
*/
|
||||
export function resetArkadeTestState(): void {
|
||||
// Drop adapter-level Realm instance refs first. closeAllArkadeRealms walks
|
||||
// realmInstances and closes each, which would no-op against the mock but
|
||||
// also removes them from the map. openInFlight isn't touched by the close
|
||||
// helpers (it self-clears in the success/error path) so we clear it
|
||||
// explicitly to drop any test-leaked promise.
|
||||
closeAllArkadeRealms();
|
||||
realmTesting.openInFlight.clear();
|
||||
|
||||
Realm.__mockRealmHelpers.reset();
|
||||
Keychain.__mockKeychainHelpers.reset();
|
||||
RNFS.__mockFsHelpers.reset();
|
||||
|
||||
for (const k of Object.keys(walletTesting.staticWalletCache)) delete walletTesting.staticWalletCache[k];
|
||||
for (const k of Object.keys(walletTesting.staticSwapsCache)) delete walletTesting.staticSwapsCache[k];
|
||||
walletTesting.initInFlight.clear();
|
||||
walletTesting.restoreInFlight.clear();
|
||||
for (const k of Object.keys(walletTesting.boardingLock)) delete walletTesting.boardingLock[k];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all `mock.calls` history on the spies the Ark tests inspect.
|
||||
* Useful when a test wants to assert call counts after a setup phase that
|
||||
* also exercised the mocks.
|
||||
*/
|
||||
export function clearArkadeMockCallHistory(): void {
|
||||
Realm.open.mockClear();
|
||||
Realm.exists.mockClear();
|
||||
Realm.deleteFile.mockClear();
|
||||
Keychain.setGenericPassword.mockClear();
|
||||
Keychain.getGenericPassword.mockClear();
|
||||
Keychain.resetGenericPassword.mockClear();
|
||||
Keychain.getSecurityLevel.mockClear();
|
||||
}
|
||||
|
||||
/** Direct accessors for tests that need to inspect/seed mock state. */
|
||||
export const arkadeMockState = {
|
||||
realmFiles: () => Realm.__mockRealmHelpers.files as Set<string>,
|
||||
realmInstances: () => Realm.__mockRealmHelpers.store as Map<string, unknown>,
|
||||
keychainStore: () => Keychain.__mockKeychainHelpers.store as Map<string, { username: string; password: string; service: string }>,
|
||||
/** Seed a Keychain entry directly, e.g. to simulate a leaked-from-previous-run state. */
|
||||
seedKeychain(service: string, password: string): void {
|
||||
Keychain.__mockKeychainHelpers.store.set(service, { username: service, password, service });
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Tear down a LightningArkWallet after integration tests. Stops SDK background
|
||||
* loops (ContractWatcher SSE, VtxoManager polling, SwapManager) via dispose()
|
||||
* before clearing module-private caches.
|
||||
*/
|
||||
export async function teardownArkadeWallet(w: LightningArkWallet): Promise<void> {
|
||||
try {
|
||||
await w.onDelete();
|
||||
} catch {
|
||||
// onDelete already logs and swallows per-namespace errors.
|
||||
}
|
||||
}
|
||||
|
||||
/** Best-effort dispose of any Arkade SDK runtime still cached module-wide. */
|
||||
export async function disposeAllArkadeRuntime(): Promise<void> {
|
||||
for (const ns of Object.keys(walletTesting.staticSwapsCache)) {
|
||||
const swaps = walletTesting.staticSwapsCache[ns];
|
||||
try {
|
||||
if (typeof swaps?.dispose === 'function') await swaps.dispose();
|
||||
} catch {}
|
||||
delete walletTesting.staticSwapsCache[ns];
|
||||
}
|
||||
for (const ns of Object.keys(walletTesting.staticWalletCache)) {
|
||||
const sdkWallet = walletTesting.staticWalletCache[ns];
|
||||
try {
|
||||
if (typeof sdkWallet?.dispose === 'function') await sdkWallet.dispose();
|
||||
} catch {}
|
||||
delete walletTesting.staticWalletCache[ns];
|
||||
}
|
||||
walletTesting.initInFlight.clear();
|
||||
walletTesting.restoreInFlight.clear();
|
||||
for (const k of Object.keys(walletTesting.boardingLock)) delete walletTesting.boardingLock[k];
|
||||
closeAllArkadeRealms();
|
||||
realmTesting.openInFlight.clear();
|
||||
}
|
||||
@ -1,134 +0,0 @@
|
||||
/**
|
||||
* Spy installers for the Arkade SDK / Boltz provider classes that
|
||||
* `LightningArkWallet.init()` reaches over the network for.
|
||||
*
|
||||
* Tests call `installSdkProviderSpies()` in `beforeEach` and
|
||||
* `restoreSdkProviderSpies()` in `afterEach`. The spies stub the methods
|
||||
* `Wallet.create` calls during init (`getInfo`, `getDelegateInfo`) plus the
|
||||
* Boltz fee/limit lookups invoked by `_fetchLightningFeesAndLimits`. With
|
||||
* these in place `init()` runs offline and the wallet's address derivation
|
||||
* is fully deterministic.
|
||||
*
|
||||
* We spy on `RestArkProvider.prototype` rather than `ExpoArkProvider.prototype`
|
||||
* because Expo* extend Rest* — installing the stub on the parent prototype
|
||||
* covers both.
|
||||
*/
|
||||
|
||||
import { ContractManager, RestArkProvider, RestDelegatorProvider, VtxoManager } from '@arkade-os/sdk';
|
||||
import { BoltzSwapProvider, SwapManager } from '@arkade-os/boltz-swap';
|
||||
|
||||
/** Snapshot of `https://arkade.computer/v1/info` for offline tests. */
|
||||
export const FAKE_ASP_INFO = {
|
||||
signerPubkey: '022b74c2011af089c849383ee527c72325de52df6a788428b68d49e9174053aaba',
|
||||
forfeitPubkey: '03b43a8363118c084a04d4f6a50ebfa58e81957f8cceceb2aee0ab64c9fd2d9977',
|
||||
forfeitAddress: 'bc1qzzdzp5c443vsetzatf2ra6hku322y7e5aq50rs',
|
||||
checkpointTapscript: '039e0440b27520b43a8363118c084a04d4f6a50ebfa58e81957f8cceceb2aee0ab64c9fd2d9977ac',
|
||||
network: 'bitcoin' as const,
|
||||
sessionDuration: 60,
|
||||
unilateralExitDelay: 605184,
|
||||
boardingExitDelay: 7776256,
|
||||
utxoMinAmount: 330,
|
||||
utxoMaxAmount: -1,
|
||||
vtxoMinAmount: 1,
|
||||
vtxoMaxAmount: -1,
|
||||
dust: 330,
|
||||
fees: {
|
||||
intentFee: { offchainInput: '', offchainOutput: '', onchainInput: '', onchainOutput: '200.0' },
|
||||
txFeeRate: 0,
|
||||
},
|
||||
scheduledSession: null,
|
||||
deprecatedSigners: [],
|
||||
serviceStatus: {},
|
||||
digest: 'test-digest',
|
||||
maxTxWeight: 40000,
|
||||
maxOpReturnOutputs: 2,
|
||||
};
|
||||
|
||||
/**
|
||||
* Test-only delegate pubkey. Does not need to match the production delegator
|
||||
* — the derivation test pins the wallet's algorithm, not the production
|
||||
* service. The value is the secp256k1 generator G in 33-byte compressed form
|
||||
* (private key = 1) so it is always on-curve and the SDK's taproot validation
|
||||
* accepts it.
|
||||
*/
|
||||
export const FAKE_DELEGATE_PUBKEY = '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798';
|
||||
|
||||
export const FAKE_DELEGATE_INFO = {
|
||||
pubkey: FAKE_DELEGATE_PUBKEY,
|
||||
fee: '0',
|
||||
delegatorAddress: 'bc1qzzdzp5c443vsetzatf2ra6hku322y7e5aq50rs',
|
||||
};
|
||||
|
||||
export const FAKE_BOLTZ_FEES = {
|
||||
reverse: { percentage: 0.5, minerFees: 0 },
|
||||
submarine: { percentage: 0.1, minerFees: 0 },
|
||||
};
|
||||
|
||||
export const FAKE_BOLTZ_LIMITS = {
|
||||
min: 1000,
|
||||
max: 1_000_000,
|
||||
};
|
||||
|
||||
/**
|
||||
* Install Jest spies on the SDK provider prototypes so init() runs offline.
|
||||
* Returns nothing; cleanup happens via `restoreSdkProviderSpies()`.
|
||||
*/
|
||||
export function installSdkProviderSpies(): void {
|
||||
jest.spyOn(RestArkProvider.prototype, 'getInfo').mockResolvedValue(FAKE_ASP_INFO as any);
|
||||
jest.spyOn(RestDelegatorProvider.prototype, 'getDelegateInfo').mockResolvedValue(FAKE_DELEGATE_INFO as any);
|
||||
jest.spyOn(BoltzSwapProvider.prototype, 'getFees').mockResolvedValue(FAKE_BOLTZ_FEES as any);
|
||||
jest.spyOn(BoltzSwapProvider.prototype, 'getLimits').mockResolvedValue(FAKE_BOLTZ_LIMITS as any);
|
||||
|
||||
// VtxoManager auto-runs `initializeSubscription()` from its constructor,
|
||||
// which schedules a setTimeout polling loop AND awaits getContractManager
|
||||
// (which opens a ContractWatcher SSE subscription via subscribeForScripts).
|
||||
// Neither shuts down without a `dispose()` call, so a Jest worker that
|
||||
// runs Wallet.create through to completion hangs after the test asserts.
|
||||
// Stub the entry point to a resolved no-op; the wallet's address-derivation
|
||||
// path doesn't need either side effect.
|
||||
jest.spyOn(VtxoManager.prototype as any, 'initializeSubscription').mockResolvedValue(undefined);
|
||||
|
||||
// ArkadeSwaps auto-starts SwapManager in its constructor (autoStart defaults
|
||||
// to true). SwapManager.start() calls tryConnectWebSocket(), which opens a
|
||||
// real OS WebSocket. On failure it enters startPollingFallback(), a recursive
|
||||
// setTimeout loop that keeps the Node.js event loop alive indefinitely and
|
||||
// prevents Jest from exiting after the test completes.
|
||||
jest.spyOn(SwapManager.prototype as any, 'start').mockResolvedValue(undefined);
|
||||
|
||||
// Any code path that calls `wallet.getContractManager()` lazily constructs
|
||||
// a ContractManager whose `initialize()` opens a ContractWatcher SSE stream
|
||||
// (via `indexerProvider.getSubscription`) and runs a delta sync against the
|
||||
// indexer. Both leave handles or pending fetches that block Jest from
|
||||
// exiting. Unit tests that only exercise address derivation never trigger
|
||||
// this, but tests that call `fetchBalance` / `fetchTransactions` /
|
||||
// `getTransactionHistory` after init do. Stub initialize to a no-op so the
|
||||
// manager exists with no watched contracts — the manager-querying methods
|
||||
// then return empty results without touching the network.
|
||||
jest.spyOn(ContractManager.prototype as any, 'initialize').mockResolvedValue(undefined);
|
||||
}
|
||||
|
||||
export function restoreSdkProviderSpies(): void {
|
||||
jest.restoreAllMocks();
|
||||
}
|
||||
|
||||
let backgroundLoopSpies: jest.SpiedFunction<any>[] = [];
|
||||
|
||||
export function restoreSdkBackgroundLoopStubs(): void {
|
||||
for (const spy of backgroundLoopSpies) spy.mockRestore();
|
||||
backgroundLoopSpies = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Stub only the SDK background subscriptions that Jest cannot shut down
|
||||
* cleanly (VtxoManager polling, SwapManager WebSocket, ContractWatcher SSE).
|
||||
* Real HTTP calls (getInfo, getTransactionHistory, restoreSwaps, etc.) still
|
||||
* run — use in env-gated integration tests that hit production services.
|
||||
*/
|
||||
export function installSdkBackgroundLoopStubs(): void {
|
||||
restoreSdkBackgroundLoopStubs();
|
||||
backgroundLoopSpies = [
|
||||
jest.spyOn(VtxoManager.prototype as any, 'initializeSubscription').mockResolvedValue(undefined),
|
||||
jest.spyOn(SwapManager.prototype as any, 'start').mockResolvedValue(undefined),
|
||||
jest.spyOn(ContractManager.prototype as any, 'initialize').mockResolvedValue(undefined),
|
||||
];
|
||||
}
|
||||
@ -100,7 +100,7 @@ describe('BlueElectrum', () => {
|
||||
assert.ok(!(await BlueElectrum.testConnection('joyreactor.cc', 80, false)));
|
||||
assert.ok(!(await BlueElectrum.testConnection('joyreactor.cc', false, 80)));
|
||||
|
||||
assert.ok(await BlueElectrum.testConnection('mainnet.foundationdevices.com', false, 50002));
|
||||
assert.ok(await BlueElectrum.testConnection('electrum1.bluewallet.io', '50001'));
|
||||
assert.ok(await BlueElectrum.testConnection('electrum1.bluewallet.io', false, 443));
|
||||
});
|
||||
|
||||
|
||||
@ -12,6 +12,9 @@ const hardcodedPeers = [
|
||||
{ host: 'electrum1.bluewallet.io', ssl: '443' },
|
||||
{ host: 'electrum2.bluewallet.io', ssl: '443' },
|
||||
{ host: 'electrum3.bluewallet.io', ssl: '443' },
|
||||
{ host: 'electrum1.bluewallet.io', tcp: '50001' },
|
||||
{ host: 'electrum2.bluewallet.io', tcp: '50001' },
|
||||
{ host: 'electrum3.bluewallet.io', tcp: '50001' },
|
||||
];
|
||||
|
||||
function bitcoinjs_crypto_sha256(buffer /*: Buffer */) /*: Buffer */ {
|
||||
|
||||
@ -0,0 +1 @@
|
||||
[]
|
||||
@ -0,0 +1 @@
|
||||
[{"id":"H8b2stB9ASah","type":"reverse","createdAt":1761224952,"preimage":"7244f7e956a91171038ea935d56cdb758cc36c345f0aa92764bfed6fe6fc9b17","request":{"invoiceAmount":10000,"claimPublicKey":"024a181382f8fa50a736a72b3f63ad3054e288cd5af2fc9a363304e4fb1a356c35","preimageHash":"1dc954a20f4a551ec33832ccb289988f433784f7f24d093db4b7a513154275fe","description":"test invoice"},"response":{"id":"H8b2stB9ASah","lockupAddress":"ark1qq4hfssprtcgnjzf8qlw2f78yvjau5kldfugg29k34y7j96q2w4t4sux5cq6zdxfmlzkm06epkuqetmaspl4zleptjm4xskhf6nxsu5jmrufq3","refundPublicKey":"03bb59af95b370f0a6133d92ee543e64b7763b301c22e47cdb8dbb76baa998581d","timeoutBlockHeights":{"refund":1761828489,"unilateralClaim":9728,"unilateralRefund":606208,"unilateralRefundWithoutReceiver":606208},"invoice":"lnbc100u1p50528cpp5rhy4fgs0ff23asecxtxt9zvc3apn0p8h7fxsj0d5k7j3x92zwhlqdq5w3jhxapqd9h8vmmfvdjscqrp80xqyf8ucsp5vcsrzye432n9wh0zwuv5z8y5n9zvkwpctr685e80utzc2yueccms9qxpqysgqd87swq3hput9k6llp0wxg098hc7ge3e5nrtnvak6zreywzaf4k9s8d3u4hrmt3m22kf0jt7ruqj0caknk5ykzdenjdphz50t7xrstnqqn6aw0m","onchainAmount":9999},"status":"invoice.settled"}]
|
||||
@ -0,0 +1 @@
|
||||
[{"id":"6c2o1R1KBX3Y","type":"submarine","createdAt":1761225645,"request":{"invoice":"lnbc80u1p5052hwpp5z4ln6hyq4wcck809pt7f0q54ag5he6ce797flm7gl9vuccm9lx2sdqqcqzysxqyz5vqsp5nh9fl4g36606tvxswtnfxzy55yze2656cw2fya7dhl8r6u0czyds9qxpqysgq83sw25g9d9ltr05nkfzejnvvunzkrk4qeuxhszuvvsguk5m6vmg3a7n5nd67l9frru3kjzpt8x6jfusjyc7ezh49jeeh900kt3v30qsqzq7fst","refundPublicKey":"024a181382f8fa50a736a72b3f63ad3054e288cd5af2fc9a363304e4fb1a356c35"},"response":{"acceptZeroConf":true,"expectedAmount":8001,"id":"6c2o1R1KBX3Y","address":"ark1qq4hfssprtcgnjzf8qlw2f78yvjau5kldfugg29k34y7j96q2w4t4sedhdvfcgaky2qk2p55wj4ut38v9tnpuvjr8ee8hv6htp23pzjpwx5esw","claimPublicKey":"03bb59af95b370f0a6133d92ee543e64b7763b301c22e47cdb8dbb76baa998581d","timeoutBlockHeights":{"refund":1762427602,"unilateralClaim":9728,"unilateralRefund":1205248,"unilateralRefundWithoutReceiver":1205248}},"status":"transaction.claimed","preimage":"182fb8f273bda01b22c0e91991e093e18b2970f389fc7f7a2121870324eb2de5"}]
|
||||
@ -0,0 +1 @@
|
||||
[]
|
||||
@ -0,0 +1 @@
|
||||
[{"txid":"3bc61895d41759789465730c4e26e260950bb77b5f3a06870547418a0340444e","vout":0,"value":9999,"status":{"confirmed":false},"virtualStatus":{"state":"preconfirmed","commitmentTxIds":["3a74555034c7f3c8053d0b30441178630dd98f645d9ed42aa9425fdc2279e159"],"batchExpiry":1763227538000},"spentBy":"","settledBy":"","arkTxId":"","createdAt":"2025-10-23T13:12:57.000Z","isUnrolled":false,"isSpent":false,"forfeitTapLeafScript":{"cb":"c150929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0add97e531438e619e16a16c17f3389d06568c45f6030bcd28b991ea57c53bb9a","s":"204a181382f8fa50a736a72b3f63ad3054e288cd5af2fc9a363304e4fb1a356c35ad202b74c2011af089c849383ee527c72325de52df6a788428b68d49e9174053aabaacc0"},"intentTapLeafScript":{"cb":"c150929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0ae1f11d2405ccce6de489e4a350add214e8bf14f3e139aa52756615402933e72","s":"039e0440b275204a181382f8fa50a736a72b3f63ad3054e288cd5af2fc9a363304e4fb1a356c35acc0"},"tapTree":"01c044204a181382f8fa50a736a72b3f63ad3054e288cd5af2fc9a363304e4fb1a356c35ad202b74c2011af089c849383ee527c72325de52df6a788428b68d49e9174053aabaac01c028039e0440b275204a181382f8fa50a736a72b3f63ad3054e288cd5af2fc9a363304e4fb1a356c35ac"},{"txid":"3343712495c9745c2344ea4c7311bd3d697463e26c86c9fa2799b7e6dfc3a0d9","vout":1,"value":1998,"status":{"confirmed":false},"virtualStatus":{"state":"preconfirmed","commitmentTxIds":["3a74555034c7f3c8053d0b30441178630dd98f645d9ed42aa9425fdc2279e159"],"batchExpiry":1763227538000},"spentBy":"","settledBy":"","arkTxId":"","createdAt":"2025-10-23T13:20:46.000Z","isUnrolled":false,"isSpent":false,"forfeitTapLeafScript":{"cb":"c150929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0add97e531438e619e16a16c17f3389d06568c45f6030bcd28b991ea57c53bb9a","s":"204a181382f8fa50a736a72b3f63ad3054e288cd5af2fc9a363304e4fb1a356c35ad202b74c2011af089c849383ee527c72325de52df6a788428b68d49e9174053aabaacc0"},"intentTapLeafScript":{"cb":"c150929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0ae1f11d2405ccce6de489e4a350add214e8bf14f3e139aa52756615402933e72","s":"039e0440b275204a181382f8fa50a736a72b3f63ad3054e288cd5af2fc9a363304e4fb1a356c35acc0"},"tapTree":"01c044204a181382f8fa50a736a72b3f63ad3054e288cd5af2fc9a363304e4fb1a356c35ad202b74c2011af089c849383ee527c72325de52df6a788428b68d49e9174053aabaac01c028039e0440b275204a181382f8fa50a736a72b3f63ad3054e288cd5af2fc9a363304e4fb1a356c35ac"}]
|
||||
@ -12,7 +12,6 @@ import { HDSegwitP2SHWallet } from '../../class/wallets/hd-segwit-p2sh-wallet';
|
||||
import { HDTaprootWallet } from '../../class/wallets/hd-taproot-wallet';
|
||||
import { LegacyWallet } from '../../class/wallets/legacy-wallet';
|
||||
import { LightningArkWallet } from '../../class/wallets/lightning-ark-wallet';
|
||||
import { installSdkProviderSpies, restoreSdkProviderSpies } from '../helpers/sdkProviderMocks';
|
||||
import { SegwitBech32Wallet } from '../../class/wallets/segwit-bech32-wallet';
|
||||
import { SegwitP2SHWallet } from '../../class/wallets/segwit-p2sh-wallet';
|
||||
import { SLIP39SegwitBech32Wallet, SLIP39SegwitP2SHWallet } from '../../class/wallets/slip39-wallets';
|
||||
@ -696,33 +695,23 @@ describe('import procedure', () => {
|
||||
// not checking other 2 wallets
|
||||
});
|
||||
|
||||
// Stub the Arkade SDK network providers so the import-time `ark.init()` does
|
||||
// not open real SSE/WebSocket subscriptions. Without these, Wallet.create
|
||||
// brings up VtxoManager + SwapManager — both keep the Node event loop alive
|
||||
// and force jest to hang at the end of the run. See `tests/helpers/
|
||||
// sdkProviderMocks.ts` for the rationale.
|
||||
describe('lightning ark', () => {
|
||||
beforeEach(() => installSdkProviderSpies());
|
||||
afterEach(() => restoreSdkProviderSpies());
|
||||
it('can import lightning ark wallet', async () => {
|
||||
const store = createStore();
|
||||
const { promise } = startImport(
|
||||
'arkade://abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
...store.callbacks,
|
||||
);
|
||||
await promise;
|
||||
|
||||
it('can import lightning ark wallet', async () => {
|
||||
const store = createStore();
|
||||
const { promise } = startImport(
|
||||
'arkade://abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
...store.callbacks,
|
||||
);
|
||||
await promise;
|
||||
|
||||
assert.strictEqual(store.state.wallets.length, 1);
|
||||
assert.strictEqual(store.state.wallets[0].type, LightningArkWallet.type);
|
||||
assert.strictEqual(
|
||||
store.state.wallets[0].getSecret(),
|
||||
'arkade://abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
|
||||
);
|
||||
});
|
||||
assert.strictEqual(store.state.wallets.length, 1);
|
||||
assert.strictEqual(store.state.wallets[0].type, LightningArkWallet.type);
|
||||
assert.strictEqual(
|
||||
store.state.wallets[0].getSecret(),
|
||||
'arkade://abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
|
||||
);
|
||||
});
|
||||
|
||||
it('can import private key in hex format', async () => {
|
||||
|
||||
@ -1,57 +1,137 @@
|
||||
import assert from 'assert';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { HDSegwitBech32Wallet } from '../../class/wallets/hd-segwit-bech32-wallet';
|
||||
import { LightningArkWallet } from '../../class/wallets/lightning-ark-wallet.ts';
|
||||
import { disposeAllArkadeRuntime, teardownArkadeWallet } from '../helpers/arkadeMocks';
|
||||
import { installSdkBackgroundLoopStubs, restoreSdkBackgroundLoopStubs } from '../helpers/sdkProviderMocks';
|
||||
|
||||
// Ark storage lives in Realm, not AsyncStorage. Realm + Keychain are mocked
|
||||
// globally by tests/setup.js (per-path Realm + service-keyed Keychain), and
|
||||
// pure unit-level coverage lives in tests/unit/lightning-ark-wallet.test.ts
|
||||
// and tests/unit/lightning-ark-derivation.test.ts. What remains here are the
|
||||
// env-gated tests that exercise the real init pipeline against the
|
||||
// production ASP / delegator using a real mnemonic.
|
||||
// Mock AsyncStorage using fs in tests/integration/fixtures/ark/
|
||||
jest.mock('@react-native-async-storage/async-storage', () => {
|
||||
const STORAGE_DIR = path.join(__dirname, 'fixtures', 'ark');
|
||||
|
||||
// Ensure storage directory exists
|
||||
if (!fs.existsSync(STORAGE_DIR)) {
|
||||
fs.mkdirSync(STORAGE_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
const getFilePath = (key: string) => {
|
||||
const sanitizedKey = key.replace(/[^a-zA-Z0-9]/g, '_');
|
||||
return path.join(STORAGE_DIR, sanitizedKey);
|
||||
};
|
||||
|
||||
async function _multiSet(keyValuePairs: [string, string][], callback?: any) {
|
||||
keyValuePairs.forEach(keyValue => {
|
||||
const key = keyValue[0];
|
||||
const value = keyValue[1];
|
||||
const filePath = getFilePath(key);
|
||||
fs.writeFileSync(filePath, value, 'utf8');
|
||||
});
|
||||
callback && callback(null);
|
||||
return null;
|
||||
}
|
||||
|
||||
async function _multiGet(keys: string[], callback?: any) {
|
||||
const values = keys.map(key => {
|
||||
const filePath = getFilePath(key);
|
||||
let value = null;
|
||||
try {
|
||||
if (fs.existsSync(filePath)) {
|
||||
value = fs.readFileSync(filePath, 'utf8');
|
||||
}
|
||||
} catch (error) {
|
||||
// ignore
|
||||
}
|
||||
return [key, value];
|
||||
});
|
||||
callback && callback(null, values);
|
||||
return values;
|
||||
}
|
||||
|
||||
async function _multiRemove(keys: string[], callback?: any) {
|
||||
keys.forEach(key => {
|
||||
const filePath = getFilePath(key);
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
}
|
||||
});
|
||||
callback && callback(null);
|
||||
return null;
|
||||
}
|
||||
|
||||
async function _clear(callback?: any) {
|
||||
if (fs.existsSync(STORAGE_DIR)) {
|
||||
const files = fs.readdirSync(STORAGE_DIR);
|
||||
for (const file of files) {
|
||||
fs.unlinkSync(path.join(STORAGE_DIR, file));
|
||||
}
|
||||
}
|
||||
callback && callback(null);
|
||||
return null;
|
||||
}
|
||||
|
||||
async function _getAllKeys() {
|
||||
if (!fs.existsSync(STORAGE_DIR)) {
|
||||
return [];
|
||||
}
|
||||
return fs.readdirSync(STORAGE_DIR);
|
||||
}
|
||||
|
||||
const asMock: any = {
|
||||
setItem: jest.fn(async (key: string, value: string, callback?: any) => {
|
||||
const setResult = await asMock.multiSet([[key, value]], undefined);
|
||||
callback && callback(setResult);
|
||||
return setResult;
|
||||
}),
|
||||
|
||||
getItem: jest.fn(async (key: string, callback?: any) => {
|
||||
const getResult = await asMock.multiGet([key], undefined);
|
||||
const result = getResult[0] ? getResult[0][1] : null;
|
||||
callback && callback(null, result);
|
||||
return result;
|
||||
}),
|
||||
|
||||
removeItem: jest.fn((key: string, callback?: any) => asMock.multiRemove([key], callback)),
|
||||
|
||||
clear: jest.fn(_clear),
|
||||
getAllKeys: jest.fn(_getAllKeys),
|
||||
flushGetRequests: jest.fn(),
|
||||
|
||||
multiGet: jest.fn(_multiGet),
|
||||
multiSet: jest.fn(_multiSet),
|
||||
multiRemove: jest.fn(_multiRemove),
|
||||
};
|
||||
|
||||
return asMock;
|
||||
});
|
||||
|
||||
jest.setTimeout(30_000);
|
||||
|
||||
const w = new LightningArkWallet();
|
||||
|
||||
beforeAll(async () => {
|
||||
// Install before the env guard: `can generate` runs init() regardless of
|
||||
// HD_MNEMONIC_OLD, and without the stubs its background loops keep Jest alive.
|
||||
installSdkBackgroundLoopStubs();
|
||||
if (!process.env.HD_MNEMONIC_OLD) {
|
||||
console.error('process.env.HD_MNEMONIC_OLD not set, skipped');
|
||||
return;
|
||||
}
|
||||
w.setSecret('arkade://' + process.env.HD_MNEMONIC_OLD);
|
||||
await w.init();
|
||||
await w.restoreSwaps();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (process.env.HD_MNEMONIC_OLD) {
|
||||
await teardownArkadeWallet(w);
|
||||
}
|
||||
await disposeAllArkadeRuntime();
|
||||
restoreSdkBackgroundLoopStubs();
|
||||
await new Promise(resolve => setTimeout(resolve, 3_000)); // sleep
|
||||
});
|
||||
|
||||
describe('LightningArkWallet (integration)', () => {
|
||||
describe('LightningArkWallet', () => {
|
||||
it('can generate', async () => {
|
||||
const wGenerated = new LightningArkWallet();
|
||||
try {
|
||||
await wGenerated.generate();
|
||||
await wGenerated.generate();
|
||||
|
||||
assert.ok(wGenerated.getSecret().startsWith('arkade://'));
|
||||
assert.ok(wGenerated.getSecret().startsWith('arkade://'));
|
||||
|
||||
const mnemonics = wGenerated.getSecret().replace('arkade://', '');
|
||||
const hd = new HDSegwitBech32Wallet();
|
||||
hd.setSecret(mnemonics);
|
||||
assert.ok(hd.validateMnemonic());
|
||||
} finally {
|
||||
await teardownArkadeWallet(wGenerated);
|
||||
}
|
||||
const mnemonics = wGenerated.getSecret().replace('arkade://', '');
|
||||
const hd = new HDSegwitBech32Wallet();
|
||||
hd.setSecret(mnemonics);
|
||||
assert.ok(hd.validateMnemonic());
|
||||
});
|
||||
|
||||
it('can fetch balance', async () => {
|
||||
@ -66,6 +146,32 @@ describe('LightningArkWallet (integration)', () => {
|
||||
assert.ok(balance > 0);
|
||||
});
|
||||
|
||||
it('can decode invoice', async () => {
|
||||
const invoice =
|
||||
'lnbc20n1p59n9nkpp58s49flel3cz5u3lrve8qeqzxljxmu0gja06elfcgwrx2e9nq959ssp5z7ytwq0rm6yq8evn2kteduj6a0rs4svn3sfwvg92a29f8l022jjqxq9z0rgqnp4qvyndeaqzman7h898jxm98dzkm0mlrsx36s93smrur7h0azyyuxc5rzjq25carzepgd4vqsyn44jrk85ezrpju92xyrk9apw4cdjh6yrwt5jgqqqqrt49lmtcqqqqqqqqqqq86qq9qrzjqwghf7zxvfkxq5a6sr65g0gdkv768p83mhsnt0msszapamzx2qvuxqqqqrt49lmtcqqqqqqqqqqq86qq9qcqzpgdq023mk7gryv9uhxgq9qyyssqy4mv8te3l6mrc7qf4pksh4m4z76jz7s2qrwxd7q2s22ghnanqt33e9p0nahz9fr32g00vn2vhc9rrhpvtr54s40tle25tyyvp59sdpsqty30rp';
|
||||
|
||||
const decoded = w.decodeInvoice(invoice);
|
||||
|
||||
assert.strictEqual(decoded.num_satoshis, 2);
|
||||
assert.strictEqual(decoded.num_millisatoshis, 2000);
|
||||
assert.strictEqual(decoded.timestamp, 1750701686);
|
||||
assert.strictEqual(decoded.expiry, 2592000);
|
||||
assert.strictEqual(decoded.description, 'Two days ');
|
||||
assert.strictEqual(decoded.payment_hash, '3c2a54ff3f8e054e47e3664e0c8046fc8dbe3d12ebf59fa70870ccac96602d0b');
|
||||
assert.strictEqual(decoded.destination, '030936e7a016fb3f5ce53c8db29da2b6dfbf8e068ea058c363e0fd77f444270d8a');
|
||||
assert.strictEqual(decoded.fallback_addr, '');
|
||||
assert.strictEqual(decoded.description_hash, '');
|
||||
assert.strictEqual(decoded.cltv_expiry, '40');
|
||||
assert.strictEqual(decoded.route_hints.length, 0); // decode function does not decode this yet cause we dont need it for now
|
||||
});
|
||||
|
||||
it('can tell if invoice expired', async () => {
|
||||
const invoice =
|
||||
'lnbc6670n1p5jp0p9pp5jmyumdwfejjxzwhxh7wnckeugcwcpkqtf5t6dh2fzykjjh4hkatqdq6235x2grhdaexggrs09exzmtfvscqz3txqyyzzssp5ae74xvmlk5q6vxsxe3sqm90w2x4x0ekejt7qp9ca5zzhu83ru8hq9qxpqysgql4dexpmwacw98va6v6smww69a3w6hs5ng0573v8skyhlj7lylt8r65jm5zqaa7hzx3vlrs2fr3h0rtqjw7x94xprdwqy6rr9ff5pnxsppnpr5q';
|
||||
assert.strictEqual(w.isInvoiceExpired(invoice), true);
|
||||
assert.strictEqual(w.isInvoiceExpired(invoice, 1763752997), false);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line jest/no-disabled-tests
|
||||
it.skip('can create invoice', async () => {
|
||||
if (!process.env.HD_MNEMONIC_OLD) {
|
||||
@ -84,48 +190,47 @@ describe('LightningArkWallet (integration)', () => {
|
||||
}
|
||||
|
||||
await w.fetchTransactions();
|
||||
await w.fetchUserInvoices();
|
||||
|
||||
const txs = w.getTransactions();
|
||||
assert.ok(txs.length > 0, 'Should have transaction history from the Ark indexer');
|
||||
assert.ok(txs.length > 0);
|
||||
|
||||
// Find the reverse swap (incoming) transaction
|
||||
const receiveTx = txs.find(t => t.value! > 0);
|
||||
assert.ok(receiveTx, 'Should have at least one receive transaction');
|
||||
assert.ok(receiveTx.value! > 0);
|
||||
assert.ok(receiveTx.timestamp! > 0);
|
||||
assert.ok(receiveTx.memo);
|
||||
assert.strictEqual(receiveTx.memo, 'test invoice');
|
||||
assert.strictEqual(receiveTx.value, 9999);
|
||||
assert.strictEqual(receiveTx.timestamp, 1761224952);
|
||||
assert.strictEqual(receiveTx.ispaid, true);
|
||||
assert.ok(receiveTx.payment_hash);
|
||||
assert.ok(receiveTx.payment_request);
|
||||
assert.strictEqual(receiveTx.payment_preimage, '7244f7e956a91171038ea935d56cdb758cc36c345f0aa92764bfed6fe6fc9b17');
|
||||
|
||||
const swapHistory: any[] = (w as any)._swapHistory ?? [];
|
||||
const settledReverse = swapHistory.find(s => s.type === 'reverse' && s.status === 'invoice.settled');
|
||||
if (settledReverse) {
|
||||
// When Boltz reverse-swap history is restored, settled receives are enriched in place.
|
||||
assert.strictEqual(receiveTx.ispaid, true);
|
||||
assert.ok(receiveTx.payment_hash);
|
||||
assert.ok(receiveTx.payment_request);
|
||||
assert.ok(receiveTx.payment_preimage);
|
||||
assert.notStrictEqual(receiveTx.memo, 'Received');
|
||||
|
||||
const ownInvoice = settledReverse.request?.invoice || settledReverse.response?.invoice;
|
||||
if (ownInvoice) {
|
||||
assert.ok(w.isInvoiceGeneratedByWallet(ownInvoice));
|
||||
}
|
||||
}
|
||||
|
||||
const settledSubmarine = swapHistory.find(s => s.type === 'submarine' && s.status === 'transaction.claimed');
|
||||
if (settledSubmarine) {
|
||||
const sendTx = txs.find(t => t.value! < 0);
|
||||
assert.ok(sendTx, 'Should have a send transaction when submarine swap history exists');
|
||||
assert.strictEqual(sendTx.ispaid, true);
|
||||
assert.ok(sendTx.payment_hash);
|
||||
assert.ok(sendTx.payment_request);
|
||||
assert.ok(sendTx.payment_preimage);
|
||||
}
|
||||
// Find the submarine swap (outgoing) transaction
|
||||
const sendTx = txs.find(t => t.value! < 0);
|
||||
assert.ok(sendTx, 'Should have at least one send transaction');
|
||||
assert.strictEqual(sendTx.value, -8001);
|
||||
assert.strictEqual(sendTx.timestamp, 1761225645);
|
||||
assert.strictEqual(sendTx.ispaid, true);
|
||||
assert.ok(sendTx.payment_hash);
|
||||
assert.ok(sendTx.payment_request);
|
||||
assert.strictEqual(sendTx.payment_preimage, '182fb8f273bda01b22c0e91991e093e18b2970f389fc7f7a2121870324eb2de5');
|
||||
|
||||
const invoices = await w.getUserInvoices();
|
||||
if (settledReverse) {
|
||||
assert.ok(invoices.length > 0);
|
||||
assert(invoices[0].value! > 0);
|
||||
assert(invoices[0].ispaid);
|
||||
}
|
||||
assert.ok(invoices.length > 0);
|
||||
assert(invoices[0].value! > 0);
|
||||
assert(invoices[0].ispaid);
|
||||
|
||||
assert.ok(
|
||||
w.isInvoiceGeneratedByWallet(
|
||||
'lnbc100u1p50528cpp5rhy4fgs0ff23asecxtxt9zvc3apn0p8h7fxsj0d5k7j3x92zwhlqdq5w3jhxapqd9h8vmmfvdjscqrp80xqyf8ucsp5vcsrzye432n9wh0zwuv5z8y5n9zvkwpctr685e80utzc2yueccms9qxpqysgqd87swq3hput9k6llp0wxg098hc7ge3e5nrtnvak6zreywzaf4k9s8d3u4hrmt3m22kf0jt7ruqj0caknk5ykzdenjdphz50t7xrstnqqn6aw0m',
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
!w.isInvoiceGeneratedByWallet(
|
||||
'lnbc80u1p5052hwpp5z4ln6hyq4wcck809pt7f0q54ag5he6ce797flm7gl9vuccm9lx2sdqqcqzysxqyz5vqsp5nh9fl4g36606tvxswtnfxzy55yze2656cw2fya7dhl8r6u0czyds9qxpqysgq83sw25g9d9ltr05nkfzejnvvunzkrk4qeuxhszuvvsguk5m6vmg3a7n5nd67l9frru3kjzpt8x6jfusjyc7ezh49jeeh900kt3v30qsqzq7fst',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line jest/no-disabled-tests
|
||||
@ -139,4 +244,33 @@ describe('LightningArkWallet (integration)', () => {
|
||||
'lnbc80u1p5052hwpp5z4ln6hyq4wcck809pt7f0q54ag5he6ce797flm7gl9vuccm9lx2sdqqcqzysxqyz5vqsp5nh9fl4g36606tvxswtnfxzy55yze2656cw2fya7dhl8r6u0czyds9qxpqysgq83sw25g9d9ltr05nkfzejnvvunzkrk4qeuxhszuvvsguk5m6vmg3a7n5nd67l9frru3kjzpt8x6jfusjyc7ezh49jeeh900kt3v30qsqzq7fst',
|
||||
);
|
||||
});
|
||||
|
||||
it('can validate ark native address', async () => {
|
||||
assert.ok(
|
||||
w.isAddressValid(
|
||||
'ark1qq4hfssprtcgnjzf8qlw2f78yvjau5kldfugg29k34y7j96q2w4t5z8sz5n95k570z5r004szc9h2q3qprkzdd5zveujdpx24srcrqg8hf6j4v',
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
w.isAddressValid(
|
||||
'ark1qqellv77udfmr20tun8dvju5vgudpf9vxe8jwhthrkn26fz96pawqfdy8nk05rsmrf8h94j26905e7n6sng8y059z8ykn2j5xcuw4xt8ngt9rw',
|
||||
),
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
!w.isAddressValid(
|
||||
'ark1qqellv77udfmr20tun8dvju5vgudpf9vxe8jwhthrkn26fz96pawqfdy8nk05rsmrf8h94j26905e7n6sng8y059z8ykn2j5xcuw4xt8ngt9r',
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
!w.isAddressValid('ark1qqellv77udfmr20tun8dvju5vgudpf9vxe8jwhthrkn26fz96pawqfdy8nk05rsmrf8h94j26905e7n6sng8y059z8ykn2j5xcuw4xt8ngt9'),
|
||||
);
|
||||
assert.ok(
|
||||
w.isAddressValid(
|
||||
'ark1qq4hfssprtcgnjzf8qlw2f78yvjau5kldfugg29k34y7j96q2w4t4sedhdvfcgaky2qk2p55wj4ut38v9tnpuvjr8ee8hv6htp23pzjpwx5esw',
|
||||
),
|
||||
);
|
||||
assert.ok(!w.isAddressValid('ark1sfhshhehehwer'));
|
||||
assert.ok(!w.isAddressValid('test'));
|
||||
});
|
||||
});
|
||||
|
||||
20
tests/integration/notifications.test.js
Normal file
20
tests/integration/notifications.test.js
Normal file
@ -0,0 +1,20 @@
|
||||
import assert from 'assert';
|
||||
import { isGroundControlUriValid } from '../../blue_modules/notifications';
|
||||
|
||||
// Notifications.default = new Notifications();
|
||||
|
||||
describe('notifications', () => {
|
||||
// yeah, lets rely less on external services...
|
||||
// eslint-disable-next-line jest/no-disabled-tests
|
||||
it.skip('can check groundcontrol server uri validity', async () => {
|
||||
assert.ok(await isGroundControlUriValid('https://groundcontrol-bluewallet.herokuapp.com'));
|
||||
assert.ok(!(await isGroundControlUriValid('https://www.google.com')));
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
});
|
||||
|
||||
// muted because it causes jest to hang waiting indefinitely
|
||||
// eslint-disable-next-line jest/no-disabled-tests
|
||||
it.skip('can check non-responding url', async () => {
|
||||
assert.ok(!(await isGroundControlUriValid('https://localhost.com')));
|
||||
});
|
||||
});
|
||||
387
tests/setup.js
387
tests/setup.js
@ -7,8 +7,7 @@ console.warn = (...args) => {
|
||||
if (
|
||||
typeof args[0] === 'string' &&
|
||||
(args[0].startsWith('WARNING: Sending to a future segwit version address can lead to loss of funds') ||
|
||||
args[0].startsWith('only compressed public keys are good') ||
|
||||
args[0].startsWith('Using standard fetch instead of expo/fetch'))
|
||||
args[0].startsWith('only compressed public keys are good'))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@ -73,34 +72,6 @@ jest.mock('react-native-notifications', () => {
|
||||
return {};
|
||||
});
|
||||
|
||||
jest.mock('react-native-background-fetch', () => {
|
||||
// The real module instantiates `new NativeEventEmitter(...)` at module
|
||||
// load, which throws under jest because the underlying native module is
|
||||
// null. Test files that don't drive scheduler behavior (i.e. anything
|
||||
// that transitively imports `blue_modules/arkade-background`) just need a
|
||||
// safe default. Tests that exercise registration/run paths jest.mock this
|
||||
// module locally with their own factory.
|
||||
const noop = jest.fn();
|
||||
const noopAsync = jest.fn().mockResolvedValue(undefined);
|
||||
const stub = {
|
||||
configure: noopAsync,
|
||||
start: noopAsync,
|
||||
stop: jest.fn().mockResolvedValue(true),
|
||||
finish: noop,
|
||||
scheduleTask: noopAsync,
|
||||
registerHeadlessTask: noop,
|
||||
STATUS_RESTRICTED: 0,
|
||||
STATUS_DENIED: 1,
|
||||
STATUS_AVAILABLE: 2,
|
||||
NETWORK_TYPE_NONE: 0,
|
||||
NETWORK_TYPE_ANY: 1,
|
||||
NETWORK_TYPE_CELLULAR: 2,
|
||||
NETWORK_TYPE_UNMETERED: 3,
|
||||
NETWORK_TYPE_NOT_ROAMING: 4,
|
||||
};
|
||||
return { __esModule: true, default: stub, ...stub };
|
||||
});
|
||||
|
||||
jest.mock('react-native-permissions', () => require('react-native-permissions/mock'));
|
||||
|
||||
jest.mock('react-native-device-info', () => {
|
||||
@ -202,32 +173,22 @@ jest.mock('react-native-default-preference', () => {
|
||||
});
|
||||
|
||||
jest.mock('react-native-fs', () => {
|
||||
// Track existence per absolute path so the Arkade Realm adapter's
|
||||
// ensureArkadeDir() / unlink() round trips behave coherently in tests.
|
||||
const mockFsExisting = new Set();
|
||||
const setExists = p => mockFsExisting.add(p);
|
||||
const clearExists = p => mockFsExisting.delete(p);
|
||||
|
||||
return {
|
||||
mkdir: jest.fn(async p => {
|
||||
setExists(p);
|
||||
}),
|
||||
mkdir: jest.fn(),
|
||||
moveFile: jest.fn(),
|
||||
copyFile: jest.fn(),
|
||||
pathForBundle: jest.fn(),
|
||||
pathForGroup: jest.fn(),
|
||||
getFSInfo: jest.fn(),
|
||||
getAllExternalFilesDirs: jest.fn(),
|
||||
unlink: jest.fn(async p => {
|
||||
clearExists(p);
|
||||
}),
|
||||
exists: jest.fn(async p => mockFsExisting.has(p)),
|
||||
unlink: jest.fn(),
|
||||
exists: jest.fn(),
|
||||
stopDownload: jest.fn(),
|
||||
resumeDownload: jest.fn(),
|
||||
isResumable: jest.fn(),
|
||||
stopUpload: jest.fn(),
|
||||
completeHandlerIOS: jest.fn(),
|
||||
readDir: jest.fn(async () => []),
|
||||
readDir: jest.fn(),
|
||||
readDirAssets: jest.fn(),
|
||||
existsAssets: jest.fn(),
|
||||
readdir: jest.fn(),
|
||||
@ -246,15 +207,14 @@ jest.mock('react-native-fs', () => {
|
||||
downloadFile: jest.fn(),
|
||||
uploadFiles: jest.fn(),
|
||||
touch: jest.fn(),
|
||||
MainBundlePath: '/mock/MainBundle',
|
||||
CachesDirectoryPath: '/mock/Caches',
|
||||
DocumentDirectoryPath: '/mock/Documents',
|
||||
ExternalDirectoryPath: '/mock/External',
|
||||
ExternalStorageDirectoryPath: '/mock/ExternalStorage',
|
||||
TemporaryDirectoryPath: '/mock/Temporary',
|
||||
LibraryDirectoryPath: '/mock/Library',
|
||||
PicturesDirectoryPath: '/mock/Pictures',
|
||||
__mockFsHelpers: { setExists, clearExists, reset: () => mockFsExisting.clear() },
|
||||
MainBundlePath: jest.fn(),
|
||||
CachesDirectoryPath: jest.fn(),
|
||||
DocumentDirectoryPath: jest.fn(),
|
||||
ExternalDirectoryPath: jest.fn(),
|
||||
ExternalStorageDirectoryPath: jest.fn(),
|
||||
TemporaryDirectoryPath: jest.fn(),
|
||||
LibraryDirectoryPath: jest.fn(),
|
||||
PicturesDirectoryPath: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
@ -262,231 +222,32 @@ jest.mock('@react-native-documents/picker', () => ({}));
|
||||
|
||||
jest.mock('react-native-haptic-feedback', () => ({}));
|
||||
|
||||
// Per-path Realm mock so the Arkade Realm adapter (one encrypted file per Ark wallet)
|
||||
// can be exercised in unit tests. Each `Realm.open({ path })` returns a stable
|
||||
// instance for that path until it is closed or deleted; concurrent opens for the
|
||||
// same path observe the same instance.
|
||||
//
|
||||
// The mock supports in-memory CRUD so SDK repository operations (saveContract,
|
||||
// getContracts, saveVtxos, getVtxos, etc.) round-trip correctly. Without this,
|
||||
// `annotateVtxos` cannot find contracts that were just saved and throws
|
||||
// "no contract matched vtxo.script" when the test wallet has live VTXOs.
|
||||
const realmInstanceMock = {
|
||||
create: function () {},
|
||||
delete: function () {},
|
||||
close: function () {},
|
||||
write: function (transactionFn) {
|
||||
if (typeof transactionFn === 'function') {
|
||||
// to test if something is not right in Realm transactional database write
|
||||
transactionFn();
|
||||
}
|
||||
},
|
||||
objectForPrimaryKey: function () {
|
||||
return {};
|
||||
},
|
||||
objects: function () {
|
||||
const wallets = {
|
||||
filtered: function () {
|
||||
return [];
|
||||
},
|
||||
};
|
||||
return wallets;
|
||||
},
|
||||
};
|
||||
jest.mock('realm', () => {
|
||||
const mockRealmStore = new Map();
|
||||
// Persisted-on-disk view: paths that have been opened at least once and not
|
||||
// yet deleted. Realm.exists / Realm.deleteFile read this rather than the
|
||||
// live (memory-cached, possibly-closed) instance map so deleteArkadeRealm
|
||||
// can realistically test the file-cleanup path.
|
||||
const mockRealmFiles = new Set();
|
||||
|
||||
// Primary-key field per Realm object type. Used by create() to key the
|
||||
// in-memory store and by delete() to remove individual objects.
|
||||
const PK_FIELD = {
|
||||
ArkContract: 'script',
|
||||
ArkVtxo: 'pk',
|
||||
ArkUtxo: 'pk',
|
||||
ArkTransaction: 'pk',
|
||||
ArkWalletState: 'key',
|
||||
BoltzSwap: 'id',
|
||||
ArkSwapNotificationSuppression: 'id',
|
||||
};
|
||||
|
||||
// Split a query string at a top-level separator (i.e. not inside parens/braces).
|
||||
const splitTop = (s, sep) => {
|
||||
const parts = [];
|
||||
let depth = 0;
|
||||
let start = 0;
|
||||
for (let i = 0; i <= s.length - sep.length; i++) {
|
||||
const c = s[i];
|
||||
if (c === '(' || c === '{') depth++;
|
||||
else if (c === ')' || c === '}') depth--;
|
||||
else if (depth === 0 && s.slice(i, i + sep.length) === sep) {
|
||||
parts.push(s.slice(start, i).trim());
|
||||
i += sep.length - 1;
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
parts.push(s.slice(start).trim());
|
||||
return parts.length > 1 ? parts : [s.trim()];
|
||||
};
|
||||
|
||||
// Evaluate a Realm query expression against a plain object.
|
||||
// Handles: `field == $N`, `field IN {$0,$1,...}`, AND, OR, and parens.
|
||||
const evalExpr = (obj, expr, args) => {
|
||||
expr = expr.trim();
|
||||
// Strip matching outer parens — e.g. "(a == $0 OR a == $1)" → "a == $0 OR a == $1"
|
||||
while (expr.startsWith('(') && expr.endsWith(')')) {
|
||||
let depth = 0;
|
||||
let allWrapped = true;
|
||||
for (let i = 0; i < expr.length - 1; i++) {
|
||||
if (expr[i] === '(') depth++;
|
||||
else if (expr[i] === ')') {
|
||||
if (--depth === 0) {
|
||||
allWrapped = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (allWrapped) expr = expr.slice(1, -1).trim();
|
||||
else break;
|
||||
}
|
||||
// AND: all sub-expressions must match
|
||||
const andParts = splitTop(expr, ' AND ');
|
||||
if (andParts.length > 1) return andParts.every(p => evalExpr(obj, p, args));
|
||||
// OR: any sub-expression must match
|
||||
const orParts = splitTop(expr, ' OR ');
|
||||
if (orParts.length > 1) return orParts.some(p => evalExpr(obj, p, args));
|
||||
// IN {$0, $1, ...} — used by BoltzSwap repository
|
||||
const inMatch = expr.match(/^(\w+)\s+IN\s+\{([^}]*)\}$/i);
|
||||
if (inMatch) {
|
||||
const field = inMatch[1];
|
||||
const values = inMatch[2].split(',').map(p => {
|
||||
const m = p.trim().match(/^\$(\d+)$/);
|
||||
return m ? args[+m[1]] : undefined;
|
||||
});
|
||||
return values.includes(obj[field]);
|
||||
}
|
||||
// field == $N
|
||||
const eqMatch = expr.match(/^(\w+)\s*==\s*\$(\d+)$/);
|
||||
if (eqMatch) return obj[eqMatch[1]] === args[+eqMatch[2]];
|
||||
return true; // unknown expression — pass through
|
||||
};
|
||||
|
||||
// Build a chainable collection over an array of Realm objects.
|
||||
const makeCollection = (type, items) => {
|
||||
const arr = Array.isArray(items) ? items : [...items];
|
||||
return {
|
||||
filtered: (query, ...args) =>
|
||||
makeCollection(
|
||||
type,
|
||||
arr.filter(o => evalExpr(o, query, args)),
|
||||
),
|
||||
sorted: (field, reverse) => {
|
||||
const sorted = [...arr].sort((a, b) => {
|
||||
if (a[field] < b[field]) return reverse ? 1 : -1;
|
||||
if (a[field] > b[field]) return reverse ? -1 : 1;
|
||||
return 0;
|
||||
});
|
||||
return makeCollection(type, sorted);
|
||||
},
|
||||
get length() {
|
||||
return arr.length;
|
||||
},
|
||||
[Symbol.iterator]: function* () {
|
||||
yield* arr;
|
||||
},
|
||||
// Internal: used by delete() to identify the backing type and items.
|
||||
_type: type,
|
||||
_items: arr,
|
||||
};
|
||||
};
|
||||
|
||||
const makeRealmInstance = path => {
|
||||
let isClosed = false;
|
||||
// type → Map<primaryKey, object>
|
||||
const typeStore = new Map();
|
||||
|
||||
const getStore = type => {
|
||||
if (!typeStore.has(type)) typeStore.set(type, new Map());
|
||||
return typeStore.get(type);
|
||||
};
|
||||
|
||||
return {
|
||||
path,
|
||||
get isClosed() {
|
||||
return isClosed;
|
||||
},
|
||||
|
||||
create(type, data) {
|
||||
const store = getStore(type);
|
||||
const pkField = PK_FIELD[type];
|
||||
const pk = pkField !== undefined ? data[pkField] : JSON.stringify(data);
|
||||
// Shallow-copy so later mutations to the caller's object don't affect
|
||||
// what the store holds. Attach a non-enumerable tag for delete().
|
||||
const stored = Object.defineProperty({ ...data }, '_realmMeta', {
|
||||
value: { type, pk },
|
||||
enumerable: false,
|
||||
});
|
||||
store.set(pk, stored);
|
||||
},
|
||||
|
||||
delete(target) {
|
||||
if (!target) return;
|
||||
// Single object returned by objectForPrimaryKey (has _realmMeta)
|
||||
if (target._realmMeta) {
|
||||
const { type, pk } = target._realmMeta;
|
||||
getStore(type).delete(pk);
|
||||
return;
|
||||
}
|
||||
// Collection returned by objects() / filtered()
|
||||
if (target._type !== undefined && target._items !== undefined) {
|
||||
const store = getStore(target._type);
|
||||
const pkField = PK_FIELD[target._type];
|
||||
for (const item of target._items) {
|
||||
const pk = pkField !== undefined ? item[pkField] : undefined;
|
||||
if (pk !== undefined) store.delete(pk);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
write(transactionFn) {
|
||||
if (typeof transactionFn === 'function') transactionFn();
|
||||
},
|
||||
|
||||
objectForPrimaryKey(type, pk) {
|
||||
return getStore(type).get(pk) ?? null;
|
||||
},
|
||||
|
||||
objects(type) {
|
||||
return makeCollection(type, getStore(type).values());
|
||||
},
|
||||
|
||||
close() {
|
||||
isClosed = true;
|
||||
},
|
||||
|
||||
addListener: jest.fn(),
|
||||
removeAllListeners: jest.fn(),
|
||||
|
||||
// Exposed so __mockRealmHelpers.reset() can wipe data in open instances.
|
||||
_clearData: () => typeStore.clear(),
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
UpdateMode: { Modified: 1 },
|
||||
open: jest.fn(async config => {
|
||||
const path = (config && config.path) || '__default__';
|
||||
const existing = mockRealmStore.get(path);
|
||||
if (existing && !existing.isClosed) return existing;
|
||||
const inst = makeRealmInstance(path);
|
||||
mockRealmStore.set(path, inst);
|
||||
mockRealmFiles.add(path);
|
||||
return inst;
|
||||
}),
|
||||
// Real Realm.exists / Realm.deleteFile are synchronous in this version.
|
||||
exists: jest.fn(arg => {
|
||||
const path = typeof arg === 'string' ? arg : (arg && arg.path) || '__default__';
|
||||
return mockRealmFiles.has(path);
|
||||
}),
|
||||
deleteFile: jest.fn(config => {
|
||||
const path = (config && config.path) || '__default__';
|
||||
mockRealmStore.delete(path);
|
||||
mockRealmFiles.delete(path);
|
||||
}),
|
||||
__mockRealmHelpers: {
|
||||
reset: () => {
|
||||
// Clear data inside any open instances so tests don't leak state
|
||||
// through instances cached in the app module's realmInstances map.
|
||||
for (const inst of mockRealmStore.values()) {
|
||||
if (typeof inst._clearData === 'function') inst._clearData();
|
||||
}
|
||||
mockRealmStore.clear();
|
||||
mockRealmFiles.clear();
|
||||
},
|
||||
store: mockRealmStore,
|
||||
files: mockRealmFiles,
|
||||
},
|
||||
open: jest.fn(() => realmInstanceMock),
|
||||
};
|
||||
});
|
||||
|
||||
@ -511,78 +272,22 @@ jest.mock('../blue_modules/analytics', () => {
|
||||
return ret;
|
||||
});
|
||||
|
||||
// addInvoice() registers a fire-and-forget payment-push callback; disable the
|
||||
// URI in unit tests so node-fetch does not leave in-flight handles after the
|
||||
// suite exits (which makes Jest fail with "did not exit one second after").
|
||||
jest.mock('../blue_modules/constants', () => {
|
||||
const actual = jest.requireActual('../blue_modules/constants');
|
||||
return {
|
||||
...actual,
|
||||
arkadePaymentPushUri: '',
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('react-native-share', () => {
|
||||
return {
|
||||
open: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
// Service-keyed Keychain mock so Arkade adapter tests can exercise the per-wallet
|
||||
// encryption-key lifecycle (load-or-create, then read on subsequent open). Defined
|
||||
// inside the factory because Jest hoists `jest.mock` above module scope and refuses
|
||||
// out-of-scope captures (only names matching /mock/i are allowed through).
|
||||
jest.mock('react-native-keychain', () => {
|
||||
const mockKeychainCreds = new Map();
|
||||
return {
|
||||
SECURITY_LEVEL_ANY: 'MOCK_SECURITY_LEVEL_ANY',
|
||||
SECURITY_LEVEL_SECURE_SOFTWARE: 'MOCK_SECURITY_LEVEL_SECURE_SOFTWARE',
|
||||
SECURITY_LEVEL_SECURE_HARDWARE: 'MOCK_SECURITY_LEVEL_SECURE_HARDWARE',
|
||||
ACCESSIBLE: {
|
||||
WHEN_UNLOCKED: 'AccessibleWhenUnlocked',
|
||||
AFTER_FIRST_UNLOCK: 'AccessibleAfterFirstUnlock',
|
||||
ALWAYS: 'AccessibleAlways',
|
||||
WHEN_PASSCODE_SET_THIS_DEVICE_ONLY: 'AccessibleWhenPasscodeSetThisDeviceOnly',
|
||||
WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'AccessibleWhenUnlockedThisDeviceOnly',
|
||||
AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY: 'AccessibleAfterFirstUnlockThisDeviceOnly',
|
||||
},
|
||||
SECURITY_LEVEL: {
|
||||
SECURE_SOFTWARE: 'SECURE_SOFTWARE',
|
||||
SECURE_HARDWARE: 'SECURE_HARDWARE',
|
||||
ANY: 'ANY',
|
||||
},
|
||||
setGenericPassword: jest.fn(async (username, password, options) => {
|
||||
const svc = (options && options.service) || '__default__';
|
||||
mockKeychainCreds.set(svc, { username, password, service: svc });
|
||||
return true;
|
||||
}),
|
||||
getGenericPassword: jest.fn(async options => {
|
||||
const svc = (options && options.service) || '__default__';
|
||||
return mockKeychainCreds.get(svc) || false;
|
||||
}),
|
||||
resetGenericPassword: jest.fn(async options => {
|
||||
const svc = (options && options.service) || '__default__';
|
||||
return mockKeychainCreds.delete(svc);
|
||||
}),
|
||||
// Default to the strongest level so the adapter's preflight selects
|
||||
// SECURE_HARDWARE in the happy path. Tests override per-case via
|
||||
// mockResolvedValueOnce when they need a downgrade scenario.
|
||||
getSecurityLevel: jest.fn(async () => 'SECURE_HARDWARE'),
|
||||
__mockKeychainHelpers: { reset: () => mockKeychainCreds.clear(), store: mockKeychainCreds },
|
||||
};
|
||||
});
|
||||
const mockKeychain = {
|
||||
SECURITY_LEVEL_ANY: 'MOCK_SECURITY_LEVEL_ANY',
|
||||
SECURITY_LEVEL_SECURE_SOFTWARE: 'MOCK_SECURITY_LEVEL_SECURE_SOFTWARE',
|
||||
SECURITY_LEVEL_SECURE_HARDWARE: 'MOCK_SECURITY_LEVEL_SECURE_HARDWARE',
|
||||
setGenericPassword: jest.fn().mockResolvedValue(),
|
||||
getGenericPassword: jest.fn().mockResolvedValue(),
|
||||
resetGenericPassword: jest.fn().mockResolvedValue(),
|
||||
};
|
||||
jest.mock('react-native-keychain', () => mockKeychain);
|
||||
|
||||
// Historic copy-paste: react-native-tcp-socket pulled the Keychain mock. Keep the
|
||||
// same surface so existing tests continue to mount, just with a fresh map.
|
||||
jest.mock('react-native-tcp-socket', () => {
|
||||
return {
|
||||
SECURITY_LEVEL_ANY: 'MOCK_SECURITY_LEVEL_ANY',
|
||||
SECURITY_LEVEL_SECURE_SOFTWARE: 'MOCK_SECURITY_LEVEL_SECURE_SOFTWARE',
|
||||
SECURITY_LEVEL_SECURE_HARDWARE: 'MOCK_SECURITY_LEVEL_SECURE_HARDWARE',
|
||||
setGenericPassword: jest.fn().mockResolvedValue(true),
|
||||
getGenericPassword: jest.fn().mockResolvedValue(false),
|
||||
resetGenericPassword: jest.fn().mockResolvedValue(true),
|
||||
};
|
||||
});
|
||||
jest.mock('react-native-tcp-socket', () => mockKeychain);
|
||||
|
||||
global.alert = () => {};
|
||||
|
||||
@ -1,626 +0,0 @@
|
||||
import assert from 'assert';
|
||||
import BackgroundFetch from 'react-native-background-fetch';
|
||||
import { BoltzSwapProvider, updateChainSwapStatus, updateReverseSwapStatus, updateSubmarineSwapStatus } from '@arkade-os/boltz-swap';
|
||||
import { RealmSwapRepository } from '@arkade-os/boltz-swap/repositories/realm';
|
||||
|
||||
import { LightningArkWallet } from '../../class/wallets/lightning-ark-wallet';
|
||||
import { BlueApp } from '../../class/blue-app';
|
||||
import { getArkadeRealm } from '../../blue_modules/arkade-adapters/realm/realmInstance';
|
||||
import {
|
||||
getArkTaskState,
|
||||
onArkBackgroundTaskTimeout,
|
||||
reconcileArkBackgroundTaskResults,
|
||||
registerArkBackgroundTask,
|
||||
runArkBackgroundTask,
|
||||
stopArkBackgroundTask,
|
||||
__testing__ as backgroundTesting,
|
||||
} from '../../blue_modules/arkade-background';
|
||||
|
||||
// jest.mock calls are hoisted before imports at runtime, so imports above
|
||||
// receive the mocked module. Factories cannot reference outer-scope user
|
||||
// variables — keep all shared mock fns inside the factory and surface them
|
||||
// through the constructor or the module's exports.
|
||||
jest.mock('react-native-background-fetch', () => {
|
||||
const mockApi = {
|
||||
configure: jest.fn().mockResolvedValue(2),
|
||||
start: jest.fn().mockResolvedValue(undefined),
|
||||
stop: jest.fn().mockResolvedValue(true),
|
||||
finish: jest.fn(),
|
||||
registerHeadlessTask: jest.fn(),
|
||||
STATUS_RESTRICTED: 0,
|
||||
STATUS_DENIED: 1,
|
||||
STATUS_AVAILABLE: 2,
|
||||
NETWORK_TYPE_ANY: 1,
|
||||
NETWORK_TYPE_NONE: 0,
|
||||
};
|
||||
return { __esModule: true, default: mockApi, ...mockApi };
|
||||
});
|
||||
|
||||
jest.mock('@arkade-os/boltz-swap', () => {
|
||||
const actual = jest.requireActual('@arkade-os/boltz-swap');
|
||||
const getSwapStatus = jest.fn();
|
||||
const updateReverseSwapStatusFn = jest.fn().mockResolvedValue(undefined);
|
||||
const updateSubmarineSwapStatusFn = jest.fn().mockResolvedValue(undefined);
|
||||
const updateChainSwapStatusFn = jest.fn().mockResolvedValue(undefined);
|
||||
const Provider = jest.fn().mockImplementation(() => ({ getSwapStatus }));
|
||||
// Hang the shared getSwapStatus mock off the constructor so the test can
|
||||
// reach it without referencing outer-scope variables in the factory.
|
||||
(Provider as any).__getSwapStatus = getSwapStatus;
|
||||
return {
|
||||
...actual,
|
||||
BoltzSwapProvider: Provider,
|
||||
updateReverseSwapStatus: updateReverseSwapStatusFn,
|
||||
updateSubmarineSwapStatus: updateSubmarineSwapStatusFn,
|
||||
updateChainSwapStatus: updateChainSwapStatusFn,
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@arkade-os/boltz-swap/repositories/realm', () => {
|
||||
const getAllSwaps = jest.fn().mockResolvedValue([]);
|
||||
const saveSwap = jest.fn().mockResolvedValue(undefined);
|
||||
const Repo = jest.fn().mockImplementation(() => ({ getAllSwaps, saveSwap }));
|
||||
(Repo as any).__getAllSwaps = getAllSwaps;
|
||||
(Repo as any).__saveSwap = saveSwap;
|
||||
return { RealmSwapRepository: Repo };
|
||||
});
|
||||
|
||||
jest.mock('../../blue_modules/arkade-adapters/realm/realmInstance', () => {
|
||||
return { getArkadeRealm: jest.fn() };
|
||||
});
|
||||
|
||||
jest.mock('../../blue_modules/arkade-adapters/realm/notificationSuppressionRepository', () => {
|
||||
// Single shared instance backed by an in-memory map so the test can
|
||||
// observe the same state pollSwap manipulates. Repo construction
|
||||
// happens inside processWallet, so we surface mocked methods through
|
||||
// the constructor.
|
||||
const store = new Map<string, true>();
|
||||
const has = jest.fn((swapId: string, action: string) => store.has(`${swapId}:${action}`));
|
||||
const record = jest.fn((swapId: string, action: string) => {
|
||||
store.set(`${swapId}:${action}`, true);
|
||||
});
|
||||
const clearForSwap = jest.fn((swapId: string) => {
|
||||
for (const k of Array.from(store.keys())) if (k.startsWith(`${swapId}:`)) store.delete(k);
|
||||
});
|
||||
const clearForSwapAction = jest.fn((swapId: string, action: string) => {
|
||||
store.delete(`${swapId}:${action}`);
|
||||
});
|
||||
const Repo = jest.fn().mockImplementation(() => ({ has, record, clearForSwap, clearForSwapAction }));
|
||||
(Repo as any).__store = store;
|
||||
(Repo as any).__has = has;
|
||||
(Repo as any).__record = record;
|
||||
(Repo as any).__clearForSwap = clearForSwap;
|
||||
(Repo as any).__clearForSwapAction = clearForSwapAction;
|
||||
return { RealmNotificationSuppressionRepository: Repo, ArkSwapNotificationSuppressionSchema: {} };
|
||||
});
|
||||
|
||||
jest.mock('../../blue_modules/arkade-notifications', () => {
|
||||
const notifyArkSwapActionable = jest.fn().mockResolvedValue(undefined);
|
||||
const resolveActionableAction = jest.fn().mockReturnValue(null);
|
||||
return {
|
||||
notifyArkSwapActionable,
|
||||
resolveActionableAction,
|
||||
ARK_SWAP_NOTIFICATION_TYPE: 100,
|
||||
ensureArkNotificationChannel: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const configureMock = BackgroundFetch.configure as unknown as jest.Mock;
|
||||
const startMock = BackgroundFetch.start as unknown as jest.Mock;
|
||||
const stopMock = BackgroundFetch.stop as unknown as jest.Mock;
|
||||
const finishMock = BackgroundFetch.finish as unknown as jest.Mock;
|
||||
|
||||
const getSwapStatusMock = (BoltzSwapProvider as any).__getSwapStatus as jest.Mock;
|
||||
const updateReverseSwapStatusMock = updateReverseSwapStatus as unknown as jest.Mock;
|
||||
const updateSubmarineSwapStatusMock = updateSubmarineSwapStatus as unknown as jest.Mock;
|
||||
const updateChainSwapStatusMock = updateChainSwapStatus as unknown as jest.Mock;
|
||||
|
||||
const getAllSwapsMock = (RealmSwapRepository as any).__getAllSwaps as jest.Mock;
|
||||
const getArkadeRealmMock = getArkadeRealm as unknown as jest.Mock;
|
||||
|
||||
const suppressionMockModule = jest.requireMock('../../blue_modules/arkade-adapters/realm/notificationSuppressionRepository') as any;
|
||||
const suppressionStore: Map<string, true> = suppressionMockModule.RealmNotificationSuppressionRepository.__store;
|
||||
const suppressionHasMock = suppressionMockModule.RealmNotificationSuppressionRepository.__has as jest.Mock;
|
||||
const suppressionRecordMock = suppressionMockModule.RealmNotificationSuppressionRepository.__record as jest.Mock;
|
||||
const suppressionClearForSwapMock = suppressionMockModule.RealmNotificationSuppressionRepository.__clearForSwap as jest.Mock;
|
||||
const suppressionClearForSwapActionMock = suppressionMockModule.RealmNotificationSuppressionRepository.__clearForSwapAction as jest.Mock;
|
||||
|
||||
const notificationsMockModule = jest.requireMock('../../blue_modules/arkade-notifications') as any;
|
||||
const notifyArkSwapActionableMock = notificationsMockModule.notifyArkSwapActionable as jest.Mock;
|
||||
const resolveActionableActionMock = notificationsMockModule.resolveActionableAction as jest.Mock;
|
||||
|
||||
const TEST_SECRET_A = 'arkade://abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
|
||||
const TEST_SECRET_B = 'arkade://about above absent absorb abstract absurd abuse access accident account accuse achieve';
|
||||
|
||||
const stubRealm = { id: 'realm' } as any;
|
||||
|
||||
function makeArkWallet(secret: string): LightningArkWallet {
|
||||
const w = new LightningArkWallet();
|
||||
w.setSecret(secret);
|
||||
return w;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
configureMock.mockClear();
|
||||
configureMock.mockResolvedValue(BackgroundFetch.STATUS_AVAILABLE);
|
||||
startMock.mockClear();
|
||||
startMock.mockResolvedValue(undefined);
|
||||
stopMock.mockClear();
|
||||
stopMock.mockResolvedValue(true);
|
||||
finishMock.mockClear();
|
||||
|
||||
getSwapStatusMock.mockReset();
|
||||
updateReverseSwapStatusMock.mockReset();
|
||||
updateReverseSwapStatusMock.mockResolvedValue(undefined);
|
||||
updateSubmarineSwapStatusMock.mockReset();
|
||||
updateSubmarineSwapStatusMock.mockResolvedValue(undefined);
|
||||
updateChainSwapStatusMock.mockReset();
|
||||
updateChainSwapStatusMock.mockResolvedValue(undefined);
|
||||
|
||||
getAllSwapsMock.mockReset();
|
||||
getAllSwapsMock.mockResolvedValue([]);
|
||||
|
||||
getArkadeRealmMock.mockReset();
|
||||
getArkadeRealmMock.mockResolvedValue(stubRealm);
|
||||
|
||||
suppressionStore.clear();
|
||||
suppressionHasMock.mockClear();
|
||||
suppressionRecordMock.mockClear();
|
||||
suppressionClearForSwapMock.mockClear();
|
||||
suppressionClearForSwapActionMock.mockClear();
|
||||
|
||||
notifyArkSwapActionableMock.mockReset();
|
||||
notifyArkSwapActionableMock.mockResolvedValue(undefined);
|
||||
resolveActionableActionMock.mockReset();
|
||||
resolveActionableActionMock.mockReturnValue(null);
|
||||
|
||||
BlueApp.getInstance().wallets = [];
|
||||
backgroundTesting.reset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
describe('registerArkBackgroundTask', () => {
|
||||
it('configures the scheduler once and records lastRegisteredAt', async () => {
|
||||
await registerArkBackgroundTask();
|
||||
assert.strictEqual(configureMock.mock.calls.length, 1);
|
||||
const cfg = configureMock.mock.calls[0][0];
|
||||
assert.strictEqual(cfg.minimumFetchInterval, 15);
|
||||
assert.strictEqual(cfg.stopOnTerminate, false);
|
||||
assert.strictEqual(cfg.startOnBoot, true);
|
||||
assert.strictEqual(cfg.enableHeadless, true);
|
||||
assert.strictEqual(cfg.requiredNetworkType, 1);
|
||||
assert.notStrictEqual(getArkTaskState().lastRegisteredAt, null);
|
||||
assert.strictEqual(getArkTaskState().availability, 'available');
|
||||
});
|
||||
|
||||
it('after stop, calls BackgroundFetch.start instead of reconfiguring', async () => {
|
||||
await registerArkBackgroundTask();
|
||||
await stopArkBackgroundTask();
|
||||
|
||||
configureMock.mockClear();
|
||||
startMock.mockClear();
|
||||
|
||||
await registerArkBackgroundTask();
|
||||
|
||||
assert.strictEqual(configureMock.mock.calls.length, 0);
|
||||
assert.strictEqual(startMock.mock.calls.length, 1);
|
||||
});
|
||||
|
||||
it('records denied status without marking the scheduler configured', async () => {
|
||||
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
configureMock.mockResolvedValueOnce(BackgroundFetch.STATUS_DENIED);
|
||||
|
||||
await registerArkBackgroundTask();
|
||||
|
||||
assert.strictEqual(getArkTaskState().availability, 'denied');
|
||||
assert.strictEqual(getArkTaskState().lastRegisteredAt, null);
|
||||
assert.strictEqual(startMock.mock.calls.length, 0);
|
||||
assert.strictEqual(warnSpy.mock.calls.length, 1);
|
||||
|
||||
configureMock.mockClear();
|
||||
await registerArkBackgroundTask();
|
||||
assert.strictEqual(configureMock.mock.calls.length, 1);
|
||||
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('runArkBackgroundTask', () => {
|
||||
it('finishes immediately with empty wallet list', async () => {
|
||||
await runArkBackgroundTask('task-1');
|
||||
|
||||
const s = getArkTaskState();
|
||||
assert.strictEqual(s.walletsScanned, 0);
|
||||
assert.strictEqual(s.swapsPolled, 0);
|
||||
assert.strictEqual(s.swapsUpdated, 0);
|
||||
assert.strictEqual(s.lastError, null);
|
||||
assert.strictEqual(finishMock.mock.calls.length, 1);
|
||||
assert.strictEqual(finishMock.mock.calls[0][0], 'task-1');
|
||||
});
|
||||
|
||||
it('marks exitedDueToUnavailableStorage when getArkadeRealm throws', async () => {
|
||||
const w = makeArkWallet(TEST_SECRET_A);
|
||||
BlueApp.getInstance().wallets = [w as any];
|
||||
getArkadeRealmMock.mockRejectedValue(new Error('keychain locked'));
|
||||
|
||||
await runArkBackgroundTask('task-keychain');
|
||||
|
||||
const s = getArkTaskState();
|
||||
assert.strictEqual(s.exitedDueToUnavailableStorage, true);
|
||||
assert.strictEqual(s.swapsPolled, 0);
|
||||
assert.strictEqual(s.swapsUpdated, 0);
|
||||
assert.ok(s.lastError && s.lastError.includes('keychain locked'));
|
||||
assert.strictEqual(finishMock.mock.calls.length, 1);
|
||||
});
|
||||
|
||||
it('polls non-terminal swaps without persisting when status matches', async () => {
|
||||
const w = makeArkWallet(TEST_SECRET_A);
|
||||
BlueApp.getInstance().wallets = [w as any];
|
||||
|
||||
const reverseSwap = { id: 'r1', type: 'reverse', status: 'swap.created' };
|
||||
const submarineSwap = { id: 's1', type: 'submarine', status: 'transaction.mempool' };
|
||||
getAllSwapsMock.mockResolvedValue([reverseSwap, submarineSwap]);
|
||||
getSwapStatusMock.mockImplementation(async (id: string) => {
|
||||
if (id === 'r1') return { status: 'swap.created' };
|
||||
if (id === 's1') return { status: 'transaction.mempool' };
|
||||
return { status: 'unknown' };
|
||||
});
|
||||
|
||||
await runArkBackgroundTask('task-poll');
|
||||
|
||||
const s = getArkTaskState();
|
||||
assert.strictEqual(s.walletsScanned, 1);
|
||||
assert.strictEqual(s.swapsPolled, 2);
|
||||
assert.strictEqual(s.swapsUpdated, 0);
|
||||
assert.strictEqual(updateReverseSwapStatusMock.mock.calls.length, 0);
|
||||
assert.strictEqual(updateSubmarineSwapStatusMock.mock.calls.length, 0);
|
||||
});
|
||||
|
||||
it('persists changed reverse-swap status through updateReverseSwapStatus', async () => {
|
||||
const w = makeArkWallet(TEST_SECRET_A);
|
||||
BlueApp.getInstance().wallets = [w as any];
|
||||
|
||||
const reverseSwap = { id: 'r1', type: 'reverse', status: 'swap.created' };
|
||||
getAllSwapsMock.mockResolvedValue([reverseSwap]);
|
||||
getSwapStatusMock.mockResolvedValue({ status: 'transaction.mempool' });
|
||||
|
||||
await runArkBackgroundTask('task-r1');
|
||||
|
||||
const s = getArkTaskState();
|
||||
assert.strictEqual(s.swapsPolled, 1);
|
||||
assert.strictEqual(s.swapsUpdated, 1);
|
||||
assert.strictEqual(updateReverseSwapStatusMock.mock.calls.length, 1);
|
||||
assert.strictEqual(updateReverseSwapStatusMock.mock.calls[0][0], reverseSwap);
|
||||
assert.strictEqual(updateReverseSwapStatusMock.mock.calls[0][1], 'transaction.mempool');
|
||||
assert.strictEqual(updateSubmarineSwapStatusMock.mock.calls.length, 0);
|
||||
assert.strictEqual(updateChainSwapStatusMock.mock.calls.length, 0);
|
||||
assert.strictEqual(finishMock.mock.calls.length, 1);
|
||||
assert.notStrictEqual(s.lastSwapUpdateAt, 0);
|
||||
});
|
||||
|
||||
it('routes submarine status changes to updateSubmarineSwapStatus', async () => {
|
||||
const w = makeArkWallet(TEST_SECRET_A);
|
||||
BlueApp.getInstance().wallets = [w as any];
|
||||
|
||||
const submarineSwap = { id: 's1', type: 'submarine', status: 'invoice.set' };
|
||||
getAllSwapsMock.mockResolvedValue([submarineSwap]);
|
||||
getSwapStatusMock.mockResolvedValue({ status: 'invoice.failedToPay' });
|
||||
|
||||
await runArkBackgroundTask('task-s1');
|
||||
|
||||
assert.strictEqual(updateSubmarineSwapStatusMock.mock.calls.length, 1);
|
||||
assert.strictEqual(updateReverseSwapStatusMock.mock.calls.length, 0);
|
||||
assert.strictEqual(updateChainSwapStatusMock.mock.calls.length, 0);
|
||||
});
|
||||
|
||||
it('routes chain status changes to updateChainSwapStatus', async () => {
|
||||
const w = makeArkWallet(TEST_SECRET_A);
|
||||
BlueApp.getInstance().wallets = [w as any];
|
||||
|
||||
const chainSwap = { id: 'c1', type: 'chain', status: 'swap.created' };
|
||||
getAllSwapsMock.mockResolvedValue([chainSwap]);
|
||||
getSwapStatusMock.mockResolvedValue({ status: 'transaction.mempool' });
|
||||
|
||||
await runArkBackgroundTask('task-c1');
|
||||
|
||||
assert.strictEqual(updateChainSwapStatusMock.mock.calls.length, 1);
|
||||
assert.strictEqual(updateReverseSwapStatusMock.mock.calls.length, 0);
|
||||
assert.strictEqual(updateSubmarineSwapStatusMock.mock.calls.length, 0);
|
||||
});
|
||||
|
||||
it('skips terminal swaps according to the per-type final-status predicate', async () => {
|
||||
const w = makeArkWallet(TEST_SECRET_A);
|
||||
BlueApp.getInstance().wallets = [w as any];
|
||||
|
||||
const swaps = [
|
||||
{ id: 'r1', type: 'reverse', status: 'invoice.settled' },
|
||||
{ id: 's1', type: 'submarine', status: 'transaction.claimed' },
|
||||
{ id: 'c1', type: 'chain', status: 'transaction.refunded' },
|
||||
{ id: 'r2', type: 'reverse', status: 'swap.expired' },
|
||||
];
|
||||
getAllSwapsMock.mockResolvedValue(swaps);
|
||||
|
||||
await runArkBackgroundTask('task-terminal');
|
||||
|
||||
assert.strictEqual(getSwapStatusMock.mock.calls.length, 0);
|
||||
assert.strictEqual(getArkTaskState().swapsPolled, 0);
|
||||
assert.strictEqual(getArkTaskState().swapsUpdated, 0);
|
||||
});
|
||||
|
||||
it('continues to next swap when one getSwapStatus call fails', async () => {
|
||||
const w = makeArkWallet(TEST_SECRET_A);
|
||||
BlueApp.getInstance().wallets = [w as any];
|
||||
|
||||
getAllSwapsMock.mockResolvedValue([
|
||||
{ id: 'r1', type: 'reverse', status: 'swap.created' },
|
||||
{ id: 'r2', type: 'reverse', status: 'swap.created' },
|
||||
]);
|
||||
getSwapStatusMock.mockImplementation(async (id: string) => {
|
||||
if (id === 'r1') throw new Error('network');
|
||||
return { status: 'transaction.mempool' };
|
||||
});
|
||||
|
||||
await runArkBackgroundTask('task-onefail');
|
||||
|
||||
assert.strictEqual(getArkTaskState().swapsPolled, 2);
|
||||
assert.strictEqual(getArkTaskState().swapsUpdated, 1);
|
||||
assert.ok(getArkTaskState().lastError && getArkTaskState().lastError!.includes('network'));
|
||||
assert.strictEqual(finishMock.mock.calls.length, 1);
|
||||
});
|
||||
|
||||
it('finishes overlapping runs immediately without starting a second scan', async () => {
|
||||
const w = makeArkWallet(TEST_SECRET_A);
|
||||
BlueApp.getInstance().wallets = [w as any];
|
||||
|
||||
let resolveSwaps: (value: any[]) => void = () => {};
|
||||
getAllSwapsMock.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise(resolve => {
|
||||
resolveSwaps = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const firstRun = runArkBackgroundTask('task-first');
|
||||
await Promise.resolve();
|
||||
await runArkBackgroundTask('task-second');
|
||||
|
||||
assert.strictEqual(finishMock.mock.calls.length, 1);
|
||||
assert.strictEqual(finishMock.mock.calls[0][0], 'task-second');
|
||||
assert.strictEqual(getArkTaskState().walletsScanned, 1);
|
||||
|
||||
resolveSwaps([]);
|
||||
await firstRun;
|
||||
|
||||
assert.strictEqual(finishMock.mock.calls.length, 2);
|
||||
assert.strictEqual(finishMock.mock.calls[1][0], 'task-first');
|
||||
});
|
||||
|
||||
it('times out a hung status poll before persisting and skips later swaps', async () => {
|
||||
const w = makeArkWallet(TEST_SECRET_A);
|
||||
BlueApp.getInstance().wallets = [w as any];
|
||||
getAllSwapsMock.mockResolvedValue([
|
||||
{ id: 'r1', type: 'reverse', status: 'swap.created' },
|
||||
{ id: 'r2', type: 'reverse', status: 'swap.created' },
|
||||
]);
|
||||
getSwapStatusMock.mockImplementation(() => new Promise(() => {}));
|
||||
// 100ms gives enough headroom for the await chain (getArkadeRealm,
|
||||
// getAllSwaps, processWallet entry) under loaded parallel workers so
|
||||
// the first pollSwap definitely starts before the deadline; the
|
||||
// never-resolving getSwapStatus then drives the withTimeout reject path.
|
||||
backgroundTesting.setMaxRunMs(100);
|
||||
|
||||
await runArkBackgroundTask('task-timeout');
|
||||
|
||||
assert.strictEqual(getArkTaskState().swapsPolled, 1);
|
||||
assert.strictEqual(getArkTaskState().swapsUpdated, 0);
|
||||
assert.ok(getArkTaskState().lastError && getArkTaskState().lastError!.includes('deadline exceeded'));
|
||||
assert.strictEqual(updateReverseSwapStatusMock.mock.calls.length, 0);
|
||||
assert.strictEqual(getSwapStatusMock.mock.calls.length, 1);
|
||||
assert.strictEqual(finishMock.mock.calls.length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onArkBackgroundTaskTimeout', () => {
|
||||
it('records lastError = timeout and calls finish', () => {
|
||||
onArkBackgroundTaskTimeout('task-to');
|
||||
assert.strictEqual(getArkTaskState().lastError, 'timeout');
|
||||
assert.strictEqual(finishMock.mock.calls.length, 1);
|
||||
assert.strictEqual(finishMock.mock.calls[0][0], 'task-to');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stopArkBackgroundTask', () => {
|
||||
it('calls BackgroundFetch.stop, clears swap cache, sets lastUnregisteredAt', async () => {
|
||||
backgroundTesting.swapStatusCache.set('ns-x', new Map([['s1', 'swap.created']]));
|
||||
|
||||
await stopArkBackgroundTask();
|
||||
|
||||
assert.strictEqual(stopMock.mock.calls.length, 1);
|
||||
assert.strictEqual(backgroundTesting.swapStatusCache.size, 0);
|
||||
assert.notStrictEqual(getArkTaskState().lastUnregisteredAt, null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reconcileArkBackgroundTaskResults', () => {
|
||||
it('does not call back when no swap update happened since last reconcile', () => {
|
||||
const cb = jest.fn();
|
||||
reconcileArkBackgroundTaskResults(cb);
|
||||
assert.strictEqual(cb.mock.calls.length, 0);
|
||||
});
|
||||
|
||||
it('calls back exactly once per Ark wallet whose namespace has cache entries after an update', async () => {
|
||||
const wA = makeArkWallet(TEST_SECRET_A);
|
||||
const wB = makeArkWallet(TEST_SECRET_B);
|
||||
BlueApp.getInstance().wallets = [wA as any, wB as any];
|
||||
|
||||
// Trigger one persisted update for wallet A.
|
||||
getAllSwapsMock.mockResolvedValueOnce([{ id: 'r1', type: 'reverse', status: 'swap.created' }]);
|
||||
getSwapStatusMock.mockResolvedValueOnce({ status: 'transaction.mempool' });
|
||||
// Wallet B has no swaps to poll.
|
||||
getAllSwapsMock.mockResolvedValueOnce([]);
|
||||
|
||||
await runArkBackgroundTask('task-reconcile');
|
||||
assert.strictEqual(getArkTaskState().swapsUpdated, 1);
|
||||
|
||||
const cb = jest.fn();
|
||||
reconcileArkBackgroundTaskResults(cb);
|
||||
|
||||
assert.strictEqual(cb.mock.calls.length, 1);
|
||||
assert.strictEqual(cb.mock.calls[0][0], wA.getID());
|
||||
});
|
||||
|
||||
it('skips on second invocation when no further updates have arrived', async () => {
|
||||
const wA = makeArkWallet(TEST_SECRET_A);
|
||||
BlueApp.getInstance().wallets = [wA as any];
|
||||
|
||||
getAllSwapsMock.mockResolvedValueOnce([{ id: 'r1', type: 'reverse', status: 'swap.created' }]);
|
||||
getSwapStatusMock.mockResolvedValueOnce({ status: 'transaction.mempool' });
|
||||
|
||||
await runArkBackgroundTask('task-rec1');
|
||||
const cb1 = jest.fn();
|
||||
reconcileArkBackgroundTaskResults(cb1);
|
||||
assert.strictEqual(cb1.mock.calls.length, 1);
|
||||
|
||||
const cb2 = jest.fn();
|
||||
reconcileArkBackgroundTaskResults(cb2);
|
||||
assert.strictEqual(cb2.mock.calls.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('actionable swap notifications', () => {
|
||||
it('calls notifyArkSwapActionable with an updatedSwap (status === remoteStatus) on transition into actionable', async () => {
|
||||
const w = makeArkWallet(TEST_SECRET_A);
|
||||
BlueApp.getInstance().wallets = [w as any];
|
||||
|
||||
const swap = { id: 'r1', type: 'reverse', status: 'swap.created' };
|
||||
getAllSwapsMock.mockResolvedValue([swap]);
|
||||
getSwapStatusMock.mockResolvedValue({ status: 'transaction.confirmed' });
|
||||
resolveActionableActionMock.mockReturnValue('claim');
|
||||
|
||||
await runArkBackgroundTask('task-actionable');
|
||||
|
||||
assert.strictEqual(notifyArkSwapActionableMock.mock.calls.length, 1);
|
||||
const [passedSwap, , walletID, walletLabel] = notifyArkSwapActionableMock.mock.calls[0];
|
||||
// Regression guard for the SDK-non-mutation issue: the first arg must
|
||||
// carry remoteStatus, not the pre-update status. updateReverseSwapStatus
|
||||
// saves a copy and does not mutate the input, so passing `swap` here
|
||||
// would silently evaluate predicates against the old status.
|
||||
assert.strictEqual(passedSwap.status, 'transaction.confirmed');
|
||||
assert.strictEqual(passedSwap.id, 'r1');
|
||||
assert.strictEqual(walletID, w.getID());
|
||||
assert.strictEqual(typeof walletLabel, 'string');
|
||||
});
|
||||
|
||||
it('clears suppression and skips notify on transition into terminal status', async () => {
|
||||
const w = makeArkWallet(TEST_SECRET_A);
|
||||
BlueApp.getInstance().wallets = [w as any];
|
||||
|
||||
const swap = { id: 'r1', type: 'reverse', status: 'transaction.confirmed' };
|
||||
getAllSwapsMock.mockResolvedValue([swap]);
|
||||
getSwapStatusMock.mockResolvedValue({ status: 'invoice.settled' });
|
||||
|
||||
await runArkBackgroundTask('task-terminal-clear');
|
||||
|
||||
assert.strictEqual(suppressionClearForSwapMock.mock.calls.length, 1);
|
||||
assert.strictEqual(suppressionClearForSwapMock.mock.calls[0][0], 'r1');
|
||||
assert.strictEqual(notifyArkSwapActionableMock.mock.calls.length, 0);
|
||||
});
|
||||
|
||||
it('clears the previous-action suppression on predicate flip out of actionable', async () => {
|
||||
const w = makeArkWallet(TEST_SECRET_A);
|
||||
BlueApp.getInstance().wallets = [w as any];
|
||||
|
||||
// Run 1: swap is actionable (claim) — populates lastSeenActionMap.
|
||||
const swap1 = { id: 'r1', type: 'reverse', status: 'swap.created' };
|
||||
getAllSwapsMock.mockResolvedValueOnce([swap1]);
|
||||
getSwapStatusMock.mockResolvedValueOnce({ status: 'transaction.confirmed' });
|
||||
resolveActionableActionMock.mockReturnValueOnce('claim');
|
||||
await runArkBackgroundTask('task-flip-1');
|
||||
|
||||
// Run 2: same swap, status moved to a non-terminal but no-longer-actionable
|
||||
// state (predicate flipped false). Realm reflects the prior persisted
|
||||
// status, so the swap presented to processWallet has status 'transaction.confirmed'.
|
||||
const swap2 = { id: 'r1', type: 'reverse', status: 'transaction.confirmed' };
|
||||
getAllSwapsMock.mockResolvedValueOnce([swap2]);
|
||||
getSwapStatusMock.mockResolvedValueOnce({ status: 'transaction.mempool' });
|
||||
resolveActionableActionMock.mockReturnValueOnce(null);
|
||||
notifyArkSwapActionableMock.mockClear();
|
||||
suppressionClearForSwapActionMock.mockClear();
|
||||
await runArkBackgroundTask('task-flip-2');
|
||||
|
||||
assert.strictEqual(suppressionClearForSwapActionMock.mock.calls.length, 1);
|
||||
assert.strictEqual(suppressionClearForSwapActionMock.mock.calls[0][0], 'r1');
|
||||
assert.strictEqual(suppressionClearForSwapActionMock.mock.calls[0][1], 'claim');
|
||||
assert.strictEqual(notifyArkSwapActionableMock.mock.calls.length, 0);
|
||||
});
|
||||
|
||||
it('re-evaluates actionable on a poll where remoteStatus === swap.status (regression guard)', async () => {
|
||||
const w = makeArkWallet(TEST_SECRET_A);
|
||||
BlueApp.getInstance().wallets = [w as any];
|
||||
|
||||
// Realm already reflects an actionable status. The remote returns the
|
||||
// same status. Old behavior would early-return; new behavior must still
|
||||
// run the actionable evaluation because a previous wake may have failed
|
||||
// to post.
|
||||
const swap = { id: 'r1', type: 'reverse', status: 'transaction.confirmed' };
|
||||
getAllSwapsMock.mockResolvedValue([swap]);
|
||||
getSwapStatusMock.mockResolvedValue({ status: 'transaction.confirmed' });
|
||||
resolveActionableActionMock.mockReturnValue('claim');
|
||||
|
||||
await runArkBackgroundTask('task-stable-actionable');
|
||||
|
||||
// No persistence — status didn't change.
|
||||
assert.strictEqual(getArkTaskState().swapsUpdated, 0);
|
||||
assert.strictEqual(updateReverseSwapStatusMock.mock.calls.length, 0);
|
||||
// But notify is still invoked.
|
||||
assert.strictEqual(notifyArkSwapActionableMock.mock.calls.length, 1);
|
||||
});
|
||||
|
||||
it('survives notify failure: pollSwap completes, BackgroundFetch.finish is called, run continues', async () => {
|
||||
const w = makeArkWallet(TEST_SECRET_A);
|
||||
BlueApp.getInstance().wallets = [w as any];
|
||||
|
||||
getAllSwapsMock.mockResolvedValue([
|
||||
{ id: 'r1', type: 'reverse', status: 'swap.created' },
|
||||
{ id: 'r2', type: 'reverse', status: 'swap.created' },
|
||||
]);
|
||||
getSwapStatusMock.mockResolvedValue({ status: 'transaction.confirmed' });
|
||||
resolveActionableActionMock.mockReturnValue('claim');
|
||||
notifyArkSwapActionableMock.mockRejectedValue(new Error('notify exploded'));
|
||||
|
||||
await runArkBackgroundTask('task-notify-throw');
|
||||
|
||||
assert.strictEqual(getArkTaskState().swapsPolled, 2);
|
||||
assert.strictEqual(notifyArkSwapActionableMock.mock.calls.length, 2);
|
||||
assert.strictEqual(finishMock.mock.calls.length, 1);
|
||||
assert.ok(getArkTaskState().lastError && getArkTaskState().lastError!.includes('notify exploded'));
|
||||
});
|
||||
|
||||
it('survives suppression-write failure: pollSwap completes, subsequent polls run', async () => {
|
||||
const w = makeArkWallet(TEST_SECRET_A);
|
||||
BlueApp.getInstance().wallets = [w as any];
|
||||
|
||||
getAllSwapsMock.mockResolvedValue([
|
||||
{ id: 'r1', type: 'reverse', status: 'transaction.confirmed' },
|
||||
{ id: 'r2', type: 'reverse', status: 'transaction.confirmed' },
|
||||
]);
|
||||
getSwapStatusMock.mockResolvedValue({ status: 'invoice.settled' });
|
||||
suppressionClearForSwapMock.mockImplementationOnce(() => {
|
||||
throw new Error('realm closed');
|
||||
});
|
||||
|
||||
await runArkBackgroundTask('task-suppression-throw');
|
||||
|
||||
// Both polls still happen.
|
||||
assert.strictEqual(getArkTaskState().swapsPolled, 2);
|
||||
assert.strictEqual(finishMock.mock.calls.length, 1);
|
||||
});
|
||||
|
||||
it('stopArkBackgroundTask clears the in-process lastSeenActionMap', async () => {
|
||||
backgroundTesting.lastSeenActionMap.set('ns-x:r1', 'claim');
|
||||
await stopArkBackgroundTask();
|
||||
assert.strictEqual(backgroundTesting.lastSeenActionMap.size, 0);
|
||||
});
|
||||
});
|
||||
@ -1,288 +0,0 @@
|
||||
import assert from 'assert';
|
||||
|
||||
import { isChainSwapClaimable, isChainSwapRefundable, isReverseSwapClaimable, isSubmarineSwapRefundable } from '@arkade-os/boltz-swap';
|
||||
|
||||
import {
|
||||
ARK_SWAP_NOTIFICATION_TYPE,
|
||||
ensureArkNotificationChannel,
|
||||
notifyArkSwapActionable,
|
||||
resolveActionableAction,
|
||||
__testing__ as notificationsTesting,
|
||||
} from '../../blue_modules/arkade-notifications';
|
||||
|
||||
// jest.mock calls are hoisted before imports at runtime, so the imports
|
||||
// above receive the mocked module. Factories cannot reference outer-scope
|
||||
// user variables — keep all shared mock fns inside the factory and surface
|
||||
// them through the module's exports.
|
||||
jest.mock('react-native-notifications', () => {
|
||||
const postLocalNotification = jest.fn();
|
||||
const setNotificationChannel = jest.fn();
|
||||
class Notification {
|
||||
payload: any;
|
||||
constructor(payload: any) {
|
||||
this.payload = payload;
|
||||
}
|
||||
}
|
||||
return {
|
||||
Notification,
|
||||
Notifications: { postLocalNotification, setNotificationChannel },
|
||||
__postLocalNotification: postLocalNotification,
|
||||
__setNotificationChannel: setNotificationChannel,
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('react-native-permissions', () => ({
|
||||
checkNotifications: jest.fn().mockResolvedValue({ status: 'granted' }),
|
||||
RESULTS: { GRANTED: 'granted', DENIED: 'denied', BLOCKED: 'blocked' },
|
||||
}));
|
||||
|
||||
jest.mock('@react-native-async-storage/async-storage', () => ({
|
||||
getItem: jest.fn().mockResolvedValue(null),
|
||||
setItem: jest.fn().mockResolvedValue(undefined),
|
||||
removeItem: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
jest.mock('@arkade-os/boltz-swap', () => ({
|
||||
isReverseSwapClaimable: jest.fn(),
|
||||
isSubmarineSwapRefundable: jest.fn(),
|
||||
isChainSwapClaimable: jest.fn(),
|
||||
isChainSwapRefundable: jest.fn(),
|
||||
}));
|
||||
|
||||
const isReverseSwapClaimableMock = isReverseSwapClaimable as unknown as jest.Mock;
|
||||
const isSubmarineSwapRefundableMock = isSubmarineSwapRefundable as unknown as jest.Mock;
|
||||
const isChainSwapClaimableMock = isChainSwapClaimable as unknown as jest.Mock;
|
||||
const isChainSwapRefundableMock = isChainSwapRefundable as unknown as jest.Mock;
|
||||
|
||||
const rnNotifications = jest.requireMock('react-native-notifications');
|
||||
const postLocalNotificationMock = rnNotifications.__postLocalNotification as jest.Mock;
|
||||
const setNotificationChannelMock = rnNotifications.__setNotificationChannel as jest.Mock;
|
||||
|
||||
interface SuppressionStub {
|
||||
has: jest.Mock;
|
||||
record: jest.Mock;
|
||||
clearForSwap: jest.Mock;
|
||||
clearForSwapAction: jest.Mock;
|
||||
}
|
||||
|
||||
function makeSuppressionStub(): SuppressionStub {
|
||||
const store = new Map<string, true>();
|
||||
return {
|
||||
has: jest.fn((swapId: string, action: string) => store.has(`${swapId}:${action}`)),
|
||||
record: jest.fn((swapId: string, action: string) => {
|
||||
store.set(`${swapId}:${action}`, true);
|
||||
}),
|
||||
clearForSwap: jest.fn((swapId: string) => {
|
||||
for (const k of Array.from(store.keys())) if (k.startsWith(`${swapId}:`)) store.delete(k);
|
||||
}),
|
||||
clearForSwapAction: jest.fn((swapId: string, action: string) => {
|
||||
store.delete(`${swapId}:${action}`);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
postLocalNotificationMock.mockReset();
|
||||
setNotificationChannelMock.mockReset();
|
||||
isReverseSwapClaimableMock.mockReset().mockReturnValue(false);
|
||||
isSubmarineSwapRefundableMock.mockReset().mockReturnValue(false);
|
||||
isChainSwapClaimableMock.mockReset().mockReturnValue(false);
|
||||
isChainSwapRefundableMock.mockReset().mockReturnValue(false);
|
||||
|
||||
notificationsTesting.setAppStateForTest('background');
|
||||
notificationsTesting.setPermissionResultForTest('granted');
|
||||
notificationsTesting.setOptOutFlagForTest(null);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
notificationsTesting.setAppStateForTest(null);
|
||||
notificationsTesting.setPermissionResultForTest(null);
|
||||
notificationsTesting.setOptOutFlagForTest(undefined);
|
||||
});
|
||||
|
||||
describe('resolveActionableAction', () => {
|
||||
it('returns claim for reverse-claimable swaps', () => {
|
||||
isReverseSwapClaimableMock.mockReturnValue(true);
|
||||
assert.strictEqual(resolveActionableAction({ id: 'r1', type: 'reverse', status: 'transaction.confirmed' } as any), 'claim');
|
||||
});
|
||||
|
||||
it('returns claim for chain-claimable swaps', () => {
|
||||
isChainSwapClaimableMock.mockReturnValue(true);
|
||||
assert.strictEqual(resolveActionableAction({ id: 'c1', type: 'chain', status: 'transaction.server.confirmed' } as any), 'claim');
|
||||
});
|
||||
|
||||
it('returns refund for submarine-refundable swaps', () => {
|
||||
isSubmarineSwapRefundableMock.mockReturnValue(true);
|
||||
assert.strictEqual(resolveActionableAction({ id: 's1', type: 'submarine', status: 'transaction.lockupFailed' } as any), 'refund');
|
||||
});
|
||||
|
||||
it('returns refund for chain-refundable swaps', () => {
|
||||
isChainSwapRefundableMock.mockReturnValue(true);
|
||||
assert.strictEqual(resolveActionableAction({ id: 'c2', type: 'chain', status: 'transaction.server.refundable' } as any), 'refund');
|
||||
});
|
||||
|
||||
it('returns null when no predicate matches', () => {
|
||||
assert.strictEqual(resolveActionableAction({ id: 'x', type: 'reverse', status: 'swap.created' } as any), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('notifyArkSwapActionable', () => {
|
||||
it('posts a notification with the expected payload for a reverse-claimable swap', async () => {
|
||||
isReverseSwapClaimableMock.mockReturnValue(true);
|
||||
const suppression = makeSuppressionStub();
|
||||
|
||||
await notifyArkSwapActionable(
|
||||
{ id: 'r1', type: 'reverse', status: 'transaction.confirmed' } as any,
|
||||
suppression as any,
|
||||
'wallet-id-A',
|
||||
'My Wallet',
|
||||
);
|
||||
|
||||
assert.strictEqual(postLocalNotificationMock.mock.calls.length, 1);
|
||||
const notif = postLocalNotificationMock.mock.calls[0][0];
|
||||
assert.strictEqual(notif.payload.type, ARK_SWAP_NOTIFICATION_TYPE);
|
||||
assert.strictEqual(notif.payload.walletID, 'wallet-id-A');
|
||||
assert.strictEqual(notif.payload.swapId, 'r1');
|
||||
assert.strictEqual(notif.payload.action, 'claim');
|
||||
// Regression guard: namespace is intentionally absent from the OS payload
|
||||
// so the persistent notification record never carries a stable per-wallet
|
||||
// handle outside the encryption boundary.
|
||||
assert.strictEqual(Object.prototype.hasOwnProperty.call(notif.payload, 'namespace'), false);
|
||||
|
||||
assert.strictEqual(suppression.record.mock.calls.length, 1);
|
||||
assert.strictEqual(suppression.record.mock.calls[0][0], 'r1');
|
||||
assert.strictEqual(suppression.record.mock.calls[0][1], 'claim');
|
||||
});
|
||||
|
||||
it('posts with action=refund for a submarine-refundable swap', async () => {
|
||||
isSubmarineSwapRefundableMock.mockReturnValue(true);
|
||||
const suppression = makeSuppressionStub();
|
||||
|
||||
await notifyArkSwapActionable(
|
||||
{ id: 's1', type: 'submarine', status: 'transaction.lockupFailed' } as any,
|
||||
suppression as any,
|
||||
'wallet-id-B',
|
||||
'Wallet B',
|
||||
);
|
||||
|
||||
const notif = postLocalNotificationMock.mock.calls[0][0];
|
||||
assert.strictEqual(notif.payload.action, 'refund');
|
||||
assert.strictEqual(suppression.record.mock.calls[0][1], 'refund');
|
||||
});
|
||||
|
||||
it('routes chain-claimable predicates to claim', async () => {
|
||||
isChainSwapClaimableMock.mockReturnValue(true);
|
||||
const suppression = makeSuppressionStub();
|
||||
|
||||
await notifyArkSwapActionable(
|
||||
{ id: 'c1', type: 'chain', status: 'transaction.server.confirmed' } as any,
|
||||
suppression as any,
|
||||
'wallet-id-C',
|
||||
'Wallet C',
|
||||
);
|
||||
|
||||
assert.strictEqual(postLocalNotificationMock.mock.calls[0][0].payload.action, 'claim');
|
||||
});
|
||||
|
||||
it('routes chain-refundable predicates to refund', async () => {
|
||||
isChainSwapRefundableMock.mockReturnValue(true);
|
||||
const suppression = makeSuppressionStub();
|
||||
|
||||
await notifyArkSwapActionable(
|
||||
{ id: 'c2', type: 'chain', status: 'transaction.server.refundable' } as any,
|
||||
suppression as any,
|
||||
'wallet-id-C',
|
||||
'Wallet C',
|
||||
);
|
||||
|
||||
assert.strictEqual(postLocalNotificationMock.mock.calls[0][0].payload.action, 'refund');
|
||||
});
|
||||
|
||||
it('does not post when suppression already recorded for this swap+action', async () => {
|
||||
isReverseSwapClaimableMock.mockReturnValue(true);
|
||||
const suppression = makeSuppressionStub();
|
||||
suppression.has.mockImplementation(() => true);
|
||||
|
||||
await notifyArkSwapActionable({ id: 'r1', type: 'reverse', status: 'transaction.confirmed' } as any, suppression as any, 'w', 'L');
|
||||
|
||||
assert.strictEqual(postLocalNotificationMock.mock.calls.length, 0);
|
||||
assert.strictEqual(suppression.record.mock.calls.length, 0);
|
||||
});
|
||||
|
||||
it('does not post when AppState is active', async () => {
|
||||
isReverseSwapClaimableMock.mockReturnValue(true);
|
||||
notificationsTesting.setAppStateForTest('active');
|
||||
const suppression = makeSuppressionStub();
|
||||
|
||||
await notifyArkSwapActionable({ id: 'r1', type: 'reverse', status: 'transaction.confirmed' } as any, suppression as any, 'w', 'L');
|
||||
|
||||
assert.strictEqual(postLocalNotificationMock.mock.calls.length, 0);
|
||||
assert.strictEqual(suppression.record.mock.calls.length, 0);
|
||||
});
|
||||
|
||||
it('does not post when no predicate matches', async () => {
|
||||
const suppression = makeSuppressionStub();
|
||||
await notifyArkSwapActionable({ id: 'r1', type: 'reverse', status: 'swap.created' } as any, suppression as any, 'w', 'L');
|
||||
assert.strictEqual(postLocalNotificationMock.mock.calls.length, 0);
|
||||
assert.strictEqual(suppression.record.mock.calls.length, 0);
|
||||
});
|
||||
|
||||
it('skips post AND suppression when OS permission is denied', async () => {
|
||||
isReverseSwapClaimableMock.mockReturnValue(true);
|
||||
notificationsTesting.setPermissionResultForTest('denied');
|
||||
const suppression = makeSuppressionStub();
|
||||
|
||||
await notifyArkSwapActionable({ id: 'r1', type: 'reverse', status: 'transaction.confirmed' } as any, suppression as any, 'w', 'L');
|
||||
|
||||
assert.strictEqual(postLocalNotificationMock.mock.calls.length, 0);
|
||||
assert.strictEqual(suppression.record.mock.calls.length, 0);
|
||||
});
|
||||
|
||||
it('skips post AND suppression when app-level opt-out flag is set', async () => {
|
||||
isReverseSwapClaimableMock.mockReturnValue(true);
|
||||
notificationsTesting.setOptOutFlagForTest('true');
|
||||
const suppression = makeSuppressionStub();
|
||||
|
||||
await notifyArkSwapActionable({ id: 'r1', type: 'reverse', status: 'transaction.confirmed' } as any, suppression as any, 'w', 'L');
|
||||
|
||||
assert.strictEqual(postLocalNotificationMock.mock.calls.length, 0);
|
||||
assert.strictEqual(suppression.record.mock.calls.length, 0);
|
||||
});
|
||||
|
||||
it('does not write suppression when postLocalNotification throws', async () => {
|
||||
isReverseSwapClaimableMock.mockReturnValue(true);
|
||||
postLocalNotificationMock.mockImplementation(() => {
|
||||
throw new Error('post failed');
|
||||
});
|
||||
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const suppression = makeSuppressionStub();
|
||||
|
||||
await notifyArkSwapActionable({ id: 'r1', type: 'reverse', status: 'transaction.confirmed' } as any, suppression as any, 'w', 'L');
|
||||
|
||||
assert.strictEqual(suppression.record.mock.calls.length, 0);
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureArkNotificationChannel', () => {
|
||||
it('calls setNotificationChannel at most once across multiple invocations', () => {
|
||||
// The module-level channelEnsured guard was set true by the import above.
|
||||
// Reset and re-invoke to assert the guard suppresses the second call.
|
||||
notificationsTesting.resetChannel();
|
||||
setNotificationChannelMock.mockClear();
|
||||
|
||||
// The mocked Platform defaults to ios in the harness; force android.
|
||||
const ReactNative = require('react-native');
|
||||
const originalOS = ReactNative.Platform.OS;
|
||||
ReactNative.Platform.OS = 'android';
|
||||
|
||||
try {
|
||||
ensureArkNotificationChannel();
|
||||
ensureArkNotificationChannel();
|
||||
ensureArkNotificationChannel();
|
||||
assert.strictEqual(setNotificationChannelMock.mock.calls.length, 1);
|
||||
} finally {
|
||||
ReactNative.Platform.OS = originalOS;
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -1,212 +0,0 @@
|
||||
import assert from 'assert';
|
||||
|
||||
import {
|
||||
closeAllArkadeRealms,
|
||||
closeArkadeRealm,
|
||||
deleteArkadeRealm,
|
||||
getArkadeRealm,
|
||||
__testing__,
|
||||
} from '../../blue_modules/arkade-adapters/realm/realmInstance';
|
||||
|
||||
const Keychain = require('react-native-keychain');
|
||||
const Realm = require('realm');
|
||||
const RNFS = require('react-native-fs');
|
||||
|
||||
beforeEach(() => {
|
||||
Keychain.__mockKeychainHelpers.reset();
|
||||
Realm.__mockRealmHelpers.reset();
|
||||
RNFS.__mockFsHelpers.reset();
|
||||
Keychain.setGenericPassword.mockClear();
|
||||
Keychain.getGenericPassword.mockClear();
|
||||
Keychain.resetGenericPassword.mockClear();
|
||||
Keychain.getSecurityLevel.mockClear();
|
||||
Realm.open.mockClear();
|
||||
Realm.exists.mockClear();
|
||||
Realm.deleteFile.mockClear();
|
||||
// Reset adapter's internal cache between tests so each test starts cold.
|
||||
closeAllArkadeRealms();
|
||||
});
|
||||
|
||||
describe('arkade realm adapter', () => {
|
||||
it('opens distinct Realm files per namespace', async () => {
|
||||
const r1 = await getArkadeRealm('ns-one');
|
||||
const r2 = await getArkadeRealm('ns-two');
|
||||
|
||||
assert.notStrictEqual(r1, r2);
|
||||
assert.notStrictEqual(__testing__.realmPathFor('ns-one'), __testing__.realmPathFor('ns-two'));
|
||||
assert.notStrictEqual(__testing__.keychainServiceFor('ns-one'), __testing__.keychainServiceFor('ns-two'));
|
||||
});
|
||||
|
||||
it('returns the cached instance on repeat opens for the same namespace', async () => {
|
||||
const r1 = await getArkadeRealm('ns');
|
||||
const r2 = await getArkadeRealm('ns');
|
||||
|
||||
assert.strictEqual(r1, r2);
|
||||
assert.strictEqual(Realm.open.mock.calls.length, 1);
|
||||
});
|
||||
|
||||
it('deduplicates concurrent opens for the same namespace', async () => {
|
||||
const [r1, r2, r3] = await Promise.all([getArkadeRealm('ns'), getArkadeRealm('ns'), getArkadeRealm('ns')]);
|
||||
|
||||
assert.strictEqual(r1, r2);
|
||||
assert.strictEqual(r2, r3);
|
||||
assert.strictEqual(Realm.open.mock.calls.length, 1);
|
||||
});
|
||||
|
||||
it('opens encrypted Realm with Ark + Boltz schemas', async () => {
|
||||
await getArkadeRealm('ns');
|
||||
|
||||
const config = Realm.open.mock.calls[0][0];
|
||||
assert.ok(Array.isArray(config.schema), 'schema is array');
|
||||
assert.ok(config.schema.length > 0, 'schema is non-empty');
|
||||
assert.ok(
|
||||
config.schema.some((s: any) => s.name === 'BoltzSwap'),
|
||||
'has BoltzSwap schema',
|
||||
);
|
||||
assert.ok(
|
||||
config.schema.some((s: any) => s.name === 'ArkVtxo'),
|
||||
'has ArkVtxo schema',
|
||||
);
|
||||
assert.ok(typeof config.schemaVersion === 'number', 'schemaVersion is a number');
|
||||
assert.ok(config.encryptionKey instanceof Uint8Array, 'encryptionKey is Uint8Array');
|
||||
assert.strictEqual(config.encryptionKey.length, 64, 'encryption key is 64 bytes');
|
||||
assert.strictEqual(config.excludeFromIcloudBackup, true);
|
||||
assert.ok(typeof config.onMigration === 'function', 'onMigration is a function');
|
||||
});
|
||||
|
||||
it('persists Realm encryption key per namespace and reuses it on reopen', async () => {
|
||||
await getArkadeRealm('ns');
|
||||
closeArkadeRealm('ns');
|
||||
await getArkadeRealm('ns');
|
||||
|
||||
assert.strictEqual(Keychain.setGenericPassword.mock.calls.length, 1, 'set called once');
|
||||
assert.ok(Keychain.getGenericPassword.mock.calls.length >= 2, 'get called at least twice');
|
||||
|
||||
const firstSet = Keychain.setGenericPassword.mock.calls[0];
|
||||
assert.strictEqual(firstSet[2].accessible, 'AccessibleWhenUnlockedThisDeviceOnly');
|
||||
assert.strictEqual(firstSet[2].service, __testing__.keychainServiceFor('ns'));
|
||||
});
|
||||
|
||||
it('reopens a fresh instance after closeArkadeRealm', async () => {
|
||||
const r1 = await getArkadeRealm('ns');
|
||||
closeArkadeRealm('ns');
|
||||
assert.strictEqual(r1.isClosed, true);
|
||||
|
||||
const r2 = await getArkadeRealm('ns');
|
||||
assert.notStrictEqual(r1, r2);
|
||||
assert.strictEqual(r2.isClosed, false);
|
||||
});
|
||||
|
||||
it('closeAllArkadeRealms closes every cached instance', async () => {
|
||||
const r1 = await getArkadeRealm('ns-a');
|
||||
const r2 = await getArkadeRealm('ns-b');
|
||||
|
||||
closeAllArkadeRealms();
|
||||
|
||||
assert.strictEqual(r1.isClosed, true);
|
||||
assert.strictEqual(r2.isClosed, true);
|
||||
});
|
||||
|
||||
it('deleteArkadeRealm closes Realm, removes Keychain entry, and clears cache', async () => {
|
||||
await getArkadeRealm('ns');
|
||||
assert.ok(Keychain.__mockKeychainHelpers.store.has(__testing__.keychainServiceFor('ns')));
|
||||
|
||||
await deleteArkadeRealm('ns');
|
||||
|
||||
assert.strictEqual(Realm.deleteFile.mock.calls.length, 1, 'Realm.deleteFile invoked');
|
||||
assert.strictEqual(Realm.deleteFile.mock.calls[0][0].path, __testing__.realmPathFor('ns'));
|
||||
assert.ok(!Keychain.__mockKeychainHelpers.store.has(__testing__.keychainServiceFor('ns')), 'keychain entry removed');
|
||||
assert.strictEqual(Keychain.resetGenericPassword.mock.calls.length, 1);
|
||||
|
||||
// Subsequent open creates a fresh keychain entry (rather than reusing the deleted one).
|
||||
Keychain.setGenericPassword.mockClear();
|
||||
await getArkadeRealm('ns');
|
||||
assert.strictEqual(Keychain.setGenericPassword.mock.calls.length, 1, 'new key generated after delete');
|
||||
});
|
||||
|
||||
it('preserves Keychain encryption key when Realm file deletion fails', async () => {
|
||||
await getArkadeRealm('ns');
|
||||
Realm.deleteFile.mockImplementationOnce(() => {
|
||||
throw new Error('disk error');
|
||||
});
|
||||
|
||||
await deleteArkadeRealm('ns');
|
||||
|
||||
assert.strictEqual(Keychain.resetGenericPassword.mock.calls.length, 0, 'keychain key preserved');
|
||||
assert.ok(Keychain.__mockKeychainHelpers.store.has(__testing__.keychainServiceFor('ns')), 'keychain entry still present');
|
||||
});
|
||||
|
||||
it('skips Realm.deleteFile when no realm file exists but still resets keychain', async () => {
|
||||
// Simulate a never-opened wallet whose keychain entry leaked. Realm.exists
|
||||
// returns false because we never called getArkadeRealm — but a stray
|
||||
// setGenericPassword still seeded Keychain.
|
||||
Keychain.__mockKeychainHelpers.store.set(__testing__.keychainServiceFor('ns'), {
|
||||
username: 'svc',
|
||||
password: 'deadbeef',
|
||||
service: __testing__.keychainServiceFor('ns'),
|
||||
});
|
||||
|
||||
await deleteArkadeRealm('ns');
|
||||
|
||||
assert.strictEqual(Realm.deleteFile.mock.calls.length, 0, 'no file deletion attempted');
|
||||
assert.strictEqual(Keychain.resetGenericPassword.mock.calls.length, 1, 'keychain still cleared');
|
||||
});
|
||||
|
||||
it('does not leak namespaces across wallets via shared keychain entries', async () => {
|
||||
await getArkadeRealm('walletA');
|
||||
await getArkadeRealm('walletB');
|
||||
|
||||
const services = Array.from(Keychain.__mockKeychainHelpers.store.keys());
|
||||
assert.ok(services.includes(__testing__.keychainServiceFor('walletA')));
|
||||
assert.ok(services.includes(__testing__.keychainServiceFor('walletB')));
|
||||
assert.notStrictEqual(services[0], services[1]);
|
||||
});
|
||||
|
||||
it('opts into SECURE_HARDWARE when getSecurityLevel reports it as supported', async () => {
|
||||
Keychain.getSecurityLevel.mockResolvedValueOnce('SECURE_HARDWARE');
|
||||
|
||||
await getArkadeRealm('ns');
|
||||
|
||||
assert.strictEqual(Keychain.setGenericPassword.mock.calls.length, 1);
|
||||
assert.strictEqual(Keychain.setGenericPassword.mock.calls[0][2].securityLevel, 'SECURE_HARDWARE');
|
||||
});
|
||||
|
||||
it('omits securityLevel option when hardware-backed keystore is not supported', async () => {
|
||||
// Android device without TEE/StrongBox: getSecurityLevel returns SECURE_SOFTWARE.
|
||||
Keychain.getSecurityLevel.mockResolvedValueOnce('SECURE_SOFTWARE');
|
||||
|
||||
await getArkadeRealm('ns');
|
||||
|
||||
assert.strictEqual(Keychain.setGenericPassword.mock.calls.length, 1);
|
||||
assert.strictEqual(
|
||||
Keychain.setGenericPassword.mock.calls[0][2].securityLevel,
|
||||
undefined,
|
||||
'no securityLevel passed → react-native-keychain default',
|
||||
);
|
||||
});
|
||||
|
||||
it('omits securityLevel option on iOS where getSecurityLevel returns null', async () => {
|
||||
Keychain.getSecurityLevel.mockResolvedValueOnce(null);
|
||||
|
||||
await getArkadeRealm('ns');
|
||||
|
||||
assert.strictEqual(Keychain.setGenericPassword.mock.calls.length, 1);
|
||||
assert.strictEqual(
|
||||
Keychain.setGenericPassword.mock.calls[0][2].securityLevel,
|
||||
undefined,
|
||||
'null preflight result → no securityLevel passed',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not silently downgrade when SECURE_HARDWARE setGenericPassword fails for unrelated reasons', async () => {
|
||||
Keychain.getSecurityLevel.mockResolvedValueOnce('SECURE_HARDWARE');
|
||||
Keychain.setGenericPassword.mockImplementationOnce(async () => {
|
||||
throw new Error('keystore write failed');
|
||||
});
|
||||
|
||||
await assert.rejects(getArkadeRealm('ns'), /keystore write failed/);
|
||||
|
||||
// Only one attempt — no fallback retry.
|
||||
assert.strictEqual(Keychain.setGenericPassword.mock.calls.length, 1);
|
||||
});
|
||||
});
|
||||
@ -43,19 +43,4 @@ describe('unit - encryption', function () {
|
||||
const decrypted = c.decrypt(crypted, 'password');
|
||||
assert.deepEqual(data2decrypt, decrypted);
|
||||
});
|
||||
|
||||
it('can decrypt a ciphertext produced by the OpenSSL CLI (wire-format check)', () => {
|
||||
// Regenerate this fixture with (copy-pasteable, verified to reproduce the byte string below):
|
||||
//
|
||||
// { printf 'Salted__\x01\x02\x03\x04\x05\x06\x07\x08'; \
|
||||
// printf 'hello world this is plaintext' \
|
||||
// | openssl enc -aes-256-cbc -k mypassword -S 0102030405060708 -md md5; \
|
||||
// } | base64
|
||||
//
|
||||
// OpenSSL's `enc` only emits the `Salted__` envelope when it picks the salt itself;
|
||||
// passing `-S <hex>` suppresses the header, so we prepend it manually. Pins the
|
||||
// on-disk format against an independent reference beyond crypto-js.
|
||||
const crypted = 'U2FsdGVkX18BAgMEBQYHCMqtJuZaneiHrVN/oMPPLvFplovZbI1K+lulGJn7NAvn';
|
||||
assert.strictEqual(c.decrypt(crypted, 'mypassword'), 'hello world this is plaintext');
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,51 +0,0 @@
|
||||
import assert from 'assert';
|
||||
|
||||
import { evpBytesToKeyMd5 } from '../../blue_modules/encryption';
|
||||
import { hexToUint8Array, stringToUint8Array, uint8ArrayToHex } from '../../blue_modules/uint8array-extras';
|
||||
|
||||
describe('evpBytesToKeyMd5', () => {
|
||||
// Vectors computed against the OpenSSL EVP_BytesToKey reference algorithm
|
||||
// (MD5, 1 iteration). The KDF is purely deterministic, so a single fixed
|
||||
// (password, salt) pair pins the bytes our wallet store relies on.
|
||||
it('matches the OpenSSL CLI reference for password="mypassword"', () => {
|
||||
// openssl enc -aes-256-cbc -k mypassword -S 0102030405060708 -md md5 -p
|
||||
const out = evpBytesToKeyMd5(stringToUint8Array('mypassword'), hexToUint8Array('0102030405060708'), 48);
|
||||
assert.strictEqual(uint8ArrayToHex(out.subarray(0, 32)), '20814c3ad75ac1d26c61a8e4702b5ff4d7baaee00c595bab71592aaf45bf41e4');
|
||||
assert.strictEqual(uint8ArrayToHex(out.subarray(32, 48)), '43269499cb6d59f4e3b9dda68098b673');
|
||||
});
|
||||
|
||||
it('matches a Node-crypto reference vector for a multi-word password', () => {
|
||||
const out = evpBytesToKeyMd5(stringToUint8Array('correct horse'), hexToUint8Array('0102030405060708'), 48);
|
||||
assert.strictEqual(uint8ArrayToHex(out.subarray(0, 32)), 'bcf8d941d9291141709c9d56360eb7148e3960ab3dc44d832c4028568545c91d');
|
||||
assert.strictEqual(uint8ArrayToHex(out.subarray(32, 48)), '5a7a1d12207f801d2f6f4cf578e8708c');
|
||||
});
|
||||
|
||||
it('returns exactly the requested number of bytes', () => {
|
||||
const pwd = stringToUint8Array('pw');
|
||||
const salt = hexToUint8Array('00000000000000ff');
|
||||
assert.strictEqual(evpBytesToKeyMd5(pwd, salt, 1).length, 1);
|
||||
assert.strictEqual(evpBytesToKeyMd5(pwd, salt, 15).length, 15);
|
||||
assert.strictEqual(evpBytesToKeyMd5(pwd, salt, 16).length, 16); // one MD5 block exactly
|
||||
assert.strictEqual(evpBytesToKeyMd5(pwd, salt, 17).length, 17); // one block + 1
|
||||
assert.strictEqual(evpBytesToKeyMd5(pwd, salt, 48).length, 48); // key + iv default
|
||||
assert.strictEqual(evpBytesToKeyMd5(pwd, salt, 65).length, 65); // multi-block + spillover
|
||||
});
|
||||
|
||||
it('is a prefix-stable stream (same first N bytes regardless of total length)', () => {
|
||||
const pwd = stringToUint8Array('xyz');
|
||||
const salt = hexToUint8Array('cafebabedeadbeef');
|
||||
const long = evpBytesToKeyMd5(pwd, salt, 64);
|
||||
for (const n of [1, 16, 17, 32, 48]) {
|
||||
assert.strictEqual(uint8ArrayToHex(evpBytesToKeyMd5(pwd, salt, n)), uint8ArrayToHex(long.subarray(0, n)));
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects non-integer or negative byteLength', () => {
|
||||
const pwd = stringToUint8Array('pw');
|
||||
const salt = hexToUint8Array('0102030405060708');
|
||||
assert.throws(() => evpBytesToKeyMd5(pwd, salt, -1));
|
||||
assert.throws(() => evpBytesToKeyMd5(pwd, salt, 1.5));
|
||||
assert.throws(() => evpBytesToKeyMd5(pwd, salt, NaN));
|
||||
assert.strictEqual(evpBytesToKeyMd5(pwd, salt, 0).length, 0);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user