fix: solana wallet bugs and security issues (#3484)

* fix android CI

* fix: duplicate outgoing tx for jup swaps and stuck pending state

* fix solana security risk by handling duplicate token symbols in wallet transactions

* feat: implement additional cost handling for pending transactions in Solana wallet.

* fix: Items on security audit list for solana wallet

* refactor: streamline fee payer index handling and improve error logging

* fix: verifySignature for solana and handle wrong mint on default token

* fix: token decimals defaulting to zero and breaking amount parsing in solana

* fix: use token mask in tx history

* refactor: apply lint to modified code

* fix: merge conflicts

* fix: use Money and mint decimals when parsing sol swaps

* test: add unit tests for SPL token amount handling and parsing

* test: add more tests

* fix: update decimal handling for fetched token and remove unused fields
This commit is contained in:
David Adegoke
2026-08-24 23:21:29 +03:00
committed by GitHub
parent f97e09cfc5
commit 703cc22b33
52 changed files with 687 additions and 373 deletions
+6
View File
@@ -55,6 +55,12 @@ class SignSPLTokenTransactionRentException implements Exception {}
class NoAssociatedTokenAccountException implements Exception {}
class AmbiguousTokenSymbolException implements Exception {
AmbiguousTokenSymbolException(this.symbol);
final String symbol;
}
class RestoreFromSeedException implements Exception {
final String message;
+1
View File
@@ -12,6 +12,7 @@ mixin PendingTransaction {
Money get amount;
Money get fee;
Money? get additionalCost => null;
String get amountFormatted;
String get feeFormatted => fee.toStringWithSymbol(fractionalDigits: 8);
+31 -28
View File
@@ -3,7 +3,6 @@ import "package:cw_core/db/sqlite.dart";
import "package:sqflite/sqflite.dart";
class SPLToken extends CryptoCurrency {
SPLToken({
required this.name,
required this.symbol,
@@ -33,17 +32,19 @@ class SPLToken extends CryptoCurrency {
required String mint,
required String symbol,
required String mintAddress,
required int decimal,
String? iconPath,
bool isPotentialScam = false,
}) => SPLToken(
name: name,
symbol: symbol,
mintAddress: mintAddress,
decimal: 0,
mint: mint,
iconPath: iconPath,
isPotentialScam: isPotentialScam,
);
}) =>
SPLToken(
name: name,
symbol: symbol,
mintAddress: mintAddress,
decimal: decimal,
mint: mint,
iconPath: iconPath,
isPotentialScam: isPotentialScam,
);
SPLToken.copyWith(SPLToken other, {String? icon, String? tag, bool? enabled, String? walletName})
: name = other.name,
@@ -125,18 +126,18 @@ class SPLToken extends CryptoCurrency {
}
Map<String, dynamic> toMap() => {
selfIdColumn: id,
"walletName": walletName,
"name": name,
"symbol": symbol,
"mintAddress": mintAddress,
"decimal": decimal,
"mint": mint,
"enabled": _enabled ? 1 : 0,
"iconPath": iconPath,
"tag": tag,
"isPotentialScam": isPotentialScam ? 1 : 0,
};
selfIdColumn: id,
"walletName": walletName,
"name": name,
"symbol": symbol,
"mintAddress": mintAddress,
"decimal": decimal,
"mint": mint,
"enabled": _enabled ? 1 : 0,
"iconPath": iconPath,
"tag": tag,
"isPotentialScam": isPotentialScam ? 1 : 0,
};
static String get tableName => "SPLToken";
static String get selfIdColumn => "${tableName}Id";
@@ -166,7 +167,8 @@ class SPLToken extends CryptoCurrency {
return List.generate(list.length, (index) => SPLToken.fromMap(list[index]));
}
static Future<List<SPLToken>> getAllForWallet(String walletName) async => selectList("walletName = ?", [walletName]);
static Future<List<SPLToken>> getAllForWallet(String walletName) async =>
selectList("walletName = ?", [walletName]);
static Future<SPLToken?> getByMint(String walletName, String mintAddress) async {
final list = await selectList("walletName = ? AND mintAddress = ?", [walletName, mintAddress]);
@@ -174,12 +176,13 @@ class SPLToken extends CryptoCurrency {
}
static Future<int> deleteForWallet(String walletName, String mintAddress) => db!.delete(
tableName,
where: "walletName = ? AND mintAddress = ?",
whereArgs: [walletName, mintAddress],
);
tableName,
where: "walletName = ? AND mintAddress = ?",
whereArgs: [walletName, mintAddress],
);
static Future<int> deleteAllForWallet(String walletName) => db!.delete(tableName, where: "walletName = ?", whereArgs: [walletName]);
static Future<int> deleteAllForWallet(String walletName) =>
db!.delete(tableName, where: "walletName = ?", whereArgs: [walletName]);
static Future<void> renameWallet(String oldName, String newName) async {
await db!.delete(tableName, where: "walletName = ?", whereArgs: [newName]);
+13
View File
@@ -45,6 +45,19 @@ void main() {
test('should fail to parse `.`, missing value',
() => expect(() => parseFixed(".", 6), throwsFormatException));
});
group("parseFixed, zero decimal currency", () {
test("should parse 5 as 5", () => expect(parseFixed("5", 0), BigInt.from(5)));
test("should parse 0 as 0", () => expect(parseFixed("0", 0), BigInt.from(0)));
test("should parse 1. as 1", () => expect(parseFixed("1.", 0), BigInt.from(1)));
test("should parse -3 as -3", () => expect(parseFixed("-3", 0), BigInt.from(-3)));
test("should fail to parse 5.5, fractional component exceeds decimals",
() => expect(() => parseFixed("5.5", 0), throwsFormatException));
});
});
group('tryParseFixed', () {
+104
View File
@@ -0,0 +1,104 @@
import "package:cw_core/amount/money.dart";
import "package:cw_core/spl_token.dart";
import "package:flutter_test/flutter_test.dart";
void main() {
group("SPL token raw amounts", () {
test("6 decimal token renders raw base units", () {
final jup = SPLToken(
name: "Jupiter",
symbol: "JUP",
mint: "jup",
mintAddress: "JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN",
decimal: 6,
);
expect(Money(BigInt.from(100000), jup).toStringWithPrecision(), "0.1");
expect(Money(BigInt.from(931614), jup).toStringWithPrecision(), "0.931614");
});
test("8 decimal token renders raw base units", () {
final xstock = SPLToken(
name: "Amazon xStock",
symbol: "AMZNX",
mint: "amznx",
mintAddress: "mintAddressWithEightDecimals",
decimal: 8,
);
expect(Money(BigInt.from(341694), xstock).toStringWithPrecision(), "0.00341694");
});
test("the same raw amount means different values per decimals", () {
final raw = BigInt.from(100000);
final six = SPLToken(
name: "Six",
symbol: "SIX",
mint: "six",
mintAddress: "mintAddressWithSixDecimals",
decimal: 6,
);
final nine = SPLToken(
name: "Nine",
symbol: "NINE",
mint: "nine",
mintAddress: "mintAddressWithNineDecimals",
decimal: 9,
);
final zero = SPLToken(
name: "Zero",
symbol: "ZERO",
mint: "zero",
mintAddress: "mintAddressWithZeroDecimals",
decimal: 0,
);
expect(Money(raw, six).toStringWithPrecision(), "0.1");
expect(Money(raw, nine).toStringWithPrecision(), "0.0001");
expect(Money(raw, zero).toStringWithPrecision(), "100000");
});
});
group("SPL token amount parsing", () {
test("parses whole amounts for a zero decimal token", () {
final zero = SPLToken(
name: "Zero",
symbol: "ZERO",
mint: "zero",
mintAddress: "mintAddressWithZeroDecimals",
decimal: 0,
);
expect(Money.parse("5", zero).amount, BigInt.from(5));
expect(Money.parse("0", zero).amount, BigInt.zero);
});
test("parses fractional amounts for a 6 decimal token", () {
final jup = SPLToken(
name: "Jupiter",
symbol: "JUP",
mint: "jup",
mintAddress: "JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN",
decimal: 6,
);
expect(Money.parse("0.1", jup).amount, BigInt.from(100000));
expect(Money.parse("10.339089", jup).amount, BigInt.from(10339089));
});
test("rejects more fractional digits than the token has decimals", () {
final zero = SPLToken(
name: "Zero",
symbol: "ZERO",
mint: "zero",
mintAddress: "mintAddressWithZeroDecimals",
decimal: 0,
);
expect(() => Money.parse("5.5", zero), throwsFormatException);
});
});
}
+1 -1
View File
@@ -87,7 +87,7 @@ class DefaultSPLTokens {
symbol: 'GMT',
mintAddress: '7i5KKsX2weiTkry7jA4ZwSuXGhs5eJBEjY8vVxR4pfRx',
decimal: 9,
mint: 'ray',
mint: 'gmt',
iconPath: 'assets/images/gmt_icon.png',
enabled: false,
),
@@ -13,6 +13,7 @@ class PendingSolanaTransaction with PendingTransaction {
required this.serializedTransaction,
required this.destinationAddress,
required this.sendTransaction,
this.additionalCost,
});
@override
@@ -22,15 +23,10 @@ class PendingSolanaTransaction with PendingTransaction {
final Money fee;
@override
String get amountFormatted {
String stringifiedAmount = amount.toString();
final Money? additionalCost;
if (stringifiedAmount.toString().length >= 6) {
stringifiedAmount = stringifiedAmount.substring(0, 6);
}
return stringifiedAmount;
}
@override
String get amountFormatted => amount.toString();
@override
Future<void> commit() async {
+205 -254
View File
@@ -3,9 +3,7 @@ import 'dart:convert';
import 'package:blockchain_utils/blockchain_utils.dart';
import 'package:cw_core/amount/money.dart';
import 'package:cw_core/amount/money_double.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/currency.dart';
import 'package:cw_core/node.dart';
import 'package:cw_core/utils/proxy_wrapper.dart';
import 'package:cw_core/solana_rpc_http_service.dart';
@@ -215,17 +213,10 @@ class SolanaWalletClient {
// For native solana transactions
if (instruction.accounts.length < 2) continue;
// Get the fee payer index based on transaction type
// For legacy transfers, the first account is usually the fee payer
// For versioned, the first account in instruction is usually the fee payer
final feePayerIndex =
txResponse.version == TransactionType.legacy ? 0 : instruction.accounts[0];
final transactionModel = await _parseNativeTransaction(
message: message,
meta: meta,
fee: fee,
feePayerIndex: feePayerIndex,
walletAddress: walletAddress,
signature: signature,
blockTime: blockTime,
@@ -234,8 +225,8 @@ class SolanaWalletClient {
if (transactionModel != null) {
return [transactionModel];
}
} else if (programId == SPLTokenProgramConst.tokenProgramId) {
// For SPL Token transactions
} else if (programId == SPLTokenProgramConst.tokenProgramId ||
programId == SPLTokenProgramConst.token2022ProgramId) {
if (instruction.accounts.length < 2) continue;
final transactionModel = await _parseSPLTokenTransaction(
@@ -265,7 +256,8 @@ class SolanaWalletClient {
bool hasTokenTransfer = false;
for (final otherInstruction in instructions) {
final otherProgramId = message.accountKeys[otherInstruction.programIdIndex];
if (otherProgramId == SPLTokenProgramConst.tokenProgramId) {
if (otherProgramId == SPLTokenProgramConst.tokenProgramId ||
otherProgramId == SPLTokenProgramConst.token2022ProgramId) {
hasTokenTransfer = true;
break;
}
@@ -389,6 +381,18 @@ class SolanaWalletClient {
return walletSent && walletReceived;
}
static CryptoCurrency currencyForRawAmount(SPLToken? token, int mintDecimals) {
if (token != null && token.decimals == mintDecimals) {
return token;
}
return CryptoCurrency(
name: (token?.title ?? "TOKEN").toLowerCase(),
title: token?.title ?? "TOKEN",
decimals: mintDecimals,
);
}
/// Parses a swap transaction and creates dual entries (outgoing and incoming)
Future<List<SolanaTransactionModel>> _parseSwapTransaction({
required VersionedMessage message,
@@ -407,6 +411,9 @@ class SolanaWalletClient {
final preTokenBalances = meta.preTokenBalances;
final postTokenBalances = meta.postTokenBalances;
final walletPaidFee = accountKeys.isNotEmpty && accountKeys.first.address == walletAddress;
final feeAdjustment = walletPaidFee ? BigInt.from(fee) : BigInt.zero;
String? decreasedMintForWallet;
String? increasedMintForWallet;
@@ -442,8 +449,7 @@ class SolanaWalletClient {
decreasedMintForWallet != increasedMintForWallet;
// Parse outgoing side (what was sent)
double outgoingAmount = 0.0;
Currency outgoingToken = CryptoCurrency.sol;
Money? outgoingMoney;
String? outgoingMintAddress;
String? outgoingFrom;
String? outgoingTo;
@@ -461,12 +467,12 @@ class SolanaWalletClient {
if (accountAddress == walletAddress) {
final preBalance = preBalances[i];
final postBalance = postBalances[i];
final balanceChange = preBalance - postBalance;
final balanceChange = preBalance - postBalance - feeAdjustment;
if (balanceChange > BigInt.zero) {
// The wallet sent SOL
outgoingAmount = balanceChange.toDouble() / SolanaUtils.lamportsPerSol;
outgoingToken = CryptoCurrency.sol;
outgoingMoney = Money(balanceChange, CryptoCurrency.sol);
outgoingMintAddress = null;
outgoingFrom = walletAddress;
// We find the intermediate account or swap program account
@@ -484,7 +490,7 @@ class SolanaWalletClient {
}
// If no SOL outgoing, we check if there are any SPL token balance changes for the wallet
if (outgoingAmount == 0.0 && preTokenBalances != null) {
if (outgoingMoney == null && preTokenBalances != null && postTokenBalances != null) {
for (final preTokenBal in preTokenBalances) {
final owner = preTokenBal.owner?.address ?? '';
@@ -494,24 +500,23 @@ class SolanaWalletClient {
if (isSplToSplSwap && mint != decreasedMintForWallet) {
continue;
}
final preAmount = preTokenBal.uiTokenAmount.uiAmount ?? 0.0;
final preRaw = BigInt.tryParse(preTokenBal.uiTokenAmount.amount) ?? BigInt.zero;
// We find the corresponding post balance
for (final postTokenBal in postTokenBalances ?? []) {
for (final postTokenBal in postTokenBalances) {
final postOwner = postTokenBal.owner?.address ?? '';
final postMint = postTokenBal.mint.address;
final postAmount = postTokenBal.uiTokenAmount.uiAmount ?? 0.0;
if (postOwner == walletAddress && postMint == mint) {
final diff = preAmount - postAmount;
final postRaw = BigInt.tryParse(postTokenBal.uiTokenAmount.amount) ?? BigInt.zero;
final diff = preRaw - postRaw;
if (diff > 0) {
if (diff > BigInt.zero) {
// The wallet sent tokens
outgoingAmount = diff.toDouble();
outgoingMintAddress = mint;
final token = await getTokenInfo(mint);
outgoingToken =
token ?? const CryptoCurrency(name: "TOKEN", title: "TOKEN", decimals: 6);
outgoingMoney =
Money(diff, currencyForRawAmount(token, preTokenBal.uiTokenAmount.decimals));
outgoingMintAddress = mint;
outgoingFrom = walletAddress;
// We find the intermediate account
if (instructions.isNotEmpty && instructions[0].accounts.isNotEmpty) {
@@ -526,14 +531,15 @@ class SolanaWalletClient {
}
}
if (outgoingAmount > 0) break;
if (outgoingMoney != null) {
break;
}
}
}
}
// Parse incoming side (what was received)
double incomingAmount = 0.0;
Currency incomingToken = CryptoCurrency.sol;
Money? incomingMoney;
String? incomingMintAddress;
String? incomingFrom;
String? incomingTo;
@@ -550,12 +556,11 @@ class SolanaWalletClient {
if (accountAddress == walletAddress) {
final preBalance = preBalances[i];
final postBalance = postBalances[i];
final balanceChange = postBalance - preBalance;
final balanceChange = postBalance - preBalance + feeAdjustment;
if (balanceChange > BigInt.zero) {
// The wallet received SOL
incomingAmount = balanceChange.toDouble() / SolanaUtils.lamportsPerSol;
incomingToken = CryptoCurrency.sol;
incomingMoney = Money(balanceChange, CryptoCurrency.sol);
incomingMintAddress = null;
incomingTo = walletAddress;
// We find the intermediate account
@@ -573,7 +578,7 @@ class SolanaWalletClient {
}
// If no SOL incoming, check SPL token incoming using ATA derivation
if (incomingAmount == 0.0 && preTokenBalances != null && postTokenBalances != null) {
if (incomingMoney == null && preTokenBalances != null && postTokenBalances != null) {
// Collect all unique mints from token balances (excluding wrapped SOL)
final mints = <String>{};
for (final tokenBal in preTokenBalances) {
@@ -595,17 +600,22 @@ class SolanaWalletClient {
final walletSolAddress = SolAddress(walletAddress);
final mintSolAddress = SolAddress(mint);
final ata = AssociatedTokenAccountProgramUtils.associatedTokenAccount(
final standardAta = AssociatedTokenAccountProgramUtils.associatedTokenAccount(
mint: mintSolAddress,
owner: walletSolAddress,
);
final ataAddress = ata.address.address;
final token2022Ata = AssociatedTokenAccountProgramUtils.associatedTokenAccount(
mint: mintSolAddress,
owner: walletSolAddress,
tokenProgramId: SPLTokenProgramConst.token2022ProgramId,
);
final ataAddresses = [standardAta.address.address, token2022Ata.address.address];
// We check if this ATA address appears in the account keys
// We check if either ATA address appears in the account keys
int? ataAccountIndex;
for (int i = 0; i < accountKeys.length; i++) {
final accountKey = accountKeys[i];
if (accountKey.address == ataAddress) {
if (ataAddresses.contains(accountKey.address)) {
ataAccountIndex = i;
break;
}
@@ -613,15 +623,17 @@ class SolanaWalletClient {
// If ATA is in the transaction, we check for balance changes
if (ataAccountIndex != null) {
double preAmount = 0.0;
double postAmount = 0.0;
BigInt preRaw = BigInt.zero;
BigInt postRaw = BigInt.zero;
int? mintDecimals;
// We find the pre balance
for (final preTokenBal in preTokenBalances) {
final accountIndex = preTokenBal.accountIndex;
final tokenMint = preTokenBal.mint.address;
if (accountIndex == ataAccountIndex && tokenMint == mint) {
preAmount = preTokenBal.uiTokenAmount.uiAmount?.toDouble() ?? 0.0;
preRaw = BigInt.tryParse(preTokenBal.uiTokenAmount.amount) ?? BigInt.zero;
mintDecimals = preTokenBal.uiTokenAmount.decimals;
break;
}
}
@@ -631,18 +643,18 @@ class SolanaWalletClient {
final accountIndex = postTokenBal.accountIndex;
final tokenMint = postTokenBal.mint.address;
if (accountIndex == ataAccountIndex && tokenMint == mint) {
postAmount = postTokenBal.uiTokenAmount.uiAmount?.toDouble() ?? 0.0;
postRaw = BigInt.tryParse(postTokenBal.uiTokenAmount.amount) ?? BigInt.zero;
mintDecimals = postTokenBal.uiTokenAmount.decimals;
break;
}
}
final diff = postAmount - preAmount;
if (diff > 0) {
final diff = postRaw - preRaw;
if (diff > BigInt.zero && mintDecimals != null) {
// The wallet received tokens
incomingAmount = diff.toDouble();
incomingMintAddress = mint;
final token = await getTokenInfo(mint);
incomingToken = token ?? const CryptoCurrency(name: "TOKEN", title: "TOKEN", decimals: 6);
incomingMoney = Money(diff, currencyForRawAmount(token, mintDecimals));
incomingMintAddress = mint;
incomingTo = walletAddress;
// We find the intermediate account
if (instructions.isNotEmpty && instructions[0].accounts.isNotEmpty) {
@@ -663,7 +675,7 @@ class SolanaWalletClient {
}
// Outgoing transaction model
if (outgoingAmount > 0.0 && outgoingFrom != null && outgoingTo != null) {
if (outgoingMoney != null && outgoingFrom != null && outgoingTo != null) {
final outgoingId =
'${signature}_outgoing'; // We create a composite ID for the outgoing transaction
swapTransactions.add(SolanaTransactionModel(
@@ -671,7 +683,7 @@ class SolanaWalletClient {
from: outgoingFrom,
to: outgoingTo,
id: outgoingId,
amount: outgoingAmount.toMoney(outgoingToken),
amount: outgoingMoney,
programId: outgoingMintAddress == null
? SystemProgramConst.programId.address
: SPLTokenProgramConst.tokenProgramId.address,
@@ -681,7 +693,7 @@ class SolanaWalletClient {
}
// Incoming transaction model
if (incomingAmount > 0.0 && incomingFrom != null && incomingTo != null) {
if (incomingMoney != null && incomingFrom != null && incomingTo != null) {
final incomingId =
'${signature}_incoming'; // We create a composite ID for the incoming transaction
swapTransactions.add(SolanaTransactionModel(
@@ -689,7 +701,7 @@ class SolanaWalletClient {
from: incomingFrom,
to: incomingTo,
id: incomingId,
amount: incomingAmount.toMoney(incomingToken),
amount: incomingMoney,
programId: incomingMintAddress == null
? SystemProgramConst.programId.address
: SPLTokenProgramConst.tokenProgramId.address,
@@ -705,7 +717,6 @@ class SolanaWalletClient {
required VersionedMessage message,
required ConfirmedTransactionMeta meta,
required int fee,
required int feePayerIndex,
required String walletAddress,
required String signature,
required BigInt? blockTime,
@@ -736,8 +747,7 @@ class SolanaWalletClient {
// Positive = wallet lost SOL, negative = wallet gained.
final walletChange = walletPre - walletPost;
final bool walletPaidFee =
feePayerIndex < accountKeys.length && accountKeys[feePayerIndex].address == walletAddress;
final walletPaidFee = accountKeys.first.address == walletAddress;
// Net transfer amount excluding the fee.
final netChange = walletPaidFee ? walletChange - BigInt.from(fee) : walletChange;
@@ -808,8 +818,9 @@ class SolanaWalletClient {
mintAddress = accountKeys[accounts[1]].address;
}
double userPreAmount = 0.0;
double userPostAmount = 0.0;
BigInt userPreRaw = BigInt.zero;
BigInt userPostRaw = BigInt.zero;
int? mintDecimals;
if (preTokenBalances != null) {
for (final preBal in preTokenBalances) {
@@ -820,7 +831,8 @@ class SolanaWalletClient {
continue;
}
mintAddress ??= preBal.mint.address;
userPreAmount = preBal.uiTokenAmount.uiAmount ?? 0.0;
mintDecimals = preBal.uiTokenAmount.decimals;
userPreRaw = BigInt.tryParse(preBal.uiTokenAmount.amount) ?? BigInt.zero;
break;
}
}
@@ -836,19 +848,21 @@ class SolanaWalletClient {
continue;
}
mintAddress ??= postBal.mint.address;
userPostAmount = postBal.uiTokenAmount.uiAmount ?? 0.0;
mintDecimals = postBal.uiTokenAmount.decimals;
userPostRaw = BigInt.tryParse(postBal.uiTokenAmount.amount) ?? BigInt.zero;
break;
}
}
}
}
final diff = userPreAmount - userPostAmount;
final rawAmount = diff.abs();
final diff = userPreRaw - userPostRaw;
final amountInString = rawAmount.toStringAsFixed(6);
final amount = double.parse(amountInString);
final isOutgoing = diff > 0;
if (diff == BigInt.zero || mintDecimals == null) {
return null;
}
final isOutgoing = diff > BigInt.zero;
// Resolve sender/receiver from token balance owners
String? senderOwner;
@@ -886,7 +900,7 @@ class SolanaWalletClient {
from: sender,
to: receiver,
id: signature,
amount: amount.toMoney(splToken ?? CryptoCurrency.sol),
amount: Money(diff.abs(), currencyForRawAmount(splToken, mintDecimals)),
programId: SPLTokenProgramConst.tokenProgramId.address,
blockTimeInInt: blockTime?.toInt() ?? 0,
fee: Money.fromInt(fee, CryptoCurrency.sol),
@@ -1013,8 +1027,7 @@ class SolanaWalletClient {
final transactions = <SolanaTransactionModel>[];
try {
final signatures =
await _getAllSignaturesSinceLastFetch(address, untilSignature, commitment);
final signatures = await _getAllSignaturesSinceLastFetch(address, untilSignature, commitment);
if (signatures.isEmpty) return TransactionSyncResult(transactions: transactions);
@@ -1096,11 +1109,9 @@ class SolanaWalletClient {
if (associatedTokenAccount == null) {
try {
associatedTokenAccount = await _getOrCreateAssociatedTokenAccount(
payerPrivateKey: privateKey,
associatedTokenAccount = await _findAssociatedTokenAccount(
mintAddress: SolAddress(mintAddress),
ownerAddress: ownerWalletAddress,
shouldCreateATA: false,
);
} catch (e, s) {
printV('$e \n $s');
@@ -1122,12 +1133,33 @@ class SolanaWalletClient {
);
}
final Map<String, SPLToken?> tokenInfoCache = {};
final Map<String, SPLToken> tokenInfoCache = {};
Future<SPLToken?> getTokenInfo(String mintAddress) async {
if (tokenInfoCache.containsKey(mintAddress)) return tokenInfoCache[mintAddress];
final cached = tokenInfoCache[mintAddress];
if (cached != null) {
return cached;
}
return tokenInfoCache[mintAddress] = await fetchSPLTokenInfo(mintAddress);
final fetched = await fetchSPLTokenInfo(mintAddress);
if (fetched != null) {
tokenInfoCache[mintAddress] = fetched;
}
return fetched;
}
Future<int?> _fetchMintDecimals(String mintAddress) async {
try {
final supply = await _provider!.request(
SolanaRPCGetTokenSupply(account: SolAddress(mintAddress)),
);
return supply.decimals;
} catch (e) {
printV("Could not read decimals for mint $mintAddress: ${e.toString()}");
return null;
}
}
Future<SPLToken?> fetchSPLTokenInfo(String mintAddress) async {
@@ -1150,18 +1182,29 @@ class SolanaWalletClient {
final symbol = decodedResponse['symbol'] ?? '';
final name = decodedResponse['name'] ?? '';
final decimal = decodedResponse['decimals'] ?? '0';
final rawDecimals = decodedResponse["decimals"];
final iconPath = decodedResponse['logo'] ?? '';
final filteredTokenSymbol = symbol.replaceFirst(RegExp('^\\\$'), '').replaceAll('\u0000', '');
final reportedDecimals =
rawDecimals is num ? rawDecimals.toInt() : int.tryParse("${rawDecimals ?? ""}");
final decimals = (reportedDecimals != null && reportedDecimals > 0)
? reportedDecimals
: await _fetchMintDecimals(mintAddress);
if (decimals == null) {
return null;
}
return SPLToken(
name: name,
mint: symbol,
symbol: filteredTokenSymbol,
mintAddress: mintAddress,
iconPath: iconPath,
decimal: int.tryParse(decimal) ?? 0,
decimal: decimals,
);
} catch (e, s) {
printV('Error fetching token info: $e \n $s');
@@ -1189,11 +1232,18 @@ class SolanaWalletClient {
String filteredTokenSymbol =
metadata.symbol.replaceFirst(RegExp('^\\\$'), '').replaceAll('\u0000', '');
final decimals = await _fetchMintDecimals(token.mint.address);
if (decimals == null) {
return null;
}
return SPLToken.fromMetadata(
name: metadata.name,
mint: metadata.symbol,
symbol: filteredTokenSymbol,
mintAddress: token.mint.address,
decimal: decimals,
iconPath: iconPath,
);
} catch (_) {}
@@ -1217,7 +1267,7 @@ class SolanaWalletClient {
}) async {
const commitment = Commitment.confirmed;
if (inputAmount.currency == CryptoCurrency.sol) {
if (tokenMint == null) {
return _signNativeTokenTransaction(
inputAmount: inputAmount,
destinationAddress: destinationAddress,
@@ -1229,7 +1279,7 @@ class SolanaWalletClient {
} else {
return _signSPLTokenTransaction(
tokenDecimals: inputAmount.currency.decimals,
tokenMint: tokenMint!,
tokenMint: tokenMint,
inputAmount: inputAmount,
ownerPrivateKey: ownerPrivateKey,
destinationAddress: destinationAddress,
@@ -1270,53 +1320,28 @@ class SolanaWalletClient {
);
}
Future<Message> _getMessageForSPLTokenTransaction({
required SolAddress ownerAddress,
required SolAddress destinationAddress,
required int tokenDecimals,
required SolAddress mintAddress,
required SolAddress sourceAccount,
required Money amount,
required Commitment commitment,
required SolAddress tokenProgramId,
}) async {
final instructions = [
SPLTokenProgram.transferChecked(
layout: SPLTokenTransferCheckedLayout(
amount: amount.amount,
decimals: tokenDecimals,
),
mint: mintAddress,
source: sourceAccount,
destination: destinationAddress,
owner: ownerAddress,
)
];
final latestBlockhash = await _getLatestBlockhash(commitment);
return Message.compile(
transactionInstructions: instructions,
payer: ownerAddress,
recentBlockhash: latestBlockhash,
);
}
Future<Money> _getFeeFromCompiledMessage(Message message, Commitment commitment) {
final base64Message = base64Encode(message.serialize());
return getFeeForMessage(base64Message, commitment);
}
Future<bool> hasSufficientFundsLeftForRent({
required Money inputAmount,
required Money solBalance,
required Money fee,
}) async {
Future<Money> _getRentExemptionAmount(int space) async {
final rent = await _provider!.request(
SolanaRPCGetMinimumBalanceForRentExemption(size: space),
);
return Money(rent, CryptoCurrency.sol);
}
Future<bool> hasSufficientFundsLeftForRent({
required Money totalOutflow,
required Money solBalance,
}) async {
final rentBuffer = await _provider!.request(
SolanaRPCGetMinimumBalanceForRentExemption(size: SolanaTokenAccountUtils.accountSize),
);
return (solBalance - (inputAmount + fee)) > Money(rent, CryptoCurrency.sol);
return (solBalance - totalOutflow) > Money(rentBuffer, CryptoCurrency.sol);
}
Future<PendingSolanaTransaction> _signNativeTokenTransaction({
@@ -1343,8 +1368,7 @@ class SolanaWalletClient {
if (!isSendAll) {
final hasSufficientFundsLeft = await hasSufficientFundsLeftForRent(
inputAmount: inputAmount,
fee: fee,
totalOutflow: inputAmount + fee,
solBalance: solBalance,
);
@@ -1357,6 +1381,10 @@ class SolanaWalletClient {
if (isSendAll) {
final updatedLamports = inputAmount - fee;
if (updatedLamports.isNegative || updatedLamports.isZero) {
throw SolanaTransactionWrongBalanceException(CryptoCurrency.sol);
}
final transaction = _constructNativeTransaction(
ownerPrivateKey: ownerPrivateKey,
destinationAddress: destinationAddress,
@@ -1502,21 +1530,10 @@ class SolanaWalletClient {
return SPLTokenProgramConst.tokenProgramId;
}
Future<ProgramDerivedAddress?> _getOrCreateAssociatedTokenAccount({
required SolanaPrivateKey payerPrivateKey,
Future<ProgramDerivedAddress?> _findAssociatedTokenAccount({
required SolAddress ownerAddress,
required SolAddress mintAddress,
required bool shouldCreateATA,
}) async {
// For transaction history loading (shouldCreateATA: false), try standard token program first
// to avoid unnecessary RPC call. Only fetch token program ID when creating accounts.
SolAddress tokenProgramId = SPLTokenProgramConst.tokenProgramId;
if (shouldCreateATA) {
// Only fetch token program ID when we need to create an account
tokenProgramId = await _getTokenProgramId(mintAddress);
}
// Try with standard token program first (most common case)
var associatedTokenAccount = AssociatedTokenAccountProgramUtils.associatedTokenAccount(
mint: mintAddress,
@@ -1539,71 +1556,29 @@ class SolanaWalletClient {
// If account exists with standard program, return it
if (accountInfo != null) return associatedTokenAccount;
// If not found and we're not creating, try Token-2022 as fallback
if (!shouldCreateATA) {
try {
final token2022ProgramId = await _getTokenProgramId(mintAddress);
if (token2022ProgramId.address != SPLTokenProgramConst.tokenProgramId.address) {
associatedTokenAccount = AssociatedTokenAccountProgramUtils.associatedTokenAccount(
mint: mintAddress,
owner: ownerAddress,
tokenProgramId: token2022ProgramId,
// if its not found under the standard program, then we try Token-2022, which derives a different address
try {
final token2022ProgramId = await _getTokenProgramId(mintAddress);
if (token2022ProgramId.address != SPLTokenProgramConst.tokenProgramId.address) {
associatedTokenAccount = AssociatedTokenAccountProgramUtils.associatedTokenAccount(
mint: mintAddress,
owner: ownerAddress,
tokenProgramId: token2022ProgramId,
);
try {
accountInfo = await _provider!.request(
SolanaRPCGetAccountInfo(
account: associatedTokenAccount.address,
commitment: Commitment.confirmed,
),
);
if (accountInfo != null) return associatedTokenAccount;
} catch (_) {}
}
} catch (_) {}
try {
accountInfo = await _provider!.request(
SolanaRPCGetAccountInfo(
account: associatedTokenAccount.address,
commitment: Commitment.confirmed,
),
);
if (accountInfo != null) return associatedTokenAccount;
} catch (_) {}
}
} catch (_) {}
return null;
}
// For account creation, use the detected token program ID
associatedTokenAccount = AssociatedTokenAccountProgramUtils.associatedTokenAccount(
mint: mintAddress,
owner: ownerAddress,
tokenProgramId: tokenProgramId,
);
final payerAddress = payerPrivateKey.publicKey().toAddress();
final createAssociatedTokenAccount = AssociatedTokenAccountProgram.associatedTokenAccount(
payer: payerAddress,
associatedToken: associatedTokenAccount.address,
owner: ownerAddress,
mint: mintAddress,
tokenProgramId: tokenProgramId,
);
final blockhash = await _getLatestBlockhash(Commitment.confirmed);
final transaction = SolanaTransaction(
payerKey: payerAddress,
instructions: [createAssociatedTokenAccount],
recentBlockhash: blockhash,
type: TransactionType.v0,
);
final serializedTransaction = await _signTransactionInternal(
ownerPrivateKey: payerPrivateKey,
transaction: transaction,
);
await sendTransaction(
serializedTransaction: serializedTransaction,
commitment: Commitment.confirmed,
);
// Wait for confirmation
await Future.delayed(const Duration(seconds: 2));
return associatedTokenAccount;
return null;
}
Future<PendingSolanaTransaction> _signSPLTokenTransaction({
@@ -1620,6 +1595,7 @@ class SolanaWalletClient {
ProgramDerivedAddress? associatedSenderAccount;
SolAddress senderTokenProgramId = tokenProgramId;
int? senderAccountSpace;
try {
associatedSenderAccount = AssociatedTokenAccountProgramUtils.associatedTokenAccount(
@@ -1638,6 +1614,7 @@ class SolanaWalletClient {
if (accountInfo != null) {
senderTokenProgramId = accountInfo.owner;
senderAccountSpace = accountInfo.space;
} else {
associatedSenderAccount = null;
}
@@ -1664,6 +1641,7 @@ class SolanaWalletClient {
if (accountInfo != null) {
senderTokenProgramId = accountInfo.owner;
senderAccountSpace = accountInfo.space;
} else {
associatedSenderAccount = null;
}
@@ -1679,18 +1657,16 @@ class SolanaWalletClient {
);
}
// Get or create recipient account using the sender's token program ID
// This ensures both accounts use the same program
ProgramDerivedAddress? associatedRecipientAccount;
final ProgramDerivedAddress associatedRecipientAccount;
bool shouldCreateRecipientAccount = false;
try {
// First, try to get/create with the sender's actual program ID
final recipientPDA = AssociatedTokenAccountProgramUtils.associatedTokenAccount(
mint: mintAddress,
owner: SolAddress(destinationAddress),
tokenProgramId: senderTokenProgramId,
);
// Check if account exists with correct program
SolanaAccountInfo? recipientInfo;
try {
recipientInfo = await _provider!.request(
@@ -1703,39 +1679,16 @@ class SolanaWalletClient {
recipientInfo = null;
}
if (recipientInfo != null && recipientInfo.owner.address == senderTokenProgramId.address) {
associatedRecipientAccount = recipientPDA;
} else {
// Create the account with the correct program ID
final createATA = AssociatedTokenAccountProgram.associatedTokenAccount(
payer: ownerPrivateKey.publicKey().toAddress(),
associatedToken: recipientPDA.address,
owner: SolAddress(destinationAddress),
mint: mintAddress,
tokenProgramId: senderTokenProgramId,
if (recipientInfo != null && recipientInfo.owner.address != senderTokenProgramId.address) {
throw SolanaCreateAssociatedTokenAccountException(
"Recipient token account is owned by ${recipientInfo.owner.address}",
);
final blockhash = await _getLatestBlockhash(Commitment.confirmed);
final createTransaction = SolanaTransaction(
payerKey: ownerPrivateKey.publicKey().toAddress(),
instructions: [createATA],
recentBlockhash: blockhash,
type: TransactionType.v0,
);
final serializedCreateTx = await _signTransactionInternal(
ownerPrivateKey: ownerPrivateKey,
transaction: createTransaction,
);
await sendTransaction(
serializedTransaction: serializedCreateTx,
commitment: Commitment.confirmed,
);
await Future.delayed(const Duration(seconds: 2));
associatedRecipientAccount = recipientPDA;
}
shouldCreateRecipientAccount = recipientInfo == null;
associatedRecipientAccount = recipientPDA;
} on SolanaCreateAssociatedTokenAccountException {
rethrow;
} catch (e) {
throw SolanaCreateAssociatedTokenAccountException(e.toString());
}
@@ -1751,30 +1704,42 @@ class SolanaWalletClient {
decimals: tokenDecimals,
);
final instructions = <TransactionInstruction>[
if (shouldCreateRecipientAccount)
AssociatedTokenAccountProgram.associatedTokenAccountIdempotent(
payer: ownerPrivateKey.publicKey().toAddress(),
associatedToken: associatedRecipientAccount.address,
owner: SolAddress(destinationAddress),
mint: mintAddress,
tokenProgramId: senderTokenProgramId,
),
transferInstructions,
];
final latestBlockHash = await _getLatestBlockhash(commitment);
final transaction = SolanaTransaction(
payerKey: ownerPrivateKey.publicKey().toAddress(),
instructions: [transferInstructions],
instructions: instructions,
recentBlockhash: latestBlockHash,
);
final message = await _getMessageForSPLTokenTransaction(
ownerAddress: ownerPrivateKey.publicKey().toAddress(),
tokenDecimals: tokenDecimals,
mintAddress: mintAddress,
destinationAddress: associatedRecipientAccount.address,
sourceAccount: associatedSenderAccount.address,
amount: inputAmount,
commitment: commitment,
tokenProgramId: tokenProgramId,
final message = Message.compile(
transactionInstructions: instructions,
payer: ownerPrivateKey.publicKey().toAddress(),
recentBlockhash: latestBlockHash,
);
final fee = await _getFeeFromCompiledMessage(message, commitment);
// The sender account exists by this point, so its space is set, and the recipient
// account is the same size because it holds the same mint under the same program.
final accountCreationCost = shouldCreateRecipientAccount
? await _getRentExemptionAmount(senderAccountSpace!)
: Money.zero(CryptoCurrency.sol);
final hasSufficientFundsLeft = await hasSufficientFundsLeftForRent(
inputAmount: Money.zero(CryptoCurrency.sol),
fee: fee,
totalOutflow: accountCreationCost + fee,
solBalance: solBalance,
);
@@ -1796,6 +1761,7 @@ class SolanaWalletClient {
destinationAddress: destinationAddress,
sendTransaction: sendTx,
fee: fee,
additionalCost: shouldCreateRecipientAccount ? accountCreationCost : null,
);
}
@@ -1884,19 +1850,10 @@ class SolanaWalletClient {
final mint = tokenData['mint'] as String? ?? '';
if (mint.isEmpty) continue;
final amountRaw = tokenData['amountRaw'] as String? ?? '0';
final decimals = tokenData['decimals'] as int? ?? 0;
final associatedTokenAddress = tokenData['associatedTokenAddress'] as String? ?? '';
tokens.add(
MoralisSolanaTokenBalance(
mint: mint,
amount: amount,
amountRaw: amountRaw,
decimals: decimals,
associatedTokenAddress: associatedTokenAddress,
),
);
}
@@ -1954,15 +1911,9 @@ class SolanaWalletClient {
class MoralisSolanaTokenBalance {
final String mint;
final double amount;
final String amountRaw;
final int decimals;
final String associatedTokenAddress;
const MoralisSolanaTokenBalance({
required this.mint,
required this.amount,
required this.amountRaw,
required this.decimals,
required this.associatedTokenAddress,
});
}
+4
View File
@@ -36,3 +36,7 @@ class SolanaNoAssociatedTokenAccountException extends NoAssociatedTokenAccountEx
final String account;
final String mint;
}
class SolanaAmbiguousTokenSymbolException extends AmbiguousTokenSymbolException {
SolanaAmbiguousTokenSymbolException(super.symbol);
}
@@ -88,9 +88,14 @@ abstract class SolanaTransactionHistoryBase extends TransactionHistoryBase<Solan
txs.entries.forEach((entry) {
final val = entry.value;
if (val is Map<String, dynamic>) {
final tx = SolanaTransactionInfo.fromJson(val);
_update(tx);
if (val is! Map<String, dynamic>) {
return;
}
try {
_update(SolanaTransactionInfo.fromJson(val));
} catch (e) {
printV("Skipping unreadable solana transaction ${entry.key}: ${e.toString()}");
}
});
} catch (e) {
+63 -50
View File
@@ -251,12 +251,7 @@ abstract class SolanaWalletBase
await updateTokenBalance();
final transactionCurrency = balance.keys.firstWhere(
(currency) =>
currency.title == credentials.currency.title &&
currency.tag == credentials.currency.tag,
orElse: () => throw Exception(
'Currency ${credentials.currency.title} ${credentials.currency.tag} is not accessible in the wallet, try to enable it first.'));
final transactionCurrency = resolveTransactionCurrency(credentials.currency, balance.keys);
final walletBalanceForCurrency = balance[transactionCurrency]!.available;
@@ -285,9 +280,8 @@ abstract class SolanaWalletBase
}
String? tokenMint;
// Token Mint is only needed for transactions that are not native tokens(non-SOL transactions)
if (transactionCurrency.title != CryptoCurrency.sol.title) {
tokenMint = (transactionCurrency as SPLToken).mintAddress;
if (transactionCurrency is SPLToken) {
tokenMint = transactionCurrency.mintAddress;
}
return _client.signSolanaTransaction(
@@ -302,6 +296,33 @@ abstract class SolanaWalletBase
);
}
static CryptoCurrency resolveTransactionCurrency(
CryptoCurrency requestedCurrency,
Iterable<CryptoCurrency> availableCurrencies,
) {
final matches = requestedCurrency is SPLToken
? availableCurrencies
.where((currency) =>
currency is SPLToken && currency.mintAddress == requestedCurrency.mintAddress)
.toList(growable: false)
: availableCurrencies
.where((currency) =>
currency.title == requestedCurrency.title && currency.tag == requestedCurrency.tag)
.toList(growable: false);
if (matches.isEmpty) {
throw Exception(
"Currency ${requestedCurrency.title} ${requestedCurrency.tag} is not accessible in the wallet, try to enable it first.",
);
}
if (matches.length > 1) {
throw SolanaAmbiguousTokenSymbolException(requestedCurrency.title);
}
return matches.first;
}
@override
Future<Map<String, SolanaTransactionInfo>> fetchTransactions() async => {};
@@ -448,6 +469,8 @@ abstract class SolanaWalletBase
}
}
static final _swapIdSuffixPattern = RegExp(r"_(outgoing|incoming)$");
void _addTransactions(List<SolanaTransactionModel> transactions) {
final Map<String, SolanaTransactionInfo> result = {};
@@ -464,6 +487,11 @@ abstract class SolanaWalletBase
isPending: false,
fee: transactionModel.fee,
);
final baseSignature = transactionModel.id.replaceFirst(_swapIdSuffixPattern, "");
if (baseSignature != transactionModel.id) {
transactionHistory.transactions.remove(baseSignature);
}
}
transactionHistory.addMany(result);
@@ -588,7 +616,9 @@ abstract class SolanaWalletBase
// Fetch SOL and SPL token balances in parallel for better performance
await Future.wait([
_fetchSOLBalance().then((solBalance) {
balance[CryptoCurrency.sol] = solBalance;
if (solBalance != null) {
balance[CryptoCurrency.sol] = solBalance;
}
}),
_updateSplTokenBalancesInternal(tokenMints: tokenMints),
]);
@@ -596,10 +626,15 @@ abstract class SolanaWalletBase
await save();
}
Future<SolanaBalance> _fetchSOLBalance() async {
final balance = await _client.getBalance(solanaAddress);
return SolanaBalance(balance);
Future<SolanaBalance?> _fetchSOLBalance() async {
try {
return SolanaBalance(
await _client.getBalance(solanaAddress, throwOnError: true),
);
} catch (e) {
printV("Error fetching SOL balance: ${e.toString()}");
return null;
}
}
/// Internal helper to update SPL token balances.
@@ -743,7 +778,7 @@ abstract class SolanaWalletBase
name: tokenInfo.name,
symbol: tokenInfo.symbol,
mintAddress: mint,
decimal: moralisToken.decimals,
decimal: tokenInfo.decimal,
mint: tokenInfo.mint,
iconPath: tokenInfo.iconPath,
tag: 'SOL',
@@ -886,7 +921,7 @@ abstract class SolanaWalletBase
await _clearLastSyncedSignature(source);
}
updateTokenBalance();
await updateTokenBalance();
}
Future<void> _removeTokenTransactionsInHistory(SPLToken token) async {
@@ -932,47 +967,25 @@ abstract class SolanaWalletBase
return Base58Encoder.encode(signature);
}
List<List<int>> bytesFromSigString(String signatureString) {
final regex = RegExp(r'Signature\(\[(.+)\], publicKey: (.+)\)');
final match = regex.firstMatch(signatureString);
if (match != null) {
final bytesString = match.group(1)!;
final base58EncodedPublicKeyString = match.group(2)!;
final sigBytes = bytesString.split(', ').map(int.parse).toList();
List<int> pubKeyBytes = SolAddrDecoder().decodeAddr(base58EncodedPublicKeyString);
return [sigBytes, pubKeyBytes];
} else {
throw const FormatException('Invalid Signature string format');
}
}
@override
Future<bool> verifyMessage(String message, String signature, {String? address}) async {
String signatureString = utf8.decode(HEX.decode(signature));
List<List<int>> bytes = bytesFromSigString(signatureString);
final messageBytes = utf8.encode(message);
final sigBytes = bytes[0];
final pubKeyBytes = bytes[1];
if (address == null) {
if (address == null || address.isEmpty) {
return false;
}
// make sure the address derived from the public key provided matches the one we expect
final pub = SolanaPublicKey.fromBytes(pubKeyBytes);
if (address != pub.toAddress().address) {
try {
final signatureBytes = Base58Decoder.decode(signature);
final publicKey = SolanaPublicKey.fromBytes(SolAddrDecoder().decodeAddr(address));
return publicKey.verify(
message: utf8.encode(message),
signature: signatureBytes,
);
} catch (e) {
printV("Error verifying solana message: ${e.toString()}");
return false;
}
return pub.verify(
message: messageBytes,
signature: sigBytes,
);
}
SolanaRPC? get solanaProvider => _client.getSolanaProvider;
+3 -3
View File
@@ -92,7 +92,7 @@ class SolanaWalletService extends WalletService<
@override
Future<void> remove(String wallet) async {
File(await pathForWalletDir(name: wallet, type: getType())).delete(recursive: true);
await File(await pathForWalletDir(name: wallet, type: getType())).delete(recursive: true);
final walletInfo = await WalletInfo.get(wallet, getType());
if (walletInfo == null) {
throw Exception('Wallet not found');
@@ -104,8 +104,8 @@ class SolanaWalletService extends WalletService<
}
final prefs = await SharedPreferences.getInstance();
for (final key in prefs.getKeys().where(
(k) => k.startsWith('solana_last_synced_signature_${wallet}_'))) {
for (final key
in prefs.getKeys().where((k) => k.startsWith('solana_last_synced_signature_${wallet}_'))) {
await prefs.remove(key);
}
}
@@ -0,0 +1,108 @@
import "package:cw_core/crypto_currency.dart";
import "package:cw_core/spl_token.dart";
import "package:cw_solana/solana_client.dart";
import "package:cw_solana/solana_exceptions.dart";
import "package:cw_solana/solana_wallet.dart";
import "package:flutter_test/flutter_test.dart";
void main() {
group("currencyForRawAmount", () {
test("keeps the stored token when its decimals match the mint", () {
final jup = SPLToken(
name: "Jupiter",
symbol: "JUP",
mint: "jup",
mintAddress: "JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN",
decimal: 6,
);
expect(SolanaWalletClient.currencyForRawAmount(jup, 6), same(jup));
});
test("prefers the mint decimals when the stored token disagrees", () {
final staleJup = SPLToken(
name: "Jupiter",
symbol: "JUP",
mint: "jup",
mintAddress: "JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN",
decimal: 0,
);
final resolved = SolanaWalletClient.currencyForRawAmount(staleJup, 6);
expect(resolved.decimals, 6);
expect(resolved.title, "JUP");
expect(resolved, isNot(same(staleJup)));
});
test("falls back to a placeholder title when the token is unknown", () {
final resolved = SolanaWalletClient.currencyForRawAmount(null, 8);
expect(resolved.decimals, 8);
expect(resolved.title, "TOKEN");
expect(resolved.name, "token");
});
test("always reports the mint decimals", () {
for (final decimals in [0, 1, 6, 8, 9, 18]) {
expect(SolanaWalletClient.currencyForRawAmount(null, decimals).decimals, decimals);
}
});
});
group("resolveTransactionCurrency", () {
final realUsdc = SPLToken(
name: "USD Coin",
symbol: "USDC",
mint: "usdc",
mintAddress: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
decimal: 6,
);
final scamUsdc = SPLToken(
name: "USD Coin",
symbol: "USDC",
mint: "usdc",
mintAddress: "scamMintAddressClaimingTheUsdcSymbol",
decimal: 9,
);
test("picks the requested mint even when another token claims the symbol", () {
final resolved = SolanaWalletBase.resolveTransactionCurrency(realUsdc, [scamUsdc, realUsdc]);
expect(resolved, same(realUsdc));
expect((resolved as SPLToken).mintAddress, realUsdc.mintAddress);
});
test("picks the scam mint only when the scam mint is the one requested", () {
final resolved = SolanaWalletBase.resolveTransactionCurrency(scamUsdc, [scamUsdc, realUsdc]);
expect(resolved, same(scamUsdc));
});
test("throws when the requested mint is not in the wallet", () {
expect(
() => SolanaWalletBase.resolveTransactionCurrency(realUsdc, [scamUsdc]),
throwsA(isA<Exception>()),
);
});
test("throws on ambiguity when a symbol lookup matches two tokens", () {
expect(
() => SolanaWalletBase.resolveTransactionCurrency(
CryptoCurrency(name: "usdc", title: "USDC", decimals: 6, tag: "SOL"),
[scamUsdc, realUsdc],
),
throwsA(isA<SolanaAmbiguousTokenSymbolException>()),
);
});
test("resolves a native currency by title and tag", () {
expect(
SolanaWalletBase.resolveTransactionCurrency(
CryptoCurrency.sol, [CryptoCurrency.sol, realUsdc]),
same(CryptoCurrency.sol),
);
});
});
}
@@ -631,6 +631,7 @@ class CakePayBuyCardPage extends BasePage {
amountValue: _sendViewModel.amountParsingProxy
.asDisplayStringWithSymbol(_sendViewModel.pendingTransaction!.amount),
quantity: 'QTY: ${cakePayBuyCardViewModel.quantity}',
explanation: _sendViewModel.pendingTransactionAdditionalCostNotice,
fiatAmountValue: _sendViewModel.pendingTransactionFiatAmountFormatted,
fee: S.of(bottomSheetContext).send_fee,
feeValue: _sendViewModel.amountParsingProxy
@@ -138,6 +138,10 @@ class JupiterExchangeProvider extends ExchangeProvider {
if (!_isSolanaCurrency(from) || !_isSolanaCurrency(to)) {
return 0.0;
}
if (amount <= 0) {
return 0.0;
}
final inputMint = _getTokenMint(from);
final outputMint = _getTokenMint(to);
@@ -100,9 +100,9 @@ class HistoryTile extends StatelessWidget {
children: [
Opacity(
opacity: pending ? 0.5 : 1,
child: CakeImageWidget(
child: TokenImageWidget(
imageUrl: asset?.iconPath ?? "",
width: 34,
size: 34,
),
),
Align(
@@ -201,6 +201,7 @@ class SendTransactionDetails extends StatelessWidget {
Widget _buildMainContent(BuildContext context) {
return Observer(builder: (context) {
final transaction = sendViewModel.pendingTransaction;
final additionalCostNotice = sendViewModel.pendingTransactionAdditionalCostNotice;
final currencySymbol =
sendViewModel.amountParsingProxy.getCryptoSymbol(sendViewModel.selectedCryptoCurrency);
@@ -398,6 +399,26 @@ class SendTransactionDetails extends StatelessWidget {
),
),
),
if (additionalCostNotice != null) ...[
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Container(
height: 1,
color: Theme.of(context).colorScheme.surfaceContainerHigh,
),
),
Padding(
padding: const EdgeInsets.all(12),
child: Text(
additionalCostNotice,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w400,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
],
if (sendViewModel.isElectrumWallet) ...[
Padding(
padding: EdgeInsets.symmetric(horizontal: 12),
+8 -4
View File
@@ -130,11 +130,11 @@ class CWSolana extends Solana {
final token = (wallet as SolanaWallet).splTokenBySymbol(transaction.amount.currency.symbol);
if (token == null) {
throw StateError('No SPL token for symbol ${transaction.amount.currency.symbol}');
if (token != null) {
return token;
}
return token;
return transaction.amount.currency as CryptoCurrency;
}
@override
@@ -276,6 +276,8 @@ class CWSolana extends Solana {
'Jupiter swap returned unknown status: $status. Error: $errorMessage. Code: $errorCode',
);
}
} on JupiterSwapFailedException {
rethrow;
} catch (e) {
throw Exception('Failed to execute Jupiter swap: $e');
}
@@ -420,7 +422,9 @@ class CWSolana extends Solana {
if (discoveredMints.isNotEmpty) {
await wallet.updateSPLTokenTransactions(specificMints: discoveredMints);
}
} catch (_) {}
} catch (e) {
printV("Error discovering wallet tokens: ${e.toString()}");
}
}
@override
@@ -328,6 +328,7 @@ class ExchangeTradeState extends State<ExchangeTradeForm> {
amount: S.of(bottomSheetContext).send_amount,
amountValue: sendVM.amountParsingProxy
.getDisplayCryptoAmount(amountValue, sendVM.selectedCryptoCurrency),
explanation: sendVM.pendingTransactionAdditionalCostNotice,
fiatAmountValue: fiatAmountValue,
fee: isEVMCompatibleChain(sendVM.walletType)
? S.of(bottomSheetContext).send_estimated_fee
+20 -4
View File
@@ -336,6 +336,18 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor
@observable
PendingTransaction? pendingTransaction;
String? get pendingTransactionAdditionalCostNotice {
final additionalCost = pendingTransaction?.additionalCost;
if (additionalCost == null) {
return null;
}
return S.current.recipient_account_creation_fee(
_appStore.amountParsingProxy.asDisplayStringWithSymbol(additionalCost),
);
}
@computed
String get balance {
if (walletType == WalletType.litecoin && coinTypeToSpendFrom == UnspentCoinType.mweb) {
@@ -1179,11 +1191,11 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor
await sharedPreferences.setString(PreferencesKey.backgroundSyncLastTrigger(wallet.name),
DateTime.now().add(Duration(minutes: 1)).toIso8601String());
} catch (e) {
if (e is JupiterSwapFailedException) {
await _updateSolanaTrade(signature: e.signature, isSuccess: false);
}
state = FailureState(translateErrorMessage(e, wallet.type, wallet.currency));
await _updateSolanaTrade(signature: '', isSuccess: false);
final failedSignature = e is JupiterSwapFailedException ? e.signature : "";
await _updateSolanaTrade(signature: failedSignature, isSuccess: false);
}
}
@@ -1509,6 +1521,10 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor
return S.current.solana_no_associated_token_account_exception;
}
if (error is AmbiguousTokenSymbolException) {
return S.current.ambiguous_token_symbol_exception(error.symbol);
}
if (errorMessage.contains('found no record of a prior credit')) {
return S.current.insufficient_funds_for_tx;
}
+2
View File
@@ -61,6 +61,7 @@
"already_have_account": "هل لديك حساب بالفعل؟",
"already_your_username": "هذا هو اسم المستخدم الخاص بك بالفعل!",
"always": "دائمًا",
"ambiguous_token_symbol_exception": "يستخدم أكثر من رمز مميز في هذه المحفظة الرمز ${symbol}. قم بتعطيل الرمز المميز المكرر حتى يمكن التعرف على الرمز الصحيح قبل الإرسال.",
"amount": "الكمية: ",
"amount_is_below_minimum_limit": "سيكون رصيدك بعد الرسوم أقل من الحد الأدنى للمبلغ المطلوب للتبادل (${min})",
"amount_is_estimate": "مبلغ الاستلام تقديري",
@@ -845,6 +846,7 @@
"received": "تم الاستلام",
"receiving": "الاستلام",
"recieving": "جارٍ الاستلام",
"recipient_account_creation_fee": "هناك حاجة إلى ${amount} إضافية لإنشاء حساب رمزي للمستلم، لأنه ليس لديه حساب حتى الآن.",
"recipient_address": "عنوان المستلم",
"reconnect": "إعادة الاتصال",
"reconnect_alert_text": "هل أنت متأكد أنك تريد إعادة الاتصال؟",
+2
View File
@@ -61,6 +61,7 @@
"already_have_account": "Вече имате акаунт?",
"already_your_username": "Това вече е вашето потребителско име!",
"always": "Винаги",
"ambiguous_token_symbol_exception": "Повече от един токен в този портфейл използва символа ${symbol}. Деактивирайте дублиращия се токен, за да може правилният да бъде идентифициран преди изпращане.",
"amount": "Количество: ",
"amount_is_below_minimum_limit": "Вашият баланс след таксите ще бъде по-малък от минималната сума, необходима за обмяната (${min})",
"amount_is_estimate": "Сумата за получаване е приблизителна",
@@ -845,6 +846,7 @@
"received": "Получено",
"receiving": "Получаване",
"recieving": "Получаване",
"recipient_account_creation_fee": "Необходими са допълнителни ${amount} за създаване на токен акаунт за получателя, тъй като той все още няма такъв.",
"recipient_address": "Адрес на получателя",
"reconnect": "Повторно свързване",
"reconnect_alert_text": "Сигурни ли сте, че искате да се свържете отново?",
+2
View File
@@ -61,6 +61,7 @@
"already_have_account": "Už máte účet?",
"already_your_username": "Toto už je vaše uživatelské jméno!",
"always": "Vždy",
"ambiguous_token_symbol_exception": "Více než jeden token v této peněžence používá symbol ${symbol}. Deaktivujte duplicitní token, aby bylo možné před odesláním identifikovat ten správný.",
"amount": "Částka: ",
"amount_is_below_minimum_limit": "Váš zůstatek po poplatcích by byl nižší než minimální částka potřebná pro směnu (${min})",
"amount_is_estimate": "Přijatá částka je pouze odhad.",
@@ -845,6 +846,7 @@
"received": "Přijato",
"receiving": "Přijímání",
"recieving": "Přijímání",
"recipient_account_creation_fee": "K vytvoření tokenového účtu pro příjemce je potřeba další ${amount}, protože ještě žádný nemá.",
"recipient_address": "Adresa příjemce",
"reconnect": "Znovu připojit",
"reconnect_alert_text": "Opravdu se chcete znovu připojit?",
+2
View File
@@ -63,6 +63,7 @@
"already_have_account": "Haben Sie bereits ein Konto?",
"already_your_username": "Das ist bereits Ihr Benutzername!",
"always": "Immer",
"ambiguous_token_symbol_exception": "Mehr als ein Token in dieser Wallet verwendet das Symbol ${symbol}. Deaktivieren Sie das Duplikat-Token, damit das richtige vor dem Senden identifiziert werden kann.",
"amount": "Betrag: ",
"amount_hidden": "Versteckter Betrag",
"amount_is_below_minimum_limit": "Ihr Guthaben nach Gebühren läge unter dem für den Swap erforderlichen Mindestbetrag (${min}).",
@@ -866,6 +867,7 @@
"received": "Empfangen",
"receiving": "Empfangen",
"recieving": "Empfangen",
"recipient_account_creation_fee": "Für die Erstellung eines Token-Kontos für den Empfänger ist ein zusätzlicher Betrag von ${amount} erforderlich, da dieser noch keins hat.",
"recipient_address": "Empfängeradresse",
"recipient_number": "Empfänger ${number}",
"reconnect": "Erneut verbinden",
+2
View File
@@ -63,6 +63,7 @@
"already_have_account": "Already have an account?",
"already_your_username": "This is already your username!",
"always": "Always",
"ambiguous_token_symbol_exception": "More than one token in this wallet uses the symbol ${symbol}. Disable the duplicate token so the correct one can be identified before sending.",
"amount": "Amount: ",
"amount_hidden": "Amount hidden",
"amount_is_below_minimum_limit": "Your balance after fees would be less than the minimum amount needed for the exchange (${min})",
@@ -868,6 +869,7 @@
"received": "Received",
"receiving": "Receiving",
"recieving": "Recieving",
"recipient_account_creation_fee": "An extra ${amount} is needed to create a token account for the recipient, because they do not have one yet.",
"recipient_address": "Recipient address",
"recipient_number": "Recipient ${number}",
"reconnect": "Reconnect",
+2
View File
@@ -61,6 +61,7 @@
"already_have_account": "¿Ya tienes una cuenta?",
"already_your_username": "¡Este ya es tu nombre de usuario!",
"always": "Siempre",
"ambiguous_token_symbol_exception": "Más de un token en esta billetera usa el símbolo ${symbol}. Deshabilite el token duplicado para que se pueda identificar el correcto antes de enviarlo.",
"amount": "Cantidad: ",
"amount_is_below_minimum_limit": "Tu saldo después de las comisiones sería inferior al importe mínimo necesario para el intercambio (${min})",
"amount_is_estimate": "La cantidad a recibir es una estimación",
@@ -847,6 +848,7 @@
"received": "Recibido",
"receiving": "Recibiendo",
"recieving": "Recibiendo",
"recipient_account_creation_fee": "Se necesita ${amount} adicional para crear una cuenta simbólica para el destinatario, porque aún no tiene una.",
"recipient_address": "Dirección del destinatario",
"reconnect": "Reconectar",
"reconnect_alert_text": "¿Estás seguro de que quieres reconectar?",
+2
View File
@@ -61,6 +61,7 @@
"already_have_account": "از قبل حساب کاربری دارید؟",
"already_your_username": "این از قبل نام کاربری شماست!",
"always": "همیشه",
"ambiguous_token_symbol_exception": "بیش از یک توکن در این کیف پول از نماد ${symbol} استفاده می‌کند. رمز تکراری را غیرفعال کنید تا قبل از ارسال، کد صحیح شناسایی شود.",
"amount": "مقدار: ",
"amount_is_below_minimum_limit": "موجودی شما پس از کسر کارمزد کمتر از حداقل مقدار مورد نیاز برای مبادله (${min}) خواهد بود",
"amount_is_estimate": "مقدار دریافتی تقریبی است",
@@ -845,6 +846,7 @@
"received": "دریافت‌شده",
"receiving": "دریافت",
"recieving": "دریافت",
"recipient_account_creation_fee": "یک ${amount} اضافی برای ایجاد یک حساب رمزی برای گیرنده مورد نیاز است، زیرا آنها هنوز حسابی ندارند.",
"recipient_address": "آدرس دریافت‌کننده",
"reconnect": "اتصال دوباره",
"reconnect_alert_text": "آیا مطمئن هستید که می‌خواهید دوباره وصل شوید؟",
+2
View File
@@ -61,6 +61,7 @@
"already_have_account": "Vous avez déjà un compte ?",
"already_your_username": "C'est déjà votre nom d'utilisateur !",
"always": "Toujours",
"ambiguous_token_symbol_exception": "Plus d'un jeton dans ce portefeuille utilise le symbole ${symbol}. Désactivez le jeton en double afin que le bon puisse être identifié avant l'envoi.",
"amount": "Montant : ",
"amount_is_below_minimum_limit": "Votre solde après les frais serait inférieur au montant minimum requis pour l'échange (${min})",
"amount_is_estimate": "Le montant à recevoir est une estimation",
@@ -845,6 +846,7 @@
"received": "Reçu",
"receiving": "Réception",
"recieving": "Réception",
"recipient_account_creation_fee": "Un ${amount} supplémentaire est nécessaire pour créer un compte token pour le destinataire, car il n'en a pas encore.",
"recipient_address": "Adresse du destinataire",
"reconnect": "Reconnecter",
"reconnect_alert_text": "Êtes-vous sûr de vouloir vous reconnecter ?",
+2
View File
@@ -61,6 +61,7 @@
"already_have_account": "¿Erekóma peteĩ cuenta?",
"already_your_username": "¡Kóva ha'e nde puruhára réra voi!",
"always": "Tapia",
"ambiguous_token_symbol_exception": "Hetave peteĩ token ko billetera-pe oipuru símbolo ${symbol}. Embogue pe token duplicado ikatu hag̃uáicha ojekuaa pe hekopetegua oñemondo mboyve.",
"amount": "Hetakue: ",
"amount_is_below_minimum_limit": "Nde saldo, pe comisión rire, saʼivéta pe mínimo oñeikotevẽva hag̃ua pe intercambio (${min})",
"amount_is_estimate": "Pe monto oñemog̃uahẽtáva ha’e peteĩ jehecha hag̃a.",
@@ -849,6 +850,7 @@
"received": "Ojapyhy",
"receiving": "Oñemog̃uahẽ",
"recieving": "Oñemog̃uahẽ",
"recipient_account_creation_fee": "Oñeikotevẽ peteĩ ${amount} extra ojejapo hag̃ua peteĩ cuenta token rehegua pe ohupytysévape g̃uarã, ndorekóigui gueteri hikuái peteĩ.",
"recipient_address": "Ojapyhýva kundaharape",
"reconnect": "Eñembojoaju jey",
"reconnect_alert_text": "¿Reime añetehápe reñembojoaju jeyse?",
+2
View File
@@ -61,6 +61,7 @@
"already_have_account": "Kana da asusu tuni?",
"already_your_username": "Wannan tuni sunan mai amfani naka ne!",
"always": "Koyaushe",
"ambiguous_token_symbol_exception": "Alama fiye da ɗaya a cikin wannan jakar tana amfani da alamar ${symbol}. Kashe alamar kwafin don a iya gano madaidaicin kafin aikawa.",
"amount": "Adadi: ",
"amount_is_below_minimum_limit": "Ma'auninka bayan kuɗaɗen ma'amala zai zama ƙasa da mafi ƙarancin adadin da ake buƙata don musayar (${min})",
"amount_is_estimate": "Adadin da za a karɓa kimantawa ne",
@@ -847,6 +848,7 @@
"received": "An karɓa",
"receiving": "Karɓa",
"recieving": "Ana karɓa",
"recipient_account_creation_fee": "Ana buƙatar ƙarin ${amount} don ƙirƙirar asusun token ga mai karɓa, saboda ba su da ɗaya tukuna.",
"recipient_address": "Adireshin mai karɓa",
"reconnect": "Sake haɗawa",
"reconnect_alert_text": "Shin kun tabbata kuna son sake haɗawa?",
+2
View File
@@ -61,6 +61,7 @@
"already_have_account": "क्या आपके पास पहले से एक खाता है?",
"already_your_username": "यह पहले से ही आपका यूज़रनेम है!",
"always": "हमेशा",
"ambiguous_token_symbol_exception": "इस वॉलेट में एक से अधिक टोकन प्रतीक ${symbol} का उपयोग करते हैं। डुप्लिकेट टोकन को अक्षम करें ताकि भेजने से पहले सही टोकन की पहचान की जा सके।",
"amount": "राशि: ",
"amount_is_below_minimum_limit": "फीस के बाद आपका बैलेंस एक्सचेंज के लिए आवश्यक न्यूनतम राशि (${min}) से कम होगा",
"amount_is_estimate": "प्राप्त होने वाली राशि अनुमानित है",
@@ -847,6 +848,7 @@
"received": "प्राप्त",
"receiving": "प्राप्त करना",
"recieving": "प्राप्त हो रहा है",
"recipient_account_creation_fee": "प्राप्तकर्ता के लिए एक टोकन खाता बनाने के लिए अतिरिक्त ${amount} की आवश्यकता होती है, क्योंकि उनके पास अभी तक कोई खाता नहीं है।",
"recipient_address": "प्राप्तकर्ता का पता",
"reconnect": "पुनः कनेक्ट करें",
"reconnect_alert_text": "क्या आप वाकई फिर से कनेक्ट करना चाहते हैं?",
+2
View File
@@ -61,6 +61,7 @@
"already_have_account": "Već imate račun?",
"already_your_username": "Ovo je već vaše korisničko ime!",
"always": "Uvijek",
"ambiguous_token_symbol_exception": "Više od jednog tokena u ovom novčaniku koristi simbol ${symbol}. Onemogućite dvostruki token kako bi se ispravan mogao identificirati prije slanja.",
"amount": "Iznos: ",
"amount_is_below_minimum_limit": "Vaš saldo nakon naknada bio bi manji od minimalnog iznosa potrebnog za razmjenu (${min})",
"amount_is_estimate": "Primljeni iznos je procijenjen",
@@ -845,6 +846,7 @@
"received": "Primljeno",
"receiving": "Primanje",
"recieving": "Primanje",
"recipient_account_creation_fee": "Dodatni iznos od ${amount} je potreban za stvaranje token računa za primatelja jer ga on još nema.",
"recipient_address": "Adresa primatelja",
"reconnect": "Ponovno poveži",
"reconnect_alert_text": "Jeste li sigurni da se želite ponovno povezati?",
+2
View File
@@ -61,6 +61,7 @@
"already_have_account": "Արդեն ունե՞ք հաշիվ։",
"already_your_username": "Սա արդեն ձեր օգտանունն է։",
"always": "Միշտ",
"ambiguous_token_symbol_exception": "Այս դրամապանակում մեկից ավելի թոքեններ օգտագործում են ${symbol} խորհրդանիշը: Անջատեք կրկնօրինակ նշանը, որպեսզի ճիշտը հնարավոր լինի նույնացնել նախքան ուղարկելը:",
"amount": "Գումար՝ ",
"amount_is_below_minimum_limit": "Միջնորդավճարներից հետո Ձեր մնացորդը կլինի փոխանակման համար անհրաժեշտ նվազագույն գումարից (${min}) պակաս",
"amount_is_estimate": "Ստացվող գումարը մոտավոր է",
@@ -845,6 +846,7 @@
"received": "Ստացված",
"receiving": "Ստացում",
"recieving": "Ստացում",
"recipient_account_creation_fee": "Ստացողի համար խորհրդանշական հաշիվ ստեղծելու համար անհրաժեշտ է լրացուցիչ ${amount}, քանի որ նրանք դեռ չունեն:",
"recipient_address": "Ստացողի հասցե",
"reconnect": "Վերամիանալ",
"reconnect_alert_text": "Վստա՞հ եք, որ ցանկանում եք կրկին միանալ:",
+2
View File
@@ -61,6 +61,7 @@
"already_have_account": "Sudah punya akun?",
"already_your_username": "Ini sudah merupakan nama pengguna Anda!",
"always": "Selalu",
"ambiguous_token_symbol_exception": "Lebih dari satu token di dompet ini menggunakan simbol ${symbol}. Nonaktifkan token duplikat sehingga token yang benar dapat diidentifikasi sebelum dikirim.",
"amount": "Jumlah: ",
"amount_is_below_minimum_limit": "Saldo Anda setelah biaya akan lebih rendah dari jumlah minimum yang diperlukan untuk penukaran (${min})",
"amount_is_estimate": "Jumlah yang diterima adalah perkiraan",
@@ -847,6 +848,7 @@
"received": "Diterima",
"receiving": "Menerima",
"recieving": "Menerima",
"recipient_account_creation_fee": "${amount} tambahan diperlukan untuk membuat akun token bagi penerima, karena mereka belum memilikinya.",
"recipient_address": "Alamat penerima",
"reconnect": "Hubungkan kembali",
"reconnect_alert_text": "Apakah Anda yakin ingin terhubung kembali?",
+3 -1
View File
@@ -61,6 +61,7 @@
"already_have_account": "Hai già un account?",
"already_your_username": "Questo è già il tuo nome utente!",
"always": "Sempre",
"ambiguous_token_symbol_exception": "Più di un token in questo portafoglio utilizza il simbolo ${symbol}. Disabilitare il token duplicato in modo da poter identificare quello corretto prima dell'invio.",
"amount": "Importo: ",
"amount_is_below_minimum_limit": "Il saldo dopo le commissioni sarebbe inferiore all'importo minimo richiesto per lo scambio (${min})",
"amount_is_estimate": "L'importo da ricevere è una stima",
@@ -1476,5 +1477,6 @@
"zcash_card_enable_later": "Puoi sempre abilitare questa carta in seguito nelle impostazioni",
"zcash_card_missing_funds": "Fondi mancanti?",
"zcash_card_scan": "Scansiona",
"zcash_card_warning": "Non chiudere l'app finché la procedura non è completata, altrimenti sarà necessario riavviare il processo da zero."
"zcash_card_warning": "Non chiudere l'app finché la procedura non è completata, altrimenti sarà necessario riavviare il processo da zero.",
"recipient_account_creation_fee": "È necessario un extra di ${amount} per creare un account token per il destinatario, perché non ne ha ancora uno."
}
+3 -1
View File
@@ -61,6 +61,7 @@
"already_have_account": "すでにアカウントをお持ちですか?",
"already_your_username": "これはすでにあなたのユーザー名です!",
"always": "常に",
"ambiguous_token_symbol_exception": "このウォレット内の複数のトークンは、シンボル ${symbol} を使用しています。送信前に正しいトークンを識別できるように、重複したトークンを無効にします。",
"amount": "金額: ",
"amount_is_below_minimum_limit": "手数料差し引き後の残高が、交換に必要な最低金額(${min})を下回ります",
"amount_is_estimate": "受け取り金額は概算です",
@@ -1474,5 +1475,6 @@
"zcash_card_enable_later": "このカードは後から設定でいつでも有効にできます",
"zcash_card_missing_funds": "資金が見当たりませんか?",
"zcash_card_scan": "スキャン",
"zcash_card_warning": "手順が完了するまでアプリを閉じないでください。閉じると、このプロセスを最初からやり直す必要があります。"
"zcash_card_warning": "手順が完了するまでアプリを閉じないでください。閉じると、このプロセスを最初からやり直す必要があります。",
"recipient_account_creation_fee": "受信者はまだトークン アカウントを持っていないため、追加の ${amount} が必要です。"
}
+3 -1
View File
@@ -61,6 +61,7 @@
"already_have_account": "이미 계정이 있으신가요?",
"already_your_username": "이미 사용 중인 사용자 이름입니다!",
"always": "항상",
"ambiguous_token_symbol_exception": "이 지갑에 있는 두 개 이상의 토큰이 ${symbol} 기호를 사용합니다. 전송하기 전에 올바른 토큰을 식별할 수 있도록 중복 토큰을 비활성화합니다.",
"amount": "수량: ",
"amount_is_below_minimum_limit": "수수료 차감 후 잔액이 교환에 필요한 최소 금액(${min})보다 적을 것입니다",
"amount_is_estimate": "수신 금액은 예상치입니다",
@@ -1473,5 +1474,6 @@
"zcash_card_enable_later": "설정에서 언제든지 나중에 이 카드를 활성화할 수 있습니다.",
"zcash_card_missing_funds": "자금이 사라졌나요?",
"zcash_card_scan": "스캔",
"zcash_card_warning": "절차가 완료될 때까지 앱을 닫지 마십시오. 닫으면 이 과정을 처음부터 다시 시작해야 합니다."
"zcash_card_warning": "절차가 완료될 때까지 앱을 닫지 마십시오. 닫으면 이 과정을 처음부터 다시 시작해야 합니다.",
"recipient_account_creation_fee": "아직 토큰 계정이 없기 때문에 수신자를 위한 토큰 계정을 생성하려면 추가 ${amount}가 필요합니다."
}
+3 -1
View File
@@ -61,6 +61,7 @@
"already_have_account": "အကောင့်ရှိပြီးသားလား?",
"already_your_username": "ဒါက သင့်အသုံးပြုသူအမည် ဖြစ်ပြီးသားပါ!",
"always": "အမြဲတမ်း",
"ambiguous_token_symbol_exception": "ဤပိုက်ဆံအိတ်ရှိ တိုကင်တစ်ခုထက်ပိုသော သင်္ကေတ ${symbol} ကို အသုံးပြုသည်။ မပို့မီ မှန်ကန်သောတစ်ခုကို ဖော်ထုတ်နိုင်စေရန် ပွားနေသော တိုကင်ကို ပိတ်ပါ။",
"amount": "ပမာဏ: ",
"amount_is_below_minimum_limit": "အခကြေးငွေများနုတ်ပြီးနောက် သင့်လက်ကျန်ငွေသည် လဲလှယ်ရန်လိုအပ်သည့် အနိမ့်ဆုံးပမာဏ (${min}) ထက် နည်းသွားမည်",
"amount_is_estimate": "လက်ခံရရှိမည့်ပမာဏသည် ခန့်မှန်းတန်ဖိုးဖြစ်သည်။",
@@ -1473,5 +1474,6 @@
"zcash_card_enable_later": "သင်သည် ဤကတ်ကို ဆက်တင်များတွင် နောက်မှ အချိန်မရွေး ဖွင့်နိုင်ပါသည်။",
"zcash_card_missing_funds": "ငွေကြေး ပျောက်ဆုံးနေပါသလား?",
"zcash_card_scan": "စကင်",
"zcash_card_warning": "လုပ်ငန်းစဉ် ပြီးဆုံးသည်အထိ အက်ပ်ကို မပိတ်ပါနှင့်။ ပိတ်လိုက်ပါက ဤလုပ်ငန်းစဉ်ကို အစမှ ပြန်လည်စတင်ရမည်ဖြစ်သည်။"
"zcash_card_warning": "လုပ်ငန်းစဉ် ပြီးဆုံးသည်အထိ အက်ပ်ကို မပိတ်ပါနှင့်။ ပိတ်လိုက်ပါက ဤလုပ်ငန်းစဉ်ကို အစမှ ပြန်လည်စတင်ရမည်ဖြစ်သည်။",
"recipient_account_creation_fee": "လက်ခံသူအတွက် တိုကင်အကောင့်တစ်ခုဖန်တီးရန် ၎င်းတို့တွင် တစ်ခုမရှိသေးသောကြောင့် အပို ${amount} လိုအပ်ပါသည်။"
}
+3 -1
View File
@@ -61,6 +61,7 @@
"already_have_account": "Heb je al een account?",
"already_your_username": "Dit is al je gebruikersnaam!",
"always": "Altijd",
"ambiguous_token_symbol_exception": "Meer dan één token in deze portemonnee gebruikt het symbool ${symbol}. Schakel het dubbele token uit, zodat het juiste token kan worden geïdentificeerd voordat het wordt verzonden.",
"amount": "Bedrag: ",
"amount_is_below_minimum_limit": "Je saldo na kosten zou lager zijn dan het minimale bedrag dat nodig is voor de exchange (${min})",
"amount_is_estimate": "Het te ontvangen bedrag is een schatting",
@@ -1474,5 +1475,6 @@
"zcash_card_enable_later": "Je kunt deze kaart later altijd inschakelen via de instellingen",
"zcash_card_missing_funds": "Fondsen ontbreken?",
"zcash_card_scan": "Scannen",
"zcash_card_warning": "Sluit de app niet voordat de procedure is voltooid. Als je dit wel doet, moet dit proces helemaal opnieuw worden gestart."
"zcash_card_warning": "Sluit de app niet voordat de procedure is voltooid. Als je dit wel doet, moet dit proces helemaal opnieuw worden gestart.",
"recipient_account_creation_fee": "Er is een extra ${amount} nodig om een ​​tokenaccount voor de ontvanger aan te maken, omdat deze er nog geen heeft."
}
+3 -1
View File
@@ -61,6 +61,7 @@
"already_have_account": "Masz już konto?",
"already_your_username": "To już jest Twoja nazwa użytkownika!",
"always": "Zawsze",
"ambiguous_token_symbol_exception": "Więcej niż jeden token w tym portfelu używa symbolu ${symbol}. Wyłącz duplikat tokena, aby przed wysłaniem można było zidentyfikować właściwy.",
"amount": "Kwota: ",
"amount_is_below_minimum_limit": "Twoje saldo po opłatach byłoby niższe niż minimalna kwota wymagana do wymiany (${min})",
"amount_is_estimate": "Otrzymywana kwota jest szacunkowa",
@@ -1473,5 +1474,6 @@
"zcash_card_enable_later": "Zawsze możesz włączyć tę kartę później w ustawieniach",
"zcash_card_missing_funds": "Brakuje środków?",
"zcash_card_scan": "Skanuj",
"zcash_card_warning": "Nie zamykaj aplikacji do czasu zakończenia procedury. Jeśli to zrobisz, proces będzie musiał rozpocząć się od nowa."
"zcash_card_warning": "Nie zamykaj aplikacji do czasu zakończenia procedury. Jeśli to zrobisz, proces będzie musiał rozpocząć się od nowa.",
"recipient_account_creation_fee": "Do założenia konta tokenowego dla odbiorcy potrzebna jest dodatkowa ${amount}, ponieważ jeszcze takiego nie posiada."
}
+3 -1
View File
@@ -61,6 +61,7 @@
"already_have_account": "Já tem uma conta?",
"already_your_username": "Este já é o seu nome de utilizador!",
"always": "Sempre",
"ambiguous_token_symbol_exception": "Mais de um token nesta carteira usa o símbolo ${symbol}. Desative o token duplicado para que o correto possa ser identificado antes do envio.",
"amount": "Quantidade: ",
"amount_is_below_minimum_limit": "Seu saldo após as taxas seria menor do que o valor mínimo necessário para a troca (${min})",
"amount_is_estimate": "O valor a receber é uma estimativa",
@@ -1476,5 +1477,6 @@
"zcash_card_enable_later": "Você sempre pode ativar este cartão mais tarde nas configurações",
"zcash_card_missing_funds": "Fundos em falta?",
"zcash_card_scan": "Escanear",
"zcash_card_warning": "Não feche o aplicativo até que o procedimento seja concluído; se você fizer isso, esse processo precisará ser reiniciado do zero."
"zcash_card_warning": "Não feche o aplicativo até que o procedimento seja concluído; se você fizer isso, esse processo precisará ser reiniciado do zero.",
"recipient_account_creation_fee": "É necessário um ${amount} extra para criar uma conta token para o destinatário, porque ele ainda não tem uma."
}
+2
View File
@@ -58,6 +58,7 @@
"already_have_account": "Já tem uma conta?",
"already_your_username": "Este já é o seu nome de usuário!",
"always": "Sempre",
"ambiguous_token_symbol_exception": "Mais de um token nesta carteira usa o símbolo ${symbol}. Desative o token duplicado para que o correto possa ser identificado antes do envio.",
"amount": "Quantidade: ",
"amount_is_below_minimum_limit": "Seu saldo após as taxas seria menor do que o valor mínimo necessário para a troca (${min})",
"amount_is_estimate": "O valor a receber é uma estimativa",
@@ -802,6 +803,7 @@
"received": "Recebido",
"receiving": "Recebendo",
"recieving": "Recebendo",
"recipient_account_creation_fee": "É necessário um ${amount} extra para criar uma conta token para o destinatário, porque ele ainda não tem uma.",
"recipient_address": "Endereço do destinatário",
"reconnect": "Reconectar",
"reconnect_alert_text": "Tem certeza de que deseja reconectar?",
+3 -1
View File
@@ -61,6 +61,7 @@
"already_have_account": "У вас уже есть аккаунт?",
"already_your_username": "Это уже ваше имя пользователя!",
"always": "Всегда",
"ambiguous_token_symbol_exception": "Более одного токена в этом кошельке используют символ ${symbol}. Отключите дубликат токена, чтобы можно было определить правильный перед отправкой.",
"amount": "Сумма: ",
"amount_is_below_minimum_limit": "Ваш баланс после комиссий будет меньше минимальной суммы, необходимой для обмена (${min})",
"amount_is_estimate": "Сумма к получению является приблизительной",
@@ -1474,5 +1475,6 @@
"zcash_card_enable_later": "Вы всегда можете включить эту карту позже в настройках",
"zcash_card_missing_funds": "Не хватает средств?",
"zcash_card_scan": "Сканировать",
"zcash_card_warning": "Не закрывайте приложение до завершения процедуры. Если вы это сделаете, процесс придется перезапустить с нуля."
"zcash_card_warning": "Не закрывайте приложение до завершения процедуры. Если вы это сделаете, процесс придется перезапустить с нуля.",
"recipient_account_creation_fee": "Для создания учетной записи токена для получателя требуется дополнительная сумма ${amount}, поскольку у него ее еще нет."
}
+3 -1
View File
@@ -61,6 +61,7 @@
"already_have_account": "มีบัญชีอยู่แล้วใช่ไหม?",
"already_your_username": "นี่คือชื่อผู้ใช้ของคุณอยู่แล้ว!",
"always": "เสมอ",
"ambiguous_token_symbol_exception": "โทเค็นมากกว่าหนึ่งรายการในกระเป๋าเงินนี้ใช้สัญลักษณ์ ${symbol} ปิดใช้งานโทเค็นที่ซ้ำกันเพื่อให้สามารถระบุโทเค็นที่ถูกต้องก่อนส่ง",
"amount": "จำนวน: ",
"amount_is_below_minimum_limit": "ยอดคงเหลือของคุณหลังหักค่าธรรมเนียมจะน้อยกว่าจำนวนขั้นต่ำที่จำเป็นสำหรับการแลกเปลี่ยน (${min})",
"amount_is_estimate": "จำนวนเงินที่ได้รับเป็นการประมาณการ",
@@ -1473,5 +1474,6 @@
"zcash_card_enable_later": "คุณสามารถเปิดใช้งานการ์ดนี้ได้เสมอในภายหลังในการตั้งค่า",
"zcash_card_missing_funds": "ยอดเงินหายไป?",
"zcash_card_scan": "สแกน",
"zcash_card_warning": "อย่าปิดแอปจนกว่าขั้นตอนจะเสร็จสิ้น หากคุณปิดแอป กระบวนการนี้จะต้องเริ่มใหม่ตั้งแต่ต้น"
"zcash_card_warning": "อย่าปิดแอปจนกว่าขั้นตอนจะเสร็จสิ้น หากคุณปิดแอป กระบวนการนี้จะต้องเริ่มใหม่ตั้งแต่ต้น",
"recipient_account_creation_fee": "จำเป็นต้องมี ${amount} เพิ่มเติมเพื่อสร้างบัญชีโทเค็นสำหรับผู้รับ เนื่องจากยังไม่มีบัญชี"
}
+3 -1
View File
@@ -61,6 +61,7 @@
"already_have_account": "Mayroon ka nang account?",
"already_your_username": "Ito na ang username mo!",
"always": "Palagi",
"ambiguous_token_symbol_exception": "Higit sa isang token sa wallet na ito ang gumagamit ng simbolo na ${symbol}. I-disable ang duplicate na token para matukoy ang tama bago ipadala.",
"amount": "Halaga: ",
"amount_is_below_minimum_limit": "Ang iyong balanse pagkatapos ng mga bayarin ay magiging mas mababa kaysa sa minimum na halagang kailangan para sa palitan (${min})",
"amount_is_estimate": "Ang halagang matatanggap ay isang pagtatantya",
@@ -1473,5 +1474,6 @@
"zcash_card_enable_later": "Maaari mo itong paganahin anumang oras sa ibang pagkakataon sa mga setting",
"zcash_card_missing_funds": "Nawawalang pondo?",
"zcash_card_scan": "I-scan",
"zcash_card_warning": "Huwag isara ang app hanggang sa makumpleto ang proseso; kung gagawin mo ito, kakailanganing magsimula muli mula sa simula."
"zcash_card_warning": "Huwag isara ang app hanggang sa makumpleto ang proseso; kung gagawin mo ito, kakailanganing magsimula muli mula sa simula.",
"recipient_account_creation_fee": "Kailangan ng dagdag na ${amount} para gumawa ng token account para sa tatanggap, dahil wala pa sila nito."
}
+3 -1
View File
@@ -61,6 +61,7 @@
"already_have_account": "Zaten bir hesabınız var mı?",
"already_your_username": "Bu zaten kullanıcı adınız!",
"always": "Her zaman",
"ambiguous_token_symbol_exception": "Bu cüzdanda birden fazla token ${symbol} sembolünü kullanıyor. Göndermeden önce doğru jetonun belirlenebilmesi için yinelenen jetonu devre dışı bırakın.",
"amount": "Miktar: ",
"amount_is_below_minimum_limit": "Ücretlerden sonra bakiyeniz, takas için gereken minimum miktardan daha az olur (${min})",
"amount_is_estimate": "Alınacak tutar tahminidir",
@@ -1473,5 +1474,6 @@
"zcash_card_enable_later": "Bu kartı daha sonra ayarlardan her zaman etkinleştirebilirsiniz",
"zcash_card_missing_funds": "Bakiye eksik mi?",
"zcash_card_scan": "Tara",
"zcash_card_warning": "İşlem tamamlanana kadar uygulamayı kapatmayın; aksi takdirde bu işlemin baştan yeniden başlatılması gerekecektir."
"zcash_card_warning": "İşlem tamamlanana kadar uygulamayı kapatmayın; aksi takdirde bu işlemin baştan yeniden başlatılması gerekecektir.",
"recipient_account_creation_fee": "Alıcının henüz bir token hesabı olmadığı için, alıcı için bir token hesabı oluşturmak için fazladan ${amount} gerekiyor."
}
+3 -1
View File
@@ -61,6 +61,7 @@
"already_have_account": "Вже маєте обліковий запис?",
"already_your_username": "Це вже ваше ім’я користувача!",
"always": "Завжди",
"ambiguous_token_symbol_exception": "Більш ніж один токен у цьому гаманці використовує символ ${symbol}. Вимкніть повторюваний маркер, щоб можна було визначити правильний перед надсиланням.",
"amount": "Сума: ",
"amount_is_below_minimum_limit": "Ваш баланс після комісій буде меншим за мінімальну суму, необхідну для обміну (${min})",
"amount_is_estimate": "Сума отримання є приблизною",
@@ -1474,5 +1475,6 @@
"zcash_card_enable_later": "Ви завжди можете ввімкнути цю картку пізніше в налаштуваннях",
"zcash_card_missing_funds": "Зникли кошти?",
"zcash_card_scan": "Сканувати",
"zcash_card_warning": "Не закривайте застосунок, доки процедура не завершиться. Якщо ви це зробите, цей процес потрібно буде перезапустити з нуля."
"zcash_card_warning": "Не закривайте застосунок, доки процедура не завершиться. Якщо ви це зробите, цей процес потрібно буде перезапустити з нуля.",
"recipient_account_creation_fee": "Потрібна додаткова сума в розмірі ${amount}, щоб створити обліковий запис-токен для одержувача, оскільки він його ще не має."
}
+3 -1
View File
@@ -61,6 +61,7 @@
"already_have_account": "کیا آپ کے پاس پہلے سے اکاؤنٹ ہے؟",
"already_your_username": "یہ پہلے ہی آپ کا صارف نام ہے!",
"always": "ہمیشہ",
"ambiguous_token_symbol_exception": "اس بٹوے میں ایک سے زیادہ ٹوکن ${symbol} کی علامت استعمال کرتا ہے۔ ڈپلیکیٹ ٹوکن کو غیر فعال کریں تاکہ بھیجنے سے پہلے صحیح کی شناخت ہو سکے۔",
"amount": "رقم: ",
"amount_is_below_minimum_limit": "فیس کے بعد آپ کا بیلنس ایکسچینج کے لیے درکار کم از کم رقم (${min}) سے کم ہوگا",
"amount_is_estimate": "وصول ہونے والی رقم ایک تخمینہ ہے۔",
@@ -1475,5 +1476,6 @@
"zcash_card_enable_later": "آپ اس کارڈ کو بعد میں بھی ترتیبات میں فعال کر سکتے ہیں",
"zcash_card_missing_funds": "فنڈز غائب ہیں؟",
"zcash_card_scan": "اسکین کریں",
"zcash_card_warning": "طریقہ کار مکمل ہونے تک ایپ بند نہ کریں۔ اگر آپ ایسا کریں گے تو یہ عمل شروع سے دوبارہ شروع کرنا پڑے گا۔"
"zcash_card_warning": "طریقہ کار مکمل ہونے تک ایپ بند نہ کریں۔ اگر آپ ایسا کریں گے تو یہ عمل شروع سے دوبارہ شروع کرنا پڑے گا۔",
"recipient_account_creation_fee": "وصول کنندہ کے لیے ٹوکن اکاؤنٹ بنانے کے لیے ایک اضافی ${amount} کی ضرورت ہے، کیونکہ ان کے پاس ابھی تک کوئی اکاؤنٹ نہیں ہے۔"
}
+3 -1
View File
@@ -61,6 +61,7 @@
"already_have_account": "Bạn đã có tài khoản chưa?",
"already_your_username": "Đây đã là tên người dùng của bạn!",
"always": "Luôn",
"ambiguous_token_symbol_exception": "Nhiều mã thông báo trong ví này sử dụng ký hiệu ${symbol}. Vô hiệu hóa mã thông báo trùng lặp để có thể xác định mã thông báo chính xác trước khi gửi.",
"amount": "Số lượng: ",
"amount_is_below_minimum_limit": "Số dư của bạn sau khi trừ phí sẽ thấp hơn số tiền tối thiểu cần thiết để trao đổi (${min})",
"amount_is_estimate": "Số tiền nhận được chỉ là ước tính",
@@ -1474,5 +1475,6 @@
"zcash_card_enable_later": "Bạn luôn có thể bật thẻ này sau trong phần cài đặt",
"zcash_card_missing_funds": "Thiếu tiền?",
"zcash_card_scan": "Quét",
"zcash_card_warning": "Không đóng ứng dụng cho đến khi quy trình hoàn tất; nếu bạn làm vậy, quy trình này sẽ phải bắt đầu lại từ đầu."
"zcash_card_warning": "Không đóng ứng dụng cho đến khi quy trình hoàn tất; nếu bạn làm vậy, quy trình này sẽ phải bắt đầu lại từ đầu.",
"recipient_account_creation_fee": "Cần thêm ${amount} để tạo tài khoản mã thông báo cho người nhận vì họ chưa có tài khoản."
}
+3 -1
View File
@@ -61,6 +61,7 @@
"already_have_account": "Ṣé o ti ní àkántì tẹ́lẹ̀?",
"already_your_username": "Eyi ti jẹ orúkọ olumulo rẹ tẹlẹ!",
"always": "Nígbà gbogbo",
"ambiguous_token_symbol_exception": "Die e sii ju ami-ami kan ninu apamọwọ yii nlo aami ${symbol}. Pa àmi àdáwòkọ rẹ́ kí ó lè mọ èyí tí ó tọ́ kí ó tó fi ránṣẹ́.",
"amount": "Iye: ",
"amount_is_below_minimum_limit": "Iwontunwonsi rẹ lẹ́yìn àwọn owó ìdúná yóò kéré ju iye kéréjù tí a nílò fún paṣipaarọ (${min})",
"amount_is_estimate": "Iye tí a máa gba jẹ́ àfojúsùn",
@@ -1474,5 +1475,6 @@
"zcash_card_enable_later": "O le ma a mu kaadi yii ṣiṣẹ nigbamii ninu awọn eto",
"zcash_card_missing_funds": "Ṣe owó rẹ sọnù?",
"zcash_card_scan": "Ṣàwárí",
"zcash_card_warning": "Maṣe pa ohun elo naa titi ilana naa yoo fi pari; bí o bá ṣe bẹ́ẹ̀, ilana yìí yóò ní láti tún bẹ̀rẹ̀ láti ìbẹ̀rẹ̀."
"zcash_card_warning": "Maṣe pa ohun elo naa titi ilana naa yoo fi pari; bí o bá ṣe bẹ́ẹ̀, ilana yìí yóò ní láti tún bẹ̀rẹ̀ láti ìbẹ̀rẹ̀.",
"recipient_account_creation_fee": "A nilo afikun ${amount} lati ṣẹda akọọlẹ ami-ami kan fun olugba, nitori wọn ko ni ọkan sibẹsibẹ."
}
+3 -1
View File
@@ -61,6 +61,7 @@
"already_have_account": "已经有账户了?",
"already_your_username": "这已经是您的用户名!",
"always": "始终",
"ambiguous_token_symbol_exception": "该钱包中有多个代币使用符号 ${symbol}。禁用重复的令牌,以便在发送之前可以识别正确的令牌。",
"amount": "数量:",
"amount_is_below_minimum_limit": "扣除手续费后,您的余额将低于兑换所需的最低金额(${min})",
"amount_is_estimate": "接收金额为预估值",
@@ -1474,5 +1475,6 @@
"zcash_card_enable_later": "您随时可以在设置中启用此卡",
"zcash_card_missing_funds": "资金不见了?",
"zcash_card_scan": "扫描",
"zcash_card_warning": "在该过程完成之前请勿关闭应用程序,否则该过程将需要从头重新开始。"
"zcash_card_warning": "在该过程完成之前请勿关闭应用程序,否则该过程将需要从头重新开始。",
"recipient_account_creation_fee": "需要额外的 ${amount} 来为收件人创建一个令牌帐户,因为他们还没有令牌帐户。"
}
+1
View File
@@ -922,6 +922,7 @@ import 'package:cw_core/wallet_info.dart';
import 'package:cw_core/wallet_service.dart';
import 'package:cw_core/spl_token.dart';
import 'package:cw_core/transaction_direction.dart';
import 'package:cw_core/utils/print_verbose.dart';
""";
const solanaCWHeaders = """