Compare commits

...

389 Commits

Author SHA1 Message Date
GLaDOS
32d3f77f4f
Merge pull request #8694 from BlueWallet/renovate/rubygems-concurrent-ruby-vulnerability
Some checks failed
BuildReleaseApk / buildReleaseApk (push) Has been cancelled
Build Release and Upload to TestFlight (iOS) / build (push) Has been cancelled
Build Release and Upload to TestFlight (iOS) / testflight-upload (push) Has been cancelled
BuildReleaseApk / browserstack (push) Has been cancelled
OPS: Update dependency concurrent-ruby to '< 1.3.8' [SECURITY]
2026-06-22 17:13:35 +01:00
GLaDOS
f26ff9189c
Merge pull request #8695 from BlueWallet/fix-walletcarouselclipping
FIX: clipping wallet balance on carousel
2026-06-22 17:13:29 +01:00
ncoelho
1fa290652c FIX: clipping wallet balance on carousel 2026-06-22 15:41:14 +02:00
renovate[bot]
099f6f46a6
OPS: Update dependency concurrent-ruby to '< 1.3.8' [SECURITY] 2026-06-22 13:32:53 +00:00
Nuno
01a11bc8dd
FIX: text size on main app views (#8689)
* fix: text size on wallet view

* fix big font sizes

* fix lint

* fix Glados comments

* fix: run prettier

---------

Co-authored-by: Ivan Vershigora <ivan.vershigora@gmail.com>
2026-06-22 15:27:48 +02:00
GLaDOS
6639891c24
Merge pull request #8632 from BlueWallet/fix-custom-input-lag
Some checks failed
Build Release and Upload to TestFlight (iOS) / build (push) Has been cancelled
BuildReleaseApk / buildReleaseApk (push) Has been cancelled
Build Release and Upload to TestFlight (iOS) / testflight-upload (push) Has been cancelled
BuildReleaseApk / browserstack (push) Has been cancelled
FIX: Amount input lag in rbf custom fee input
2026-06-20 11:45:47 +01:00
GLaDOS
4029d294f8
Merge pull request #8566 from BlueWallet/cryptojs
Some checks are pending
Build Release and Upload to TestFlight (iOS) / build (push) Waiting to run
Build Release and Upload to TestFlight (iOS) / testflight-upload (push) Blocked by required conditions
BuildReleaseApk / buildReleaseApk (push) Waiting to run
BuildReleaseApk / browserstack (push) Blocked by required conditions
REF: swap crypto-js for @noble/ciphers + hashes
2026-06-19 15:00:49 +01:00
Ivan Vershigora
276a9ea8f8
REF: swap crypto-js for @noble/ciphers + hashes 2026-06-19 12:23:35 +01:00
Nuno
d415f1a0b8
feat: iOS 26 glass (#8508)
Some checks failed
Build Release and Upload to TestFlight (iOS) / build (push) Has been cancelled
BuildReleaseApk / buildReleaseApk (push) Has been cancelled
Build Release and Upload to TestFlight (iOS) / testflight-upload (push) Has been cancelled
BuildReleaseApk / browserstack (push) Has been cancelled
2026-06-18 16:37:24 +01:00
Nuno
6124cf1c04
fix: key on tx list (#8687) 2026-06-18 14:17:47 +01:00
renovate[bot]
b922346bb6
fix(deps): update dependency react-native-permissions to v5.5.3 (#8670)
Some checks are pending
Build Release and Upload to TestFlight (iOS) / build (push) Waiting to run
Build Release and Upload to TestFlight (iOS) / testflight-upload (push) Blocked by required conditions
BuildReleaseApk / buildReleaseApk (push) Waiting to run
BuildReleaseApk / browserstack (push) Blocked by required conditions
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Overtorment <overtorment@gmail.com>
2026-06-17 21:57:35 +01:00
Cursor Agent
64f1bd78db FIX: persist Arkade LNURL payment result
Co-authored-by: Overtorment <Overtorment@users.noreply.github.com>
2026-06-17 20:54:03 +01:00
Overtorment
1412a302a1 OPS: improve renovate bot 2026-06-17 17:57:39 +01:00
GLaDOS
6785427fe8
Merge pull request #8678 from BlueWallet/renovate/react-navigation-monorepo
fix(deps): update react-navigation monorepo
2026-06-17 17:47:50 +01:00
renovate[bot]
1f0ce7c813 chore(deps): update dependency @react-native/js-polyfills to ^0.86.0 2026-06-17 17:27:05 +01:00
Ramez Medhat
f5379795de ADD: support importing Unchained JSON as multisig cosigner
Some checks failed
Build Release and Upload to TestFlight (iOS) / build (push) Has been cancelled
BuildReleaseApk / buildReleaseApk (push) Has been cancelled
Build Release and Upload to TestFlight (iOS) / testflight-upload (push) Has been cancelled
BuildReleaseApk / browserstack (push) Has been cancelled
- Normalized `h` to `'` on all three derivation paths and accept either field name p2wsh_p2sh (Coldcard) or for p2sh_p2wsh (Unchained)
- Added a unit test for that.

Closes: https://github.com/BlueWallet/BlueWallet/issues/8251
2026-06-15 16:59:40 +01:00
Ivan Vershigora
c76db2f84a tst: fix flaky goBack in iOS e2e tests
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-15 16:20:46 +01:00
Andrew Camilleri (Kukks)
cb6e3ae69b OPS: bump Arkade SDKs (@arkade-os/sdk 0.4.35, @arkade-os/boltz-swap 0.3.40) 2026-06-15 16:12:52 +01:00
renovate[bot]
7bc2c0e797
fix(deps): update react-navigation monorepo 2026-06-14 17:19:34 +00:00
GLaDOS
5a7c514548
Merge pull request #8617 from BlueWallet/renovate/react-native-vector-icons-entypo-13.x
Some checks are pending
BuildReleaseApk / browserstack (push) Blocked by required conditions
Build Release and Upload to TestFlight (iOS) / build (push) Waiting to run
Build Release and Upload to TestFlight (iOS) / testflight-upload (push) Blocked by required conditions
BuildReleaseApk / buildReleaseApk (push) Waiting to run
fix(deps): update dependency @react-native-vector-icons/entypo to v13.1.2
2026-06-14 18:14:01 +01:00
Ojok Emmanuel Nsubuga
81cf0011b3
Merge branch 'master' into fix-custom-input-lag 2026-06-14 14:27:05 +03:00
Ivan Vershigora
94062ffc9f fix: improve typescript coverage
Some checks are pending
Build Release and Upload to TestFlight (iOS) / build (push) Waiting to run
Build Release and Upload to TestFlight (iOS) / testflight-upload (push) Blocked by required conditions
BuildReleaseApk / buildReleaseApk (push) Waiting to run
BuildReleaseApk / browserstack (push) Blocked by required conditions
2026-06-14 11:28:25 +01:00
GLaDOS
0449a25c6a
Merge pull request #8669 from BlueWallet/renovate/dayjs-1.x
Some checks are pending
BuildReleaseApk / browserstack (push) Blocked by required conditions
Build Release and Upload to TestFlight (iOS) / build (push) Waiting to run
Build Release and Upload to TestFlight (iOS) / testflight-upload (push) Blocked by required conditions
BuildReleaseApk / buildReleaseApk (push) Waiting to run
fix(deps): update dependency dayjs to v1.11.21
2026-06-13 19:06:04 +01:00
renovate[bot]
ff98ca0c1c
fix(deps): update dependency dayjs to v1.11.21 2026-06-13 16:00:26 +00:00
GLaDOS
c9dcbf40e7
Merge pull request #8653 from BlueWallet/renovate/react-native-vector-icons-material-icons-13.x
fix(deps): update dependency @react-native-vector-icons/material-icons to v13.1.2
2026-06-13 16:51:33 +01:00
GLaDOS
c8a7887808
Merge pull request #8649 from BlueWallet/renovate/react-native-vector-icons-fontawesome6-13.x
Some checks are pending
Build Release and Upload to TestFlight (iOS) / build (push) Waiting to run
Build Release and Upload to TestFlight (iOS) / testflight-upload (push) Blocked by required conditions
BuildReleaseApk / buildReleaseApk (push) Waiting to run
BuildReleaseApk / browserstack (push) Blocked by required conditions
fix(deps): update dependency @react-native-vector-icons/fontawesome6 to v13.1.2
2026-06-13 03:21:50 +01:00
Marcos Rodriguez Vélez
e4504b2355
FIX: Remove chevron on modal open ux (#8651)
* FIX: Remove chevron on modal open ux

* Update WalletDetails.tsx
2026-06-13 03:21:46 +01:00
renovate[bot]
13cedbe49e
fix(deps): update dependency @react-native-vector-icons/material-icons to v13.1.2 2026-06-12 17:26:13 +00:00
GLaDOS
abb80665af
Merge pull request #8652 from BlueWallet/renovate/react-native-vector-icons-material-design-icons-13.x
fix(deps): update dependency @react-native-vector-icons/material-design-icons to v13.1.2
2026-06-12 18:12:58 +01:00
renovate[bot]
681cbcf2dc
fix(deps): update dependency @react-native-vector-icons/material-design-icons to v13.1.2 2026-06-12 05:30:13 +00:00
renovate[bot]
78c9d49359
fix(deps): update dependency @react-native-vector-icons/fontawesome6 to v13.1.2 2026-06-12 05:30:02 +00:00
renovate[bot]
da606dbff0
fix(deps): update dependency @react-native-vector-icons/entypo to v13.1.2 2026-06-12 05:29:49 +00:00
GLaDOS
8fda883933
Merge pull request #8648 from BlueWallet/renovate/react-native-vector-icons-fontawesome-13.x
Some checks are pending
Build Release and Upload to TestFlight (iOS) / build (push) Waiting to run
Build Release and Upload to TestFlight (iOS) / testflight-upload (push) Blocked by required conditions
BuildReleaseApk / buildReleaseApk (push) Waiting to run
BuildReleaseApk / browserstack (push) Blocked by required conditions
fix(deps): update dependency @react-native-vector-icons/fontawesome to v13.1.2
2026-06-12 06:24:27 +01:00
GLaDOS
c4abe9562e
Merge pull request #8650 from BlueWallet/renovate/react-native-vector-icons-ionicons-13.x
fix(deps): update dependency @react-native-vector-icons/ionicons to v13.1.2
2026-06-12 06:24:22 +01:00
renovate[bot]
5d8e605fe7
fix(deps): update dependency @react-native-vector-icons/ionicons to v13.1.2 2026-06-12 00:17:10 +00:00
GLaDOS
387f8dffd6
Merge pull request #8646 from BlueWallet/renovate/arkade-os-boltz-swap-0.x
Some checks are pending
Build Release and Upload to TestFlight (iOS) / build (push) Waiting to run
Build Release and Upload to TestFlight (iOS) / testflight-upload (push) Blocked by required conditions
BuildReleaseApk / buildReleaseApk (push) Waiting to run
BuildReleaseApk / browserstack (push) Blocked by required conditions
fix(deps): update dependency @arkade-os/boltz-swap to v0.3.38
2026-06-12 01:10:04 +01:00
renovate[bot]
b34f52fdce
fix(deps): update dependency @arkade-os/boltz-swap to v0.3.38 2026-06-11 21:40:26 +00:00
GLaDOS
a3c12a27eb
Merge pull request #8647 from BlueWallet/renovate/arkade-os-sdk-0.x
fix(deps): update dependency @arkade-os/sdk to v0.4.33
2026-06-11 22:28:44 +01:00
Ivan Vershigora
a04cad686f fix: comment with the reference 2026-06-11 19:52:13 +01:00
D N
61877ed0db No babel duplicates 2026-06-11 19:52:13 +01:00
renovate[bot]
859877979e
fix(deps): update dependency @react-native-vector-icons/fontawesome to v13.1.2 2026-06-11 17:58:31 +00:00
GLaDOS
71c76bd8c8
Merge pull request #8643 from BlueWallet/renovate/actions-checkout-6.x
chore(deps): update actions/checkout action to v6.0.3
2026-06-11 18:44:41 +01:00
renovate[bot]
dd52747191
fix(deps): update dependency @arkade-os/sdk to v0.4.33 2026-06-11 16:45:01 +00:00
renovate[bot]
fa9434b0de
fix(deps): update dependency @noble/secp256k1 to v1.7.2 (#8590)
* fix(deps): update dependency @noble/secp256k1 to v1.7.2

* fix(deps): secp

* fix(deps): secp

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Overtorment <overtorment@gmail.com>
2026-06-11 17:27:52 +01:00
GLaDOS
770a7a4075
Merge pull request #8639 from BlueWallet/perf-hd-fetch-transactions
REF: single-pass tx-to-address mapping in HD fetchTransactions
2026-06-11 14:37:51 +01:00
renovate[bot]
f6f0238e0a
chore(deps): update actions/checkout action to v6.0.3 2026-06-11 11:47:08 +00:00
Ivan Vershigora
236791f32e
REF: single-pass tx-to-address mapping in HD fetchTransactions
Mapping fetched transactions into per-address cells re-scanned every
transaction for every address index, and the upsert-by-txid step
re-scanned the target cell for every insert — both quadratic. Build
address -> index lookup maps once, do a single pass over fetched
transactions, and keep a per-cell txid -> position map for
constant-time upserts.

Measured on a wallet emulating one address with 63,017 real
transactions (genesis address), median of 3 runs on real chain data:
mapping went from 32.1s to 0.47s.

Also remove a duplicate @babel/preset-env entry from devDependencies:
it is pinned in dependencies (needed by the --omit=dev iOS release
build), so the second entry was ignored by npm.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 10:54:15 +01:00
Overtorment
0181f0a849
ADD: arkade ln pushes (#8634)
Some checks are pending
Build Release and Upload to TestFlight (iOS) / build (push) Waiting to run
Build Release and Upload to TestFlight (iOS) / testflight-upload (push) Blocked by required conditions
BuildReleaseApk / buildReleaseApk (push) Waiting to run
BuildReleaseApk / browserstack (push) Blocked by required conditions
2026-06-10 17:35:17 +01:00
Cursor Agent
f334b985e8 REF: remove GroundControl server URI saving from notifications module
Some checks are pending
Build Release and Upload to TestFlight (iOS) / build (push) Waiting to run
Build Release and Upload to TestFlight (iOS) / testflight-upload (push) Blocked by required conditions
BuildReleaseApk / buildReleaseApk (push) Waiting to run
BuildReleaseApk / browserstack (push) Blocked by required conditions
Co-authored-by: Overtorment <Overtorment@users.noreply.github.com>
2026-06-10 12:44:18 +01:00
Cursor Agent
bfeda40284 REF: remove GroundControl server URL option from notification settings
Co-authored-by: Overtorment <Overtorment@users.noreply.github.com>
2026-06-10 12:44:18 +01:00
Ivan Vershigora
a4e224ec96 fix: no round corners in qrcode
Some checks failed
Build Release and Upload to TestFlight (iOS) / build (push) Has been cancelled
BuildReleaseApk / buildReleaseApk (push) Has been cancelled
Build Release and Upload to TestFlight (iOS) / testflight-upload (push) Has been cancelled
BuildReleaseApk / browserstack (push) Has been cancelled
2026-06-08 22:25:34 +01:00
Ojok Emmanuel Nsubuga
d259e68a85 FIX: Amount input lag in rbf custom fee input 2026-06-08 08:44:57 +03:00
Marcos Rodriguez
926a863d27 OPS: Bump version
Some checks failed
BuildReleaseApk / buildReleaseApk (push) Has been cancelled
Build Release and Upload to TestFlight (iOS) / build (push) Has been cancelled
Build Release and Upload to TestFlight (iOS) / testflight-upload (push) Has been cancelled
BuildReleaseApk / browserstack (push) Has been cancelled
2026-06-04 22:03:02 +01:00
GLaDOS
f0336e1789
Merge pull request #8618 from BlueWallet/fix-startup-crash
Some checks failed
Build Release and Upload to TestFlight (iOS) / build (push) Has been cancelled
BuildReleaseApk / buildReleaseApk (push) Has been cancelled
Build Release and Upload to TestFlight (iOS) / testflight-upload (push) Has been cancelled
BuildReleaseApk / browserstack (push) Has been cancelled
FIX: startup crash
2026-06-02 14:01:21 +01:00
Overtorment
2c8cfd3690 FIX: startup crash 2026-06-01 18:16:27 +01:00
pietro909
e37c4a693c
OPS: upgrade Arkade SDKs and harden Ark wallet integration (#8585)
Some checks are pending
Build Release and Upload to TestFlight (iOS) / build (push) Waiting to run
Build Release and Upload to TestFlight (iOS) / testflight-upload (push) Blocked by required conditions
BuildReleaseApk / buildReleaseApk (push) Waiting to run
BuildReleaseApk / browserstack (push) Blocked by required conditions
2026-06-01 15:22:17 +01:00
GladosBlueWallet
2a3de6f473 OPS: swap GroundControl production URL to groundcontrol.bluewallet.io 2026-06-01 11:08:57 +01:00
GLaDOS
bbd6101ddb
OPS: bump detox to 20.51.3 (#8613)
Some checks failed
Build Release and Upload to TestFlight (iOS) / build (push) Has been cancelled
BuildReleaseApk / buildReleaseApk (push) Has been cancelled
Build Release and Upload to TestFlight (iOS) / testflight-upload (push) Has been cancelled
BuildReleaseApk / browserstack (push) Has been cancelled
* OPS: bump detox to 20.51.3

* OPS: pin detox to exact version 20.51.3
2026-05-31 13:51:45 +01:00
Marcos Rodriguez Vélez
687e007c56
OPS: Bump Nav packages (#8573)
Some checks failed
Build Release and Upload to TestFlight (iOS) / build (push) Has been cancelled
BuildReleaseApk / buildReleaseApk (push) Has been cancelled
Build Release and Upload to TestFlight (iOS) / testflight-upload (push) Has been cancelled
BuildReleaseApk / browserstack (push) Has been cancelled
* OPS: Bump Nav packages

* OPS: Package update

---------

Co-authored-by: Overtorment <overtorment@gmail.com>
2026-05-28 23:03:43 +01:00
GLaDOS
0641bf70cd
Merge pull request #8588 from BlueWallet/renovate/bugsnag-js-monorepo
Some checks are pending
Build Release and Upload to TestFlight (iOS) / build (push) Waiting to run
Build Release and Upload to TestFlight (iOS) / testflight-upload (push) Blocked by required conditions
BuildReleaseApk / buildReleaseApk (push) Waiting to run
BuildReleaseApk / browserstack (push) Blocked by required conditions
fix(deps): update dependency @bugsnag/react-native to v8.9.0
2026-05-28 20:35:00 +01:00
renovate[bot]
cbe6ebb423
fix(deps): update dependency @bugsnag/react-native to v8.9.0 2026-05-28 14:09:13 +00:00
Overtorment
e19ce1136f REF: electrum 2026-05-28 13:04:49 +01:00
dependabot[bot]
1a88c085ec build(deps): bump tmp from 0.2.5 to 0.2.7
Bumps [tmp](https://github.com/raszi/node-tmp) from 0.2.5 to 0.2.7.
- [Changelog](https://github.com/raszi/node-tmp/blob/master/CHANGELOG.md)
- [Commits](https://github.com/raszi/node-tmp/compare/v0.2.5...v0.2.7)

---
updated-dependencies:
- dependency-name: tmp
  dependency-version: 0.2.7
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-28 10:40:55 +01:00
PeterXMR
d7261d4d2a FIX: ElectrumTransaction confirmation fields are optional until mined (#8093)
Electrum's `blockchain.transaction.get` verbose response does not include
`blockhash`, `confirmations`, `time`, or `blocktime` when the transaction
is still in the mempool. Both `ElectrumTransaction` in
`blue_modules/BlueElectrum.ts` and the sibling `Transaction` type in
`class/wallets/types.ts` declared all four as required, which silently
let unguarded access compile and crash at runtime on real mempool data.

- Mark the four confirmation-only fields optional on both types. They
  describe the same shape and have the same bug.
- Export `ElectrumTransaction` so a regression test can reference it.
- Collapse the two-line `tx.timestamp = tx.blocktime; if (!tx.blocktime)
  tx.timestamp = ...` pattern in `abstract-hd-electrum-wallet.ts` into a
  single `||` fallback — type-safe and runtime-equivalent.
- Add nullish-coalesce guards at the two call sites that compared
  `confirmations` directly to a number. In `useWidgetCommunication.ios.ts`,
  `t.confirmations ?? 0` keeps the filter semantically unchanged. In
  `PaymentCodesList.tsx`, normalize once via
  `notificationTx?.confirmations ?? 0` and use the local in both the
  `> 0` (already confirmed) and `=== 0` (mempool / unconfirmed alert)
  branches — otherwise a mempool notification tx would skip both branches
  and the code would create a duplicate notification transaction.
- Add `tests/unit/electrum-transaction-types.test.ts` documenting that a
  mempool-shaped object satisfies the type.
2026-05-28 09:43:46 +01:00
GLaDOS
4aa07ed904
Merge pull request #8591 from BlueWallet/ref-managewallets-2
Some checks are pending
Build Release and Upload to TestFlight (iOS) / build (push) Waiting to run
Build Release and Upload to TestFlight (iOS) / testflight-upload (push) Blocked by required conditions
BuildReleaseApk / buildReleaseApk (push) Waiting to run
BuildReleaseApk / browserstack (push) Blocked by required conditions
tst: manage wallets
2026-05-27 22:22:18 +01:00
Marcos Rodriguez
b315d587ff FIX: Manage funds layout 2026-05-27 20:43:00 +01:00
Ivan Vershigora
d367a2f383 FIX: setDisabled aborts in-flight ensureConnected; keep disabled state through abort; add tests 2026-05-27 20:40:07 +01:00
Overtorment
e4c8a3057d REF: blue electrum 2026-05-27 20:40:07 +01:00
Overtorment
1109a836e9 REF: blue electrum 2026-05-27 20:40:07 +01:00
Overtorment
8e62aee2fc REF: blue electrum 2026-05-27 20:40:07 +01:00
Overtorment
220cd7e61d REF: blue electrum 2026-05-27 20:40:07 +01:00
Ivan Vershigora
d043b86310
tst: manage wallets 2026-05-27 19:23:55 +01:00
GLaDOS
8310c5f9fa
Merge pull request #8601 from BlueWallet/fix-amount-input
FIX: swap AmountInput zIndex so TextInput sits on top
2026-05-27 19:14:06 +01:00
Ivan Vershigora
8599607574
FIX: swap AmountInput zIndex so TextInput sits on top 2026-05-27 12:31:48 +01:00
dependabot[bot]
1a0ac1188e build(deps): bump qs from 6.15.0 to 6.15.2
Some checks failed
Build Release and Upload to TestFlight (iOS) / build (push) Has been cancelled
BuildReleaseApk / buildReleaseApk (push) Has been cancelled
Build Release and Upload to TestFlight (iOS) / testflight-upload (push) Has been cancelled
BuildReleaseApk / browserstack (push) Has been cancelled
Bumps [qs](https://github.com/ljharb/qs) from 6.15.0 to 6.15.2.
- [Changelog](https://github.com/ljharb/qs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ljharb/qs/compare/v6.15.0...v6.15.2)

---
updated-dependencies:
- dependency-name: qs
  dependency-version: 6.15.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-23 15:59:27 +01:00
GLaDOS
66ca6a9fca
Merge pull request #8583 from BlueWallet/renovate/fastlane-2.x
Some checks are pending
Build Release and Upload to TestFlight (iOS) / build (push) Waiting to run
Build Release and Upload to TestFlight (iOS) / testflight-upload (push) Blocked by required conditions
BuildReleaseApk / buildReleaseApk (push) Waiting to run
BuildReleaseApk / browserstack (push) Blocked by required conditions
chore(deps): update dependency fastlane to "~> 2.234.0"
2026-05-22 13:05:33 +01:00
GLaDOS
90fdee2a8f
Merge pull request #8581 from BlueWallet/renovate/lottie-react-native-7.x
Some checks are pending
Build Release and Upload to TestFlight (iOS) / build (push) Waiting to run
Build Release and Upload to TestFlight (iOS) / testflight-upload (push) Blocked by required conditions
BuildReleaseApk / buildReleaseApk (push) Waiting to run
BuildReleaseApk / browserstack (push) Blocked by required conditions
fix(deps): update dependency lottie-react-native to v7.3.8
2026-05-22 00:58:37 +01:00
renovate[bot]
d14556ff80
fix(deps): update react-navigation monorepo (#8582)
* fix(deps): update react-navigation monorepo

* OPS: Ruby

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Marcos Rodriguez <marcospr@pm.me>
2026-05-22 00:32:17 +01:00
GLaDOS
047b675faf
Merge pull request #8543 from BlueWallet/renovate/react-native-reanimated-4.x
fix(deps): update dependency react-native-reanimated to v4.3.1
2026-05-22 00:04:06 +01:00
GLaDOS
7085dd4a81
Merge pull request #8565 from BlueWallet/deps
REF: prune unused deps + stale tsconfig entries
2026-05-21 22:56:49 +01:00
renovate[bot]
8c58df270a
chore(deps): update dependency fastlane to "~> 2.234.0" 2026-05-21 21:23:19 +00:00
Marcos Rodriguez Vélez
3952ec7e12
ADD: If lndhub server is set then button should be visible (#8572)
Co-authored-by: Overtorment <overtorment@gmail.com>
2026-05-21 22:21:41 +01:00
GLaDOS
26b6419507
Merge pull request #8578 from BlueWallet/prompt
ref: prompt
2026-05-21 21:46:52 +01:00
renovate[bot]
964ceecd6a
fix(deps): update dependency lottie-react-native to v7.3.8 2026-05-21 20:02:57 +00:00
renovate[bot]
df782c5f7a chore(deps): update dependency ruby to v3.4.9 2026-05-21 21:01:23 +01:00
Overtorment
f7c72e13d4
Merge branch 'master' into renovate/react-native-reanimated-4.x 2026-05-21 21:00:59 +01:00
renovate[bot]
9e66ed003a fix(deps): update electrum-client digest to 83420b8 2026-05-21 21:00:16 +01:00
renovate[bot]
08c4c02491 fix(deps): update dependency @babel/preset-env to v7.29.5 2026-05-21 20:59:49 +01:00
renovate[bot]
3c199ab9f2 fix(deps): update react-native-capture-protection digest to b17b9ec 2026-05-21 20:57:37 +01:00
GLaDOS
0881efbd40
Merge pull request #8580 from BlueWallet/fix-inputamount-2
fix: styles in AmountInput
2026-05-21 17:02:06 +01:00
Ivan Vershigora
a5a7d34478 FIX: patch react-native-tcp-socket onConnect crash; stabilize iOS e2e 2026-05-21 16:33:43 +01:00
Ivan Vershigora
c8344e6037
ref: prompt 2026-05-21 13:29:42 +01:00
Ivan Vershigora
54c5f34e11
fix: styles in AmountInput 2026-05-21 12:53:41 +01:00
Nuno
431a8006ea
fix: center amount input (#8574) 2026-05-21 12:18:52 +02:00
Nuno
092c437557
fix: fee jump on mount (#8575) 2026-05-21 12:17:39 +02:00
dependabot[bot]
f65ccb5427 build(deps-dev): bump @babel/plugin-transform-modules-systemjs
Bumps [@babel/plugin-transform-modules-systemjs](https://github.com/babel/babel/tree/HEAD/packages/babel-plugin-transform-modules-systemjs) from 7.29.0 to 7.29.4.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.29.4/packages/babel-plugin-transform-modules-systemjs)

---
updated-dependencies:
- dependency-name: "@babel/plugin-transform-modules-systemjs"
  dependency-version: 7.29.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-21 11:09:37 +01:00
Nuno
aa5a6ba7f0
fix: inputs alignments (#8570) 2026-05-21 09:14:38 +02:00
Nuno
9e907566f0
feat: redesigned wallet details (#8301)
* feat: redesign transaction detail screen with unified layout and Lottie pending animation

* ADD: decode OP_RETURN payload as UTF-8 text in transaction detail

Co-authored-by: Cursor <cursoragent@cursor.com>

* REF: transaction detail redesign (themes, pending icon, loc)

Co-authored-by: Cursor <cursoragent@cursor.com>

* REF: remove deprecated TransactionDetails, TransactionStatus and getTransactionStatusOptions

Co-authored-by: Cursor <cursoragent@cursor.com>

* FIX: resolve lint errors (unused vars, styles, loc keys, no-bitwise, inline styles)

Co-authored-by: Cursor <cursoragent@cursor.com>

* FIX: remove redundant !tx check in transaction detail guard

Co-authored-by: Cursor <cursoragent@cursor.com>

* FIX: show transaction not available when tx not found after load

Co-authored-by: Cursor <cursoragent@cursor.com>

* FIX: remove unused transaction prop type from TransactionDetail

Co-authored-by: Cursor <cursoragent@cursor.com>

* TST: update UTXO note E2E to use new transaction detail note prompt UI

Co-authored-by: Cursor <cursoragent@cursor.com>

* simplify changes on the PR for review

* remove unused loc

* remove unchanged colors

* better offline support for tx details

* remove unused key loc

* fix code review issues

* fix balance

* fix tests

* REF: address PR #8289 review feedback

* redesigned wallets details

* fix lint

* fix lint

* fix bip84 test

* fix test

* fix tests

* fix tests

* fix: truncation and sendTo logic display

* fix: new arch fixes

* fix: lint

* fix: crash on status update

* fix: lint and tests

* fix: tests

* fix: tests

* fix: tests

* fix: tests

* fix: tests

* Potential fix for pull request finding 'Identical operands'

Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>

* fix: tests

* fix: tests

* fix: tests

* fix: tests

* fix: tests

* fix style

* fix merge master

* Merge branch 'wallet-details' of https://github.com/BlueWallet/BlueWallet into wallet-details

* fix loc

* fix loc

* fix style

* improve coin control from wallet details

* fix: e2e

* fix: WalletDetails

* fix: flat

* fix: e2e

* fix: e2e

* Potential fix for pull request finding 'Unused variable, import, function or class'

Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>

* fix: remove notifications dialogs

* fix: second button title

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Ivan Vershigora <ivan.vershigora@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
Co-authored-by: Overtorment <overtorment@gmail.com>
2026-05-21 09:12:49 +02:00
Ivan Vershigora
265cebef62 REF: prune unused deps + stale tsconfig entries 2026-05-20 18:40:25 +01:00
GLaDOS
c1c13e9e58
Merge pull request #8577 from BlueWallet/fix-wakeup-revert
Some checks failed
Build Release and Upload to TestFlight (iOS) / build (push) Has been cancelled
BuildReleaseApk / buildReleaseApk (push) Has been cancelled
Build Release and Upload to TestFlight (iOS) / testflight-upload (push) Has been cancelled
BuildReleaseApk / browserstack (push) Has been cancelled
fix: revert wake up
2026-05-20 17:37:13 +01:00
Ivan Vershigora
87b2bb2156
fix: revert wake up 2026-05-20 14:54:11 +01:00
GLaDOS
7a5589eb00
Merge pull request #8564 from BlueWallet/components
Some checks are pending
Build Release and Upload to TestFlight (iOS) / build (push) Waiting to run
Build Release and Upload to TestFlight (iOS) / testflight-upload (push) Blocked by required conditions
BuildReleaseApk / buildReleaseApk (push) Waiting to run
BuildReleaseApk / browserstack (push) Blocked by required conditions
REF: split BlueComponents, prune dead/optional props
2026-05-19 22:16:24 +01:00
GLaDOS
87cf8a5600
Merge pull request #8559 from BlueWallet/i18-lazy
Some checks are pending
Build Release and Upload to TestFlight (iOS) / build (push) Waiting to run
Build Release and Upload to TestFlight (iOS) / testflight-upload (push) Blocked by required conditions
BuildReleaseApk / buildReleaseApk (push) Waiting to run
BuildReleaseApk / browserstack (push) Blocked by required conditions
ref: lazy-load translations
2026-05-19 16:53:23 +01:00
Ojok Emmanuel Nsubuga
1da0414474
ADD: Import private keys in hex or base64 formats 2026-05-19 13:18:31 +01:00
GLaDOS
151dbbbc67
Merge pull request #8561 from BlueWallet/e2e-simplify
Some checks are pending
Build Release and Upload to TestFlight (iOS) / build (push) Waiting to run
Build Release and Upload to TestFlight (iOS) / testflight-upload (push) Blocked by required conditions
BuildReleaseApk / buildReleaseApk (push) Waiting to run
BuildReleaseApk / browserstack (push) Blocked by required conditions
tst: simplify wallet discovery and import test
2026-05-18 19:32:10 +01:00
renovate[bot]
bcc393dca8
fix(deps): update dependency react-native-reanimated to v4.3.1 2026-05-18 12:13:49 +00:00
Ivan Vershigora
1766cadcdf
REF: split BlueComponents, prune dead/optional props 2026-05-18 12:56:12 +01:00
Ivan Vershigora
4524882015
tst: simplify wallet discovery and import test 2026-05-18 10:29:13 +01:00
GLaDOS
4429f127c6
Merge pull request #8562 from BlueWallet/renovate/lottie-react-native-7.x
Some checks are pending
Build Release and Upload to TestFlight (iOS) / build (push) Waiting to run
Build Release and Upload to TestFlight (iOS) / testflight-upload (push) Blocked by required conditions
BuildReleaseApk / buildReleaseApk (push) Waiting to run
BuildReleaseApk / browserstack (push) Blocked by required conditions
fix(deps): update dependency lottie-react-native to v7.3.7
2026-05-18 00:49:27 +01:00
Ivan Vershigora
44b5640ff3
ref: lazy-load translations 2026-05-17 22:02:20 +01:00
renovate[bot]
8d8b2fb9cf
fix(deps): update dependency lottie-react-native to v7.3.7 2026-05-17 20:13:51 +00:00
GLaDOS
714984cefe
Merge pull request #8558 from BlueWallet/renovate/react-native-svg-15.x
fix(deps): update dependency react-native-svg to v15.15.5
2026-05-17 21:08:54 +01:00
GLaDOS
42b1154543
Merge pull request #8541 from BlueWallet/amouunt
Some checks failed
Build Release and Upload to TestFlight (iOS) / build (push) Has been cancelled
BuildReleaseApk / buildReleaseApk (push) Has been cancelled
Build Release and Upload to TestFlight (iOS) / testflight-upload (push) Has been cancelled
BuildReleaseApk / browserstack (push) Has been cancelled
FIX: amountinput layout
2026-05-16 00:56:27 +01:00
Marcos Rodriguez
492a617d42 Merge branch 'amouunt' of https://github.com/BlueWallet/BlueWallet into amouunt 2026-05-15 16:02:30 -05:00
Marcos Rodriguez
ebf8e245ec w 2026-05-15 16:02:24 -05:00
Overtorment
14b3695a36
Merge branch 'master' into amouunt 2026-05-15 21:49:39 +01:00
renovate[bot]
f3294d1fc6
fix(deps): update dependency react-native-svg to v15.15.5 2026-05-15 15:17:52 +00:00
GLaDOS
ae9a5605f3
Merge pull request #8535 from BlueWallet/renovate/react-native-gesture-handler-2.x
fix(deps): update dependency react-native-gesture-handler to v2.31.2
2026-05-15 16:13:30 +01:00
GLaDOS
0749457c04
Merge pull request #8542 from BlueWallet/e2e-stability
tst: e2e stability
2026-05-15 16:13:15 +01:00
GLaDOS
4fe998aad8
Merge pull request #8545 from BlueWallet/locsync31
ref: localization sweep — translations, lint script, cleanups
2026-05-15 16:13:05 +01:00
GLaDOS
5f865777c9
Merge pull request #8546 from BlueWallet/cursor-fix-tx-details-fee-rate-decimal-585e
FIX: transaction details fee rate to one decimal place
2026-05-15 16:13:00 +01:00
GLaDOS
66736f9c91
Merge pull request #8532 from BlueWallet/dependabot/npm_and_yarn/fast-xml-builder-1.2.0
Some checks are pending
Build Release and Upload to TestFlight (iOS) / build (push) Waiting to run
Build Release and Upload to TestFlight (iOS) / testflight-upload (push) Blocked by required conditions
BuildReleaseApk / buildReleaseApk (push) Waiting to run
BuildReleaseApk / browserstack (push) Blocked by required conditions
build(deps): bump fast-xml-builder from 1.1.4 to 1.2.0
2026-05-14 22:35:34 +01:00
Ivan Vershigora
fef40b4990
fix: cleanup 2026-05-14 16:37:30 +01:00
Ivan Vershigora
8b16c53e5c
ref: localization sweep — translations, lint script, cleanups 2026-05-14 16:27:30 +01:00
Marcos Rodriguez
32f810cb46 Update StorageProvider.tsx 2026-05-13 16:51:05 -05:00
Marcos Rodriguez
fb8fd51d86 Update StorageProvider.tsx 2026-05-13 16:49:22 -05:00
Marcos Rodriguez
71dd93ede2 FIX: amountinput layout 2026-05-13 14:49:39 -05:00
Nuno
1728f33f0a
ref: sign and verify screen (#8548)
Some checks are pending
Build Release and Upload to TestFlight (iOS) / build (push) Waiting to run
Build Release and Upload to TestFlight (iOS) / testflight-upload (push) Blocked by required conditions
BuildReleaseApk / buildReleaseApk (push) Waiting to run
BuildReleaseApk / browserstack (push) Blocked by required conditions
2026-05-13 20:27:11 +02:00
Ivan Vershigora
295a32caef tst: e2e stability 2026-05-13 18:47:12 +01:00
Nuno
8195855f05
fix: wake up (#8537)
Some checks are pending
Build Release and Upload to TestFlight (iOS) / build (push) Waiting to run
Build Release and Upload to TestFlight (iOS) / testflight-upload (push) Blocked by required conditions
BuildReleaseApk / buildReleaseApk (push) Waiting to run
BuildReleaseApk / browserstack (push) Blocked by required conditions
2026-05-13 16:34:14 +02:00
Nuno
d09bd69b96
fix: sats format (#8551)
Some checks are pending
Build Release and Upload to TestFlight (iOS) / build (push) Waiting to run
Build Release and Upload to TestFlight (iOS) / testflight-upload (push) Blocked by required conditions
BuildReleaseApk / buildReleaseApk (push) Waiting to run
BuildReleaseApk / browserstack (push) Blocked by required conditions
2026-05-13 07:24:53 +02:00
renovate[bot]
ccb1dcaef7
fix(deps): update dependency react-native-gesture-handler to v2.31.2 2026-05-12 12:09:38 +00:00
Cursor Agent
3a48182105
FIX: show transaction fee rate with one decimal on details screen
Fee rate was computed with Math.round, collapsing values like 3.6 sats/vB
to 4. Round to one decimal place for display and copy text.

Co-authored-by: Overtorment <Overtorment@users.noreply.github.com>
2026-05-11 16:22:37 +00:00
Nuno
e2bcae3818
ADD: Redesign txdetail (#8289)
Some checks failed
Build Release and Upload to TestFlight (iOS) / build (push) Has been cancelled
BuildReleaseApk / buildReleaseApk (push) Has been cancelled
Build Release and Upload to TestFlight (iOS) / testflight-upload (push) Has been cancelled
BuildReleaseApk / browserstack (push) Has been cancelled
2026-05-11 17:10:49 +01:00
Ivan Vershigora
f039c993cd OPS: pin action versions, prebuild RN core, warm iOS sim for e2e 2026-05-11 12:52:23 +01:00
renovate[bot]
11d367b975 fix(deps): update dependency bitcoinjs-lib to v7.0.1 2026-05-11 12:49:03 +01:00
renovate[bot]
31820f4cad fix(deps): update dependency ecpair to v3.0.1 2026-05-11 12:48:18 +01:00
Nuno
167dc05cdf
ref: manage wallets list and search (#8527)
Some checks are pending
Build Release and Upload to TestFlight (iOS) / build (push) Waiting to run
Build Release and Upload to TestFlight (iOS) / testflight-upload (push) Blocked by required conditions
BuildReleaseApk / buildReleaseApk (push) Waiting to run
BuildReleaseApk / browserstack (push) Blocked by required conditions
* ref: manage wallets list and search

* fix review

* improvements

* fix: svg and localization

* fix: swipe

* fix: dark mode

* fix: props type

* fix: remove dead code

* fix: drop TouchableWithoutFeedback

* fix: usePreventRemove

* ref: address search

* fix header animation

* fix: code style

---------

Co-authored-by: Ivan Vershigora <ivan.vershigora@gmail.com>
2026-05-10 17:59:12 +02:00
Nuno
8276b8d22b
ref: floatbuttons borders and animation (#8529)
Some checks are pending
Build Release and Upload to TestFlight (iOS) / build (push) Waiting to run
Build Release and Upload to TestFlight (iOS) / testflight-upload (push) Blocked by required conditions
BuildReleaseApk / buildReleaseApk (push) Waiting to run
BuildReleaseApk / browserstack (push) Blocked by required conditions
2026-05-09 18:57:08 +02:00
Overtorment
edff2468d4
Merge branch 'master' into dependabot/npm_and_yarn/fast-xml-builder-1.2.0 2026-05-09 13:07:41 +01:00
Overtorment
55f7004440
OPS: speed up CI, split e2e workflows per platform 2026-05-09 12:57:36 +01:00
Ivan Vershigora
5e7f61fba4
fix: Podfile.lock 2026-05-09 09:33:04 +01:00
Ivan Vershigora
6c23fdc0d4
fix: sync with master 2026-05-09 09:17:10 +01:00
GLaDOS
ae24973596
Merge pull request #8528 from BlueWallet/853
OPS: Update RN
2026-05-09 02:04:47 +01:00
dependabot[bot]
620553d0a2
build(deps): bump fast-xml-builder from 1.1.4 to 1.2.0
Bumps [fast-xml-builder](https://github.com/NaturalIntelligence/fast-xml-builder) from 1.1.4 to 1.2.0.
- [Changelog](https://github.com/NaturalIntelligence/fast-xml-builder/blob/main/CHANGELOG.md)
- [Commits](https://github.com/NaturalIntelligence/fast-xml-builder/compare/v1.1.4...v1.2.0)

---
updated-dependencies:
- dependency-name: fast-xml-builder
  dependency-version: 1.2.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-08 16:45:54 +00:00
Marcos Rodriguez
6256258fa0 OPS: detox 2026-05-06 23:21:00 -05:00
Ivan Vershigora
e9cd4262dd
OPS: split e2e workflows per platform, add build/test split, tune CI caches 2026-05-06 13:29:34 +01:00
Marcos Rodriguez
4181019ad3 OPS: Update RN 2026-05-05 15:42:04 -05:00
Nuno
7560f92c4b
feat: status pills on the header (#8418)
Some checks failed
Build Release and Upload to TestFlight (iOS) / build (push) Has been cancelled
BuildReleaseApk / buildReleaseApk (push) Has been cancelled
Build Release and Upload to TestFlight (iOS) / testflight-upload (push) Has been cancelled
BuildReleaseApk / browserstack (push) Has been cancelled
* feat: status pills on the header

* ref: review suggestions

* ref: speed

* optimize

* comments

* conflicts

* review comments

* review comments

* fix comments

* fix comments

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-04 12:00:00 +02:00
Nuno
2376abb2d3
ref: receive screen layout (#8518)
Some checks are pending
Build Release and Upload to TestFlight (iOS) / build (push) Waiting to run
Build Release and Upload to TestFlight (iOS) / testflight-upload (push) Blocked by required conditions
BuildReleaseApk / buildReleaseApk (push) Waiting to run
BuildReleaseApk / browserstack (push) Blocked by required conditions
2026-05-03 08:42:51 +02:00
GLaDOS
c063ce81b6
Merge pull request #8515 from BlueWallet/import
Some checks failed
Build Release and Upload to TestFlight (iOS) / build (push) Has been cancelled
BuildReleaseApk / buildReleaseApk (push) Has been cancelled
Build Release and Upload to TestFlight (iOS) / testflight-upload (push) Has been cancelled
BuildReleaseApk / browserstack (push) Has been cancelled
Update SegmentedControlNativeComponent.ts
2026-05-01 20:42:08 +01:00
Marcos Rodriguez
48e880dbb5 Update SegmentedControlNativeComponent.ts 2026-04-30 11:19:52 -05:00
Overtorment
f75eb7bf71
OPS: Update to RN 85
Some checks failed
Build Release and Upload to TestFlight (iOS) / build (push) Has been cancelled
BuildReleaseApk / buildReleaseApk (push) Has been cancelled
Build Release and Upload to TestFlight (iOS) / testflight-upload (push) Has been cancelled
BuildReleaseApk / browserstack (push) Has been cancelled
2026-04-29 22:30:57 +01:00
Nuno
03412bbf07
ref: flicker on warning and buttons (#8511) 2026-04-29 22:19:39 +02:00
Marcos Rodriguez
fc05826092 Merge branch 'master' into rn85 2026-04-29 14:02:08 -05:00
GLaDOS
4d662809c2
Merge pull request #8505 from BlueWallet/new-qr
Some checks are pending
Build Release and Upload to TestFlight (iOS) / build (push) Waiting to run
Build Release and Upload to TestFlight (iOS) / testflight-upload (push) Blocked by required conditions
BuildReleaseApk / buildReleaseApk (push) Waiting to run
BuildReleaseApk / browserstack (push) Blocked by required conditions
feat: new qrcodes
2026-04-29 18:00:11 +01:00
Marcos Rodriguez
00b5129787 OPS: Update to RN 85 2026-04-29 11:30:07 -05:00
Ivan Vershigora
35a39e2153
feat: new qrcodes 2026-04-29 15:10:49 +01:00
GLaDOS
ab01de5ef8
Merge pull request #8507 from BlueWallet/ref-tooltip
REF: tooltip
2026-04-29 14:22:07 +01:00
Overtorment
7b6731b875 REF: tooltip 2026-04-29 12:18:19 +01:00
Overtorment
992e0c987b Merge remote-tracking branch 'origin/master' into ref-tooltip 2026-04-29 12:17:04 +01:00
Nuno
8b3bc67818
fix: image placement on the cards (#8509)
Some checks are pending
Build Release and Upload to TestFlight (iOS) / build (push) Waiting to run
Build Release and Upload to TestFlight (iOS) / testflight-upload (push) Blocked by required conditions
BuildReleaseApk / buildReleaseApk (push) Waiting to run
BuildReleaseApk / browserstack (push) Blocked by required conditions
2026-04-29 07:13:15 +02:00
Nuno
eaae18d002
fix: send screen scroll and fees modal (#8495) 2026-04-29 07:11:42 +02:00
GLaDOS
9d5af27d02
Merge pull request #8510 from BlueWallet/feat-rounded
feat: Cleaner transaction list
2026-04-29 02:57:26 +01:00
li0nd3v
0594f09a8e fix: lint 2026-04-28 16:30:38 +02:00
li0nd3v
3866b6cd2c fix: lint 2026-04-28 16:11:29 +02:00
li0nd3v
53026182f5 feat: Cleaner transaction list 2026-04-28 16:00:51 +02:00
GLaDOS
8f8c080ed8
Merge pull request #8502 from BlueWallet/segm
Some checks are pending
Build Release and Upload to TestFlight (iOS) / build (push) Waiting to run
Build Release and Upload to TestFlight (iOS) / testflight-upload (push) Blocked by required conditions
BuildReleaseApk / buildReleaseApk (push) Waiting to run
BuildReleaseApk / browserstack (push) Blocked by required conditions
REF: segmentedcontrol to new arch
2026-04-28 08:28:57 +01:00
Marcos Rodriguez
e098e89dc3 REF: migrate SegmentedControl to New Architecture 2026-04-27 21:20:35 -05:00
overtorment
f1a2d29a1b REF: tooltip 2026-04-27 18:29:41 +01:00
Nuno
038cabedaf
fix: animations got lost (#8504)
Some checks are pending
Build Release and Upload to TestFlight (iOS) / build (push) Waiting to run
Build Release and Upload to TestFlight (iOS) / testflight-upload (push) Blocked by required conditions
BuildReleaseApk / buildReleaseApk (push) Waiting to run
BuildReleaseApk / browserstack (push) Blocked by required conditions
2026-04-27 16:40:50 +02:00
GLaDOS
f87ffa9633
Merge pull request #8485 from BlueWallet/renovate/actions-cache-digest
Update actions/cache digest to 27d5ce7
2026-04-27 13:49:56 +01:00
GLaDOS
4645ad5c3c
Merge pull request #8500 from BlueWallet/image
feat: drop rn-qr-generator depenedency
2026-04-26 18:25:23 +01:00
Ivan Vershigora
15319ed2e6
feat: drop rn-qr-generator depenedency 2026-04-26 11:28:58 +01:00
GLaDOS
f82de26f59
Merge pull request #8431 from BlueWallet/841
Some checks failed
Build Release and Upload to TestFlight (iOS) / build (push) Has been cancelled
BuildReleaseApk / buildReleaseApk (push) Has been cancelled
Build Release and Upload to TestFlight (iOS) / testflight-upload (push) Has been cancelled
BuildReleaseApk / browserstack (push) Has been cancelled
OPS: Update to RN 84
2026-04-25 11:55:12 +01:00
Nuno
81170856fb
fix: header with the new arch (#8493) 2026-04-25 09:23:42 +02:00
Overtorment
d28ed3f706
Merge branch 'master' into 841 2026-04-24 19:36:17 +01:00
GLaDOS
2e7785bc60
Merge pull request #8494 from BlueWallet/fix-carousel
Some checks are pending
Build Release and Upload to TestFlight (iOS) / build (push) Waiting to run
Build Release and Upload to TestFlight (iOS) / testflight-upload (push) Blocked by required conditions
BuildReleaseApk / buildReleaseApk (push) Waiting to run
BuildReleaseApk / browserstack (push) Blocked by required conditions
fix: carousel cards snap
2026-04-24 17:11:13 +01:00
renovate[bot]
1a7b24635a Update dependency react-native-svg to v15.15.4 2026-04-24 16:55:47 +01:00
Ivan
167617d929
Merge branch 'master' into 841 2026-04-24 15:18:07 +01:00
Ivan Vershigora
9cf59da550
fix: use react 19.2.3 2026-04-24 15:15:14 +01:00
Ivan Vershigora
5b1422d049
fix: code format 2026-04-24 14:36:25 +01:00
li0nd3v
b393e97ed5 fix: address comments 2026-04-24 08:31:00 +02:00
Nuno
e5308334c1
Merge branch 'master' into fix-carousel 2026-04-24 08:23:04 +02:00
GLaDOS
3087e8a9ef
Merge pull request #8486 from BlueWallet/renovate/react-native-gesture-handler-2.x
Update dependency react-native-gesture-handler to v2.31.1
2026-04-23 19:54:22 +01:00
GLaDOS
1e4edfafdb
Merge pull request #8496 from BlueWallet/fix-icons
fix: new loading icons
2026-04-23 19:54:15 +01:00
Marcos Rodriguez
a0a42b30dd Merge branch 'master' into 841 2026-04-23 10:55:30 -05:00
Nuno
480bf9f3c5
Merge branch 'master' into fix-icons 2026-04-23 15:31:57 +02:00
li0nd3v
0fd5ec7315 fix: comments 2026-04-23 15:31:29 +02:00
Ivan Vershigora
4f2a1e1f33 fix: podfile.lock update
Some checks are pending
Build Release and Upload to TestFlight (iOS) / build (push) Waiting to run
Build Release and Upload to TestFlight (iOS) / testflight-upload (push) Blocked by required conditions
BuildReleaseApk / buildReleaseApk (push) Waiting to run
BuildReleaseApk / browserstack (push) Blocked by required conditions
2026-04-23 12:22:31 +01:00
Ojok Emmanuel Nsubuga
af7fc44a3b OPS: Implement 16kb page size support 2026-04-23 12:22:31 +01:00
Nuno
ce4486f80d
Merge pull request #8492 from BlueWallet/fix-static-card 2026-04-22 22:50:26 +02:00
li0nd3v
b055c05f08 fix: remove unnecessary spinner 2026-04-22 19:07:14 +02:00
li0nd3v
2499d0816d fix: icon images 2026-04-22 18:33:33 +02:00
li0nd3v
7073d54089 fix: lint 2026-04-22 17:47:24 +02:00
li0nd3v
c98173471e fix: carousel cards snap 2026-04-22 17:25:18 +02:00
li0nd3v
35d6699085 fix: static card color on home 2026-04-22 16:42:04 +02:00
overtorment
66cf16fba3 FIX: greatly improve startup time
Some checks are pending
Build Release and Upload to TestFlight (iOS) / build (push) Waiting to run
Build Release and Upload to TestFlight (iOS) / testflight-upload (push) Blocked by required conditions
BuildReleaseApk / buildReleaseApk (push) Waiting to run
BuildReleaseApk / browserstack (push) Blocked by required conditions
2026-04-21 19:18:25 +01:00
overtorment
adbeeaf5f3 fix: bump rn-camera-kit, pin github deps
Some checks are pending
Build Release and Upload to TestFlight (iOS) / build (push) Waiting to run
Build Release and Upload to TestFlight (iOS) / testflight-upload (push) Blocked by required conditions
BuildReleaseApk / buildReleaseApk (push) Waiting to run
BuildReleaseApk / browserstack (push) Blocked by required conditions
2026-04-21 12:08:26 +01:00
Ivan Vershigora
0caa48d5ed fix: bump rn-camera-kit, pin github deps 2026-04-21 12:08:26 +01:00
Overtorment
2d9d3eb19f ref: use camerakit from github instead of npm 2026-04-21 12:08:26 +01:00
Overtorment
ec9a78a88e REF: bump camerakit 2026-04-21 12:08:26 +01:00
renovate[bot]
6a00db50cb
Update dependency react-native-gesture-handler to v2.31.1 2026-04-15 15:01:29 +00:00
renovate[bot]
f9766ae5fd
Update actions/cache digest to 27d5ce7 2026-04-15 14:57:26 +00:00
GLaDOS
8d5ec224c9
Merge pull request #8472 from BlueWallet/renovate/react-native-vector-icons-material-icons-12.x
Some checks failed
Build Release and Upload to TestFlight (iOS) / build (push) Has been cancelled
BuildReleaseApk / buildReleaseApk (push) Has been cancelled
Build Release and Upload to TestFlight (iOS) / testflight-upload (push) Has been cancelled
BuildReleaseApk / browserstack (push) Has been cancelled
Update dependency @react-native-vector-icons/material-icons to v12.5.0
2026-04-15 15:55:04 +01:00
GLaDOS
270cafb89a
Merge pull request #8477 from BlueWallet/renovate/androidx.core-core-ktx-1.x
Update dependency androidx.core:core-ktx to v1.18.0
2026-04-15 15:54:53 +01:00
GLaDOS
f3cac05b07
Merge pull request #8464 from BlueWallet/dependabot/npm_and_yarn/lodash-4.18.1
Some checks failed
Build Release and Upload to TestFlight (iOS) / build (push) Has been cancelled
BuildReleaseApk / buildReleaseApk (push) Has been cancelled
Build Release and Upload to TestFlight (iOS) / testflight-upload (push) Has been cancelled
BuildReleaseApk / browserstack (push) Has been cancelled
Bump lodash from 4.17.21 to 4.18.1
2026-04-10 14:42:14 +01:00
Overtorment
d670c5f029
Merge branch 'master' into dependabot/npm_and_yarn/lodash-4.18.1 2026-04-10 11:02:56 +01:00
GLaDOS
016ea1c9ad
Merge pull request #8479 from BlueWallet/renovate/react-native-gesture-handler-2.x
Some checks are pending
Build Release and Upload to TestFlight (iOS) / build (push) Waiting to run
Build Release and Upload to TestFlight (iOS) / testflight-upload (push) Blocked by required conditions
BuildReleaseApk / buildReleaseApk (push) Waiting to run
BuildReleaseApk / browserstack (push) Blocked by required conditions
Update dependency react-native-gesture-handler to v2.31.0
2026-04-09 05:19:43 +01:00
renovate[bot]
650973848f
Update dependency react-native-gesture-handler to v2.31.0 2026-04-09 00:53:28 +00:00
GLaDOS
e129791a19
Merge pull request #8478 from BlueWallet/renovate/detox-20.x
Update dependency detox to v20.50.1
2026-04-09 01:47:21 +01:00
renovate[bot]
b8efd854c3
Update dependency detox to v20.50.1 2026-04-08 17:29:46 +00:00
GLaDOS
581ce99f26
Merge pull request #8476 from BlueWallet/renovate/rn-qr-generator-digest
Some checks are pending
Build Release and Upload to TestFlight (iOS) / build (push) Waiting to run
Build Release and Upload to TestFlight (iOS) / testflight-upload (push) Blocked by required conditions
BuildReleaseApk / buildReleaseApk (push) Waiting to run
BuildReleaseApk / browserstack (push) Blocked by required conditions
Update rn-qr-generator digest to d53be84
2026-04-08 18:24:49 +01:00
renovate[bot]
2d4e1c6d3c
Update dependency androidx.core:core-ktx to v1.18.0 2026-04-08 17:06:33 +00:00
renovate[bot]
6a72414495 Update dependency activesupport to v7.2.3.1 [SECURITY] 2026-04-08 18:04:38 +01:00
renovate[bot]
972886ac7f
Update dependency @react-native-vector-icons/material-icons to v12.5.0 2026-04-08 17:02:31 +00:00
renovate[bot]
9a9c1fcaf0
Update rn-qr-generator digest to d53be84 2026-04-07 09:55:19 +00:00
GLaDOS
58a9cd77b1
Merge pull request #8470 from BlueWallet/renovate/react-native-vector-icons-ionicons-12.x
Some checks failed
Build Release and Upload to TestFlight (iOS) / build (push) Has been cancelled
Build Release and Upload to TestFlight (iOS) / testflight-upload (push) Has been cancelled
BuildReleaseApk / buildReleaseApk (push) Has been cancelled
BuildReleaseApk / browserstack (push) Has been cancelled
Update dependency @react-native-vector-icons/ionicons to v12.5.0
2026-04-07 10:47:05 +01:00
renovate[bot]
94fa299c32
Update dependency @react-native-vector-icons/ionicons to v12.5.0 2026-04-07 06:50:00 +00:00
GLaDOS
24d002f2dc
Merge pull request #8471 from BlueWallet/renovate/react-native-vector-icons-material-design-icons-12.x
Update dependency @react-native-vector-icons/material-design-icons to v12.5.0
2026-04-07 07:44:04 +01:00
renovate[bot]
881f07b021
Update dependency @react-native-vector-icons/material-design-icons to v12.5.0 2026-04-06 01:44:41 +00:00
GLaDOS
0b5d9817df
Merge pull request #8453 from BlueWallet/renovate/dayjs-1.x
Some checks are pending
Build Release and Upload to TestFlight (iOS) / build (push) Waiting to run
Build Release and Upload to TestFlight (iOS) / testflight-upload (push) Blocked by required conditions
BuildReleaseApk / buildReleaseApk (push) Waiting to run
BuildReleaseApk / browserstack (push) Blocked by required conditions
Update dependency dayjs to v1.11.20
2026-04-05 21:49:10 +01:00
GLaDOS
d3dccde0d6
Merge pull request #8457 from BlueWallet/renovate/react-navigation-monorepo
Update react-navigation monorepo
2026-04-05 21:49:04 +01:00
GLaDOS
c04fcf149a
Merge pull request #8463 from BlueWallet/renovate/react-native-vector-icons-entypo-12.x
Update dependency @react-native-vector-icons/entypo to v12.5.0
2026-04-03 02:42:12 +01:00
dependabot[bot]
65ec292138
Bump lodash from 4.17.21 to 4.18.1
Bumps [lodash](https://github.com/lodash/lodash) from 4.17.21 to 4.18.1.
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.21...4.18.1)

---
updated-dependencies:
- dependency-name: lodash
  dependency-version: 4.18.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-02 04:16:34 +00:00
renovate[bot]
aa9bd9f545
Update dependency @react-native-vector-icons/entypo to v12.5.0 2026-04-02 02:05:03 +00:00
GLaDOS
77cea7102b
Merge pull request #8454 from BlueWallet/renovate/react-native-gesture-handler-2.x
Update dependency react-native-gesture-handler to v2.30.1
2026-04-02 02:58:21 +01:00
renovate[bot]
c314dcf596
Update react-navigation monorepo 2026-04-01 16:39:07 +00:00
renovate[bot]
f872bc9769
Update dependency react-native-gesture-handler to v2.30.1 2026-04-01 16:38:37 +00:00
renovate[bot]
2b80bbbe2e
Update dependency dayjs to v1.11.20 2026-04-01 16:38:22 +00:00
renovate[bot]
1da5d67959 Pin dependencies 2026-04-01 12:22:30 +01:00
renovate[bot]
c3639a6283 Update maxim-lobanov/setup-xcode digest to ed7a3b1 2026-04-01 12:21:08 +01:00
renovate[bot]
1c6655cc55 Update dependency androidx.compose.ui:ui to v1.10.6 2026-04-01 12:20:32 +01:00
renovate[bot]
839284783b Update actions/cache digest to 6682284 2026-04-01 12:19:39 +01:00
renovate[bot]
24737e5714 Update dependency @babel/preset-env to v7.29.2 2026-04-01 12:16:48 +01:00
renovate[bot]
5813ac9fc5 Update dependency androidx.work:work-runtime-ktx to v2.11.2 2026-04-01 12:13:35 +01:00
GLaDOS
9738edde92
Merge pull request #8406 from BlueWallet/renovate/reactivecircus-android-emulator-runner-digest
Update reactivecircus/android-emulator-runner digest to e89f39f
2026-03-30 15:49:21 +01:00
Overtorment
c935334ca7
Merge branch 'master' into renovate/reactivecircus-android-emulator-runner-digest 2026-03-30 12:31:33 +01:00
Overtorment
24a069fb5d REL: bump to 8.0.0 2026-03-30 11:56:54 +01:00
Nuno
e9d26ac107
Update README.md
Fix translations repo link
2026-03-30 11:05:12 +02:00
Marcos Rodriguez
0b2144b8e0 w9- 2026-03-29 13:32:17 -05:00
Marcos Rodriguez
302f87b881 Merge branch 'master' into 841 2026-03-29 13:30:09 -05:00
Marcos Rodriguez
987449f77c OPS: Fix lint 2026-03-29 13:26:58 -05:00
Overtorment
49cd0c34c4
Merge branch 'master' into renovate/reactivecircus-android-emulator-runner-digest 2026-03-28 22:01:06 +00:00
copilot-swe-agent[bot]
fffbbadd2e REF: Address review comments on TooltipMenu and package.json
Agent-Logs-Url: https://github.com/BlueWallet/BlueWallet/sessions/e7b28207-f39d-474d-9504-12de5332c287

Co-authored-by: marcosrdz <4793122+marcosrdz@users.noreply.github.com>
2026-03-28 21:26:48 +00:00
Marcos Rodriguez
2172d4a20b REF: Update tooltip 2026-03-28 21:26:48 +00:00
Marcos Rodriguez
adc52b53c7 Update TransactionDetails.tsx 2026-03-28 10:30:11 -05:00
Marcos Rodriguez
eed5e77447 Update SettingsButton.tsx 2026-03-27 18:49:28 -05:00
Marcos Rodriguez
d4c2d05808 OPS: Update to RN 84 2026-03-18 17:28:50 -05:00
GLaDOS
c4fefbd7f8
Merge pull request #8425 from BlueWallet/ref-cleanup-andr-build
REF: cleanup and andr build fix
2026-03-18 19:46:37 +00:00
GLaDOS
d2a9280785
Merge pull request #8428 from BlueWallet/cleanup2
fix: old RN code cleanup
2026-03-17 19:53:35 +00:00
GLaDOS
a846cd46ad
Merge pull request #8429 from BlueWallet/locsync30
fix: sync language files
2026-03-17 19:53:31 +00:00
Ivan Vershigora
9ec2cf9b67
fix: sync language files 2026-03-17 16:03:07 +00:00
Ivan Vershigora
08cc14a420
fix: sync language files 2026-03-17 14:08:53 +03:00
Ivan Vershigora
c52c0fd63f
fix: old RN code cleanup 2026-03-17 14:06:01 +03:00
Overtorment
22e6ecbb39 REF: cleanup and andr build fix 2026-03-17 10:40:18 +00:00
Overtorment
ad28079f7c Merge remote-tracking branch 'origin/master' into ref-cleanup-andr-build 2026-03-17 09:58:40 +00:00
GLaDOS
2cc5979f9c
Merge pull request #8426 from BlueWallet/ref-settings-privacy
REF: settings-privacy
2026-03-17 04:53:29 +00:00
Overtorment
5e1c60c5ff REF: settings-privacy 2026-03-16 22:13:55 +00:00
Overtorment
416a643baf REF: cleanup and andr build fix 2026-03-16 21:58:05 +00:00
Overtorment
9dc35cecb9
REF: bump react native 2026-03-16 21:13:23 +00:00
GLaDOS
1d208142e8
Merge pull request #8407 from BlueWallet/renovate/react-native-vector-icons-entypo-12.x
Update dependency @react-native-vector-icons/entypo to v12.4.2
2026-03-14 19:30:33 +00:00
renovate[bot]
c331616a95
Update dependency @react-native-vector-icons/entypo to v12.4.2 2026-03-14 14:33:40 +00:00
renovate[bot]
dd8ec1ce6e
Update reactivecircus/android-emulator-runner digest to e89f39f 2026-03-14 14:26:00 +00:00
GLaDOS
642208d22b
Merge pull request #8401 from BlueWallet/translations_loc-en-json--master_pl
Updates for file loc/en.json in pl
2026-03-10 13:03:21 +00:00
transifex-integration[bot]
e8a7890822
Translate loc/en.json in pl
100% reviewed source file: 'loc/en.json'
on 'pl'.
2026-03-09 16:15:42 +00:00
transifex-integration[bot]
db652eeb32
Translate loc/en.json in pl
100% reviewed source file: 'loc/en.json'
on 'pl'.
2026-03-09 16:15:31 +00:00
GLaDOS
fc37c6f07e
Merge pull request #8382 from BlueWallet/renovate/react-native-tcp-socket-6.x
Update dependency react-native-tcp-socket to v6.4.1
2026-03-06 18:14:41 +00:00
Overtorment
44f497a1cb
Merge branch 'master' into renovate/react-native-tcp-socket-6.x 2026-03-06 16:13:24 +00:00
GLaDOS
7e06e24062
Merge pull request #8384 from BlueWallet/renovate/ruby-3.x
Update dependency ruby to v3.4.8
2026-03-05 14:53:55 +00:00
GLaDOS
5a62ae3fa0
Merge pull request #8387 from BlueWallet/renovate/react-native-permissions-5.x
Update dependency react-native-permissions to v5.5.0
2026-03-05 14:53:51 +00:00
renovate[bot]
26a6cb2c02 Update dependency react-native-safe-area-context to v5.7.0 2026-03-05 13:18:00 +00:00
renovate[bot]
ac2d65ee5a
Update dependency react-native-tcp-socket to v6.4.1 2026-03-05 12:52:37 +00:00
GLaDOS
92ffb5779f
Merge pull request #8386 from BlueWallet/color
Update BlueComponents.tsx
2026-03-05 12:46:05 +00:00
renovate[bot]
ea39e4ee1a
Update dependency react-native-permissions to v5.5.0 2026-03-05 12:42:40 +00:00
renovate[bot]
919dd83d1a Update dependency react-native-keychain to v9.2.3 2026-03-05 12:36:20 +00:00
renovate[bot]
4b9aedaa3a Update dependency react-native-svg to v15.15.3 2026-03-05 12:35:03 +00:00
renovate[bot]
c640591af8
Update dependency ruby to v3.4.8 2026-03-05 12:25:55 +00:00
GLaDOS
030ba4dd8b
Merge pull request #8378 from BlueWallet/ref-electrum-default-server
REF: prefer bluewallet electrum server; drop bad electrum server from…
2026-03-05 12:24:02 +00:00
Marcos Rodriguez
c24a53fc27 Update BlueComponents.tsx 2026-03-04 16:56:31 -05:00
renovate[bot]
a79ca9d17b
Update dependency react-native-share to v12.2.5 (#8380) 2026-03-04 13:46:54 -05:00
overtorment
d7a71bc170 FIX: edit ms cosigner 2026-03-04 13:46:09 -05:00
GLaDOS
93b7bc6234
Merge pull request #8351 from BlueWallet/renovate/detox-20.x
Update dependency detox to v20.47.0
2026-03-04 16:03:34 +00:00
renovate[bot]
91a79addf6
Update dependency detox to v20.47.0 2026-03-04 16:03:15 +00:00
GLaDOS
65387377ec
Merge pull request #8352 from BlueWallet/renovate/fastlane-2.x
Update dependency fastlane to "~> 2.232.0"
2026-03-04 15:44:53 +00:00
GLaDOS
2a5131f278
Merge pull request #8374 from BlueWallet/renovate/react-native-localize-3.x
Update dependency react-native-localize to v3.7.0
2026-03-04 15:44:46 +00:00
GLaDOS
d60efba9b8
Merge pull request #8376 from BlueWallet/renovate/react-native-reanimated-3.x
Update dependency react-native-reanimated to v3.19.5
2026-03-04 15:44:40 +00:00
Ojok Emmanuel Nsubuga
965f1a9f2c ADD: Highlight first and last segments of bitcoin address 2026-03-04 10:32:57 -05:00
Marcos Rodriguez
023ca31536 FIX: JSX issue 2026-03-04 10:30:39 -05:00
Overtorment
282cd6cfd7
Delete tests/unit/blue-electrum-peer-rotation.test.js 2026-03-04 13:45:07 +00:00
Cursor Agent
dab363dbd7 FIX: rotate through all hardcoded electrum peers
Co-authored-by: Overtorment <Overtorment@users.noreply.github.com>
2026-03-04 13:18:29 +00:00
overtorment
2bfcc75882 REF: prefer bluewallet electrum server; drop bad electrum server from the list 2026-03-04 13:06:22 +00:00
renovate[bot]
3e21bfcfa4
Update dependency react-native-reanimated to v3.19.5 2026-03-04 12:56:16 +00:00
GLaDOS
5240fc4e0d
Merge pull request #8373 from BlueWallet/renovate/actions-setup-node-digest
Update actions/setup-node digest to 53b8394
2026-03-04 12:44:35 +00:00
GLaDOS
e60ddbc071
Merge pull request #8366 from BlueWallet/renovate/react-native-vector-icons-ionicons-12.x
Update dependency @react-native-vector-icons/ionicons to v12.4.1
2026-03-04 10:58:18 +00:00
renovate[bot]
98a6becf32
Update dependency react-native-localize to v3.7.0 2026-03-04 10:56:02 +00:00
renovate[bot]
cfbb01571e
Update actions/setup-node digest to 53b8394 2026-03-04 10:43:23 +00:00
GLaDOS
a1abd15a5e
Merge pull request #8356 from BlueWallet/renovate/react-native-vector-icons-entypo-12.x
Update dependency @react-native-vector-icons/entypo to v12.4.1
2026-03-03 20:42:41 +00:00
Marcos Rodriguez
844f01b42a OPS: Podfile 2026-03-03 15:29:45 -05:00
GLaDOS
e5670e99de
Merge pull request #8365 from BlueWallet/renovate/react-native-vector-icons-material-icons-12.x
Update dependency @react-native-vector-icons/material-icons to v12.4.1
2026-03-03 18:11:32 +00:00
renovate[bot]
1b7a27839c
Update dependency @react-native-vector-icons/ionicons to v12.4.1 2026-03-03 18:01:26 +00:00
renovate[bot]
82b7db8f48
Update dependency @react-native-vector-icons/entypo to v12.4.1 2026-03-03 18:01:15 +00:00
GLaDOS
af4d7556c6
Merge pull request #8354 from BlueWallet/renovate/react-native-edge-to-edge-1.x
Update dependency react-native-edge-to-edge to v1.8.1
2026-03-03 17:53:16 +00:00
GLaDOS
58c4544a50
Merge pull request #8355 from BlueWallet/renovate/react-native-gesture-handler-2.x
Update dependency react-native-gesture-handler to v2.30.0
2026-03-03 17:53:13 +00:00
GLaDOS
48e00dd57b
Merge pull request #8357 from BlueWallet/renovate/react-native-vector-icons-fontawesome-12.x
Update dependency @react-native-vector-icons/fontawesome to v12.4.1
2026-03-03 17:53:07 +00:00
Marcos Rodriguez Vélez
3e8906e31b
Merge pull request #8362 from BlueWallet/enhance-address 2026-03-03 12:31:06 -05:00
Marcos Rodriguez Vélez
e64d805f07
Merge pull request #8361 from BlueWallet/taptocopy 2026-03-03 12:30:01 -05:00
Marcos Rodriguez
50e4e3a581 FIX: Icon alignment 2026-03-03 12:29:34 -05:00
renovate[bot]
8382fd601e
Update dependency @react-native-vector-icons/material-icons to v12.4.1 2026-03-03 16:10:52 +00:00
GLaDOS
f631ef37b9
Merge pull request #8364 from BlueWallet/renovate/react-native-vector-icons-material-design-icons-12.x
Update dependency @react-native-vector-icons/material-design-icons to v12.4.1
2026-03-03 16:03:06 +00:00
Ojok Emmanuel Nsubuga
e1b8fd91a8 ADD: Highlight first and last segments of bitcoin address 2026-03-03 15:02:27 +03:00
renovate[bot]
70aadbe9ba
Update dependency @react-native-vector-icons/material-design-icons to v12.4.1 2026-03-03 11:47:08 +00:00
renovate[bot]
8355954b9b
Update dependency @react-native-vector-icons/fontawesome to v12.4.1 2026-03-03 11:47:00 +00:00
GLaDOS
69a0569998
Merge pull request #8363 from BlueWallet/renovate/react-native-vector-icons-fontawesome6-12.x
Update dependency @react-native-vector-icons/fontawesome6 to v12.3.1
2026-03-03 11:39:53 +00:00
renovate[bot]
22853c1079
Update dependency @react-native-vector-icons/fontawesome6 to v12.3.1 2026-03-02 21:59:27 +00:00
GLaDOS
638254edce
Merge pull request #8349 from BlueWallet/renovate/androidx.compose.ui-ui-1.x
Update dependency androidx.compose.ui:ui to v1.10.4
2026-03-02 21:51:29 +00:00
Marcos Rodriguez
077fddf75f ADD: Tap to copy missing in tx details 2026-03-02 11:34:17 -05:00
Overtorment
baf9f17231
OPS: renovate config 2026-03-02 16:08:35 +00:00
renovate[bot]
f9413a8c1b
Update dependency react-native-gesture-handler to v2.30.0 2026-03-02 15:08:15 +00:00
renovate[bot]
ee05946c2a
Update dependency react-native-edge-to-edge to v1.8.1 2026-03-02 15:08:00 +00:00
renovate[bot]
f04e2afe5e
Update dependency fastlane to "~> 2.232.0" 2026-03-02 15:07:38 +00:00
renovate[bot]
362cff7783
Update dependency androidx.compose.ui:ui to v1.10.4 2026-03-02 15:06:52 +00:00
Marcos Rodriguez Vélez
37916ab2e5
REF: Use new icons package 2026-03-02 14:54:47 +00:00
GLaDOS
ae761a7ea8
Merge pull request #8350 from BlueWallet/renovate/androidx.work-work-runtime-ktx-2.x
Update dependency androidx.work:work-runtime-ktx to v2.11.1
2026-03-02 13:01:09 +00:00
renovate[bot]
61bb1823f7
Update dependency androidx.work:work-runtime-ktx to v2.11.1 2026-03-02 11:18:13 +00:00
GLaDOS
abbc780323
Merge pull request #8344 from BlueWallet/translations_loc-en-json--master_pl
Updates for file loc/en.json in pl
2026-03-02 11:06:04 +00:00
transifex-integration[bot]
511c3c4c66
Translate loc/en.json in pl
100% reviewed source file: 'loc/en.json'
on 'pl'.
2026-03-01 20:26:59 +00:00
GLaDOS
16aacd4bbb
Merge pull request #8341 from BlueWallet/renovate/react-native-community-push-notification-ios-1.x
Update dependency @react-native-community/push-notification-ios to v1.12.0
2026-03-01 17:13:16 +00:00
GLaDOS
c60839445d
Merge pull request #8338 from BlueWallet/renovate/react-navigation-monorepo
Update react-navigation monorepo
2026-03-01 17:00:58 +00:00
GLaDOS
2b2a40f22d
Merge pull request #8340 from BlueWallet/renovate/bugsnag-js-monorepo
Update dependency @bugsnag/react-native to v8.8.1
2026-03-01 17:00:54 +00:00
renovate[bot]
adf20d84d3
Update dependency @react-native-community/push-notification-ios to v1.12.0 2026-03-01 09:15:35 +00:00
renovate[bot]
020177c30a
Update dependency @bugsnag/react-native to v8.8.1 2026-03-01 09:15:24 +00:00
renovate[bot]
d436ae7db2
Update react-navigation monorepo 2026-03-01 09:15:09 +00:00
Overtorment
3e280edf1b
OPS: ios build fix (#8339) 2026-02-28 18:55:22 -05:00
renovate[bot]
2b1dd08716 Pin dependencies 2026-02-27 16:06:26 +00:00
renovate[bot]
8db841add2 Update dependency @babel/preset-env to v7.29.0 2026-02-27 13:41:25 +00:00
GLaDOS
882717e775
Merge pull request #8331 from BlueWallet/renovate/bip32-5.x
Update dependency bip32 to v5.0.1
2026-02-27 01:25:06 +00:00
renovate[bot]
3d1c5d7466
Update dependency bip32 to v5.0.1 2026-02-26 19:14:29 +00:00
Overtorment
d1c457c6ae
OPS: renovate config 2026-02-26 19:03:26 +00:00
GLaDOS
2315ea7fdf
Merge pull request #8305 from BlueWallet/e2e
Improve e2e test DX: cleaner stack traces & robust emulator setup
2026-02-26 16:52:55 +00:00
Overtorment
01e4b37348
Merge branch 'master' into e2e 2026-02-26 14:48:31 +00:00
overtorment
0d1f09408b FIX: import keystone wallets 2026-02-26 14:45:29 +00:00
Adam SHaY
8bb4ee916c FIX: maxSendableAmount using wrong balance 2026-02-26 14:34:32 +00:00
Adam SHaY
4ad7cfe6f0 REF: move maxSendableAmount calc into fee useEffect 2026-02-26 14:34:32 +00:00
Adam SHaY
e646bdaee3 FIX: align max sendable text to center 2026-02-26 14:34:32 +00:00
Adam SHaY
0f8b04d43a ADD: show MAX sendable amount
When MAX is selected, display the calculated sendable amount below.
  - Works only for 1 recipient (Main goal of feature is this for atomic swap)
  - Shows ≈ prefix when recipient address is unknown (using P2TR estimate)
  - Shows exact amount when valid address is entered
  - Forces displayed amount into transaction (no recalculation on Next)
2026-02-26 14:34:32 +00:00
GLaDOS
248182c1f6
Merge pull request #8309 from BlueWallet/cccc
OPS: Catalyst
2026-02-26 01:21:35 +00:00
Marcos Rodriguez
f70685b769 Merge branch 'master' into cccc 2026-02-25 18:12:01 -05:00
Marcos Rodriguez Vélez
d5c56b6e69 add: additional unique ID instructions in issue template 2026-02-25 10:34:12 +00:00
Marcos Rodriguez
2956f2e5a5 ADD: integrate match for catalyst provisioning profiles with manual signing 2026-02-24 18:15:36 -05:00
Marcos Rodriguez
019f93d7c1 Merge branch 'master' into cccc 2026-02-24 18:01:08 -05:00
Marcos Rodriguez
403573a8d3 OPS: version bump 2026-02-23 18:01:08 -05:00
Marcos Rodriguez
64414e40d3 ADD: re-run catalyst build on push while mac-dmg label is present 2026-02-22 17:16:30 -05:00
Marcos Rodriguez
feecc46490 Update build-mac-catalyst.yml 2026-02-22 15:01:51 -05:00
Marcos Rodriguez Vélez
207f5b549f
Merge pull request #8322 from BlueWallet/copilot/sub-pr-8309 2026-02-22 14:09:07 -05:00
copilot-swe-agent[bot]
5280b7b521 Address review comments: fix shell escaping, API key creation, lane description
Co-authored-by: marcosrdz <4793122+marcosrdz@users.noreply.github.com>
2026-02-22 19:05:56 +00:00
Marcos Rodriguez Vélez
b3b635ef00
Merge pull request #8324 from BlueWallet/copilot/sub-pr-8309-another-one 2026-02-22 14:02:34 -05:00
Marcos Rodriguez Vélez
da3767266f
Merge pull request #8323 from BlueWallet/copilot/sub-pr-8309-again 2026-02-22 13:57:27 -05:00
copilot-swe-agent[bot]
0ae944abbc FIX: use array form of sh and File.open to avoid shell injection in Fastfile
Co-authored-by: marcosrdz <4793122+marcosrdz@users.noreply.github.com>
2026-02-22 18:56:30 +00:00
copilot-swe-agent[bot]
9c8f660604 Add step to create appstore_api_key.json before TestFlight upload
Co-authored-by: marcosrdz <4793122+marcosrdz@users.noreply.github.com>
2026-02-22 18:54:14 +00:00
copilot-swe-agent[bot]
0cf466910f Initial plan 2026-02-22 18:51:45 +00:00
copilot-swe-agent[bot]
e991ed8814 Initial plan 2026-02-22 18:51:31 +00:00
copilot-swe-agent[bot]
849191f413 Initial plan 2026-02-22 18:50:37 +00:00
Marcos Rodriguez Vélez
a066d6c377
Update Fastfile
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-22 13:49:45 -05:00
Marcos Rodriguez Vélez
4b240b429b
Update build-mac-catalyst.yml
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-22 13:49:38 -05:00
Marcos Rodriguez Vélez
1386e0738a
Update build-mac-catalyst.yml
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-22 13:49:18 -05:00
Marcos Rodriguez Vélez
f390d2015b
Update Fastfile
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-22 13:48:57 -05:00
Marcos Rodriguez Vélez
c3fa43ccb8
Update build-mac-catalyst.yml
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-22 13:48:46 -05:00
Marcos Rodriguez Vélez
70dc1e2f5a
Merge branch 'master' into cccc 2026-02-22 13:32:14 -05:00
Marcos Rodriguez Vélez
2b9dbc0a28
Update Fastfile 2026-02-22 13:31:25 -05:00
Marcos Rodriguez Vélez
4dae36b251
Update build-mac-catalyst.yml 2026-02-22 13:29:33 -05:00
Marcos Rodriguez
19c0d3ae39 ADD: upload Mac Catalyst to TestFlight when both mac-dmg and testflight labels present 2026-02-21 20:32:08 -05:00
Marcos Rodriguez
582d6b8e44 Update Fastfile 2026-02-21 20:29:23 -05:00
Marcos Rodriguez
b77a9b27ca ADD: multilingual README in DMG (EN, ES, ZH, PT, RU, JA, DE, FR, AR) 2026-02-21 20:26:36 -05:00
Marcos Rodriguez
93faa47121 ADD: trigger catalyst build only on mac-dmg label, cancel on unlabel 2026-02-21 20:25:48 -05:00
Marcos Rodriguez
bfe73af235 ADD: post PR comment with direct DMG download link 2026-02-21 20:25:01 -05:00
Marcos Rodriguez
fb1a25dba7 Merge branch 'master' into cccc 2026-02-21 20:23:38 -05:00
Marcos Rodriguez
e7a607b6bb ADD: README with first-launch instructions in Catalyst DMG 2026-02-21 20:23:25 -05:00
Marcos Rodriguez
c36f1f934f Update project.pbxproj 2026-02-19 21:16:26 -05:00
Marcos Rodriguez
66b3e261e5 FIX: disable xcodebuild code signing when no credentials provided 2026-02-19 21:13:12 -05:00
Marcos Rodriguez
67a5f2c9ac ADD: Mac Catalyst build lane with DMG, ad-hoc signing, no notarization 2026-02-19 20:50:19 -05:00
Marcos Rodriguez
6078d47d40 FIX: skip notarization when secrets are empty strings 2026-02-19 19:13:43 -05:00
Marcos Rodriguez
b33ea3e1cc Merge branch 'cccc' of https://github.com/BlueWallet/BlueWallet into cccc 2026-02-19 15:05:59 -05:00
Marcos Rodriguez
8044d37666 Merge branch 'cccc' of https://github.com/BlueWallet/BlueWallet into cccc 2026-02-19 15:05:15 -05:00
Marcos Rodriguez
c52742e1b6 Merge branch 'cccc' of https://github.com/BlueWallet/BlueWallet into cccc 2026-02-19 15:03:17 -05:00
Marcos Rodriguez
44602e406e FIX: Catalyst build - arm64 only, auth bypass, robust app path detection 2026-02-19 15:02:35 -05:00
Marcos Rodriguez
8ea9b7aa16 FIX: Catalyst build - arm64 only, auth bypass, robust app path detection 2026-02-19 00:08:09 -05:00
Marcos Rodriguez
0905c28989 OPS: Catalyst 2026-02-18 18:46:44 -05:00
Ivan Vershigora
a3a10ff440
feat: e2e stacktrace; emulator prepare script 2026-02-17 12:33:00 +00:00
509 changed files with 51320 additions and 16567 deletions

View File

@ -33,7 +33,7 @@
"simulator": {
"type": "ios.simulator",
"device": {
"type": "iPhone 16"
"type": "iPhone 17"
}
},
"emulator": {
@ -44,6 +44,10 @@
}
},
"configurations": {
"ios.debug": {
"device": "simulator",
"app": "ios.debug"
},
"ios.release": {
"device": "simulator",
"app": "ios.release"

View File

@ -21,6 +21,10 @@
"react-native/no-unused-styles": "error",
"react/no-is-mounted": "off",
"react-native/no-single-element-style-arrays": "error",
"react-hooks/refs": "off",
"react-hooks/immutability": "off",
"react-hooks/purity": "off",
"react-hooks/set-state-in-effect": "off",
"prettier/prettier": [
"warn",
{

View File

@ -16,7 +16,7 @@ Please provide:
* your phone model and OS version
* BlueWallet app version (settings->about->scroll down)
* self-test passes? Open settings->about->scroll down, tap "Run self-test"
* unique ID for our crash reporting service (settings->about->scroll down, tap "copy")
* unique ID for our crash reporting service (option 1: settings->about->scroll down, tap "copy")(option 2: open the settings app->apps->BlueWallet,double tap the unique id text field and select copy)
## Proposing a feature?

View File

@ -10,9 +10,13 @@ on:
- master
workflow_dispatch:
concurrency:
group: build-ios-release-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
runs-on: macos-15
runs-on: macos-26
timeout-minutes: 180
outputs:
new_build_number: ${{ steps.generate_build_number.outputs.build_number }}
@ -26,7 +30,7 @@ jobs:
steps:
- name: Checkout Project
uses: actions/checkout@v6
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0 # Ensures the full Git history is
@ -65,13 +69,12 @@ jobs:
echo "Branch Name: ${{ env.CURRENT_BRANCH }}"
- name: Specify Node.js Version
uses: actions/setup-node@v6
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24
cache: 'npm'
cache-dependency-path: package-lock.json
- uses: maxim-lobanov/setup-xcode@v1
- uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0
with:
xcode-version: latest
@ -205,9 +208,9 @@ jobs:
echo -e "\033[1;34m======================================================\033[0m"
- name: Set Up Ruby
uses: ruby/setup-ruby@v1
uses: ruby/setup-ruby@c4e5b1316158f92e3d49443a9d58b31d25ac0f8f # v1.306.0
with:
ruby-version: 3.1.6
ruby-version: 3.4.9
- name: System Debug Information
run: |
@ -244,7 +247,21 @@ jobs:
- name: Install Node Modules
run: npm ci --omit=dev --yes
- name: Cache CocoaPods
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
ios/Pods
~/Library/Caches/CocoaPods
~/.cocoapods/repos
key: ${{ runner.os }}-pods-ios-release-${{ hashFiles('ios/Podfile.lock') }}
restore-keys: |
${{ runner.os }}-pods-ios-release-
- name: Install CocoaPods Dependencies
env:
RCT_USE_RN_DEP: "1"
RCT_USE_PREBUILT_RNCORE: "1"
run: |
bundle exec fastlane ios install_pods
echo "CocoaPods dependencies installed successfully"
@ -430,7 +447,7 @@ jobs:
- name: Upload Build Logs
if: always()
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: build_logs
path: ./ios/build_logs/
@ -451,7 +468,7 @@ jobs:
- name: Upload IPA as Artifact
if: success()
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: BlueWallet_IPA
path: ${{ env.IPA_OUTPUT_PATH }}
@ -463,7 +480,7 @@ jobs:
testflight-upload:
needs: build
runs-on: macos-15
runs-on: macos-26
if: github.event_name == 'push' || contains(github.event.pull_request.labels.*.name, 'testflight')
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
@ -473,20 +490,20 @@ jobs:
BRANCH_NAME: ${{ needs.build.outputs.branch_name }}
steps:
- name: Checkout Project
uses: actions/checkout@v6
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Set Up Ruby
uses: ruby/setup-ruby@v1
uses: ruby/setup-ruby@c4e5b1316158f92e3d49443a9d58b31d25ac0f8f # v1.306.0
with:
ruby-version: 3.1.6
ruby-version: 3.4.9
- name: Install Dependencies with Bundler
run: |
bundle config path vendor/bundle
bundle install --jobs 4 --retry 3 --quiet
- name: Download IPA from Artifact
uses: actions/download-artifact@v5
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: BlueWallet_IPA
path: ./
@ -530,7 +547,7 @@ jobs:
- name: Post PR Comment
if: success() && github.event_name == 'pull_request'
uses: actions/github-script@v7
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
BUILD_NUMBER: ${{ needs.build.outputs.new_build_number }}
PROJECT_VERSION: ${{ needs.build.outputs.project_version }}

187
.github/workflows/build-mac-catalyst.yml vendored Normal file
View File

@ -0,0 +1,187 @@
name: Build Mac Catalyst
on:
workflow_dispatch:
pull_request:
branches:
- master
types: [labeled, synchronize]
concurrency:
group: catalyst-build-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: true
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
steps:
- name: Check PR labels
if: github.event_name == 'pull_request'
id: labels
env:
GH_TOKEN: ${{ github.token }}
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
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
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: |
ios/Pods
~/Library/Caches/CocoaPods
~/.cocoapods/repos
key: ${{ runner.os }}-pods-catalyst-${{ hashFiles('ios/Podfile.lock') }}
restore-keys: |
${{ 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"
RCT_USE_PREBUILT_RNCORE: "1"
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: 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 || '' }}
MATCH_READONLY: ${{ steps.labels.outputs.upload_testflight == 'true' && 'false' || 'true' }}
KEYCHAIN_NAME: ${{ steps.labels.outputs.upload_testflight == 'true' && 'build' || '' }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
- name: Upload Mac Catalyst DMG
id: upload_dmg
if: success() && (github.event_name == 'workflow_dispatch' || steps.labels.outputs.has_mac_dmg == 'true')
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'
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 }}
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'
run: rm -f ./appstore_api_key.json
- name: Cleanup temporary keychain
if: always() && steps.labels.outputs.upload_testflight == 'true'
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'
env:
GH_TOKEN: ${{ github.token }}
UPLOADED_TO_TF: ${{ steps.labels.outputs.upload_testflight }}
run: |
ARTIFACT_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}/artifacts/${{ steps.upload_dmg.outputs.artifact-id }}"
COMMENT_TAG="<!-- catalyst-dmg-link -->"
TF_LINE=""
if [[ "$UPLOADED_TO_TF" == "true" ]]; then
TF_LINE=$'\n\n**Also uploaded to TestFlight.** Check [App Store Connect](https://appstoreconnect.apple.com) for the build.'
fi
COMMENT_FILE="$(mktemp)"
{
printf '%s\n' "${COMMENT_TAG}"
printf '### Mac Catalyst Build\n\n'
printf 'The Mac Catalyst DMG is ready for download:\n\n'
printf '[Download BlueWallet-Mac-Catalyst.dmg](%s)\n' "${ARTIFACT_URL}"
if [[ -n "$TF_LINE" ]]; then
printf '%s\n' "${TF_LINE}"
fi
printf '<sub>Built from `%s`"
} >"${COMMENT_FILE}"
gh api "repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments" \
--paginate --jq '.[] | select(.body | contains("<!-- catalyst-dmg-link -->")) | .id' | \
while read -r comment_id; do
gh api -X DELETE "repos/${{ github.repository }}/issues/comments/${comment_id}" || true
done
gh pr comment "${{ github.event.pull_request.number }}" --body-file "${COMMENT_FILE}"

View File

@ -11,75 +11,110 @@ on:
jobs:
buildReleaseApk:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
steps:
- name: Checkout project
uses: actions/checkout@v6
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: "0"
- name: Free disk space (Android build)
shell: bash
run: |
df -h
sudo rm -rf /usr/share/dotnet || true
sudo rm -rf /opt/ghc || true
sudo rm -rf /usr/local/share/boost || true
sudo rm -rf /usr/local/lib/android/sdk/ndk || true
docker system prune -af || true
sudo rm -rf /usr/local/lib/android/sdk/system-images || true
sudo rm -rf /usr/local/lib/android/sdk/emulator || true
rm -rf ~/.gradle/caches/modules-2/files-2.1 || true
rm -rf ~/.gradle/caches/build-cache || true
rm -rf ~/.npm/_cacache ~/.cache || true
sudo rm -rf /home/runner/work/_temp || true
df -h
- name: Specify node version
uses: actions/setup-node@v6
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24
cache: 'npm'
cache-dependency-path: package-lock.json
- name: Use specific Java version for sdkmanager to work
uses: actions/setup-java@v5
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
distribution: 'temurin'
java-version: '17'
cache: 'gradle'
- name: Install node_modules
run: npm ci --omit=dev --yes
- name: Use gradle caches
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('android/**/*.gradle', 'android/**/*.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Set up Android SDK
uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1
- name: Install Android SDK components
run: |
yes | sdkmanager --licenses
sdkmanager "platforms;android-36" "platform-tools" "build-tools;36.0.0" "ndk;27.1.12297006"
- name: Install node_modules (include dev deps for patch-package)
run: npm ci --yes
- name: Set up Ruby
uses: ruby/setup-ruby@v1
uses: ruby/setup-ruby@c4e5b1316158f92e3d49443a9d58b31d25ac0f8f # v1.306.0
with:
ruby-version: 3.1.6
ruby-version: 3.4.9
bundler-cache: true
- name: Cache Ruby Gems
uses: actions/cache@v5
with:
path: vendor/bundle
key: ${{ runner.os }}-ruby-${{ hashFiles('**/Gemfile.lock') }}
restore-keys: |
${{ runner.os }}-ruby-
- name: Generate Build Number based on timestamp
id: build_number
run: |
NEW_BUILD_NUMBER="$(date +%s)"
echo "NEW_BUILD_NUMBER=$NEW_BUILD_NUMBER" >> $GITHUB_ENV
echo "build_number=$NEW_BUILD_NUMBER" >> $GITHUB_OUTPUT
- name: Prepare Keystore
run: bundle exec fastlane android prepare_keystore
env:
KEYSTORE_FILE_HEX: ${{ secrets.KEYSTORE_FILE_HEX }}
- name: Update Version Code, Build, and Sign APK
- name: Build and sign APK
id: build_and_sign_apk
run: |
bundle exec fastlane android update_version_build_and_sign_apk
run: bundle exec fastlane android build_release_apk
env:
BUILD_NUMBER: ${{ env.NEW_BUILD_NUMBER }}
BUILD_NUMBER: ${{ steps.build_number.outputs.build_number }}
KEYSTORE_FILE_HEX: ${{ secrets.KEYSTORE_FILE_HEX }}
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
- name: Upload build logs on failure
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: android-build-logs
path: |
fastlane/logs/**/*.log
android/**/*.log
android/**/build/**/*.log
android/**/outputs/logs/**/*.log
android/**/reports/**/*.log
if-no-files-found: warn
- name: Determine APK Filename and Path
id: determine_apk_path
run: |
BUILD_NUMBER=${{ steps.build_number.outputs.build_number }}
VERSION_NAME=$(grep versionName android/app/build.gradle | awk '{print $2}' | tr -d '"')
BRANCH_NAME=${GITHUB_HEAD_REF:-${GITHUB_REF_NAME}}
BRANCH_NAME=$(echo "$BRANCH_NAME" | sed 's/[^a-zA-Z0-9_-]/_/g')
if [ -n "$BRANCH_NAME" ] && [ "$BRANCH_NAME" != "master" ]; then
EXPECTED_FILENAME="BlueWallet-${VERSION_NAME}-${NEW_BUILD_NUMBER}-${BRANCH_NAME}.apk"
EXPECTED_FILENAME="BlueWallet-${VERSION_NAME}-${BUILD_NUMBER}-${BRANCH_NAME}.apk"
else
EXPECTED_FILENAME="BlueWallet-${VERSION_NAME}-${NEW_BUILD_NUMBER}.apk"
EXPECTED_FILENAME="BlueWallet-${VERSION_NAME}-${BUILD_NUMBER}.apk"
fi
APK_PATH="android/app/build/outputs/apk/release/${EXPECTED_FILENAME}"
@ -87,32 +122,32 @@ jobs:
echo "APK_PATH=${APK_PATH}" >> $GITHUB_ENV
- name: Upload APK as artifact
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: signed-apk
path: ${{ env.APK_PATH }}
if-no-files-found: error
browserstack:
runs-on: ubuntu-latest
runs-on: macos-26
needs: buildReleaseApk
if: ${{ github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'browserstack') }}
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Set up Ruby
uses: ruby/setup-ruby@v1
uses: ruby/setup-ruby@c4e5b1316158f92e3d49443a9d58b31d25ac0f8f # v1.306.0
with:
ruby-version: 3.1.6
ruby-version: 3.4.9
bundler-cache: true
- name: Install dependencies with Bundler
run: bundle install --jobs 4 --retry 3
- name: Download APK artifact
uses: actions/download-artifact@v5
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: signed-apk
@ -127,4 +162,4 @@ jobs:
BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }}
GITHUB_PR_NUMBER: ${{ github.event.pull_request.number }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: bundle exec fastlane upload_to_browserstack_and_comment
run: bundle exec fastlane upload_to_browserstack_and_comment

View File

@ -14,16 +14,15 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout project
uses: actions/checkout@v6
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
- name: Specify node version
uses: actions/setup-node@v6
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24
cache: 'npm'
cache-dependency-path: package-lock.json
- name: Install node_modules
run: npm ci || npm ci
@ -35,16 +34,15 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout project
uses: actions/checkout@v6
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
- name: Specify node version
uses: actions/setup-node@v6
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24
cache: 'npm'
cache-dependency-path: package-lock.json
- name: Install node_modules
run: npm ci || npm ci
@ -55,6 +53,7 @@ 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 }}
@ -66,16 +65,15 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout project
uses: actions/checkout@v6
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
- name: Specify node version
uses: actions/setup-node@v6
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24
cache: 'npm'
cache-dependency-path: package-lock.json
- name: Install node_modules
run: npm ci || npm ci
@ -86,6 +84,7 @@ 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 }}

152
.github/workflows/e2e-android.yml vendored Normal file
View File

@ -0,0 +1,152 @@
name: Tests e2e Android
on: [pull_request]
permissions:
contents: read
concurrency:
group: e2e-android-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Free disk space (Ubuntu)
run: |
echo "Disk before cleanup:" && df -h
sudo rm -rf /usr/share/dotnet /opt/ghc
sudo apt-get clean
sudo rm -rf /opt/ghc || true
sudo rm -rf /usr/local/share/boost || true
sudo rm -rf /usr/local/lib/android/sdk/ndk || true
sudo docker system prune -af || true
sudo rm -rf /usr/local/lib/android/sdk/system-images || true
sudo rm -rf /usr/local/lib/android/sdk/emulator || true
rm -rf ~/.gradle/caches/modules-2/files-2.1 || true
rm -rf ~/.gradle/caches/build-cache || true
rm -rf ~/.npm/_cacache ~/.cache || true
sudo rm -rf /home/runner/work/_temp || true
echo "Disk after cleanup:" && df -h
- name: Specify node version
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24
cache: 'npm'
- name: Use gradle caches
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('android/**/*.gradle', 'android/**/*.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Install node_modules
run: npm ci || npm ci
- name: Use specific Java version for sdkmanager to work
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
distribution: 'temurin'
java-version: '17'
- name: Build
run: npm run e2e:release-build || npm run e2e:release-build
- name: Package APKs
run: |
tar -czf bluewallet-android-apks.tar.gz \
android/app/build/outputs/apk/release/app-release.apk \
android/app/build/outputs/apk/androidTest/release/app-release-androidTest.apk
- name: Upload APKs
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: bluewallet-android-apks
path: bluewallet-android-apks.tar.gz
retention-days: 3
compression-level: 0
if-no-files-found: error
test:
runs-on: ubuntu-24.04
needs: build
env:
HD_MNEMONIC: ${{ secrets.HD_MNEMONIC }}
HD_MNEMONIC_BIP84: ${{ secrets.HD_MNEMONIC_BIP84 }}
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Free disk space (Ubuntu)
run: |
echo "Disk before cleanup:" && df -h
sudo rm -rf /usr/share/dotnet /opt/ghc
sudo apt-get clean
sudo rm -rf /opt/ghc || true
sudo rm -rf /usr/local/share/boost || true
sudo rm -rf /usr/local/lib/android/sdk/ndk || true
sudo docker system prune -af || true
rm -rf ~/.npm/_cacache ~/.cache || true
sudo rm -rf /home/runner/work/_temp || true
echo "Disk after cleanup:" && df -h
- name: Ensure artifacts directory
run: mkdir -p ${{ github.workspace }}/artifacts
- name: Specify node version
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24
cache: 'npm'
- name: Install node_modules
run: npm ci || npm ci
- name: Use specific Java version for sdkmanager to work
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
distribution: 'temurin'
java-version: '17'
- name: Enable KVM group perms
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: Download APKs
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: bluewallet-android-apks
- name: Restore APKs
run: tar -xzf bluewallet-android-apks.tar.gz
- name: Run tests
uses: reactivecircus/android-emulator-runner@e89f39f1abbbd05b1113a29cf4db69e7540cae5a # v2.37.0
with:
api-level: 36
profile: pixel
avd-name: Pixel_API_29_AOSP
force-avd-creation: true
enable-hw-keyboard: true
emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -noaudio -no-boot-anim -camera-back none -camera-front none -partition-size 2047
arch: x86_64
script: npm run e2e:release-test -- --record-videos failing --record-logs failing --take-screenshots failing --headless --retries 4 --reuse --artifacts-location ${{ github.workspace }}/artifacts
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: failure()
with:
name: e2e-android-videos
path: ${{ github.workspace }}/artifacts

233
.github/workflows/e2e-ios.yml vendored Normal file
View File

@ -0,0 +1,233 @@
name: Tests e2e iOS
on: [pull_request]
permissions:
contents: read
concurrency:
group: e2e-ios-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
runs-on: macos-26
env:
BUILD_CONFIGURATION: Release
CCACHE_MAXSIZE: "2G"
CCACHE_DIR: /Users/runner/.ccache
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24
cache: 'npm'
- name: Setup Ruby
uses: ruby/setup-ruby@c4e5b1316158f92e3d49443a9d58b31d25ac0f8f # v1.306.0
with:
ruby-version: "3.4.9"
bundler-cache: true
- name: Install Node dependencies
run: npm ci || npm ci
- name: Cache CocoaPods
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
ios/Pods
~/Library/Caches/CocoaPods
~/.cocoapods/repos
key: ${{ runner.os }}-pods-prebuilt-${{ hashFiles('ios/Podfile.lock') }}
restore-keys: |
${{ runner.os }}-pods-prebuilt-
- name: Install ccache
run: brew install ccache
- name: Cache ccache
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.ccache
key: ${{ runner.os }}-ccache-${{ github.sha }}
restore-keys: |
${{ runner.os }}-ccache-
- name: Delete extension and watch targets
run: |
bundle exec ruby <<'RUBY'
require 'xcodeproj'
project_path = 'ios/BlueWallet.xcodeproj'
project = Xcodeproj::Project.open(project_path)
target_names = %w[BlueWalletWatch WidgetsExtension Stickers]
embed_phase_names = ['Embed Watch Content', 'Embed Foundation Extensions']
removed_any = false
target_names.each do |target_name|
target = project.targets.find { |t| t.name == target_name }
next unless target
puts "Removing target #{target_name}"
target.dependencies.each(&:remove_from_project)
target.build_phases.each(&:remove_from_project)
project.targets.delete(target)
removed_any = true
end
main_target = project.targets.find { |t| t.name == 'BlueWallet' }
if main_target
main_target.build_phases.select { |phase| embed_phase_names.include?(phase.display_name) }.each do |phase|
puts "Removing build phase #{phase.display_name}"
phase.remove_from_project
main_target.build_phases.delete(phase)
removed_any = true
end
end
if removed_any
project.save
puts 'Extension and watch target references removed'
else
puts 'No extension or watch targets found'
end
RUBY
- name: Remove extension and watch schemes
run: |
rm -f ios/BlueWallet.xcodeproj/xcshareddata/xcschemes/BlueWalletWatch.xcscheme
rm -f ios/BlueWallet.xcodeproj/xcshareddata/xcschemes/WidgetsExtension.xcscheme
rm -f ios/BlueWallet.xcodeproj/xcshareddata/xcschemes/Stickers.xcscheme
- name: Install CocoaPods dependencies
env:
RCT_USE_RN_DEP: "1"
RCT_USE_PREBUILT_RNCORE: "1"
USE_CCACHE: "1"
run: bundle exec fastlane ios install_pods || bundle exec fastlane ios install_pods
- name: Reset ccache stats
run: ccache -z || true
- name: Build iOS simulator app
working-directory: ios
env:
RCT_NO_LAUNCH_PACKAGER: "1"
CCACHE_BINARY: /opt/homebrew/bin/ccache
run: |
set -eo pipefail
build() {
xcodebuild \
-workspace BlueWallet.xcworkspace \
-scheme BlueWallet \
-configuration "${BUILD_CONFIGURATION}" \
-sdk iphonesimulator \
-destination 'generic/platform=iOS Simulator' \
-derivedDataPath build \
CLANG_ENABLE_EXPLICIT_MODULES=NO \
SWIFT_ENABLE_EXPLICIT_MODULES=NO \
build
}
build || build
- name: ccache stats
if: always()
run: ccache -s || true
- name: Package simulator app
run: |
APP_DIR="ios/build/Build/Products/${BUILD_CONFIGURATION}-iphonesimulator/BlueWallet.app"
if [ ! -d "$APP_DIR" ]; then
echo "Simulator app not found at $APP_DIR"
find ios/build -maxdepth 5 -name '*.app' || true
exit 1
fi
tar -czf BlueWallet.app.tar.gz -C "$(dirname "$APP_DIR")" "$(basename "$APP_DIR")"
- name: Upload simulator app
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: bluewallet-ios-app
path: BlueWallet.app.tar.gz
retention-days: 3
compression-level: 0
if-no-files-found: error
test:
runs-on: macos-26
needs: build
env:
HD_MNEMONIC: ${{ secrets.HD_MNEMONIC }}
HD_MNEMONIC_BIP84: ${{ secrets.HD_MNEMONIC_BIP84 }}
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24
cache: 'npm'
- name: Install Node dependencies
run: npm ci || npm ci
- name: Install applesimutils
run: |
brew tap wix/brew
brew install applesimutils
- name: Download simulator app
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: bluewallet-ios-app
- name: Restore simulator app
run: |
mkdir -p ios/build/Build/Products/Release-iphonesimulator
tar -xzf BlueWallet.app.tar.gz -C ios/build/Build/Products/Release-iphonesimulator
# Pre-boot simulator so first detox launchApp lands warm.
- name: Pre-boot iOS simulator
run: |
DEVICE_TYPE=$(jq -r '.devices.simulator.device.type' .detoxrc.json)
UDID=$(applesimutils --list --byType "$DEVICE_TYPE" | jq -r '.[0].udid // empty')
if [ -z "$UDID" ]; then
echo "ERROR: no simulator of type '$DEVICE_TYPE' found"
exit 1
fi
xcrun simctl boot "$UDID" 2>/dev/null || true
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: |
npm run e2e:test:ios-release -- \
--record-videos failing \
--record-logs failing \
--take-screenshots failing \
--headless \
--retries 3 \
--reuse \
--artifacts-location ./artifacts
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: failure()
with:
name: e2e-ios-videos
path: ./artifacts/

View File

@ -1,251 +0,0 @@
name: Tests e2e
on: [pull_request]
permissions:
contents: read
jobs:
ios:
runs-on: macos-15
env:
BUILD_CONFIGURATION: Release
HD_MNEMONIC: ${{ secrets.HD_MNEMONIC }}
HD_MNEMONIC_BIP84: ${{ secrets.HD_MNEMONIC_BIP84 }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
cache: 'npm'
cache-dependency-path: package-lock.json
- name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: "3.1.6"
- name: Cache Ruby gems
uses: actions/cache@v5
with:
path: vendor/bundle
key: ${{ runner.os }}-ruby-${{ hashFiles('Gemfile.lock') }}
restore-keys: |
${{ runner.os }}-ruby-
- name: Install Ruby gems
run: |
bundle config path vendor/bundle
bundle install --jobs 4 --retry 3 --quiet
- name: Install Node dependencies
run: npm ci || npm ci
- name: Cache CocoaPods
uses: actions/cache@v5
with:
path: ios/Pods
key: ${{ runner.os }}-pods-${{ hashFiles('ios/Podfile.lock') }}
restore-keys: |
${{ runner.os }}-pods-
- name: Install CocoaPods dependencies
run: bundle exec fastlane ios install_pods || bundle exec fastlane ios install_pods
- name: Delete Apple Watch target
run: |
bundle exec ruby <<'RUBY'
require 'xcodeproj'
project_path = 'ios/BlueWallet.xcodeproj'
project = Xcodeproj::Project.open(project_path)
target_names = %w[BlueWalletWatch]
removed_any = false
target_names.each do |target_name|
target = project.targets.find { |t| t.name == target_name }
next unless target
puts "Removing target #{target_name}"
target.dependencies.each(&:remove_from_project)
target.build_phases.each(&:remove_from_project)
project.targets.delete(target)
removed_any = true
end
main_target = project.targets.find { |t| t.name == 'BlueWallet' }
if main_target
main_target.build_phases.select { |phase| phase.display_name == 'Embed Watch Content' }.each do |phase|
puts "Removing build phase #{phase.display_name}"
phase.remove_from_project
main_target.build_phases.delete(phase)
removed_any = true
end
end
if removed_any
project.save
puts 'Apple Watch target references removed'
else
puts 'No Apple Watch targets found'
end
RUBY
- name: Remove Watch schemes
run: rm -f ios/BlueWallet.xcodeproj/xcshareddata/xcschemes/BlueWalletWatch.xcscheme
- name: Build iOS simulator app
working-directory: ios
env:
RCT_NO_LAUNCH_PACKAGER: "1"
run: |
set -eo pipefail
build() {
xcodebuild \
-workspace BlueWallet.xcworkspace \
-scheme BlueWallet \
-configuration "${BUILD_CONFIGURATION}" \
-sdk iphonesimulator \
-destination 'generic/platform=iOS Simulator' \
-derivedDataPath build \
build
}
build || build
- name: Package simulator app
run: |
APP_DIR="ios/build/Build/Products/${BUILD_CONFIGURATION}-iphonesimulator/BlueWallet.app"
if [ ! -d "$APP_DIR" ]; then
echo "Simulator app not found at $APP_DIR"
find ios/build -maxdepth 5 -name '*.app' || true
exit 1
fi
OUTPUT_DIR="BlueWallet-simulator"
rm -rf "$OUTPUT_DIR"
mkdir -p "$OUTPUT_DIR"
cp -R "$APP_DIR" "$OUTPUT_DIR/BlueWallet.app"
echo "APP_EXPORT_PATH=$OUTPUT_DIR" >> "$GITHUB_ENV"
- name: Install applesimutils
run: |
brew tap wix/brew
brew install applesimutils
- name: Run detox tests
run: |
npm run e2e:test:ios-release -- \
--record-videos failing \
--record-logs failing \
--take-screenshots failing \
--headless \
--retries 3 \
--reuse \
--artifacts-location ./artifacts
- uses: actions/upload-artifact@v6
if: failure()
with:
name: e2e-ios-videos
path: ./artifacts/
android:
runs-on: ubuntu-latest
env:
HD_MNEMONIC: ${{ secrets.HD_MNEMONIC }}
HD_MNEMONIC_BIP84: ${{ secrets.HD_MNEMONIC_BIP84 }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Free disk space
uses: jlumbroso/free-disk-space@main
with:
tool-cache: true
android: false
dotnet: true
haskell: true
large-packages: true
docker-images: true
swap-storage: true
- name: npm and gradle caches in /mnt
run: |
rm -rf ~/.npm
rm -rf ~/.gradle
sudo mkdir -p /mnt/.npm
sudo mkdir -p /mnt/.gradle
sudo chown -R runner /mnt/.npm
sudo chown -R runner /mnt/.gradle
ln -s /mnt/.npm /home/runner/
ln -s /mnt/.gradle /home/runner/
- name: Create artifacts directory on /mnt
run: |
sudo mkdir -p /mnt/artifacts
sudo chown -R runner /mnt/artifacts
- name: Specify node version
uses: actions/setup-node@v6
with:
node-version: 24
cache: 'npm'
cache-dependency-path: package-lock.json
- name: Use gradle caches
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Install node_modules
run: npm ci --omit=dev --yes || npm ci --omit=dev --yes
- name: Use specific Java version for sdkmanager to work
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: '17'
- name: Enable KVM group perms
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: Build
run: npm run e2e:release-build || npm run e2e:release-build
- name: Install dev deps needed for tests
run: npm i || npm i
- name: Run tests
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 31
avd-name: Pixel_API_29_AOSP
force-avd-creation: false
enable-hw-keyboard: true
emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -noaudio -no-boot-anim -camera-back none -camera-front none -partition-size 2047
arch: x86_64
script: npm run e2e:release-test -- --record-videos failing --record-logs failing --take-screenshots failing --headless --retries 3 --reuse --artifacts-location /mnt/artifacts
- uses: actions/upload-artifact@v6
if: failure()
with:
name: e2e-android-videos
path: /mnt/artifacts/

2
.gitignore vendored
View File

@ -88,9 +88,11 @@ artifacts/
*.realm
*.realm.lock
android/app/.project
android/.settings/org.eclipse.buildship.core.prefs
android/app/.classpath
android/.settings/org.eclipse.buildship.core.prefs
android/.project
android/app/.settings/org.eclipse.jdt.core.prefs
android/.settings/org.eclipse.buildship.core.prefs
android/app/.classpath
android/app/.project

View File

@ -1 +1 @@
3.1.6
3.4.9

View File

@ -1,4 +1,4 @@
import { NavigationContainer } from '@react-navigation/native';
import { NavigationContainer, NavigationContainerRef, ParamListBase } from '@react-navigation/native';
import React from 'react';
import { useColorScheme } from 'react-native';
import { SafeAreaProvider } from 'react-native-safe-area-context';
@ -13,7 +13,7 @@ import { StorageProvider } from './components/Context/StorageProvider';
const App = () => {
const colorScheme = useColorScheme();
useLogger(navigationRef);
useLogger(navigationRef as unknown as React.RefObject<NavigationContainerRef<ParamListBase>>);
return (
<SizeClassProvider>

View File

@ -1,139 +0,0 @@
/* eslint react/prop-types: "off", react-native/no-inline-styles: "off" */
import React, { forwardRef } from 'react';
import { Dimensions, Platform, Pressable, StyleSheet, TextInput, View } from 'react-native';
import { Icon, Text } from '@rneui/themed';
import { useTheme } from './components/themes';
import { useLocale } from '@react-navigation/native';
const { height, width } = Dimensions.get('window');
const aspectRatio = height / width;
let isIpad;
if (aspectRatio > 1.6) {
isIpad = false;
} else {
isIpad = true;
}
/**
* TODO: remove this comment once this file gets properly converted to typescript.
*
* @type {React.FC<any>}
*/
export const BlueButtonLink = forwardRef((props, ref) => {
const { colors } = useTheme();
return (
<Pressable accessibilityRole="button" style={({ pressed }) => [styles.blueButtonLink, pressed && styles.pressed]} {...props} ref={ref}>
<Text style={{ color: colors.foregroundColor, textAlign: 'center', fontSize: 16 }}>{props.title}</Text>
</Pressable>
);
});
export const BlueCard = props => {
return <View {...props} style={{ padding: 20 }} />;
};
export const BlueText = ({ bold = false, ...props }) => {
const { colors } = useTheme();
const { direction } = useLocale();
const style = StyleSheet.compose(
{
color: colors.foregroundColor,
writingDirection: direction,
fontWeight: bold ? 'bold' : 'normal',
},
props.style,
);
return <Text {...props} style={style} />;
};
export const BlueTextCentered = props => {
const { colors } = useTheme();
return <Text {...props} style={{ color: colors.foregroundColor, textAlign: 'center' }} />;
};
export const BlueFormLabel = props => {
const { colors } = useTheme();
const { direction } = useLocale();
return (
<Text
{...props}
style={{
color: colors.foregroundColor,
fontWeight: '400',
marginHorizontal: 20,
writingDirection: direction,
}}
/>
);
};
export const BlueFormMultiInput = props => {
const { colors } = useTheme();
return (
<TextInput
multiline
underlineColorAndroid="transparent"
numberOfLines={4}
editable={!props.editable}
style={{
paddingHorizontal: 8,
paddingVertical: 16,
flex: 1,
marginTop: 5,
marginHorizontal: 20,
borderColor: colors.formBorder,
borderBottomColor: colors.formBorder,
borderWidth: 1,
borderBottomWidth: 0.5,
borderRadius: 4,
backgroundColor: colors.inputBackgroundColor,
color: colors.foregroundColor,
textAlignVertical: 'top',
}}
autoCorrect={false}
autoCapitalize="none"
spellCheck={false}
{...props}
selectTextOnFocus={false}
keyboardType={Platform.OS === 'android' ? 'visible-password' : 'default'}
/>
);
};
export class is {
static ipad() {
return isIpad;
}
}
export function BlueBigCheckmark({ style = {} }) {
const defaultStyles = {
backgroundColor: '#ccddf9',
width: 120,
height: 120,
borderRadius: 60,
alignSelf: 'center',
justifyContent: 'center',
marginTop: 0,
marginBottom: 0,
};
const mergedStyles = { ...defaultStyles, ...style };
return (
<View style={mergedStyles}>
<Icon name="check" size={50} type="font-awesome" color="#0f5cc0" />
</View>
);
}
const styles = StyleSheet.create({
blueButtonLink: {
minWidth: 100,
minHeight: 36,
justifyContent: 'center',
},
pressed: {
opacity: 0.6,
},
});

View File

@ -60,6 +60,8 @@ React Navigation 7.x with native stack. Typed params in `navigation/DetailViewSt
**Dependencies:** Do not add new dependencies without strong justification. Bonus for removing dependencies.
**Patches:** Local fixes to `node_modules` live in `patches/` and are applied by `patch-package` on `postinstall`. Each patch is documented in `patches/README.md` (what/why + upstream issue link); update it when adding or removing a patch.
**Components:** New components go in `components/`, not legacy `BlueComponents.js`.
**Linting Rules:**
@ -67,7 +69,7 @@ React Navigation 7.x with native stack. Typed params in `navigation/DetailViewSt
- No unused styles (`react-native/no-unused-styles`: error)
- Prettier: single quotes, 140 char width, trailing commas
**Localization:** Keys in `loc/en.json`. Run `find-unused-loc.js` to detect unused keys.
**Localization:** Keys in `loc/en.json`. Run `find-unused-loc.js` to detect unused keys. See `loc/vocabulary.md` for the canonical glossary of Bitcoin/Lightning terms and their per-language renderings — use it as ground truth when translating or generating translations with LLMs.
## Testing

View File

@ -25,3 +25,9 @@ Do *not* add new dependencies. Bonus points if you manage to actually remove a d
All new files must be in typescript. Bonus points if you convert some of the existing files to typescript.
New components must go in `components/`. Bonus points if you refactor some of old components in `BlueComponents.js` to separate files.
Don't forget to add tests. Bonus points for e2e tests.
# PRs
When submitting PR, it must include screenshot (from the emulator or the device) how the proposed change looks, even better - a video; and a short description of why (it was implemented) and how (it works under the hood).

12
Gemfile
View File

@ -1,13 +1,19 @@
source "https://rubygems.org"
# You may use http://rbenv.org/ or https://rvm.io/ to install and use this version
ruby "3.1.6"
gem "fastlane", "~> 2.228.0"
ruby "3.4.9"
gem "fastlane", "~> 2.234.0"
# Exclude problematic versions of cocoapods and activesupport that causes build failures.
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.4'
gem 'concurrent-ruby', '< 1.3.8'
# Ruby 3.4.0 removed these from the standard library
gem 'bigdecimal'
gem 'logger'
gem 'benchmark'
gem 'mutex_m'
# Required for App Store Connect API
gem "jwt"

View File

@ -1,9 +1,9 @@
GEM
remote: https://rubygems.org/
specs:
CFPropertyList (3.0.9)
CFPropertyList (3.0.8)
abbrev (0.1.2)
activesupport (7.2.3)
activesupport (7.2.3.1)
base64
benchmark (>= 0.3)
bigdecimal
@ -12,10 +12,10 @@ GEM
drb
i18n (>= 1.6, < 2)
logger (>= 1.4.2)
minitest (>= 5.1)
minitest (>= 5.1, < 6)
securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5)
addressable (2.8.8)
addressable (2.9.0)
public_suffix (>= 2.0.2, < 8.0)
algoliasearch (1.27.5)
httpclient (~> 2.8, >= 2.8.3)
@ -23,8 +23,8 @@ GEM
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.4.0)
aws-partitions (1.1217.0)
aws-sdk-core (3.242.0)
aws-partitions (1.1252.0)
aws-sdk-core (3.247.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
@ -32,11 +32,11 @@ GEM
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.122.0)
aws-sdk-core (~> 3, >= 3.241.4)
aws-sdk-kms (1.127.0)
aws-sdk-core (~> 3, >= 3.247.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.213.0)
aws-sdk-core (~> 3, >= 3.241.4)
aws-sdk-s3 (1.223.0)
aws-sdk-core (~> 3, >= 3.247.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)
@ -44,7 +44,7 @@ GEM
babosa (1.0.4)
base64 (0.3.0)
benchmark (0.5.0)
bigdecimal (4.0.1)
bigdecimal (4.1.2)
claide (1.1.0)
cocoapods (1.15.2)
addressable (~> 2.8)
@ -87,8 +87,9 @@ GEM
colored2 (3.1.2)
commander (4.6.0)
highline (~> 2.0.0)
concurrent-ruby (1.3.3)
connection_pool (2.5.5)
concurrent-ruby (1.3.7)
connection_pool (3.0.2)
csv (3.3.5)
declarative (0.0.20)
digest-crc (0.7.0)
rake (>= 12.0.0, < 14.0.0)
@ -126,19 +127,23 @@ GEM
faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0)
faraday-rack (1.0.0)
faraday-retry (1.0.3)
faraday-retry (1.0.4)
faraday_middleware (1.2.1)
faraday (~> 1.0)
fastimage (2.4.0)
fastlane (2.228.0)
CFPropertyList (>= 2.3, < 4.0.0)
fastimage (2.4.1)
fastlane (2.234.0)
CFPropertyList (>= 2.3, < 5.0.0)
abbrev (~> 0.1)
addressable (>= 2.8, < 3.0.0)
artifactory (~> 3.0)
aws-sdk-s3 (~> 1.0)
aws-sdk-s3 (~> 1.197)
babosa (>= 1.0.3, < 2.0.0)
bundler (>= 1.12.0, < 3.0.0)
base64 (~> 0.2)
benchmark (>= 0.1.0)
bundler (>= 1.17.3, < 5.0.0)
colored (~> 1.2)
commander (~> 4.6)
csv (~> 3.3)
dotenv (>= 2.1.1, < 3.0.0)
emoji_regex (>= 0.1, < 4.0)
excon (>= 0.71.0, < 1.0.0)
@ -146,20 +151,24 @@ GEM
faraday-cookie_jar (~> 0.0.6)
faraday_middleware (~> 1.0)
fastimage (>= 2.1.0, < 3.0.0)
fastlane-sirp (>= 1.0.0)
fastlane-sirp (>= 1.1.0)
gh_inspector (>= 1.1.2, < 2.0.0)
google-apis-androidpublisher_v3 (~> 0.3)
google-apis-playcustomapp_v1 (~> 0.1)
google-cloud-env (>= 1.6.0, < 2.0.0)
google-cloud-env (>= 1.6.0, <= 2.1.1)
google-cloud-storage (~> 1.31)
highline (~> 2.0)
http-cookie (~> 1.0.5)
json (< 3.0.0)
jwt (>= 2.1.0, < 3)
logger (>= 1.6, < 2.0)
mini_magick (>= 4.9.4, < 5.0.0)
multipart-post (>= 2.0.0, < 3.0.0)
mutex_m (~> 0.3)
naturally (~> 2.2)
nkf (~> 0.2)
optparse (>= 0.1.1, < 1.0.0)
ostruct (>= 0.1.0)
plist (>= 3.1.0, < 4.0.0)
rubyzip (>= 2.0.0, < 3.0.0)
security (= 0.1.5)
@ -179,49 +188,50 @@ GEM
git
xml-simple
fastlane-plugin-bugsnag_sourcemaps_upload (0.2.0)
fastlane-sirp (1.0.0)
sysrandom (~> 1.0)
fastlane-sirp (1.1.0)
ffi (1.17.3)
fourflusher (2.3.1)
fuzzy_match (2.0.4)
gh_inspector (1.1.3)
git (3.1.1)
git (4.3.1)
activesupport (>= 5.0)
addressable (~> 2.8)
process_executer (~> 1.3)
process_executer (~> 4.0)
rchardet (~> 1.9)
google-apis-androidpublisher_v3 (0.54.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-core (0.11.3)
google-apis-androidpublisher_v3 (0.100.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-core (0.18.0)
addressable (~> 2.5, >= 2.5.1)
googleauth (>= 0.16.2, < 2.a)
httpclient (>= 2.8.1, < 3.a)
googleauth (~> 1.9)
httpclient (>= 2.8.3, < 3.a)
mini_mime (~> 1.0)
mutex_m
representable (~> 3.0)
retriable (>= 2.0, < 4.a)
rexml
google-apis-iamcredentials_v1 (0.17.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-playcustomapp_v1 (0.13.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-storage_v1 (0.31.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-iamcredentials_v1 (0.27.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-playcustomapp_v1 (0.17.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-storage_v1 (0.62.0)
google-apis-core (>= 0.15.0, < 2.a)
google-cloud-core (1.8.0)
google-cloud-env (>= 1.0, < 3.a)
google-cloud-errors (~> 1.0)
google-cloud-env (1.6.0)
faraday (>= 0.17.3, < 3.0)
google-cloud-errors (1.5.0)
google-cloud-storage (1.47.0)
google-cloud-env (2.1.1)
faraday (>= 1.0, < 3.a)
google-cloud-errors (1.6.0)
google-cloud-storage (1.60.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
google-apis-iamcredentials_v1 (~> 0.1)
google-apis-storage_v1 (~> 0.31.0)
google-apis-core (>= 0.18, < 2)
google-apis-iamcredentials_v1 (~> 0.18)
google-apis-storage_v1 (>= 0.42)
google-cloud-core (~> 1.6)
googleauth (>= 0.16.2, < 2.a)
googleauth (~> 1.9)
mini_mime (~> 1.0)
googleauth (1.8.1)
faraday (>= 0.17.3, < 3.a)
googleauth (1.11.2)
faraday (>= 1.0, < 3.a)
google-cloud-env (~> 2.1)
jwt (>= 1.4, < 3.0)
multi_json (~> 1.11)
os (>= 0.9, < 2.0)
@ -235,31 +245,34 @@ GEM
i18n (1.14.8)
concurrent-ruby (~> 1.0)
jmespath (1.6.2)
json (2.18.1)
json (2.19.5)
jwt (2.10.2)
base64
logger (1.7.0)
mime-types (3.7.0)
logger
mime-types-data (~> 3.2025, >= 3.2025.0507)
mime-types-data (3.2026.0203)
mime-types-data (3.2026.0317)
mini_magick (4.13.2)
mini_mime (1.1.5)
minitest (5.27.0)
molinillo (0.8.0)
multi_json (1.19.1)
multi_json (1.21.1)
multipart-post (2.4.1)
mutex_m (0.3.0)
nanaimo (0.3.0)
nap (1.1.0)
naturally (2.3.0)
netrc (0.11.0)
nkf (0.2.0)
optparse (0.8.1)
os (1.1.4)
ostruct (0.6.3)
plist (3.7.2)
process_executer (1.3.0)
process_executer (4.0.2)
track_open_instances (~> 0.1)
public_suffix (4.0.7)
rake (13.3.1)
rake (13.4.2)
rchardet (1.10.0)
representable (3.2.0)
declarative (< 0.1.0)
@ -270,7 +283,7 @@ GEM
http-cookie (>= 1.0.2, < 2.0)
mime-types (>= 1.16, < 4.0)
netrc (~> 0.8)
retriable (3.2.1)
retriable (3.4.1)
rexml (3.4.4)
rouge (3.28.0)
ruby-macho (2.5.1)
@ -286,17 +299,17 @@ GEM
simctl (1.6.10)
CFPropertyList
naturally
sysrandom (1.0.5)
terminal-notifier (2.0.0)
terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3)
track_open_instances (0.1.15)
trailblazer-option (0.1.2)
tty-cursor (0.7.1)
tty-screen (0.8.2)
tty-spinner (0.9.3)
tty-cursor (~> 0.7)
typhoeus (1.4.1)
ethon (>= 0.9.0)
typhoeus (1.6.0)
ethon (>= 0.18.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
uber (0.1.0)
@ -321,17 +334,157 @@ PLATFORMS
DEPENDENCIES
activesupport (>= 6.1.7.5, != 7.1.0)
benchmark
bigdecimal
cocoapods (>= 1.13, != 1.15.1, != 1.15.0)
concurrent-ruby (< 1.3.4)
fastlane (~> 2.228.0)
concurrent-ruby (< 1.3.8)
fastlane (~> 2.234.0)
fastlane-plugin-browserstack
fastlane-plugin-bugsnag
fastlane-plugin-bugsnag_sourcemaps_upload
jwt
logger
mutex_m
xcodeproj (< 1.26.0)
CHECKSUMS
CFPropertyList (3.0.8) sha256=2c99d0d980536d3d7ab252f7bd59ac8be50fbdd1ff487c98c949bb66bb114261
abbrev (0.1.2) sha256=ad1b4eaaaed4cb722d5684d63949e4bde1d34f2a95e20db93aecfe7cbac74242
activesupport (7.2.3.1) sha256=11ebed516a43a0bb47346227a35ebae4d9427465a7c9eb197a03d5c8d283cb34
addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af
algoliasearch (1.27.5) sha256=26c1cddf3c2ec4bd60c148389e42702c98fdac862881dc6b07a4c0b89ffec853
artifactory (3.0.17) sha256=3023d5c964c31674090d655a516f38ca75665c15084140c08b7f2841131af263
atomos (0.1.3) sha256=7d43b22f2454a36bace5532d30785b06de3711399cb1c6bf932573eda536789f
aws-eventstream (1.4.0) sha256=116bf85c436200d1060811e6f5d2d40c88f65448f2125bc77ffce5121e6e183b
aws-partitions (1.1252.0) sha256=b44c74136ebd634d35f3fb8fd37def5214db21b9375f22c6954dbe7a7f2a449d
aws-sdk-core (3.247.0) sha256=789864594ce8cef05ee3d81fa8ed506099280bda6ea12a7612b8b7c5e5e62851
aws-sdk-kms (1.127.0) sha256=5d540b6afb9574327202989db2217741211e1cce3fb443ad0e1e37de730202e5
aws-sdk-s3 (1.223.0) sha256=655e382af34926caa76b77cf0171caed5f61ff52b8b58ae50f6f3e22c39e6cbc
aws-sigv4 (1.12.1) sha256=6973ff95cb0fd0dc58ba26e90e9510a2219525d07620c8babeb70ef831826c00
babosa (1.0.4) sha256=18dea450f595462ed7cb80595abd76b2e535db8c91b350f6c4b3d73986c5bc99
base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
benchmark (0.5.0) sha256=465df122341aedcb81a2a24b4d3bd19b6c67c1530713fd533f3ff034e419236c
bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd
claide (1.1.0) sha256=6d3c5c089dde904d96aa30e73306d0d4bd444b1accb9b3125ce14a3c0183f82e
cocoapods (1.15.2) sha256=f0f5153de8d028d133b96f423e04f37fb97a1da0d11dda581a9f46c0cba4090a
cocoapods-core (1.15.2) sha256=322650d97fe1ad4c0831a09669764b888bd91c6d79d0f6bb07281a17667a2136
cocoapods-deintegrate (1.0.5) sha256=517c2a448ef563afe99b6e7668704c27f5de9e02715a88ee9de6974dc1b3f6a2
cocoapods-downloader (2.1) sha256=bb6ebe1b3966dc4055de54f7a28b773485ac724fdf575d9bee2212d235e7b6d1
cocoapods-plugins (1.0.0) sha256=725d17ce90b52f862e73476623fd91441b4430b742d8a071000831efb440ca9a
cocoapods-search (1.0.1) sha256=1b133b0e6719ed439bd840e84a1828cca46425ab73a11eff5e096c3b2df05589
cocoapods-trunk (1.6.0) sha256=5f5bda8c172afead48fa2d43a718cf534b1313c367ba1194cebdeb9bfee9ed31
cocoapods-try (1.2.0) sha256=145b946c6e7747ed0301d975165157951153d27469e6b2763c83e25c84b9defe
colored (1.2) sha256=9d82b47ac589ce7f6cab64b1f194a2009e9fd00c326a5357321f44afab2c1d2c
colored2 (3.1.2) sha256=b13c2bd7eeae2cf7356a62501d398e72fde78780bd26aec6a979578293c28b4a
commander (4.6.0) sha256=7d1ddc3fccae60cc906b4131b916107e2ef0108858f485fdda30610c0f2913d9
concurrent-ruby (1.3.7) sha256=4412caec3a5ea2e5fdc52076724c071a81f2c0593d83b2ac8cbb8ca63b3151b0
connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a
csv (3.3.5) sha256=6e5134ac3383ef728b7f02725d9872934f523cb40b961479f69cf3afa6c8e73f
declarative (0.0.20) sha256=8021dd6cb17ab2b61233c56903d3f5a259c5cf43c80ff332d447d395b17d9ff9
digest-crc (0.7.0) sha256=64adc23a26a241044cbe6732477ca1b3c281d79e2240bcff275a37a5a0d78c07
domain_name (0.6.20240107) sha256=5f693b2215708476517479bf2b3802e49068ad82167bcd2286f899536a17d933
dotenv (2.8.1) sha256=c5944793349ae03c432e1780a2ca929d60b88c7d14d52d630db0508c3a8a17d8
drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373
emoji_regex (3.2.3) sha256=ecd8be856b7691406c6bf3bb3a5e55d6ed683ffab98b4aa531bb90e1ddcc564b
escape (0.0.4) sha256=e49f44ae2b4f47c6a3abd544ae77fe4157802794e32f19b8e773cbc4dcec4169
ethon (0.18.0) sha256=b598afc9f30448cb068b850714b7d6948e941476095d04f90a4ac65b8d6efcb2
excon (0.112.0) sha256=daf9ac3a4c2fc9aa48383a33da77ecb44fa395111e973084d5c52f6f214ae0f0
faraday (1.10.5) sha256=b144f1d2b045652fa820b5f532723e1643cc28b93dae911d784e5c5f88e8f6ed
faraday-cookie_jar (0.0.8) sha256=0140605823f8cc63c7028fccee486aaed8e54835c360cffc1f7c8c07c4299dbb
faraday-em_http (1.0.0) sha256=7a3d4c7079789121054f57e08cd4ef7e40ad1549b63101f38c7093a9d6c59689
faraday-em_synchrony (1.0.1) sha256=bf3ce45dcf543088d319ab051f80985ea6d294930635b7a0b966563179f81750
faraday-excon (1.1.0) sha256=b055c842376734d7f74350fe8611542ae2000c5387348d9ba9708109d6e40940
faraday-httpclient (1.0.1) sha256=4c8ff1f0973ff835be8d043ef16aaf54f47f25b7578f6d916deee8399a04d33b
faraday-multipart (1.2.0) sha256=7d89a949693714176f612323ca13746a2ded204031a6ba528adee788694ef757
faraday-net_http (1.0.2) sha256=63992efea42c925a20818cf3c0830947948541fdcf345842755510d266e4c682
faraday-net_http_persistent (1.2.0) sha256=0b0cbc8f03dab943c3e1cc58d8b7beb142d9df068b39c718cd83e39260348335
faraday-patron (1.0.0) sha256=dc2cd7b340bb3cc8e36bcb9e6e7eff43d134b6d526d5f3429c7a7680ddd38fa7
faraday-rack (1.0.0) sha256=ef60ec969a2bb95b8dbf24400155aee64a00fc8ba6c6a4d3968562bcc92328c0
faraday-retry (1.0.4) sha256=dc659233777fabf96c69c2ffe56c0a5d2c102af90321a42cc6c90157bcd716aa
faraday_middleware (1.2.1) sha256=d45b78c8ee864c4783fbc276f845243d4a7918a67301c052647bacabec0529e9
fastimage (2.4.1) sha256=c64bebd46b6fd8943ab70c1e6e85ff728f970f2e48f92ecd249b6bc3a540ad20
fastlane (2.234.0) sha256=b74835681ad9a8e9c0931a5727dad1bab433895ac534c864a1ed5749625d26e9
fastlane-plugin-browserstack (0.3.4) sha256=a4f3e4a552e2390a4733570857512571535912100ffada177d5374413f2c1333
fastlane-plugin-bugsnag (3.0.0) sha256=8ddac4b79cb4b5d00432cccd5789a9e1a1119c29f7773a27d01b1d8a2363915d
fastlane-plugin-bugsnag_sourcemaps_upload (0.2.0) sha256=a05afaefa81a7bf56c36386dddeb0931db31ead6886e3eae24f9683bda1a064d
fastlane-sirp (1.1.0) sha256=10bc94f9682efd8e1badfb31452a76dd8981f1f3a33717c765fde6d75b54d847
ffi (1.17.3) sha256=0e9f39f7bb3934f77ad6feab49662be77e87eedcdeb2a3f5c0234c2938563d4c
fourflusher (2.3.1) sha256=1b3de61c7c791b6a4e64f31e3719eb25203d151746bb519a0292bff1065ccaa9
fuzzy_match (2.0.4) sha256=b5de4f95816589c5b5c3ad13770c0af539b75131c158135b3f3bbba75d0cfca5
gh_inspector (1.1.3) sha256=04cca7171b87164e053aa43147971d3b7f500fcb58177698886b48a9fc4a1939
git (4.3.1) sha256=91ca566c39766a033e61a148c8f470908bd4786b818f8f3ff566d3a9a0200c50
google-apis-androidpublisher_v3 (0.100.0) sha256=7a82935bee985190e8fe23bf5e53df3a27d65dd084114bb71b846b617de16489
google-apis-core (0.18.0) sha256=96b057816feeeab448139ed5b5c78eab7fc2a9d8958f0fbc8217dedffad054ee
google-apis-iamcredentials_v1 (0.27.0) sha256=9289f29968610754ef11d98b9ec627f0153f3e2616fef839aef096de529f6d1e
google-apis-playcustomapp_v1 (0.17.0) sha256=d5bc90b705f3f862bab4998086449b0abe704ee1685a84821daa90ca7fa95a78
google-apis-storage_v1 (0.62.0) sha256=f62467c36df53287fb0252ebb4da85f9e25d7b4c5809d045c2aab1fc307760c1
google-cloud-core (1.8.0) sha256=e572edcbf189cfcab16590628a516cec3f4f63454b730e59f0b36575120281cf
google-cloud-env (2.1.1) sha256=cf4bb8c7d517ee1ea692baedf06e0b56ce68007549d8d5a66481aa9f97f46999
google-cloud-errors (1.6.0) sha256=1da8476dd706ad04b9d32e3c4b90d07d3463b37d6407cb56d41342ea7647d0a1
google-cloud-storage (1.60.0) sha256=b21b752d37945d678a4533be5ef4303f15d33a964d8bc709c7c41c3600f650db
googleauth (1.11.2) sha256=7e6bacaeed7aea3dd66dcea985266839816af6633e9f5983c3c2e0e40a44731e
highline (2.0.3) sha256=2ddd5c127d4692721486f91737307236fe005352d12a4202e26c48614f719479
http-accept (1.7.0) sha256=c626860682bfbb3b46462f8c39cd470fd7b0584f61b3cc9df5b2e9eb9972a126
http-cookie (1.0.8) sha256=b14fe0445cf24bf9ae098633e9b8d42e4c07c3c1f700672b09fbfe32ffd41aa6
httpclient (2.9.0) sha256=4b645958e494b2f86c2f8a2f304c959baa273a310e77a2931ddb986d83e498c8
i18n (1.14.8) sha256=285778639134865c5e0f6269e0b818256017e8cde89993fdfcbfb64d088824a5
jmespath (1.6.2) sha256=238d774a58723d6c090494c8879b5e9918c19485f7e840f2c1c7532cf84ebcb1
json (2.19.5) sha256=218a18553e4801d579ca7e0f5bc72bafd776d7397238a1fb4e74db5b0a812c59
jwt (2.10.2) sha256=31e1ee46f7359883d5e622446969fe9c118c3da87a0b1dca765ce269c3a0c4f4
logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
mime-types (3.7.0) sha256=dcebf61c246f08e15a4de34e386ebe8233791e868564a470c3fe77c00eed5e56
mime-types-data (3.2026.0317) sha256=77f078a4d8631d52b842ba77099734b06eddb7ad339d792e746d2272b67e511b
mini_magick (4.13.2) sha256=71d6258e0e8a3d04a9a0a09784d5d857b403a198a51dd4f882510435eb95ddd9
mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef
minitest (5.27.0) sha256=2d3b17f8a36fe7801c1adcffdbc38233b938eb0b4966e97a6739055a45fa77d5
molinillo (0.8.0) sha256=efbff2716324e2a30bccd3eba1ff3a735f4d5d53ffddbc6a2f32c0ca9433045d
multi_json (1.21.1) sha256=e6126a31808e3b4d19f483c775ceac34df190dffa62adfb63a165ee14ba68080
multipart-post (2.4.1) sha256=9872d03a8e552020ca096adadbf5e3cb1cd1cdd6acd3c161136b8a5737cdb4a8
mutex_m (0.3.0) sha256=cfcb04ac16b69c4813777022fdceda24e9f798e48092a2b817eb4c0a782b0751
nanaimo (0.3.0) sha256=aaaedc60497070b864a7e220f7c4b4cad3a0daddda2c30055ba8dae306342376
nap (1.1.0) sha256=949691660f9d041d75be611bb2a8d2fd559c467537deac241f4097d9b5eea576
naturally (2.3.0) sha256=459923cf76c2e6613048301742363200c3c7e4904c324097d54a67401e179e01
netrc (0.11.0) sha256=de1ce33da8c99ab1d97871726cba75151113f117146becbe45aa85cb3dabee3f
nkf (0.2.0) sha256=fbc151bda025451f627fafdfcb3f4f13d0b22ae11f58c6d3a2939c76c5f5f126
optparse (0.8.1) sha256=42bea10d53907ccff4f080a69991441d611fbf8733b60ed1ce9ee365ce03bd1a
os (1.1.4) sha256=57816d6a334e7bd6aed048f4b0308226c5fb027433b67d90a9ab435f35108d3f
ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912
plist (3.7.2) sha256=d37a4527cc1116064393df4b40e1dbbc94c65fa9ca2eec52edf9a13616718a42
process_executer (4.0.2) sha256=c73eb646d450044241c973a8360f6326e33ec5ad933f7acf503f6f3579873a71
public_suffix (4.0.7) sha256=8be161e2421f8d45b0098c042c06486789731ea93dc3a896d30554ee38b573b8
rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701
rchardet (1.10.0) sha256=d5ea2ed61a720a220f1914778208e718a0c7ed2a484b6d357ba695aa7001390f
representable (3.2.0) sha256=cc29bf7eebc31653586849371a43ffe36c60b54b0a6365b5f7d95ec34d1ebace
rest-client (2.1.0) sha256=35a6400bdb14fae28596618e312776c158f7ebbb0ccad752ff4fa142bf2747e3
retriable (3.4.1) sha256=fb3f114b7d492121c158c01f3d5152b5a615c5b70d5877d0bc08c7ec3725c3bc
rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142
rouge (3.28.0) sha256=0d6de482c7624000d92697772ab14e48dca35629f8ddf3f4b21c99183fd70e20
ruby-macho (2.5.1) sha256=9075e52e0f9270b552a90b24fcc6219ad149b0d15eae1bc364ecd0ac8984f5c9
ruby2_keywords (0.0.5) sha256=ffd13740c573b7301cf7a2e61fc857b2a8e3d3aff32545d6f8300d8bae10e3ef
rubyzip (2.4.1) sha256=8577c88edc1fde8935eb91064c5cb1aef9ad5494b940cf19c775ee833e075615
securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1
security (0.1.5) sha256=3a977a0eca7706e804c96db0dd9619e0a94969fe3aac9680fcfc2bf9b8a833b7
signet (0.21.0) sha256=d617e9fbf24928280d39dcfefba9a0372d1c38187ffffd0a9283957a10a8cd5b
simctl (1.6.10) sha256=b99077f4d13ad81eace9f86bf5ba4df1b0b893a4d1b368bd3ed59b5b27f9236b
terminal-notifier (2.0.0) sha256=7a0d2b2212ab9835c07f4b2e22a94cff64149dba1eed203c04835f7991078cea
terminal-table (3.0.2) sha256=f951b6af5f3e00203fb290a669e0a85c5dd5b051b3b023392ccfd67ba5abae91
track_open_instances (0.1.15) sha256=7f0e48821e6b4c881daaa40fb1583e308937c22a9c84883c150b399c3b5c3029
trailblazer-option (0.1.2) sha256=20e4f12ea4e1f718c8007e7944ca21a329eee4eed9e0fa5dde6e8ad8ac4344a3
tty-cursor (0.7.1) sha256=79534185e6a777888d88628b14b6a1fdf5154a603f285f80b1753e1908e0bf48
tty-screen (0.8.2) sha256=c090652115beae764336c28802d633f204fb84da93c6a968aa5d8e319e819b50
tty-spinner (0.9.3) sha256=0e036f047b4ffb61f2aa45f5a770ec00b4d04130531558a94bfc5b192b570542
typhoeus (1.6.0) sha256=bacc41c23e379547e29801dc235cd1699b70b955a1ba3d32b2b877aa844c331d
tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b
uber (0.1.0) sha256=5beeb407ff807b5db994f82fa9ee07cfceaa561dad8af20be880bc67eba935dc
unicode-display_width (2.6.0) sha256=12279874bba6d5e4d2728cef814b19197dbb10d7a7837a869bab65da943b7f5a
word_wrap (1.0.0) sha256=f556d4224c812e371000f12a6ee8102e0daa724a314c3f246afaad76d82accc7
xcodeproj (1.25.1) sha256=9a2310dccf6d717076e86f602b17c640046b6f1dfe64480044596f6f2f13dc84
xcpretty (0.4.1) sha256=b14c50e721f6589ee3d6f5353e2c2cfcd8541fa1ea16d6c602807dd7327f3892
xcpretty-travis-formatter (1.0.1) sha256=aacc332f17cb7b2cba222994e2adc74223db88724fe76341483ad3098e232f93
xml-simple (1.1.9) sha256=d21131e519c86f1a5bc2b6d2d57d46e6998e47f18ed249b25cad86433dbd695d
RUBY VERSION
ruby 3.1.6p260
ruby 3.4.9
BUNDLED WITH
2.3.27
4.0.7

View File

@ -104,7 +104,7 @@ Grab an issue from [the backlog](https://github.com/BlueWallet/BlueWallet/issues
## Translations
We accept translations via [Transifex](https://www.transifex.com/bluewallet/bluewallet/)
We accept translations via [Transifex](https://explore.transifex.com/bluewallet/bluewallet/)
To participate you need to:
1. Sign up to Transifex
@ -116,6 +116,10 @@ Please note the values in curly braces should not be translated. These are the n
Transifex automatically creates Pull Request when language reaches 100% translation. We also trigger this by hand before each release, so don't worry if you can't translate everything, every word counts.
### Vocabulary glossaries
[`loc/vocabulary.md`](loc/vocabulary.md) + the per-language files under [`loc/vocabulary/`](loc/vocabulary/) are the canonical glossary of Bitcoin/Lightning terms (Wallet, Vault, Seed, Mnemonic, Passphrase, Multisig, Payment Code, Coin Control, …) and their chosen rendering in each locale, with the reasoning behind each choice and ⚠️ anti-meaning callouts (e.g. Passcode ≠ Password, Change-output ≠ verb "to change"). Use them as ground truth when translating by hand or when feeding `loc/<lang>.json` to an LLM — terminology consistency across screens is the difference between "looks translated" and "is correct for a Bitcoin wallet". When you change a shipped string, update the matching row in the same PR.
## Q&A
Builds automated and tested with BrowserStack

View File

@ -1,2 +0,0 @@
connection.project.dir=
eclipse.preferences.version=1

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<classpath>
<classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-1.8/"/>
<classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-17/"/>
<classpathentry kind="con" path="org.eclipse.buildship.core.gradleclasspathcontainer"/>
<classpathentry kind="output" path="bin/default"/>
</classpath>

View File

@ -1,2 +1,2 @@
connection.project.dir=..
eclipse.preferences.version=1
eclipse.preferences.version=1

View File

@ -0,0 +1,4 @@
eclipse.preferences.version=1
org.eclipse.jdt.core.compiler.codegen.targetPlatform=17
org.eclipse.jdt.core.compiler.compliance=17
org.eclipse.jdt.core.compiler.source=17

View File

@ -19,9 +19,9 @@ react {
/* Variants */
// The list of variants to that are debuggable. For those we're going to
// skip the bundling of the JS bundle and the assets. By default is just 'debug'.
// skip the bundling of the JS bundle and the assets. Default is "debug", "debugOptimized".
// If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants.
// debuggableVariants = ["liteDebug", "prodDebug"]
// debuggableVariants = ["liteDebug", "liteDebugOptimized", "prodDebug", "prodDebugOptimized"]
/* Bundling */
// A list containing the node command and its flags. Default is just 'node'.
@ -87,13 +87,14 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "7.2.6"
versionName "8.0.1"
testBuildType System.getProperty('testBuildType', 'debug')
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
missingDimensionStrategy "react-native-capture-protection", "fullMediaCapture"
// Keep compatibility across react-native-capture-protection flavor changes.
missingDimensionStrategy "react-native-capture-protection", "callbackTiramisu", "base"
}
lintOptions {
lint {
abortOnError false
checkReleaseBuilds false
}
@ -101,13 +102,12 @@ android {
sourceSets {
main {
assets.srcDirs = ['src/main/assets', 'src/main/res/assets']
java.srcDirs = ['src/main/java', '../../blue_modules/Views/SegmentedControl/android']
}
}
buildTypes {
release {
// Caution! In production, you need to generate your own keystore file.
// see https://reactnative.dev/docs/signed-apk-android.
minifyEnabled enableProguardInReleaseBuilds
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
proguardFile "${rootProject.projectDir}/../node_modules/detox/android/detox/proguard-rules-app.pro"
@ -123,14 +123,21 @@ task copyFiatUnits(type: Copy) {
preBuild.dependsOn(copyFiatUnits)
// Ensure fiat units are available before codegen scans JS sources
tasks.configureEach { task ->
if (task.name == 'generateCodegenSchemaFromJavaScript') {
task.dependsOn(copyFiatUnits)
}
}
dependencies {
androidTestImplementation('com.wix:detox:+')
// The version of react-native is set by the React Native Gradle Plugin
implementation("com.facebook.react:react-android")
implementation 'androidx.core:core-ktx:1.16.0'
implementation 'androidx.work:work-runtime-ktx:2.10.5'
implementation 'androidx.core:core-ktx:1.18.0'
implementation 'androidx.work:work-runtime-ktx:2.11.2'
implementation 'com.google.android.material:material:1.12.0'
implementation 'androidx.compose.ui:ui:1.9.4'
implementation 'androidx.compose.ui:ui:1.10.6'
implementation 'androidx.compose.material3:material3:1.3.2'
implementation 'androidx.preference:preference-ktx:1.2.1'
@ -139,9 +146,7 @@ dependencies {
} else {
implementation jscFlavor
}
androidTestImplementation('com.wix:detox:0.1.1')
implementation 'androidx.appcompat:appcompat:1.7.1'
implementation fileTree(dir: "libs", include: ["*.jar"])
implementation 'androidx.constraintlayout:constraintlayout:2.2.1'
}
apply plugin: 'com.google.gms.google-services' // Google Services plugin

View File

@ -13,7 +13,6 @@
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<uses-permission android:name="android.permission.USE_FINGERPRINT" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.DETECT_SCREEN_CAPTURE" />
@ -34,27 +33,18 @@
android:networkSecurityConfig="@xml/network_security_config"
android:configChanges="uiMode">
<meta-data
android:name="com.dieam.reactnativepushnotification.notification_channel_name"
android:value="BlueWallet notifications" />
<meta-data
android:name="com.dieam.reactnativepushnotification.notification_channel_description"
android:value="Notifications about incoming payments" />
<meta-data
android:name="com.dieam.reactnativepushnotification.notification_foreground"
android:value="true" />
<meta-data
android:name="com.dieam.reactnativepushnotification.channel_create_default"
android:value="true" />
<meta-data
android:name="com.dieam.reactnativepushnotification.notification_color"
android:resource="@color/white" />
<meta-data
android:name="firebase_messaging_auto_init_enabled"
android:value="false" />
<meta-data
android:name="firebase_analytics_collection_enabled"
android:value="false" />
<meta-data
android:name="com.google.firebase.messaging.default_notification_icon"
android:resource="@drawable/notification_icon" />
<meta-data
android:name="com.google.firebase.messaging.default_notification_color"
android:resource="@color/foreground_color" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
@ -64,16 +54,6 @@
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<receiver android:name="com.dieam.reactnativepushnotification.modules.RNPushNotificationActions" />
<receiver android:name="com.dieam.reactnativepushnotification.modules.RNPushNotificationPublisher" />
<receiver
android:name="com.dieam.reactnativepushnotification.modules.RNPushNotificationBootEventReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
<receiver android:name=".BitcoinPriceWidget" android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
@ -130,19 +110,11 @@
</intent-filter>
</activity-alias>
<service
android:name="com.dieam.reactnativepushnotification.modules.RNPushNotificationListenerService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<activity
android:name=".MainActivity"
android:label="@string/app_name"
android:launchMode="singleTask"
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|uiMode"
android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode"
android:windowSoftInputMode="adjustResize"
android:exported="true">

View File

@ -39,31 +39,43 @@ class BitcoinPriceWidget : AppWidgetProvider() {
// Try to load cached data first
val sharedPref = context.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
val cachedPrice = sharedPref.getString("previous_price", null)
val preferredCurrency = sharedPref.getString("preferredCurrency", "USD")
val preferredCurrencyLocale = sharedPref.getString("preferredCurrencyLocale", null)
if (cachedPrice != null) {
// Show cached data immediately
val preferredCurrency = sharedPref.getString("preferredCurrency", "USD")
val preferredCurrencyLocale = sharedPref.getString("preferredCurrencyLocale", "en-US")
try {
val localeParts = preferredCurrencyLocale?.split("-") ?: listOf("en", "US")
val locale = if (localeParts.size == 2) {
java.util.Locale(localeParts[0], localeParts[1])
} else {
java.util.Locale.getDefault()
}
val locale = preferredCurrencyLocale
?.let { runCatching { java.util.Locale.forLanguageTag(it) }.getOrNull() }
?.takeIf { it.language.isNotBlank() }
?: java.util.Locale.getDefault()
val currencyFormat = java.text.NumberFormat.getCurrencyInstance(locale)
val currency = java.util.Currency.getInstance(preferredCurrency ?: "USD")
currencyFormat.currency = currency
currencyFormat.maximumFractionDigits = 0
val parsedCached = cachedPrice.toDoubleOrNull()?.toInt()
views.setViewVisibility(R.id.loading_indicator, View.GONE)
views.setViewVisibility(R.id.price_value, View.VISIBLE)
views.setViewVisibility(R.id.last_updated_label, View.VISIBLE)
views.setViewVisibility(R.id.last_updated_time, View.VISIBLE)
views.setTextViewText(R.id.price_value, currencyFormat.format(cachedPrice.toDouble().toInt()))
views.setTextViewText(R.id.last_updated_time, java.text.SimpleDateFormat("hh:mm a", java.util.Locale.getDefault()).format(java.util.Date()))
views.setViewVisibility(R.id.price_arrow_container, View.GONE)
if (parsedCached != null) {
views.setViewVisibility(R.id.price_value, View.VISIBLE)
views.setViewVisibility(R.id.last_updated_label, View.VISIBLE)
views.setViewVisibility(R.id.last_updated_time, View.VISIBLE)
views.setTextViewText(R.id.price_value, currencyFormat.format(parsedCached))
views.setTextViewText(
R.id.last_updated_time,
java.text.SimpleDateFormat("hh:mm a", java.util.Locale.getDefault()).format(java.util.Date())
)
} else {
// If parsing fails, show loading state
views.setViewVisibility(R.id.price_value, View.GONE)
views.setViewVisibility(R.id.last_updated_label, View.GONE)
views.setViewVisibility(R.id.last_updated_time, View.GONE)
views.setViewVisibility(R.id.loading_indicator, View.VISIBLE)
}
} catch (e: Exception) {
Log.e(TAG, "Error displaying cached price", e)
// Show loading state if cache display fails

View File

@ -11,14 +11,13 @@ import com.facebook.react.PackageList
import com.facebook.react.ReactApplication
import com.facebook.react.ReactHost
import com.facebook.react.ReactNativeHost
import com.facebook.react.ReactPackage
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.load
import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative
import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost
import com.facebook.react.defaults.DefaultReactNativeHost
import com.facebook.react.soloader.OpenSourceMergedSoMapping
import com.facebook.soloader.SoLoader
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.CustomSegmentedControlPackage
import io.bluewallet.bluewallet.components.segmentedcontrol.SegmentedControlPackage
class MainApplication : Application(), ReactApplication {
@ -66,26 +65,25 @@ class MainApplication : Application(), ReactApplication {
}
}
override val reactNativeHost: ReactNativeHost =
override val reactNativeHost: ReactNativeHost by lazy {
object : DefaultReactNativeHost(this) {
override fun getPackages(): List<ReactPackage> =
override fun getPackages() =
PackageList(this).packages.apply {
// Packages that cannot be autolinked yet can be added manually here, for example:
// add(MyReactNativePackage())
add(CustomSegmentedControlPackage())
add(SegmentedControlPackage())
add(SettingsPackage())
}
override fun getJSMainModuleName(): String = "index"
override fun getUseDeveloperSupport() = BuildConfig.DEBUG
override fun getUseDeveloperSupport(): Boolean = BuildConfig.DEBUG
override val isNewArchEnabled: Boolean = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED
override val isHermesEnabled: Boolean = BuildConfig.IS_HERMES_ENABLED
override fun getJSMainModuleName() = "index"
}
}
override val reactHost: ReactHost
get() = getDefaultReactHost(applicationContext, reactNativeHost)
override val reactHost: ReactHost by lazy {
getDefaultReactHost(applicationContext, reactNativeHost)
}
override fun onCreate() {
super.onCreate()
@ -101,12 +99,15 @@ class MainApplication : Application(), ReactApplication {
val sharedI18nUtilInstance = I18nUtil.getInstance()
sharedI18nUtilInstance.allowRTL(applicationContext, true)
SoLoader.init(this, OpenSourceMergedSoMapping)
if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
// If you opted-in for the New Architecture, we load the native entry point for this app.
load()
// 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()
initializeBugsnag()
}

View File

@ -4,12 +4,13 @@ import android.content.Context
import android.content.SharedPreferences
import android.util.Log
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.bridge.Promise
import java.util.UUID
import com.facebook.react.module.annotations.ReactModule
import io.bluewallet.bluewallet.NativeSettingsModuleSpec
class SettingsModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
@ReactModule(name = SettingsModule.NAME)
class SettingsModule(reactContext: ReactApplicationContext) : NativeSettingsModuleSpec(reactContext) {
private val sharedPref: SharedPreferences = reactContext.getSharedPreferences(
"group.io.bluewallet.bluewallet",
@ -22,10 +23,7 @@ class SettingsModule(reactContext: ReactApplicationContext) : ReactContextBaseJa
private const val DEVICE_UID_COPY_KEY = "deviceUIDCopy"
private const val CLEAR_FILES_ON_LAUNCH_KEY = "clearFilesOnLaunch"
private const val DO_NOT_TRACK_KEY = "donottrack"
}
override fun getName(): String {
return "SettingsModule"
const val NAME = "SettingsModule"
}
/**
@ -33,7 +31,7 @@ class SettingsModule(reactContext: ReactApplicationContext) : ReactContextBaseJa
* Uses the same Android ID as react-native-device-info's getUniqueId()
*/
@ReactMethod
fun initializeDeviceUID(promise: Promise) {
override fun initializeDeviceUID(promise: Promise) {
try {
val isDoNotTrackEnabled = sharedPref.getString(DO_NOT_TRACK_KEY, "0") == "1"
@ -86,7 +84,7 @@ class SettingsModule(reactContext: ReactApplicationContext) : ReactContextBaseJa
* Get the device UID
*/
@ReactMethod
fun getDeviceUID(promise: Promise) {
override fun getDeviceUID(promise: Promise) {
try {
val isDoNotTrackEnabled = sharedPref.getString(DO_NOT_TRACK_KEY, "0") == "1"
@ -107,7 +105,7 @@ class SettingsModule(reactContext: ReactApplicationContext) : ReactContextBaseJa
* Get the device UID copy (for Settings display)
*/
@ReactMethod
fun getDeviceUIDCopy(promise: Promise) {
override fun getDeviceUIDCopy(promise: Promise) {
try {
val deviceUIDCopy = sharedPref.getString(DEVICE_UID_COPY_KEY, "")
promise.resolve(deviceUIDCopy)
@ -121,7 +119,7 @@ class SettingsModule(reactContext: ReactApplicationContext) : ReactContextBaseJa
* Set the clearFilesOnLaunch preference
*/
@ReactMethod
fun setClearFilesOnLaunch(value: Boolean, promise: Promise) {
override fun setClearFilesOnLaunch(value: Boolean, promise: Promise) {
try {
sharedPref.edit()
.putBoolean(CLEAR_FILES_ON_LAUNCH_KEY, value)
@ -138,7 +136,7 @@ class SettingsModule(reactContext: ReactApplicationContext) : ReactContextBaseJa
* Get the clearFilesOnLaunch preference
*/
@ReactMethod
fun getClearFilesOnLaunch(promise: Promise) {
override fun getClearFilesOnLaunch(promise: Promise) {
try {
val value = sharedPref.getBoolean(CLEAR_FILES_ON_LAUNCH_KEY, false)
promise.resolve(value)
@ -152,7 +150,7 @@ class SettingsModule(reactContext: ReactApplicationContext) : ReactContextBaseJa
* Set Do Not Track setting
*/
@ReactMethod
fun setDoNotTrack(enabled: Boolean, promise: Promise) {
override fun setDoNotTrack(enabled: Boolean, promise: Promise) {
try {
val value = if (enabled) "1" else "0"
sharedPref.edit()
@ -184,7 +182,7 @@ class SettingsModule(reactContext: ReactApplicationContext) : ReactContextBaseJa
* Get Do Not Track setting
*/
@ReactMethod
fun getDoNotTrack(promise: Promise) {
override fun getDoNotTrack(promise: Promise) {
try {
val value = sharedPref.getString(DO_NOT_TRACK_KEY, "0")
val enabled = value == "1"
@ -199,7 +197,7 @@ class SettingsModule(reactContext: ReactApplicationContext) : ReactContextBaseJa
* Open the settings activity from JavaScript
*/
@ReactMethod
fun openSettings(promise: Promise) {
override fun openSettings(promise: Promise) {
try {
val intent = android.content.Intent(reactApplicationContext, SettingsActivity::class.java)
intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)

View File

@ -1,16 +1,29 @@
package io.bluewallet.bluewallet
import com.facebook.react.ReactPackage
import com.facebook.react.TurboReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.module.model.ReactModuleInfo
import com.facebook.react.module.model.ReactModuleInfoProvider
import com.facebook.react.uimanager.ViewManager
class SettingsPackage : ReactPackage {
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
return listOf(SettingsModule(reactContext))
class SettingsPackage : TurboReactPackage() {
override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? {
return if (name == SettingsModule.NAME) SettingsModule(reactContext) else null
}
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
return emptyList()
override fun getReactModuleInfoProvider(): ReactModuleInfoProvider = ReactModuleInfoProvider {
val moduleInfo = ReactModuleInfo(
SettingsModule.NAME,
SettingsModule.NAME,
false, // canOverrideExistingModule
false, // needsEagerInit
false, // hasConstants
false, // isCxxModule
true // isTurboModule
)
mapOf(SettingsModule.NAME to moduleInfo)
}
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> = emptyList()
}

View File

@ -205,16 +205,23 @@ class WidgetUpdateWorker(context: Context, workerParams: WorkerParameters) : Cor
preferredCurrency: String?,
preferredCurrencyLocale: String?
) {
val parsedPrevious = previousPrice?.toDoubleOrNull()
val currencyFormat = getCurrencyFormat(preferredCurrency, preferredCurrencyLocale)
views.apply {
setViewVisibility(R.id.loading_indicator, View.GONE)
setTextViewText(R.id.price_value, currencyFormat.format(previousPrice?.toDouble()?.toInt()))
setTextViewText(R.id.last_updated_time, currentTime)
setViewVisibility(R.id.price_value, View.VISIBLE)
setViewVisibility(R.id.last_updated_label, View.VISIBLE)
setViewVisibility(R.id.last_updated_time, View.VISIBLE)
setViewVisibility(R.id.price_arrow_container, View.GONE)
if (parsedPrevious != null) {
setTextViewText(R.id.price_value, currencyFormat.format(parsedPrevious.toInt()))
setViewVisibility(R.id.price_value, View.VISIBLE)
setViewVisibility(R.id.last_updated_label, View.VISIBLE)
setViewVisibility(R.id.last_updated_time, View.VISIBLE)
} else {
setViewVisibility(R.id.price_value, View.GONE)
setViewVisibility(R.id.last_updated_label, View.GONE)
setViewVisibility(R.id.last_updated_time, View.GONE)
}
setTextViewText(R.id.last_updated_time, currentTime)
}
}
@ -226,37 +233,45 @@ class WidgetUpdateWorker(context: Context, workerParams: WorkerParameters) : Cor
preferredCurrency: String?,
preferredCurrencyLocale: String?
) {
val currentPrice = fetchedPrice.toDouble().toInt()
val currentPrice = fetchedPrice.toDoubleOrNull()?.toInt()
val currencyFormat = getCurrencyFormat(preferredCurrency, preferredCurrencyLocale)
views.apply {
setViewVisibility(R.id.loading_indicator, View.GONE)
setTextViewText(R.id.price_value, currencyFormat.format(currentPrice))
setTextViewText(R.id.last_updated_time, currentTime)
setViewVisibility(R.id.price_value, View.VISIBLE)
setViewVisibility(R.id.last_updated_label, View.VISIBLE)
setViewVisibility(R.id.last_updated_time, View.VISIBLE)
if (currentPrice != null) {
setTextViewText(R.id.price_value, currencyFormat.format(currentPrice))
setTextViewText(R.id.last_updated_time, currentTime)
setViewVisibility(R.id.price_value, View.VISIBLE)
setViewVisibility(R.id.last_updated_label, View.VISIBLE)
setViewVisibility(R.id.last_updated_time, View.VISIBLE)
if (previousPrice != null) {
setViewVisibility(R.id.price_arrow_container, View.VISIBLE)
setTextViewText(R.id.previous_price, currencyFormat.format(previousPrice.toDouble().toInt()))
setImageViewResource(
R.id.price_arrow,
if (currentPrice > previousPrice.toDouble().toInt()) android.R.drawable.arrow_up_float else android.R.drawable.arrow_down_float
)
val previousParsed = previousPrice?.toDoubleOrNull()?.toInt()
if (previousParsed != null) {
setViewVisibility(R.id.price_arrow_container, View.VISIBLE)
setTextViewText(R.id.previous_price, currencyFormat.format(previousParsed))
setImageViewResource(
R.id.price_arrow,
if (currentPrice > previousParsed) android.R.drawable.arrow_up_float else android.R.drawable.arrow_down_float
)
} else {
setViewVisibility(R.id.price_arrow_container, View.GONE)
}
} else {
// Fallback to loading state if parsing failed
setViewVisibility(R.id.price_value, View.GONE)
setViewVisibility(R.id.last_updated_label, View.GONE)
setViewVisibility(R.id.last_updated_time, View.GONE)
setViewVisibility(R.id.price_arrow_container, View.GONE)
setViewVisibility(R.id.loading_indicator, View.VISIBLE)
}
}
}
private fun getCurrencyFormat(currencyCode: String?, localeString: String?): NumberFormat {
val localeParts = localeString?.split("-") ?: listOf("en", "US")
val locale = if (localeParts.size == 2) {
Locale(localeParts[0], localeParts[1])
} else {
Locale.getDefault()
}
val locale = localeString
?.let { runCatching { Locale.forLanguageTag(it) }.getOrNull() }
?.takeIf { it.language.isNotBlank() }
?: Locale.getDefault()
val currencyFormat = NumberFormat.getCurrencyInstance(locale)
val currency = try {
Currency.getInstance(currencyCode ?: "USD")

View File

@ -1,47 +0,0 @@
package io.bluewallet.bluewallet.components.segmentedcontrol
import com.facebook.react.bridge.ReadableArray
import com.facebook.react.common.MapBuilder
import com.facebook.react.uimanager.SimpleViewManager
import com.facebook.react.uimanager.ThemedReactContext
import com.facebook.react.uimanager.annotations.ReactProp
class CustomSegmentedControlManager : SimpleViewManager<CustomSegmentedControl>() {
companion object {
const val REACT_CLASS = "CustomSegmentedControl"
private const val ON_CHANGE_EVENT = "onChangeEvent"
}
override fun getName(): String = REACT_CLASS
override fun createViewInstance(reactContext: ThemedReactContext): CustomSegmentedControl {
return CustomSegmentedControl(reactContext)
}
@ReactProp(name = "values")
fun setValues(view: CustomSegmentedControl, values: ReadableArray?) {
val valuesArray = values?.let { array ->
Array(array.size()) { index ->
array.getString(index) ?: ""
}
} ?: emptyArray()
view.values = valuesArray
}
@ReactProp(name = "selectedIndex", defaultInt = 0)
fun setSelectedIndex(view: CustomSegmentedControl, selectedIndex: Int) {
view.selectedIndex = selectedIndex
}
override fun getExportedCustomDirectEventTypeConstants(): Map<String, Any>? {
return MapBuilder.builder<String, Any>()
.put(ON_CHANGE_EVENT, MapBuilder.of("registrationName", ON_CHANGE_EVENT))
.build()
}
override fun onAfterUpdateTransaction(view: CustomSegmentedControl) {
super.onAfterUpdateTransaction(view)
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -3,16 +3,14 @@
buildscript {
ext {
minSdkVersion = 24
buildToolsVersion = "35.0.0"
compileSdkVersion = 35
targetSdkVersion = 35
buildToolsVersion = "36.0.0"
compileSdkVersion = 36
targetSdkVersion = 36
googlePlayServicesVersion = "16.+"
googlePlayServicesIidVersion = "16.0.1"
firebaseVersion = "17.3.4"
firebaseMessagingVersion = "21.1.0"
ndkVersion = "27.1.12297006"
kotlin_version = '2.0.21'
kotlinVersion = '2.0.21'
firebaseVersion = "21.1.0"
ndkVersion = "28.2.13676358"
kotlinVersion = '2.1.20'
}
repositories {
google()
@ -21,17 +19,51 @@ buildscript {
dependencies {
classpath("com.android.tools.build:gradle")
classpath("com.facebook.react:react-native-gradle-plugin")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin")
classpath 'com.google.gms:google-services:4.4.4' // Google Services plugin
classpath("com.bugsnag:bugsnag-android-gradle-plugin:8.2.0")
}
}
// Gradle 9 removes jcenter(); add a shim that redirects any jcenter() call to mavenCentral()
def addJcenterShim = { repoContainer, logger ->
def mc = repoContainer.metaClass
if (!mc.respondsTo(repoContainer, 'jcenter')) {
mc.jcenter << { Closure config = null ->
def repo = repoContainer.mavenCentral()
if (config != null) {
config.delegate = repo
config.resolveStrategy = Closure.DELEGATE_FIRST
config.call(repo)
}
logger.lifecycle("Redirected jcenter() to mavenCentral()")
return repo
}
}
if (!mc.respondsTo(repoContainer, 'methodMissing')) {
mc.methodMissing = { String name, args ->
if (name == 'jcenter') {
def repo = repoContainer.mavenCentral()
logger.lifecycle("Redirected jcenter() (methodMissing) to mavenCentral()")
return repo
}
throw new MissingMethodException(name, repoContainer.class, args)
}
}
}
allprojects {
repositories {
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
@ -40,26 +72,66 @@ allprojects {
excludeGroup "com.facebook.react"
}
}
mavenLocal()
maven {
// All of React Native (JS, Obj-C sources, Android binaries) is installed from npm
url("$rootDir/../node_modules/react-native/android")
}
maven {
// Android JSC is installed from npm
url("$rootDir/../node_modules/jsc-android/dist")
}
google()
maven { url 'https://www.jitpack.io' }
}
}
subprojects {
afterEvaluate {project ->
if (project.hasProperty("android")) {
// Apply jcenter shim very early for every project before its build.gradle is evaluated
gradle.beforeProject { project ->
addJcenterShim(project.repositories, project.logger)
if (project.buildscript != null) {
addJcenterShim(project.buildscript.repositories, project.logger)
}
}
// Apply to root as well
addJcenterShim(repositories, logger)
if (buildscript != null) {
addJcenterShim(buildscript.repositories, logger)
}
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 ->
if (repo instanceof org.gradle.api.artifacts.repositories.MavenArtifactRepository &&
repo.url?.toString()?.contains('jcenter')) {
project.logger.lifecycle("Removing jcenter() from ${project.path}")
repoContainer.remove(repo)
}
}
repoContainer.whenObjectAdded { repo ->
if (repo instanceof org.gradle.api.artifacts.repositories.MavenArtifactRepository &&
repo.url?.toString()?.contains('jcenter')) {
project.logger.lifecycle("Blocking jcenter() from ${project.path}")
repoContainer.remove(repo)
repoContainer.mavenCentral()
}
}
}
scrub(project.repositories)
if (project.buildscript != null) {
scrub(project.buildscript.repositories)
}
afterEvaluate {proj ->
if (proj.hasProperty("android")) {
android {
buildToolsVersion "35.0.0"
compileSdkVersion 35
buildToolsVersion "36.0.0"
compileSdkVersion 36
defaultConfig {
minSdkVersion 24
}
@ -68,12 +140,32 @@ subprojects {
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile) {
// FIXME: next line should be removed when https://github.com/wix/Detox/issues/4678 is fixed
kotlinOptions.freeCompilerArgs += ["-Xopt-in=kotlin.ExperimentalStdlibApi"]
if (project.plugins.hasPlugin("com.android.application") || project.plugins.hasPlugin("com.android.library")) {
if (proj.plugins.hasPlugin("com.android.application") || proj.plugins.hasPlugin("com.android.library")) {
kotlinOptions.jvmTarget = android.compileOptions.sourceCompatibility
} else {
kotlinOptions.jvmTarget = sourceCompatibility
}
}
if (proj.name == "react-native-reanimated" && proj.hasProperty("android")) {
// Wire Reanimated's generated codegen sources so WorkletsModule spec is visible under new architecture
proj.android.sourceSets.main.java.srcDir("${proj.buildDir}/generated/source/codegen/java")
}
}
}
}
// Final guard: fail fast if any jcenter repository slips through
gradle.projectsEvaluated {
allprojects { proj ->
def offenders = proj.repositories.findAll { repo ->
repo instanceof org.gradle.api.artifacts.repositories.MavenArtifactRepository &&
repo.url?.toString()?.contains('jcenter')
}
if (!offenders.isEmpty()) {
throw new GradleException("jcenter() detected in ${proj.path}; remove or replace with mavenCentral()");
}
}
}
apply plugin: "com.facebook.react.rootproject"

View File

@ -21,7 +21,6 @@ org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
android.enableJetifier=true
# Use this property to specify which architecture you want to build.
# You can also override it from the CLI using
# ./gradlew <task> -PreactNativeArchitectures=x86_64
@ -32,8 +31,16 @@ reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64
# your application. You should enable this flag either if you want
# to write custom TurboModules/Fabric components OR use libraries that
# are providing them.
newArchEnabled=false
newArchEnabled=true
# Use this property to enable or disable the Hermes JS engine.
# If set to false, you will be using JSC instead.
hermesEnabled=true
hermesEnabled=true
# Use this property to enable edge-to-edge display support.
# This allows your app to draw behind system bars for an immersive UI.
# Note: Only works with ReactActivity and should not be used with custom Activity.
edgeToEdgeEnabled=true
# Use legacy NDK symbol upload for Bugsnag (avoids v5.26.0 requirement)
bugsnag.useLegacyNdkSymbolUpload=true

Binary file not shown.

View File

@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME

6
android/gradlew vendored
View File

@ -114,8 +114,6 @@ case "$( uname )" in #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
@ -172,7 +170,6 @@ fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
@ -212,8 +209,7 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.

4
android/gradlew.bat vendored
View File

@ -70,11 +70,9 @@ goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell

View File

@ -1,4 +1,7 @@
module.exports = {
presets: ['module:@react-native/babel-preset'],
plugins: ['react-native-reanimated/plugin'], // required by react-native-reanimated v2 https://docs.swmansion.com/react-native-reanimated/docs/installation/
// 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' }]],
plugins: ['react-native-worklets/plugin'],
};

View File

@ -5,7 +5,10 @@ import RNFS from 'react-native-fs';
import Realm from 'realm';
import { sha256 as _sha256 } from '@noble/hashes/sha256';
import { LegacyWallet, SegwitBech32Wallet, SegwitP2SHWallet, TaprootWallet } from '../class';
import type { LegacyWallet as LegacyWalletT } from '../class/wallets/legacy-wallet';
import type { SegwitBech32Wallet as SegwitBech32WalletT } from '../class/wallets/segwit-bech32-wallet';
import type { SegwitP2SHWallet as SegwitP2SHWalletT } from '../class/wallets/segwit-p2sh-wallet';
import type { TaprootWallet as TaprootWalletT } from '../class/wallets/taproot-wallet';
import presentAlert from '../components/Alert';
import loc from '../loc';
import { GROUP_IO_BLUEWALLET } from './currency';
@ -27,7 +30,7 @@ type Utxo = {
wif?: string;
};
type ElectrumTransaction = {
export type ElectrumTransaction = {
txid: string;
hash: string;
version: number;
@ -55,13 +58,14 @@ type ElectrumTransaction = {
addresses: string[];
};
}[];
blockhash: string;
confirmations: number;
time: number;
blocktime: number;
// Confirmation-only fields: absent on mempool (unconfirmed) responses.
blockhash?: string;
confirmations?: number;
time?: number;
blocktime?: number;
};
type ElectrumTransactionWithHex = ElectrumTransaction & {
export type ElectrumTransactionWithHex = ElectrumTransaction & {
hex: string;
};
@ -90,7 +94,6 @@ export const hardcodedPeers: Peer[] = [
// { host: 'electrum.jochen-hoenicke.de', ssl: '50006' },
{ host: 'electrum1.bluewallet.io', ssl: 443 },
{ host: 'electrum.acinq.co', ssl: 50002 },
{ host: 'electrum.bitaroo.net', ssl: 50002 },
];
export const suggestedServers: Peer[] = hardcodedPeers.map(peer => ({
@ -98,13 +101,84 @@ export const suggestedServers: Peer[] = hardcodedPeers.map(peer => ({
}));
let mainClient: typeof ElectrumClient | undefined;
let mainConnected: boolean = false;
let wasConnectedAtLeastOnce: boolean = false;
let serverName: string | false = false;
let disableBatching: boolean = false;
let connectionAttempt: number = 0;
let currentPeerIndex = Math.floor(Math.random() * hardcodedPeers.length);
let currentPeerIndex = hardcodedPeers.findIndex(peer => peer.host === defaultPeer.host && peer.ssl === defaultPeer.ssl);
if (currentPeerIndex < 0) currentPeerIndex = 0;
let latestBlock: { height: number; time: number } | { height: undefined; time: undefined } = { height: undefined, time: undefined };
// --- Single source of truth for connection liveness -----------------------------
// We previously tracked `mainConnected` (boolean) separately from the client's own
// `mainClient.status`. They drifted on iOS suspend/resume: a transient `ping()`
// failure cleared the flag while the socket was still alive, then `waitTillConnected`
// blocked for ~30s on the stale flag and surfaced a false network-error alert. The
// state machine + `ensureConnected()` below is the only place that mutates the
// connection lifecycle, and UI is driven by subscribing to state changes.
export type ConnectionState = 'disabled' | 'disconnected' | 'connecting' | 'connected';
let connState: ConnectionState = 'disconnected';
type ConnectionListener = (state: ConnectionState) => void;
const connectionListeners = new Set<ConnectionListener>();
function setConnectionState(next: ConnectionState): void {
if (connState === next) return;
connState = next;
for (const l of connectionListeners) {
try {
l(next);
} catch (e) {
console.warn('[electrum] connection listener threw:', e);
}
}
}
/** Current connection state for UI. */
export function getConnectionState(): ConnectionState {
return connState;
}
/** Subscribe to state changes. Returns an unsubscribe function. */
export function subscribeConnectionState(listener: ConnectionListener): () => void {
connectionListeners.add(listener);
return () => {
connectionListeners.delete(listener);
};
}
/** Convenience: `true` iff a usable Electrum connection is currently believed to exist. */
export function isConnected(): boolean {
return connState === 'connected';
}
// --- Connection lifecycle internals ---------------------------------------------
/** One liveness check (`server_ping`) wall-time before giving up and marking the socket dead. */
const PING_TIMEOUT_MS = 5_000;
/** One full connect attempt (TLS + `server_version` handshake) wall-time before retrying. */
const CONNECT_ATTEMPT_TIMEOUT_MS = 10_000;
/** Reconnect attempts inside a single `ensureConnected()` call before declaring failure. */
const CONNECT_MAX_ATTEMPTS = 5;
/** Backoff between attempts to avoid hammering a flaky server. */
const CONNECT_BACKOFF_MS = 500;
/** Delay before the auto-reconnect triggered by a live-socket `onError`. Onions are slower. */
const RECONNECT_ONION_DELAY_MS = 4_000;
const RECONNECT_TCP_DELAY_MS = 500;
/** Max wall time one `ensureConnected()` call may take when no live socket exists. */
export const ENSURE_CONNECTED_MAX_WALL_MS =
CONNECT_MAX_ATTEMPTS * CONNECT_ATTEMPT_TIMEOUT_MS + (CONNECT_MAX_ATTEMPTS - 1) * CONNECT_BACKOFF_MS;
/** Coalesces concurrent `ensureConnected()` callers — at most one connect attempt at a time. */
let ensureInFlight: Promise<boolean> | null = null;
/** If any coalesced caller asked for the failure alert, honour it once the in-flight attempt finishes. */
let ensureInFlightShowAlert = false;
/**
* Bumps every time the caller asks us to abandon the current connection
* (`forceDisconnect()` or user disabling Electrum). In-flight `ensureConnected()`
* checks this between attempts so it can bail out promptly instead of racing back
* to `connected` after a disconnect was requested.
*/
let disconnectGeneration = 0;
const txhashHeightCache: Record<string, number> = {};
let _realm: Realm | undefined;
@ -150,10 +224,10 @@ export const getPreferredServer = async (): Promise<ElectrumServerItem | undefin
const tcpPort = await DefaultPreference.get(ELECTRUM_TCP_PORT);
const sslPort = await DefaultPreference.get(ELECTRUM_SSL_PORT);
console.log('Getting preferred server:', { host, tcpPort, sslPort });
console.log('[electrum] Getting preferred server:', { host, tcpPort, sslPort });
if (!host) {
console.warn('Preferred server host is undefined');
console.warn('[electrum] Preferred server host is undefined');
return;
}
@ -163,7 +237,7 @@ export const getPreferredServer = async (): Promise<ElectrumServerItem | undefin
ssl: sslPort ? Number(sslPort) : undefined,
};
} catch (error) {
console.error('Error in getPreferredServer:', error);
console.error('[electrum] Error in getPreferredServer:', error);
return undefined;
}
};
@ -171,12 +245,12 @@ export const getPreferredServer = async (): Promise<ElectrumServerItem | undefin
export const removePreferredServer = async () => {
try {
await DefaultPreference.setName(GROUP_IO_BLUEWALLET);
console.log('Removing preferred server');
console.log('[electrum] Removing preferred server');
await DefaultPreference.clear(ELECTRUM_HOST);
await DefaultPreference.clear(ELECTRUM_TCP_PORT);
await DefaultPreference.clear(ELECTRUM_SSL_PORT);
} catch (error) {
console.error('Error in removePreferredServer:', error);
console.error('[electrum] Error in removePreferredServer:', error);
}
};
@ -185,14 +259,14 @@ export async function isDisabled(): Promise<boolean> {
try {
await DefaultPreference.setName(GROUP_IO_BLUEWALLET);
const savedValue = await DefaultPreference.get(ELECTRUM_CONNECTION_DISABLED);
console.log('Getting Electrum connection disabled state:', savedValue);
console.log('[electrum] Getting Electrum connection disabled state:', savedValue);
if (savedValue === null) {
result = false;
} else {
result = savedValue;
}
} catch (error) {
console.error('Error getting Electrum connection disabled state:', error);
console.error('[electrum] Error getting Electrum connection disabled state:', error);
result = false;
}
return !!result;
@ -200,8 +274,23 @@ export async function isDisabled(): Promise<boolean> {
export async function setDisabled(disabled = true) {
await DefaultPreference.setName(GROUP_IO_BLUEWALLET);
console.log('Setting Electrum connection disabled state to:', disabled);
return DefaultPreference.set(ELECTRUM_CONNECTION_DISABLED, disabled ? '1' : '');
console.log('[electrum] Setting Electrum connection disabled state to:', disabled);
const result = await DefaultPreference.set(ELECTRUM_CONNECTION_DISABLED, disabled ? '1' : '');
// Disabling must abort any in-flight ensureConnected() and tear down the live
// socket so callers don't have to remember to pair this with forceDisconnect().
// Without bumping the generation, an in-flight connect could race back to
// 'connected' after the user toggled Electrum off.
if (disabled) {
disconnectGeneration += 1;
if (mainClient) {
try {
mainClient.close();
} catch {}
mainClient = undefined;
}
setConnectionState('disabled');
}
return result;
}
function getCurrentPeer() {
@ -214,7 +303,7 @@ function getCurrentPeer() {
function getNextPeer() {
const peer = getCurrentPeer();
currentPeerIndex++;
if (currentPeerIndex + 1 >= hardcodedPeers.length) currentPeerIndex = 0;
if (currentPeerIndex >= hardcodedPeers.length) currentPeerIndex = 0;
return peer;
}
@ -225,7 +314,7 @@ async function getSavedPeer(): Promise<Peer | null> {
const tcpPort = await DefaultPreference.get(ELECTRUM_TCP_PORT);
const sslPort = await DefaultPreference.get(ELECTRUM_SSL_PORT);
console.log('Getting saved peer:', { host, tcpPort, sslPort });
console.log('[electrum] Getting saved peer:', { host, tcpPort, sslPort });
if (!host) {
return null;
@ -241,53 +330,98 @@ async function getSavedPeer(): Promise<Peer | null> {
return null;
} catch (error) {
console.error('Error in getSavedPeer:', error);
console.error('[electrum] Error in getSavedPeer:', error);
return null;
}
}
export async function connectMain(): Promise<void> {
if (await isDisabled()) {
console.log('Electrum connection disabled by user. Skipping connectMain call');
return;
}
/** Resolve to the peer this attempt should target (preferred saved peer, or rotate hardcoded list). */
async function pickPeer(): Promise<Peer> {
let usingPeer = getNextPeer();
const savedPeer = await getSavedPeer();
if (savedPeer && savedPeer.host && (savedPeer.tcp || savedPeer.ssl)) {
usingPeer = savedPeer;
}
return usingPeer;
}
console.log('Using peer:', JSON.stringify(usingPeer));
function scheduleReconnectFromClient(client: typeof ElectrumClient, usingPeer: Peer, reason: string): void {
if (connState !== 'connected' || mainClient !== client) return;
console.log(`[electrum] scheduling Electrum reconnect after ${reason}`);
try {
// Also neutralises electrum-client's own timers/reconnect hooks for this instance.
client.close();
} catch {}
if (mainClient === client) mainClient = undefined;
setConnectionState('disconnected');
const delay = usingPeer.host.endsWith('.onion') ? RECONNECT_ONION_DELAY_MS : RECONNECT_TCP_DELAY_MS;
const generationAtSchedule = disconnectGeneration;
setTimeout(() => {
if (generationAtSchedule !== disconnectGeneration) return;
// eslint-disable-next-line @typescript-eslint/no-use-before-define -- defined later in file
ensureConnected().catch(() => {
/* ensureConnected never throws, but be defensive */
});
}, delay);
}
/**
* One connect attempt: build a fresh `ElectrumClient`, run the version handshake,
* subscribe to headers. No retries, no UI side effects. Returns the peer used
* (for caller-side telemetry/alerts) and whether the attempt succeeded.
*/
async function attemptConnectOnce(): Promise<{ ok: boolean; peer: Peer }> {
const usingPeer = await pickPeer();
console.log('[electrum] Using peer:', JSON.stringify(usingPeer));
// Drop any prior client before allocating a new one. Closing also neutralises
// electrum-client's internal `reconnect()` loop on the old instance.
if (mainClient) {
try {
mainClient.close();
} catch {}
mainClient = undefined;
}
try {
console.log('begin connection:', JSON.stringify(usingPeer));
mainClient = new ElectrumClient(net, tls, usingPeer.ssl || usingPeer.tcp, usingPeer.host, usingPeer.ssl ? 'tls' : 'tcp');
console.log('[electrum] begin connection:', JSON.stringify(usingPeer));
const client = new ElectrumClient(net, tls, usingPeer.ssl || usingPeer.tcp, usingPeer.host, usingPeer.ssl ? 'tls' : 'tcp');
mainClient = client;
mainClient.onError = function (e: { message: string }) {
console.log('electrum mainClient.onError():', e.message);
if (mainConnected) {
// most likely got a timeout from electrum ping. lets reconnect
// but only if we were previously connected (mainConnected), otherwise theres other
// code which does connection retries
mainClient?.close();
mainClient = undefined;
mainConnected = false;
// dropping `mainConnected` flag ensures there wont be reconnection race condition if several
// errors triggered
console.log('reconnecting after socket error');
setTimeout(connectMain, usingPeer.host.endsWith('.onion') ? 4000 : 500);
}
// Live-socket errors after a successful handshake: schedule a single
// `ensureConnected()` (deduped). Errors during this attempt's own handshake
// are caught below — we must not double-handle them here.
client.onError = function (e: { message: string }) {
console.log('[electrum] electrum mainClient.onError():', e.message);
scheduleReconnectFromClient(client, usingPeer, 'socket error');
};
const ver = await mainClient.initElectrum({ client: 'bluewallet', version: '1.4' });
const ver = await Promise.race([
client.initElectrum(
{ client: 'bluewallet', version: '1.4' },
{
maxRetry: 0,
callback: () => scheduleReconnectFromClient(client, usingPeer, 'socket close'),
},
),
new Promise<never>((_resolve, reject) => setTimeout(() => reject(new Error('connect timeout')), CONNECT_ATTEMPT_TIMEOUT_MS)),
]);
if (mainClient !== client) {
// Caller raced `forceDisconnect()` while we were awaiting. Bail.
try {
client.close();
} catch {}
return { ok: false, peer: usingPeer };
}
if (ver && ver[0]) {
console.log('connected to ', ver);
console.log('[electrum] connected to ', ver);
serverName = ver[0];
mainConnected = true;
wasConnectedAtLeastOnce = true;
if (ver[0].startsWith('ElectrumPersonalServer') || ver[0].startsWith('electrs') || ver[0].startsWith('Fulcrum')) {
disableBatching = true;
// exeptions for versions:
const [electrumImplementation, electrumVersion] = ver[0].split(' ');
switch (electrumImplementation) {
case 'electrs':
@ -296,8 +430,6 @@ export async function connectMain(): Promise<void> {
}
break;
case 'electrs-esplora':
// its a different one, and it does NOT support batching
// nop
break;
case 'Fulcrum':
if (semVerToInt(electrumVersion) >= semVerToInt('1.9.0')) {
@ -306,35 +438,154 @@ export async function connectMain(): Promise<void> {
break;
}
}
const header = await mainClient.blockchainHeaders_subscribe();
const header = await client.blockchainHeaders_subscribe();
if (header && header.height) {
latestBlock = {
height: header.height,
time: Math.floor(+new Date() / 1000),
};
}
// AsyncStorage.setItem(storageKey, JSON.stringify(peers)); TODO: refactor
return { ok: true, peer: usingPeer };
}
return { ok: false, peer: usingPeer };
} catch (e) {
mainConnected = false;
console.log('bad connection:', JSON.stringify(usingPeer), e);
mainClient?.close();
mainClient = undefined;
console.log('[electrum] bad connection:', JSON.stringify(usingPeer), e);
if (mainClient) {
try {
mainClient.close();
} catch {}
mainClient = undefined;
}
return { ok: false, peer: usingPeer };
}
}
/** Single liveness check on the current `mainClient`, bounded by `PING_TIMEOUT_MS`. */
async function pingWithTimeout(timeoutMs: number = PING_TIMEOUT_MS): Promise<boolean> {
if (!mainClient) return false;
const client = mainClient;
try {
await Promise.race([
client.server_ping(),
new Promise<never>((_resolve, reject) => setTimeout(() => reject(new Error('ping timeout')), timeoutMs)),
]);
return mainClient === client; // server replied AND client wasn't swapped while we waited
} catch {
return false;
}
}
export type EnsureConnectedOptions = {
/**
* Show the legacy "couldn't connect" alert (Try again / Reset / Cancel) on failure.
* Used by initial bootstrap (`SettingsProvider` re-enabling Electrum) and the manual
* help alert. Off-hot-path callers (refresh, broadcast, etc.) should leave this false
* and surface their own UI.
*/
showAlertOnFailure?: boolean;
};
/**
* Make sure a usable Electrum connection exists, healing if needed.
*
* - If we already think we're connected, run one fast `ping` to verify. If the ping
* succeeds, we're done. If it fails the client is torn down and we fall through
* to a reconnect.
* - Otherwise run up to `CONNECT_MAX_ATTEMPTS` connect attempts (each with its own
* timeout + backoff).
*
* Concurrent callers share the same in-flight promise there is at most one connect
* attempt at a time per process. This replaces the old `mainConnected`-flag-polling
* `waitTillConnected()`, which could block ~30s on a stale flag while the socket was
* still alive.
*/
export async function ensureConnected(opts: EnsureConnectedOptions = {}): Promise<boolean> {
const { showAlertOnFailure = false } = opts;
if (await isDisabled()) {
setConnectionState('disabled');
return false;
}
if (!mainConnected) {
console.log('retry');
connectionAttempt = connectionAttempt + 1;
mainClient?.close();
mainClient = undefined;
if (connectionAttempt >= 5) {
presentNetworkErrorAlert(usingPeer);
} else {
console.log('reconnection attempt #', connectionAttempt);
await new Promise(resolve => setTimeout(resolve, 500)); // sleep
return connectMain();
}
if (ensureInFlight) {
if (showAlertOnFailure) ensureInFlightShowAlert = true;
return ensureInFlight;
}
ensureInFlightShowAlert = showAlertOnFailure;
ensureInFlight = (async (): Promise<boolean> => {
const myGeneration = disconnectGeneration;
/** True iff the current generation no longer matches ours (i.e. `forceDisconnect()` ran). */
const aborted = (where: string): boolean => {
if (myGeneration === disconnectGeneration) return false;
console.log(`[electrum] ensureConnected aborted by forceDisconnect at ${where} (gen ${myGeneration}${disconnectGeneration})`);
return true;
};
let lastPeer: Peer | undefined;
try {
// Fast path: live ping on the existing client.
if (mainClient && connState === 'connected') {
if (await pingWithTimeout()) {
// If a disconnect/disable raced us, the bumper already set the right
// state ('disconnected' or 'disabled'); don't clobber it from here.
if (aborted('post-ping')) return false;
return true;
}
// Stale socket. Tear it down so the attempt loop starts fresh.
try {
mainClient.close();
} catch {}
mainClient = undefined;
setConnectionState('disconnected');
}
if (aborted('pre-loop')) return false;
setConnectionState('connecting');
for (let i = 0; i < CONNECT_MAX_ATTEMPTS; i++) {
if (await isDisabled()) {
setConnectionState('disabled');
return false;
}
// Generation-bumper (`forceDisconnect` or `setDisabled(true)`) already
// set the appropriate terminal state; we must not clobber 'disabled'
// back to 'disconnected' here.
if (aborted(`attempt ${i} start`)) return false;
const { ok, peer } = await attemptConnectOnce();
lastPeer = peer;
if (aborted(`attempt ${i} end`)) {
if (mainClient) {
try {
mainClient.close();
} catch {}
mainClient = undefined;
}
return false;
}
if (ok) {
setConnectionState('connected');
return true;
}
if (i < CONNECT_MAX_ATTEMPTS - 1) {
await new Promise(resolve => setTimeout(resolve, CONNECT_BACKOFF_MS));
}
}
setConnectionState('disconnected');
if (ensureInFlightShowAlert) {
// eslint-disable-next-line @typescript-eslint/no-use-before-define -- defined later in file
presentNetworkErrorAlert(lastPeer);
}
return false;
} finally {
ensureInFlight = null;
ensureInFlightShowAlert = false;
}
})();
return ensureInFlight;
}
export async function presentResetToDefaultsAlert(): Promise<boolean> {
@ -356,7 +607,7 @@ export async function presentResetToDefaultsAlert(): Promise<boolean> {
await DefaultPreference.clear(ELECTRUM_SSL_PORT);
await DefaultPreference.clear(ELECTRUM_TCP_PORT);
} catch (e) {
console.log(e); // Must be running on Android
console.log('[electrum]', e); // Must be running on Android
}
resolve(true);
},
@ -375,7 +626,7 @@ export async function presentResetToDefaultsAlert(): Promise<boolean> {
await DefaultPreference.clear(ELECTRUM_SSL_PORT);
await DefaultPreference.clear(ELECTRUM_TCP_PORT);
} catch (e) {
console.log(e); // Must be running on Android
console.log('[electrum]', e); // Must be running on Android
}
resolve(true);
},
@ -398,16 +649,16 @@ export async function presentResetToDefaultsAlert(): Promise<boolean> {
});
}
const presentNetworkErrorAlert = async (usingPeer?: Peer) => {
async function presentNetworkErrorAlert(usingPeer?: Peer, allowRepeat = false) {
if (await isDisabled()) {
console.log(
'Electrum connection disabled by user. Perhaps we are attempting to show this network error alert after the user disabled connections.',
'[electrum] Electrum connection disabled by user. Perhaps we are attempting to show this network error alert after the user disabled connections.',
);
return;
}
presentAlert({
allowRepeat: false,
allowRepeat,
title: loc.errors.network,
message: loc.formatString(
usingPeer ? loc.settings.electrum_unable_to_connect : loc.settings.electrum_error_connect,
@ -417,10 +668,10 @@ const presentNetworkErrorAlert = async (usingPeer?: Peer) => {
{
text: loc.wallets.list_tryagain,
onPress: () => {
connectionAttempt = 0;
mainClient?.close();
mainClient = undefined;
setTimeout(connectMain, 500);
forceDisconnect();
setTimeout(() => {
ensureConnected({ showAlertOnFailure: true }).catch(() => {});
}, 500);
},
style: 'default',
},
@ -429,10 +680,10 @@ const presentNetworkErrorAlert = async (usingPeer?: Peer) => {
onPress: () => {
presentResetToDefaultsAlert().then(result => {
if (result) {
connectionAttempt = 0;
mainClient?.close();
mainClient = undefined;
setTimeout(connectMain, 500);
forceDisconnect();
setTimeout(() => {
ensureConnected({ showAlertOnFailure: true }).catch(() => {});
}, 500);
}
});
},
@ -441,16 +692,21 @@ const presentNetworkErrorAlert = async (usingPeer?: Peer) => {
{
text: loc._.cancel,
onPress: () => {
connectionAttempt = 0;
mainClient?.close();
mainClient = undefined;
forceDisconnect();
},
style: 'cancel',
},
],
options: { cancelable: false },
});
};
}
/**
* Wallets list header when Electrum looks disconnected: same actions as the internal timeout alert, with allowRepeat so the user can open it again after dismiss.
*/
export async function presentElectrumDisconnectedHelpAlert(): Promise<void> {
await presentNetworkErrorAlert(undefined, true);
}
/**
* Returns random electrum server out of list of servers
@ -497,18 +753,27 @@ export const getBalanceByAddress = async function (address: string): Promise<{ c
balance.addr = address;
return balance;
} catch (error) {
console.error('Error in getBalanceByAddress:', error);
console.error('[electrum] Error in getBalanceByAddress:', error);
throw error;
}
};
export const getConfig = async function () {
if (!mainClient) throw new Error('Electrum client is not connected');
if (!mainClient) {
return {
host: undefined,
port: undefined,
serverName: false as typeof serverName,
connected: connState === 'connected' ? 1 : 0,
};
}
return {
host: mainClient.host,
port: mainClient.port,
serverName,
connected: mainClient.timeLastCall !== 0 && mainClient.status,
// Drive UI "connected" indicator from the single state machine so the settings
// screen agrees with the wallets-list header pill and with `ensureConnected()`.
connected: connState === 'connected' ? 1 : 0,
};
};
@ -537,14 +802,24 @@ export const getMempoolTransactionsByAddress = async function (address: string):
return mainClient.blockchainScripthash_getMempool(uint8ArrayToHex(reversedHash));
};
export const ping = async function () {
try {
await mainClient.server_ping();
return true;
} catch (_) {}
mainConnected = false;
return false;
/**
* Read-only liveness probe. Does NOT trigger reconnects (use `ensureConnected()`
* for that). Updates the connection state machine to reflect the probe result so
* subscribers (UI pill, settings screen) stay in sync.
*
* - `true`: server replied within `PING_TIMEOUT_MS`.
* - `false`: client missing, timed out, or server errored.
*/
export const ping = async function (): Promise<boolean> {
if (await isDisabled()) return false;
const ok = await pingWithTimeout();
if (ok) {
// Heal stale `disconnected` state from a transient ping failure earlier.
if (connState !== 'connected') setConnectionState('connected');
} else if (connState === 'connected') {
setConnectionState('disconnected');
}
return ok;
};
// exported only to be used in unit tests
@ -599,6 +874,21 @@ export function txhexToElectrumTransaction(txhex: string): ElectrumTransactionWi
let address: false | string = false;
let type: false | string = false;
// Lazy require to avoid the module-scope cycle described above. These
// modules are fully loaded by the time this function is actually invoked.
const { SegwitBech32Wallet } = require('../class/wallets/segwit-bech32-wallet') as {
SegwitBech32Wallet: typeof SegwitBech32WalletT;
};
const { SegwitP2SHWallet } = require('../class/wallets/segwit-p2sh-wallet') as {
SegwitP2SHWallet: typeof SegwitP2SHWalletT;
};
const { LegacyWallet } = require('../class/wallets/legacy-wallet') as {
LegacyWallet: typeof LegacyWalletT;
};
const { TaprootWallet } = require('../class/wallets/taproot-wallet') as {
TaprootWallet: typeof TaprootWalletT;
};
if (SegwitBech32Wallet.scriptPubKeyToAddress(uint8ArrayToHex(out.script))) {
address = SegwitBech32Wallet.scriptPubKeyToAddress(uint8ArrayToHex(out.script));
type = 'witness_v0_keyhash';
@ -742,7 +1032,7 @@ export const multiGetBalanceByAddress = async (addresses: string[], batchsize: n
}
for (const bal of balances) {
if (bal.error) console.warn('multiGetBalanceByAddress():', bal.error);
if (bal.error) console.warn('[electrum] multiGetBalanceByAddress():', bal.error);
ret.balance += +bal.result.confirmed;
ret.unconfirmed_balance += +bal.result.unconfirmed;
ret.addresses[scripthash2addr[bal.param]] = bal.result;
@ -836,7 +1126,7 @@ export const multiGetHistoryByAddress = async function (
}
for (const history of results) {
if (history.error) console.warn('multiGetHistoryByAddress():', history.error);
if (history.error) console.warn('[electrum] multiGetHistoryByAddress():', history.error);
ret[scripthash2addr[history.param]] = history.result || [];
for (const result of history.result || []) {
if (result.tx_hash) txhashHeightCache[result.tx_hash] = result.height; // cache tx height
@ -877,7 +1167,7 @@ export async function multiGetTransactionByTxid<T extends boolean>(
try {
ret[txid] = JSON.parse(jsonString.cache_value as string);
} catch (error) {
console.log(error, 'cache failed to parse', jsonString.cache_value);
console.log('[electrum]', error, 'cache failed to parse', jsonString.cache_value);
}
}
@ -927,7 +1217,7 @@ export async function multiGetTransactionByTxid<T extends boolean>(
tx = txhexToElectrumTransaction(tx);
results.push({ result: tx, param: txid });
} catch (err) {
console.log(err);
console.log('[electrum]', err);
}
}
} else {
@ -943,7 +1233,7 @@ export async function multiGetTransactionByTxid<T extends boolean>(
}
results.push({ result: tx, param: txid });
} catch (err) {
console.log(err);
console.log('[electrum]', err);
}
}
}
@ -998,41 +1288,12 @@ export async function multiGetTransactionByTxid<T extends boolean>(
}
});
} catch (writeError) {
console.error('Failed to write transaction cache:', writeError);
console.error('[electrum] Failed to write transaction cache:', writeError);
}
return ret;
}
/**
* Simple waiter till `mainConnected` becomes true (which means
* it Electrum was connected in other function), or timeout 30 sec.
*/
export const waitTillConnected = async function (): Promise<boolean> {
let waitTillConnectedInterval: NodeJS.Timeout | undefined;
let retriesCounter = 0;
if (await isDisabled()) {
console.warn('Electrum connections disabled by user. waitTillConnected skipping...');
return false;
}
return new Promise(function (resolve, reject) {
waitTillConnectedInterval = setInterval(() => {
if (mainConnected) {
clearInterval(waitTillConnectedInterval);
return resolve(true);
}
if (wasConnectedAtLeastOnce && retriesCounter++ >= 150) {
// `wasConnectedAtLeastOnce` needed otherwise theres gona be a race condition with the code that connects
// electrum during app startup
clearInterval(waitTillConnectedInterval);
presentNetworkErrorAlert();
reject(new Error('Waiting for Electrum connection timeout'));
}
}, 100);
});
};
// Returns the value at a given percentile in a sorted numeric array.
// "Linear interpolation between closest ranks" method
function percentile(arr: number[], p: number) {
@ -1184,10 +1445,11 @@ export const testConnection = async function (host: string, tcpPort?: number, ss
client.onError = () => {}; // mute
let timeoutId: NodeJS.Timeout | undefined;
const timeoutMs = host.endsWith('.onion') ? 21_000 : 5_000;
try {
const rez = await Promise.race([
new Promise(resolve => {
timeoutId = setTimeout(() => resolve('timeout'), 5000);
timeoutId = setTimeout(() => resolve('timeout'), timeoutMs);
}),
client.connect(),
]);
@ -1205,8 +1467,19 @@ export const testConnection = async function (host: string, tcpPort?: number, ss
return false;
};
/**
* Drop the current connection and tell any in-flight `ensureConnected()` to abort
* (so it doesn't race the disconnect by setting state back to `connected`).
*/
export const forceDisconnect = (): void => {
mainClient?.close();
disconnectGeneration += 1;
if (mainClient) {
try {
mainClient.close();
} catch {}
mainClient = undefined;
}
setConnectionState('disconnected');
};
export const setBatchingDisabled = () => {

View File

@ -0,0 +1 @@
export { default, type Spec } from '../codegen/NativeEventEmitter';

View File

@ -0,0 +1 @@
export { default, type Spec } from '../codegen/NativeMenuElementsEmitter';

View File

@ -0,0 +1 @@
export { default, type Spec } from '../codegen/NativeWidgetHelper';

View File

@ -1,4 +1,5 @@
import { NativeModules, Platform } from 'react-native';
import { Platform } from 'react-native';
import NativeSettingsModule from '../codegen/NativeSettingsModule';
interface SettingsModuleInterface {
/**
@ -46,6 +47,7 @@ interface SettingsModuleInterface {
}
// Only available on Android
const SettingsModule: SettingsModuleInterface | null = Platform.OS === 'android' ? NativeModules.SettingsModule : null;
const nativeModule = NativeSettingsModule ?? null;
const SettingsModule: SettingsModuleInterface | null = Platform.OS === 'android' ? nativeModule : null;
export default SettingsModule;

View File

@ -9,13 +9,13 @@ import androidx.core.content.ContextCompat
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.ReactContext
import com.facebook.react.bridge.WritableMap
import com.facebook.react.uimanager.events.Event
import com.facebook.react.uimanager.UIManagerHelper
import com.facebook.react.uimanager.events.Event
import com.google.android.material.button.MaterialButton
import com.google.android.material.button.MaterialButtonToggleGroup
import io.bluewallet.bluewallet.R
class CustomSegmentedControl @JvmOverloads constructor(
class SegmentedControl @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
@ -23,7 +23,11 @@ class CustomSegmentedControl @JvmOverloads constructor(
private val toggleGroup: MaterialButtonToggleGroup
private var currentSelectedIndex: Int = 0
private var onChangeEvent: ((WritableMap) -> Unit)? = null
private var backgroundColorProp: Int? = null
private var tintColorProp: Int? = null
private var textColorProp: Int? = null
private var momentaryProp: Boolean = false
private var isEnabledProp: Boolean = true
var values: Array<String> = emptyArray()
set(value) {
@ -44,10 +48,13 @@ class CustomSegmentedControl @JvmOverloads constructor(
isSingleSelection = true
isSelectionRequired = true
}
addView(toggleGroup, LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
))
addView(
toggleGroup,
LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT,
),
)
toggleGroup.addOnButtonCheckedListener { _, checkedId, isChecked ->
if (isChecked) {
@ -55,6 +62,9 @@ class CustomSegmentedControl @JvmOverloads constructor(
if (newIndex != -1 && newIndex != currentSelectedIndex) {
currentSelectedIndex = newIndex
emitChangeEvent(newIndex)
if (momentaryProp) {
toggleGroup.clearChecked()
}
}
}
}
@ -62,28 +72,29 @@ class CustomSegmentedControl @JvmOverloads constructor(
private fun updateSegments() {
toggleGroup.removeAllViews()
values.forEachIndexed { index, title ->
val button = MaterialButton(
context,
null,
com.google.android.material.R.attr.materialButtonOutlinedStyle
com.google.android.material.R.attr.materialButtonOutlinedStyle,
).apply {
text = title
id = generateViewId()
layoutParams = LinearLayout.LayoutParams(
0,
LinearLayout.LayoutParams.WRAP_CONTENT,
1f
1f,
)
isCheckable = true
strokeWidth = 2
applyEnabledState()
val cornerRadius = resources.getDimensionPixelSize(
com.google.android.material.R.dimen.mtrl_btn_corner_radius
com.google.android.material.R.dimen.mtrl_btn_corner_radius,
)
when {
values.size == 1 -> {
this.cornerRadius = cornerRadius
@ -99,10 +110,10 @@ class CustomSegmentedControl @JvmOverloads constructor(
}
}
}
toggleGroup.addView(button)
}
updateButtonColors()
updateSelectedSegment()
}
@ -110,54 +121,82 @@ class CustomSegmentedControl @JvmOverloads constructor(
private fun updateButtonColors() {
for (i in 0 until toggleGroup.childCount) {
val button = toggleGroup.getChildAt(i) as? MaterialButton ?: continue
val selectedBgColor = ContextCompat.getColor(context, R.color.button_background_color)
val unselectedBgColor = ContextCompat.getColor(context, R.color.button_disabled_background_color)
val selectedTextColor = ContextCompat.getColor(context, R.color.button_text_color)
val unselectedTextColor = ContextCompat.getColor(context, R.color.button_disabled_text_color)
val selectedBgColor = tintColorProp ?: ContextCompat.getColor(context, R.color.button_background_color)
val unselectedBgColor = backgroundColorProp ?: ContextCompat.getColor(context, R.color.button_disabled_background_color)
val resolvedTextColor = textColorProp ?: ContextCompat.getColor(context, R.color.button_text_color)
val selectedTextColor = resolvedTextColor
val unselectedTextColor = textColorProp ?: ContextCompat.getColor(context, R.color.button_disabled_text_color)
val borderColor = ContextCompat.getColor(context, R.color.form_border_color)
val rippleColor = ContextCompat.getColor(context, R.color.ripple_color)
val rippleColorSelected = ContextCompat.getColor(context, R.color.ripple_color_selected)
val bgColorStateList = ColorStateList(
arrayOf(
intArrayOf(android.R.attr.state_checked),
intArrayOf(-android.R.attr.state_checked)
intArrayOf(-android.R.attr.state_checked),
),
intArrayOf(selectedBgColor, unselectedBgColor)
intArrayOf(selectedBgColor, unselectedBgColor),
)
val textColorStateList = ColorStateList(
arrayOf(
intArrayOf(android.R.attr.state_checked),
intArrayOf(-android.R.attr.state_checked)
intArrayOf(android.R.attr.state_checked),
intArrayOf(-android.R.attr.state_checked),
),
intArrayOf(selectedTextColor, unselectedTextColor)
intArrayOf(selectedTextColor, unselectedTextColor),
)
val strokeColorStateList = ColorStateList(
arrayOf(
intArrayOf(android.R.attr.state_checked),
intArrayOf(-android.R.attr.state_checked)
intArrayOf(android.R.attr.state_checked),
intArrayOf(-android.R.attr.state_checked),
),
intArrayOf(borderColor, borderColor)
intArrayOf(borderColor, borderColor),
)
val rippleColorStateList = ColorStateList(
arrayOf(
intArrayOf(android.R.attr.state_checked),
intArrayOf(-android.R.attr.state_checked)
intArrayOf(android.R.attr.state_checked),
intArrayOf(-android.R.attr.state_checked),
),
intArrayOf(rippleColorSelected, rippleColor)
intArrayOf(rippleColorSelected, rippleColor),
)
button.backgroundTintList = bgColorStateList
button.setTextColor(textColorStateList)
button.strokeColor = strokeColorStateList
button.rippleColor = rippleColorStateList
button.isEnabled = isEnabledProp
}
}
fun setBackgroundColorProp(color: String?) {
backgroundColorProp = parseColor(color)
updateButtonColors()
}
fun setTintColorProp(color: String?) {
tintColorProp = parseColor(color)
updateButtonColors()
}
fun setTextColorProp(color: String?) {
textColorProp = parseColor(color)
updateButtonColors()
}
fun setMomentaryProp(momentary: Boolean) {
momentaryProp = momentary
toggleGroup.isSelectionRequired = !momentary
}
fun setEnabledProp(enabled: Boolean) {
isEnabledProp = enabled
toggleGroup.isEnabled = enabled
applyEnabledState()
}
private fun updateSelectedSegment() {
if (values.isNotEmpty() && currentSelectedIndex in 0 until values.size) {
val buttonId = getButtonIdAtIndex(currentSelectedIndex)
@ -188,26 +227,37 @@ class CustomSegmentedControl @JvmOverloads constructor(
val reactContext = context as? ReactContext ?: return
val surfaceId = UIManagerHelper.getSurfaceId(reactContext)
val eventDispatcher = UIManagerHelper.getEventDispatcherForReactTag(reactContext, id)
val event = Arguments.createMap().apply {
putInt("selectedIndex", selectedIndex)
}
eventDispatcher?.dispatchEvent(ChangeEvent(surfaceId, id, event))
}
private fun applyEnabledState() {
for (i in 0 until toggleGroup.childCount) {
val button = toggleGroup.getChildAt(i) as? MaterialButton ?: continue
button.isEnabled = isEnabledProp
}
}
private fun parseColor(color: String?): Int? {
return try {
color?.let { Color.parseColor(it) }
} catch (_: IllegalArgumentException) {
null
}
}
private inner class ChangeEvent(
surfaceId: Int,
viewId: Int,
private val eventData: WritableMap
private val eventData: WritableMap,
) : Event<ChangeEvent>(surfaceId, viewId) {
override fun getEventName(): String = "onChangeEvent"
override fun getEventName(): String = "topChange"
override fun getEventData(): WritableMap = eventData
}
fun setOnChangeEvent(callback: ((WritableMap) -> Unit)?) {
onChangeEvent = callback
}
}
}

View File

@ -0,0 +1,67 @@
package io.bluewallet.bluewallet.components.segmentedcontrol
import com.facebook.react.bridge.ReadableArray
import com.facebook.react.common.MapBuilder
import com.facebook.react.module.annotations.ReactModule
import com.facebook.react.uimanager.SimpleViewManager
import com.facebook.react.uimanager.ThemedReactContext
import com.facebook.react.uimanager.annotations.ReactProp
@ReactModule(name = SegmentedControlManager.REACT_CLASS)
class SegmentedControlManager : SimpleViewManager<SegmentedControl>() {
companion object {
const val REACT_CLASS = "SegmentedControl"
}
override fun getName(): String = REACT_CLASS
override fun createViewInstance(reactContext: ThemedReactContext): SegmentedControl =
SegmentedControl(reactContext)
@ReactProp(name = "values")
fun setValues(view: SegmentedControl, values: ReadableArray?) {
view.values = values?.let { arr -> Array(arr.size()) { arr.getString(it) ?: "" } } ?: emptyArray()
}
@ReactProp(name = "selectedIndex", defaultInt = 0)
fun setSelectedIndex(view: SegmentedControl, selectedIndex: Int) {
view.selectedIndex = selectedIndex
}
@ReactProp(name = "enabled", defaultBoolean = true)
fun setEnabled(view: SegmentedControl, enabled: Boolean) {
view.setEnabledProp(enabled)
}
@ReactProp(name = "momentary", defaultBoolean = false)
fun setMomentary(view: SegmentedControl, momentary: Boolean) {
view.setMomentaryProp(momentary)
}
@ReactProp(name = "backgroundColor")
fun setBackgroundColor(view: SegmentedControl, backgroundColor: String?) {
view.setBackgroundColorProp(backgroundColor)
}
@ReactProp(name = "tintColor")
fun setTintColor(view: SegmentedControl, tintColor: String?) {
view.setTintColorProp(tintColor)
}
@ReactProp(name = "textColor")
fun setTextColor(view: SegmentedControl, textColor: String?) {
view.setTextColorProp(textColor)
}
override fun getExportedCustomBubblingEventTypeConstants(): Map<String, Any>? =
MapBuilder.builder<String, Any>()
.put(
"topChange",
MapBuilder.of(
"phasedRegistrationNames",
MapBuilder.of("bubbled", "onChange", "captured", "onChangeCapture"),
),
)
.build()
}

View File

@ -5,13 +5,13 @@ import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.uimanager.ViewManager
class CustomSegmentedControlPackage : ReactPackage {
class SegmentedControlPackage : ReactPackage {
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
return emptyList()
}
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
return listOf(CustomSegmentedControlManager())
return listOf(SegmentedControlManager())
}
}
}

View File

@ -0,0 +1,6 @@
#import <React/RCTViewManager.h>
@interface RCT_EXTERN_MODULE(SegmentedControlManager, RCTViewManager)
@end

View File

@ -0,0 +1,23 @@
import Foundation
import UIKit
import React
@objc(SegmentedControlManager)
final class SegmentedControlManager: RCTViewManager {
override class func requiresMainQueueSetup() -> Bool { true }
override func view() -> UIView! {
return SegmentedControlView()
}
@objc class func propConfig_values() -> [String]! { ["NSArray"] }
@objc class func propConfig_selectedIndex() -> [String]! { ["NSInteger"] }
@objc class func propConfig_enabled() -> [String]! { ["BOOL"] }
@objc class func propConfig_momentary() -> [String]! { ["BOOL"] }
@objc class func propConfig_tintColor() -> [String]! { ["UIColor"] }
@objc class func propConfig_backgroundColor() -> [String]! { ["UIColor"] }
@objc class func propConfig_textColor() -> [String]! { ["UIColor"] }
@objc class func propConfig_onChange() -> [String]! { ["RCTBubblingEventBlock"] }
}

View File

@ -0,0 +1,98 @@
import UIKit
import React
@objc(SegmentedControlView)
final class SegmentedControlView: UIView {
private let segmentedControl = UISegmentedControl()
// MARK: - Lifecycle
override init(frame: CGRect) {
super.init(frame: frame)
setup()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
setup()
}
private func setup() {
segmentedControl.addTarget(self, action: #selector(handleValueChanged(_:)), for: .valueChanged)
addSubview(segmentedControl)
segmentedControl.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
segmentedControl.leadingAnchor.constraint(equalTo: leadingAnchor),
segmentedControl.trailingAnchor.constraint(equalTo: trailingAnchor),
segmentedControl.topAnchor.constraint(equalTo: topAnchor),
segmentedControl.bottomAnchor.constraint(equalTo: bottomAnchor),
])
}
// MARK: - Prop setters
@objc var values: NSArray = [] {
didSet { rebuildSegments() }
}
@objc var selectedIndex: Int = 0 {
didSet {
guard segmentedControl.numberOfSegments > 0 else { return }
let clamped = min(max(selectedIndex, 0), segmentedControl.numberOfSegments - 1)
if segmentedControl.selectedSegmentIndex != clamped {
segmentedControl.selectedSegmentIndex = clamped
}
}
}
@objc var enabled: Bool = true {
didSet { segmentedControl.isEnabled = enabled }
}
@objc var momentary: Bool = false {
didSet { segmentedControl.isMomentary = momentary }
}
@objc var textColor: UIColor? {
didSet { applyTextAttributes() }
}
@objc var onChange: RCTBubblingEventBlock?
override var tintColor: UIColor! {
didSet { segmentedControl.selectedSegmentTintColor = tintColor }
}
override var backgroundColor: UIColor? {
didSet { segmentedControl.backgroundColor = backgroundColor }
}
// MARK: - Private helpers
private func rebuildSegments() {
let titles = values as? [String] ?? []
segmentedControl.removeAllSegments()
for (i, title) in titles.enumerated() {
segmentedControl.insertSegment(withTitle: title, at: i, animated: false)
}
guard !titles.isEmpty else { return }
let clamped = min(max(selectedIndex, 0), titles.count - 1)
segmentedControl.selectedSegmentIndex = clamped
}
private func applyTextAttributes() {
if let color = textColor {
segmentedControl.setTitleTextAttributes([.foregroundColor: color], for: .normal)
segmentedControl.setTitleTextAttributes([.foregroundColor: UIColor.white], for: .selected)
} else {
segmentedControl.setTitleTextAttributes(nil, for: .normal)
segmentedControl.setTitleTextAttributes(nil, for: .selected)
}
}
@objc private func handleValueChanged(_ sender: UISegmentedControl) {
onChange?(["selectedIndex": sender.selectedSegmentIndex])
}
}

View File

@ -1,7 +1,7 @@
import Bugsnag from '@bugsnag/react-native';
import { getUniqueId } from 'react-native-device-info';
import { BlueApp as BlueAppClass } from '../class';
import { BlueApp as BlueAppClass } from '../class/blue-app';
const BlueApp = BlueAppClass.getInstance();

View File

@ -0,0 +1,71 @@
// 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);
});
}
}

View File

@ -0,0 +1,197 @@
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,
};

View File

@ -0,0 +1,423 @@
// 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;
},
};

View File

@ -0,0 +1,163 @@
// 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;
},
};

View File

@ -2,4 +2,7 @@
* Let's keep config vars, constants and definitions here
*/
export const groundControlUri: string = 'https://groundcontrol-bluewallet.herokuapp.com';
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';

View File

@ -1,23 +1,98 @@
import AES from 'crypto-js/aes';
import Utf8 from 'crypto-js/enc-utf8';
import { cbc } from '@noble/ciphers/aes';
import { md5 } from '@noble/hashes/legacy';
import { randomBytes } from '@noble/hashes/utils';
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 ciphertext = AES.encrypt(data, password);
return ciphertext.toString();
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]));
}
/**
* 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 {
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;
// 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;
}
}

View File

@ -2,7 +2,7 @@ import { Platform } from 'react-native';
import { pick, types, keepLocalCopy, errorCodes } from '@react-native-documents/picker';
import RNFS from 'react-native-fs';
import { launchImageLibrary, ImagePickerResponse } from 'react-native-image-picker';
import RNQRGenerator from 'rn-qr-generator';
import { detectQRCodeInImage } from 'react-native-camera-kit-no-google';
import Share from 'react-native-share';
import presentAlert from '../components/Alert';
@ -113,6 +113,7 @@ export const showImagePickerAndReadImage = async (): Promise<string | undefined>
maxHeight: 800,
maxWidth: 600,
selectionLimit: 1,
includeBase64: true,
});
if (response.didCancel) {
@ -120,19 +121,12 @@ export const showImagePickerAndReadImage = async (): Promise<string | undefined>
} else if (response.errorCode) {
throw new Error(response.errorMessage);
} else if (response.assets) {
try {
const uri = response.assets[0].uri;
if (uri) {
const result = await RNQRGenerator.detect({ uri: decodeURI(uri.toString()) });
if (result?.values.length > 0) {
return result?.values[0];
}
}
throw new Error(loc.send.qr_error_no_qrcode);
} catch (error) {
console.error(error);
presentAlert({ message: loc.send.qr_error_no_qrcode });
const base64 = response.assets[0].base64;
if (base64) {
const result = await detectQRCodeInImage(base64);
if (result) return result;
}
throw new Error(loc.send.qr_error_no_qrcode);
}
return undefined;
@ -186,33 +180,23 @@ export const showFilePickerAndReadFile = async function (): Promise<{ data: stri
}
};
const handleImageFile = async (fileCopyUri: string): Promise<{ data: string | false; uri: string | false }> => {
const readFileAsBase64 = async (uri: string): Promise<string> => {
try {
const exists = await RNFS.exists(fileCopyUri);
if (!exists) {
presentAlert({ message: 'File does not exist' });
return { data: false, uri: false };
}
// First attempt: use original URI
let result = await RNQRGenerator.detect({ uri: decodeURI(fileCopyUri) });
if (result?.values && result.values.length > 0) {
return { data: result.values[0], uri: fileCopyUri };
}
// Second attempt: remove file:// prefix and try again
const altUri = fileCopyUri.replace(/^file:\/\//, '');
result = await RNQRGenerator.detect({ uri: decodeURI(altUri) });
if (result?.values && result.values.length > 0) {
return { data: result.values[0], uri: fileCopyUri };
}
presentAlert({ message: loc.send.qr_error_no_qrcode });
return { data: false, uri: false };
} catch (error: any) {
console.error(error);
presentAlert({ message: loc.send.qr_error_no_qrcode });
return { data: false, uri: false };
return await RNFS.readFile(uri, 'base64');
} catch {
return await RNFS.readFile(uri.replace(/^file:\/\//, ''), 'base64');
}
};
const handleImageFile = async (fileCopyUri: string): Promise<{ data: string | false; uri: string | false }> => {
const base64 = await readFileAsBase64(fileCopyUri);
const result = await detectQRCodeInImage(base64);
if (result) {
return { data: result, uri: fileCopyUri };
}
throw new Error(loc.send.qr_error_no_qrcode);
};
export const readFileOutsideSandbox = (filePath: string) => {
if (Platform.OS === 'ios') {
return readFile(filePath);

View File

@ -26,44 +26,93 @@ export interface TinySecp256k1InterfaceExtended {
signDER(h: Uint8Array, d: Uint8Array, e?: Uint8Array): Uint8Array;
}
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);
};
// @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
>;
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;
// 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;
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 { 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);
},
};
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;
}
}
@ -71,7 +120,8 @@ function throwToNull<Type>(fn: () => Type): Type | null {
function isPoint(p: Uint8Array, xOnly: boolean): boolean {
if ((p.length === 32) !== xOnly) return false;
try {
return !!necc.Point.fromHex(p);
pointFromBytes(p);
return true;
} catch (e) {
return false;
}
@ -79,23 +129,12 @@ function isPoint(p: Uint8Array, xOnly: boolean): boolean {
const ecc: TinySecp256k1InterfaceExtended & TinySecp256k1Interface & TinySecp256k1InterfaceBIP32 = {
isPoint: (p: Uint8Array): boolean => isPoint(p, false),
isPrivate: (d: Uint8Array): boolean => {
/* if (
[
'0000000000000000000000000000000000000000000000000000000000000000',
'fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141',
'fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364142',
].includes(d.toString('hex'))
) {
return false;
} */
return necc.utils.isValidPrivateKey(d);
},
isPrivate: (d: Uint8Array): boolean => necc.utils.isValidSecretKey(d),
isXOnlyPoint: (p: Uint8Array): boolean => isPoint(p, true),
xOnlyPointAddTweak: (p: Uint8Array, tweak: Uint8Array): { parity: 0 | 1; xOnlyPubkey: Uint8Array } | null =>
throwToNull(() => {
const P = necc.utils.pointAddScalar(p, tweak, true);
const P = tweakUtils.pointAddScalar(p, tweak, true);
const parity = P[0] % 2 === 1 ? 1 : 0;
return { parity, xOnlyPubkey: P.slice(1) };
}),
@ -104,60 +143,56 @@ const ecc: TinySecp256k1InterfaceExtended & TinySecp256k1Interface & TinySecp256
throwToNull(() => necc.getPublicKey(sk, defaultTrue(compressed))),
pointCompress: (p: Uint8Array, compressed?: boolean): Uint8Array => {
return necc.Point.fromHex(p).toRawBytes(defaultTrue(compressed));
return pointFromBytes(p).toBytes(defaultTrue(compressed));
},
pointMultiply: (a: Uint8Array, tweak: Uint8Array, compressed?: boolean): Uint8Array | null =>
throwToNull(() => necc.utils.pointMultiply(a, tweak, defaultTrue(compressed))),
throwToNull(() => tweakUtils.pointMultiply(a, tweak, defaultTrue(compressed))),
pointAdd: (a: Uint8Array, b: Uint8Array, compressed?: boolean): Uint8Array | null =>
throwToNull(() => {
const A = necc.Point.fromHex(a);
const B = necc.Point.fromHex(b);
return A.add(B).toRawBytes(defaultTrue(compressed));
const A = pointFromBytes(a);
const B = pointFromBytes(b);
return A.add(B).toBytes(defaultTrue(compressed));
}),
pointAddScalar: (p: Uint8Array, tweak: Uint8Array, compressed?: boolean): Uint8Array | null =>
throwToNull(() => necc.utils.pointAddScalar(p, tweak, defaultTrue(compressed))),
throwToNull(() => tweakUtils.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 = necc.utils.privateAdd(d, tweak);
// console.log(ret);
const ret = tweakUtils.privateAdd(d, tweak);
if (ret.join('') === '00000000000000000000000000000000') {
return null;
}
return ret;
}),
privateNegate: (d: Uint8Array): Uint8Array => necc.utils.privateNegate(d),
privateNegate: (d: Uint8Array): Uint8Array => tweakUtils.privateNegate(d),
sign: (h: Uint8Array, d: Uint8Array, e?: Uint8Array): Uint8Array => {
return necc.signSync(h, d, { der: false, extraEntropy: e });
return necc.sign(h, d, { prehash: false, extraEntropy: e });
},
signDER: (h: Uint8Array, d: Uint8Array, e?: Uint8Array): Uint8Array => {
return necc.signSync(h, d, { der: true, extraEntropy: e });
return compactToDER(necc.sign(h, d, { prehash: false, extraEntropy: e }));
},
signSchnorr: (h: Uint8Array, d: Uint8Array, e: Uint8Array = new Uint8Array(32).fill(0x00)): Uint8Array => {
return necc.schnorr.signSync(h, d, e);
return necc.schnorr.sign(h, d, e);
},
verify: (h: Uint8Array, Q: Uint8Array, signature: Uint8Array, strict?: boolean): boolean => {
return necc.verify(signature, h, Q, { strict });
return necc.verify(signature, h, Q, { prehash: false, lowS: strict !== false });
},
verifySchnorr: (h: Uint8Array, Q: Uint8Array, signature: Uint8Array): boolean => {
return necc.schnorr.verifySync(signature, h, Q);
return necc.schnorr.verify(signature, h, Q);
},
};
export default ecc;
// module.exports.ecc = ecc;

View File

@ -1,47 +1,176 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import PushNotificationIOS from '@react-native-community/push-notification-ios';
import { AppState, AppStateStatus, Platform } from 'react-native';
import { AppState, AppStateStatus, EmitterSubscription, Platform } from 'react-native';
import { getApplicationName, getSystemName, getSystemVersion, getVersion, hasGmsSync, hasHmsSync } from 'react-native-device-info';
import {
Notification as RNNotification,
NotificationBackgroundFetchResult,
NotificationCompletion,
Notifications,
} from 'react-native-notifications';
import { checkNotifications, requestNotifications, RESULTS } from 'react-native-permissions';
import PushNotification, { ReceivedNotification } from 'react-native-push-notification';
import type { BoltzReverseSwap } from '@arkade-os/boltz-swap';
import loc from '../loc';
import { groundControlUri } from './constants';
import { arkadePaymentPushUri, 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';
let alreadyConfigured = false;
let baseURI = groundControlUri;
const baseURI = groundControlUri;
let notificationSubscriptions: EmitterSubscription[] = [];
let onProcessNotificationsHandler: undefined | (() => void | Promise<void>);
const handledNotificationKeys = new Set<string>();
let pendingRegistrationPromise: Promise<boolean> | null = null;
let pendingRegistrationResolve: ((value: boolean) => void) | null = null;
let pendingRegistrationTimeout: ReturnType<typeof setTimeout> | undefined;
type TPushToken = {
token: string;
os: string; // its actually ('ios' | 'android'), but types for the lib are a bit more generic...
os: 'ios' | 'android';
};
// thats unwrapped `ReceivedNotification`, withall `data` fields inline
type TPayload = {
// inherited from `ReceivedNotification`:
subText?: string;
title?: string;
identifier?: string;
message?: string | object;
foreground: boolean;
userInteraction: boolean;
// hopefully stuffed in `data` and uwrapped when received:
address: string;
txid: string;
type: number;
hash: string;
[key: string]: any;
};
function deepClone<T>(obj: T): T {
return JSON.parse(JSON.stringify(obj));
}
const createPushToken = (deviceToken: string): TPushToken => ({
token: deviceToken,
os: Platform.OS as TPushToken['os'],
});
const settlePendingRegistration = (value: boolean) => {
if (!pendingRegistrationResolve) return;
const resolve = pendingRegistrationResolve;
pendingRegistrationResolve = null;
pendingRegistrationPromise = null;
if (pendingRegistrationTimeout) {
clearTimeout(pendingRegistrationTimeout);
pendingRegistrationTimeout = undefined;
}
resolve(value);
};
const waitForRemoteRegistration = (timeoutMs = 10_000): Promise<boolean> => {
if (pendingRegistrationPromise) return pendingRegistrationPromise;
pendingRegistrationPromise = new Promise<boolean>(resolve => {
pendingRegistrationResolve = resolve;
pendingRegistrationTimeout = setTimeout(() => {
settlePendingRegistration(false);
}, timeoutMs);
});
Notifications.registerRemoteNotifications();
return pendingRegistrationPromise;
};
const ensureAndroidNotificationChannel = () => {
if (Platform.OS !== 'android') return;
Notifications.setNotificationChannel({
channelId: ANDROID_NOTIFICATION_CHANNEL_ID,
name: 'BlueWallet notifications',
description: 'Notifications about incoming payments',
importance: 4,
enableVibration: true,
showBadge: true,
});
};
const getNotificationKey = (payload: Partial<TPayload>, notification?: RNNotification) => {
return JSON.stringify({
identifier: notification?.identifier ?? payload.identifier ?? '',
type: payload.type ?? '',
hash: payload.hash ?? '',
txid: payload.txid ?? '',
address: payload.address ?? '',
message: payload.message ?? '',
});
};
const markNotificationHandled = (key: string) => {
handledNotificationKeys.add(key);
if (handledNotificationKeys.size > 100) {
const oldestKey = handledNotificationKeys.values().next().value;
if (oldestKey) handledNotificationKeys.delete(oldestKey);
}
};
const normalizeNotificationPayload = (notification: RNNotification, status: Pick<TPayload, 'foreground' | 'userInteraction'>): TPayload => {
const rawPayload =
notification.payload && typeof notification.payload === 'object' ? (deepClone(notification.payload) as Record<string, any>) : {};
const nestedPayload = rawPayload.data && typeof rawPayload.data === 'object' ? rawPayload.data : {};
const nestedData = nestedPayload.data && typeof nestedPayload.data === 'object' ? nestedPayload.data : {};
const payload: TPayload = {
...rawPayload,
...nestedPayload,
...nestedData,
title: notification.title ?? rawPayload.title,
subText: rawPayload.subText ?? rawPayload.subtitle ?? notification.title,
message: rawPayload.message ?? notification.body,
identifier: notification.identifier,
foreground: status.foreground,
userInteraction: status.userInteraction,
} as TPayload;
delete payload.data;
return payload;
};
const storeIncomingNotification = async (
notification: RNNotification,
status: Pick<TPayload, 'foreground' | 'userInteraction'>,
completion?: ((response: NotificationCompletion) => void) | ((response: NotificationBackgroundFetchResult) => void),
) => {
try {
const payload = normalizeNotificationPayload(notification, status);
const notificationKey = getNotificationKey(payload, notification);
if (handledNotificationKeys.has(notificationKey)) {
return;
}
markNotificationHandled(notificationKey);
if (!payload.subText && !payload.message) {
console.warn('Notification missing required fields:', payload);
return;
}
await addNotification(payload);
if (payload.foreground && onProcessNotificationsHandler) {
await onProcessNotificationsHandler();
}
} catch (error) {
console.error('Failed to store incoming notification:', error);
} finally {
if (completion) {
if (status.foreground) {
(completion as (response: NotificationCompletion) => void)({ alert: false, sound: false, badge: false });
} else {
(completion as (response: NotificationBackgroundFetchResult) => void)(NotificationBackgroundFetchResult.NO_DATA);
}
}
}
};
const checkAndroidNotificationPermission = async () => {
try {
const { status } = await checkNotifications();
console.debug('Notification permission check:', status);
console.log('Notification permission check:', status);
return status === RESULTS.GRANTED;
} catch (err) {
console.error('Failed to check notification permission:', err);
@ -90,22 +219,14 @@ export const cleanUserOptOutFlag = async () => {
* Should be called when user is most interested in receiving push notifications.
* If we dont have a token it will show alert asking whether
* user wants to receive notifications, and if yes - will configure push notifications.
* FYI, on Android permissions are acquired when app is installed, so basically we dont need to ask,
* we can just call `configure`. On iOS its different, and calling `configure` triggers system's dialog box.
*
* @returns {Promise<boolean>} TRUE if permissions were obtained, FALSE otherwise
*/
/**
* Attempts to obtain permissions and configure notifications.
* Shows a rationale on Android if permissions are needed.
*
* @returns {Promise<boolean>}
*/
export const tryToObtainPermissions = async () => {
console.debug('tryToObtainPermissions: Starting user-triggered permission request');
export const tryToObtainPermissions = async (): Promise<boolean> => {
console.log('tryToObtainPermissions: Starting user-triggered permission request');
if (!isNotificationsCapable) {
console.debug('tryToObtainPermissions: Device not capable');
console.log('tryToObtainPermissions: Device not capable');
return false;
}
@ -122,7 +243,7 @@ export const tryToObtainPermissions = async () => {
Platform.OS === 'android' && Platform.Version < 33 ? rationale : undefined,
);
if (status !== RESULTS.GRANTED) {
console.debug('tryToObtainPermissions: Permission denied');
console.log('tryToObtainPermissions: Permission denied');
return false;
}
return configureNotifications();
@ -131,6 +252,29 @@ export const tryToObtainPermissions = async () => {
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
@ -141,7 +285,7 @@ export const tryToObtainPermissions = async () => {
* @returns {Promise<object>} Response object from API rest call
*/
export const majorTomToGroundControl = async (addresses: string[], hashes: string[], txids: string[]) => {
console.debug('majorTomToGroundControl: Starting notification registration', {
console.log('majorTomToGroundControl: Starting notification registration', {
addressCount: addresses?.length,
hashCount: hashes?.length,
txidCount: txids?.length,
@ -159,7 +303,7 @@ export const majorTomToGroundControl = async (addresses: string[], hashes: strin
}
const pushToken = await getPushToken();
console.debug('majorTomToGroundControl: Retrieved push token:', !!pushToken);
console.log('majorTomToGroundControl: Retrieved push token:', !!pushToken);
if (!pushToken || !pushToken.token || !pushToken.os) {
return;
}
@ -174,7 +318,7 @@ export const majorTomToGroundControl = async (addresses: string[], hashes: strin
let response;
try {
console.debug('majorTomToGroundControl: Sending request to:', `${baseURI}/majorTomToGroundControl`);
console.log('majorTomToGroundControl: Sending request to:', `${baseURI}/majorTomToGroundControl`);
response = await fetch(`${baseURI}/majorTomToGroundControl`, {
method: 'POST',
headers: _getHeaders(),
@ -206,6 +350,44 @@ 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
@ -216,11 +398,18 @@ export const majorTomToGroundControl = async (addresses: string[], hashes: strin
*/
export const checkPermissions = async () => {
try {
return new Promise(function (resolve) {
PushNotification.checkPermissions((result: any) => {
resolve(result);
});
});
if (Platform.OS === 'ios') {
return Notifications.ios.checkPermissions();
}
const { status } = await checkNotifications();
const granted = status === RESULTS.GRANTED;
return {
alert: granted,
badge: granted,
sound: granted,
status,
};
} catch (error) {
console.error('Error checking permissions:', error);
throw error;
@ -255,12 +444,14 @@ export const setLevels = async (levelAll: boolean) => {
}
if (!levelAll) {
console.debug('Disabling notifications as user opted out...');
PushNotification.removeAllDeliveredNotifications();
PushNotification.setApplicationIconBadgeNumber(0);
PushNotification.cancelAllLocalNotifications();
console.log('Disabling notifications as user opted out...');
Notifications.removeAllDeliveredNotifications();
if (Platform.OS === 'ios') {
Notifications.ios.setBadgeCount(0);
Notifications.ios.cancelAllLocalNotifications();
}
await AsyncStorage.setItem(NOTIFICATIONS_NO_AND_DONT_ASK_FLAG, 'true');
console.debug('Notifications disabled successfully');
console.log('Notifications disabled successfully');
} else {
await AsyncStorage.removeItem(NOTIFICATIONS_NO_AND_DONT_ASK_FLAG); // Clear flag when enabling
}
@ -286,19 +477,19 @@ export const addNotification = async (notification: TPayload) => {
};
const postTokenConfig = async () => {
console.debug('postTokenConfig: Starting token configuration');
console.log('postTokenConfig: Starting token configuration');
const pushToken = await getPushToken();
console.debug('postTokenConfig: Retrieved push token:', !!pushToken);
console.log('postTokenConfig: Retrieved push token:', !!pushToken);
if (!pushToken || !pushToken.token || !pushToken.os) {
console.debug('postTokenConfig: Invalid token or missing OS info');
console.log('postTokenConfig: Invalid token or missing OS info');
return;
}
try {
const lang = (await AsyncStorage.getItem('lang')) || 'en';
const appVersion = getSystemName() + ' ' + getSystemVersion() + ';' + getApplicationName() + ' ' + getVersion();
console.debug('postTokenConfig: Posting configuration', { lang, appVersion });
console.log('postTokenConfig: Posting configuration', { lang, appVersion });
await fetch(`${baseURI}/setTokenConfiguration`, {
method: 'POST',
@ -329,101 +520,72 @@ const _setPushToken = async (token: TPushToken) => {
/**
* Configures notifications. For Android, it will show a native rationale prompt if necessary.
*
* @returns {Promise<boolean>}
* @returns {Promise<boolean>} whether successfully registered for remote push notifications
*/
export const configureNotifications = async (onProcessNotifications?: () => void) => {
if (alreadyConfigured) {
console.debug('configureNotifications: Already configured, skipping');
return true;
const configureNotifications = async (onProcessNotifications?: () => void): Promise<boolean> => {
console.log('configureNotifications()');
if (onProcessNotifications) {
onProcessNotificationsHandler = onProcessNotifications;
}
return new Promise(resolve => {
const handleRegistration = async (token: TPushToken) => {
if (__DEV__) {
console.debug('configureNotifications: Token received:', token);
}
alreadyConfigured = true;
await _setPushToken(token);
resolve(true);
};
// const handleNotification = async (notification: TPushNotification & { data: any }) => {
const handleNotification = async (notification: Omit<ReceivedNotification, 'userInfo'>) => {
// Deep clone to avoid modifying the original object
// @ts-ignore some missing properties hopefully will be unwrapped from `.data`
const payload: TPayload = deepClone({
...notification,
...notification.data,
});
if (notification.data?.data) {
const validData = Object.fromEntries(Object.entries(notification.data.data).filter(([_, value]) => value != null));
Object.assign(payload, validData);
}
// @ts-ignore stfu ts, its cleanup
payload.data = undefined;
if (!payload.subText && !payload.message) {
console.warn('Notification missing required fields:', payload);
return;
}
await addNotification(payload);
notification.finish(PushNotificationIOS.FetchResult.NoData);
if (payload.foreground && onProcessNotifications) {
await onProcessNotifications();
}
};
const configure = async () => {
try {
const { status } = await checkNotifications();
if (status !== RESULTS.GRANTED) {
console.debug('configureNotifications: Permissions not granted');
return resolve(false);
}
const existingToken = await getPushToken();
if (existingToken) {
alreadyConfigured = true;
console.debug('Notifications already configured with existing token');
return resolve(true);
}
PushNotification.configure({
onRegister: handleRegistration,
onNotification: handleNotification,
onRegistrationError: (error: any) => {
console.error('Registration error:', error);
resolve(false);
},
permissions: { alert: true, badge: true, sound: true },
popInitialNotification: true,
});
} catch (error) {
console.error('Error in configure:', error);
resolve(false);
}
};
configure();
});
};
/**
* 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 (_) {
const { status } = await checkNotifications();
if (status !== RESULTS.GRANTED) {
console.log('configureNotifications: Permissions not granted');
return false;
}
ensureAndroidNotificationChannel();
if (notificationSubscriptions.length === 0) {
notificationSubscriptions = [
Notifications.events().registerRemoteNotificationsRegistered(async event => {
console.log('processing event', event);
const token = createPushToken(event.deviceToken);
if (__DEV__) {
console.log('configureNotifications: Token received:', token);
}
await _setPushToken(token);
await postTokenConfig().catch(error => console.error('Failed to post token configuration:', error));
settlePendingRegistration(true);
}),
Notifications.events().registerRemoteNotificationsRegistrationFailed(error => {
console.error('Registration error:', error);
settlePendingRegistration(false);
}),
Notifications.events().registerRemoteNotificationsRegistrationDenied(() => {
console.log('Remote notification registration denied');
settlePendingRegistration(false);
}),
Notifications.events().registerNotificationReceivedForeground(async (notification, completion) => {
await storeIncomingNotification(notification, { foreground: true, userInteraction: false }, completion);
}),
Notifications.events().registerNotificationReceivedBackground(async (notification, completion) => {
await storeIncomingNotification(notification, { foreground: false, userInteraction: false }, completion);
}),
Notifications.events().registerNotificationOpened(async (notification, completion) => {
try {
await storeIncomingNotification(notification, { foreground: false, userInteraction: true });
} finally {
completion();
}
}),
];
}
Notifications.getInitialNotification()
.then(async initialNotification => {
if (initialNotification) {
console.log('App was launched by a push notification:', initialNotification);
await storeIncomingNotification(initialNotification, { foreground: false, userInteraction: true });
}
})
.catch(error => console.error('Failed to retrieve initial notification:', error));
// waiting and returning actual result of remote pushes registration: success or failure
return await waitForRemoteRegistration();
} catch (error) {
console.error('Error in configureNotifications:', error);
return false;
}
};
@ -528,9 +690,15 @@ export const clearStoredNotifications = async () => {
export const getDeliveredNotifications: () => Promise<Record<string, any>[]> = () => {
try {
return new Promise(resolve => {
PushNotification.getDeliveredNotifications((notifications: Record<string, any>[]) => resolve(notifications));
});
if (Platform.OS !== 'ios') {
return Promise.resolve([]);
}
return Notifications.ios
.getDeliveredNotifications()
.then(notifications =>
notifications.map(notification => normalizeNotificationPayload(notification, { foreground: true, userInteraction: false })),
);
} catch (error) {
console.error('Error getting delivered notifications:', error);
throw error;
@ -538,47 +706,19 @@ export const getDeliveredNotifications: () => Promise<Record<string, any>[]> = (
};
export const removeDeliveredNotifications = (identifiers = []) => {
PushNotification.removeDeliveredNotifications(identifiers);
if (Platform.OS === 'ios') {
Notifications.ios.removeDeliveredNotifications(identifiers);
}
};
export const setApplicationIconBadgeNumber = (badges: number) => {
PushNotification.setApplicationIconBadgeNumber(badges);
if (Platform.OS === 'ios') {
Notifications.ios.setBadgeCount(badges);
}
};
export const removeAllDeliveredNotifications = () => {
PushNotification.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;
}
Notifications.removeAllDeliveredNotifications();
};
export const isNotificationsEnabled = async () => {
@ -619,25 +759,22 @@ export const getStoredNotifications = async (): Promise<TPayload[]> => {
// on app launch (load module):
export const initializeNotifications = async (onProcessNotifications?: () => void) => {
console.debug('initializeNotifications: Starting initialization');
console.log('initializeNotifications: Starting initialization');
try {
const noAndDontAskFlag = await AsyncStorage.getItem(NOTIFICATIONS_NO_AND_DONT_ASK_FLAG);
console.debug('initializeNotifications: No ask flag status:', noAndDontAskFlag);
console.log('initializeNotifications: No ask flag status:', noAndDontAskFlag);
if (noAndDontAskFlag === 'true') {
console.warn('User has opted out of notifications.');
return;
}
const baseUriStored = await AsyncStorage.getItem(GROUNDCONTROL_BASE_URI);
baseURI = baseUriStored || groundControlUri;
console.debug('Base URI set to:', baseURI);
setApplicationIconBadgeNumber(0);
// Only check permissions, never request
currentPermissionStatus = await checkNotificationPermissionStatus();
console.debug('initializeNotifications: Permission status:', currentPermissionStatus);
console.log('initializeNotifications: Permission status:', currentPermissionStatus);
// Handle Android 13+ permissions differently
const canProceed =
@ -646,23 +783,12 @@ export const initializeNotifications = async (onProcessNotifications?: () => voi
: currentPermissionStatus === 'granted';
if (canProceed) {
console.debug('initializeNotifications: Can proceed with notification setup');
const token = await getPushToken();
if (token) {
console.debug('initializeNotifications: Existing token found, configuring');
await configureNotifications(onProcessNotifications);
await postTokenConfig();
} else {
console.debug('initializeNotifications: No token found, will request permissions');
await tryToObtainPermissions();
}
console.log('initializeNotifications: Can proceed with notification setup');
await configureNotifications(onProcessNotifications);
} else {
console.debug('Notifications require user action to enable');
console.log('Notifications require user action to enable');
}
} 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));
}
};

4
blue_modules/pako/dist/pako.esm.mjs vendored Normal file
View File

@ -0,0 +1,4 @@
import * as pako from '../index.js';
export * from '../index.js';
export default pako;

View File

@ -1,7 +1,26 @@
import { Dimensions, Platform, AppState, AppStateStatus } from 'react-native';
import { useState, useEffect } from 'react';
import { AppState, AppStateStatus, Dimensions, NativeEventEmitter, NativeModules, Platform } from 'react-native';
import { useEffect, useState } from 'react';
import { isDesktop } from './environment';
type NativeSizeClassPayload = {
horizontal?: number;
vertical?: number;
sizeClass?: number;
orientation?: string;
isLargeScreen?: boolean;
};
const sizeClassNativeModule = NativeModules.SizeClassEmitter as
| {
getCurrentSizeClass?: () => Promise<NativeSizeClassPayload>;
addListener: (eventType: string) => any;
removeListeners: (count: number) => void;
}
| undefined;
const sizeClassNativeEmitter = sizeClassNativeModule ? new NativeEventEmitter(sizeClassNativeModule) : null;
const NATIVE_EVENT_NAME = 'sizeClassDidChange';
// Size class definitions following iOS conventions
export enum SizeClass {
Compact, // Small size (iPhone width or height in landscape)
@ -29,64 +48,40 @@ export interface SizeClassInfo {
isLargeScreen: boolean;
}
/**
* Get current size class information based on device dimensions
*/
export function getSizeClass(): SizeClassInfo {
// Get device dimensions
const normalizeOrientation = (orientation?: string): 'portrait' | 'landscape' => (orientation === 'landscape' ? 'landscape' : 'portrait');
const coerceSizeClassValue = (value?: number): SizeClass => {
if (value === SizeClass.Compact || value === SizeClass.Regular || value === SizeClass.Large) {
return value;
}
return SizeClass.Regular;
};
const calculateFromDimensions = (): SizeClassInfo => {
const { width, height } = Dimensions.get('window');
const isLandscape = width > height;
const orientation = isLandscape ? 'landscape' : 'portrait';
// Determine horizontal size class (following iOS conventions)
let horizontalSizeClass: SizeClass;
const horizontalSizeClass =
Platform.OS === 'ios' && Platform.isPad
? SizeClass.Regular
: isDesktop
? SizeClass.Large
: isLandscape && width >= 667
? SizeClass.Regular
: SizeClass.Compact;
if (Platform.OS === 'ios' && Platform.isPad) {
// iPads always have Regular width
horizontalSizeClass = SizeClass.Regular;
} else if (isDesktop) {
// Desktop systems get Large width
horizontalSizeClass = SizeClass.Large;
} else if (isLandscape && width >= 667) {
// iPhone Plus models (and modern equivalent sizes) in landscape: Regular width
// 667 points corresponds roughly to iPhone Plus models
horizontalSizeClass = SizeClass.Regular;
} else {
// Regular iPhones: Compact width
horizontalSizeClass = SizeClass.Compact;
}
const verticalSizeClass =
Platform.OS === 'ios' && Platform.isPad
? SizeClass.Regular
: isDesktop
? SizeClass.Large
: isLandscape
? SizeClass.Compact
: SizeClass.Regular;
// Determine vertical size class (following iOS conventions)
let verticalSizeClass: SizeClass;
if (Platform.OS === 'ios' && Platform.isPad) {
// iPads always have Regular height
verticalSizeClass = SizeClass.Regular;
} else if (isDesktop) {
// Desktop systems get Large height
verticalSizeClass = SizeClass.Large;
} else if (isLandscape) {
// All iPhones in landscape: Compact height
verticalSizeClass = SizeClass.Compact;
} else {
// iPhones in portrait: Regular height
verticalSizeClass = SizeClass.Regular;
}
// Derive overall size class - simplified logic to avoid redundant comparisons
let sizeClass: SizeClass;
if (horizontalSizeClass === SizeClass.Compact) {
// If width is compact, overall is compact
sizeClass = SizeClass.Compact;
} else {
// Otherwise, width is Regular or Large, so overall is Large
// (per requirements that any non-Compact width device is considered Large)
sizeClass = SizeClass.Large;
}
// Determine isLargeScreen property (true for Regular and Large widths)
const isLargeScreen = horizontalSizeClass !== SizeClass.Compact;
const sizeClass = coerceSizeClassValue(horizontalSizeClass);
const isLargeScreen = sizeClass === SizeClass.Large;
return {
horizontalSizeClass,
@ -97,43 +92,126 @@ export function getSizeClass(): SizeClassInfo {
isLarge: sizeClass === SizeClass.Large,
isLargeScreen,
};
};
const normalizeNativePayload = (payload?: NativeSizeClassPayload | null): SizeClassInfo | null => {
if (!payload) {
return null;
}
const horizontalSizeClass = coerceSizeClassValue(payload.horizontal);
const verticalSizeClass = coerceSizeClassValue(payload.vertical);
const sizeClass = coerceSizeClassValue(payload.sizeClass);
const isLargeScreen = payload.isLargeScreen ?? sizeClass === SizeClass.Large;
const orientation = normalizeOrientation(payload.orientation);
return {
horizontalSizeClass,
verticalSizeClass,
sizeClass,
orientation,
isCompact: sizeClass === SizeClass.Compact,
isLarge: sizeClass === SizeClass.Large,
isLargeScreen,
};
};
let cachedSizeClassInfo: SizeClassInfo = calculateFromDimensions();
let nativeInitRequested = false;
const fetchNativeSizeClass = async (): Promise<SizeClassInfo | null> => {
if (!sizeClassNativeModule?.getCurrentSizeClass) {
return null;
}
try {
const result = await sizeClassNativeModule.getCurrentSizeClass();
return normalizeNativePayload(result);
} catch (error) {
console.debug('[SizeClass] Failed to read native size class', error);
return null;
}
};
/**
* Get current size class information.
*/
export function getSizeClass(): SizeClassInfo {
if (!sizeClassNativeModule) {
cachedSizeClassInfo = calculateFromDimensions();
} else if (!nativeInitRequested) {
nativeInitRequested = true;
fetchNativeSizeClass().then(nativeInfo => {
if (nativeInfo) {
cachedSizeClassInfo = nativeInfo;
}
});
}
return cachedSizeClassInfo;
}
/**
* React hook to use size classes in components
*/
export function useSizeClass(): SizeClassInfo {
const [sizeClassInfo, setSizeClassInfo] = useState<SizeClassInfo>(getSizeClass());
const [sizeClassInfo, setSizeClassInfo] = useState<SizeClassInfo>(cachedSizeClassInfo);
useEffect(() => {
// Update size class when dimensions change
const updateSizeClass = () => {
const newInfo = getSizeClass();
setSizeClassInfo(newInfo);
let isMounted = true;
const applySizeClass = (info: SizeClassInfo) => {
if (!isMounted) return;
cachedSizeClassInfo = info;
setSizeClassInfo(info);
console.debug(
`[SizeClass] Updated:`,
`horizontal=${SizeClass[newInfo.horizontalSizeClass]}`,
`vertical=${SizeClass[newInfo.verticalSizeClass]}`,
`orientation=${newInfo.orientation}`,
`isLargeScreen=${newInfo.isLargeScreen}`,
`horizontal=${SizeClass[info.horizontalSizeClass]}`,
`vertical=${SizeClass[info.verticalSizeClass]}`,
`orientation=${info.orientation}`,
`isLargeScreen=${info.isLargeScreen}`,
);
};
const dimensionSubscription = Dimensions.addEventListener('change', updateSizeClass);
const updateFromDimensions = () => {
const calculated = calculateFromDimensions();
applySizeClass(calculated);
};
// Also update when app becomes active
const handleAppStateChange = (nextAppState: AppStateStatus) => {
if (nextAppState === 'active') {
updateSizeClass();
const requestNativeUpdate = async () => {
const nativeInfo = await fetchNativeSizeClass();
if (nativeInfo) {
applySizeClass(nativeInfo);
}
};
const appStateSubscription = AppState.addEventListener('change', handleAppStateChange);
const dimensionSubscription = Dimensions.addEventListener('change', () => {
updateFromDimensions();
requestNativeUpdate();
});
const appStateSubscription = AppState.addEventListener('change', (nextAppState: AppStateStatus) => {
if (nextAppState === 'active') {
requestNativeUpdate();
}
});
const nativeSubscription = sizeClassNativeEmitter?.addListener(NATIVE_EVENT_NAME, (payload: NativeSizeClassPayload) => {
const normalized = normalizeNativePayload(payload);
if (normalized) {
applySizeClass(normalized);
}
});
// Kick off an initial native fetch to override the heuristic when available.
requestNativeUpdate();
// Clean up
return () => {
isMounted = false;
dimensionSubscription.remove();
appStateSubscription.remove();
nativeSubscription?.remove();
};
}, []);

View File

@ -1,6 +1,6 @@
import { Platform } from 'react-native';
import { BlueApp as BlueAppClass } from '../class/';
import { BlueApp as BlueAppClass } from '../class/blue-app';
import prompt from '../helpers/prompt';
import { showKeychainWipeAlert } from '../hooks/useBiometrics';
import loc from '../loc';
@ -23,7 +23,7 @@ export const startAndDecrypt = async (retry?: boolean, passwordPrompt?: Password
password = await passwordPrompt();
} else {
do {
password = await prompt((retry && loc._.bad_password) || loc._.enter_password, loc._.storage_is_encrypted, false);
password = await prompt((retry && loc._.bad_password) || loc._.enter_password, loc._.storage_is_encrypted, { cancelable: false });
} while (!password);
}
}

View File

@ -0,0 +1,41 @@
// 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';
}
}

View File

@ -13,7 +13,8 @@ import AsyncStorage from '@react-native-async-storage/async-storage';
import { Psbt } from 'bitcoinjs-lib';
import b58 from 'bs58check';
import { MultisigCosigner, MultisigHDWallet } from '../../class';
import { MultisigCosigner } from '../../class/multisig-cosigner';
import { MultisigHDWallet } from '../../class/wallets/multisig-hd-wallet';
import { joinQRs } from '../bbqr/join';
import {
concatUint8Arrays,

View File

@ -147,11 +147,10 @@ export class BlueApp {
console.warn('error reading', key, error.message);
console.warn('fallback to realm');
const realmKeyValue = await this.openRealmKeyValue();
const obj = realmKeyValue.objectForPrimaryKey('KeyValue', key); // search for a realm object with a primary key
const obj = realmKeyValue.objectForPrimaryKey<{ key: string; value: string }>('KeyValue', 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;
}
@ -547,10 +546,11 @@ export class BlueApp {
(walletToInflate._txs_by_internal_index[tx.index] as Transaction[]).push(transaction);
}
} else {
if (!Array.isArray(walletToInflate._txs_by_external_index)) walletToInflate._txs_by_external_index = [];
walletToInflate._txs_by_external_index = walletToInflate._txs_by_external_index || [];
// 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] || [];
const transaction = JSON.parse(tx.tx);
(walletToInflate._txs_by_external_index as Transaction[]).push(transaction);
walletToInflate._txs_by_external_index[0].push(transaction);
}
}
}
@ -559,32 +559,6 @@ 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
@ -592,16 +566,14 @@ export class BlueApp {
realm.delete(walletTransactionsToDelete);
// insert new ones:
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 [indexStr, txs] of Object.entries(walletToSave._txs_by_external_index)) {
for (const tx of txs) {
realm.create(
'WalletTransactions',
{
walletid: id,
internal: false,
index: parseInt(index, 10),
index: parseInt(indexStr, 10),
tx: JSON.stringify(tx),
},
Realm.UpdateMode.Modified,
@ -609,16 +581,14 @@ export class BlueApp {
}
}
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 [indexStr, txs] of Object.entries(walletToSave._txs_by_internal_index)) {
for (const tx of txs) {
realm.create(
'WalletTransactions',
{
walletid: id,
internal: true,
index: parseInt(index, 10),
index: parseInt(indexStr, 10),
tx: JSON.stringify(tx),
},
Realm.UpdateMode.Modified,
@ -756,10 +726,12 @@ export class BlueApp {
}
}
} else {
for (const wallet of this.wallets) {
console.log('fetching balance for', wallet.getLabel());
await wallet.fetchBalance();
}
await Promise.all(
this.wallets.map(async wallet => {
console.log('fetching balance for', wallet.getLabel());
await wallet.fetchBalance();
}),
);
}
};
@ -788,13 +760,15 @@ export class BlueApp {
}
}
} else {
for (const wallet of this.wallets) {
await wallet.fetchTransactions();
if ('fetchPendingTransactions' in wallet) {
await wallet.fetchPendingTransactions();
await wallet.fetchUserInvoices();
}
}
await Promise.all(
this.wallets.map(async wallet => {
await wallet.fetchTransactions();
if ('fetchPendingTransactions' in wallet) {
await wallet.fetchPendingTransactions();
await wallet.fetchUserInvoices();
}
}),
);
}
};
@ -809,14 +783,16 @@ export class BlueApp {
console.error('Failed to fetch sender payment codes for wallet', index, error);
}
} else {
for (const wallet of this.wallets) {
try {
if (!(wallet.allowBIP47() && wallet.isBIP47Enabled() && 'fetchBIP47SenderPaymentCodes' in wallet)) continue;
await wallet.fetchBIP47SenderPaymentCodes();
} catch (error) {
console.error('Failed to fetch sender payment codes for wallet', wallet.label, error);
}
}
await Promise.all(
this.wallets.map(async wallet => {
try {
if (!(wallet.allowBIP47() && wallet.isBIP47Enabled() && 'fetchBIP47SenderPaymentCodes' in wallet)) return;
await wallet.fetchBIP47SenderPaymentCodes();
} catch (error) {
console.error('Failed to fetch sender payment codes for wallet', wallet.label, error);
}
}),
);
}
};

View File

@ -3,7 +3,7 @@ import * as bitcoin from 'bitcoinjs-lib';
import URL from 'url';
import { readFileOutsideSandbox } from '../blue_modules/fs';
import { Chain } from '../models/bitcoinUnits';
import { WatchOnlyWallet } from './';
import { WatchOnlyWallet } from './wallets/watch-only-wallet';
import Azteco from './azteco';
import Lnurl from './lnurl';
import type { TWallet } from './wallets/types';

View File

@ -390,7 +390,7 @@ export class HDSegwitBech32Transaction {
}
}
// @ts-ignore stfu
return { tx, inputs, outputs, fee };
// 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! };
}
}

View File

@ -1,22 +0,0 @@
export * from './blue-app';
export * from './hd-segwit-bech32-transaction';
export * from './multisig-cosigner';
export * from './wallets/abstract-hd-wallet';
export * from './wallets/abstract-wallet';
export * from './wallets/hd-aezeed-wallet';
export * from './wallets/hd-legacy-breadwallet-wallet';
export * from './wallets/hd-legacy-electrum-seed-p2pkh-wallet';
export * from './wallets/hd-legacy-p2pkh-wallet';
export * from './wallets/hd-segwit-bech32-wallet';
export * from './wallets/hd-segwit-electrum-seed-p2wpkh-wallet';
export * from './wallets/hd-segwit-p2sh-wallet';
export * from './wallets/hd-taproot-wallet';
export * from './wallets/legacy-wallet';
export * from './wallets/lightning-custodian-wallet';
export * from './wallets/lightning-ark-wallet';
export * from './wallets/multisig-hd-wallet';
export * from './wallets/segwit-bech32-wallet';
export * from './wallets/segwit-p2sh-wallet';
export * from './wallets/slip39-wallets';
export * from './wallets/taproot-wallet';
export * from './wallets/watch-only-wallet';

View File

@ -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 CryptoJS from 'crypto-js';
import { cbc } from '@noble/ciphers/aes';
import ecc from '../blue_modules/noble_ecc';
import { parse } from 'url'; // eslint-disable-line n/no-deprecated-api
import { fetch } from '../util/fetch';
@ -321,13 +321,24 @@ export default class Lnurl {
}
static decipherAES(ciphertextBase64: string, preimageHex: string, ivBase64: string): string {
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);
// 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 '';
}
}
getCommentAllowed(): number | false {

View File

@ -106,23 +106,31 @@ export class MultisigCosigner {
this._valid = false;
}
// is it coldcard json?
// is it coldcard / unchained json?
try {
const json = JSON.parse(data);
if (json.p2sh && json.p2sh_deriv && json.xfp) {
const cc = new MultisigCosigner(MultisigCosigner.exportToJson(json.xfp, json.p2sh, json.p2sh_deriv));
// 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));
this._valid = true;
this._cosigners.push(cc);
}
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));
if (xpub && path && json.xfp) {
const cc = new MultisigCosigner(MultisigCosigner.exportToJson(json.xfp, xpub, path));
this._valid = true;
this._cosigners.push(cc);
}
if (json.p2wsh && json.p2wsh_deriv && json.xfp) {
const cc = new MultisigCosigner(MultisigCosigner.exportToJson(json.xfp, json.p2wsh, json.p2wsh_deriv));
if (json.p2wsh && p2wsh_deriv && json.xfp) {
const cc = new MultisigCosigner(MultisigCosigner.exportToJson(json.xfp, json.p2wsh, p2wsh_deriv));
this._valid = true;
this._cosigners.push(cc);
}

View File

@ -11,7 +11,7 @@
* @return {Promise.<Uint8Array>} The random bytes
*/
export async function randomBytes(size: number): Promise<Uint8Array> {
const g: any = globalThis as any;
const g = globalThis as any;
const rnCrypto = g && g.crypto;
if (!rnCrypto || typeof rnCrypto.getRandomValues !== 'function') {
throw new Error('crypto.getRandomValues is not available');

View File

@ -1,4 +1,3 @@
import { useTheme } from '../components/themes';
import { HDAezeedWallet } from './wallets/hd-aezeed-wallet';
import { HDLegacyBreadwalletWallet } from './wallets/hd-legacy-breadwallet-wallet';
import { HDLegacyElectrumSeedP2PKHWallet } from './wallets/hd-legacy-electrum-seed-p2pkh-wallet';
@ -30,8 +29,7 @@ export default class WalletGradient {
static aezeedWallet: string[] = ['#8584FF', '#5351FB'];
static createWallet = () => {
const { colors } = useTheme();
return colors.lightButton;
return WalletGradient.defaultGradients[0];
};
static gradientsFor(type: string): string[] {

View File

@ -2,27 +2,23 @@ import bip38 from 'bip38';
import wif from 'wif';
import loc from '../loc';
import {
HDAezeedWallet,
HDLegacyBreadwalletWallet,
HDLegacyElectrumSeedP2PKHWallet,
HDLegacyP2PKHWallet,
HDSegwitBech32Wallet,
HDSegwitElectrumSeedP2WPKHWallet,
HDSegwitP2SHWallet,
HDTaprootWallet,
LegacyWallet,
LightningCustodianWallet,
LightningArkWallet,
MultisigHDWallet,
SegwitBech32Wallet,
SegwitP2SHWallet,
SLIP39LegacyP2PKHWallet,
SLIP39SegwitBech32Wallet,
SLIP39SegwitP2SHWallet,
TaprootWallet,
WatchOnlyWallet,
} from '.';
import { HDAezeedWallet } from './wallets/hd-aezeed-wallet';
import { HDLegacyBreadwalletWallet } from './wallets/hd-legacy-breadwallet-wallet';
import { HDLegacyElectrumSeedP2PKHWallet } from './wallets/hd-legacy-electrum-seed-p2pkh-wallet';
import { HDLegacyP2PKHWallet } from './wallets/hd-legacy-p2pkh-wallet';
import { HDSegwitBech32Wallet } from './wallets/hd-segwit-bech32-wallet';
import { HDSegwitElectrumSeedP2WPKHWallet } from './wallets/hd-segwit-electrum-seed-p2wpkh-wallet';
import { HDSegwitP2SHWallet } from './wallets/hd-segwit-p2sh-wallet';
import { HDTaprootWallet } from './wallets/hd-taproot-wallet';
import { LegacyWallet } from './wallets/legacy-wallet';
import { LightningCustodianWallet } from './wallets/lightning-custodian-wallet';
import { LightningArkWallet } from './wallets/lightning-ark-wallet';
import { MultisigHDWallet } from './wallets/multisig-hd-wallet';
import { SegwitBech32Wallet } from './wallets/segwit-bech32-wallet';
import { SegwitP2SHWallet } from './wallets/segwit-p2sh-wallet';
import { SLIP39LegacyP2PKHWallet, SLIP39SegwitBech32Wallet, SLIP39SegwitP2SHWallet } from './wallets/slip39-wallets';
import { TaprootWallet } from './wallets/taproot-wallet';
import { WatchOnlyWallet } from './wallets/watch-only-wallet';
import bip39WalletFormatsElectrum from './bip39_wallet_formats.json'; // https://github.com/spesmilo/electrum/blob/master/electrum/bip39_wallet_formats.json
import bip39WalletFormatsBlueWallet from './bip39_wallet_formats_bluewallet.json';
import type { TWallet } from './wallets/types';
@ -220,10 +216,35 @@ const startImport = (
if (text.startsWith('arkade://')) {
const ark = new LightningArkWallet();
ark.setSecret(text);
await ark.init();
// 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.
if (!offline) {
await ark.fetchBalance();
await ark.fetchTransactions();
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);
}
}
yield { wallet: ark };
}
@ -323,6 +344,7 @@ const startImport = (
}
yield { progress: 'wif' };
const segwitWallet = new SegwitP2SHWallet();
segwitWallet.setSecret(text);
if (segwitWallet.getAddress()) {
@ -386,6 +408,89 @@ const startImport = (
yield { wallet: legacyWallet };
}
yield { progress: 'Private key in hex/base64' };
// check if text is in hex or base64 format
const isHexKey = /^[0-9a-fA-F]{64}$/.test(text);
const isBase64Key = /^[A-Za-z0-9+/=]{43,44}$/.test(text);
let rawKeyBuffer;
let privateKey;
if (isHexKey) {
rawKeyBuffer = Buffer.from(text, 'hex');
} else if (isBase64Key) {
rawKeyBuffer = Buffer.from(text, 'base64');
}
if (rawKeyBuffer && rawKeyBuffer.length === 32) {
let walletFound = false;
// convert the bytes to Wallet import format, 0x80 for mainnet,
// start with uncompressed p2pkh
privateKey = wif.encode(0x80, rawKeyBuffer, false);
yield { progress: 'p2pkh uncompressed' };
const legacyWalletUncompressed = new LegacyWallet('Legacy (P2PKH) - Uncompressed');
legacyWalletUncompressed.setSecret(privateKey);
if (await wasUsed(legacyWalletUncompressed)) {
await fetch(legacyWalletUncompressed, true);
walletFound = true;
yield { wallet: legacyWalletUncompressed };
}
// compressed is true for other wallet types
privateKey = wif.encode(0x80, rawKeyBuffer, true);
yield { progress: 'p2wpkh' };
const segwitBech32Wallet = new SegwitBech32Wallet();
segwitBech32Wallet.setSecret(privateKey);
if (await wasUsed(segwitBech32Wallet)) {
await fetch(segwitBech32Wallet, true);
walletFound = true;
yield { wallet: segwitBech32Wallet };
}
yield { progress: 'p2tr' };
const taprootWallet = new TaprootWallet();
taprootWallet.setSecret(privateKey);
if (await wasUsed(taprootWallet)) {
await fetch(taprootWallet, true);
walletFound = true;
yield { wallet: taprootWallet };
}
yield { progress: 'p2wpkh-p2sh' };
segwitWallet.setSecret(privateKey);
if (await wasUsed(segwitWallet)) {
await fetch(segwitWallet, true);
walletFound = true;
yield { wallet: segwitWallet };
}
yield { progress: 'p2pkh compressed' };
const legacyWalletCompressed = new LegacyWallet('Legacy (P2PKH) - Compressed');
legacyWalletCompressed.setSecret(privateKey);
if (await wasUsed(legacyWalletCompressed)) {
await fetch(legacyWalletCompressed, true);
walletFound = true;
yield { wallet: legacyWalletCompressed };
}
if (!walletFound) {
yield { wallet: segwitBech32Wallet };
yield { wallet: segwitWallet };
yield { wallet: legacyWalletCompressed };
yield { wallet: taprootWallet };
yield { wallet: legacyWalletUncompressed };
}
}
// maybe its a watch-only address?
yield { progress: 'watch only' };
const wo1 = new WatchOnlyWallet();

View File

@ -45,9 +45,7 @@ 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[];
@ -158,6 +156,15 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet {
return ret;
}
getBalanceForExternalIndex(index: number): number {
const bal = this._balances_by_external_index[index];
return (bal?.c || 0) + (bal?.u || 0);
}
getTransactionCountForExternalIndex(index: number): number {
return this._txs_by_external_index[index]?.length ?? 0;
}
async generate() {
const buf = await randomBytes(16);
this.secret = bip39.entropyToMnemonic(uint8ArrayToHex(buf));
@ -195,70 +202,37 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet {
return child.toWIF();
}
_getNodeAddressByIndex(node: number, index: number): string {
index = index * 1; // cast to int
_getNodeByIndex(node: 0 | 1, index: number): BIP32Interface {
const cachedNode = node === 0 ? this._node0 : this._node1;
if (cachedNode) {
return cachedNode.derive(index);
}
const xpub = this._zpubToXpub(this.getXpub());
const hdNode = bip32.fromBase58(xpub).derive(node);
if (node === 0) {
if (this.external_addresses_cache[index]) return this.external_addresses_cache[index]; // cache hit
}
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));
this._node0 = hdNode;
} else {
// tbh the only possible else is node === 1
// @ts-ignore
address = this._hdNodeToAddress(this._node1.derive(index));
this._node1 = hdNode;
}
if (node === 0) {
return (this.external_addresses_cache[index] = address);
} else {
// tbh the only possible else option is node === 1
return (this.internal_addresses_cache[index] = address);
}
return hdNode.derive(index);
}
_getNodePubkeyByIndex(node: number, index: number) {
index = index * 1; // cast to int
_getNodeAddressByIndex(node: 0 | 1, index: number): string {
const cache = node === 0 ? this.external_addresses_cache : this.internal_addresses_cache;
if (node === 0 && !this._node0) {
const xpub = this._zpubToXpub(this.getXpub());
const hdNode = bip32.fromBase58(xpub);
this._node0 = hdNode.derive(node);
}
if (cache[index]) return cache[index]; // cache hit
if (node === 1 && !this._node1) {
const xpub = this._zpubToXpub(this.getXpub());
const hdNode = bip32.fromBase58(xpub);
this._node1 = hdNode.derive(node);
}
const hdNode = this._getNodeByIndex(node, index);
const address = this._hdNodeToAddress(hdNode);
if (node === 0 && this._node0) {
return this._node0.derive(index).publicKey;
}
return (cache[index] = address);
}
if (node === 1 && this._node1) {
return this._node1.derive(index).publicKey;
}
throw new Error('Internal error: this._node0 or this._node1 is undefined');
_getNodePubkeyByIndex(node: 0 | 1, index: number) {
return this._getNodeByIndex(node, index).publicKey;
}
_getExternalAddressByIndex(index: number): string {
@ -415,137 +389,95 @@ 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++) {
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);
}
}
}
externalIndexByAddress.set(this._getExternalAddressByIndex(c), c);
}
const internalIndexByAddress = new Map<string, number>();
for (let c = 0; c < next_free_change_address_index + this.gap_limit; 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);
}
}
}
internalIndexByAddress.set(this._getInternalAddressByIndex(c), c);
}
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++) {
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 */,
};
paymentCodeIndexByAddress.set(this._getBIP47AddressReceive(pc, c), { pc, c });
}
}
// 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);
}
}
// 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);
}
};
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]);
}
}
@ -592,8 +524,7 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet {
const ret: Transaction[] = [];
for (const tx of txs) {
tx.timestamp = tx.blocktime;
if (!tx.blocktime) tx.timestamp = Math.floor(+new Date() / 1000) - 30; // unconfirmed
tx.timestamp = tx.blocktime || Math.floor(+new Date() / 1000) - 30; // fallback for unconfirmed
tx.confirmations = tx.confirmations || 0; // unconfirmed
tx.hash = tx.txid;
tx.value = 0;
@ -644,8 +575,7 @@ 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));
// @ts-ignore
if (this.constructor._getTransactionsFromHistories(histories).length > 0) {
if (AbstractHDElectrumWallet._getTransactionsFromHistories(histories).length > 0) {
// in this particular chunk we have used addresses
lastChunkWithUsedAddressesNum = c;
lastHistoriesWithUsedAddresses = histories;
@ -687,8 +617,7 @@ 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));
// @ts-ignore
if (this.constructor._getTransactionsFromHistories(histories).length > 0) {
if (AbstractHDElectrumWallet._getTransactionsFromHistories(histories).length > 0) {
// in this particular chunk we have used addresses
lastChunkWithUsedAddressesNum = c;
lastHistoriesWithUsedAddresses = histories;
@ -730,8 +659,7 @@ 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));
// @ts-ignore
if (this.constructor._getTransactionsFromHistories(histories).length > 0) {
if (AbstractHDElectrumWallet._getTransactionsFromHistories(histories).length > 0) {
// in this particular chunk we have used addresses
lastChunkWithUsedAddressesNum = c;
lastHistoriesWithUsedAddresses = histories;

View File

@ -315,7 +315,7 @@ export class AbstractHDWallet extends LegacyWallet {
throw new Error('Not implemented');
}
_getNodePubkeyByIndex(node: number, index: number): Uint8Array | undefined {
_getNodePubkeyByIndex(node: 0 | 1, index: number): Uint8Array | undefined {
throw new Error('Not implemented');
}

View File

@ -136,6 +136,14 @@ export class AbstractWallet {
return BitcoinUnit.BTC;
}
setPreferredBalanceUnit(unit: BitcoinUnit): void {
if (Object.values(BitcoinUnit).includes(unit)) {
this.preferredBalanceUnit = unit;
return;
}
this.preferredBalanceUnit = BitcoinUnit.BTC;
}
async allowOnchainAddress(): Promise<boolean> {
throw new Error('allowOnchainAddress: Not implemented');
}

View File

@ -8,7 +8,7 @@ import { ECPairAPI, ECPairFactory, Signer } from 'ecpair';
import * as BlueElectrum from '../../blue_modules/BlueElectrum';
import ecc from '../../blue_modules/noble_ecc';
import { hexToUint8Array, concatUint8Arrays } from '../../blue_modules/uint8array-extras';
import { HDSegwitBech32Wallet } from '..';
import type { HDSegwitBech32Wallet as HDSegwitBech32WalletT } from './hd-segwit-bech32-wallet';
import { randomBytes } from '../rng';
import { AbstractWallet } from './abstract-wallet';
import { CreateTransactionResult, CreateTransactionTarget, CreateTransactionUtxo, Transaction, Utxo } from './types';
@ -21,14 +21,20 @@ bitcoin.initEccLib(ecc);
*/
export class LegacyWallet extends AbstractWallet {
static readonly type = 'legacy';
static readonly typeReadable = 'Legacy (P2PKH)';
static readonly defaultTypeReadable = 'Legacy (P2PKH)';
// @ts-ignore: override
public readonly type = LegacyWallet.type;
// @ts-ignore: override
public readonly typeReadable = LegacyWallet.typeReadable;
public readonly typeReadable: string;
_txs_by_external_index: Transaction[] = [];
_txs_by_internal_index: Transaction[] = [];
_txs_by_external_index: Record<number, Transaction[]> = {};
_txs_by_internal_index: Record<number, Transaction[]> = {};
constructor(typeReadable?: string) {
super();
this.typeReadable = typeReadable ?? LegacyWallet.defaultTypeReadable;
}
/**
* Simple function which says that we havent tried to fetch balance
@ -338,15 +344,18 @@ export class LegacyWallet extends AbstractWallet {
}
}
this._txs_by_external_index = _txsByExternalIndex;
this._txs_by_external_index = { 0: _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;
};
const hd = new HDSegwitBech32Wallet();
return hd.getTransactions.apply(this);
}

File diff suppressed because it is too large Load Diff

View File

@ -104,6 +104,11 @@ 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;
@ -123,10 +128,11 @@ export type Transaction = {
locktime: number;
inputs: TransactionInput[];
outputs: TransactionOutput[];
blockhash: string;
confirmations: number;
time: number;
blocktime: number;
// Confirmation-only fields: absent on mempool (unconfirmed) responses.
blockhash?: string;
confirmations?: number;
time?: number;
blocktime?: number;
timestamp: number; // seconds, not milliseconds
value?: number;

View File

@ -197,12 +197,13 @@ export class WatchOnlyWallet extends LegacyWallet {
async fetchUtxo() {
if (this._hdWalletInstance) return this._hdWalletInstance.fetchUtxo();
throw new Error('Not initialized');
// Single-address watch-only uses LegacyWallet UTXO + derivation from txs (no HD instance).
return super.fetchUtxo();
}
getUtxo(...args: Parameters<THDWalletForWatchOnly['getUtxo']>) {
if (this._hdWalletInstance) return this._hdWalletInstance.getUtxo(...args);
throw new Error('Not initialized');
return super.getUtxo(...args);
}
combinePsbt(...args: Parameters<THDWalletForWatchOnly['combinePsbt']>) {

View File

@ -0,0 +1,14 @@
import type { TurboModule } from 'react-native';
import { TurboModuleRegistry } from 'react-native';
import type { Double, UnsafeObject } from 'react-native/Libraries/Types/CodegenTypes';
export interface Spec extends TurboModule {
addListener(eventName: string): void;
removeListeners(count: Double): void;
getMostRecentUserActivity(): Promise<UnsafeObject | null>;
}
const moduleProxy = TurboModuleRegistry.getEnforcing<Spec>('EventEmitter');
export default moduleProxy;

View File

@ -0,0 +1,18 @@
import type { TurboModule } from 'react-native';
import { TurboModuleRegistry } from 'react-native';
import type { Double } from 'react-native/Libraries/Types/CodegenTypes';
export interface Spec extends TurboModule {
addListener(eventName: string): void;
removeListeners(count: Double): void;
openSettings(): void;
addWalletMenuAction(): void;
importWalletMenuAction(): void;
reloadTransactionsMenuAction(): void;
sharedInstance?(): void;
}
const moduleProxy = TurboModuleRegistry.getEnforcing<Spec>('MenuElementsEmitter');
export default moduleProxy;

View File

@ -0,0 +1,17 @@
import { TurboModuleRegistry } from 'react-native';
import type { TurboModule } from 'react-native';
export interface Spec extends TurboModule {
initializeDeviceUID(): Promise<string>;
getDeviceUID(): Promise<string | null>;
getDeviceUIDCopy(): Promise<string>;
setClearFilesOnLaunch(value: boolean): Promise<boolean>;
getClearFilesOnLaunch(): Promise<boolean>;
setDoNotTrack(enabled: boolean): Promise<boolean>;
getDoNotTrack(): Promise<boolean>;
openSettings(): Promise<boolean>;
}
const nativeModule = TurboModuleRegistry.get<Spec>('SettingsModule');
export default nativeModule;

View File

@ -0,0 +1,10 @@
import type { TurboModule } from 'react-native';
import { TurboModuleRegistry } from 'react-native';
export interface Spec extends TurboModule {
reloadAllWidgets(): void;
}
const moduleProxy = TurboModuleRegistry.getEnforcing<Spec>('WidgetHelper');
export default moduleProxy;

Some files were not shown because too many files have changed in this diff Show More