Merge pull request #64 from Forte11Cuba/feature/peanut-emoji-encoding

feat: add peanut emoji encoding for Cashu tokens
This commit is contained in:
Forte11
2026-03-12 08:33:05 -06:00
committed by GitHub
27 changed files with 309 additions and 9 deletions
+12 -1
View File
@@ -1,5 +1,7 @@
// Parser para detectar el tipo de dato entrante (QR, clipboard, etc.)
// Soporta: tokens Cashu (A/B), invoices Lightning, URLs de mint, payment requests
// Soporta: tokens Cashu (A/B), invoices Lightning, URLs de mint, payment requests, peanut emoji
import 'peanut_codec.dart';
/// Modo de escaneo
enum ScanMode {
@@ -57,6 +59,15 @@ class IncomingDataParser {
/// Detecta el tipo de dato y extrae información relevante
static ParsedData parse(String data) {
final trimmed = data.trim();
// Peanut-encoded token (🥜 + variation selectors)
if (PeanutCodec.isPeanut(trimmed)) {
final decoded = PeanutCodec.decode(trimmed);
if (decoded != null) {
return parse(decoded);
}
}
final lower = trimmed.toLowerCase();
// Token Cashu (cashuA... o cashuB...)
+62
View File
@@ -0,0 +1,62 @@
/// Peanut encoding/decoding for Cashu tokens.
///
/// Encodes Cashu tokens into invisible Unicode Variation Selectors
/// appended to a 🥜 emoji, compatible with cashu.me's implementation.
///
/// Each character of the base64 token is mapped to a Variation Selector:
/// - charCode 0-15 → VS1-VS16 (U+FE00 to U+FE0F)
/// - charCode 16-255 → VS17-VS256 (U+E0100 to U+E01EF)
class PeanutCodec {
static const String _peanutEmoji = '🥜';
/// Encodes a Cashu token string into peanut format (🥜 + variation selectors).
static String encode(String token) {
final buffer = StringBuffer(_peanutEmoji);
for (int i = 0; i < token.length; i++) {
final byte = token.codeUnitAt(i);
if (byte < 16) {
// VS1-VS16: U+FE00 to U+FE0F
buffer.writeCharCode(0xFE00 + byte);
} else if (byte < 256) {
// VS17-VS256: U+E0100 to U+E01EF
buffer.writeCharCode(0xE0100 + (byte - 16));
}
}
return buffer.toString();
}
/// Decodes a peanut-encoded string back to the original Cashu token.
/// Returns null if the input is not valid peanut format.
static String? decode(String peanut) {
final trimmed = peanut.trimLeft();
if (!trimmed.startsWith(_peanutEmoji)) return null;
final buffer = StringBuffer();
final runes = trimmed.runes.toList();
// Skip the first rune (🥜 emoji)
for (int i = 1; i < runes.length; i++) {
final codePoint = runes[i];
if (codePoint >= 0xFE00 && codePoint <= 0xFE0F) {
// VS1-VS16 → byte 0-15
buffer.writeCharCode(codePoint - 0xFE00);
} else if (codePoint >= 0xE0100 && codePoint <= 0xE01EF) {
// VS17-VS256 → byte 16-255
buffer.writeCharCode(codePoint - 0xE0100 + 16);
}
// Skip any other characters (whitespace, etc.)
}
final result = buffer.toString();
return result.isNotEmpty ? result : null;
}
/// Returns true if the string starts with the 🥜 emoji.
static bool isPeanut(String data) {
return data.trimLeft().startsWith(_peanutEmoji);
}
}
+3
View File
@@ -137,6 +137,9 @@
},
"keepTokenWarning": "Bewahre diesen Token auf, bis der Empfänger ihn einlöst. Wenn du ihn verlierst, verlierst du die Mittel.",
"tokenCopiedToClipboard": "Token in Zwischenablage kopiert",
"copyAsEmoji": "Als Emoji kopieren",
"emojiCopiedToClipboard": "Token als Emoji kopiert 🥜",
"peanutDecodeError": "Emoji-Token konnte nicht dekodiert werden. Möglicherweise beschädigt.",
"amountToDeposit": "Einzuzahlender Betrag:",
"descriptionOptional": "Beschreibung (optional):",
+3
View File
@@ -112,6 +112,9 @@
"tokenCashuAnimatedQr": "Cashu Token (animated QR - {fragments} UR fragments)",
"keepTokenWarning": "Keep this token until the recipient claims it. If you lose it, you will lose the funds.",
"tokenCopiedToClipboard": "Token copied to clipboard",
"copyAsEmoji": "Copy as emoji",
"emojiCopiedToClipboard": "Token copied as emoji 🥜",
"peanutDecodeError": "Could not decode emoji token. It may be corrupted.",
"amountToDeposit": "Amount to deposit:",
"descriptionOptional": "Description (optional):",
+3
View File
@@ -137,6 +137,9 @@
},
"keepTokenWarning": "Guarda este token hasta que el receptor lo reclame. Si lo pierdes, perderás los fondos.",
"tokenCopiedToClipboard": "Token copiado al portapapeles",
"copyAsEmoji": "Copiar como emoji",
"emojiCopiedToClipboard": "Token copiado como emoji 🥜",
"peanutDecodeError": "No se pudo decodificar el token emoji. Puede estar corrupto.",
"amountToDeposit": "Monto a depositar:",
"descriptionOptional": "Descripción (opcional):",
+3
View File
@@ -137,6 +137,9 @@
},
"keepTokenWarning": "Conservez ce token jusqu'à ce que le destinataire le réclame. Si vous le perdez, vous perdrez les fonds.",
"tokenCopiedToClipboard": "Token copié dans le presse-papiers",
"copyAsEmoji": "Copier en emoji",
"emojiCopiedToClipboard": "Token copié en emoji 🥜",
"peanutDecodeError": "Impossible de décoder le token emoji. Il est peut-être corrompu.",
"amountToDeposit": "Montant à déposer :",
"descriptionOptional": "Description (optionnelle) :",
+3
View File
@@ -137,6 +137,9 @@
},
"keepTokenWarning": "Conserva questo token finché il destinatario non lo riscatta. Se lo perdi, perderai i fondi.",
"tokenCopiedToClipboard": "Token copiato negli appunti",
"copyAsEmoji": "Copia come emoji",
"emojiCopiedToClipboard": "Token copiato come emoji 🥜",
"peanutDecodeError": "Impossibile decodificare il token emoji. Potrebbe essere corrotto.",
"amountToDeposit": "Importo da depositare:",
"descriptionOptional": "Descrizione (opzionale):",
+3
View File
@@ -137,6 +137,9 @@
},
"keepTokenWarning": "受取人が請求するまでこのトークンを保管してください。紛失すると資金を失います。",
"tokenCopiedToClipboard": "トークンをクリップボードにコピーしました",
"copyAsEmoji": "絵文字としてコピー",
"emojiCopiedToClipboard": "トークンを絵文字としてコピーしました 🥜",
"peanutDecodeError": "絵文字トークンをデコードできませんでした。破損している可能性があります。",
"amountToDeposit": "入金額:",
"descriptionOptional": "説明(任意):",
+3
View File
@@ -137,6 +137,9 @@
},
"keepTokenWarning": "수신자가 청구할 때까지 이 토큰을 보관하세요. 분실하면 자금을 잃게 됩니다.",
"tokenCopiedToClipboard": "토큰이 클립보드에 복사되었습니다",
"copyAsEmoji": "이모지로 복사",
"emojiCopiedToClipboard": "토큰이 이모지로 복사되었습니다 🥜",
"peanutDecodeError": "이모지 토큰을 디코딩할 수 없습니다. 손상되었을 수 있습니다.",
"amountToDeposit": "입금할 금액:",
"descriptionOptional": "설명 (선택사항):",
+18
View File
@@ -707,6 +707,24 @@ abstract class L10n {
/// **'Token copiado al portapapeles'**
String get tokenCopiedToClipboard;
/// No description provided for @copyAsEmoji.
///
/// In es, this message translates to:
/// **'Copiar como emoji'**
String get copyAsEmoji;
/// No description provided for @emojiCopiedToClipboard.
///
/// In es, this message translates to:
/// **'Token copiado como emoji 🥜'**
String get emojiCopiedToClipboard;
/// No description provided for @peanutDecodeError.
///
/// In es, this message translates to:
/// **'No se pudo decodificar el token emoji. Puede estar corrupto.'**
String get peanutDecodeError;
/// No description provided for @amountToDeposit.
///
/// In es, this message translates to:
+9
View File
@@ -315,6 +315,15 @@ class L10nDe extends L10n {
@override
String get tokenCopiedToClipboard => 'Token in Zwischenablage kopiert';
@override
String get copyAsEmoji => 'Als Emoji kopieren';
@override
String get emojiCopiedToClipboard => 'Token als Emoji kopiert 🥜';
@override
String get peanutDecodeError => 'Emoji-Token konnte nicht dekodiert werden. Möglicherweise beschädigt.';
@override
String get amountToDeposit => 'Einzuzahlender Betrag:';
+9
View File
@@ -315,6 +315,15 @@ class L10nEn extends L10n {
@override
String get tokenCopiedToClipboard => 'Token copied to clipboard';
@override
String get copyAsEmoji => 'Copy as emoji';
@override
String get emojiCopiedToClipboard => 'Token copied as emoji 🥜';
@override
String get peanutDecodeError => 'Could not decode emoji token. It may be corrupted.';
@override
String get amountToDeposit => 'Amount to deposit:';
+9
View File
@@ -315,6 +315,15 @@ class L10nEs extends L10n {
@override
String get tokenCopiedToClipboard => 'Token copiado al portapapeles';
@override
String get copyAsEmoji => 'Copiar como emoji';
@override
String get emojiCopiedToClipboard => 'Token copiado como emoji 🥜';
@override
String get peanutDecodeError => 'No se pudo decodificar el token emoji. Puede estar corrupto.';
@override
String get amountToDeposit => 'Monto a depositar:';
+9
View File
@@ -315,6 +315,15 @@ class L10nFr extends L10n {
@override
String get tokenCopiedToClipboard => 'Token copié dans le presse-papiers';
@override
String get copyAsEmoji => 'Copier en emoji';
@override
String get emojiCopiedToClipboard => 'Token copié en emoji 🥜';
@override
String get peanutDecodeError => 'Impossible de décoder le token emoji. Il est peut-être corrompu.';
@override
String get amountToDeposit => 'Montant à déposer :';
+9
View File
@@ -315,6 +315,15 @@ class L10nIt extends L10n {
@override
String get tokenCopiedToClipboard => 'Token copiato negli appunti';
@override
String get copyAsEmoji => 'Copia come emoji';
@override
String get emojiCopiedToClipboard => 'Token copiato come emoji 🥜';
@override
String get peanutDecodeError => 'Impossibile decodificare il token emoji. Potrebbe essere corrotto.';
@override
String get amountToDeposit => 'Importo da depositare:';
+9
View File
@@ -315,6 +315,15 @@ class L10nJa extends L10n {
@override
String get tokenCopiedToClipboard => 'トークンをクリップボードにコピーしました';
@override
String get copyAsEmoji => '絵文字としてコピー';
@override
String get emojiCopiedToClipboard => 'トークンを絵文字としてコピーしました 🥜';
@override
String get peanutDecodeError => '絵文字トークンをデコードできませんでした。破損している可能性があります。';
@override
String get amountToDeposit => '入金額:';
+9
View File
@@ -315,6 +315,15 @@ class L10nKo extends L10n {
@override
String get tokenCopiedToClipboard => '토큰이 클립보드에 복사되었습니다';
@override
String get copyAsEmoji => '이모지로 복사';
@override
String get emojiCopiedToClipboard => '토큰이 이모지로 복사되었습니다 🥜';
@override
String get peanutDecodeError => '이모지 토큰을 디코딩할 수 없습니다. 손상되었을 수 있습니다.';
@override
String get amountToDeposit => '입금할 금액:';
+9
View File
@@ -315,6 +315,15 @@ class L10nPt extends L10n {
@override
String get tokenCopiedToClipboard => 'Token copiado para área de transferência';
@override
String get copyAsEmoji => 'Copiar como emoji';
@override
String get emojiCopiedToClipboard => 'Token copiado como emoji 🥜';
@override
String get peanutDecodeError => 'Não foi possível decodificar o token emoji. Pode estar corrompido.';
@override
String get amountToDeposit => 'Valor a depositar:';
+9
View File
@@ -315,6 +315,15 @@ class L10nRu extends L10n {
@override
String get tokenCopiedToClipboard => 'Токен скопирован в буфер обмена';
@override
String get copyAsEmoji => 'Скопировать как эмодзи';
@override
String get emojiCopiedToClipboard => 'Токен скопирован как эмодзи 🥜';
@override
String get peanutDecodeError => 'Не удалось декодировать эмодзи-токен. Возможно, он повреждён.';
@override
String get amountToDeposit => 'Сумма для пополнения:';
+9
View File
@@ -315,6 +315,15 @@ class L10nSw extends L10n {
@override
String get tokenCopiedToClipboard => 'Tokeni imenakiliwa kwenye ubao';
@override
String get copyAsEmoji => 'Nakili kama emoji';
@override
String get emojiCopiedToClipboard => 'Tokeni imenakiliwa kama emoji 🥜';
@override
String get peanutDecodeError => 'Imeshindwa kusimbua tokeni ya emoji. Inaweza kuwa imeharibika.';
@override
String get amountToDeposit => 'Kiasi cha kuweka:';
+9
View File
@@ -315,6 +315,15 @@ class L10nZh extends L10n {
@override
String get tokenCopiedToClipboard => '代币已复制到剪贴板';
@override
String get copyAsEmoji => '复制为表情符号';
@override
String get emojiCopiedToClipboard => '代币已复制为表情符号 🥜';
@override
String get peanutDecodeError => '无法解码表情符号代币。可能已损坏。';
@override
String get amountToDeposit => '存入金额:';
+3
View File
@@ -137,6 +137,9 @@
},
"keepTokenWarning": "Guarde este token até que o destinatário o resgate. Se você perdê-lo, perderá os fundos.",
"tokenCopiedToClipboard": "Token copiado para área de transferência",
"copyAsEmoji": "Copiar como emoji",
"emojiCopiedToClipboard": "Token copiado como emoji 🥜",
"peanutDecodeError": "Não foi possível decodificar o token emoji. Pode estar corrompido.",
"amountToDeposit": "Valor a depositar:",
"descriptionOptional": "Descrição (opcional):",
+3
View File
@@ -137,6 +137,9 @@
},
"keepTokenWarning": "Сохраните этот токен, пока получатель не заберёт его. Если потеряете — потеряете средства.",
"tokenCopiedToClipboard": "Токен скопирован в буфер обмена",
"copyAsEmoji": "Скопировать как эмодзи",
"emojiCopiedToClipboard": "Токен скопирован как эмодзи 🥜",
"peanutDecodeError": "Не удалось декодировать эмодзи-токен. Возможно, он повреждён.",
"amountToDeposit": "Сумма для пополнения:",
"descriptionOptional": "Описание (необязательно):",
+3
View File
@@ -137,6 +137,9 @@
},
"keepTokenWarning": "Hifadhi tokeni hii hadi mpokeaji aidai. Ukiipoteza, utapoteza fedha.",
"tokenCopiedToClipboard": "Tokeni imenakiliwa kwenye ubao",
"copyAsEmoji": "Nakili kama emoji",
"emojiCopiedToClipboard": "Tokeni imenakiliwa kama emoji 🥜",
"peanutDecodeError": "Imeshindwa kusimbua tokeni ya emoji. Inaweza kuwa imeharibika.",
"amountToDeposit": "Kiasi cha kuweka:",
"descriptionOptional": "Maelezo (si lazima):",
+3
View File
@@ -137,6 +137,9 @@
},
"keepTokenWarning": "请保留此代币直到收款方领取。如果丢失,资金将无法找回。",
"tokenCopiedToClipboard": "代币已复制到剪贴板",
"copyAsEmoji": "复制为表情符号",
"emojiCopiedToClipboard": "代币已复制为表情符号 🥜",
"peanutDecodeError": "无法解码表情符号代币。可能已损坏。",
"amountToDeposit": "存入金额:",
"descriptionOptional": "描述(可选):",
+50 -7
View File
@@ -8,6 +8,7 @@ import '../../core/constants/dimensions.dart';
import '../../core/utils/formatters.dart';
import '../../core/utils/incoming_data_parser.dart' hide TokenInfo;
import '../../core/utils/nostr_utils.dart';
import '../../core/utils/peanut_codec.dart';
import '../../widgets/common/gradient_background.dart';
import '../../widgets/common/glass_card.dart';
import '../../widgets/common/primary_button.dart';
@@ -779,9 +780,29 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
Future<void> _pasteFromClipboard() async {
final clipboardData = await Clipboard.getData(Clipboard.kTextPlain);
if (!mounted) return;
if (clipboardData?.text != null) {
_tokenController.text = clipboardData!.text!.trim();
_onTokenChanged(clipboardData.text!.trim());
var text = clipboardData!.text!.trim();
// Auto-decode peanut emoji format
if (PeanutCodec.isPeanut(text)) {
final decoded = PeanutCodec.decode(text);
if (decoded != null) {
text = decoded;
} else {
// Malformed peanut: clear field and show specific error
_tokenController.clear();
setState(() {
_isValidToken = false;
_tokenInfo = null;
_errorMessage = L10n.of(context)!.peanutDecodeError;
});
return;
}
}
_tokenController.text = text;
_onTokenChanged(text);
}
}
@@ -789,6 +810,28 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
final walletProvider = context.read<WalletProvider>();
final p2pkProvider = context.read<P2PKProvider>();
// Auto-decode peanut emoji format
var tokenValue = value.trim();
if (PeanutCodec.isPeanut(tokenValue)) {
final decoded = PeanutCodec.decode(tokenValue);
if (decoded != null) {
tokenValue = decoded;
// Update the text field with the decoded token
_tokenController.text = tokenValue;
_tokenController.selection = TextSelection.collapsed(
offset: tokenValue.length,
);
} else {
// Malformed peanut: show specific error
setState(() {
_isValidToken = false;
_tokenInfo = null;
_errorMessage = L10n.of(context)!.peanutDecodeError;
});
return;
}
}
setState(() {
_errorMessage = null;
_manualKeyError = null;
@@ -799,24 +842,24 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
_lockedToPubkeyHex = null;
_matchingKeyLabel = null;
if (value.isEmpty) {
if (tokenValue.isEmpty) {
_isValidToken = false;
_tokenInfo = null;
return;
}
// Parsear token real con cdk-flutter
final tokenInfo = walletProvider.parseToken(value.trim());
final tokenInfo = walletProvider.parseToken(tokenValue);
if (tokenInfo != null) {
_isValidToken = true;
_tokenInfo = tokenInfo;
// Detectar P2PK
_isP2PKLocked = p2pkProvider.isTokenLocked(value.trim());
_isP2PKLocked = p2pkProvider.isTokenLocked(tokenValue);
if (_isP2PKLocked) {
_lockedToPubkeyHex = p2pkProvider.extractLockedPubkey(value.trim());
_isLockedToUs = p2pkProvider.isTokenLockedToUs(value.trim());
_lockedToPubkeyHex = p2pkProvider.extractLockedPubkey(tokenValue);
_isLockedToUs = p2pkProvider.isTokenLockedToUs(tokenValue);
// Si es nuestra, buscar el label de la clave
if (_isLockedToUs && _lockedToPubkeyHex != null) {
+35 -1
View File
@@ -9,6 +9,7 @@ import 'package:elcaju/l10n/app_localizations.dart';
import '../../core/constants/colors.dart';
import '../../core/constants/dimensions.dart';
import '../../core/utils/formatters.dart';
import '../../core/utils/peanut_codec.dart';
import '../../widgets/common/gradient_background.dart';
import '../../widgets/common/glass_card.dart';
import '../../widgets/common/primary_button.dart';
@@ -414,7 +415,27 @@ class _ShareTokenScreenState extends State<ShareTokenScreen> {
),
),
),
const SizedBox(width: AppDimensions.paddingMedium),
const SizedBox(width: AppDimensions.paddingSmall),
// Copiar como emoji (peanut encoding)
Tooltip(
message: L10n.of(context)!.copyAsEmoji,
child: GestureDetector(
onTap: () => _copyPeanut(context),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: Colors.white.withValues(alpha: 0.2),
width: 1,
),
),
child: Icon(LucideIcons.bean, color: AppColors.primaryAction, size: 22),
),
),
),
const SizedBox(width: AppDimensions.paddingSmall),
// Compartir
Expanded(
child: GestureDetector(
@@ -492,6 +513,19 @@ class _ShareTokenScreenState extends State<ShareTokenScreen> {
);
}
void _copyPeanut(BuildContext context) {
final peanut = PeanutCodec.encode(widget.token);
Clipboard.setData(ClipboardData(text: peanut));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(L10n.of(context)!.emojiCopiedToClipboard),
backgroundColor: AppColors.success,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
);
}
void _shareToken(BuildContext context) async {
final memo = widget.memo != null && widget.memo!.isNotEmpty
? '\n"${widget.memo}"'