diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
index 0000000000..3e95b55e9b
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,520 @@
+name: Build
+
+on:
+ pull_request:
+
+permissions:
+ contents: read
+
+jobs:
+ build-macos:
+ name: Build macOS artifact
+ runs-on: macos-14
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ submodules: recursive
+
+ - name: Set up Flutter
+ uses: subosito/flutter-action@v2
+ with:
+ flutter-version: "3.41.9"
+ channel: stable
+ cache: true
+
+ - name: Set up Rust
+ uses: dtolnay/rust-toolchain@stable
+
+ - name: Install Rust toolchains required by project
+ run: rustup install 1.85.1 1.81.0
+
+ - name: Install macOS build dependencies
+ run: |
+ brew update
+ brew install automake cmake cocoapods libtool pkg-config
+
+ - name: Generate app config files from templates
+ run: |
+ source scripts/env.sh
+ source scripts/app_config/templates/configure_template_files.sh
+ source scripts/app_config/configure_campfire.sh macos
+ scripts/app_config/platforms/macos/platform_config.sh
+ scripts/app_config/shared/link_assets.sh campfire macos
+
+ - name: Set CI app version in pubspec
+ run: |
+ source scripts/env.sh
+ scripts/app_config/shared/update_version.sh -v "0.0.0" -b "${{ github.run_number }}"
+
+ - name: Generate Android app icons and splash assets
+ run: |
+ source scripts/env.sh
+ scripts/app_config/shared/asset_generators.sh android
+
+ - name: Ensure Android launcher icons exist
+ shell: bash
+ run: |
+ set -euxo pipefail
+ ICON_SOURCE="asset_sources/icon/campfire/icon.png"
+ test -f "${ICON_SOURCE}"
+ for d in mipmap-mdpi mipmap-hdpi mipmap-xhdpi mipmap-xxhdpi mipmap-xxxhdpi; do
+ mkdir -p "android/app/src/main/res/${d}"
+ cp "${ICON_SOURCE}" "android/app/src/main/res/${d}/ic_launcher.png"
+ cp "${ICON_SOURCE}" "android/app/src/main/res/${d}/ic_launcher_round.png"
+ done
+
+ - name: Generate local template secrets
+ run: |
+ cd scripts
+ ./prebuild.sh
+
+ - name: Build macOS crypto plugins
+ run: |
+ cd scripts/macos
+ ./build_all_campfire.sh
+
+ - name: Resolve Dart and CocoaPods dependencies
+ run: |
+ flutter pub get
+ cd macos
+ pod install
+
+ - name: Build macOS app
+ run: flutter build macos --release
+
+ - name: Pack macOS artifact
+ run: |
+ APP_PATH="$(ls -d build/macos/Build/Products/Release/*.app | head -n 1)"
+ ditto -c -k --sequesterRsrc --keepParent "${APP_PATH}" campfire-macos-app.zip
+
+ - name: Upload macOS artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: campfire-macos
+ path: campfire-macos-app.zip
+ if-no-files-found: error
+
+ build-windows:
+ name: Build Windows artifact
+ runs-on: windows-latest
+ timeout-minutes: 120
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ submodules: recursive
+ fetch-depth: 0
+
+ - name: Setup Flutter
+ uses: subosito/flutter-action@v2
+ with:
+ flutter-version: "3.41.9"
+ channel: stable
+ cache: true
+
+ - name: Init submodules
+ shell: bash
+ run: |
+ set -euxo pipefail
+ git submodule sync --recursive
+ git submodule update --init --recursive
+
+ - name: Disable frostdart DLL install in CI
+ shell: bash
+ run: |
+ set -euxo pipefail
+ if [ -f crypto_plugins/frostdart/windows/CMakeLists.txt ]; then
+ sed -i '/frostdart\.dll/d' crypto_plugins/frostdart/windows/CMakeLists.txt
+ fi
+
+ - name: Generate Campfire config + generated files
+ shell: bash
+ run: |
+ set -euxo pipefail
+ source scripts/env.sh
+ export BUILD_ISAR_FROM_SOURCE=0
+
+ (cd scripts && bash prebuild.sh)
+ source scripts/app_config/templates/configure_template_files.sh
+ bash scripts/app_config/shared/update_version.sh -v 0.0.0 -b "${{ github.run_number }}"
+ source scripts/app_config/configure_campfire.sh windows
+ bash scripts/app_config/platforms/windows/platform_config.sh
+
+ - name: Prepare assets for Campfire
+ shell: pwsh
+ run: |
+ $dirs = @("default_themes","icon","lottie","in_app_logo_icons","svg")
+ foreach ($d in $dirs) {
+ $src = "asset_sources/$d/campfire"
+ $dst = "assets/$d"
+ if (!(Test-Path $src)) { throw "Missing source assets directory: $src" }
+ if (Test-Path $dst) { Remove-Item $dst -Recurse -Force }
+ Copy-Item $src $dst -Recurse -Force
+ }
+
+ - name: Flutter clean
+ run: flutter clean
+
+ - name: Flutter pub get
+ run: flutter pub get
+
+ - name: Build secp256k1.dll (Release)
+ shell: pwsh
+ run: |
+ $ErrorActionPreference = "Stop"
+
+ New-Item -ItemType Directory -Force -Path build | Out-Null
+ Push-Location build
+
+ if (-not (Test-Path "secp256k1")) {
+ git clone https://github.com/bitcoin-core/secp256k1
+ }
+
+ Push-Location secp256k1
+ git checkout 68b55209f1ba3e6c0417789598f5f75649e9c14c
+ git reset --hard
+
+ cmake -G "Visual Studio 17 2022" -A x64 -S . -B build
+ cmake --build build --config Release
+
+ $dll = Join-Path (Get-Location).Path "build\bin\Release\libsecp256k1-2.dll"
+ if (-not (Test-Path $dll)) { throw "Missing Release DLL: $dll" }
+
+ $here = (Get-Location).Path
+ $repoBuildDir = Split-Path -Path $here -Parent
+ $repoRootDir = Split-Path -Path $repoBuildDir -Parent
+
+ Copy-Item -LiteralPath $dll -Destination (Join-Path $repoBuildDir "secp256k1.dll") -Force
+ Copy-Item -LiteralPath $dll -Destination (Join-Path $repoRootDir "secp256k1.dll") -Force
+
+ Pop-Location
+ Pop-Location
+
+ if (-not (Test-Path "build/secp256k1.dll")) { throw "build/secp256k1.dll missing" }
+ if (-not (Test-Path "secp256k1.dll")) { throw "secp256k1.dll missing in repo root" }
+
+ - name: Build Windows release
+ run: flutter build windows --release -v
+
+ - name: Copy secp256k1.dll into final Release
+ shell: pwsh
+ run: |
+ $ErrorActionPreference = "Stop"
+
+ $release = "build/windows/x64/runner/Release"
+ if (-not (Test-Path $release)) { $release = "build/windows/runner/Release" }
+ if (-not (Test-Path $release)) { throw "Release directory not found" }
+
+ if (-not (Test-Path "build/secp256k1.dll")) { throw "build/secp256k1.dll not found" }
+
+ Copy-Item "build/secp256k1.dll" (Join-Path $release "secp256k1.dll") -Force
+
+ - name: Verify release
+ shell: pwsh
+ run: |
+ $ErrorActionPreference = "Stop"
+
+ $release = "build/windows/x64/runner/Release"
+ if (-not (Test-Path $release)) { $release = "build/windows/runner/Release" }
+ if (-not (Test-Path $release)) { throw "Release directory missing" }
+
+ if (!(Test-Path (Join-Path $release "secp256k1.dll"))) {
+ throw "secp256k1.dll missing in Release"
+ }
+
+ Get-ChildItem $release -Force
+
+ - name: Zip release
+ shell: pwsh
+ run: |
+ $release = "build/windows/x64/runner/Release"
+ if (-not (Test-Path $release)) { $release = "build/windows/runner/Release" }
+ Compress-Archive -Path (Join-Path $release "*") -DestinationPath campfire-windows.zip -Force
+
+ - name: Upload Windows artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: campfire-windows
+ path: campfire-windows.zip
+ if-no-files-found: error
+
+ build-linux:
+ name: Build Linux artifact
+ runs-on: ubuntu-24.04
+ timeout-minutes: 120
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ submodules: recursive
+
+ - name: Set up Flutter
+ uses: subosito/flutter-action@v2
+ with:
+ flutter-version: "3.41.9"
+ channel: stable
+ cache: true
+
+ - name: Set up Rust
+ uses: dtolnay/rust-toolchain@stable
+
+ - name: Install Rust toolchains required by project
+ run: rustup install 1.85.1 1.81.0
+
+ - name: Install Linux build dependencies
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y \
+ clang cmake ninja-build pkg-config libgtk-3-dev liblzma-dev \
+ meson python3-pip libgirepository1.0-dev valac xsltproc docbook-xsl \
+ libssl-dev curl unzip automake build-essential file git python3 \
+ libtool libtinfo6 libgit2-dev libncurses5-dev libncursesw5-dev \
+ zlib1g-dev llvm g++ gcc gperf libopencv-dev python3-typogrify \
+ libgcrypt20-dev libsecret-1-dev
+
+ - name: Generate app config files from templates
+ shell: bash
+ run: |
+ set -euxo pipefail
+ source scripts/env.sh
+ export BUILD_ISAR_FROM_SOURCE=0
+ source scripts/app_config/templates/configure_template_files.sh
+ source scripts/app_config/configure_campfire.sh linux
+ scripts/app_config/platforms/linux/platform_config.sh
+ scripts/app_config/shared/link_assets.sh campfire linux
+
+ - name: Set CI app version in pubspec
+ run: |
+ source scripts/env.sh
+ scripts/app_config/shared/update_version.sh -v "0.0.0" -b "${{ github.run_number }}"
+
+ - name: Generate iOS app icons and splash assets
+ run: |
+ source scripts/env.sh
+ scripts/app_config/shared/asset_generators.sh ios
+
+ - name: Generate local template secrets
+ run: |
+ cd scripts
+ ./prebuild.sh
+
+ - name: Build Linux crypto plugins
+ run: |
+ cd scripts/linux
+ ./build_all_campfire.sh
+
+ - name: Resolve Dart dependencies
+ run: flutter pub get
+
+ - name: Build Linux app
+ run: flutter build linux --release
+
+ - name: Pack Linux artifact
+ run: |
+ tar -C build/linux/x64/release -czf campfire-linux.tar.gz bundle
+
+ - name: Upload Linux artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: campfire-linux
+ path: campfire-linux.tar.gz
+ if-no-files-found: error
+
+ build-android:
+ name: Build Android artifact
+ runs-on: ubuntu-24.04
+ timeout-minutes: 120
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ submodules: recursive
+
+ - name: Set up Java
+ uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: "17"
+
+ - name: Set up Flutter
+ uses: subosito/flutter-action@v2
+ with:
+ flutter-version: "3.41.9"
+ channel: stable
+ cache: true
+
+ - name: Set up Rust
+ uses: dtolnay/rust-toolchain@stable
+
+ - name: Install Rust toolchains required by project
+ run: rustup install 1.85.1 1.81.0
+
+ - name: Generate app config files from templates
+ shell: bash
+ run: |
+ set -euxo pipefail
+ source scripts/env.sh
+ export BUILD_ISAR_FROM_SOURCE=0
+ source scripts/app_config/templates/configure_template_files.sh
+ source scripts/app_config/configure_campfire.sh android
+ scripts/app_config/platforms/android/platform_config.sh
+ scripts/app_config/shared/link_assets.sh campfire android
+
+ - name: Set CI app version in pubspec
+ run: |
+ source scripts/env.sh
+ scripts/app_config/shared/update_version.sh -v "0.0.0" -b "${{ github.run_number }}"
+
+ - name: Generate Android app icons and splash assets
+ run: |
+ source scripts/env.sh
+ scripts/app_config/shared/asset_generators.sh android
+
+ - name: Ensure Android launcher icons exist
+ shell: bash
+ run: |
+ set -euxo pipefail
+ ICON_SOURCE="asset_sources/icon/campfire/icon.png"
+ test -f "${ICON_SOURCE}"
+ for d in mipmap-mdpi mipmap-hdpi mipmap-xhdpi mipmap-xxhdpi mipmap-xxxhdpi; do
+ mkdir -p "android/app/src/main/res/${d}"
+ cp "${ICON_SOURCE}" "android/app/src/main/res/${d}/ic_launcher.png"
+ cp "${ICON_SOURCE}" "android/app/src/main/res/${d}/ic_launcher_round.png"
+ done
+
+ - name: Generate local template secrets
+ run: |
+ cd scripts
+ ./prebuild.sh
+
+ - name: Build Android crypto plugins
+ run: |
+ cd scripts/android
+ ./build_all_campfire.sh
+
+ - name: Resolve Dart dependencies
+ run: flutter pub get
+
+ - name: Set up Android local.properties
+ run: |
+ printf 'sdk.dir=%s\nflutter.sdk=%s\n' "${ANDROID_SDK_ROOT}" "${FLUTTER_ROOT}" > android/local.properties
+
+ - name: Build Android artifacts (release if secrets exist, else debug)
+ env:
+ KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
+ ANDROID_STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }}
+ ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
+ ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
+ run: |
+ if [ -n "${KEYSTORE_BASE64:-}" ]; then
+ echo "$KEYSTORE_BASE64" | base64 --decode > android/keystore.jks
+ printf '%s\n' \
+ 'storeFile=../keystore.jks' \
+ "storePassword=${ANDROID_STORE_PASSWORD}" \
+ "keyPassword=${ANDROID_KEY_PASSWORD}" \
+ "keyAlias=${ANDROID_KEY_ALIAS}" \
+ > android/key.properties
+ flutter build apk --split-per-abi --release
+ flutter build appbundle --release
+ else
+ flutter build apk --debug
+ fi
+
+ - name: Collect Android artifacts
+ shell: bash
+ run: |
+ set -euxo pipefail
+ mkdir -p android-artifacts
+ cp -f build/app/outputs/flutter-apk/*.apk android-artifacts/ || true
+ cp -f build/app/outputs/bundle/release/*.aab android-artifacts/ || true
+ ls -la android-artifacts
+
+ - name: Upload Android artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: campfire-android
+ path: android-artifacts/
+ if-no-files-found: error
+
+ build-ios:
+ name: Build iOS artifact
+ runs-on: macos-14
+ timeout-minutes: 120
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ submodules: recursive
+
+ - name: Set up Flutter
+ uses: subosito/flutter-action@v2
+ with:
+ flutter-version: "3.41.9"
+ channel: stable
+ cache: true
+
+ - name: Set up Rust
+ uses: dtolnay/rust-toolchain@stable
+
+ - name: Install Rust toolchains required by project
+ run: rustup install 1.85.1 1.81.0
+
+ - name: Install iOS build dependencies
+ run: |
+ brew update
+ brew install cocoapods
+ rustup target add aarch64-apple-ios x86_64-apple-ios
+
+ - name: Generate app config files from templates
+ shell: bash
+ run: |
+ set -euxo pipefail
+ source scripts/env.sh
+ export BUILD_ISAR_FROM_SOURCE=0
+ source scripts/app_config/templates/configure_template_files.sh
+ source scripts/app_config/configure_campfire.sh ios
+ scripts/app_config/platforms/ios/platform_config.sh
+ scripts/app_config/shared/link_assets.sh campfire ios
+
+ - name: Set CI app version in pubspec
+ run: |
+ source scripts/env.sh
+ scripts/app_config/shared/update_version.sh -v "0.0.0" -b "${{ github.run_number }}"
+
+ - name: Generate local template secrets
+ run: |
+ cd scripts
+ ./prebuild.sh
+
+ - name: Build iOS crypto plugins
+ run: |
+ cd scripts/ios
+ ./build_all_campfire.sh
+
+ - name: Resolve Dart dependencies and CocoaPods
+ run: |
+ flutter pub get
+ cd ios
+ pod install
+
+ - name: Build iOS app (no codesign)
+ run: flutter build ios --release --no-codesign
+
+ - name: Package iOS artifact
+ run: |
+ mkdir Payload
+ cp -r build/ios/iphoneos/Runner.app Payload/
+ zip -r campfire-ios.ipa Payload/
+
+ - name: Upload iOS artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: campfire-ios
+ path: campfire-ios.ipa
+ if-no-files-found: error
diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml
index 622387ba1e..121c2edde3 100644
--- a/.github/workflows/test.yaml
+++ b/.github/workflows/test.yaml
@@ -5,9 +5,6 @@ jobs:
runs-on: ubuntu-24.04
container:
image: stackwallet/stackwallet-ci:latest
- credentials:
- username: ${{ secrets.DOCKERHUB_USERNAME }}
- password: ${{ secrets.DOCKERHUB_TOKEN }}
steps:
- name: Prepare repository
uses: actions/checkout@v6
@@ -36,11 +33,24 @@ jobs:
String getPluginVersion() => "stub-for-tests";
EOF
- - name: Decode secrets
+ - name: Prepare external API keys for tests
env:
CHANGE_NOW: ${{ secrets.CHANGE_NOW }}
run: |
- echo "$CHANGE_NOW" | base64 --decode > lib/external_api_keys.dart
+ if [ -n "${CHANGE_NOW:-}" ]; then
+ echo "$CHANGE_NOW" | base64 --decode > lib/external_api_keys.dart
+ else
+ printf '%s\n' \
+ 'const kChangeNowApiKey = "";' \
+ 'const kSimpleSwapApiKey = "";' \
+ 'const kNanswapApiKey = "";' \
+ 'const kNanoSwapRpcApiKey = "";' \
+ 'const kWizSwapApiKey = "";' \
+ 'const kShopInBitAccessKey = "";' \
+ 'const kShopInBitPartnerSecret = "";' \
+ 'const kCakePayApiToken = "";' \
+ > lib/external_api_keys.dart
+ fi
- name: Ensure app config for tests
run: bash scripts/ensure_test_app_config.sh
diff --git a/README.md b/README.md
index 70bf3f836f..e4b60d449b 100644
--- a/README.md
+++ b/README.md
@@ -1,44 +1,23 @@
-[](https://codecov.io/gh/cypherstack/stack_wallet)
-# Stack Wallet
-Stack Wallet is a fully open source cryptocurrency wallet. With an easy to use user interface and quick and speedy transactions, this wallet is ideal for anyone no matter how much they know about the cryptocurrency space. The app is actively maintained to provide new user friendly features.
+# Campfire Wallet
+Campfire is a fuly open source Firo-only wallet. With full Spark support, an easy to use user interface and quick and speedy sync times, this wallet is the ideal first experience to Firo.
-
+Campfire is a fork of Stack wallet, a multi-currency wallet developed by [Cypher Stack](https://cypherstack.com/) that also has Firo support.
+
+
## Feature List
Highlights include:
-- 23 Different cryptocurrencies:
- - [Bitcoin](https://bitcoin.org/en/)
- - Bitcoin Frost
- - [Bitcoin Cash](https://bch.info/en/)
- - [Banano](https://banano.cc/)
- - [Cardano](https://cardano.org/)
- - [Dash](https://www.dash.org/)
- - [Dogecoin](https://dogecoin.com/)
- - [Epic Cash](https://linktr.ee/epiccash)
- - [MimbleWimbleCoin](https://mwc.mw)
- - [Ethereum](https://ethereum.org/en/)
- - [Ecash](https://e.cash/)
- - [Fact0rn](https://www.fact0rn.io/)
- - [Firo](https://firo.org/)
- - [Litecoin](https://litecoin.org/)
- - [Monero](https://www.getmonero.org/)
- - [Nano](https://nano.org/)
- - [Namecoin](https://www.namecoin.org/)
- - [Particl](https://particl.io/)
- - [Peercoin](https://www.peercoin.net/)
- - [Salvium](https://salvium.io/)
- - [Solana](https://solana.com/)
- - [Stellar](https://stellar.org/)
- - [Tezos](https://tezos.com/)
- - [Wownero](https://wownero.org/)
- - [Xelis](https://xelis.org/)
+
+- Full Lelantus Spark support
+- EX address support
+- Masternode collateral recognition
+-
- All private keys and seeds stay on device and are never shared.
- Easy backup and restore feature to save all the information that's important to you.
-- Trading cryptocurrencies through our partners.
- Custom address book
- Favorite wallets with fast syncing
- Custom Nodes.
diff --git a/lib/pages/send_view/send_view.dart b/lib/pages/send_view/send_view.dart
index 94b5663c82..4048fa4754 100644
--- a/lib/pages/send_view/send_view.dart
+++ b/lib/pages/send_view/send_view.dart
@@ -159,7 +159,6 @@ class _SendViewState extends ConsumerState {
try {
// auto fill address
_address = paymentData.address.trim();
- sendToController.text = _address!;
// autofill notes field
if (paymentData.message != null) {
@@ -179,7 +178,25 @@ class _SendViewState extends ConsumerState {
ref.read(pSendAmount.notifier).state = amount;
}
+ // Extract OP_RETURN data if present (for Rosen Bridge and other protocols)
+ // Must be set BEFORE sendToController.text to avoid re-entrant
+ // onChanged handler reading stale null value.
+ if (paymentData.additionalParams.containsKey('op_return')) {
+ final data = paymentData.additionalParams['op_return'];
+ ref.read(pOpReturnData.notifier).state = data;
+ Logging.instance.i(
+ "Extracted OP_RETURN data from URI, length: ${data!.length ~/ 2} bytes",
+ );
+ } else {
+ ref.read(pOpReturnData.notifier).state = null;
+ }
+
_setValidAddressProviders(_address);
+
+ // Assign controller.text last — it triggers onChanged which depends
+ // on pOpReturnData already being set above.
+ sendToController.text = _address!;
+
setState(() {
_addressToggleFlag = sendToController.text.isNotEmpty;
});
@@ -919,6 +936,7 @@ class _SendViewState extends ConsumerState {
selectedUTXOs.isNotEmpty)
? selectedUTXOs
: null,
+ opReturnData: ref.read(pOpReturnData),
),
);
} else if (wallet is FiroWallet) {
@@ -960,6 +978,7 @@ class _SendViewState extends ConsumerState {
utxos: (coinControlEnabled && selectedUTXOs.isNotEmpty)
? selectedUTXOs
: null,
+ opReturnData: ref.read(pOpReturnData),
),
);
}
@@ -1131,6 +1150,7 @@ class _SendViewState extends ConsumerState {
memoController.text = "";
_address = "";
_addressToggleFlag = false;
+ ref.read(pOpReturnData.notifier).state = null;
if (mounted) {
setState(() {});
}
@@ -1720,9 +1740,10 @@ class _SendViewState extends ConsumerState {
final trimmed = newValue.trim();
if ((trimmed.length -
- (_address?.length ?? 0))
- .abs() >
- 1) {
+ (_address?.length ?? 0))
+ .abs() >
+ 1 ||
+ trimmed.contains(':')) {
final parsed =
AddressUtils.parsePaymentUri(
trimmed,
@@ -1731,6 +1752,8 @@ class _SendViewState extends ConsumerState {
if (parsed != null) {
_applyUri(parsed);
} else {
+ ref.read(pOpReturnData.notifier).state =
+ null;
await _checkSparkNameAndOrSetAddress(
newValue,
);
@@ -1943,6 +1966,38 @@ class _SendViewState extends ConsumerState {
),
),
),
+ if (ref.watch(pOpReturnData) != null &&
+ _address != null &&
+ _address!.isNotEmpty &&
+ (ref.watch(pValidSendToAddress) ||
+ ref.watch(pValidSparkSendToAddress)) &&
+ balType == BalanceType.public)
+ Align(
+ alignment: Alignment.topLeft,
+ child: Padding(
+ padding: const EdgeInsets.only(
+ left: 12.0,
+ top: 4.0,
+ ),
+ child: Tooltip(
+ message: AddressUtils.formatOpReturnTooltip(
+ ref.watch(pOpReturnData)!,
+ ),
+ child: Text(
+ "Transaction includes metadata "
+ "(${ref.watch(pOpReturnData)!.length ~/ 2} bytes) "
+ "\u2014 tap for details",
+ textAlign: TextAlign.left,
+ style: STextStyles.label(context)
+ .copyWith(
+ color: Theme.of(context)
+ .extension()!
+ .accentColorGreen,
+ ),
+ ),
+ ),
+ ),
+ ),
Builder(
builder: (_) {
final String? error;
@@ -2660,16 +2715,42 @@ class _SendViewState extends ConsumerState {
),
const Spacer(),
const SizedBox(height: 12),
+ if (ref.watch(pOpReturnData) != null &&
+ balType == BalanceType.private)
+ Padding(
+ padding: const EdgeInsets.only(
+ left: 12.0,
+ right: 12.0,
+ bottom: 12.0,
+ ),
+ child: Text(
+ "Bridge data detected but Spark (private) "
+ "transactions cannot carry OP_RETURN data. "
+ "Switch to public balance to complete the "
+ "bridge transaction.",
+ textAlign: TextAlign.left,
+ style: STextStyles.label(context).copyWith(
+ color: Theme.of(
+ context,
+ ).extension()!.textError,
+ ),
+ ),
+ ),
TextButton(
onPressed:
- ref.watch(pPreviewTxButtonEnabled(coin))
+ ref.watch(pPreviewTxButtonEnabled(coin)) &&
+ (ref.watch(pOpReturnData) == null ||
+ balType != BalanceType.private)
? isMwcSlatepack
? _createSlatepack
: isEpicSlatepack
? _createEpicSlatepack
: _previewTransaction
: null,
- style: ref.watch(pPreviewTxButtonEnabled(coin))
+ style:
+ ref.watch(pPreviewTxButtonEnabled(coin)) &&
+ (ref.watch(pOpReturnData) == null ||
+ balType != BalanceType.private)
? Theme.of(context)
.extension()!
.getPrimaryEnabledButtonStyle(context)
diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart
index b8dc85f4d7..6854650be9 100644
--- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart
+++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart
@@ -646,6 +646,7 @@ class _DesktopSendState extends ConsumerState {
ref.read(pDesktopUseUTXOs).isNotEmpty)
? ref.read(pDesktopUseUTXOs)
: null,
+ opReturnData: ref.read(pOpReturnData),
),
);
}
@@ -915,8 +916,11 @@ class _DesktopSendState extends ConsumerState {
if (paymentData != null &&
paymentData.coin?.uriScheme == coin.uriScheme) {
+ ref.read(pOpReturnData.notifier).state =
+ paymentData.additionalParams['op_return'];
_applyUri(paymentData);
} else {
+ ref.read(pOpReturnData.notifier).state = null;
_address = qrCodeData.split("\n").first.trim();
sendToController.text = _address ?? "";
@@ -1045,8 +1049,11 @@ class _DesktopSendState extends ConsumerState {
);
if (paymentData != null &&
paymentData.coin?.uriScheme == coin.uriScheme) {
+ ref.read(pOpReturnData.notifier).state =
+ paymentData.additionalParams['op_return'];
_applyUri(paymentData);
} else {
+ ref.read(pOpReturnData.notifier).state = null;
if (coin is Epiccash) {
content = AddressUtils().formatEpicCashAddress(content);
}
@@ -1063,6 +1070,7 @@ class _DesktopSendState extends ConsumerState {
});
}
} catch (e) {
+ ref.read(pOpReturnData.notifier).state = null;
// If parsing fails, treat it as a plain address.
if (coin is Epiccash) {
// strip http:// and https:// if content contains @
@@ -1748,14 +1756,18 @@ class _DesktopSendState extends ConsumerState {
onChanged: (newValue) async {
final trimmed = newValue;
- if ((trimmed.length - (_address?.length ?? 0)).abs() > 1) {
+ if ((trimmed.length - (_address?.length ?? 0)).abs() > 1 ||
+ trimmed.contains(':')) {
final parsed = AddressUtils.parsePaymentUri(
trimmed,
logging: Logging.instance,
);
if (parsed != null) {
+ ref.read(pOpReturnData.notifier).state =
+ parsed.additionalParams['op_return'];
_applyUri(parsed);
} else {
+ ref.read(pOpReturnData.notifier).state = null;
await _checkSparkNameAndOrSetAddress(newValue);
}
} else {
@@ -1809,6 +1821,8 @@ class _DesktopSendState extends ConsumerState {
onTap: () {
sendToController.text = "";
_address = "";
+ ref.read(pOpReturnData.notifier).state =
+ null;
_setValidAddressProviders(_address);
setState(() {
_addressToggleFlag = false;
@@ -1954,6 +1968,66 @@ class _DesktopSendState extends ConsumerState {
}
},
),
+ // OP_RETURN metadata info (green, public mode only, with tooltip)
+ Builder(
+ builder: (context) {
+ final opData = ref.watch(pOpReturnData);
+ final balType = ref.watch(publicPrivateBalanceStateProvider);
+ if (opData == null ||
+ opData.isEmpty ||
+ balType != BalanceType.public) {
+ return Container();
+ }
+ return Align(
+ alignment: Alignment.topLeft,
+ child: Padding(
+ padding: const EdgeInsets.only(left: 12.0, top: 4.0),
+ child: Tooltip(
+ message: AddressUtils.formatOpReturnTooltip(opData),
+ child: Text(
+ "Transaction includes metadata "
+ "(${opData.length ~/ 2} bytes)",
+ textAlign: TextAlign.left,
+ style: STextStyles.label(context).copyWith(
+ color: Theme.of(
+ context,
+ ).extension()!.accentColorGreen,
+ ),
+ ),
+ ),
+ ),
+ );
+ },
+ ),
+ // OP_RETURN bridge warning (red, private mode only)
+ Builder(
+ builder: (context) {
+ final opData = ref.watch(pOpReturnData);
+ final balType = ref.watch(publicPrivateBalanceStateProvider);
+ if (opData == null ||
+ opData.isEmpty ||
+ balType != BalanceType.private) {
+ return Container();
+ }
+ return Align(
+ alignment: Alignment.topLeft,
+ child: Padding(
+ padding: const EdgeInsets.only(left: 12.0, top: 4.0),
+ child: Text(
+ "Bridge data detected but Spark (private) transactions "
+ "cannot carry OP_RETURN data. Switch to public balance "
+ "to complete the bridge transaction.",
+ textAlign: TextAlign.left,
+ style: STextStyles.label(context).copyWith(
+ color: Theme.of(
+ context,
+ ).extension()!.textError,
+ ),
+ ),
+ ),
+ );
+ },
+ ),
if (hasOptionalMemo || ref.watch(pValidSparkSendToAddress))
const SizedBox(height: 10),
if (hasOptionalMemo || ref.watch(pValidSparkSendToAddress))
diff --git a/lib/providers/ui/preview_tx_button_state_provider.dart b/lib/providers/ui/preview_tx_button_state_provider.dart
index fcb77fe649..1ad75aa4d9 100644
--- a/lib/providers/ui/preview_tx_button_state_provider.dart
+++ b/lib/providers/ui/preview_tx_button_state_provider.dart
@@ -23,6 +23,8 @@ final pValidSparkSendToAddress = StateProvider.autoDispose((_) => false);
final pIsExchangeAddress = StateProvider((_) => false);
+final pOpReturnData = StateProvider((_) => null);
+
// MWC Transaction Method Provider.
final pSelectedMwcTransactionMethod = StateProvider(
(_) => MwcTransactionMethod.slatepack,
@@ -47,42 +49,44 @@ final pIsSlatepack = Provider.family((ref, walletId) {
return false;
});
-final pPreviewTxButtonEnabled = Provider.autoDispose
- .family((ref, coin) {
- final amount = ref.watch(pSendAmount) ?? Amount.zero;
+final pPreviewTxButtonEnabled = Provider.autoDispose.family(
+ (ref, coin) {
+ final amount = ref.watch(pSendAmount) ?? Amount.zero;
- // For MWC slatepack transactions, address validation is not required.
- if (coin is Mimblewimblecoin) {
- final selectedMethod = ref.watch(pSelectedMwcTransactionMethod);
- if (selectedMethod == MwcTransactionMethod.slatepack) {
- return amount > Amount.zero;
- }
+ // For MWC slatepack transactions, address validation is not required.
+ if (coin is Mimblewimblecoin) {
+ final selectedMethod = ref.watch(pSelectedMwcTransactionMethod);
+ if (selectedMethod == MwcTransactionMethod.slatepack) {
+ return amount > Amount.zero;
}
+ }
- // For Epic Cash slatepack transactions, address validation is not required.
- if (coin is Epiccash) {
- final selectedMethod = ref.watch(pSelectedEpicTransactionMethod);
- if (selectedMethod == EpicTransactionMethod.slatepack) {
- return amount > Amount.zero;
- }
+ // For Epic Cash slatepack transactions, address validation is not required.
+ if (coin is Epiccash) {
+ final selectedMethod = ref.watch(pSelectedEpicTransactionMethod);
+ if (selectedMethod == EpicTransactionMethod.slatepack) {
+ return amount > Amount.zero;
}
+ }
- if (coin is Firo) {
- final firoType = ref.watch(publicPrivateBalanceStateProvider);
- switch (firoType) {
- case BalanceType.private:
- return (ref.watch(pValidSendToAddress) ||
- ref.watch(pValidSparkSendToAddress)) &&
- !ref.watch(pIsExchangeAddress) &&
- amount > Amount.zero;
+ if (coin is Firo) {
+ final firoType = ref.watch(publicPrivateBalanceStateProvider);
+ switch (firoType) {
+ case BalanceType.private:
+ return (ref.watch(pValidSendToAddress) ||
+ ref.watch(pValidSparkSendToAddress)) &&
+ !ref.watch(pIsExchangeAddress) &&
+ ref.watch(pOpReturnData) == null &&
+ amount > Amount.zero;
- case BalanceType.public:
- return ref.watch(pValidSendToAddress) && amount > Amount.zero;
- }
- } else {
- return ref.watch(pValidSendToAddress) && amount > Amount.zero;
+ case BalanceType.public:
+ return ref.watch(pValidSendToAddress) && amount > Amount.zero;
}
- });
+ } else {
+ return ref.watch(pValidSendToAddress) && amount > Amount.zero;
+ }
+ },
+);
final previewTokenTxButtonStateProvider = StateProvider.autoDispose((_) {
return false;
diff --git a/lib/utilities/address_utils.dart b/lib/utilities/address_utils.dart
index ff0880cec7..43e72b6f78 100644
--- a/lib/utilities/address_utils.dart
+++ b/lib/utilities/address_utils.dart
@@ -23,6 +23,7 @@ class AddressUtils {
'tx_payment_id',
'recipient_name',
'tx_description',
+ 'op_return', // For Rosen Bridge and other OP_RETURN protocols.
// TODO [prio=med]: Add more recognized params for other coins.
};
@@ -268,24 +269,85 @@ class AddressUtils {
if ((mimblewimblecoinAddress.startsWith("http://") ||
mimblewimblecoinAddress.startsWith("https://")) &&
mimblewimblecoinAddress.contains("@")) {
- mimblewimblecoinAddress =
- mimblewimblecoinAddress.replaceAll("http://", "");
- mimblewimblecoinAddress =
- mimblewimblecoinAddress.replaceAll("https://", "");
+ mimblewimblecoinAddress = mimblewimblecoinAddress.replaceAll(
+ "http://",
+ "",
+ );
+ mimblewimblecoinAddress = mimblewimblecoinAddress.replaceAll(
+ "https://",
+ "",
+ );
}
// strip mailto: prefix
if (mimblewimblecoinAddress.startsWith("mailto:")) {
- mimblewimblecoinAddress =
- mimblewimblecoinAddress.replaceAll("mailto:", "");
+ mimblewimblecoinAddress = mimblewimblecoinAddress.replaceAll(
+ "mailto:",
+ "",
+ );
}
// strip / suffix if the address contains an @ symbol (and is thus an mwcmqs address)
if (mimblewimblecoinAddress.endsWith("/") &&
mimblewimblecoinAddress.contains("@")) {
mimblewimblecoinAddress = mimblewimblecoinAddress.substring(
- 0, mimblewimblecoinAddress.length - 1);
+ 0,
+ mimblewimblecoinAddress.length - 1,
+ );
}
return mimblewimblecoinAddress;
}
+
+ /// Formats OP_RETURN hex data for display in tooltip.
+ /// If data matches Rosen Bridge format, shows structured fields.
+ /// Otherwise returns the raw hex with a generic description.
+ static String formatOpReturnTooltip(String hex) {
+ // Rosen Bridge OP_RETURN format:
+ // toChain(1B) + bridgeFee(8B) + networkFee(8B) + addrLen(1B) + toAddress(var)
+ const minRosenLen = 36; // minimum 18 bytes
+ if (hex.length < minRosenLen) {
+ return "Raw OP_RETURN data:\n$hex";
+ }
+
+ try {
+ const chains = [
+ 'ergo',
+ 'cardano',
+ 'bitcoin',
+ 'ethereum',
+ 'binance',
+ 'doge',
+ 'bitcoin-runes',
+ 'firo',
+ ];
+
+ final toChainCode = int.parse(hex.substring(0, 2), radix: 16);
+ if (toChainCode >= chains.length) {
+ return "Raw OP_RETURN data:\n$hex";
+ }
+
+ final bridgeFee = BigInt.parse(
+ hex.substring(2, 18),
+ radix: 16,
+ ).toString();
+ final networkFee = BigInt.parse(
+ hex.substring(18, 34),
+ radix: 16,
+ ).toString();
+ final addrLen = int.parse(hex.substring(34, 36), radix: 16);
+ final addrEnd = 36 + addrLen * 2;
+ if (hex.length < addrEnd) {
+ return "Raw OP_RETURN data:\n$hex";
+ }
+ final toAddressHex = hex.substring(36, addrEnd);
+
+ return "Rosen Bridge data\n"
+ " To chain: ${chains[toChainCode]}\n"
+ " Bridge fee: $bridgeFee\n"
+ " Network fee: $networkFee\n"
+ " To address (hex): $toAddressHex";
+ } catch (_) {
+ return "Raw OP_RETURN data:\n$hex";
+ }
+ }
}
class PaymentUriData {
diff --git a/lib/wallets/models/tx_data.dart b/lib/wallets/models/tx_data.dart
index 14c7186f2b..bdff31c709 100644
--- a/lib/wallets/models/tx_data.dart
+++ b/lib/wallets/models/tx_data.dart
@@ -112,6 +112,9 @@ class TxData {
final bool salviumStakeTx;
+ // Generic OP_RETURN data (hex string) - for Rosen Bridge and other protocols
+ final String? opReturnData;
+
TxData({
this.feeRateType,
this.feeRateAmount,
@@ -149,6 +152,7 @@ class TxData {
this.sparkNameInfo,
this.vExtraData,
this.overrideVersion,
+ this.opReturnData,
this.type = TxType.regular,
this.salviumStakeTx = false,
});
@@ -263,6 +267,7 @@ class TxData {
String? noteOnChain,
String? memo,
String? otherData,
+ String? opReturnData,
Set? utxos,
List? usedUTXOs,
List? recipients,
@@ -341,6 +346,7 @@ class TxData {
sparkNameInfo: sparkNameInfo ?? this.sparkNameInfo,
vExtraData: vExtraData ?? this.vExtraData,
overrideVersion: overrideVersion ?? this.overrideVersion,
+ opReturnData: opReturnData ?? this.opReturnData,
type: type ?? this.type,
);
}
@@ -383,6 +389,7 @@ class TxData {
'sparkNameInfo: $sparkNameInfo, '
'vExtraData: ${vExtraData?.toHex}, '
'overrideVersion: $overrideVersion, '
+ 'opReturnData: $opReturnData, '
'type: $type, '
'}';
}
diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart
index e963566b61..91496e752e 100644
--- a/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart
+++ b/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart
@@ -852,6 +852,63 @@ mixin ElectrumXInterface
);
}
+ // Add OP_RETURN output if provided (for Rosen Bridge and other protocols)
+ // Currently only supported for Firo
+ if (cryptoCurrency is Firo &&
+ txData.opReturnData != null &&
+ txData.opReturnData!.isNotEmpty) {
+ try {
+ final opReturnBytes = txData.opReturnData!.toUint8ListFromHex;
+
+ // Validate OP_RETURN size (Bitcoin/Firo limit is 80 bytes)
+ if (opReturnBytes.length > 80) {
+ throw Exception(
+ "OP_RETURN data exceeds 80 byte limit: ${opReturnBytes.length} bytes",
+ );
+ }
+
+ // Encode push data: OP_PUSHDATA1 (0x4c) for 76-80 bytes, direct length otherwise
+ final pushData = opReturnBytes.length <= 75
+ ? Uint8List.fromList([opReturnBytes.length, ...opReturnBytes])
+ : Uint8List.fromList([
+ 0x4c,
+ opReturnBytes.length,
+ ...opReturnBytes,
+ ]);
+
+ final opReturnScript = Uint8List.fromList([
+ 0x6a, // OP_RETURN opcode
+ ...pushData,
+ ]);
+
+ final opReturnOutput = coinlib.Output.fromScriptBytes(
+ BigInt.zero, // OP_RETURN outputs have 0 value
+ opReturnScript,
+ );
+
+ clTx = clTx.addOutput(opReturnOutput);
+
+ Logging.instance.i(
+ "Added OP_RETURN output with ${opReturnBytes.length} bytes of data",
+ );
+
+ tempOutputs.add(
+ OutputV2.isarCantDoRequiredInDefaultConstructor(
+ scriptPubKeyHex: opReturnScript.toHex,
+ valueStringSats: "0",
+ addresses: [],
+ walletOwns: false,
+ ),
+ );
+ } catch (e, s) {
+ Logging.instance.e(
+ "Failed to add OP_RETURN output",
+ error: e,
+ stackTrace: s,
+ );
+ throw Exception("Invalid OP_RETURN data: $e");
+ }
+ }
if (isMweb) {
if (hasNonWitnessInput) {
throw Exception("Found non witness input in mweb tx");
diff --git a/windows/runner/CMakeLists.txt b/windows/runner/CMakeLists.txt
index 17411a8ab8..9be59393bc 100644
--- a/windows/runner/CMakeLists.txt
+++ b/windows/runner/CMakeLists.txt
@@ -1,6 +1,10 @@
cmake_minimum_required(VERSION 3.14)
project(runner LANGUAGES CXX)
+if(NOT DEFINED BINARY_NAME OR "${BINARY_NAME}" STREQUAL "")
+ set(BINARY_NAME "campfire")
+endif()
+
# Define the application target. To change its name, change BINARY_NAME in the
# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer
# work.