Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
520 changes: 520 additions & 0 deletions .github/workflows/build.yml

Large diffs are not rendered by default.

20 changes: 15 additions & 5 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
41 changes: 10 additions & 31 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,44 +1,23 @@
[![codecov](https://codecov.io/gh/cypherstack/stack_wallet/branch/main/graph/badge.svg?token=PM1N56UTEW)](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.

<a href="https://play.google.com/store/apps/details?id=com.cypherstack.stackwallet">
Campfire is a fork of Stack wallet, a multi-currency wallet developed by [Cypher Stack](https://cypherstack.com/) that also has Firo support.

<a href="[https://play.google.com/store/apps/details?id=com.cypherstack.stackwallet](https://play.google.com/store/apps/details?id=com.cypherstack.campfire)">
<img width="250px" src="https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png"></img>
</a>

## 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.
Expand Down
93 changes: 87 additions & 6 deletions lib/pages/send_view/send_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,6 @@ class _SendViewState extends ConsumerState<SendView> {
try {
// auto fill address
_address = paymentData.address.trim();
sendToController.text = _address!;

// autofill notes field
if (paymentData.message != null) {
Expand All @@ -179,7 +178,25 @@ class _SendViewState extends ConsumerState<SendView> {
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;
});
Expand Down Expand Up @@ -919,6 +936,7 @@ class _SendViewState extends ConsumerState<SendView> {
selectedUTXOs.isNotEmpty)
? selectedUTXOs
: null,
opReturnData: ref.read(pOpReturnData),
),
);
} else if (wallet is FiroWallet) {
Expand Down Expand Up @@ -960,6 +978,7 @@ class _SendViewState extends ConsumerState<SendView> {
utxos: (coinControlEnabled && selectedUTXOs.isNotEmpty)
? selectedUTXOs
: null,
opReturnData: ref.read(pOpReturnData),
),
);
}
Expand Down Expand Up @@ -1131,6 +1150,7 @@ class _SendViewState extends ConsumerState<SendView> {
memoController.text = "";
_address = "";
_addressToggleFlag = false;
ref.read(pOpReturnData.notifier).state = null;
if (mounted) {
setState(() {});
}
Expand Down Expand Up @@ -1720,9 +1740,10 @@ class _SendViewState extends ConsumerState<SendView> {
final trimmed = newValue.trim();

if ((trimmed.length -
(_address?.length ?? 0))
.abs() >
1) {
(_address?.length ?? 0))
.abs() >
1 ||
trimmed.contains(':')) {
final parsed =
AddressUtils.parsePaymentUri(
trimmed,
Expand All @@ -1731,6 +1752,8 @@ class _SendViewState extends ConsumerState<SendView> {
if (parsed != null) {
_applyUri(parsed);
} else {
ref.read(pOpReturnData.notifier).state =
null;
await _checkSparkNameAndOrSetAddress(
newValue,
);
Expand Down Expand Up @@ -1943,6 +1966,38 @@ class _SendViewState extends ConsumerState<SendView> {
),
),
),
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<StackColors>()!
.accentColorGreen,
),
),
),
),
),
Builder(
builder: (_) {
final String? error;
Expand Down Expand Up @@ -2660,16 +2715,42 @@ class _SendViewState extends ConsumerState<SendView> {
),
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<StackColors>()!.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<StackColors>()!
.getPrimaryEnabledButtonStyle(context)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -646,6 +646,7 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
ref.read(pDesktopUseUTXOs).isNotEmpty)
? ref.read(pDesktopUseUTXOs)
: null,
opReturnData: ref.read(pOpReturnData),
),
);
}
Expand Down Expand Up @@ -915,8 +916,11 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {

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 ?? "";

Expand Down Expand Up @@ -1045,8 +1049,11 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
);
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);
}
Expand All @@ -1063,6 +1070,7 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
});
}
} 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 @
Expand Down Expand Up @@ -1748,14 +1756,18 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
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 {
Expand Down Expand Up @@ -1809,6 +1821,8 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
onTap: () {
sendToController.text = "";
_address = "";
ref.read(pOpReturnData.notifier).state =
null;
_setValidAddressProviders(_address);
setState(() {
_addressToggleFlag = false;
Expand Down Expand Up @@ -1954,6 +1968,66 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
}
},
),
// 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<StackColors>()!.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<StackColors>()!.textError,
),
),
),
);
},
),
if (hasOptionalMemo || ref.watch(pValidSparkSendToAddress))
const SizedBox(height: 10),
if (hasOptionalMemo || ref.watch(pValidSparkSendToAddress))
Expand Down
Loading
Loading