Merge branch 'dev' into cw-1440-new-buy-sell-flow

# Conflicts:
#	res/values/strings_it.arb
#	res/values/strings_ja.arb
#	res/values/strings_ko.arb
#	res/values/strings_my.arb
#	res/values/strings_nl.arb
#	res/values/strings_pl.arb
#	res/values/strings_pt.arb
#	res/values/strings_pt_BR.arb
#	res/values/strings_ru.arb
#	res/values/strings_th.arb
#	res/values/strings_tl.arb
#	res/values/strings_tr.arb
#	res/values/strings_uk.arb
#	res/values/strings_ur.arb
#	res/values/strings_vi.arb
#	res/values/strings_yo.arb
#	res/values/strings_zh.arb
This commit is contained in:
Robert Malikowski
2026-09-11 12:53:54 +02:00
153 changed files with 4277 additions and 899 deletions
+7 -3
View File
@@ -116,9 +116,13 @@ We have excellent user guides, which are also open-source and open for contribut
# Build Instructions
More instructions to follow
Building Cake Wallet for each platform is covered in the platform-specific guides under `docs/builds/`:
For instructions on how to build for Android: please view file `howto-build-android.md`
- [Building for Android](docs/builds/ANDROID.md)
- [Building for iOS](docs/builds/IOS.md)
- [Building for Linux](docs/builds/LINUX.md)
- [Building for macOS](docs/builds/MACOS.md)
- [Building for Windows](docs/builds/WINDOWS.md)
# Contributing
@@ -186,4 +190,4 @@ The only parts to be translated, if needed, are the values m and s after the var
---
Copyright (C) 2018-2023 Cake Labs LLC
Copyright (C) 2018-2025 Cake Labs LLC
@@ -1 +1 @@
[{"title":"تحسينات AnyPay","description":"الآن، سيؤدي لصق عنوان ما أو مسحه ضوئيًا من أي محفظة إلى توجيهك بشكل صحيح إما للتبديل أو الإرسال من المحفظة الصحيحة، والمزيد من التحسينات"},{"title":"تحسينات واجهة المستخدم","description":"تمت إضافة بعض عناصر واجهة المستخدم الجديدة خصيصًا لك"},{"title":"إصلاحات الشوائب","description":"سحق المزيد من الأخطاء"}]
[{"title":"تريزور أصلا لبيتكوين","description":"قم بإقران جهازك وتوقيع معاملات Bitcoin مباشرة في التطبيق"},{"title":"تحسينات Solana وNFTs","description":"أرسل Solana NFTs الخاصة بك، واحتفظ بالبطاقات المستوردة المحفوظة، واطلع في لمحة سريعة على تلك التي تحتوي عليها هذه المحفظة"},{"title":"تحسينات مزامنة مونيرو","description":"يستمر تقدم المزامنة في التحرك أثناء تسخين محفظتك، حتى تعرف دائمًا مكانها"},{"title":"إصلاحات الشوائب","description":"مزامنة جهات الاتصال، واختيار العقدة، وتسجيل الدخول إلى Cake Pay، والمزيد، تم سحقها جميعًا"}]
@@ -1 +1 @@
[{"title":"Подобрения на AnyPay","description":"Сега поставянето или сканирането на адрес от който и да е портфейл ще ви насочи правилно към размяна или изпращане от правилния портфейл и много повече подобрения"},{"title":"Подобрения на потребителския интерфейс","description":"Добавени са някои нови елементи на потребителския интерфейс само за вас"},{"title":"Поправки на грешки","description":"Смачкани още грешки"}]
[{"title":"Trezor първоначално за биткойн","description":"Сдвоете вашето устройство и подписвайте биткойн транзакции направо в приложението"},{"title":"Solana и подобрения на NFT","description":"Изпратете вашите Solana NFT, запазете импортираните и вижте с един поглед кои съхранява този портфейл"},{"title":"Подобрения в синхронизирането на Monero","description":"Прогресът на синхронизирането продължава да се движи, докато портфейлът ви загрява, така че винаги да знаете къде се намира"},{"title":"Поправки на грешки","description":"Синхронизиране на контакти, избор на възел, влизане в Cake Pay и други, всички смачкани"}]
@@ -1 +1 @@
[{"title":"Vylepšení AnyPay","description":"Nyní vložení nebo skenování adresy z libovolné peněženky vás správně nasměruje buď na výměnu nebo odeslání ze správné peněženky, a k mnoha dalším vylepšením"},{"title":"vylepšení uživatelského rozhraní","description":"Přidali jsme několik nových prvků uživatelského rozhraní jen pro vás"},{"title":"Opravy chyb","description":"Zmáčkl další brouky"}]
[{"title":"Trezor nativně pro bitcoiny","description":"Spárujte své zařízení a podepište bitcoinové transakce přímo v aplikaci"},{"title":"Solana a vylepšení NFT","description":"Pošlete své Solana NFT, mějte importované uložené a na první pohled uvidíte, které z nich tato peněženka obsahuje"},{"title":"Vylepšení synchronizace Monero","description":"Zatímco se vaše peněženka zahřívá, postup synchronizace se neustále pohybuje, takže vždy víte, kde se nachází"},{"title":"Opravy chyb","description":"Synchronizace kontaktů, výběr uzlů, přihlášení do Cake Pay a další, vše zmačkané"}]
@@ -1 +1 @@
[{"title":"AnyPay-Verbesserungen","description":"Wenn Sie jetzt eine Adresse aus einem beliebigen Wallet einfügen oder scannen, werden Sie direkt zum Tauschen oder Senden von dem richtigen Wallet weitergeleitet und es gibt noch viele weitere Verbesserungen"},{"title":"Verbesserungen der Benutzeroberfläche","description":"Einige neue UI-Elemente wurden speziell für Sie hinzugefügt"},{"title":"Fehlerbehebungen","description":"Weitere Bugs beseitigt"}]
[{"title":"Trezor nativ für Bitcoin","description":"Koppeln Sie Ihr Gerät und signieren Sie Bitcoin-Transaktionen direkt in der App"},{"title":"Verbesserungen bei Solana und NFTs","description":"Senden Sie Ihre Solana-NFTs, bewahren Sie importierte NFTs auf und sehen Sie auf einen Blick, welche in dieser Wallet gespeichert sind"},{"title":"Verbesserungen bei der Monero-Synchronisierung","description":"Der Synchronisierungsfortschritt läuft weiter, während sich Ihr Geldbeutel aufwärmt, sodass Sie immer wissen, wo er steht"},{"title":"Fehlerbehebungen","description":"Kontaktsynchronisierung, Knotenauswahl, Cake Pay-Anmeldung und mehr, alles gequetscht"}]
@@ -1,14 +1,18 @@
[
{
"title": "AnyPay Improvements",
"description": "Now pasting or scanning an address from any wallet will point you correctly to either swapping or sending from the correct wallet, and much more improvements"
"title": "Trezor natively for Bitcoin",
"description": "Pair your device and sign Bitcoin transactions right in the app"
},
{
"title": "UI improvements",
"description": "Added some fresh UI elements just for you"
"title": "Solana and NFTs Improvements",
"description": "Send your Solana NFTs, keep imported ones saved, and see at a glance which ones this wallet holds"
},
{
"title": "Monero sync improvements",
"description": "Sync progress keeps moving while your wallet warms up, so you always know where it stands"
},
{
"title": "Bug fixes",
"description": "Squashed more bugs"
"description": "Contact syncing, node selection, Cake Pay sign-in and more, all squashed"
}
]
@@ -1 +1 @@
[{"title":"Mejoras en cualquier pago","description":"Ahora, pegar o escanear una dirección desde cualquier billetera le indicará correctamente si desea intercambiar o enviar desde la billetera correcta, y muchas más mejoras."},{"title":"Mejoras en la interfaz de usuario","description":"Se agregaron algunos elementos nuevos de la interfaz de usuario solo para ti."},{"title":"Corrección de errores","description":"Aplastado más errores"}]
[{"title":"Trezor de forma nativa para Bitcoin","description":"Empareja tu dispositivo y firma transacciones de Bitcoin directamente en la aplicación"},{"title":"Mejoras en Solana y NFT","description":"Envíe sus NFT de Solana, guarde los importados y vea de un vistazo cuáles contiene esta billetera"},{"title":"Mejoras en la sincronización de Monero","description":"El progreso de la sincronización continúa mientras tu billetera se calienta, para que siempre sepas dónde se encuentra"},{"title":"Corrección de errores","description":"Sincronización de contactos, selección de nodos, inicio de sesión de Cake Pay y más, todo aplastado"}]
@@ -1 +1 @@
[{"title":"AnyPay Improvements","description":"اکنون چسباندن یا اسکن یک آدرس از هر کیف پولی، شما را به درستی به تعویض یا ارسال از کیف پول صحیح و پیشرفت های بسیار بیشتر راهنمایی می کند."},{"title":"بهبودهای رابط کاربری","description":"برخی از عناصر رابط کاربری جدید را فقط برای شما اضافه کرد"},{"title":"رفع اشکال","description":"اشکالات بیشتری را له کرد"}]
[{"title":"Trezor به صورت بومی برای بیت کوین","description":"دستگاه خود را جفت کنید و معاملات بیت کوین را مستقیماً در برنامه امضا کنید"},{"title":"بهبود سولانا و NFTs","description":"NFT های Solana خود را ارسال کنید، موارد وارداتی را ذخیره کنید و در یک نگاه ببینید که این کیف پول کدام یک را در خود جای می دهد."},{"title":"بهبود همگام سازی مونرو","description":"پیشرفت همگام‌سازی تا زمانی که کیف پول شما گرم می‌شود به حرکت خود ادامه می‌دهد، بنابراین همیشه می‌دانید کجا قرار دارد"},{"title":"رفع اشکال","description":"همگام‌سازی مخاطب، انتخاب گره، ورود به سیستم Cake Pay و موارد دیگر، همه له شده‌اند"}]
@@ -1 +1 @@
[{"title":"Améliorations d'AnyPay","description":"Désormais, coller ou scanner une adresse à partir de n'importe quel portefeuille vous indiquera correctement soit l'échange, soit l'envoi à partir du bon portefeuille, et bien d'autres améliorations."},{"title":"Améliorations de l'interface utilisateur","description":"Ajout de nouveaux éléments d'interface utilisateur juste pour vous"},{"title":"Corrections de bugs","description":"Écrasé plus de bugs"}]
[{"title":"Trezor nativement pour Bitcoin","description":"Associez votre appareil et signez des transactions Bitcoin directement dans l'application"},{"title":"Améliorations de Solana et des NFT","description":"Envoyez vos NFT Solana, conservez ceux importés et voyez en un coup d'œil lesquels contiennent ce portefeuille"},{"title":"Améliorations de la synchronisation Monero","description":"La progression de la synchronisation continue pendant que votre portefeuille se réchauffe, afin que vous sachiez toujours où il en est"},{"title":"Corrections de bugs","description":"Synchronisation des contacts, sélection des nœuds, connexion à Cake Pay et bien plus encore, le tout écrasé"}]
@@ -1 +1 @@
[{"title":"AnyPay Ñemyatyrõ","description":"Ko’áĝa emboja térã eescanea peteĩ dirección oimeraẽva billetera-gui nde señaláta hekopete taha’e intercambio térã emondo haĝua billetera hekopeteguágui, ha hetaiteve ñemyatyrõ"},{"title":"UI ñemyatyrõ","description":"Oñemoĩve peteĩ elemento UI pyahu ndéve g̃uarãnte"},{"title":"Umi mba’e’oka ñemyatyrõ","description":"Oñembopupu hetave mba’e’oka"}]
[{"title":"Trezor nativamente Bitcoin-pe g̃uarã","description":"Embojoaju nde tembipuru ha efirma umi transacción Bitcoin rehegua pe app-pe voi"},{"title":"Solana ha NFTs Ñemyatyrõ","description":"Emondo nde NFT Solana, eñongatu umi ojeguerúva, ha ehecha peteĩ jesarekópe mávapa oguereko ko billetera"},{"title":"Monero sincronización ñemyatyrõ","description":"Progreso sincronización rehegua osegi omýi nde billetera oñembopiro’y aja, upéicha akóinte reikuaa moõpa oĩ"},{"title":"Umi mba’e’oka ñemyatyrõ","description":"Contacto ñembojoaju, nodo jeporavo, Cake Pay jeike ha hetave, opaite oñembotapykue"}]
@@ -1 +1 @@
[{"title":"AnyBiyan Ingantawa","description":"Yanzu liƙa ko duba adireshi daga kowane walat zai nuna maka daidai ga ko dai musanya ko aikawa daga ainihin walat ɗin, da ƙarin haɓakawa."},{"title":"UI inganta","description":"An ƙara wasu sabbin abubuwan UI don ku kawai"},{"title":"Gyaran kwaro","description":"Squashed karin kwari"}]
[{"title":"Trezor na asali don Bitcoin","description":"Haɗa na'urarka kuma sanya hannu kan ma'amalar Bitcoin daidai a cikin ƙa'idar"},{"title":"Solana da NFTs Ingantawa","description":"Aika Solana NFT ɗinku, adana waɗanda aka shigo da su, sannan ku duba ɗaya daga cikin wallet ɗin."},{"title":"Monero sync inganta","description":"Ci gaban daidaitawa yana ci gaba da motsawa yayin da walat ɗin ku ke yin dumi, don haka koyaushe ku san inda yake tsaye"},{"title":"Gyaran kwaro","description":"Daidaita lamba, zaɓin kumburi, shigar da Biyan Kek da ƙari, duk an murƙushe"}]
@@ -1 +1 @@
[{"title":"AnyPay सुधार","description":"अब किसी भी वॉलेट से किसी पते को चिपकाने या स्कैन करने से आपको स्वैपिंग या सही वॉलेट से भेजने का सही संकेत मिलेगा, और भी बहुत कुछ सुधार होगा"},{"title":"यूआई सुधार","description":"केवल आपके लिए कुछ ताज़ा यूआई तत्व जोड़े गए हैं"},{"title":"कंप्यूटर प्रोग्राम या प्रणाली में बग को दूर करना","description":"अधिक कीड़ों को कुचल दिया"}]
[{"title":"ट्रेज़ोर मूल रूप से बिटकॉइन के लिए है","description":"अपने डिवाइस को पेयर करें और ऐप में ही बिटकॉइन लेनदेन पर हस्ताक्षर करें"},{"title":"सोलाना और एनएफटी सुधार","description":"अपने सोलाना एनएफटी भेजें, आयातित एनएफटी को सहेज कर रखें और एक नज़र में देखें कि इस वॉलेट में कौन से एनएफटी हैं"},{"title":"मोनेरो सिंक में सुधार","description":"जब आपका बटुआ गर्म हो जाता है तो सिंक प्रगति चलती रहती है, इसलिए आप हमेशा जानते हैं कि यह कहां खड़ा है"},{"title":"कंप्यूटर प्रोग्राम या प्रणाली में बग को दूर करना","description":"संपर्क सिंकिंग, नोड चयन, केक पे साइन-इन और बहुत कुछ, सब खत्म हो गया"}]
@@ -1 +1 @@
[{"title":"AnyPay poboljšanja","description":"Sada će vas lijepljenje ili skeniranje adrese iz bilo kojeg novčanika ispravno usmjeriti na zamjenu ili slanje iz ispravnog novčanika i još mnogo poboljšanja"},{"title":"Poboljšanja korisničkog sučelja","description":"Dodani su novi elementi korisničkog sučelja samo za vas"},{"title":"Ispravci grešaka","description":"Zgnječio još kukaca"}]
[{"title":"Trezor izvorno za Bitcoin","description":"Uparite svoj uređaj i potpisujte Bitcoin transakcije izravno u aplikaciji"},{"title":"Solana i poboljšanja NFT-a","description":"Pošaljite svoje Solana NFT-ove, sačuvajte uvezene i na prvi pogled pogledajte koje ovaj novčanik ima"},{"title":"Poboljšanja sinkronizacije Monera","description":"Napredak sinkronizacije nastavlja se kretati dok se vaš novčanik zagrijava, tako da uvijek znate gdje stoji"},{"title":"Ispravci grešaka","description":"Sinkronizacija kontakata, odabir čvora, Cake Pay prijava i više, sve zgnječeno"}]
@@ -1 +1 @@
[{"title":"AnyPay բարելավումներ","description":"Այժմ ցանկացած դրամապանակից հասցե կպցնելը կամ սկանավորումը ձեզ ճիշտ կուղղորդի՝ կա՛մ փոխանակելու, կա՛մ ճիշտ դրամապանակից ուղարկելու և շատ ավելի բարելավումներ:"},{"title":"UI բարելավումներ","description":"Ավելացրել է մի քանի թարմ UI տարրեր հենց ձեզ համար"},{"title":"Սխալների շտկում","description":"Ճզմեց ավելի շատ սխալներ"}]
[{"title":"Trezor բնիկ համար Bitcoin","description":"Զուգավորեք ձեր սարքը և ստորագրեք Bitcoin գործարքները հենց հավելվածում"},{"title":"Սոլանայի և NFT-ների բարելավումներ","description":"Ուղարկեք ձեր Solana NFT-ները, պահեք ներմուծվածները և մի հայացքով տեսեք, թե որոնք են պահում այս դրամապանակը"},{"title":"Monero-ի համաժամացման բարելավումներ","description":"Համաժամացման առաջընթացը շարունակվում է, մինչ ձեր դրամապանակը տաքանում է, այնպես որ դուք միշտ իմանաք, թե որտեղ է այն կանգնած"},{"title":"Սխալների շտկում","description":"Կոնտակտների համաժամացում, հանգույցների ընտրություն, Cake Pay-ի մուտք և ավելին, բոլորը սեղմված են"}]
@@ -1 +1 @@
[{"title":"Peningkatan AnyPay","description":"Sekarang menempelkan atau memindai alamat dari dompet mana pun akan mengarahkan Anda dengan benar untuk menukar atau mengirim dari dompet yang benar, dan banyak lagi peningkatan lainnya"},{"title":"Peningkatan UI","description":"Menambahkan beberapa elemen UI baru hanya untuk Anda"},{"title":"Perbaikan bug","description":"Membasmi lebih banyak bug"}]
[{"title":"Trezor aslinya untuk Bitcoin","description":"Pasangkan perangkat Anda dan tanda tangani transaksi Bitcoin langsung di aplikasi"},{"title":"Peningkatan Solana dan NFT","description":"Kirim NFT Solana Anda, simpan NFT yang diimpor, dan lihat sekilas mana yang disimpan dalam dompet ini"},{"title":"Peningkatan sinkronisasi Monero","description":"Kemajuan sinkronisasi terus berjalan saat dompet Anda memanas, sehingga Anda selalu tahu di mana posisinya"},{"title":"Perbaikan bug","description":"Sinkronisasi kontak, pemilihan node, proses masuk Cake Pay, dan banyak lagi, semuanya terhenti"}]
@@ -1 +1 @@
[{"title":"Miglioramenti di AnyPay","description":"Ora incollare o scansionare un indirizzo da qualsiasi portafoglio ti indicherà correttamente lo scambio o l'invio dal portafoglio corretto e molti altri miglioramenti"},{"title":"Miglioramenti dell'interfaccia utente","description":"Aggiunti alcuni nuovi elementi dell'interfaccia utente solo per te"},{"title":"Correzioni di bug","description":"Eliminati altri bug"}]
[{"title":"Trezor nativo per Bitcoin","description":"Accoppia il tuo dispositivo e firma le transazioni Bitcoin direttamente nell'app"},{"title":"Miglioramenti di Solana e NFT","description":"Invia i tuoi NFT Solana, mantieni salvati quelli importati e scopri a colpo d'occhio quali sono contenuti in questo portafoglio"},{"title":"Miglioramenti alla sincronizzazione di Monero","description":"I progressi della sincronizzazione continuano a muoversi mentre il tuo portafoglio si riscalda, così sai sempre a che punto è"},{"title":"Correzioni di bug","description":"Sincronizzazione dei contatti, selezione dei nodi, accesso a Cake Pay e altro ancora, tutto schiacciato"}]
@@ -1 +1 @@
[{"title":"AnyPayの改善","description":"任意のウォレットからアドレスを貼り付けるかスキャンすると、正しいウォレットからの交換または送信が正しく示されるようになり、さらに多くの改善が行われました。"},{"title":"UIの改善","description":"あなたのためだけにいくつかの新しい UI 要素を追加しました"},{"title":"バグ修正","description":"さらに多くのバグを潰しました"}]
[{"title":"Trezor はビットコインにネイティブ対応","description":"デバイスをペアリングして、アプリ内で直接ビットコイン取引に署名します"},{"title":"Solana と NFT の改善","description":"Solana NFT を送信し、インポートされた NFT を保存し、このウォレットにどの NFT が保持されているかを一目で確認できます"},{"title":"Monero 同期の改善","description":"ウォレットがウォームアップしている間も同期の進行状況は継続するため、ウォレットの状態を常に把握できます"},{"title":"バグ修正","description":"連絡先の同期、ノードの選択、Cake Pay のサインインなど、すべてが潰されています"}]
@@ -1 +1 @@
[{"title":"애니페이 개선","description":"이제 지갑에서 주소를 붙여넣거나 스캔하면 올바른 지갑에서 주소를 교환하거나 보내는 것을 올바르게 가리킬 수 있으며 훨씬 더 많은 개선 사항이 있습니다."},{"title":"UI 개선","description":"당신을 위한 새로운 UI 요소를 추가했습니다."},{"title":"버그 수정","description":"더 많은 버그를 해결했습니다."}]
[{"title":"기본적으로 Bitcoin용 Trezor","description":"기기를 페어링하고 앱에서 바로 비트코인 ​​거래에 서명하세요."},{"title":"솔라나 및 NFT 개선","description":"솔라나 NFT를 보내고, 가져온 NFT를 저장하고, 이 지갑에 어떤 NFT가 들어 있는지 한눈에 확인하세요"},{"title":"모네로 동기화 개선","description":"지갑이 준비되는 동안 동기화 진행이 계속 진행되므로 항상 현재 상태를 알 수 있습니다."},{"title":"버그 수정","description":"연락처 동기화, 노드 선택, Cake Pay 로그인 등이 모두 중단되었습니다."}]
@@ -1 +1 @@
[{"title":"AnyPay တိုးတက်မှုများ","description":"ယခု မည်သည့်ပိုက်ဆံအိတ်မှ လိပ်စာတစ်ခုကို ကူးထည့်ခြင်း သို့မဟုတ် စကင်န်ဖတ်ခြင်းသည် သင့်အား မှန်ကန်သောပိုက်ဆံအိတ်မှ လဲလှယ်ရန် သို့မဟုတ် ပေးပို့ခြင်းတို့ကို မှန်ကန်စွာ ညွှန်ပြပေးမည်ဖြစ်ပြီး ပိုမိုကောင်းမွန်လာစေရန်၊"},{"title":"UI တိုးတက်မှုများ","description":"သင့်အတွက်သာ လတ်ဆတ်သော UI အစိတ်အပိုင်းအချို့ကို ထည့်ထားသည်။"},{"title":"ချွတ်ယွင်းချက်ပြင်ဆင်မှုများ","description":"နောက်ထပ် ပိုးကောင်များကို နှိမ်နင်းခဲ့သည်။"}]
[{"title":"Bitcoin အတွက် မူရင်း Trezor","description":"သင့်စက်ကို တွဲချိတ်ပြီး အက်ပ်တွင် Bitcoin ငွေပေးငွေယူများကို လက်မှတ်ထိုးပါ။"},{"title":"Solana နှင့် NFTs တိုးတက်မှုများ","description":"သင်၏ Solana NFTs များကို ပေးပို့ပါ၊ တင်သွင်းထားသည့်အရာများကို သိမ်းဆည်းထားပါ၊ ဤပိုက်ဆံအိတ်တွင် မည်သည့်အရာများ ကိုင်ဆောင်ထားသည်ကို တစ်ချက်ကြည့်လိုက်ပါ။"},{"title":"Monero စင့်ခ်လုပ်ခြင်း တိုးတက်မှု","description":"သင့်ပိုက်ဆံအိတ် ပူနွေးလာချိန်တွင် စင့်ခ်လုပ်ဆောင်မှုသည် ဆက်လက်ရွေ့လျားနေသဖြင့် ၎င်းသည် မည်သည့်နေရာတွင် ရှိနေသည်ကို သင်အမြဲသိပါသည်။"},{"title":"ချွတ်ယွင်းချက်ပြင်ဆင်မှုများ","description":"အဆက်အသွယ်စင့်ခ်လုပ်ခြင်း၊ node ရွေးချယ်ခြင်း၊ Cake Pay လက်မှတ်ထိုးဝင်ခြင်းနှင့် အခြားအရာများအားလုံးကို ဖြိုခွဲလိုက်ပါပြီ။"}]
@@ -1 +1 @@
[{"title":"AnyPay-verbeteringen","description":"Als u nu een adres uit een willekeurige portemonnee plakt of scant, wordt u correct verwezen naar het wisselen of verzenden vanuit de juiste portemonnee, en nog veel meer verbeteringen"},{"title":"UI-verbeteringen","description":"Speciaal voor jou een aantal nieuwe UI-elementen toegevoegd"},{"title":"Bugfixes","description":"Nog meer bugs geplet"}]
[{"title":"Trezor oorspronkelijk voor Bitcoin","description":"Koppel uw apparaat en onderteken Bitcoin-transacties rechtstreeks in de app"},{"title":"Verbeteringen aan Solana en NFT's","description":"Verstuur uw Solana NFT's, bewaar geïmporteerde NFT's en zie in één oogopslag welke deze portemonnee bevat"},{"title":"Verbeteringen voor de synchronisatie van Monero","description":"De voortgang van de synchronisatie blijft in beweging terwijl uw portemonnee opwarmt, zodat u altijd weet waar hij aan toe is"},{"title":"Bugfixes","description":"Contactsynchronisatie, knooppuntselectie, aanmelden bij Cake Pay en meer, allemaal platgedrukt"}]
@@ -1 +1 @@
[{"title":"Ulepszenia AnyPay","description":"Teraz wklejenie lub zeskanowanie adresu z dowolnego portfela wskaże prawidłowo opcję wymiany lub wysłania z odpowiedniego portfela oraz wiele innych ulepszeń"},{"title":"Ulepszenia interfejsu użytkownika","description":"Dodano kilka świeżych elementów interfejsu użytkownika specjalnie dla Ciebie"},{"title":"Poprawki błędów","description":"Zmiażdżono więcej błędów"}]
[{"title":"Trezor natywnie dla Bitcoin","description":"Sparuj swoje urządzenie i podpisuj transakcje Bitcoin bezpośrednio w aplikacji"},{"title":"Ulepszenia Solany i NFT","description":"Wysyłaj swoje Solana NFT, przechowuj zaimportowane i od razu zobacz, które z nich przechowuje ten portfel"},{"title":"Ulepszenia synchronizacji Monero","description":"Postęp synchronizacji stale się zmienia, podczas gdy Twój portfel się nagrzewa, więc zawsze wiesz, gdzie się znajduje"},{"title":"Poprawki błędów","description":"Synchronizacja kontaktów, wybór węzła, logowanie do Cake Pay i wiele więcej – wszystko to zmiażdżone"}]
@@ -1 +1 @@
[{"title":"Melhorias no AnyPay","description":"Agora, colar ou digitalizar um endereço de qualquer carteira indicará corretamente a troca ou o envio da carteira correta e muito mais melhorias"},{"title":"Melhorias na interface do usuário","description":"Adicionados alguns novos elementos de UI só para você"},{"title":"Correções de bugs","description":"Esmagou mais bugs"}]
[{"title":"Trezor nativamente para Bitcoin","description":"Emparelhe seu dispositivo e assine transações Bitcoin diretamente no aplicativo"},{"title":"Melhorias em Solana e NFTs","description":"Envie seus NFTs Solana, mantenha salvos os importados e veja rapidamente quais esta carteira contém"},{"title":"Melhorias na sincronização do Monero","description":"O progresso da sincronização continua avançando enquanto sua carteira aquece, para que você sempre saiba onde ela está"},{"title":"Correções de bugs","description":"Sincronização de contatos, seleção de nós, login no Cake Pay e muito mais, tudo esmagado"}]
@@ -1 +1 @@
[{"title":"Улучшения AnyPay","description":"Теперь при вставке или сканировании адреса из любого кошелька вы сможете правильно выбрать обмен или отправку из правильного кошелька, а также множество других улучшений."},{"title":"Улучшения пользовательского интерфейса","description":"Специально для вас добавлено несколько свежих элементов пользовательского интерфейса."},{"title":"Исправления ошибок","description":"Устранено больше ошибок"}]
[{"title":"Trezor изначально для биткойнов","description":"Подключите свое устройство и подпишите транзакции Bitcoin прямо в приложении."},{"title":"Улучшения Соланы и NFT","description":"Отправляйте свои NFT Solana, сохраняйте импортированные и сразу смотрите, какие из них хранятся в этом кошельке."},{"title":"Улучшения синхронизации Monero","description":"Процесс синхронизации продолжается, пока ваш кошелек нагревается, поэтому вы всегда знаете, где он находится."},{"title":"Исправления ошибок","description":"Синхронизация контактов, выбор узла, вход в систему Cake Pay и многое другое — все раздавлено"}]
@@ -1 +1 @@
[{"title":"การปรับปรุง AnyPay","description":"ตอนนี้การวางหรือสแกนที่อยู่จากกระเป๋าเงินใด ๆ จะนำคุณไปสู่การแลกเปลี่ยนหรือส่งจากกระเป๋าเงินที่ถูกต้องอย่างถูกต้อง และการปรับปรุงอื่น ๆ อีกมากมาย"},{"title":"การปรับปรุง UI","description":"เพิ่มองค์ประกอบ UI ใหม่สำหรับคุณโดยเฉพาะ"},{"title":"แก้ไขข้อบกพร่อง","description":"กำจัดข้อบกพร่องเพิ่มเติม"}]
[{"title":"Trezor โดยกำเนิดสำหรับ Bitcoin","description":"จับคู่อุปกรณ์ของคุณและลงนามธุรกรรม Bitcoin ในแอป"},{"title":"การปรับปรุง Solana และ NFT","description":"ส่ง Solana NFT ของคุณ เก็บรายการที่นำเข้าบันทึกไว้ และดูได้อย่างรวดเร็วว่ากระเป๋าสตางค์นี้เก็บรายการใดบ้าง"},{"title":"การปรับปรุงการซิงค์ Monero","description":"ความคืบหน้าในการซิงค์จะดำเนินต่อไปในขณะที่กระเป๋าเงินของคุณอุ่นขึ้น ดังนั้นคุณจะรู้อยู่เสมอว่ามันอยู่ที่ไหน"},{"title":"แก้ไขข้อบกพร่อง","description":"การซิงค์ผู้ติดต่อ การเลือกโหนด การลงชื่อเข้าใช้ Cake Pay และอื่นๆ ทั้งหมดถูกแบน"}]
@@ -1 +1 @@
[{"title":"Mga Pagpapabuti ng AnyPay","description":"Ngayon, ang pag-paste o pag-scan ng isang address mula sa anumang pitaka ay ituturo sa iyo nang tama sa alinman sa pagpapalit o pagpapadala mula sa tamang pitaka, at marami pang mga pagpapabuti"},{"title":"Mga pagpapabuti ng UI","description":"Nagdagdag ng ilang sariwang elemento ng UI para lang sa iyo"},{"title":"Mga pag-aayos ng bug","description":"Nilapit ang higit pang mga bug"}]
[{"title":"Trezor natively para sa Bitcoin","description":"Ipares ang iyong device at lagdaan ang mga transaksyon sa Bitcoin sa app mismo"},{"title":"Mga Pagpapabuti ng Solana at NFT","description":"Ipadala ang iyong mga Solana NFT, panatilihing naka-save ang mga na-import, at tingnan sa isang sulyap kung alin ang hawak ng wallet na ito"},{"title":"Mga pagpapabuti sa pag-sync ng Monero","description":"Patuloy na gumagalaw ang pag-unlad ng pag-sync habang umiinit ang iyong wallet, para lagi mong alam kung saan ito nakatayo"},{"title":"Mga pag-aayos ng bug","description":"Ang pag-sync ng contact, pagpili ng node, pag-sign in sa Cake Pay at higit pa, lahat ay lapirat"}]
@@ -1 +1 @@
[{"title":"AnyPay İyileştirmeleri","description":"Artık herhangi bir cüzdandan bir adresi yapıştırmak veya taramak, sizi doğru cüzdandan değiştirmeye veya göndermeye yönlendirecek ve çok daha fazla iyileştirme yapacak"},{"title":"Kullanıcı arayüzü iyileştirmeleri","description":"Size özel bazı yeni kullanıcı arayüzü öğeleri eklendi"},{"title":"Hata düzeltmeleri","description":"Daha fazla hata giderildi"}]
[{"title":"Bitcoin için yerel olarak Trezor","description":"Cihazınızı eşleştirin ve Bitcoin işlemlerini doğrudan uygulamada imzalayın"},{"title":"Solana ve NFT İyileştirmeleri","description":"Solana NFT'lerinizi gönderin, içe aktarılanları saklayın ve bu cüzdanda hangilerinin bulunduğunu bir bakışta görün"},{"title":"Monero senkronizasyon iyileştirmeleri","description":"Cüzdanınız ısınırken senkronizasyon ilerlemesi devam eder, böylece nerede olduğunu her zaman bilirsiniz"},{"title":"Hata düzeltmeleri","description":"Kişi senkronizasyonu, düğüm seçimi, Cake Pay'de oturum açma ve daha fazlası, hepsi engellendi"}]
@@ -1 +1 @@
[{"title":"Покращення AnyPay","description":"Тепер вставлення або сканування адреси з будь-якого гаманця правильно вкаже вас на обмін або надсилання з правильного гаманця, а також багато інших покращень"},{"title":"Покращення інтерфейсу користувача","description":"Додано кілька свіжих елементів інтерфейсу спеціально для вас"},{"title":"Виправлення помилок","description":"Роздавив більше помилок"}]
[{"title":"Trezor спочатку для Bitcoin","description":"Підключіть свій пристрій і підписуйте транзакції Bitcoin прямо в додатку"},{"title":"Покращення Solana та NFT","description":"Надсилайте свої NFT Solana, зберігайте імпортовані та одразу дивіться, які з них зберігає цей гаманець"},{"title":"Покращення синхронізації Monero","description":"Процес синхронізації продовжується, поки ваш гаманець розігрівається, тож ви завжди знаєте, де він стоїть"},{"title":"Виправлення помилок","description":"Синхронізація контактів, вибір вузла, вхід у систему Cake Pay тощо – усе зіпсовано"}]
@@ -1 +1 @@
[{"title":"AnyPay میں بہتری","description":"اب کسی بھی بٹوے سے ایڈریس پیسٹ یا اسکین کرنا آپ کو صحیح بٹوے سے تبدیل کرنے یا بھیجنے کی طرف صحیح طریقے سے اشارہ کرے گا، اور بہت زیادہ بہتری"},{"title":"UI میں بہتری","description":"صرف آپ کے لیے کچھ تازہ UI عناصر شامل کیے گئے۔"},{"title":"بگ کی اصلاحات","description":"مزید کیڑے کو کچل دیا۔"}]
[{"title":"Trezor مقامی طور پر Bitcoin کے لیے","description":"اپنے آلے کو جوڑیں اور ایپ میں ہی بٹ کوائن کے لین دین پر دستخط کریں۔"},{"title":"سولانا اور NFTs میں بہتری","description":"اپنے سولانا NFTs بھیجیں، درآمد شدہ کو محفوظ رکھیں، اور ایک نظر میں دیکھیں کہ اس پرس میں کون سا ہے"},{"title":"Monero مطابقت پذیری میں بہتری","description":"آپ کے بٹوے کے گرم ہونے کے دوران مطابقت پذیری کی پیشرفت جاری رہتی ہے، لہذا آپ کو ہمیشہ معلوم ہوتا ہے کہ یہ کہاں کھڑا ہے۔"},{"title":"بگ کی اصلاحات","description":"رابطہ کی مطابقت پذیری، نوڈ کا انتخاب، کیک پے سائن ان اور بہت کچھ، سب کو کچل دیا گیا۔"}]
@@ -1 +1 @@
[{"title":"Cải tiến AnyPay","description":"Giờ đây, việc dán hoặc quét địa chỉ từ bất kỳ ví nào sẽ chỉ cho bạn cách hoán đổi hoặc gửi từ đúng ví và nhiều cải tiến hơn nữa"},{"title":"Cải tiến giao diện người dùng","description":"Đã thêm một số thành phần UI mới dành riêng cho bạn"},{"title":"Sửa lỗi","description":"Dẹp nhiều lỗi hơn"}]
[{"title":"Trezor vốn dành cho Bitcoin","description":"Ghép nối thiết bị của bạn và ký các giao dịch Bitcoin ngay trong ứng dụng"},{"title":"Cải tiến Solana và NFT","description":"Gửi NFT Solana của bạn, lưu những cái đã nhập và xem nhanh những cái nào trong ví này chứa"},{"title":"Cải tiến đồng bộ hóa Monero","description":"Tiến trình đồng bộ hóa tiếp tục di chuyển trong khi ví của bạn ấm lên, vì vậy bạn luôn biết vị trí của nó"},{"title":"Sửa lỗi","description":"Đồng bộ hóa liên hệ, chọn nút, đăng nhập Cake Pay và hơn thế nữa, tất cả đều bị nén"}]
@@ -1 +1 @@
[{"title":"Awọn ilọsiwaju AnyPay","description":"Ni bayi titọ tabi ṣiṣayẹwo adirẹsi kan lati eyikeyi apamọwọ yoo tọka si ni deede si boya paarọ tabi firanṣẹ lati apamọwọ to pe, ati awọn ilọsiwaju pupọ diẹ sii."},{"title":"UI awọn ilọsiwaju","description":"Ṣafikun diẹ ninu awọn eroja UI tuntun kan fun ọ"},{"title":"Awọn atunṣe kokoro","description":"Squashed diẹ idun"}]
[{"title":"Trezor abinibi fun Bitcoin","description":"So ẹrọ rẹ pọ ki o forukọsilẹ awọn iṣowo Bitcoin ni ọtun ninu ohun elo naa"},{"title":"Solana ati NFTs Awọn ilọsiwaju","description":"Firanṣẹ awọn NFT Solana rẹ, tọju awọn ti a ko wọle si, ki o wo ni iwo kan kini awọn ti apamọwọ yii mu"},{"title":"Monero ìsiṣẹpọ","description":"Ilọsiwaju mimuṣiṣẹpọ n tẹsiwaju lakoko ti apamọwọ rẹ n gbona, nitorinaa o mọ ibiti o duro nigbagbogbo"},{"title":"Awọn atunṣe kokoro","description":"Imuṣiṣẹpọ olubasọrọ, yiyan ipade, Wọle isanwo Cake Pay ati diẹ sii, gbogbo wọn ti fọ"}]
@@ -1 +1 @@
[{"title":"AnyPay 改进","description":"现在,从任何钱包粘贴或扫描地址将正确地指示您从正确的钱包进行交换或发送,以及更多改进"},{"title":"用户界面改进","description":"为您添加了一些新鲜的 UI 元素"},{"title":"错误修复","description":"消除了更多错误"}]
[{"title":"Trezor 原生适用于比特币","description":"配对您的设备并直接在应用程序中签署比特币交易"},{"title":"Solana 和 NFT 的改进","description":"发送您的 Solana NFT,保存导入的 NFT,并一目了然地查看该钱包包含哪些 NFT"},{"title":"门罗币同步改进","description":"当您的钱包热身时,同步进度会不断变化,因此您始终知道进展情况"},{"title":"错误修复","description":"联系人同步、节点选择、Cake Pay 登录等全部被压缩"}]
+1
View File
@@ -0,0 +1 @@
use_precompiled_binaries: false
+48 -16
View File
@@ -430,7 +430,7 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store {
.isNotEmpty;
Future<PsbtV2> buildPsbt({
required List<BitcoinBaseOutput> outputs,
required List<BitcoinOutput> outputs,
required List<OutputInfo> cwOutputs,
required BigInt fee,
required BasedUtxoNetwork network,
@@ -447,33 +447,65 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store {
final rawTx = await electrumClient.getTransactionHex(hash: utxo.utxo.txHash);
final publicKeyAndDerivationPath = publicKeys[utxo.ownerDetails.address.pubKeyHash()]!;
psbtReadyInputs.add(PSBTReadyUtxoWithAddress(
utxo: utxo.utxo,
rawTx: rawTx,
ownerDetails: utxo.ownerDetails,
ownerDerivationPath: publicKeyAndDerivationPath.derivationPath,
ownerMasterFingerprint: masterFingerprint,
ownerPublicKey: publicKeyAndDerivationPath.publicKey,
));
psbtReadyInputs.add(
PSBTReadyUtxoWithAddress(
utxo: utxo.utxo,
rawTx: rawTx,
ownerDetails: utxo.ownerDetails,
ownerDerivationPath: publicKeyAndDerivationPath.derivationPath,
ownerMasterFingerprint: masterFingerprint,
ownerPublicKey: publicKeyAndDerivationPath.publicKey,
),
);
}
final psbtReadyOutputs = outputs.map((o) {
final cwOutput = cwOutputs
.where(
(e) => [e.address, e.extractedAddress]
.map((e) => e?.toLowerCase())
.contains(o.address.toAddress().toLowerCase()),
)
.firstOrNull;
if (o.isChange && publicKeys.containsKey(o.address.pubKeyHash())) {
final changeKey = publicKeys[o.address.pubKeyHash()]!;
return PSBTReadyBitcoinOutput(
address: o.address,
value: o.value,
isSilentPayment: o.isSilentPayment,
isChange: o.isChange,
changeMasterFingerprint: masterFingerprint,
changeDerivationPath: changeKey.derivationPath,
changePublicKey: changeKey.publicKey,
outputInfo: cwOutput,
);
}
return PSBTReadyBitcoinOutput(
address: o.address,
value: o.value,
isSilentPayment: o.isSilentPayment,
isChange: o.isChange,
outputInfo: cwOutput,
);
}).toList();
final locktime = antiFeeSnipingLocktime(
chainTip: await getCurrentChainTip(),
synced: syncStatus is SyncedSyncStatus,
);
return PSBTTransactionBuild(
inputs: psbtReadyInputs,
outputs: outputs,
enableRBF: enableRBF,
cwOutputs: cwOutputs,
locktime: locktime)
.psbt;
inputs: psbtReadyInputs,
outputs: psbtReadyOutputs,
enableRBF: enableRBF,
locktime: locktime,
).psbt;
}
@override
Future<BtcTransaction> buildHardwareWalletTransaction({
required List<BitcoinBaseOutput> outputs,
required List<BitcoinOutput> outputs,
required BigInt fee,
required BasedUtxoNetwork network,
required List<UtxoWithAddress> utxos,
+5 -2
View File
@@ -1283,8 +1283,11 @@ abstract class ElectrumWalletBase
final changeDerivationPath = "${_hardenedDerivationPath(changeBaseDerivationPath)}"
"/${changeAddress.isHidden ? "1" : "0"}"
"/${changeAddress.index}";
final changeHd = _hdFor(record: changeAddress).childKey(Bip32KeyIndex(changeAddress.index));
final changePubKeyHex = changeHd.publicKey.toHex();
utxoDetails.publicKeys[address.pubKeyHash()] =
PublicKeyWithDerivationPath('', changeDerivationPath);
PublicKeyWithDerivationPath(changePubKeyHex, changeDerivationPath);
// calcFee updates the silent payment outputs to calculate the tx size accounting
// for taproot addresses, but if more inputs are needed to make up for fees,
@@ -1737,7 +1740,7 @@ abstract class ElectrumWalletBase
HardwareWalletService? hardwareWalletService;
Future<BtcTransaction> buildHardwareWalletTransaction({
required List<BitcoinBaseOutput> outputs,
required List<BitcoinOutput> outputs,
required BigInt fee,
required BasedUtxoNetwork network,
required List<UtxoWithAddress> utxos,
+2 -2
View File
@@ -106,11 +106,11 @@ class LitecoinBitboxService extends HardwareWalletService
@override
Future<String> signLitecoinTransaction({
required List<BitcoinBaseOutput> outputs,
required List<PSBTReadyBitcoinOutput> outputs,
required List<PSBTReadyUtxoWithAddress> inputs,
required Map<String, PublicKeyWithDerivationPath> publicKeys,
}) async {
final psbt = PSBTTransactionBuild(inputs: inputs, outputs: outputs, cwOutputs: []).psbt;
final psbt = PSBTTransactionBuild(inputs: inputs, outputs: outputs).psbt;
final signedPsbt = await manager.signBTCPsbt(bitboxCoinType, base64Encode(psbt.asPsbtV0()));
final transactionRes = PsbtV2()
@@ -1,8 +1,7 @@
import 'dart:typed_data';
import "dart:typed_data";
import 'package:cw_bitcoin/electrum_wallet.dart';
import 'package:bitcoin_base/bitcoin_base.dart';
import 'package:cw_bitcoin/psbt/transaction_builder.dart';
import "package:cw_bitcoin/electrum_wallet.dart";
import "package:cw_bitcoin/psbt/transaction_builder.dart";
mixin BitcoinHardwareWalletService {
Future<Uint8List> getMasterFingerprint() async => Uint8List.fromList([0, 0, 0, 0]);
@@ -10,7 +9,7 @@ mixin BitcoinHardwareWalletService {
mixin LitecoinHardwareWalletService on BitcoinHardwareWalletService {
Future<String> signLitecoinTransaction({
required List<BitcoinBaseOutput> outputs,
required List<PSBTReadyBitcoinOutput> outputs,
required List<PSBTReadyUtxoWithAddress> inputs,
required Map<String, PublicKeyWithDerivationPath> publicKeys,
});
@@ -1,16 +1,16 @@
import 'dart:async';
import 'dart:typed_data';
import "dart:async";
import "dart:typed_data";
import 'package:bitcoin_base/bitcoin_base.dart';
import 'package:blockchain_utils/blockchain_utils.dart';
import 'package:cw_bitcoin/electrum_wallet.dart';
import 'package:cw_bitcoin/hardware/bitcoin_hardware_wallet_service.dart';
import 'package:cw_bitcoin/psbt/transaction_builder.dart';
import 'package:cw_bitcoin/utils.dart';
import 'package:cw_core/hardware/hardware_account_data.dart';
import 'package:cw_core/hardware/hardware_wallet_service.dart';
import 'package:ledger_flutter_plus/ledger_flutter_plus.dart';
import 'package:ledger_litecoin/ledger_litecoin.dart';
import "package:bitcoin_base/bitcoin_base.dart";
import "package:blockchain_utils/blockchain_utils.dart";
import "package:cw_bitcoin/electrum_wallet.dart";
import "package:cw_bitcoin/hardware/bitcoin_hardware_wallet_service.dart";
import "package:cw_bitcoin/psbt/transaction_builder.dart";
import "package:cw_bitcoin/utils.dart";
import "package:cw_core/hardware/hardware_account_data.dart";
import "package:cw_core/hardware/hardware_wallet_service.dart";
import "package:ledger_flutter_plus/ledger_flutter_plus.dart";
import "package:ledger_litecoin/ledger_litecoin.dart";
class LitecoinLedgerService extends HardwareWalletService
with BitcoinHardwareWalletService, LitecoinHardwareWalletService {
@@ -31,18 +31,21 @@ class LitecoinLedgerService extends HardwareWalletService
for (final i in indexRange) {
final derivationPath = "m/84'/2'/$i'";
final xpub = await litecoinLedgerApp.getXPubKey(
accountsDerivationPath: derivationPath,
xPubVersion: int.parse(hex.encode(xpubVersion.public), radix: 16));
accountsDerivationPath: derivationPath,
xPubVersion: int.parse(hex.encode(xpubVersion.public), radix: 16),
);
final hd = Bip32Slip10Secp256k1.fromExtendedKey(xpub, xpubVersion).childKey(Bip32KeyIndex(0));
final address = generateP2WPKHAddress(hd: hd, index: 0, network: LitecoinNetwork.mainnet);
accounts.add(HardwareAccountData(
address: address,
accountIndex: i,
derivationPath: derivationPath,
xpub: xpub,
));
accounts.add(
HardwareAccountData(
address: address,
accountIndex: i,
derivationPath: derivationPath,
xpub: xpub,
),
);
}
return accounts;
@@ -50,7 +53,7 @@ class LitecoinLedgerService extends HardwareWalletService
@override
Future<String> signLitecoinTransaction({
required List<BitcoinBaseOutput> outputs,
required List<PSBTReadyBitcoinOutput> outputs,
required List<PSBTReadyUtxoWithAddress> inputs,
required Map<String, PublicKeyWithDerivationPath> publicKeys,
}) {
@@ -58,31 +61,39 @@ class LitecoinLedgerService extends HardwareWalletService
for (final utxo in inputs) {
final publicKeyAndDerivationPath = publicKeys[utxo.ownerDetails.address.pubKeyHash()]!;
readyInputs.add(LedgerTransaction(
rawTx: utxo.rawTx,
outputIndex: utxo.utxo.vout,
ownerPublicKey: Uint8List.fromList(hex.decode(publicKeyAndDerivationPath.publicKey)),
ownerDerivationPath: publicKeyAndDerivationPath.derivationPath,
));
readyInputs.add(
LedgerTransaction(
rawTx: utxo.rawTx,
outputIndex: utxo.utxo.vout,
ownerPublicKey: Uint8List.fromList(hex.decode(publicKeyAndDerivationPath.publicKey)),
ownerDerivationPath: publicKeyAndDerivationPath.derivationPath,
),
);
}
// Check if we have the key to one of the output addresses to hide change on the device
String? changePath;
for (final output in outputs) {
final maybeChangePath = publicKeys[(output as BitcoinOutput).address.pubKeyHash()];
if (maybeChangePath != null) changePath ??= maybeChangePath.derivationPath;
if (output.isChange) {
changePath ??= output.changeDerivationPath;
}
}
return litecoinLedgerApp.createTransaction(
inputs: readyInputs,
outputs: outputs
.map((e) => TransactionOutput.fromBigInt((e as BitcoinOutput).value,
Uint8List.fromList(e.address.toScriptPubKey().toBytes())))
.toList(),
changePath: changePath,
sigHashType: 0x01,
additionals: ["bech32"],
isSegWit: true,
useTrustedInputForSegwit: true);
inputs: readyInputs,
outputs: outputs
.map(
(e) => TransactionOutput.fromBigInt(
(e as BitcoinOutput).value,
Uint8List.fromList(e.address.toScriptPubKey().toBytes()),
),
)
.toList(),
changePath: changePath,
sigHashType: 0x01,
additionals: ["bech32"],
isSegWit: true,
useTrustedInputForSegwit: true,
);
}
}
@@ -0,0 +1,100 @@
import 'dart:async';
import 'package:bitcoin_base/bitcoin_base.dart';
import 'package:blockchain_utils/blockchain_utils.dart';
import 'package:cw_bitcoin/electrum_wallet.dart';
import 'package:cw_bitcoin/hardware/bitcoin_hardware_wallet_service.dart';
import 'package:cw_bitcoin/psbt/transaction_builder.dart';
import 'package:cw_bitcoin/utils.dart';
import 'package:cw_core/hardware/hardware_account_data.dart';
import 'package:cw_core/hardware/hardware_wallet_service.dart';
import 'package:trezor_connect/trezor_connect.dart';
class LitecoinTrezorService extends HardwareWalletService
with BitcoinHardwareWalletService, LitecoinHardwareWalletService {
LitecoinTrezorService(this.connect);
final TrezorConnect connect;
@override
Future<List<HardwareAccountData>> getAvailableAccounts({int index = 0, int limit = 5}) async {
final indexRange = List.generate(limit, (i) => i + index);
final requestParams = <TrezorGetPublicKeyParams>[];
final xpubVersion = Bip44Conf.litecoinMainNet.altKeyNetVer;
for (final i in indexRange) {
final derivationPath = "m/84'/2'/$i'";
requestParams.add(TrezorGetPublicKeyParams(path: derivationPath, coin: "LTC"));
}
final accounts = await connect.getPublicKeyBundle(requestParams);
return accounts?.map((account) {
final hd = Bip32Slip10Secp256k1.fromExtendedKey(account.xpub, xpubVersion)
.childKey(Bip32KeyIndex(0));
final address = generateP2WPKHAddress(hd: hd, index: 0, network: LitecoinNetwork.mainnet);
return HardwareAccountData(
address: address,
xpub: account.xpub,
accountIndex: account.path[2] - 0x80000000, // unharden the path to get the index
derivationPath: account.serializedPath,
);
}).toList() ??
[];
}
@override
Future<String> signLitecoinTransaction({
required List<PSBTReadyBitcoinOutput> outputs,
required List<PSBTReadyUtxoWithAddress> inputs,
required Map<String, PublicKeyWithDerivationPath> publicKeys,
}) async {
final readyInputs = inputs
.map((input) => TrezorTxInput(
prevHash: input.utxo.txHash,
prevIndex: input.utxo.vout,
amount: input.utxo.value.toInt(),
addressPath: Bip32PathParser.parse(input.ownerDerivationPath).toList(),
scriptType: "SPENDWITNESS",
))
.toList();
final readyOutputs = outputs.map((output) {
final maybeChangePath = publicKeys[(output as BitcoinOutput).address.pubKeyHash()];
return TrezorTxOutput(
amount: output.toOutput.amount.toInt(),
address: maybeChangePath != null
? null
: output.toOutput.scriptPubKey.toAddress(network: LitecoinNetwork.mainnet),
scriptType: _getScriptType(output.toOutput.scriptPubKey.getAddressType()!),
addressPath: maybeChangePath != null
? Bip32PathParser.parse(maybeChangePath.derivationPath).toList()
: null,
);
}).toList();
final signedTx =
await connect.signTransaction(coin: 'LTC', inputs: readyInputs, outputs: readyOutputs);
return signedTx!.serializedTx;
}
}
String _getScriptType(BitcoinAddressType addressType) {
switch (addressType) {
case P2pkhAddressType.p2pkh:
return "PAYTOADDRESS";
case P2shAddressType.p2wpkhInP2sh:
return "PAYTOSCRIPTHASH";
case SegwitAddresType.p2tr:
return "PAYTOTAPROOT";
case SegwitAddresType.p2wsh:
return "PAYTOP2SHWITNESS";
case SegwitAddresType.p2wpkh:
return "PAYTOWITNESS";
default:
throw Exception("Unknown Address Type");
}
}
+128 -140
View File
@@ -1,190 +1,178 @@
import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'package:bitcoin_base/bitcoin_base.dart';
import 'package:blockchain_utils/blockchain_utils.dart';
import 'package:cw_bitcoin/electrum_wallet.dart';
import 'package:cw_bitcoin/hardware/bitcoin_hardware_wallet_service.dart';
import 'package:cw_bitcoin/psbt/transaction_builder.dart';
import 'package:cw_bitcoin/utils.dart';
import 'package:cw_core/hardware/hardware_account_data.dart';
import 'package:cw_core/hardware/hardware_wallet_service.dart';
import 'package:flutter/foundation.dart';
import 'package:ledger_bitcoin/psbt.dart';
import 'package:trezor_connect/trezor_connect.dart';
import 'package:ledger_bitcoin/src/utils/uint8list_extension.dart' as ext;
import 'package:trezor_flutter/trezor_flutter.dart' as sdk;
class BitcoinTrezorService extends HardwareWalletService with BitcoinHardwareWalletService {
BitcoinTrezorService(this.connect);
BitcoinTrezorService(this.client) : _trezorBitcoin = sdk.TrezorBitcoin(client);
final TrezorConnect connect;
final sdk.TrezorClient client;
final sdk.TrezorBitcoin _trezorBitcoin;
@override
Future<Uint8List> getMasterFingerprint() async {
final publicKey = await _trezorBitcoin.getPublicKey(derivationPath: "m/84'/0'/0'");
final fingerprintBuffer = ByteData(4)..setUint32(0, publicKey.$2);
return fingerprintBuffer.buffer.asUint8List();
}
@override
Future<List<HardwareAccountData>> getAvailableAccounts({int index = 0, int limit = 5}) async {
final accounts = <HardwareAccountData>[];
final indexRange = List.generate(limit, (i) => i + index);
final requestParams = <TrezorGetPublicKeyParams>[];
for (final i in indexRange) {
requestParams.add(TrezorGetPublicKeyParams(path: "m/84'/0'/$i'"));
final derivationPath = "m/84'/0'/$i'";
final xPub =
await _trezorBitcoin.getPublicKey(derivationPath: derivationPath, ignoreXpubMagic: true);
final hd = Bip32Slip10Secp256k1.fromExtendedKey(xPub.$1).childKey(Bip32KeyIndex(0));
final address = generateP2WPKHAddress(hd: hd, index: 0, network: BitcoinNetwork.mainnet);
accounts.add(
HardwareAccountData(
address: address,
accountIndex: i,
derivationPath: derivationPath,
xpub: xPub.$1,
),
);
}
final accounts = await connect.getPublicKeyBundle(requestParams);
return accounts?.map((account) {
final hd = Bip32Slip10Secp256k1.fromExtendedKey(account.xpub).childKey(Bip32KeyIndex(0));
final address = generateP2WPKHAddress(hd: hd, index: 0, network: BitcoinNetwork.mainnet);
return HardwareAccountData(
address: address,
xpub: account.xpub,
accountIndex: account.path[2] - 0x80000000, // unharden the path to get the index
derivationPath: account.serializedPath,
);
}).toList() ??
[];
return accounts;
}
@override
Future<Uint8List> signTransaction({required String transaction}) async {
final psbt = PsbtV2()..deserialize(base64Decode(transaction));
final masterFingerprint = await getMasterFingerprint();
final inputs = <sdk.TrezorTxInput>[];
final prevTxs = <String, sdk.TrezorPrevTx>{};
final inputs = <TrezorTxInput>[];
final inputCount = psbt.getGlobalInputCount();
for (var i = 0; i < inputCount; i++) {
final inputTxRaw = psbt.getInputNonWitnessUtxo(i);
final inputTx = BtcTransaction.fromRaw(hex.encode(inputTxRaw!));
final inputOutputIndex = psbt.getInputOutputIndex(i);
final rawPrevTx = psbt.getInputNonWitnessUtxo(i);
if (rawPrevTx == null) {
throw StateError("PSBT input $i is missing its previous transaction");
}
final prevTx = BtcTransaction.fromRaw(hex.encode(rawPrevTx));
final vout = psbt.getInputOutputIndex(i);
final publicKeys = psbt.inputMaps[i].keys.where((e) => e.startsWith("06"));
final pubkey = Uint8List.fromList(hex.decode(publicKeys.first.substring(2)));
final txidBytes = psbt.getInputPreviousTxid(i).reversed.toList();
inputs.add(TrezorTxInput(
prevHash: hex.encode(psbt.getInputPreviousTxid(i).reversed.toList()),
prevIndex: inputOutputIndex,
amount: inputTx.outputs[inputOutputIndex].amount.toInt(),
addressPath: psbt.getInputBip32Derivation(i, pubkey)!.$2,
final pubkey = _firstDerivationPubkey(psbt.inputMaps[i], "06");
if (pubkey == null) {
throw StateError("PSBT input $i is missing its BIP32 derivation");
}
final derivation = psbt.getInputBip32Derivation(i, pubkey)!;
final path = derivation.$2;
if (!listEquals(derivation.$1, masterFingerprint)) {
throw Exception("Fingerprint missmatch with the PSBT");
}
inputs.add(
sdk.TrezorTxInput(
addressPath: path,
prevHash: txidBytes,
prevIndex: vout,
amount: prevTx.outputs[vout].amount.toInt(),
sequence: psbt.getInputSequence(i),
scriptType: "SPENDWITNESS"));
scriptType: "SPENDWITNESS",
),
);
prevTxs[hex.encode(txidBytes)] = sdk.TrezorPrevTx(
meta: sdk.TrezorPrevTxMeta(
version: Uint8List.fromList(prevTx.version).readUint32LE(0),
lockTime: Uint8List.fromList(prevTx.locktime).readUint32LE(0),
inputsCount: prevTx.inputs.length,
outputsCount: prevTx.outputs.length,
),
inputs: prevTx.inputs
.map(
(e) => sdk.TrezorPrevInput(
prevHash: hex.decode(e.txId),
prevIndex: e.txIndex,
scriptSig: e.scriptSig.toBytes(),
sequence: Uint8List.fromList(e.sequence).readUint32LE(0),
),
)
.toList(),
outputs: prevTx.outputs
.map(
(e) => sdk.TrezorPrevOutput(
amount: e.amount.toInt(),
scriptPubkey: e.scriptPubKey.toBytes(),
),
)
.toList(),
);
}
final outputs = <TrezorTxOutput>[];
final outputs = <sdk.TrezorTxOutput>[];
final outputCount = psbt.getGlobalOutputCount();
for (var i = 0; i < outputCount; i++) {
final amount = psbt.getOutputAmount(i);
// An output carrying our own BIP32 derivation is change: identify it
// by path so the device verifies it internally instead of displaying
// it as a payment.
final changePubkey = _firstDerivationPubkey(psbt.outputMaps[i], "02");
if (changePubkey != null) {
final (fingerprint, path) = psbt.getOutputBip32Derivation(i, changePubkey);
if (listEquals(fingerprint, masterFingerprint)) {
outputs.add(sdk.TrezorTxOutput(
addressPath: path,
amount: amount,
scriptType: "PAYTOWITNESS",
));
continue;
}
}
final script = Script.fromRaw(byteData: psbt.getOutputScript(i));
// Trezor's protocol expects script_type: PAYTOADDRESS whenever the
// output is identified by `address` (vs. own-change `addressPath`).
// Suite parses the address string itself to determine the actual
// on-chain script type (P2WPKH, P2TR, P2SH, etc.); script_type is
// only semantically meaningful when addressPath is set. Passing
// PAYTOWITNESS / PAYTOP2SHWITNESS / PAYTOTAPROOT here together with
// `address` causes Suite to reject the deeplink as "Invalid
// parameters from calling app" because those values specifically
// mean "own change paid to that wallet type."
outputs.add(TrezorTxOutput(
amount: psbt.getOutputAmount(i),
outputs.add(sdk.TrezorTxOutput(
address: script.toAddress(),
amount: amount,
scriptType: "PAYTOADDRESS",
// ToDo: when change-output detection lands, set addressPath + _getScriptType(...) for own-change outputs.
// ToDo: addressPath: psbt.getOutputBip32Derivation(i, pubkey).$2, // To highlight change outputs
));
}
final signedTx = await connect.signTransaction(coin: 'btc', inputs: inputs, outputs: outputs);
final signed = await _trezorBitcoin.signTransaction(
inputs: inputs,
outputs: outputs,
prevTxs: prevTxs,
version: psbt.getGlobalTxVersion(),
lockTime: psbt.getGlobalFallbackLocktime() ?? 0,
);
return Uint8List.fromList(BytesUtils.fromHexString(signedTx!.serializedTx));
return signed.serializedTx;
}
@override
Future<Uint8List> signMessage({required Uint8List message, String? derivationPath}) async {
final sig = await connect.signMessage(derivationPath ?? "m/84'/0'/0'/0/0",
message: hex.encode(message), hex: true);
return base64Decode(sig!.signature);
}
}
class LitecoinTrezorService extends HardwareWalletService
with BitcoinHardwareWalletService, LitecoinHardwareWalletService {
LitecoinTrezorService(this.connect);
final TrezorConnect connect;
@override
Future<List<HardwareAccountData>> getAvailableAccounts({int index = 0, int limit = 5}) async {
final indexRange = List.generate(limit, (i) => i + index);
final requestParams = <TrezorGetPublicKeyParams>[];
final xpubVersion = Bip44Conf.litecoinMainNet.altKeyNetVer;
for (final i in indexRange) {
final derivationPath = "m/84'/2'/$i'";
requestParams.add(TrezorGetPublicKeyParams(path: derivationPath, coin: "LTC"));
}
final accounts = await connect.getPublicKeyBundle(requestParams);
return accounts?.map((account) {
final hd = Bip32Slip10Secp256k1.fromExtendedKey(account.xpub, xpubVersion)
.childKey(Bip32KeyIndex(0));
final address = generateP2WPKHAddress(hd: hd, index: 0, network: LitecoinNetwork.mainnet);
return HardwareAccountData(
address: address,
xpub: account.xpub,
accountIndex: account.path[2] - 0x80000000, // unharden the path to get the index
derivationPath: account.serializedPath,
);
}).toList() ??
[];
}
@override
Future<String> signLitecoinTransaction({
required List<BitcoinBaseOutput> outputs,
required List<PSBTReadyUtxoWithAddress> inputs,
required Map<String, PublicKeyWithDerivationPath> publicKeys,
}) async {
final readyInputs = inputs
.map((input) => TrezorTxInput(
prevHash: input.utxo.txHash,
prevIndex: input.utxo.vout,
amount: input.utxo.value.toInt(),
addressPath: Bip32PathParser.parse(input.ownerDerivationPath).toList(),
scriptType: "SPENDWITNESS",
))
.toList();
final readyOutputs = outputs.map((output) {
final maybeChangePath = publicKeys[(output as BitcoinOutput).address.pubKeyHash()];
return TrezorTxOutput(
amount: output.toOutput.amount.toInt(),
address: maybeChangePath != null
? null
: output.toOutput.scriptPubKey.toAddress(network: LitecoinNetwork.mainnet),
scriptType: _getScriptType(output.toOutput.scriptPubKey.getAddressType()!),
addressPath: maybeChangePath != null
? Bip32PathParser.parse(maybeChangePath.derivationPath).toList()
: null,
Future<Uint8List> signMessage({required Uint8List message, String? derivationPath}) =>
_trezorBitcoin.signMessage(
derivationPath: derivationPath ?? "m/84'/0'/0'/0/0",
message: message,
);
}).toList();
final signedTx =
await connect.signTransaction(coin: 'LTC', inputs: readyInputs, outputs: readyOutputs);
return signedTx!.serializedTx;
}
}
String _getScriptType(BitcoinAddressType addressType) {
switch (addressType) {
case P2pkhAddressType.p2pkh:
return "PAYTOADDRESS";
case P2shAddressType.p2wpkhInP2sh:
return "PAYTOSCRIPTHASH";
case SegwitAddresType.p2tr:
return "PAYTOTAPROOT";
case SegwitAddresType.p2wsh:
return "PAYTOP2SHWITNESS";
case SegwitAddresType.p2wpkh:
return "PAYTOWITNESS";
default:
throw Exception("Unknown Address Type");
/// Extracts the pubkey from the first BIP32-derivation record
/// ([keyTypeHex] `06` for inputs, `02` for outputs) of a PSBT key-value map.
Uint8List? _firstDerivationPubkey(Map<String, Uint8List> map, String keyTypeHex) {
final key = map.keys.where((mapKey) => mapKey.startsWith(keyTypeHex)).firstOrNull;
if (key == null) return null;
return Uint8List.fromList(hex.decode(key.substring(2)));
}
}
+34 -14
View File
@@ -1556,7 +1556,7 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store {
@override
Future<BtcTransaction> buildHardwareWalletTransaction({
required List<BitcoinBaseOutput> outputs,
required List<BitcoinOutput> outputs,
required BigInt fee,
required BasedUtxoNetwork network,
required List<UtxoWithAddress> utxos,
@@ -1568,28 +1568,48 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store {
BitcoinOrdering outputOrdering = BitcoinOrdering.bip69,
}) async {
final masterFingerprint =
await (hardwareWalletService as BitcoinHardwareWalletService).getMasterFingerprint();
await (hardwareWalletService! as BitcoinHardwareWalletService).getMasterFingerprint();
final readyInputs = <PSBTReadyUtxoWithAddress>[];
for (final utxo in utxos) {
final rawTx = await electrumClient.getTransactionHex(hash: utxo.utxo.txHash);
final publicKeyAndDerivationPath = publicKeys[utxo.ownerDetails.address.pubKeyHash()]!;
readyInputs.add(PSBTReadyUtxoWithAddress(
utxo: utxo.utxo,
rawTx: rawTx,
ownerDetails: utxo.ownerDetails,
ownerDerivationPath: publicKeyAndDerivationPath.derivationPath,
ownerMasterFingerprint: masterFingerprint,
ownerPublicKey: publicKeyAndDerivationPath.publicKey,
));
readyInputs.add(
PSBTReadyUtxoWithAddress(
utxo: utxo.utxo,
rawTx: rawTx,
ownerDetails: utxo.ownerDetails,
ownerDerivationPath: publicKeyAndDerivationPath.derivationPath,
ownerMasterFingerprint: masterFingerprint,
ownerPublicKey: publicKeyAndDerivationPath.publicKey,
),
);
}
final orderedOutputs = orderOutputs(outputs, outputOrdering);
final orderedOutputs =
orderOutputs(outputs, outputOrdering).map((o) {
if (o.isChange && publicKeys.containsKey(o.address.pubKeyHash())) {
final changeKey = publicKeys[o.address.pubKeyHash()]!;
return PSBTReadyBitcoinOutput(
address: o.address,
value: o.value,
isSilentPayment: o.isSilentPayment,
isChange: o.isChange,
changeMasterFingerprint: masterFingerprint,
changeDerivationPath: changeKey.derivationPath,
changePublicKey: changeKey.publicKey,
);
}
return PSBTReadyBitcoinOutput.fromOutput(o);
});
final rawHex = await (hardwareWalletService as LitecoinHardwareWalletService)
.signLitecoinTransaction(
outputs: orderedOutputs, inputs: readyInputs, publicKeys: publicKeys);
final rawHex =
await (hardwareWalletService! as LitecoinHardwareWalletService).signLitecoinTransaction(
outputs: orderedOutputs.toList(),
inputs: readyInputs,
publicKeys: publicKeys,
);
return BtcTransaction.fromRaw(rawHex);
}
+112 -63
View File
@@ -1,21 +1,19 @@
import 'dart:convert';
import 'dart:typed_data';
import "dart:convert";
import "dart:typed_data";
import 'package:bitcoin_base/bitcoin_base.dart';
import 'package:convert/convert.dart';
import 'package:cw_core/output_info.dart';
import 'package:cw_core/utils/print_verbose.dart';
import 'package:ledger_bitcoin/psbt.dart';
import "package:bitcoin_base/bitcoin_base.dart";
import "package:convert/convert.dart";
import "package:cw_core/output_info.dart";
import "package:cw_core/utils/print_verbose.dart";
import "package:ledger_bitcoin/psbt.dart";
class PSBTTransactionBuild {
final PsbtV2 psbt = PsbtV2();
PSBTTransactionBuild(
{required List<PSBTReadyUtxoWithAddress> inputs,
required List<BitcoinBaseOutput> outputs,
required List<OutputInfo> cwOutputs,
bool enableRBF = true,
int locktime = 0}) {
PSBTTransactionBuild({
required List<PSBTReadyUtxoWithAddress> inputs,
required List<PSBTReadyBitcoinOutput> outputs,
bool enableRBF = true,
int locktime = 0,
}) {
psbt.setGlobalTxVersion(2);
psbt.setGlobalFallbackLocktime(locktime);
psbt.setGlobalInputCount(inputs.length);
@@ -24,12 +22,10 @@ class PSBTTransactionBuild {
for (var i = 0; i < inputs.length; i++) {
final input = inputs[i];
printV(input.utxo.isP2tr());
printV(input.utxo.isSegwit());
printV(input.utxo.isP2shSegwit());
psbt.setInputPreviousTxId(
i, Uint8List.fromList(hex.decode(input.utxo.txHash).reversed.toList()));
i,
Uint8List.fromList(hex.decode(input.utxo.txHash).reversed.toList()),
);
psbt.setInputOutputIndex(i, input.utxo.vout);
psbt.setInputSequence(i, enableRBF ? 0xfffffffd : 0xffffffff);
@@ -47,75 +43,98 @@ class PSBTTransactionBuild {
for (var i = 0; i < outputs.length; i++) {
final output = outputs[i];
if (output is BitcoinOutput) {
psbt.setOutputScript(i, Uint8List.fromList(output.address.toScriptPubKey().toBytes()));
psbt.setOutputAmount(i, output.value.toInt());
if (cwOutputs.isNotEmpty) {
try {
final cwOutput = cwOutputs
.where((e) => [e.address, e.extractedAddress]
.map((e) => e?.toLowerCase())
.contains(output.address.toAddress().toLowerCase()))
.firstOrNull;
if (cwOutput != null &&
cwOutput.extra.containsKey('bip353_name') &&
cwOutput.extra.containsKey('bip353_proof')) {
final bip353Name = utf8.encode(cwOutput.extra['bip353_name'] as String);
final bip353Proof = base64.decode(cwOutput.extra['bip353_proof'] as String);
psbt.setOutputScript(i, Uint8List.fromList(output.address.toScriptPubKey().toBytes()));
psbt.setOutputAmount(i, output.value.toInt());
if (bip353Name.length > 255) {
printV('BIP353 name is too long, skipping');
continue;
}
final proof = Uint8List.fromList([
bip353Name.length,
...bip353Name,
...bip353Proof,
]);
if (output.isChange &&
output.changeDerivationPath != null &&
output.changePublicKey != null &&
output.changeMasterFingerprint != null) {
psbt.setOutputBip32Derivation(
i,
Uint8List.fromList(hex.decode(output.changePublicKey!)),
output.changeMasterFingerprint!,
BIPPath.fromString(output.changeDerivationPath!).toPathArray(),
);
}
psbt.setOutputDNSSECProof(i, proof);
if (output.outputInfo != null) {
try {
final cwOutput = output.outputInfo!;
if (cwOutput.extra.containsKey("bip353_name") &&
cwOutput.extra.containsKey("bip353_proof")) {
final bip353Name = utf8.encode(cwOutput.extra["bip353_name"] as String);
final bip353Proof = base64.decode(cwOutput.extra["bip353_proof"] as String);
if (bip353Name.length > 255) {
printV("BIP353 name is too long, skipping");
continue;
}
} catch (e) {
printV('Error setting DNSSEC proof: $e');
final proof = Uint8List.fromList([
bip353Name.length,
...bip353Name,
...bip353Proof,
]);
psbt.setOutputDNSSECProof(i, proof);
}
} catch (e) {
printV("Error setting DNSSEC proof: $e");
}
}
}
}
final PsbtV2 psbt = PsbtV2();
void setInputP2pkh(int i, PSBTReadyUtxoWithAddress input) {
psbt.setInputNonWitnessUtxo(i, Uint8List.fromList(hex.decode(input.rawTx)));
psbt.setInputBip32Derivation(i, Uint8List.fromList(hex.decode(input.ownerPublicKey)),
input.ownerMasterFingerprint, BIPPath.fromString(input.ownerDerivationPath).toPathArray());
psbt.setInputBip32Derivation(
i,
Uint8List.fromList(hex.decode(input.ownerPublicKey)),
input.ownerMasterFingerprint,
BIPPath.fromString(input.ownerDerivationPath).toPathArray(),
);
}
void setInputSegwit(int i, PSBTReadyUtxoWithAddress input) {
psbt.setInputNonWitnessUtxo(i, Uint8List.fromList(hex.decode(input.rawTx)));
psbt.setInputBip32Derivation(i, Uint8List.fromList(hex.decode(input.ownerPublicKey)),
input.ownerMasterFingerprint, BIPPath.fromString(input.ownerDerivationPath).toPathArray());
psbt.setInputBip32Derivation(
i,
Uint8List.fromList(hex.decode(input.ownerPublicKey)),
input.ownerMasterFingerprint,
BIPPath.fromString(input.ownerDerivationPath).toPathArray(),
);
psbt.setInputWitnessUtxo(i, Uint8List.fromList(bigIntToUint64LE(input.utxo.value)),
Uint8List.fromList(input.ownerDetails.address.toScriptPubKey().toBytes()));
psbt.setInputWitnessUtxo(
i,
Uint8List.fromList(bigIntToUint64LE(input.utxo.value)),
Uint8List.fromList(input.ownerDetails.address.toScriptPubKey().toBytes()),
);
}
void setInputP2shSegwit(int i, PSBTReadyUtxoWithAddress input) {
psbt.setInputNonWitnessUtxo(i, Uint8List.fromList(hex.decode(input.rawTx)));
psbt.setInputBip32Derivation(i, Uint8List.fromList(hex.decode(input.ownerPublicKey)),
input.ownerMasterFingerprint, BIPPath.fromString(input.ownerDerivationPath).toPathArray());
psbt.setInputBip32Derivation(
i,
Uint8List.fromList(hex.decode(input.ownerPublicKey)),
input.ownerMasterFingerprint,
BIPPath.fromString(input.ownerDerivationPath).toPathArray(),
);
psbt.setInputRedeemScript(
i, Uint8List.fromList(input.ownerDetails.address.toScriptPubKey().toBytes()));
psbt.setInputWitnessUtxo(i, Uint8List.fromList(bigIntToUint64LE(input.utxo.value)),
Uint8List.fromList(input.ownerDetails.address.toScriptPubKey().toBytes()));
i,
Uint8List.fromList(input.ownerDetails.address.toScriptPubKey().toBytes()),
);
psbt.setInputWitnessUtxo(
i,
Uint8List.fromList(bigIntToUint64LE(input.utxo.value)),
Uint8List.fromList(input.ownerDetails.address.toScriptPubKey().toBytes()),
);
}
}
class PSBTReadyUtxoWithAddress extends UtxoWithAddress {
final String rawTx;
final String ownerDerivationPath;
final Uint8List ownerMasterFingerprint;
final String ownerPublicKey;
PSBTReadyUtxoWithAddress({
required super.utxo,
required this.rawTx,
@@ -124,4 +143,34 @@ class PSBTReadyUtxoWithAddress extends UtxoWithAddress {
required this.ownerMasterFingerprint,
required this.ownerPublicKey,
});
final String rawTx;
final String ownerDerivationPath;
final Uint8List ownerMasterFingerprint;
final String ownerPublicKey;
}
class PSBTReadyBitcoinOutput extends BitcoinOutput {
PSBTReadyBitcoinOutput({
required super.address,
required super.value,
super.isSilentPayment = false,
super.isChange = false,
this.changeDerivationPath,
this.changeMasterFingerprint,
this.changePublicKey,
this.outputInfo,
});
factory PSBTReadyBitcoinOutput.fromOutput(BitcoinOutput output) => PSBTReadyBitcoinOutput(
address: output.address,
value: output.value,
isSilentPayment: output.isSilentPayment,
isChange: output.isChange,
);
final String? changeDerivationPath;
final Uint8List? changeMasterFingerprint;
final String? changePublicKey;
final OutputInfo? outputInfo;
}
+5
View File
@@ -55,6 +55,11 @@ dependencies:
git:
url: https://github.com/cake-tech/trezor_connect
ref: d1242cea90f84b00200e7bcab914a5af750e23fb
trezor_flutter:
git:
url: https://github.com/cake-tech/trezor-flutter
ref: f5121b1f3a06000ff836da8e6e6c83f53a9fa487
path: trezor-flutter
bitbox_flutter:
path: ../scripts/bitbox_flutter
socks_socket:
+2 -5
View File
@@ -1,5 +1,5 @@
import "package:cw_core/amount/money.dart";
import "package:intl/intl.dart";
import "package:cw_core/crypto_amount_format.dart";
extension WithLocalSeparator on Money {
String toLocalStringWithSymbol({
@@ -37,10 +37,7 @@ extension WithLocalSeparator on Money {
String _withLocalSeparator(String amount, {String? locale}) {
final isNegative = amount.startsWith("-");
final formater = NumberFormat("#,###", locale);
final parts = (isNegative ? amount.substring(1) : amount).split(".");
final formatted = [formater.format(int.tryParse(parts.first) ?? 0), ...parts.sublist(1)]
.join(formater.symbols.DECIMAL_SEP);
final formatted = (isNegative ? amount.substring(1) : amount).withLocalSeperator(locale);
return isNegative ? "-$formatted" : formatted;
}
+4 -1
View File
@@ -67,7 +67,10 @@ extension MaxDecimals on String {
return [parts.first.withLocalSeperator(locale), ...parts.sublist(1)].join(" ");
}
final formater = NumberFormat("#,###", locale);
final formater = NumberFormat(
"#,###",
Intl.verifiedLocale(locale, NumberFormat.localeExists, onFailure: (_) => "en"),
);
final parts = replaceAll(",", "").split(".");
if (parts.first.contains("< 0")) parts.first = "0";
+26 -1
View File
@@ -63,7 +63,7 @@ Future<void> _initDb({String? pathOverride}) async {
}
}
await db?.close();
db = await openDatabase(dbFile.path, version: 10,
db = await openDatabase(dbFile.path, version: 11,
onUpgrade: (Database db, int oldVersion, int newVersion) async {
printV("migrating: $oldVersion, $newVersion");
if (oldVersion <= 1) {
@@ -152,6 +152,10 @@ CREATE TABLE IF NOT EXISTS BalanceCardStyleSettings (
await _createSplTokenTable(db);
await _createTronTokenTable(db);
}
if (oldVersion <= 10) {
await _createImportedNFTTable(db);
}
}, onCreate: (Database db, int version) async {
await db.execute('''
CREATE TABLE WalletInfo (
@@ -250,6 +254,7 @@ CREATE TABLE BalanceCardStyleSettings (
await _createErc20TokenTable(db);
await _createSplTokenTable(db);
await _createTronTokenTable(db);
await _createImportedNFTTable(db);
});
}
@@ -422,6 +427,26 @@ ON SPLToken (walletName, mintAddress);
""");
}
Future<void> _createImportedNFTTable(Database db) async {
await db.execute("""
CREATE TABLE IF NOT EXISTS ImportedNFT (
ImportedNFTId INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
walletName TEXT NOT NULL,
chain TEXT NOT NULL,
identifier TEXT NOT NULL,
name TEXT,
symbol TEXT,
description TEXT,
imageUrl TEXT,
isOwned INTEGER
);
""");
await db.execute("""
CREATE UNIQUE INDEX IF NOT EXISTS idx_importednft_wallet_chain_identifier
ON ImportedNFT (walletName, chain, identifier);
""");
}
Future<void> _createTronTokenTable(Database db) async {
await db.execute("""
CREATE TABLE IF NOT EXISTS TronToken (
+2
View File
@@ -55,6 +55,8 @@ class SignSPLTokenTransactionRentException implements Exception {}
class NoAssociatedTokenAccountException implements Exception {}
class NotAnNFTException implements Exception {}
class AmbiguousTokenSymbolException implements Exception {
AmbiguousTokenSymbolException(this.symbol);
+138
View File
@@ -0,0 +1,138 @@
import "package:cw_core/db/sqlite.dart";
import "package:sqflite/sqflite.dart";
class ImportedNFT {
ImportedNFT({
required this.walletName,
required this.chain,
required this.identifier,
this.name,
this.symbol,
this.description,
this.imageUrl,
this.isOwned,
this.id = 0,
});
ImportedNFT.copyWith(ImportedNFT other, {String? walletName})
: id = 0,
walletName = walletName ?? other.walletName,
chain = other.chain,
identifier = other.identifier,
name = other.name,
symbol = other.symbol,
description = other.description,
imageUrl = other.imageUrl,
isOwned = other.isOwned;
factory ImportedNFT.fromMap(Map<String, Object?> map) => ImportedNFT(
id: map[selfIdColumn] as int? ?? 0,
walletName: map["walletName"]! as String,
chain: map["chain"]! as String,
identifier: map["identifier"]! as String,
name: map["name"] as String?,
symbol: map["symbol"] as String?,
description: map["description"] as String?,
imageUrl: map["imageUrl"] as String?,
isOwned: map["isOwned"] == null ? null : map["isOwned"] == 1,
);
int id;
final String walletName;
final String chain;
final String identifier;
final String? name;
final String? symbol;
final String? description;
final String? imageUrl;
final bool? isOwned;
static String get tableName => "ImportedNFT";
static String get selfIdColumn => "${tableName}Id";
static const solanaChain = "solana";
Map<String, Object?> toMap() => {
selfIdColumn: id,
"walletName": walletName,
"chain": chain,
"identifier": identifier,
"name": name,
"symbol": symbol,
"description": description,
"imageUrl": imageUrl,
"isOwned": isOwned == null ? null : (isOwned! ? 1 : 0),
};
Future<int> save() async {
final row = toMap();
if (row[selfIdColumn] == 0) {
row[selfIdColumn] = null;
}
id = await db!.insert(tableName, row, conflictAlgorithm: ConflictAlgorithm.replace);
return id;
}
static Future<int> updateMetadata(ImportedNFT nft) => db!.update(
tableName,
nft.toMap()..remove(selfIdColumn),
where: "walletName = ? AND chain = ? AND identifier = ?",
whereArgs: [nft.walletName, nft.chain, nft.identifier],
);
static Future<List<ImportedNFT>> getAllForWallet(String walletName, [String? chain]) async {
final rows = await db!.query(
tableName,
where: chain == null ? "walletName = ?" : "walletName = ? AND chain = ?",
whereArgs: chain == null ? [walletName] : [walletName, chain],
orderBy: selfIdColumn,
);
return rows.map(ImportedNFT.fromMap).toList();
}
static Future<int> deleteOne(String walletName, String chain, String identifier) => db!.delete(
tableName,
where: "walletName = ? AND chain = ? AND identifier = ?",
whereArgs: [walletName, chain, identifier],
);
static String _scope(List<String>? chains) =>
chains == null ? "" : " AND chain IN (${List.filled(chains.length, "?").join(", ")})";
static Future<int> deleteAllForWallet(String walletName, {List<String>? chains}) async {
if (chains != null && chains.isEmpty) {
return 0;
}
return db!.delete(
tableName,
where: "walletName = ?${_scope(chains)}",
whereArgs: [walletName, ...?chains],
);
}
static Future<void> renameWallet(
String oldName,
String newName, {
List<String>? chains,
}) async {
if (chains != null && chains.isEmpty) {
return;
}
await db!.delete(
tableName,
where: "walletName = ?${_scope(chains)}",
whereArgs: [newName, ...?chains],
);
await db!.update(
tableName,
{"walletName": newName},
where: "walletName = ?${_scope(chains)}",
whereArgs: [oldName, ...?chains],
);
}
}
+25
View File
@@ -0,0 +1,25 @@
const _ipfsGateway = "https://ipfs.io/ipfs/";
const _ipfsScheme = "ipfs://";
String? tryNormalizeIpfsUrl(String? url) {
if (url == null) {
return null;
}
if (!url.toLowerCase().startsWith(_ipfsScheme)) {
return url.toLowerCase().startsWith("https://") ? url : null;
}
var cid = url.substring(_ipfsScheme.length);
// Some minters write ipfs://ipfs/<cid>, which would double the gateway path.
if (cid.toLowerCase().startsWith("ipfs/")) {
cid = cid.substring(5);
}
if (cid.isEmpty) {
return null;
}
return "$_ipfsGateway$cid";
}
+25
View File
@@ -0,0 +1,25 @@
// Control characters and bidi overrides let a mint's own metadata fake extra
// lines or reverse the text around it wherever the name is shown.
final _unsafeDisplayCharacters = RegExp(
"[\u0000-\u001F\u007F\u00AD\u034F\u061C\u115F\u1160\u180E\u200B\u200E\u200F"
"\u2028\u2029\u202A-\u202E\u2060-\u2064\u2066-\u2069\u3164\uFEFF\uFFA0]"
"|[\u{E0000}-\u{E007F}]",
unicode: true,
);
const _maxDisplayTextLength = 512;
String? sanitizeNFTText(String? text) {
if (text == null) {
return null;
}
final cleaned = text.replaceAll(_unsafeDisplayCharacters, " ").trim();
final runes = cleaned.runes;
if (runes.length <= _maxDisplayTextLength) {
return cleaned;
}
return "${String.fromCharCodes(runes.take(_maxDisplayTextLength))}\u2026";
}
+6
View File
@@ -1,6 +1,7 @@
import "dart:convert";
import "dart:io";
import "package:cw_core/imported_nft.dart";
import "package:cw_core/pathForWallet.dart";
import "package:cw_core/spl_token.dart";
import "package:cw_core/tron_token.dart";
@@ -62,6 +63,11 @@ abstract class WalletService<N extends WalletCredentials, RFS extends WalletCred
Future<void> _renameTokenRows(String currentName, String newName) async {
if (getType() == WalletType.solana) {
await SPLToken.renameWallet(currentName, newName);
await ImportedNFT.renameWallet(
currentName,
newName,
chains: const [ImportedNFT.solanaChain],
);
}
if (getType() == WalletType.tron) {
@@ -23,6 +23,10 @@ void main() {
expect(_btcGrouping.toLocalStringWithPrecision(locale: "de_DE"), "1.234,5");
});
test("formats as English when intl has no number data for the locale (yo)", () {
expect(_btcGrouping.toLocalStringWithPrecision(locale: "yo"), "1,234.5");
});
test("base unit renders integer sats with grouping", () {
expect(
_btc012.toLocalStringWithPrecision(useBaseUnit: true, locale: "en_US"),
+9
View File
@@ -108,6 +108,15 @@ void main() {
});
});
group("Locale without intl number data", () {
test("should format as English instead of throwing (ha)", () {
const input = "1123.4567";
final result = input.withLocalSeperator("ha");
expect(result, equals("1,123.4567"));
});
});
group('With XMR Suffix', () {
const locale = 'de_CH';
+176
View File
@@ -0,0 +1,176 @@
import "dart:io";
import "package:cw_core/db/sqlite.dart";
import "package:cw_core/imported_nft.dart";
import "package:flutter_test/flutter_test.dart";
import "package:path_provider_platform_interface/path_provider_platform_interface.dart";
import "package:sqflite_common_ffi/sqflite_ffi.dart";
// Faking the documents dir keeps getAppDir() off the platform channel, so the
// test runs with a plain `flutter test` on any host and in CI.
class _FakePathProviderPlatform extends PathProviderPlatform {
_FakePathProviderPlatform(this.root);
final String root;
@override
Future<String?> getApplicationDocumentsPath() async => root;
@override
Future<String?> getApplicationSupportPath() async => root;
}
Future<void> main() async {
final dataRoot = Directory("./test/data/imported_nft");
Future<void> insert(String walletName, String chain, String identifier) =>
ImportedNFT(walletName: walletName, chain: chain, identifier: identifier, name: identifier)
.save();
Future<List<String>> identifiersFor(String walletName, [String? chain]) async =>
(await ImportedNFT.getAllForWallet(walletName, chain)).map((nft) => nft.identifier).toList();
setUpAll(() async {
if (dataRoot.existsSync()) {
dataRoot.deleteSync(recursive: true);
}
dataRoot.createSync(recursive: true);
Directory("${dataRoot.path}/cake_wallet").createSync(recursive: true);
PathProviderPlatform.instance = _FakePathProviderPlatform(dataRoot.absolute.path);
sqfliteFfiInit();
databaseFactory = databaseFactoryFfi;
await initDb();
});
setUp(() async {
await db!.delete(ImportedNFT.tableName);
});
tearDownAll(() {
if (dataRoot.existsSync()) {
dataRoot.deleteSync(recursive: true);
}
});
group("ImportedNFT wallet lifecycle", () {
test("reading without a chain returns every chain the wallet imported on", () async {
await insert("Main", ImportedNFT.solanaChain, "SolMint");
await insert("Main", "eth", "0xcontract:1");
await insert("Main", "polygon", "0xother:7");
expect(await identifiersFor("Main", "eth"), ["0xcontract:1"]);
expect(
(await identifiersFor("Main"))..sort(),
["0xcontract:1", "0xother:7", "SolMint"],
);
});
test("rename moves every chain and leaves other wallets alone", () async {
await insert("Main", ImportedNFT.solanaChain, "SolMint");
await insert("Main", "eth", "0xcontract:1");
await insert("Sidekick", "eth", "0xkeep:9");
await ImportedNFT.renameWallet("Main", "Renamed");
expect(await identifiersFor("Main"), isEmpty);
expect((await identifiersFor("Renamed"))..sort(), ["0xcontract:1", "SolMint"]);
expect(await identifiersFor("Sidekick"), ["0xkeep:9"]);
});
test("renaming into a name that still has orphan rows does not trip the unique index",
() async {
await insert("Orphaned", "eth", "0xcontract:1");
await insert("Orphaned", "polygon", "0xsurvivor:5");
await insert("Main", "eth", "0xcontract:1");
await insert("Main", "eth", "0xsecond:2");
await ImportedNFT.renameWallet("Main", "Orphaned", chains: const ["eth"]);
// Only the colliding chain is cleared. A row the destination holds on
// another chain is not ours to delete.
expect(
(await identifiersFor("Orphaned"))..sort(),
["0xcontract:1", "0xsecond:2", "0xsurvivor:5"],
);
});
test("renaming a solana wallet leaves a same-named evm wallet's NFTs alone", () async {
await insert("Sol", ImportedNFT.solanaChain, "SolMint");
await insert("Vault", "eth", "0xEthNFT:1");
await ImportedNFT.renameWallet(
"Sol",
"Vault",
chains: const [ImportedNFT.solanaChain],
);
expect((await identifiersFor("Vault"))..sort(), ["0xEthNFT:1", "SolMint"]);
});
test("deleting a solana wallet leaves a same-named evm wallet's NFTs alone", () async {
await insert("Vault", ImportedNFT.solanaChain, "SolMint");
await insert("Vault", "eth", "0xEthNFT:1");
await ImportedNFT.deleteAllForWallet(
"Vault",
chains: const [ImportedNFT.solanaChain],
);
expect(await identifiersFor("Vault"), ["0xEthNFT:1"]);
});
test("an empty chain scope deletes nothing rather than everything", () async {
await insert("Main", "eth", "0xcontract:1");
await ImportedNFT.deleteAllForWallet("Main", chains: const []);
expect(await identifiersFor("Main"), ["0xcontract:1"]);
});
test("delete removes every chain for that wallet only", () async {
await insert("Main", ImportedNFT.solanaChain, "SolMint");
await insert("Main", "eth", "0xcontract:1");
await insert("Sidekick", ImportedNFT.solanaChain, "OtherMint");
await ImportedNFT.deleteAllForWallet("Main");
expect(await identifiersFor("Main"), isEmpty);
expect(await identifiersFor("Sidekick"), ["OtherMint"]);
});
test("deleting one leaves the wallet's other imports in place", () async {
await insert("Main", "eth", "0xcontract:1");
await insert("Main", "eth", "0xsecond:2");
await insert("Main", "polygon", "0xcontract:1");
await ImportedNFT.deleteOne("Main", "eth", "0xcontract:1");
expect(await identifiersFor("Main", "eth"), ["0xsecond:2"]);
expect(await identifiersFor("Main", "polygon"), ["0xcontract:1"]);
});
test("copying to a new name keeps the rows under the old one", () async {
await insert("Main", ImportedNFT.solanaChain, "SolMint");
await insert("Main", "eth", "0xcontract:1");
for (final nft in await ImportedNFT.getAllForWallet("Main")) {
await ImportedNFT.copyWith(nft, walletName: "Copy").save();
}
expect((await identifiersFor("Main"))..sort(), ["0xcontract:1", "SolMint"]);
expect((await identifiersFor("Copy"))..sort(), ["0xcontract:1", "SolMint"]);
});
test("a copy is a new row, so saving it does not overwrite the original", () async {
await insert("Main", "eth", "0xcontract:1");
final original = (await ImportedNFT.getAllForWallet("Main")).single;
final copy = ImportedNFT.copyWith(original, walletName: "Copy");
await copy.save();
expect(copy.id, isNot(original.id));
expect((await ImportedNFT.getAllForWallet("Main")).single.id, original.id);
});
});
}
+55
View File
@@ -0,0 +1,55 @@
import "package:cw_core/utils/ipfs_url.dart";
import "package:flutter_test/flutter_test.dart";
void main() {
group("tryNormalizeIpfsUrl", () {
test("rewrites an ipfs uri to the gateway", () {
expect(tryNormalizeIpfsUrl("ipfs://QmAbC123"), "https://ipfs.io/ipfs/QmAbC123");
});
test("does not double the gateway path when the uri already says ipfs/", () {
expect(tryNormalizeIpfsUrl("ipfs://ipfs/QmAbC123"), "https://ipfs.io/ipfs/QmAbC123");
});
test("matches the scheme whatever its case", () {
expect(tryNormalizeIpfsUrl("IPFS://QmAbC123"), "https://ipfs.io/ipfs/QmAbC123");
expect(tryNormalizeIpfsUrl("Ipfs://QmAbC123"), "https://ipfs.io/ipfs/QmAbC123");
});
test("returns null for an ipfs uri with no cid, rather than a bare gateway url", () {
expect(tryNormalizeIpfsUrl("ipfs://"), isNull);
expect(tryNormalizeIpfsUrl("ipfs://ipfs/"), isNull);
});
test("passes through an https url that is not ipfs", () {
expect(tryNormalizeIpfsUrl("https://arweave.net/abc"), "https://arweave.net/abc");
});
test("drops a scheme nothing can render, rather than handing it to the image widget", () {
expect(tryNormalizeIpfsUrl("ar://abc123"), isNull);
expect(tryNormalizeIpfsUrl(""), isNull);
});
test("returns null for null", () {
expect(tryNormalizeIpfsUrl(null), isNull);
});
test("is idempotent, so a value read back from storage stays stable", () {
final once = tryNormalizeIpfsUrl("ipfs://QmAbC123");
expect(tryNormalizeIpfsUrl(once), once);
});
test("drops any scheme that is not https", () {
// These render through Image.network, which does not go through the proxy.
expect(tryNormalizeIpfsUrl("http://127.0.0.1:8080/x.png"), isNull);
expect(tryNormalizeIpfsUrl("file:///etc/passwd"), isNull);
expect(tryNormalizeIpfsUrl("javascript:alert(1)"), isNull);
expect(tryNormalizeIpfsUrl("data:text/html,x"), isNull);
});
test("keeps an https url whatever its casing", () {
expect(tryNormalizeIpfsUrl("https://ok.example/a.png"), "https://ok.example/a.png");
expect(tryNormalizeIpfsUrl("HTTPS://Up.example/a.png"), "HTTPS://Up.example/a.png");
});
});
}
+89
View File
@@ -0,0 +1,89 @@
import "package:cw_core/utils/nft_text.dart";
import "package:flutter_test/flutter_test.dart";
void main() {
group("sanitizeNFTText", () {
test("removes the separators that would fake extra lines", () {
expect(sanitizeNFTText("Ape\u2028Address: 9xQeW"), "Ape Address: 9xQeW");
expect(sanitizeNFTText("Ape\u2029Address: 9xQeW"), "Ape Address: 9xQeW");
expect(sanitizeNFTText("Ape\nAddress: 9xQeW"), "Ape Address: 9xQeW");
});
test("removes bidi overrides and isolates that reorder what follows", () {
expect(sanitizeNFTText("evil\u202Etxen"), "evil txen");
expect(sanitizeNFTText("a\u2066b\u2069c"), "a b c");
});
test("removes invisibles that hide or reorder characters", () {
expect(sanitizeNFTText("A\u200BB"), "A B");
expect(sanitizeNFTText("A\u200FB"), "A B");
expect(sanitizeNFTText("A\u061CB"), "A B");
expect(sanitizeNFTText("A\u2060B"), "A B");
});
test("removes control characters and delete", () {
expect(sanitizeNFTText("A\u0000B"), "A B");
expect(sanitizeNFTText("A\u001FB"), "A B");
expect(sanitizeNFTText("A\u007FB"), "A B");
});
test("leaves ordinary text alone", () {
expect(sanitizeNFTText("DeGod #1234"), "DeGod #1234");
expect(sanitizeNFTText(" padded "), "padded");
});
test("returns null for null", () {
expect(sanitizeNFTText(null), isNull);
});
test("counts the cap in characters, so emoji are not cut in half", () {
final long = sanitizeNFTText("\u{1F600}" * 600)!;
expect(long.runes.length, 513);
expect(long.runes.last, 0x2026);
final units = long.codeUnits;
final endsOnHighSurrogate = units.isNotEmpty && units.last >= 0xD800 && units.last <= 0xDBFF;
expect(endsOnHighSurrogate, isFalse);
});
test("does not truncate a name that is long in code units but short in characters", () {
expect(sanitizeNFTText("\u{1F600}" * 400)!.runes.length, 400);
});
test("is idempotent, so re-reading a saved value does not change it", () {
const raw = "Ape\u2028Name\u202E with \u{1F600}";
final once = sanitizeNFTText(raw);
expect(sanitizeNFTText(once), once);
});
test("keeps the joiners that carry meaning in persian and emoji", () {
// Replacing these put a visible space inside a Persian word and split
// a two-person emoji into two glyphs.
expect(sanitizeNFTText("\u{1F468}\u200D\u{1F4BB}"), "\u{1F468}\u200D\u{1F4BB}");
expect(
sanitizeNFTText("\u0645\u06CC\u200C\u062E\u0648\u0627\u0647\u0645"),
"\u0645\u06CC\u200C\u062E\u0648\u0627\u0647\u0645",
);
});
test("strips the invisibles a mint can hide text behind", () {
for (final invisible in [
"\u00AD", // soft hyphen
"\u034F", // combining grapheme joiner
"\u180E", // mongolian vowel separator
"\u3164", // hangul filler
"\uFEFF", // zero width no-break space
"\uFFA0", // halfwidth hangul filler
"\u{E0041}", // tag latin capital A
]) {
expect(
sanitizeNFTText("A${invisible}B"),
"A B",
reason: "U+${invisible.runes.first.toRadixString(16).toUpperCase()} survived",
);
}
});
});
}
+12
View File
@@ -3,6 +3,7 @@ import "dart:io";
import 'package:bip39/bip39.dart' as bip39;
import 'package:cw_core/encryption_file_utils.dart';
import "package:cw_core/erc20_token.dart";
import "package:cw_core/imported_nft.dart";
import 'package:cw_core/pathForWallet.dart';
import 'package:cw_core/utils/print_verbose.dart';
import 'package:cw_core/wallet_base.dart';
@@ -36,6 +37,9 @@ class EVMChainWalletService extends WalletService<
return _registry.getRegisteredWalletTypes();
}
List<String> get _importedNFTChains =>
_registry.getAllChains().map((chain) => chain.shortCode).toList();
Future<WalletInfo?> _findWalletByName(String name) async {
for (final type in _evmWalletTypes) {
final walletInfo = await WalletInfo.get(name, type);
@@ -192,8 +196,15 @@ class EVMChainWalletService extends WalletService<
final copiedToken = Erc20Token.copyWith(token, walletName: newName);
await copiedToken.save();
}
final ownNFTs = (await ImportedNFT.getAllForWallet(currentName))
.where((nft) => _importedNFTChains.contains(nft.chain));
for (final nft in ownNFTs) {
await ImportedNFT.copyWith(nft, walletName: newName).save();
}
} else {
await Erc20Token.renameWallet(currentName, newName);
await ImportedNFT.renameWallet(currentName, newName, chains: _importedNFTChains);
}
final oldDir = Directory(p.join(await pathForWalletTypeDir(type: type), currentName));
@@ -351,6 +362,7 @@ class EVMChainWalletService extends WalletService<
final nameStillUsed = (await _findWalletByName(wallet)) != null;
if (!nameStillUsed) {
await Erc20Token.deleteAllForWallet(wallet);
await ImportedNFT.deleteAllForWallet(wallet, chains: _importedNFTChains);
}
}
+50 -14
View File
@@ -165,23 +165,44 @@ int getCurrentHeight() => currentWallet?.blockChainHeight() ?? 0;
int cachedNodeHeight = 0;
bool isHeightRefreshing = false;
Future<int>? _nodeHeightInFlight;
Future<int> _refreshNodeHeight() {
final inFlight = _nodeHeightInFlight;
if (inFlight != null) return inFlight;
try {
final wallet = currentWallet;
if (wallet == null) return Future.value(cachedNodeHeight);
final wptrAddress = wallet.ffiAddress();
isHeightRefreshing = true;
final f = Isolate.run(() async {
return monero.Wallet_daemonBlockChainHeight(Pointer.fromAddress(wptrAddress));
}).then((h) {
cachedNodeHeight = h;
return h;
}).catchError((_) => cachedNodeHeight).whenComplete(() {
isHeightRefreshing = false;
_nodeHeightInFlight = null;
});
_nodeHeightInFlight = f;
return f;
} catch (e) {
printV("error refreshing node height: $e");
isHeightRefreshing = false;
_nodeHeightInFlight = null;
return Future.value(cachedNodeHeight);
}
}
int getNodeHeightSync() {
if (isHeightRefreshing == false) {
if (cachedNodeHeight != 0 && getWlptr()?.height() == 1) {
return cachedNodeHeight;
}
(() async {
try {
isHeightRefreshing = true;
final wptrAddress = currentWallet!.ffiAddress();
cachedNodeHeight = await Isolate.run(() async {
return monero.Wallet_daemonBlockChainHeight(Pointer.fromAddress(wptrAddress));
});
} catch (_) {
} finally {
isHeightRefreshing = false;
}
})();
unawaited(_refreshNodeHeight());
}
return cachedNodeHeight;
}
@@ -319,6 +340,9 @@ class SyncListener {
int _cachedBlockchainHeight;
int _lastKnownBlockHeight;
int _initialSyncHeight;
int _lastKnownNodeHeight = 0;
int _ticksSinceEmit = 0;
static const int _heartbeatTicks = 5;
Future<int> getNodeHeightOrUpdate(int baseHeight) async {
if (_cachedBlockchainHeight < baseHeight || _cachedBlockchainHeight == 0) {
@@ -331,6 +355,8 @@ class SyncListener {
void _start() {
_cachedBlockchainHeight = 0;
_lastKnownBlockHeight = 0;
_lastKnownNodeHeight = 0;
_ticksSinceEmit = 0;
_initialSyncHeight = 0;
_updateSyncInfoTimer ??= Timer.periodic(Duration(milliseconds: 1200), (_) async {
if (isNewTransactionExist()) {
@@ -355,11 +381,17 @@ class SyncListener {
}
final bchHeight = max(nodeHeight, syncHeight);
// printV("syncHeight: $syncHeight, _lastKnownBlockHeight: $_lastKnownBlockHeight, bchHeight: $bchHeight");
if (_lastKnownBlockHeight == syncHeight) {
_ticksSinceEmit++;
final nothingChanged = _lastKnownBlockHeight == syncHeight &&
_lastKnownNodeHeight == nodeHeight;
if (nothingChanged && _ticksSinceEmit < _heartbeatTicks) {
return;
}
_ticksSinceEmit = 0;
_lastKnownBlockHeight = syncHeight;
_lastKnownNodeHeight = nodeHeight;
final track = bchHeight - _initialSyncHeight;
final diff = track - (bchHeight - syncHeight);
final ptc = diff <= 0 ? 0.0 : diff / track;
@@ -410,7 +442,11 @@ void startRefresh() => startRefreshSync();
Future<void> store() async => _storeSync(0);
Future<int> getNodeHeight() async => getNodeHeightSync();
Future<int> getNodeHeight() async {
final cached = getNodeHeightSync();
if (cached != 0) return cached;
return _refreshNodeHeight();
}
void rescanBlockchainAsync() => currentWallet!.rescanBlockchainAsync();
+2 -2
View File
@@ -32,8 +32,8 @@ dependencies:
ledger_flutter_plus: ^1.4.1
trezor_flutter:
git:
url: https://github.com/cake-tech/trezor-flutter.git
ref: 7d27e19b7bcdcd6241df35953f9a6ec2df0c7a97
url: https://github.com/cake-tech/trezor-flutter
ref: f5121b1f3a06000ff836da8e6e6c83f53a9fa487
path: trezor-flutter
dev_dependencies:
+263 -12
View File
@@ -1,5 +1,6 @@
import 'dart:async';
import 'dart:convert';
import "dart:typed_data";
import 'package:blockchain_utils/blockchain_utils.dart';
import 'package:cw_core/amount/money.dart';
@@ -7,6 +8,7 @@ import 'package:cw_core/crypto_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';
import "package:cw_core/utils/ipfs_url.dart";
import 'package:cw_core/utils/print_verbose.dart';
import 'package:cw_solana/pending_solana_transaction.dart';
import 'package:cw_solana/solana_balance.dart';
@@ -110,6 +112,62 @@ class SolanaWalletClient {
}
}
Future<Set<String>> _mintsToWithholdFromTokenList(Set<String> mints) async {
const batchSize = 10;
final ordered = mints.toList();
final withheld = <String>{};
Future<bool> isUnconfirmedFungible(String mint) async {
try {
return await isSupplyOfOne(mint, throwOnError: true);
} catch (_) {
return true;
}
}
for (var i = 0; i < ordered.length; i += batchSize) {
final batch = ordered.skip(i).take(batchSize).toList();
final results = await Future.wait(batch.map(isUnconfirmedFungible));
for (var j = 0; j < batch.length; j++) {
if (results[j]) {
withheld.add(batch[j]);
}
}
}
return withheld;
}
Future<Set<String>> fetchHeldTokenMints(String walletAddress) async {
final owner = SolAddress(walletAddress);
final responses = await Future.wait([
_provider!.request(
SolanaRPCGetTokenAccountsByOwner(
account: owner,
programId: SPLTokenProgramConst.tokenProgramId,
commitment: Commitment.confirmed,
encoding: SolanaRPCEncoding.base64,
),
),
_provider!.request(
SolanaRPCGetTokenAccountsByOwner(
account: owner,
programId: SPLTokenProgramConst.token2022ProgramId,
commitment: Commitment.confirmed,
encoding: SolanaRPCEncoding.base64,
),
),
]);
return {
for (final accounts in responses)
for (final account in accounts)
if (account.tokenAccount.amount > BigInt.zero) account.tokenAccount.mint.address,
};
}
Future<SolanaBalance?> getSplTokenBalance(SPLToken token, String walletAddress,
{bool throwOnError = false}) async {
try {
@@ -1162,6 +1220,43 @@ class SolanaWalletClient {
}
}
int _parseMoralisDecimals(Object? raw) {
if (raw == null) {
return 0;
}
if (raw is num) {
return raw.toInt();
}
final parsed = int.tryParse(raw.toString());
if (parsed == null) {
printV("Unexpected decimals value from Moralis: $raw");
return 0;
}
return parsed;
}
Future<bool> isSupplyOfOne(String mintAddress, {bool throwOnError = false}) async {
try {
final supply = await _provider!.request(
SolanaRPCGetTokenSupply(account: SolAddress(mintAddress)),
);
return supply.amount == "1" && supply.decimals == 0;
} catch (e) {
printV("Could not read supply for mint $mintAddress: ${e.toString()}");
if (throwOnError) {
rethrow;
}
return false;
}
}
Future<SPLToken?> fetchSPLTokenInfo(String mintAddress) async {
try {
final uri = Uri.https(
@@ -1252,6 +1347,101 @@ class SolanaWalletClient {
}
}
// This is the exact size for an spl token, anything larger is Token-2022
// carrying extensions
static const _baseTokenAccountSize = 165;
static const _maxOffChainMetadataBytes = 256 * 1024;
Future<String?> _readOffChainMetadata(Uri uri) async {
final httpClient = ProxyWrapper().getHttpClient();
try {
final request = await httpClient.getUrl(uri);
request.maxRedirects = 3;
final response = await request.close().timeout(const Duration(seconds: 30));
if (response.redirects.any((redirect) => redirect.location.scheme != "https")) {
return null;
}
if (response.statusCode < 200 || response.statusCode >= 300) {
return null;
}
if (response.contentLength > _maxOffChainMetadataBytes) {
return null;
}
final bytes = BytesBuilder(copy: false);
await for (final chunk in response) {
bytes.add(chunk);
if (bytes.length > _maxOffChainMetadataBytes) {
return null;
}
}
return utf8.decode(bytes.takeBytes(), allowMalformed: true);
} finally {
httpClient.close(force: true);
}
}
Future<NFTOnChainMetadata?> getNFTOnChainMetadata(String mintAddress) async {
try {
final programAddress =
MetaplexTokenMetaDataProgramUtils.findMetadataPda(mint: SolAddress(mintAddress));
final token = await _provider!.request(
SolanaRPCGetMetadataAccount(
account: programAddress.address,
commitment: Commitment.confirmed,
),
);
if (token == null) {
return null;
}
final metadata = token.data;
String stripNulls(String value) => value.replaceAll("\u0000", "").trim();
String? stringOrNull(Object? value) => value is String ? value : null;
final metadataUri = stripNulls(metadata.uri);
final metadataUrl = tryNormalizeIpfsUrl(metadataUri);
final offChainUri = metadataUrl == null ? null : Uri.tryParse(metadataUrl);
String? imageUrl;
if (offChainUri != null && offChainUri.scheme == "https") {
try {
final body = await _readOffChainMetadata(offChainUri);
if (body != null) {
imageUrl = stringOrNull((jsonDecode(body) as Map<String, dynamic>)["image"]);
}
} catch (e) {
printV("Off-chain NFT metadata fetch failed: ${e.toString()}");
}
}
return NFTOnChainMetadata(
mint: mintAddress,
name: stripNulls(metadata.name),
symbol: stripNulls(metadata.symbol),
metadataUri: metadataUri,
imageUrl: imageUrl,
);
} catch (e) {
printV("Error fetching on-chain NFT metadata: $e");
return null;
}
}
void stop() => _isStopped = true;
SolanaRPC? get getSolanaProvider => _provider;
@@ -1263,6 +1453,7 @@ class SolanaWalletClient {
required bool isSendAll,
required Money solBalance,
String? tokenMint,
bool closeSenderAccountWhenEmptied = false,
List<String> references = const [],
}) async {
const commitment = Commitment.confirmed;
@@ -1285,6 +1476,7 @@ class SolanaWalletClient {
destinationAddress: destinationAddress,
commitment: commitment,
solBalance: solBalance,
closeSenderAccountWhenEmptied: closeSenderAccountWhenEmptied,
);
}
}
@@ -1589,6 +1781,7 @@ class SolanaWalletClient {
required SolanaPrivateKey ownerPrivateKey,
required Commitment commitment,
required Money solBalance,
bool closeSenderAccountWhenEmptied = false,
}) async {
final mintAddress = SolAddress(tokenMint);
final tokenProgramId = await _getTokenProgramId(mintAddress);
@@ -1716,6 +1909,30 @@ class SolanaWalletClient {
transferInstructions,
];
final senderAccountHasExtensions = senderAccountSpace != _baseTokenAccountSize;
if (closeSenderAccountWhenEmptied && !senderAccountHasExtensions) {
try {
final senderBalance = await _provider!.request(
SolanaRPCGetTokenAccountBalance(
account: associatedSenderAccount.address,
commitment: commitment,
),
);
if (BigInt.tryParse(senderBalance.amount) == inputAmount.amount) {
instructions.add(SPLTokenProgram.closeAccount(
account: associatedSenderAccount.address,
destination: ownerPrivateKey.publicKey().toAddress(),
authority: ownerPrivateKey.publicKey().toAddress(),
programId: senderTokenProgramId,
));
}
} catch (e) {
printV("Skipping rent reclaim, could not check sender balance: ${e.toString()}");
}
}
final latestBlockHash = await _getLatestBlockhash(commitment);
final transaction = SolanaTransaction(
@@ -1838,24 +2055,42 @@ class SolanaWalletClient {
final decodedResponse = jsonDecode(response.body) as List;
final List<MoralisSolanaTokenBalance> tokens = [];
final nftCandidates = <String>{};
for (final item in decodedResponse) {
final tokenData = item as Map<String, dynamic>;
try {
final tokenData = item as Map<String, dynamic>;
final amountStr = tokenData['amount'] as String? ?? '0';
final amount = double.tryParse(amountStr) ?? 0.0;
final amountStr = tokenData["amount"] as String? ?? "0";
final amount = double.tryParse(amountStr) ?? 0.0;
if (amount <= 0) continue;
if (amount <= 0) continue;
final mint = tokenData['mint'] as String? ?? '';
if (mint.isEmpty) continue;
final mint = tokenData["mint"] as String? ?? "";
if (mint.isEmpty) continue;
tokens.add(
MoralisSolanaTokenBalance(
mint: mint,
amount: amount,
),
);
final amountRaw = tokenData["amountRaw"]?.toString() ?? "0";
final decimals = _parseMoralisDecimals(tokenData["decimals"]);
if (decimals == 0 && amountRaw == "1") {
nftCandidates.add(mint);
}
tokens.add(
MoralisSolanaTokenBalance(
mint: mint,
amount: amount,
),
);
} catch (e) {
printV("Skipping malformed Moralis token entry: ${e.toString()}");
}
}
if (nftCandidates.isNotEmpty) {
final withheld = await _mintsToWithholdFromTokenList(nftCandidates);
tokens.removeWhere((token) => withheld.contains(token.mint));
}
return tokens;
@@ -1908,6 +2143,22 @@ class SolanaWalletClient {
}
}
class NFTOnChainMetadata {
const NFTOnChainMetadata({
required this.mint,
required this.name,
required this.symbol,
required this.metadataUri,
this.imageUrl,
});
final String mint;
final String name;
final String symbol;
final String metadataUri;
final String? imageUrl;
}
class MoralisSolanaTokenBalance {
final String mint;
final double amount;
+2
View File
@@ -30,6 +30,8 @@ class SolanaCreateAssociatedTokenAccountException extends CreateAssociatedTokenA
class SolanaSignSPLTokenTransactionRentException extends SignSPLTokenTransactionRentException {}
class SolanaNotAnNFTException extends NotAnNFTException {}
class SolanaNoAssociatedTokenAccountException extends NoAssociatedTokenAccountException {
SolanaNoAssociatedTokenAccountException(this.account, this.mint);
+32
View File
@@ -323,6 +323,38 @@ abstract class SolanaWalletBase
return matches.first;
}
Future<Set<String>> heldTokenMints() => _client.fetchHeldTokenMints(walletAddresses.address);
Future<PendingTransaction> sendNFT({
required String mintAddress,
required String destinationAddress,
String? name,
}) async {
if (!await _client.isSupplyOfOne(mintAddress, throwOnError: true)) {
throw SolanaNotAnNFTException();
}
final nftCurrency = SPLToken(
name: name ?? "NFT",
symbol: "NFT",
mintAddress: mintAddress,
decimal: 0,
mint: mintAddress,
);
final solBalance = await _client.getBalance(walletAddresses.address, throwOnError: true);
return _client.signSolanaTransaction(
tokenMint: mintAddress,
inputAmount: Money(BigInt.one, nftCurrency),
ownerPrivateKey: _solanaPrivateKey,
destinationAddress: destinationAddress,
isSendAll: false,
solBalance: solBalance,
closeSenderAccountWhenEmptied: true,
);
}
@override
Future<Map<String, SolanaTransactionInfo>> fetchTransactions() async => {};
+2
View File
@@ -3,6 +3,7 @@ import 'dart:io';
import 'package:bip39/bip39.dart' as bip39;
import 'package:cw_core/encryption_file_utils.dart';
import 'package:cw_core/balance.dart';
import "package:cw_core/imported_nft.dart";
import 'package:cw_core/pathForWallet.dart';
import 'package:cw_core/spl_token.dart';
import 'package:cw_core/transaction_history.dart';
@@ -101,6 +102,7 @@ class SolanaWalletService extends WalletService<
final nameStillUsed = await WalletInfo.get(wallet, getType()) != null;
if (!nameStillUsed) {
await SPLToken.deleteAllForWallet(wallet);
await ImportedNFT.deleteAllForWallet(wallet, chains: const [ImportedNFT.solanaChain]);
}
final prefs = await SharedPreferences.getInstance();
+3 -3
View File
@@ -2,7 +2,7 @@
## Important: EVM-Compatible Wallets
**If you're adding an EVM-compatible wallet (Ethereum, Polygon, Base, Arbitrum, Optimism, etc.)**, please refer to the **[Adding New L2 Network Guide](./adding_new_l2_network_guide.md)** instead. EVM wallets use a unified architecture and don't require the full setup described in this guide.
**If you're adding an EVM-compatible wallet (Ethereum, Polygon, Base, Arbitrum, Optimism, etc.)**, please refer to the **[Adding New L2 Network Guide](./ADDING_EVM_L2_WALLET_NETWORK_TYPES.md)** instead. EVM wallets use a unified architecture and don't require the full setup described in this guide.
**For EVM wallets:**
- Use `WalletType.evm` (unified type for all EVM chains)
@@ -984,7 +984,7 @@ Add:
dart run build_runner build --delete-conflicting-outputs
```
4. **EVM Wallets**: If adding an EVM-compatible wallet, use the [L2 Network Guide](./adding_new_l2_network_guide.md) instead.
4. **EVM Wallets**: If adding an EVM-compatible wallet, use the [L2 Network Guide](./ADDING_EVM_L2_WALLET_NETWORK_TYPES.md) instead.
5. **Token Support**: If your wallet supports tokens (like ERC20, SPL, TRC20):
- Add token checks in `exchange_trade_view_model.dart`
@@ -1029,4 +1029,4 @@ Add:
---
Copyright (C) 2018-2023 Cake Labs LLC
Copyright (C) 2018-2025 Cake Labs LLC
+3 -3
View File
@@ -558,9 +558,9 @@ class CWBitcoin extends Bitcoin {
@override
HardwareWalletService getTrezorHardwareWalletService(
trezor.TrezorConnect connect, bool isBitcoin) {
if (isBitcoin) return BitcoinTrezorService(connect);
return LitecoinTrezorService(connect);
trezor.TrezorConnect? connect, trezor.TrezorClient? client, bool isBitcoin) {
if (isBitcoin) return BitcoinTrezorService(client!);
return LitecoinTrezorService(connect!);
}
@override
@@ -0,0 +1,6 @@
class CakePayUnauthorizedException implements Exception {
const CakePayUnauthorizedException();
@override
String toString() => 'Cake Pay session is no longer valid, please log in again.';
}
@@ -1,6 +1,7 @@
import 'dart:io';
import 'package:cake_wallet/bitcoin/bitcoin.dart';
import 'package:cake_wallet/cake_pay/src/cake_pay_exceptions.dart';
import 'package:cake_wallet/cake_pay/src/models/cake_pay_card.dart';
import 'package:cake_wallet/cake_pay/src/models/cake_pay_order.dart';
import 'package:cake_wallet/cake_pay/src/widgets/cake_pay_alert_modal.dart';
@@ -497,19 +498,37 @@ class CakePayBuyCardPage extends BasePage {
}
Future<void> purchaseCard(BuildContext context) async {
bool isLogged = await cakePayBuyCardViewModel.isUserLogged;
if (!isLogged) {
cakePayBuyCardViewModel.isSimulatingFlow = false;
Navigator.of(context).pushNamed(Routes.cakePayWelcomePage);
} else {
try {
await cakePayBuyCardViewModel.createOrder();
} catch (_) {
try {
final isLogged = await cakePayBuyCardViewModel.isUserLogged;
if (!isLogged) {
cakePayBuyCardViewModel.isSimulatingFlow = false;
await cakePayBuyCardViewModel.logout();
if (context.mounted) {
Navigator.of(context).pushNamed(Routes.cakePayWelcomePage);
}
return;
}
await cakePayBuyCardViewModel.createOrder();
} on CakePayUnauthorizedException {
cakePayBuyCardViewModel.isSimulatingFlow = false;
if (context.mounted) {
Navigator.of(context).pushNamed(Routes.cakePayWelcomePage);
}
} catch (error) {
cakePayBuyCardViewModel.isSimulatingFlow = false;
_sendViewModel.state = FailureState(
_sendViewModel.translateErrorMessage(
error,
cakePayBuyCardViewModel.walletType,
_sendViewModel.wallet.currency,
),
);
} finally {
cakePayBuyCardViewModel.isPurchasing = false;
}
cakePayBuyCardViewModel.isPurchasing = false;
}
BuildContext? dialogContext;
@@ -477,11 +477,18 @@ class _ShopTabState extends State<_ShopTab> {
if (i >= vendors.length) return const _Loading();
final vendor = vendors[i];
return CardItem(
logoUrl: vendor.card?.cardImageUrl,
title: vendor.name,
subTitle: vendor.card?.description ?? '',
onTap: () => Navigator.pushNamed(context, Routes.cakePayBuyCardPage,
arguments: [vendor]));
logoUrl: vendor.card?.cardImageUrl,
title: vendor.name,
subTitle: vendor.card?.description ?? '',
onTap: () async {
await Navigator.pushNamed(
context,
Routes.cakePayBuyCardPage,
arguments: [vendor],
);
await viewModel.checkAuth();
},
);
},
),
],
@@ -1,5 +1,6 @@
import 'dart:convert';
import 'package:cake_wallet/cake_pay/src/cake_pay_exceptions.dart';
import 'package:cake_wallet/cake_pay/src/models/cake_pay_order.dart';
import 'package:cake_wallet/cake_pay/src/models/cake_pay_user_credentials.dart';
import 'package:cake_wallet/cake_pay/src/models/cake_pay_vendor.dart';
@@ -129,6 +130,10 @@ class CakePayApi {
body: body,
);
if (response.statusCode == 401) {
throw const CakePayUnauthorizedException();
}
if (response.statusCode == 201) {
final data = json.decode(response.body) as Map<String, dynamic>;
return CakePayOrder.fromMap(data);
@@ -168,6 +173,10 @@ class CakePayApi {
headers: headers,
);
if (response.statusCode == 401) {
throw const CakePayUnauthorizedException();
}
if (response.statusCode != 200) {
throw Exception('Unexpected http status: ${response.statusCode}');
}
@@ -203,6 +212,10 @@ class CakePayApi {
printV('Response: ${response.statusCode}');
if (response.statusCode == 401) {
throw const CakePayUnauthorizedException();
}
if (response.statusCode != 200) {
throw Exception('Unexpected http status: ${response.statusCode}');
}
+42 -19
View File
@@ -1,4 +1,5 @@
import 'package:cake_wallet/.secrets.g.dart' as secrets;
import 'package:cake_wallet/cake_pay/src/cake_pay_exceptions.dart';
import 'package:cake_wallet/cake_pay/src/services/cake_pay_api.dart';
import 'package:cake_wallet/cake_pay/src/models/cake_pay_order.dart';
import 'package:cake_wallet/cake_pay/src/models/cake_pay_vendor.dart';
@@ -95,29 +96,51 @@ class CakePayService {
required bool confirmsTermsAgreed,
}) async {
final userEmail = (await secureStorage.read(key: cakePayEmailStorageKey))!;
final token = (await secureStorage.read(key: cakePayUserTokenKey))!;
return await cakePayApi.createOrder(
apiKey: cakePayApiKey,
cardId: cardId,
price: price,
quantity: quantity,
token: token,
userEmail: userEmail,
confirmsNoVpn: confirmsNoVpn,
confirmsVoidedRefund: confirmsVoidedRefund,
confirmsTermsAgreed: confirmsTermsAgreed,
return _withUserToken(
(token) => cakePayApi.createOrder(
apiKey: cakePayApiKey,
cardId: cardId,
price: price,
quantity: quantity,
token: token,
userEmail: userEmail,
confirmsNoVpn: confirmsNoVpn,
confirmsVoidedRefund: confirmsVoidedRefund,
confirmsTermsAgreed: confirmsTermsAgreed,
),
);
}
Future<CakePayOrder> findOrderById({required String orderId}) async {
final token = (await secureStorage.read(key: cakePayUserTokenKey))!;
return await cakePayApi.getOrderById(orderId: orderId, token: token);
}
Future<CakePayOrder> findOrderById({required String orderId}) => _withUserToken(
(token) => cakePayApi.getOrderById(
orderId: orderId,
token: token,
),
);
///Simulate Purchase Gift Card
Future<String> simulatePayment({required String orderId}) async {
final token = (await secureStorage.read(key: cakePayUserTokenKey))!;
return await cakePayApi.simulatePayment(
CSRFToken: CSRFToken, authorization: authorization, token: token, orderId: orderId);
Future<String> simulatePayment({required String orderId}) => _withUserToken(
(token) => cakePayApi.simulatePayment(
CSRFToken: CSRFToken,
authorization: authorization,
token: token,
orderId: orderId,
),
);
/// Wrapper for requests with user token
Future<T> _withUserToken<T>(Future<T> Function(String token) request) async {
try {
final token = await secureStorage.read(key: cakePayUserTokenKey) ?? '';
if (token.isEmpty) {
throw const CakePayUnauthorizedException();
}
return await request(token);
} on CakePayUnauthorizedException {
await logout();
rethrow;
}
}
}
+4
View File
@@ -227,6 +227,7 @@ import 'package:cake_wallet/view_model/dashboard/cake_features_view_model.dart';
import 'package:cake_wallet/view_model/dashboard/dashboard_view_model.dart';
import 'package:cake_wallet/view_model/dashboard/desktop_sidebar_view_model.dart';
import 'package:cake_wallet/view_model/dashboard/home_settings_view_model.dart';
import "package:cake_wallet/view_model/dashboard/nft_send_view_model.dart";
import 'package:cake_wallet/view_model/dashboard/nft_view_model.dart';
import 'package:cake_wallet/view_model/dashboard/receive_option_view_model.dart';
import 'package:cake_wallet/view_model/dashboard/sign_view_model.dart';
@@ -1708,6 +1709,9 @@ Future<void> setup({
getIt.registerFactory(() => NFTViewModel(appStore, getIt.get<BottomSheetService>()));
getIt.registerFactory(() =>
NFTSendViewModel(appStore, getIt.get<ContactListViewModel>(param1: CryptoCurrency.sol)));
getIt.registerFactory(() => SignViewModel(getIt.get<AppStore>().wallet!));
getIt.registerFactory(() => SeedVerificationPage(getIt.get<WalletSeedViewModel>()));
@@ -641,6 +641,13 @@ Future<void> defaultSettingsMigration(
case 70:
await _addTbbTokenToExistingSolanaWallets();
break;
case 71:
_changeExchangeProviderAvailability(
sharedPreferences,
providerName: "Swaps.XYZ",
enabled: false,
);
break;
default:
break;
}
+15 -3
View File
@@ -1,7 +1,15 @@
import 'package:cake_wallet/generated/locales.dart';
import 'package:devicelocale/devicelocale.dart';
import "package:flutter/widgets.dart";
import 'package:intl/intl.dart';
// Flutter's Material delegates match on the language subtag, so "pt_BR" must be split into two
Locale localeFromLanguageCode(String code) {
final parts = code.split("_");
return parts.length == 2 ? Locale(parts[0], parts[1]) : Locale(code);
}
class LanguageService {
static const Map<String, String> supportedLocales = {
'en': 'English',
@@ -85,12 +93,16 @@ class LanguageService {
static Future<String> localeDetection() async {
try {
var locale = await Devicelocale.currentLocale ?? '';
locale = Intl.shortLocale(locale);
final locale = Intl.canonicalizedLocale(await Devicelocale.currentLocale ?? "");
if (list.keys.contains(locale)) {
return locale;
}
final language = Intl.shortLocale(locale);
if (list.keys.contains(language)) {
return language;
}
return LanguageService.defaultLocale;
} catch (_) {
return LanguageService.defaultLocale;
+116
View File
@@ -0,0 +1,116 @@
import "package:cake_wallet/entities/solana_nft_asset_model.dart";
import "package:cake_wallet/entities/wallet_nft_response.dart";
import "package:cw_core/imported_nft.dart";
class SavedNFTs {
Future<Map<String, SolanaNFTAssetModel>> solanaNFTs(String walletName) async {
final rows = await ImportedNFT.getAllForWallet(walletName, ImportedNFT.solanaChain);
return {
for (final row in rows)
row.identifier: SolanaNFTAssetModel(
address: row.identifier,
mint: row.identifier,
name: row.name,
symbol: row.symbol,
description: row.description,
imageOriginalUrl: row.imageUrl,
isOwned: row.isOwned,
),
};
}
Future<void> addSolana(String walletName, SolanaNFTAssetModel asset) async {
final mint = asset.mint;
if (mint == null || mint.isEmpty) {
return;
}
await _rowForSolana(walletName, mint, asset).save();
}
Future<void> refreshSolana(String walletName, List<SolanaNFTAssetModel> assets) async {
for (final asset in assets) {
final mint = asset.mint;
if (mint == null) {
continue;
}
await ImportedNFT.updateMetadata(_rowForSolana(walletName, mint, asset));
}
}
Future<List<NFTAssetModel>> evmNFTs(String walletName, String chain) async {
final rows = await ImportedNFT.getAllForWallet(walletName, chain);
return rows
.map(
(row) => NFTAssetModel(
tokenAddress: _tokenAddressOf(row.identifier),
tokenId: _tokenIdOf(row.identifier),
name: row.name,
symbol: row.symbol,
normalizedMetadata: NormalizedMetadata(
name: row.name,
description: row.description,
image: row.imageUrl,
),
),
)
.toList();
}
Future<void> addEvm(String walletName, String chain, NFTAssetModel asset) async {
final identifier = evmIdentifier(asset);
if (identifier == null) {
return;
}
await ImportedNFT(
walletName: walletName,
chain: chain,
identifier: identifier,
name: asset.name,
symbol: asset.symbol,
description: asset.normalizedMetadata?.description,
imageUrl: asset.normalizedMetadata?.imageUrl,
).save();
}
Future<void> removeSolana(String walletName, String mint) =>
ImportedNFT.deleteOne(walletName, ImportedNFT.solanaChain, mint);
static String? evmIdentifier(NFTAssetModel asset) {
final address = asset.tokenAddress;
final tokenId = asset.tokenId;
if (address == null || address.isEmpty) {
return null;
}
return "$address:${tokenId ?? ""}";
}
ImportedNFT _rowForSolana(String walletName, String mint, SolanaNFTAssetModel asset) =>
ImportedNFT(
walletName: walletName,
chain: ImportedNFT.solanaChain,
identifier: mint,
name: asset.name,
symbol: asset.symbol,
description: asset.description,
imageUrl: asset.imageOriginalUrl,
isOwned: asset.isOwned,
);
String _tokenAddressOf(String identifier) => identifier.split(":").first;
String _tokenIdOf(String identifier) {
final parts = identifier.split(":");
return parts.length > 1 ? parts.sublist(1).join(":") : "";
}
}
+46 -41
View File
@@ -1,3 +1,6 @@
import "package:cw_core/utils/ipfs_url.dart";
import "package:cw_core/utils/nft_text.dart";
class SolanaNFTAssetModel {
String? address;
String? mint;
@@ -12,46 +15,50 @@ class SolanaNFTAssetModel {
Metaplex? metaplex;
Collection? collection;
Contract? contract;
bool? isOwned;
SolanaNFTAssetModel({
this.address,
this.mint,
this.standard,
this.name,
this.symbol,
this.description,
this.imageOriginalUrl,
String? name,
String? symbol,
String? description,
String? imageOriginalUrl,
this.externalUrl,
this.metadataOriginalUrl,
this.totalSupply,
this.metaplex,
this.collection,
this.contract,
});
this.isOwned,
}) : name = sanitizeNFTText(name),
symbol = sanitizeNFTText(symbol),
description = sanitizeNFTText(description),
imageOriginalUrl = tryNormalizeIpfsUrl(imageOriginalUrl);
factory SolanaNFTAssetModel.fromJson(Map<String, dynamic> json) {
return SolanaNFTAssetModel(
address: json['address'] as String?,
mint: json['mint'] as String?,
standard: json['standard'] as String?,
name: json['name'] as String?,
symbol: json['symbol'] as String?,
description: json['description'] as String?,
imageOriginalUrl: json['imageOriginalUrl'] as String?,
externalUrl: json['externalUrl'] as String?,
metadataOriginalUrl: json['metadataOriginalUrl'] as String?,
totalSupply: json['totalSupply'] as String?,
metaplex: json['metaplex'] != null
? Metaplex.fromJson(json['metaplex'] as Map<String, dynamic>)
: null,
collection: json['collection'] != null
? Collection.fromJson(json['collection'] as Map<String, dynamic>)
: null,
contract: json['contract'] != null
? Contract.fromJson(json['contract'] as Map<String, dynamic>)
: null,
);
}
factory SolanaNFTAssetModel.fromJson(Map<String, dynamic> json) => SolanaNFTAssetModel(
address: json["address"] as String?,
mint: json["mint"] as String?,
standard: json["standard"] as String?,
name: json["name"] as String?,
symbol: json["symbol"] as String?,
description: json["description"] as String?,
imageOriginalUrl: json["imageOriginalUrl"] as String?,
externalUrl: json["externalUrl"] as String?,
metadataOriginalUrl: json["metadataOriginalUrl"] as String?,
totalSupply: json["totalSupply"] as String?,
metaplex: json["metaplex"] != null
? Metaplex.fromJson(json["metaplex"] as Map<String, dynamic>)
: null,
collection: json["collection"] != null
? Collection.fromJson(json["collection"] as Map<String, dynamic>)
: null,
contract: json["contract"] != null
? Contract.fromJson(json["contract"] as Map<String, dynamic>)
: null,
isOwned: json["isOwned"] as bool?,
);
}
class Metaplex {
@@ -100,17 +107,15 @@ class Collection {
this.metaplexMint,
this.sellerFeeBasisPoints});
factory Collection.fromJson(Map<String, dynamic> json) {
return Collection(
collectionAddress: json['collectionAddress'] as String?,
name: json['name'] as String?,
description: json['description'] as String?,
imageOriginalUrl: json['imageOriginalUrl'] as String?,
externalUrl: json['externalUrl'] as String?,
metaplexMint: json['metaplexMint'] as String?,
sellerFeeBasisPoints: json['sellerFeeBasisPoints'] as int?,
);
}
factory Collection.fromJson(Map<String, dynamic> json) => Collection(
collectionAddress: json["collectionAddress"] as String?,
name: sanitizeNFTText(json["name"] as String?),
description: sanitizeNFTText(json["description"] as String?),
imageOriginalUrl: tryNormalizeIpfsUrl(json["imageOriginalUrl"] as String?),
externalUrl: json["externalUrl"] as String?,
metaplexMint: json["metaplexMint"] as String?,
sellerFeeBasisPoints: json["sellerFeeBasisPoints"] as int?,
);
}
class Contract {
@@ -123,8 +128,8 @@ class Contract {
factory Contract.fromJson(Map<String, dynamic> json) {
return Contract(
type: json['type'] as String?,
name: json['name'] as String?,
symbol: json['symbol'] as String?,
name: sanitizeNFTText(json["name"] as String?),
symbol: sanitizeNFTText(json["symbol"] as String?),
);
}
}
+9 -28
View File
@@ -1,3 +1,6 @@
import "package:cw_core/utils/ipfs_url.dart";
import "package:cw_core/utils/nft_text.dart";
class WalletNFTsResponseModel {
final int? page;
final int? pageSize;
@@ -40,8 +43,8 @@ class NFTAssetModel {
tokenAddress: json['token_address'] as String?,
tokenId: json['token_id'] as String?,
contractType: json['contract_type'] as String?,
name: json['name'] as String?,
symbol: json['symbol'] as String?,
name: sanitizeNFTText(json["name"] as String?),
symbol: sanitizeNFTText(json["symbol"] as String?),
normalizedMetadata: json['normalized_metadata'] != null
? new NormalizedMetadata.fromJson(json['normalized_metadata'] as Map<String, dynamic>)
: null,
@@ -61,33 +64,11 @@ class NormalizedMetadata {
factory NormalizedMetadata.fromJson(Map<String, dynamic> json) {
return NormalizedMetadata(
name: json['name'] as String?,
description: json['description'] as String?,
image: json['image'] as String?,
name: sanitizeNFTText(json["name"] as String?),
description: sanitizeNFTText(json["description"] as String?),
image: json["image"] as String?,
);
}
String? get imageUrl {
if (image == null) return image;
if (image!.contains('ipfs.io')) return image;
if (!image!.contains('ipfs')) return image;
// IPFS public gateway provided by Cloudflare is https://cloudflare-ipfs.com/ipfs/
//
// Here is an example of an ipfs image link:
//
// [ipfs://bafkreia2i2ctfexpovgzfff66wqhbmwwpvqjvozan7ioifzcnq76jharwu]
//https://ipfs.io/ipfs/QmTRcRXo6cXByjHYHTVxGpag6vpocrG3rxjPC9PxKAArR9/1620.png
const String ipfsPublicGateway = 'https://cloudflare-ipfs.com/ipfs/';
final ipfsPath = image?.split('//')[1];
final imageLink = '$ipfsPublicGateway$ipfsPath';
return imageLink;
}
String? get imageUrl => tryNormalizeIpfsUrl(image);
}
+4 -2
View File
@@ -65,6 +65,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_daemon/flutter_daemon.dart';
import "package:flutter_localizations/flutter_localizations.dart";
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:hive/hive.dart';
import 'package:quick_actions/quick_actions.dart';
@@ -308,7 +309,7 @@ Future<void> initializeAppConfigs({bool loadWallet = true}) async {
payjoinSessionSource: payjoinSessionSource,
anonpayInvoiceInfo: anonpayInvoiceInfo,
havenSeedStore: havenSeedStore,
initialMigrationVersion: 70,
initialMigrationVersion: 71,
);
}
@@ -329,6 +330,7 @@ Future<void> initialSetup({
required int initialMigrationVersion,
}) async {
LanguageService.loadLocaleList();
await GlobalMaterialLocalizations.delegate.load(const Locale("en"));
await defaultSettingsMigration(
secureStorage: secureStorage,
version: initialMigrationVersion,
@@ -426,7 +428,7 @@ class AppState extends State<App> with SingleTickerProviderStateMixin {
themeMode: themeMode,
localizationsDelegates: localizationDelegates,
supportedLocales: S.delegate.supportedLocales,
locale: Locale(appStore.settingsStore.languageCode),
locale: localeFromLanguageCode(appStore.settingsStore.languageCode),
onGenerateRoute: (settings) => Router.createRoute(settings),
initialRoute: initialRoute,
scrollBehavior: AppScrollBehavior(),
+17
View File
@@ -294,6 +294,23 @@ class CWMonero extends Monero {
};
}
@override
Future<int> getNodeHeight(Object wallet) => (wallet as MoneroWallet).getNodeHeight();
@override
String getLegacySeed(Object wallet, String langName) =>
(wallet as MoneroWallet).seedLegacy(langName);
@override
bool isBackgroundSyncRunning(Object wallet) => (wallet as MoneroWallet).isBackgroundSyncRunning;
@override
Future<void> startBackgroundSync(Object wallet) => (wallet as MoneroWallet).startBackgroundSync();
@override
Future<void> stopBackgroundSync(Object wallet, String password) =>
(wallet as MoneroWallet).stopBackgroundSync(password);
@override
int? getRestoreHeight(Object wallet) {
final moneroWallet = wallet as MoneroWallet;
+4
View File
@@ -2,6 +2,7 @@ import 'dart:io';
import 'package:cake_wallet/di.dart';
import 'package:cake_wallet/entities/preferences_key.dart';
import "package:cake_wallet/new-ui/page_open_listener.dart";
import 'package:cake_wallet/new-ui/pages/home_page.dart';
import 'package:cake_wallet/new-ui/widgets/changelog_modal.dart';
import 'package:cake_wallet/src/screens/contact/contact_list_page.dart';
@@ -112,6 +113,9 @@ class _NewDashboardState extends State<NewDashboard> {
setState(() {
_selectedPage = index;
});
if (widget.dashboardPageWidgets[_selectedPage] case PageOpenListener page) {
page.onPageOpen();
}
},
)
],
+3
View File
@@ -0,0 +1,3 @@
abstract interface class PageOpenListener {
void onPageOpen();
}
+9 -6
View File
@@ -1,3 +1,5 @@
import "dart:async";
import 'package:cake_wallet/core/auth_service.dart';
import 'package:cake_wallet/di.dart';
import 'package:cake_wallet/generated/i18n.dart';
@@ -18,9 +20,6 @@ import 'package:cake_wallet/view_model/dashboard/dashboard_view_model.dart';
import 'package:cake_wallet/view_model/dashboard/nft_view_model.dart';
import 'package:cake_wallet/view_model/monero_account_list/monero_account_edit_or_create_view_model.dart';
import 'package:cake_wallet/view_model/monero_account_list/monero_account_list_view_model.dart';
import "package:cw_core/amount/money.dart";
import "package:cw_core/crypto_currency.dart";
import "package:cw_core/transaction_direction.dart";
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@@ -53,9 +52,9 @@ class _NewHomePageState extends State<NewHomePage> {
});
});
reaction((_) => widget.dashboardViewModel.isMigratingToIronwood, (val) {
reaction((_) => widget.dashboardViewModel.isMigratingToIronwood, (val) {
if (val && !widget.dashboardViewModel.settingsStore.zcashMigrationModalViewed) {
if(!context.mounted) {
if (!context.mounted) {
return;
}
widget.dashboardViewModel.settingsStore.zcashMigrationModalViewed = true;
@@ -102,7 +101,11 @@ class _NewHomePageState extends State<NewHomePage> {
sliver: CupertinoSliverRefreshControl(
refreshTriggerPullDistance: 160,
refreshIndicatorExtent: 90,
onRefresh: () => widget.dashboardViewModel.refreshDashboard(),
onRefresh: () {
unawaited(widget.nftViewModel.getNFTAssetByWallet());
return widget.dashboardViewModel.refreshDashboard();
},
),
),
SliverToBoxAdapter(
@@ -1,15 +1,20 @@
import "package:cake_wallet/new-ui/widgets/money/money_settings_cubit.dart";
import "package:cake_wallet/new-ui/widgets/money/use_base_unit_provider.dart";
import "package:cake_wallet/store/settings_store.dart";
import "package:flutter/material.dart";
import "package:flutter_bloc/flutter_bloc.dart";
class MoneySettingsProvider extends StatelessWidget {
const MoneySettingsProvider({required this.settingsStore, this.child, super.key});
const MoneySettingsProvider({required this.settingsStore, required this.child, super.key});
final Widget? child;
final Widget child;
final SettingsStore settingsStore;
@override
Widget build(BuildContext context) =>
BlocProvider(create: (_) => MoneySettingsCubit(settingsStore), child: child);
Widget build(BuildContext context) => BlocProvider(
create: (_) => MoneySettingsCubit(settingsStore),
child: BaseUnitScope(
child: child,
),
);
}
@@ -0,0 +1,63 @@
import "dart:async";
import "package:cake_wallet/new-ui/widgets/money/money_settings_cubit.dart";
import "package:cw_core/currency.dart";
import "package:flutter/widgets.dart";
import "package:flutter_bloc/flutter_bloc.dart";
/// Read-only view of the money display settings for the current subtree.
///
/// ```dart
/// final useBaseUnit = BaseUnit.useBaseUnitOf(context, CryptoCurrency.btc);
/// ```
class BaseUnit extends InheritedModel<Currency> {
const BaseUnit({required this.state, required super.child});
final MoneySettingsState state;
static bool useBaseUnitOf(BuildContext context, Currency currency) =>
InheritedModel.inheritFrom<BaseUnit>(context, aspect: currency)!.state.useBaseUnit(currency);
static String getSymbolOf(BuildContext context, Currency currency) =>
InheritedModel.inheritFrom<BaseUnit>(context, aspect: currency)!.state.getSymbol(currency);
@override
bool updateShouldNotify(BaseUnit old) =>
state.bitcoinAmountDisplayMode != old.state.bitcoinAmountDisplayMode;
@override
bool updateShouldNotifyDependent(BaseUnit old, Set<Currency> aspects) => aspects.any((c) =>
state.useBaseUnit(c) != old.state.useBaseUnit(c) ||
state.getSymbol(c) != old.state.getSymbol(c));
}
class BaseUnitScope extends StatefulWidget {
const BaseUnitScope({required this.child, super.key});
final Widget child;
@override
State<BaseUnitScope> createState() => _BaseUnitScopeState();
}
class _BaseUnitScopeState extends State<BaseUnitScope> {
late MoneySettingsState _state;
late final StreamSubscription<MoneySettingsState> _sub;
@override
void initState() {
super.initState();
final cubit = context.read<MoneySettingsCubit>();
_state = cubit.state;
_sub = cubit.stream.listen((s) => setState(() => _state = s));
}
@override
void dispose() {
_sub.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) => BaseUnit(state: _state, child: widget.child);
}
+6 -2
View File
@@ -8,14 +8,18 @@ void startOnCurrentNodeChangeReaction(AppStore appStore) {
_onCurrentNodeChangeReaction?.reaction.dispose();
appStore.settingsStore.nodes.observe((change) async {
try {
await appStore.wallet!.connectToNode(node: change.newValue!);
if(change.newValue?.type == appStore.wallet!.type) {
await appStore.wallet!.connectToNode(node: change.newValue!);
}
} catch (e) {
printV(e.toString());
}
});
appStore.settingsStore.powNodes.observe((change) async {
try {
await appStore.wallet!.connectToPowNode(node: change.newValue!);
if(change.newValue?.type == appStore.wallet!.type) {
await appStore.wallet!.connectToPowNode(node: change.newValue!);
}
} catch (e) {
printV(e.toString());
}
+28 -11
View File
@@ -1,6 +1,7 @@
import 'dart:io';
import 'package:cake_wallet/anonpay/anonpay_invoice_info.dart';
import "package:cake_wallet/core/auth_service.dart";
import 'package:cake_wallet/core/new_wallet_arguments.dart';
import 'package:cake_wallet/new-ui/new_dashboard.dart';
import 'package:cake_wallet/new-ui/pages/about_page.dart';
@@ -46,6 +47,7 @@ import 'package:cake_wallet/src/screens/dashboard/edit_token_page.dart';
import 'package:cake_wallet/src/screens/dashboard/home_settings_page.dart';
import 'package:cake_wallet/src/screens/dashboard/pages/address_page.dart';
import 'package:cake_wallet/src/screens/dashboard/pages/nft_details_page.dart';
import "package:cake_wallet/src/screens/dashboard/pages/nft_send_page.dart";
import 'package:cake_wallet/src/screens/dashboard/pages/transactions_page.dart';
import 'package:cake_wallet/src/screens/dashboard/sign_page.dart';
import 'package:cake_wallet/src/screens/dev/exchange_provider_logs_page.dart';
@@ -146,6 +148,7 @@ import 'package:cake_wallet/view_model/bridge/bridge_view_model.dart';
import 'package:cake_wallet/view_model/bridge/bridge_history_view_model.dart';
import "package:cake_wallet/view_model/buy/buy_sell_view_model.dart";
import 'package:cake_wallet/view_model/dashboard/dashboard_view_model.dart';
import "package:cake_wallet/view_model/dashboard/nft_send_view_model.dart";
import 'package:cake_wallet/view_model/dashboard/nft_view_model.dart';
import 'package:cake_wallet/view_model/dashboard/sign_view_model.dart';
import 'package:cake_wallet/view_model/hardware_wallet/hardware_wallet_view_model.dart';
@@ -364,9 +367,10 @@ Route<dynamic> createRoute(RouteSettings settings) {
ConnectDevicePageParams(
walletType: availableWalletTypes.first,
hardwareWalletType: hardwareWalletType,
onConnectDevice: (BuildContext context, _) => Navigator.of(context).pushNamed(
Routes.chooseHardwareWalletAccount,
arguments: [availableWalletTypes.first, hardwareWalletType]),
onConnectDevice: (context, _) => Navigator.of(context).pushNamed(
Routes.chooseHardwareWalletAccount,
arguments: [availableWalletTypes.first, hardwareWalletType],
),
isReconnect: false,
),
getIt.get<HardwareWalletViewModel>(param1: hardwareWalletType),
@@ -376,19 +380,23 @@ Route<dynamic> createRoute(RouteSettings settings) {
return handleRouteWithPlatformAwareness(
(_) => getIt.get<NewWalletTypePage>(
param1: NewWalletTypeArguments(
onTypeSelected: (BuildContext context, WalletType type) {
if (hardwareWalletType == HardwareWalletType.trezor && type != WalletType.monero) {
Navigator.of(context).pushNamed(Routes.chooseHardwareWalletAccount,
arguments: [type, hardwareWalletType]);
onTypeSelected: (context, type) {
if (hardwareWalletType == HardwareWalletType.trezor &&
!trezorUseNative.contains(type)) {
Navigator.of(context).pushNamed(
Routes.chooseHardwareWalletAccount,
arguments: [type, hardwareWalletType],
);
return;
}
final arguments = ConnectDevicePageParams(
walletType: type,
hardwareWalletType: hardwareWalletType,
onConnectDevice: (BuildContext context, _) => Navigator.of(context).pushNamed(
Routes.chooseHardwareWalletAccount,
arguments: [type, hardwareWalletType]),
onConnectDevice: (context, _) => Navigator.of(context).pushNamed(
Routes.chooseHardwareWalletAccount,
arguments: [type, hardwareWalletType],
),
isReconnect: false,
);
@@ -959,7 +967,7 @@ Route<dynamic> createRoute(RouteSettings settings) {
));
case Routes.nftDetailsPage:
return MaterialPageRoute<void>(
return MaterialPageRoute<bool>(
builder: (_) => NFTDetailsPage(
arguments: settings.arguments as NFTDetailsPageArguments,
dashboardViewModel: getIt.get<DashboardViewModel>(),
@@ -973,6 +981,15 @@ Route<dynamic> createRoute(RouteSettings settings) {
),
);
case Routes.nftSendPage:
return MaterialPageRoute<bool>(
builder: (_) => NFTSendPage(
nftSendViewModel: getIt.get<NFTSendViewModel>(),
authService: getIt.get<AuthService>(),
arguments: settings.arguments as NFTSendPageArguments,
),
);
case Routes.signPage:
final vm = getIt.get<SignViewModel>();
return MaterialPageRoute<void>(
+1
View File
@@ -115,6 +115,7 @@ class Routes {
static const walletConnectConnectionsListing = '/wallet-connect-connections-listing';
static const nftDetailsPage = '/nft_details_page';
static const importNFTPage = '/import_nft_page';
static const nftSendPage = "/nft_send_page";
static const backgroundSync = '/background_sync';
static const startTor = '/start_tor';
static const receiveAddresses = "/receive_addresses";
+34
View File
@@ -384,6 +384,40 @@ class CWSolana extends Solana {
}
}
@override
Future<PendingTransaction> sendNFT(
WalletBase wallet, {
required String mintAddress,
required String destinationAddress,
String? name,
}) =>
(wallet as SolanaWallet).sendNFT(
mintAddress: mintAddress,
destinationAddress: destinationAddress,
name: name,
);
@override
Future<SolanaNFTMetadata?> getNFTOnChainMetadata(WalletBase wallet, String mintAddress) async {
final metadata = await (wallet as SolanaWallet).client.getNFTOnChainMetadata(mintAddress);
if (metadata == null) {
return null;
}
return SolanaNFTMetadata(
mint: metadata.mint,
name: metadata.name,
symbol: metadata.symbol,
metadataUri: metadata.metadataUri,
imageUrl: metadata.imageUrl,
);
}
@override
Future<Set<String>> getHeldTokenMints(WalletBase wallet) =>
(wallet as SolanaWallet).heldTokenMints();
@override
Future<void> discoverAndAddWalletTokens(WalletBase wallet) async {
if (wallet is! SolanaWallet) return;
@@ -1,9 +1,12 @@
import "dart:async";
import 'package:cake_wallet/core/auth_service.dart';
import 'package:cake_wallet/entities/contact_base.dart';
import 'package:cake_wallet/entities/contact_record.dart';
import 'package:cake_wallet/entities/wallet_contact.dart';
import 'package:cake_wallet/entities/wallet_list_order_types.dart';
import 'package:cake_wallet/generated/i18n.dart';
import "package:cake_wallet/new-ui/page_open_listener.dart";
import 'package:cake_wallet/routes.dart';
import 'package:cake_wallet/src/screens/base_page.dart';
import 'package:cake_wallet/src/screens/dashboard/widgets/filter_list_widget.dart';
@@ -26,7 +29,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
class ContactListPage extends BasePage {
class ContactListPage extends BasePage implements PageOpenListener {
ContactListPage(this.contactListViewModel, this.authService, {this.showAddButton = false});
final ContactListViewModel contactListViewModel;
@@ -89,6 +92,11 @@ class ContactListPage extends BasePage {
);
}
@override
void onPageOpen() {
unawaited(contactListViewModel.loadWalletContacts());
}
@override
Widget body(BuildContext context) => ContactPageBody(contactListViewModel: contactListViewModel);
}
@@ -87,7 +87,11 @@ class _DashboardPageState extends State<DashboardPage> {
final dashboardPageView = RefreshIndicator(
displacement: responsiveLayoutUtil.screenHeight * 0.1,
onRefresh: () async => await widget.dashboardViewModel.refreshDashboard(),
onRefresh: () async {
unawaited(widget.balancePage.nftViewModel.getNFTAssetByWallet());
await widget.dashboardViewModel.refreshDashboard();
},
child: SingleChildScrollView(
physics: AlwaysScrollableScrollPhysics(),
child: Container(
@@ -2,10 +2,13 @@ import 'package:flutter/material.dart';
import 'package:cake_wallet/entities/solana_nft_asset_model.dart';
import 'package:cake_wallet/entities/wallet_nft_response.dart';
import 'package:cake_wallet/generated/i18n.dart';
import "package:cake_wallet/routes.dart";
import 'package:cake_wallet/src/screens/base_page.dart';
import "package:cake_wallet/src/screens/dashboard/pages/nft_send_page.dart";
import 'package:cake_wallet/src/screens/dashboard/widgets/menu_widget.dart';
import 'package:cake_wallet/src/widgets/cake_image_widget.dart';
import 'package:cake_wallet/src/widgets/gradient_background.dart';
import "package:cake_wallet/src/widgets/primary_button.dart";
import 'package:cake_wallet/view_model/dashboard/dashboard_view_model.dart';
class NFTDetailsPage extends BasePage {
@@ -56,30 +59,56 @@ class NFTDetailsPage extends BasePage {
// );
}
bool get _canSend =>
arguments.isSolanaNFT &&
(arguments.solanaNFTAssetModel?.mint?.isNotEmpty ?? false) &&
arguments.solanaNFTAssetModel?.isOwned != false;
@override
Widget body(BuildContext context) {
return SingleChildScrollView(
child: Container(
width: double.infinity,
margin: const EdgeInsets.all(16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(24.0),
border: Border.all(
color: Theme.of(context).colorScheme.outline,
width: 1,
),
color: Theme.of(context).colorScheme.surface,
),
child: arguments.isSolanaNFT
? SolanaNFTDetailsWidget(
solanaNftAsset: arguments.solanaNFTAssetModel,
)
: EVMChainNFTDetailsWidget(
nftAsset: arguments.nftAsset,
Widget body(BuildContext context) => SingleChildScrollView(
child: Column(
children: [
Container(
width: double.infinity,
margin: const EdgeInsets.all(16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(24.0),
border: Border.all(
color: Theme.of(context).colorScheme.outline,
width: 1,
),
color: Theme.of(context).colorScheme.surface,
),
),
);
}
child: arguments.isSolanaNFT
? SolanaNFTDetailsWidget(
solanaNftAsset: arguments.solanaNFTAssetModel,
)
: EVMChainNFTDetailsWidget(
nftAsset: arguments.nftAsset,
),
),
if (_canSend)
Padding(
padding: const EdgeInsets.only(left: 16, right: 16, bottom: 24),
child: PrimaryButton(
text: S.of(context).send,
color: Theme.of(context).colorScheme.primary,
textColor: Theme.of(context).colorScheme.onPrimary,
onPressed: () async {
final sent = await Navigator.of(context).pushNamed(
Routes.nftSendPage,
arguments: NFTSendPageArguments(asset: arguments.solanaNFTAssetModel!),
);
if (sent == true && context.mounted) {
Navigator.of(context).pop(true);
}
},
),
),
],
),
);
}
class _NFTImageWidget extends StatelessWidget {
@@ -162,6 +191,15 @@ class SolanaNFTDetailsWidget extends StatelessWidget {
return Center(child: Text(S.current.no_extra_detail));
}
final moreInfoEntries = <MapEntry<String, String>>[
MapEntry(S.current.mint_address, solanaNftAsset?.mint ?? ""),
MapEntry(S.current.contractName, solanaNftAsset?.contract?.name ?? ""),
MapEntry(S.current.contractSymbol, solanaNftAsset?.contract?.symbol ?? ""),
MapEntry(S.current.collection_name, solanaNftAsset?.collection?.name ?? ""),
MapEntry(S.current.collection_description, solanaNftAsset?.collection?.description ?? ""),
MapEntry(S.current.collection_address, solanaNftAsset?.collection?.collectionAddress ?? ""),
].where((entry) => entry.value.isNotEmpty).toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -171,49 +209,75 @@ class SolanaNFTDetailsWidget extends StatelessWidget {
infoType: S.current.name,
infoValue: solanaNftAsset?.name ?? '---',
),
if (solanaNftAsset?.description != null) ...[
if (solanaNftAsset?.isOwned == false) ...[
const SizedBox(height: 16),
_NFTSingleInfoTile(
infoType: S.current.watching,
infoValue: S.current.nft_not_held_by_wallet,
),
],
if (solanaNftAsset?.description?.isNotEmpty ?? false) ...[
const SizedBox(height: 16),
_NFTSingleInfoTile(
infoType: S.current.description,
infoValue: solanaNftAsset!.description ?? '---',
infoValue: solanaNftAsset!.description!,
),
],
const SizedBox(height: 16),
_NFTSingleInfoTile(
infoType: S.current.mint_address,
infoValue: solanaNftAsset?.mint ?? '---',
),
const SizedBox(height: 16),
_NFTSingleInfoTile(
infoType: S.current.contractName,
infoValue: solanaNftAsset?.contract?.name ?? '---',
),
const SizedBox(height: 16),
_NFTSingleInfoTile(
infoType: S.current.contractSymbol,
infoValue: solanaNftAsset?.contract?.symbol ?? '---',
),
const SizedBox(height: 16),
_NFTSingleInfoTile(
infoType: S.current.collection_name,
infoValue: solanaNftAsset?.collection?.name ?? '---',
),
const SizedBox(height: 16),
_NFTSingleInfoTile(
infoType: S.current.collection_description,
infoValue: solanaNftAsset?.collection?.description ?? '---',
),
const SizedBox(height: 16),
_NFTSingleInfoTile(
infoType: S.current.collection_address,
infoValue: solanaNftAsset?.collection?.collectionAddress ?? '---',
),
if (moreInfoEntries.isNotEmpty) _NFTMoreInfoSection(entries: moreInfoEntries),
const SizedBox(height: 16),
],
);
}
}
class _NFTMoreInfoSection extends StatefulWidget {
const _NFTMoreInfoSection({required this.entries});
final List<MapEntry<String, String>> entries;
@override
State<_NFTMoreInfoSection> createState() => _NFTMoreInfoSectionState();
}
class _NFTMoreInfoSectionState extends State<_NFTMoreInfoSection> {
bool isExpanded = false;
@override
Widget build(BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 8),
InkWell(
onTap: () => setState(() => isExpanded = !isExpanded),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
isExpanded ? S.current.hide_details : S.current.show_details,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.primary,
),
),
Icon(
isExpanded ? Icons.keyboard_arrow_up : Icons.keyboard_arrow_down,
color: Theme.of(context).colorScheme.primary,
),
],
),
),
),
if (isExpanded)
for (final entry in widget.entries) ...[
const SizedBox(height: 16),
_NFTSingleInfoTile(infoType: entry.key, infoValue: entry.value),
],
],
);
}
class _NFTSingleInfoTile extends StatelessWidget {
final String infoType;
final String infoValue;
@@ -89,7 +89,7 @@ class _ImportNFTPage extends BasePage {
},
fillColor: Theme.of(context).colorScheme.surfaceContainer,
iconColor: Theme.of(context).colorScheme.primary,
placeholder: '0x...',
placeholder: nftViewModel.isSolanaWallet ? S.current.mint_address : "0x...",
textStyle: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Theme.of(context).colorScheme.onSurface,
),
@@ -7,6 +7,7 @@ import 'package:cake_wallet/src/screens/dashboard/widgets/solana_nft_tile_widget
import 'package:cake_wallet/src/widgets/primary_button.dart';
import 'package:cake_wallet/view_model/dashboard/nft_view_model.dart';
import 'package:cw_core/wallet_type.dart';
import "package:mobx/mobx.dart";
class NFTListingPage extends StatefulWidget {
final NFTViewModel nftViewModel;
@@ -18,23 +19,34 @@ class NFTListingPage extends StatefulWidget {
}
class _NFTListingPageState extends State<NFTListingPage> {
late final ReactionDisposer _walletDisposer;
@override
void initState() {
super.initState();
_walletDisposer = reaction(
(_) => widget.nftViewModel.appStore.wallet,
(_) => widget.nftViewModel.onWalletChanged(),
);
fetchNFTsForWallet();
}
@override
void dispose() {
_walletDisposer();
super.dispose();
}
Future<void> fetchNFTsForWallet() async {
await widget.nftViewModel.getNFTAssetByWallet();
}
@override
Widget build(BuildContext context) {
return SliverToBoxAdapter(
child: Observer(
builder: (context) {
return Column(
Widget build(BuildContext context) => SliverToBoxAdapter(
child: Observer(
builder: (context) => Column(
children: [
const SizedBox(height: 16),
Padding(
@@ -50,6 +62,17 @@ class _NFTListingPageState extends State<NFTListingPage> {
),
),
),
if (widget.nftViewModel.unresolvedNFTCount > 0)
Padding(
padding: const EdgeInsets.only(left: 16, right: 16, bottom: 12),
child: Text(
S.current.nft_some_not_loaded,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
if (widget.nftViewModel.isLoading)
Center(
child: CircularProgressIndicator(
@@ -62,11 +85,9 @@ class _NFTListingPageState extends State<NFTListingPage> {
else
NFTListWidget(nftViewModel: widget.nftViewModel)
],
);
},
),
);
}
),
),
);
}
class NFTListWidget extends StatelessWidget {
@@ -103,7 +124,7 @@ class NFTListWidget extends StatelessWidget {
itemCount: nftViewModel.solanaNftAssetModels.length,
itemBuilder: (context, index) {
final nftAsset = nftViewModel.solanaNftAssetModels[index];
return SolanaNFTTileWidget(nftAsset: nftAsset);
return SolanaNFTTileWidget(nftAsset: nftAsset, nftViewModel: nftViewModel);
},
),
);
@@ -0,0 +1,380 @@
import "package:cake_wallet/core/address_validator.dart";
import "package:cake_wallet/core/auth_service.dart";
import "package:cake_wallet/core/execution_state.dart";
import "package:cake_wallet/entities/solana_nft_asset_model.dart";
import "package:cake_wallet/generated/i18n.dart";
import "package:cake_wallet/src/screens/base_page.dart";
import "package:cake_wallet/src/widgets/address_text_field.dart";
import "package:cake_wallet/src/widgets/alert_with_one_action.dart";
import "package:cake_wallet/src/widgets/alert_with_two_actions.dart";
import "package:cake_wallet/src/widgets/cake_image_widget.dart";
import "package:cake_wallet/src/widgets/gradient_background.dart";
import "package:cake_wallet/src/widgets/primary_button.dart";
import "package:cake_wallet/utils/show_pop_up.dart";
import "package:cake_wallet/view_model/dashboard/nft_send_view_model.dart";
import "package:cw_core/crypto_currency.dart";
import "package:cw_core/pending_transaction.dart";
import "package:flutter/material.dart";
import "package:flutter_mobx/flutter_mobx.dart";
class NFTSendPage extends StatefulWidget {
const NFTSendPage({
required this.nftSendViewModel,
required this.authService,
required this.arguments,
super.key,
});
final NFTSendViewModel nftSendViewModel;
final AuthService authService;
final NFTSendPageArguments arguments;
@override
State<NFTSendPage> createState() => _NFTSendPageState();
}
class _NFTSendPageState extends State<NFTSendPage> {
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
late TextEditingController addressController;
@override
void initState() {
super.initState();
addressController = TextEditingController();
}
@override
void dispose() {
addressController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => _NFTSendPage(
nftSendViewModel: widget.nftSendViewModel,
authService: widget.authService,
asset: widget.arguments.asset,
formKey: formKey,
addressController: addressController,
);
}
class _NFTSendPage extends BasePage {
_NFTSendPage({
required this.nftSendViewModel,
required this.authService,
required this.asset,
required this.formKey,
required this.addressController,
});
final NFTSendViewModel nftSendViewModel;
final AuthService authService;
final SolanaNFTAssetModel asset;
final GlobalKey<FormState> formKey;
final TextEditingController addressController;
@override
String get title => S.current.send;
@override
bool get gradientBackground => true;
@override
Widget Function(BuildContext, Widget) get rootWrapper =>
(context, scaffold) => GradientBackground(scaffold: scaffold);
@override
Widget body(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(16),
child: Form(
key: formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
height: MediaQuery.sizeOf(context).height / 3.5,
clipBehavior: Clip.hardEdge,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(24),
border: Border.all(color: colorScheme.outline),
color: colorScheme.surface,
),
child: CakeImageWidget(imageUrl: asset.imageOriginalUrl),
),
const SizedBox(height: 16),
Text(
(asset.name?.isNotEmpty ?? false) ? asset.name! : (asset.symbol ?? "---"),
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
color: colorScheme.onSurface,
),
),
const SizedBox(height: 24),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
color: colorScheme.surfaceContainer,
),
child: AddressTextField(
controller: addressController,
placeholder: S.of(context).recipient_address,
options: const [
AddressTextFieldOption.paste,
AddressTextFieldOption.qrCode,
],
buttonColor: colorScheme.surfaceContainerHighest,
validator: AddressValidator(type: CryptoCurrency.sol).call,
),
),
const SizedBox(height: 24),
Observer(
builder: (_) => LoadingPrimaryButton(
text: S.of(context).send,
color: colorScheme.primary,
textColor: colorScheme.onPrimary,
isLoading: nftSendViewModel.state is IsExecutingState,
onPressed: () => _onSendPressed(context),
),
),
],
),
),
),
);
}
Future<void> _onSendPressed(BuildContext context) async {
if (nftSendViewModel.isStartingSend || nftSendViewModel.state is IsExecutingState) {
return;
}
if (!(formKey.currentState?.validate() ?? false)) {
return;
}
nftSendViewModel.markSendStarted();
final destinationAddress = addressController.text.trim();
try {
await authService.authenticateAction(
context,
conditionToDetermineIfToUse2FA:
nftSendViewModel.shouldRequireTOTP2FAFor(destinationAddress),
onAuthSuccess: (isAuthenticatedSuccessfully) async {
if (!isAuthenticatedSuccessfully || !context.mounted) {
nftSendViewModel.reset();
return;
}
await _createAndConfirm(context, destinationAddress);
},
);
} finally {
nftSendViewModel.markSendSettled();
}
}
Future<void> _createAndConfirm(BuildContext context, String destinationAddress) async {
await nftSendViewModel.createTransaction(asset, destinationAddress);
if (!context.mounted) {
return;
}
final state = nftSendViewModel.state;
if (state is FailureState) {
await _showError(context, state.error);
return;
}
final pendingTransaction = nftSendViewModel.pendingTransaction;
if (pendingTransaction == null) {
return;
}
final confirmed = await _confirm(context, destinationAddress, pendingTransaction);
if (!context.mounted) {
return;
}
if (!confirmed) {
nftSendViewModel.reset();
return;
}
await _commit(context);
}
Future<bool> _confirm(
BuildContext context,
String destinationAddress,
PendingTransaction pendingTransaction,
) async {
final additionalCost = pendingTransaction.additionalCost;
var confirmed = false;
await showPopUp<void>(
context: context,
builder: (dialogContext) => AlertWithTwoActions(
alertTitle: S.of(context).confirm_sending,
alertContent: "",
alertContentTextWidget: _NFTSendConfirmation(
name: asset.name,
mint: asset.mint,
destinationAddress: destinationAddress,
fee: pendingTransaction.feeFormatted,
accountCreationFee: additionalCost?.toStringWithSymbol(fractionalDigits: 8),
),
leftButtonText: S.of(context).cancel,
rightButtonText: S.of(context).send,
actionLeftButton: () => Navigator.of(dialogContext).pop(),
actionRightButton: () {
confirmed = true;
Navigator.of(dialogContext).pop();
},
),
);
return confirmed;
}
Future<void> _commit(BuildContext context) async {
await nftSendViewModel.commitTransaction();
if (!context.mounted) {
return;
}
final state = nftSendViewModel.state;
if (state is FailureState) {
await _showError(context, state.error);
return;
}
await showPopUp<void>(
context: context,
builder: (dialogContext) => AlertWithOneAction(
alertTitle: "",
alertContent: S.of(context).transaction_sent,
buttonText: S.of(context).ok,
buttonAction: () => Navigator.of(dialogContext).pop(),
),
);
if (!context.mounted) {
return;
}
Navigator.of(context).pop(true);
}
Future<void> _showError(BuildContext context, String error) => showPopUp<void>(
context: context,
builder: (dialogContext) => AlertWithOneAction(
alertTitle: S.of(context).error,
alertContent: error,
buttonText: S.of(context).ok,
buttonAction: () => Navigator.of(dialogContext).pop(),
),
);
}
class NFTSendPageArguments {
NFTSendPageArguments({required this.asset});
final SolanaNFTAssetModel asset;
}
class _NFTSendConfirmation extends StatelessWidget {
const _NFTSendConfirmation({
required this.name,
required this.mint,
required this.destinationAddress,
required this.fee,
required this.accountCreationFee,
});
final String? name;
final String? mint;
final String destinationAddress;
final String fee;
final String? accountCreationFee;
@override
Widget build(BuildContext context) {
final creationFee = accountCreationFee;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: 12,
children: [
if (name?.isNotEmpty ?? false) _ConfirmationRow(label: S.of(context).name, value: name!),
if (mint?.isNotEmpty ?? false)
_ConfirmationRow(label: S.of(context).mint_address, value: mint!),
_ConfirmationRow(label: S.of(context).address, value: destinationAddress),
_ConfirmationRow(label: S.of(context).send_estimated_fee, value: fee),
if (creationFee != null)
Text(
S.of(context).recipient_account_creation_fee(creationFee),
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
decoration: TextDecoration.none,
),
),
],
);
}
}
class _ConfirmationRow extends StatelessWidget {
const _ConfirmationRow({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
final colorScheme = Theme.of(context).colorScheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
label,
textAlign: TextAlign.center,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
decoration: TextDecoration.none,
),
),
const SizedBox(height: 2),
Text(
value,
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
decoration: TextDecoration.none,
),
),
],
);
}
}
@@ -1,87 +1,127 @@
import 'package:flutter/material.dart';
import 'package:cake_wallet/entities/solana_nft_asset_model.dart';
import "package:cake_wallet/generated/i18n.dart";
import 'package:cake_wallet/routes.dart';
import 'package:cake_wallet/src/screens/dashboard/pages/nft_details_page.dart';
import 'package:cake_wallet/src/widgets/cake_image_widget.dart';
import "package:cake_wallet/view_model/dashboard/nft_view_model.dart";
class SolanaNFTTileWidget extends StatelessWidget {
const SolanaNFTTileWidget({super.key, required this.nftAsset});
const SolanaNFTTileWidget({
required this.nftAsset,
required this.nftViewModel,
super.key,
});
final SolanaNFTAssetModel nftAsset;
final NFTViewModel nftViewModel;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: () {
Navigator.pushNamed(
context,
Routes.nftDetailsPage,
arguments: NFTDetailsPageArguments(
isSolanaNFT: true,
solanaNFTAssetModel: nftAsset,
),
);
},
child: Container(
width: double.infinity,
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16.0),
border: Border.all(
color: Theme.of(context).colorScheme.outline,
width: 0.0,
),
color: Theme.of(context).colorScheme.surfaceContainer,
),
child: Row(
children: [
Container(
height: 100,
width: 100,
margin: const EdgeInsets.all(8),
clipBehavior: Clip.hardEdge,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(24.0),
border: Border.all(
color: Theme.of(context).colorScheme.outline,
width: 0.0,
),
color: Theme.of(context).colorScheme.surface,
),
child: CakeImageWidget(
imageUrl: nftAsset.imageOriginalUrl,
),
Widget build(BuildContext context) => InkWell(
onTap: () async {
final walletName = nftViewModel.currentWalletName;
final sent = await Navigator.of(context).pushNamed(
Routes.nftDetailsPage,
arguments: NFTDetailsPageArguments(
isSolanaNFT: true,
solanaNFTAssetModel: nftAsset,
),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Symbol: ${nftAsset.symbol ?? '---'}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
height: 1,
),
);
if (sent != true) {
return;
}
final mint = nftAsset.mint;
if (walletName != null && mint != null && mint.isNotEmpty) {
await nftViewModel.onNFTSent(walletName, mint);
}
},
child: Container(
width: double.infinity,
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16.0),
border: Border.all(
color: Theme.of(context).colorScheme.outline,
width: 0.0,
),
color: Theme.of(context).colorScheme.surfaceContainer,
),
child: Row(
children: [
Container(
height: 100,
width: 100,
margin: const EdgeInsets.all(8),
clipBehavior: Clip.hardEdge,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(24.0),
border: Border.all(
color: Theme.of(context).colorScheme.outline,
width: 0.0,
),
const SizedBox(height: 8),
Text(
(nftAsset.name?.isNotEmpty ?? false)
? nftAsset.name!
: (nftAsset.symbol ?? '---'),
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w900,
color: Theme.of(context).colorScheme.onSurface,
height: 1,
),
),
],
color: Theme.of(context).colorScheme.surface,
),
child: CakeImageWidget(
imageUrl: nftAsset.imageOriginalUrl,
),
),
)
],
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Symbol: ${nftAsset.symbol ?? "---"}",
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
height: 1,
),
),
const SizedBox(height: 8),
Text(
(nftAsset.name?.isNotEmpty ?? false)
? nftAsset.name!
: (nftAsset.symbol ?? "---"),
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w900,
color: Theme.of(context).colorScheme.onSurface,
height: 1,
),
),
if (nftAsset.isOwned == false) ...[
const SizedBox(height: 8),
const _WatchingBadge(),
],
],
),
)
],
),
),
),
);
}
);
}
class _WatchingBadge extends StatelessWidget {
const _WatchingBadge();
@override
Widget build(BuildContext context) => Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8.0),
color: Theme.of(context).colorScheme.surfaceContainerHighest,
),
child: Text(
S.current.watching,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
height: 1,
),
),
);
}
@@ -343,13 +343,14 @@ class _AdvancedPrivacySettingsBodyState extends State<_AdvancedPrivacySettingsBo
),
const SizedBox(height: 24),
LoadingPrimaryButton(
onPressed: () {
onPressed: () async {
if (widget.privacySettingsViewModel.addCustomNode) {
if (_formKey.currentState != null && !_formKey.currentState!.validate()) {
return;
}
widget.nodeViewModel.save();
await widget.nodeViewModel.save();
widget.nodeViewModel.setAsCurrent(widget.nodeViewModel.editingNode!);
}
if (testnetValue == true &&
widget.privacySettingsViewModel.type == WalletType.bitcoin) {
@@ -358,7 +359,7 @@ class _AdvancedPrivacySettingsBodyState extends State<_AdvancedPrivacySettingsBo
widget.nodeViewModel.address = publicBitcoinTestnetElectrumAddress;
widget.nodeViewModel.port = publicBitcoinTestnetElectrumPort;
widget.nodeViewModel.save();
await widget.nodeViewModel.save();
}
if (_passphraseFormKey.currentState != null &&
!_passphraseFormKey.currentState!.validate()) {
@@ -1,10 +1,12 @@
import 'package:cake_wallet/src/screens/order_details/cake_pay_detail_list_card_item.dart';
import 'package:cake_wallet/src/screens/trade_details/trade_details_status_item.dart';
import 'package:cake_wallet/src/widgets/alert_with_one_action.dart';
import 'package:cake_wallet/src/widgets/cake_pay_order_list_card.dart';
import 'package:cake_wallet/src/widgets/standard_list.dart';
import 'package:cake_wallet/src/widgets/standard_list_status_row.dart';
import 'package:cake_wallet/themes/core/material_base_theme.dart';
import 'package:cake_wallet/utils/show_bar.dart';
import 'package:cake_wallet/utils/show_pop_up.dart';
import 'package:cake_wallet/view_model/order_details_view_model.dart';
import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart';
@@ -14,6 +16,7 @@ import 'package:cake_wallet/generated/i18n.dart';
import 'package:cake_wallet/src/screens/base_page.dart';
import 'package:cake_wallet/src/widgets/list_row.dart';
import 'package:cake_wallet/src/screens/trade_details/track_trade_list_item.dart';
import 'package:mobx/mobx.dart';
class OrderDetailsPage extends BasePage {
OrderDetailsPage(this.orderDetailsViewModel);
@@ -43,11 +46,44 @@ class OrderDetailsPageBodyState extends State<OrderDetailsPageBody> {
final OrderDetailsViewModel orderDetailsViewModel;
final MaterialThemeBase currentTheme;
ReactionDisposer? _sessionExpiredReaction;
@override
void initState() {
super.initState();
_sessionExpiredReaction = reaction<bool>(
(_) => orderDetailsViewModel.isCakePaySessionExpired,
(isExpired) {
if (!isExpired) {
return;
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) {
return;
}
showPopUp<void>(
context: context,
builder: (dialogContext) => AlertWithOneAction(
alertTitle: S.of(dialogContext).error,
alertContent: S.of(dialogContext).cake_pay_session_expired,
buttonText: S.of(dialogContext).ok,
buttonAction: () => Navigator.of(dialogContext).pop(),
),
);
});
},
fireImmediately: true,
);
}
@override
void dispose() {
super.dispose();
_sessionExpiredReaction?.call();
orderDetailsViewModel.timer?.cancel();
super.dispose();
}
@override
@@ -32,30 +32,35 @@ class ListItemSelectorWidget extends StatelessWidget {
onTap: onTap,
isFirstInSection: isFirstInSection,
isLastInSection: isLastInSection,
builder: (context, textStyle, labelStyle) {
return Row(
builder: (context, textStyle, labelStyle) => Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Flexible(child: Text(label, style: textStyle)),
Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Row(
spacing: 8,
children: [
Text(
options[selectedIndex],
style: labelStyle,
),
CakeImageWidget(
imageUrl: "assets/new-ui/chooser.svg",
colorFilter:
ColorFilter.mode(theme.colorScheme.onSurfaceVariant, BlendMode.srcIn),
),
],
Flexible(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Row(
mainAxisSize: MainAxisSize.min,
spacing: 8,
children: [
Flexible(
child: Text(
options[selectedIndex],
style: labelStyle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
CakeImageWidget(
imageUrl: "assets/new-ui/chooser.svg",
colorFilter:
ColorFilter.mode(theme.colorScheme.onSurfaceVariant, BlendMode.srcIn),
),
],
),
),
),
],
);
});
));
}
}
+14 -3
View File
@@ -1378,8 +1378,8 @@ abstract class SettingsStoreBase with Store {
pinLength = defaultPinLength;
}
final savedLanguageCode = sharedPreferences.getString(PreferencesKey.currentLanguageCode) ??
await LanguageService.localeDetection();
final savedLanguageCode =
_offeredLanguageCode(sharedPreferences) ?? await LanguageService.localeDetection();
final nodeId = sharedPreferences.getInt(PreferencesKey.currentNodeIdKey);
final bitcoinElectrumServerId =
sharedPreferences.getInt(PreferencesKey.currentBitcoinElectrumSererIdKey);
@@ -1938,7 +1938,12 @@ abstract class SettingsStoreBase with Store {
}
pinCodeLength = pinLength;
languageCode = sharedPreferences.getString(PreferencesKey.currentLanguageCode) ?? languageCode;
final restoredLanguageCode = _offeredLanguageCode(sharedPreferences);
if (restoredLanguageCode == null) {
await sharedPreferences.setString(PreferencesKey.currentLanguageCode, languageCode);
} else {
languageCode = restoredLanguageCode;
}
shouldShowYatPopup =
sharedPreferences.getBool(PreferencesKey.shouldShowYatPopup) ?? shouldShowYatPopup;
shouldShowDEuroDisclaimer =
@@ -2315,6 +2320,12 @@ abstract class SettingsStoreBase with Store {
await _sharedPreferences.setString(key, serializedData);
}
static String? _offeredLanguageCode(SharedPreferences sharedPreferences) {
final code = sharedPreferences.getString(PreferencesKey.currentLanguageCode);
return LanguageService.list.containsKey(code) ? code : null;
}
static Future<String?> _getDeviceName() async {
String? deviceName = '';
final deviceInfoPlugin = DeviceInfoPlugin();
@@ -1,5 +1,6 @@
import 'dart:async';
import 'package:cake_wallet/cake_pay/src/cake_pay_exceptions.dart';
import 'package:cake_wallet/order/order_provider_description.dart';
import 'package:cake_wallet/order/order.dart';
import 'package:cake_wallet/order/order_source_description.dart';
@@ -221,6 +222,8 @@ abstract class CakePayBuyCardViewModelBase with Store {
orders.add(orderRecord);
updateRemainingTime();
_startExpirationTimer();
} on CakePayUnauthorizedException {
rethrow;
} catch (e) {
sendViewModel.state = FailureState(
sendViewModel.translateErrorMessage(e, walletType, sendViewModel.wallet.currency));
@@ -275,8 +278,6 @@ abstract class CakePayBuyCardViewModelBase with Store {
}
}
Future<void> logout() async => await _cakePayService.logout();
void _startExpirationTimer() {
_timer?.cancel();
_timer = Timer.periodic(Duration(seconds: 1), (_) {
@@ -78,7 +78,6 @@ abstract class CakePayCardsListViewModelBase with Store {
};
String searchString;
String? username;
int page;
late Country _initialSelectedCountry;
@@ -87,6 +86,9 @@ abstract class CakePayCardsListViewModelBase with Store {
late bool _initialDisplayDenominationsCards;
late bool _initialDisplayCustomValueCards;
@observable
String? username;
@observable
List<CakePayCard> userCards;
@@ -189,12 +191,9 @@ abstract class CakePayCardsListViewModelBase with Store {
@action
Future<void> checkAuth() async {
authFuture = ObservableFuture(cakePayService.isLogged());
final logged = await authFuture!;
if (logged) {
username = await cakePayService.getUserEmail();
}
final logged = await cakePayService.isLogged();
username = logged ? await cakePayService.getUserEmail() : null;
authFuture = ObservableFuture.value(logged);
}
@action
@@ -14,6 +14,7 @@ import 'package:cake_wallet/utils/mobx.dart';
import 'package:cake_wallet/utils/token_utilities.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/currency_for_wallet_type.dart';
import "package:cw_core/utils/print_verbose.dart";
import 'package:cw_core/wallet_info.dart';
import 'package:cw_core/wallet_type.dart';
import 'package:hive/hive.dart';
@@ -33,79 +34,103 @@ abstract class ContactListViewModelBase with Store {
}
Future<void> _init() async {
final walletInfos = await WalletInfo.getAll();
for (final info in walletInfos) {
final addressInfos = await info.getAddressInfos();
final addresses = await info.getAddresses();
if ([WalletType.monero, WalletType.wownero, WalletType.haven].contains(info.type) &&
addressInfos.isNotEmpty) {
for (var key in addressInfos.keys) {
final value = addressInfos[key];
final address = value?.first;
if (address != null) {
final name = _createName(info.name, address.label, key: key);
walletContacts.add(WalletContact(
address.address,
await loadWalletContacts();
_subscription = contactSource.bindToListWithTransform(
contacts,
(contact) => ContactRecord(contactSource, contact),
initialFire: true,
);
setOrderType(settingsStore.contactListOrder);
}
Future<void> _lastWalletContactsLoad = Future.value();
@action
Future<void> loadWalletContacts() async {
final waitFor = _lastWalletContactsLoad;
final done = Completer<void>();
_lastWalletContactsLoad = done.future;
await waitFor;
try {
final loadedContacts = <WalletContact>[];
final walletInfos = await WalletInfo.getAll();
for (final info in walletInfos) {
final addressInfos = await info.getAddressInfos();
final addresses = await info.getAddresses();
if ([WalletType.monero, WalletType.wownero, WalletType.haven].contains(info.type) &&
addressInfos.isNotEmpty) {
for (var key in addressInfos.keys) {
final value = addressInfos[key];
final address = value?.first;
if (address != null) {
final name = _createName(info.name, address.label, key: key);
loadedContacts.add(WalletContact(
address.address,
name,
getCryptoCurrencyForWalletListItem(
info.type,
),
walletType: info.type,
));
}
}
} else if (addresses.isNotEmpty == true && addresses.length > 1) {
if ([WalletType.monero, WalletType.wownero, WalletType.haven, WalletType.decred]
.contains(info.type)) {
final address = info.address;
final name = _createName(info.name, "", key: 0);
loadedContacts.add(WalletContact(
address,
name,
getCryptoCurrencyForWalletListItem(
info.type,
),
walletType: info.type,
));
} else {
addresses.forEach((address, label) {
if (label.isEmpty) {
return;
}
final name = _createName(info.name, label, key: null);
loadedContacts.add(WalletContact(
address,
name,
getCryptoCurrencyForWalletListItem(
info.type,
isTestnet: info.network == null
? false
: info.network!.toLowerCase().contains("testnet"),
),
walletType: info.type,
));
});
}
}
} else if (addresses.isNotEmpty == true && addresses.length > 1) {
if ([WalletType.monero, WalletType.wownero, WalletType.haven, WalletType.decred]
.contains(info.type)) {
final address = info.address;
final name = _createName(info.name, "", key: 0);
walletContacts.add(WalletContact(
address,
name,
} else {
loadedContacts.add(WalletContact(
info.address,
_createName(info.name, "",
key: [WalletType.monero, WalletType.wownero, WalletType.haven].contains(info.type)
? 0
: null),
getCryptoCurrencyForWalletListItem(
info.type,
),
walletType: info.type,
));
} else {
addresses.forEach((address, label) {
if (label.isEmpty) {
return;
}
final name = _createName(info.name, label, key: null);
walletContacts.add(WalletContact(
address,
name,
getCryptoCurrencyForWalletListItem(
info.type,
isTestnet:
info.network == null ? false : info.network!.toLowerCase().contains("testnet"),
),
walletType: info.type,
));
});
}
} else {
walletContacts.add(WalletContact(
info.address,
_createName(info.name, "",
key: [WalletType.monero, WalletType.wownero, WalletType.haven].contains(info.type)
? 0
: null),
getCryptoCurrencyForWalletListItem(
info.type,
),
walletType: info.type,
));
}
walletContacts = loadedContacts;
} catch (e) {
printV("loadWalletContacts failed: $e");
} finally {
done.complete();
}
_subscription = contactSource.bindToListWithTransform(
contacts, (Contact contact) => ContactRecord(contactSource, contact),
initialFire: true);
setOrderType(settingsStore.contactListOrder);
walletContacts = walletContacts.toList(); // rebuild
}
String _createName(String walletName, String label, {int? key = null}) {

Some files were not shown because too many files have changed in this diff Show More