What changed, and why it matters
This commit fixes a bug in how Stack Wallet prepares Nano cryptocurrency sends. Previously, the app calculated the remaining balance after a send using a locally cached balance, which could be outdated. It now fetches the live balance from the network and explicitly checks that the user has enough funds. If the cached balance was higher than the real balance, the old code could have created an invalid or over-spending transaction.
Review whether any other wallet interfaces rely on cached balances for live transaction construction, and ensure similar live-balance validation is applied consistently. Confirm the new `parseNanoSendState` helper handles malformed RPC responses gracefully.
Security signals we found
Use of stale cached balance replaced with live on-chain balance for transaction construction
New explicit insufficient-balance check before building a send block
Potential for creating an invalid/overdraft Nano state block when cached balance diverges from live balance
Test added covering both successful live-balance calculation and insufficient-balance exception
Evidence from the diff
The patch refactors Nano send-block construction in nano_interface.dart. Before, balanceAfterTx was derived from info.cachedBalance.spendable.raw minus the transaction amount. After the patch, a new parseNanoSendState() helper parses the live account_info RPC response, validates sendAmount <= liveBalance, and returns balanceAfterSend = liveBalance - sendAmount. The send block now uses this live-derived balance. A unit test verifies the helper uses the live balance and rejects insufficient funds.
Changed components
lib/wallets/wallet/wallet_mixin_interfaces/nano_interface.dartNano send transaction constructiontest/wallets/nano_interface_test.dartInspect captured patch +41 / −16
diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/nano_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/nano_interface.dart
index b08b6bb..1fbe4fb 100644
--- a/lib/wallets/wallet/wallet_mixin_interfaces/nano_interface.dart
+++ b/lib/wallets/wallet/wallet_mixin_interfaces/nano_interface.dart
@@ -37,6 +37,19 @@ Map<String, String> _buildHeaders(String url) {
return result;
}
+({String frontier, String representative, BigInt balanceAfterSend})
+parseNanoSendState(Map<String, dynamic> accountInfo, BigInt sendAmount) {
+ final liveBalance = BigInt.parse(accountInfo["balance"].toString());
+ if (sendAmount > liveBalance) {
+ throw Exception("Insufficient balance");
+ }
+ return (
+ frontier: accountInfo["frontier"].toString(),
+ representative: accountInfo["representative"].toString(),
+ balanceAfterSend: liveBalance - sendAmount,
+ );
+}
+
mixin NanoInterface<T extends NanoCurrency> on Bip39Wallet<T> {
// since nano based coins only have a single address/account we can cache
// the address instead of fetching from db every time we need it in certain
@@ -412,12 +425,6 @@ mixin NanoInterface<T extends NanoCurrency> on Bip39Wallet<T> {
final String publicAddress =
(_cachedAddress ?? await getCurrentReceivingAddress())!.value;
- // first update to get latest account balance:
-
- final currentBalance = info.cachedBalance.spendable;
- final txAmount = txData.amount!;
- final BigInt balanceAfterTx = (currentBalance - txAmount).raw;
-
// get the account info (we need the frontier and representative):
final infoBody = jsonEncode({
"action": "account_info",
@@ -435,12 +442,10 @@ mixin NanoInterface<T extends NanoCurrency> on Bip39Wallet<T> {
: null,
);
- final String frontier = jsonDecode(
- infoResponse.body,
- )["frontier"].toString();
- final String representative = jsonDecode(
- infoResponse.body,
- )["representative"].toString();
+ final accountInfo = Map<String, dynamic>.from(
+ jsonDecode(infoResponse.body) as Map,
+ );
+ final sendState = parseNanoSendState(accountInfo, txData.amount!.raw);
// link = destination address:
final String linkAsAccount = txData.recipients!.first.address;
final String link = NanoAccounts.extractPublicKey(linkAsAccount);
@@ -449,9 +454,9 @@ mixin NanoInterface<T extends NanoCurrency> on Bip39Wallet<T> {
final Map<String, String> sendBlock = {
"type": "state",
"account": publicAddress,
- "previous": frontier,
- "representative": representative,
- "balance": balanceAfterTx.toString(),
+ "previous": sendState.frontier,
+ "representative": sendState.representative,
+ "balance": sendState.balanceAfterSend.toString(),
"link": link,
};
@@ -468,7 +473,7 @@ mixin NanoInterface<T extends NanoCurrency> on Bip39Wallet<T> {
final String signature = NanoSignatures.signBlock(hash, privateKey);
// get PoW for the send block:
- final String? work = await _requestWork(frontier);
+ final String? work = await _requestWork(sendState.frontier);
if (work == null) {
throw Exception("Failed to get PoW for send block");
}
diff --git a/test/wallets/nano_interface_test.dart b/test/wallets/nano_interface_test.dart
new file mode 100644
index 0000000..d80968f
--- /dev/null
+++ b/test/wallets/nano_interface_test.dart
@@ -0,0 +1,20 @@
+import 'package:flutter_test/flutter_test.dart';
+import 'package:stackwallet/wallets/wallet/wallet_mixin_interfaces/nano_interface.dart';
+
+void main() {
+ test('Nano send state uses the live account balance', () {
+ final state = parseNanoSendState({
+ 'frontier': 'frontier',
+ 'representative': 'representative',
+ 'balance': '15',
+ }, BigInt.from(3));
+
+ expect(state.frontier, 'frontier');
+ expect(state.representative, 'representative');
+ expect(state.balanceAfterSend, BigInt.from(12));
+ expect(
+ () => parseNanoSendState({'balance': '2'}, BigInt.from(3)),
+ throwsException,
+ );
+ });
+}
Why this scored 56/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.