From 82e3ebf4fa9839e56da02ca53e55c64098c66394 Mon Sep 17 00:00:00 2001 From: Konstantin Ullrich Date: Mon, 12 May 2025 19:33:14 +0200 Subject: [PATCH] implement-payjoin (#1949) * Initial Payjoin * Initial Payjoin * More payjoin stuff * Minor fixes * Minor fixes * Minor cleanup * Minor cleanup * Minor cleanup * Minor cleanup * Minor cleanup * Minor cleanup * Fix minor bug causes by data inconsistency in the btc utxos * Minor cleanup * Minor cleanup * Minor cleanup * Minor cleanup * Initial Payjoin * Initial Payjoin * More payjoin stuff * Minor fixes * Minor fixes * Minor cleanup * Minor cleanup * Minor cleanup * Minor cleanup * Minor cleanup * Minor cleanup * Fix minor bug causes by data inconsistency in the btc utxos * Minor cleanup * Minor cleanup * Minor cleanup * Minor cleanup * Fix Rebase issues * Move PJ Receiver to isolate * Add Payjoin Setting * Payjoin Sender are now isolated * Added Payjoin sessions to tx overview. Fix Fee issue with payjoin * Clean up code * Fix taproot for payjoin * Fix CI Errors * Add Payjoin UI elements and details page * Add Payjoin UI elements and details page * Fix Translations * feat: Detect Payjoin URIs in pasted text and show to the User sending Payjoin * feat: rename pjUri to payjoinURI for more code clarity * Update res/values/strings_pl.arb Co-authored-by: cyan * Update cw_bitcoin/lib/payjoin/manager.dart Co-authored-by: cyan * Update cw_bitcoin/lib/payjoin/manager.dart Co-authored-by: cyan * feat: Disable Payjoin per default * feat: Disable Payjoin fully if disabled or no Inputs available * feat: Resume Payjoin if app comes back to foreground * chore: Revert overly aggressive code formats * feat: show correct Payjoin amount for receivers * feat: Improved payjoin status * feat: Show payjoin errors on payjoin details screen * deps: update flutter to 3.27.4 * feat: Revert localisations * bug: Remove duplicate transaction id on payjoin details * style: remove double await in payjoin sender * refactor(cw_bitcoin): Refactor method signatures and convert constructor to factory * refactor(cw_bitcoin): Refactor wallet service and PSBT signer for cleaner code Removed unnecessary `CakeHive` dependency and refactored `BitcoinWallet` initialization to use `payjoinSessionSource`. Improved code readability in `PsbtSigner` by reformatting lines and simplifying constructor methods for `UtxoWithPrivateKey`. * fix: Resume Payjoin Sessions and load PJUri after sleep * feat: Add "Copy Payjoin URL button" to receive screen * fix: Add "Payjoin enabled"-Box below QR Code on the receive screen * fix: Set payjoin_enabled color to black independent of the theme * refactor: Payjoin session management and cleanup unused code. --------- Co-authored-by: Omar Hatem Co-authored-by: cyan --- assets/images/payjoin.png | Bin 0 -> 30662 bytes cw_bitcoin/lib/address_from_output.dart | 44 ++- .../lib/bitcoin_transaction_credentials.dart | 10 +- cw_bitcoin/lib/bitcoin_wallet.dart | 227 ++++++++++--- cw_bitcoin/lib/bitcoin_wallet_addresses.dart | 26 ++ cw_bitcoin/lib/bitcoin_wallet_service.dart | 12 +- cw_bitcoin/lib/electrum_wallet.dart | 6 + cw_bitcoin/lib/payjoin/manager.dart | 298 ++++++++++++++++++ .../lib/payjoin/payjoin_receive_worker.dart | 219 +++++++++++++ .../lib/payjoin/payjoin_send_worker.dart | 119 +++++++ .../lib/payjoin/payjoin_session_errors.dart | 16 + cw_bitcoin/lib/payjoin/storage.dart | 95 ++++++ .../lib/pending_bitcoin_transaction.dart | 9 + cw_bitcoin/lib/psbt/signer.dart | 263 ++++++++++++++++ .../transaction_builder.dart} | 0 cw_bitcoin/lib/psbt/utils.dart | 41 +++ cw_bitcoin/lib/psbt/v0_deserialize.dart | 52 +++ cw_bitcoin/lib/psbt/v0_finalizer.dart | 143 +++++++++ cw_bitcoin/pubspec.lock | 53 +++- cw_bitcoin/pubspec.yaml | 5 + cw_core/lib/hive_type_ids.dart | 1 + cw_core/lib/payjoin_session.dart | 67 ++++ cw_core/pubspec.lock | 2 +- cw_decred/pubspec.lock | 2 +- cw_monero/pubspec.lock | 2 +- cw_nano/pubspec.lock | 2 +- cw_wownero/pubspec.lock | 2 +- cw_zano/pubspec.lock | 2 +- lib/bitcoin/cw_bitcoin.dart | 82 +++-- lib/di.dart | 25 ++ lib/entities/preferences_key.dart | 1 + lib/main.dart | 9 + lib/reactions/on_current_wallet_change.dart | 5 + lib/router.dart | 9 + lib/routes.dart | 1 + .../dashboard/pages/transactions_page.dart | 21 ++ .../widgets/payjoin_transaction_row.dart | 99 ++++++ .../payjoin_details/payjoin_details_page.dart | 77 +++++ .../screens/receive/widgets/qr_widget.dart | 86 +++-- lib/src/screens/root/root.dart | 10 +- lib/src/screens/send/send_page.dart | 2 +- lib/src/screens/send/widgets/send_card.dart | 30 +- lib/src/screens/settings/privacy_page.dart | 8 + .../trade_details_status_item.dart | 4 +- lib/src/widgets/address_text_field.dart | 34 +- lib/src/widgets/standard_list_status_row.dart | 6 +- .../dashboard/payjoin_transactions_store.dart | 47 +++ lib/store/settings_store.dart | 11 + lib/utils/payment_request.dart | 11 +- .../dashboard/dashboard_view_model.dart | 41 ++- .../payjoin_transaction_list_item.dart | 34 ++ .../payjoin_details_view_model.dart | 122 +++++++ lib/view_model/send/send_view_model.dart | 7 + .../settings/privacy_settings_view_model.dart | 38 ++- .../wallet_address_list_view_model.dart | 25 +- res/values/strings_ar.arb | 9 +- res/values/strings_bg.arb | 9 +- res/values/strings_cs.arb | 9 +- res/values/strings_de.arb | 7 + res/values/strings_en.arb | 7 + res/values/strings_es.arb | 7 + res/values/strings_fr.arb | 9 +- res/values/strings_ha.arb | 9 +- res/values/strings_hi.arb | 9 +- res/values/strings_hr.arb | 9 +- res/values/strings_hy.arb | 9 +- res/values/strings_id.arb | 9 +- res/values/strings_it.arb | 9 +- res/values/strings_ja.arb | 9 +- res/values/strings_ko.arb | 7 + res/values/strings_my.arb | 9 +- res/values/strings_nl.arb | 9 +- res/values/strings_pl.arb | 9 +- res/values/strings_pt.arb | 9 +- res/values/strings_ru.arb | 9 +- res/values/strings_th.arb | 9 +- res/values/strings_tl.arb | 9 +- res/values/strings_tr.arb | 9 +- res/values/strings_uk.arb | 9 +- res/values/strings_ur.arb | 9 +- res/values/strings_vi.arb | 9 +- res/values/strings_yo.arb | 9 +- res/values/strings_zh.arb | 9 +- tool/configure.dart | 13 +- 84 files changed, 2622 insertions(+), 198 deletions(-) create mode 100644 assets/images/payjoin.png create mode 100644 cw_bitcoin/lib/payjoin/manager.dart create mode 100644 cw_bitcoin/lib/payjoin/payjoin_receive_worker.dart create mode 100644 cw_bitcoin/lib/payjoin/payjoin_send_worker.dart create mode 100644 cw_bitcoin/lib/payjoin/payjoin_session_errors.dart create mode 100644 cw_bitcoin/lib/payjoin/storage.dart create mode 100644 cw_bitcoin/lib/psbt/signer.dart rename cw_bitcoin/lib/{psbt_transaction_builder.dart => psbt/transaction_builder.dart} (100%) create mode 100644 cw_bitcoin/lib/psbt/utils.dart create mode 100644 cw_bitcoin/lib/psbt/v0_deserialize.dart create mode 100644 cw_bitcoin/lib/psbt/v0_finalizer.dart create mode 100644 cw_core/lib/payjoin_session.dart create mode 100644 lib/src/screens/dashboard/widgets/payjoin_transaction_row.dart create mode 100644 lib/src/screens/payjoin_details/payjoin_details_page.dart create mode 100644 lib/store/dashboard/payjoin_transactions_store.dart create mode 100644 lib/view_model/dashboard/payjoin_transaction_list_item.dart create mode 100644 lib/view_model/payjoin_details_view_model.dart diff --git a/assets/images/payjoin.png b/assets/images/payjoin.png new file mode 100644 index 0000000000000000000000000000000000000000..1ba3dccdbfa7799cd0cd78560ecb23048e0c1cd2 GIT binary patch literal 30662 zcmeGE_dnHt{6CJL^Kj%Gl4C33VUMg-gm6O0%qo$UoteFzBO{}nXpk*CJA33H(I9)z zvdP}_^PuPJ^?v;c-ygm|bg7*CxsTiZcDvrz^)Bq5x*|F0B~lm+Mt)aGUJC{z0#E-S z&p|&Kb~#pDh9Ze^LCeJpX@96xKqkE1`vAzxwtMPXcy-c|157EY=NZKUx1-LI2Ln zZRgjO?!(xf{ezYtKdLI(s83Z3a~%%n(2p2khLow6ZS_g)?lpNka?-&(7U0{D$?4GPS zkiujU`=b~ZyZpBwAsMpVJcK58uxTU~VyPD))pCL~S$xJ)@N`kWRd(wULqG2XB|_^1 zp^9H2-t*$$`u)peVK@S$&J$mYZ4k&Wbe1T3cOh|Dpgtbk!O6C{5`gPycrHQHKDsxx$dEmf9 zVgx=Zur|p=g_FMneeEs=_N_<1{0$);@D}H?_pGVYz^t@N3u#k> z9cmcZyn6OdkpgYh2eP@E)d;?j_1x99*0|-7+BuM*I z;QjwD6H13x2yrlyR7?qlz(;OW0(G8ibyt!}?8j(tz*~zo;*SkO{mbZGRwuWe<|yV| z5&+Q-&0$5{reX3JchN_3na+4cNq7BHp1R9CZO)%3IS4LFG=|H@y5?@mYwXxdt?0LM zCTMsa+q3KGqhNgTu`uqHU1p`BN&)Ccc*0BG^cgY?KDgqLkKrkmr~#7Xh9k z5K%YnO;wPpye6J~uiG55PaXj}W7Jh{w|WR= zs=yJXc>ddcswtJ&3C)DkDS##LYg0$|!O6Iq?%gr7ANy;2^I3zzF=qI(+4V+md($7> z9Xil8eYdwjEFh80ZjUGgbvte%>1{+CIJ9hU4b$D`ux=XQ2M(Z#{fsiX(8`$AxuMH} z3J;E@ublj;qn6_GyVNMj<~Nf62Ez)uUUYgh+L{;iEbTciF32K0JxL0g)HWOfmy}PA zR<|~ezM?qnhn3Jai}v;iQpghrg90HKGU$ZC>z@c~yPrdfI(z({zc~^KT3lY}MoZFq zaU))}=9>*84_QGh4=I3xu5aN*y2l5JwUH}mrAuY?U>9=e2{HkK#P+?oc#JMI*`-2) z=8L|-3Q~shGTjA&YhWw<`X{tCgfpd|%DI3V#IfJWU&nggqKKvZumHq`rkwCN{d=Z3 zmO73xc3$o)&tHEkJwpOi)H~=PM)}cI?^@I#S<#c%BiAph7E<-UtD;kX?`ecc%m! zc*7r>)KuN9-Jc1X6NC2zq}UZNbwpic*1Sf6OM@&#Q=N>x$PlwS;IK9PTDHYh;@V4| z)th!3GGiBq2dlm1+ymnhFyfHZ6EZj{2lywXqS|ycsJ)ZT#NsYm(g^D;Vm^|wk#zi& zok+mKoZEkmAly-NTLaD0elHCladEAF#0r;uVne)2G~wcQdIb3j2P<_To)`g5Bimxh zM@=;#2u9kKdV9)D^@Z|_ddEc)Zo}bIV8S5Mj&{|DIX)c`rKZZo=43=bcaVp8%ZY+< zL4>E!kjg}HR4wlWTR<@;=w*aF7kEztQ`maW;I=tY^j+ibuM zs+$c_LPHVW1=Ji|{EKB~9CX3^A6ouIIL`d?l^@u7NL+hmePCU-H7^uHQ0^!=C zFCbVcGxR8`#dq2<3RTN9QOooE=5^!}Ep+(sIDch$9kXDOHr?k$t|B=M`r+GVp`4X$ zhM0DK5c_jvf7>qx8?lg9VWyoC3pqwz0@)(P?I{Rfe(?puaPNo_=r^|~XrVa*L!Ui^ z;r;;9e-@GsVRY>t zAg9o1dFRrYe2#l67+4OcZB-5;vzEb|?Om>dJwJ|;IKfBJPWM25q6AX8!B!d~GS&UG zy}w(=Cw^j1pN4J#h-j25gZz0fwfi4LEAk+$@9XZOLRC(1KH-m7#u8|N?!SqixyXqbSwu=Y{^+kPq4Wmg=@kRYGACoU)C zkyoIouhj5u;1j7Tjr=Y7_hqfzLyl+Pe)=HmqBE!GPrb4DoxP{3N8OuZh3%dmTgl{P zetyTHagN4@UwcPo=-&p&tUwV>p^#bAx%cLFIGe!d8~X#nxAw+RU<^H>wMORKo$oOv zuOfCEKi4tv`;$|?Ym_}#7b?7N>q)62Qu=7xKH>Lep&*?Cwulu@5W5*&F_sIQd_LLU zIZ68R(rIJseawVrS>R*-!-s^*hhNF)GF};<=kwN9zSg2YMq~VwO7<~rVm>b$gg~aH zcpiYdc7@ebp0_-3cQxlarXE6w>`m zHYpkWTYGyG1$U$~%53w0eN8PiekJ1jgFeY8tH<9!#8vSrM0-ii~dq|_g?6!8bA=5Ur|PjX4s~#NiwI+ zez_5WzS+*G-$((+ap+w=&nW*$CQ$c%TNMY0eMGgsl8QJ-&f+a4@nGW$BdE4%6+}2J{>X5D`Ntys~5uP zhdi3z#F=)WOD=p{UyByue9TJ=eIdM(&QQUm<6WCn4xcu2&099trQae?Rd6lzLt8IP z5JDbA?mqYH~D6Pn)hM*KxYxe1aXa1V= zezBJD19s??l%>5(^bE^^6Jb5aY&V5NHKwHHHq3%Z_;^P6#~5t}I0_`mi}Rm6fox}C zCaPxRnIU4+TSxGQ(5naM%6EByMx|$O85{-*LygIEzw&KE!HiF{s3P*OJLm%1OR z$;JdBeXS6&;KVhkFD-Uw{Cbm}X^=Pea{m3ug?Ch|jO-a40sJjxxi@o&)geSx9oeOH z_e-I!rf(yoeNF$J?6&@S95-C9G`zPF)6btgFL0lh&Q%`rfoYaWY(P^&tKo=BZ&RDI z$JTs6{rSH;65-F)YthLp{#9$_oqX>1D*b6cl&0K;?&~h&=N$(M*LDBd;Lm@0HYPK(1N=4iHe68e4fyE z`FHo)!h|c69zhxsK9t&Mke;WMPROQsfSY(4+a8HnAiY_bxb9{z*=#j~f|f-w*7XZg zI0g1CJ}vS@L_Kq|Wbe=?^nbb#LE-db#*oftqbvJl%-w>OKl_#t4_E2wG_p-{c9muozl&aRK zfL^;!zefj#q%&$LzndczDADrnW$Ct)f`Zf<5LT&nb>w?o+d}m>ySY@IUk!5H`1~FZ z!;K>nu86&pUw6qMagH|_3NnC>o3E8Q%&~fN`(#CpS}~!O*iHK*M}4frkRmlDSfokt za-r7Q-(2Z6lEBkV_O4)t>R-EIipSJZOm!unVj}(BSuP3gEXn7Jxm= z;Vk?9Zd!Xd0!c&dl;n4C>tJ$mc6xb1`|652+4^ch;uhYP3KL@2=PvnXo2&QES})sO z8HFmtJp*buU_hMUqIheTl+|Ey+%*6aZTRmZ%zY2lggs(P|}D%yrR#c zmf5A0eb02uEMEzB@uO7yU$w5)NsEpCXDt$liOh%ahfq!G8d=`#?|_2(fWuqY8dnaI zb`vF8=+VeN6QO&p51x*DC2VytYmFie!Tahj1O@3(1rN>c4xvxjR z2s#S|N974gzjR_43|#;gGXxex_j3PyF~GTOGR#{4Wmp*?t-W(vSO_b(X;dtw8?2Y| zmO734V7PMtg^anU&(YBz`)6etdcn31;g%(@NT6GO=Jcs;Mn1N_S-afS-`xj;B{0J^ zIs-bDy~g@NeE9r_Y>4qT@%{%dCE*Sg;WsmkqMqkJj)!u0ZGzeo1#`bi%lGGynLW?f zcS?><+V!>EDL$v=FFet_eOwS*~Fb7Hma)K~L`gVPj&wJJ22^>V{C0S~q->iYILZ>ii)ePK9Ig~((H zTpUXLXk!2O8B|E6S|-+#x|@8M^B}j}*%zvirUVm~`h!(J&~rI{qzjb`aYKR^^>fxm zGpkb*55K%wy8B@IdzmLxp}9pJd$fN<+kL+$T!6NkF=7~ufIhz>0)oxwwH{8hPA z^@gYxEGPB7NnB1+wuU$BTAEiN(KjxtInES%+la$bq3kSgC(aMst$UwqkQb-;sF?VGTvw&1SSV{pDP*kJ8}LR$+7|0%nJv@y2o z^Mnd}5jKr2C8|3d9Gb(CE|>vo05L;@mi=9R5M)nXadp8V4yM&F_Opw6bfTSJ zdi_=+srE0?AgDIRP5j7K9k?Gqsxsee%`Tt^3+uT=TfzBFWmek|eZDGJZZIZ?~c3p~6wK zDad()NZ?B6H;eaAlOBdN67!kG%R;;E1G*2rz2B2Orhb99He8bG=f-aK6jmb-W2e6PM5BNE;V<17^NItMX3u38@6&L<_EKLytvik17#b8oK^4-J zG{UX^obxN3q^z_mbuDScc;*G#eqLXlFQZj+H1@?V$*RZIgnc!BZXH@#6Ja>A4 z*hzi56V@RbDXr;n(V1ujKK5^k?MJFx#SF*huTsd}dtZm0o@0U*CobE%uWv9?j~W66 z{o@3vG2d^x8z_e`jzd;2>6&O^{mg;*2~;*jGiaKuGEQ(bRcGxn6|+*LQhGee^dmj- zVfmAse$Dr@Q>I+wXDUcEFR+USjI$MeA`L#`N3Ry`8agyG7xB#xpX_q4sJA~2Z+?4L zV)lafSFL9ryuEpdUh%u^l}U$LFYOgF;Pc(IuUtL$^U6zv)N%tE$t;^z|6028B%34} z7F}dP9ocqyKP+hV)oMyHglp(*Ug2Fer$8ZlZd4Eoj|29ePK2g}@?Bk3J#`v;e%-PG&K^^% zlt;w1@;B_XA>l&BHgs1`sv`5bv9QwbPl)k+dDHZl_EEK~nx7j4UY*XhPIi7@UV67C zyD6mpkmSoRDKv(v6pFu!`J0&M8Z?SfeEnRINU;PF2V@o5a5a$(1G(*ImnH;0(IlTc z>UcU#vqmB~s_%`1-|7DKObBSIqhs@)39;{*Y*>Wjw+^w!lkw4-#gm;s_D+qX@$^H! zJ7cl9)3s%hkr!pzAki)%U!b>qo!Jri80-xz3aQN}9m{HyA1v<}h(g1-=+Ux6bcB>0 zK6}c=JT+L(A8+rR)EZF|sg&TTZVSm7pii^C{k2(nejQtfkx6UTdTL=ZQOM_Q3j_Zb zf*+^VBKWU5ue3a0@sr;c)VfCp*&`g#M1+vBrn0=ha9EtcL+ocRiNLczc{7XhyLQWD zMbUcYdzs}LbRe+fj>#7^)s7z?zG7IdW8d(G6ePZ?BEYvqap8g|jEp5Cx6k>sCuNdF z@MPs!r`Wj^UiRtMqPifqXKS=>lOXoxPa=N!?wSZWATKUUa~amj z44c3??ky5uVEJJU?XdiUPPAz7sdS!@ou`wPt*1z%Pj|oJX!IFY5|?`%6k^mXX4DV* zmOd?~gUk#)z$0vgL`gQ9l#=8!Uf~}x2bAz!C?D;mbcS?UyK~Sy$nyfiLtB0K@=||n zEw!Vd@-*2`7ExdRANS-6Wj)B~v)Lm+NNjaMc`~oHj(`0sd@WY?1pyER<2(8v$rV?# zoClP4xb)r<+|c#I@--f)@Fy6gAO%*fN6xnGE?c87zqk1A+OTd z20iBgL;)yWY-v+L`K*$!ml)`cx6wY9J-rQZHIQ(#e?B&OhUtsgk79Q0b%z|h#w>|f zNr`C2yLLwU(q4P2)#WxQmZk)q?wa7^r&9VCOvfs!bQCC$4PiC|q^wOXn0j~S-U`#O zhqPzw3J8Bua~|j6&~v=P%E+*rG`7g$q~)^6$k1Sswh5MT z8o#6D&+2Ezx}5w7Ht1Mx`w~ym2h&jNQ$X(1zA)S}eS9|7N!H}K2c4|ZFA}5&_d@P| z9-w6L`#IbkpZb8(*y!UUH98Hjn&5r+p^U^5lnaIc^KwcZse1EMeZn^n%dVApSVa?+bm`Cz6_T z=WnF<{tp+xt-^;EXmu$0aZSEfv)zAH{(9XbROYEQz=0RU(K1fgX+A!!bJb6TJciMV zqF0Yo=CnsA)xYbyh&x<)CVx46I(Sp0*Q=feo(|4bXFF*u#K@pfHu{y8{4Y-r1cfUM zrabpc9lf)}1Rj}X(Q#uFejm8ZSZgeh*YS-I=oHV+ErR3-fL(Ovxxb#2p=5CdOJhe~ z$3lFS#XH8~)J%T}_I90KJ(j=$d~=K?`iw82Pwz-&$I=8oqW`e-0i1a@dr8{i`{}Ts zyc4!LF6A|KTzD@I`Bsuw-{cHmv&+JGd#Jo+NN-CEawS*|j()m+cI_R?;Fk6Qc84fO z%7At>V+#qs3lcr{$sUV3{3HFx@941^Q*0y*CjxleeZQ$siu1uO?GSe=$j=dr`x89^Y2hZ`t4~V9S*9Og~d4nnI#QzY2V%h%@i(y zz%$7la^d$g#YcQn!-x!n*yUj2gQT5bxwrxbRd;VbjI!iDyN5zj4u7Q_dh@($I*fX$ zRq5(g0>dwB841@kiJ!`6q6452&V}?GCQ?iz98fWT8*)dQGlx${=t*9ZSWX988_L7n zyA!H+{v3(P6<+8|9sG9zthn^klU;uD*XbGxBze#AXwAw$n$nJ880RcZcBXKxJa{jj z`*~4)9ON!i5%*!@DJ*$AO{gghj+=6IP;qOQ*r=0!xU|-@IEf|KOVx zh+E-&T!&8*{i&Os8xcW+5u`ejxV<3-fU3oWRab5+d2SFlU>qBeV^ZNJk=yVE4x z{bvJLPGu3e6_`%$od12vMha+ESi2|IepiF)id#CmqIh0S;p~bs(MktS*Scyz@7tnG zSAbTo%mc>R`gaypgEzf;zjY;nwVp<0g8Wd!IF7jQN7cA%ViiF8*XKL;uJ>)yGXJJ# zB*<8_a##GG0c3|B>5GUkZ5Bv?p4^;b>_ zR^=2P=DLwkzSAU>^vjR+)6S`4<2As^dwC{Jbz6b8np;3?28?QYMtrdwDU>u@GdJ)L zM~nD*9aHMaqCdD2h6fTW4r;(>k4-6 zXyE>xLRDH`LgEnRwSV_1P7GO(-A-Q9tj6+o^B^()yx1@CWQ?!Ta)GpA9_EaM0cpuo zV&}1EBm5o@7FQ~^PE_Xl0X0|{GbY-7eE!J#>8?J>*`nEkDfCm9pDNe8)cATiz^BS! zk9T{g7s8mwF09}#p4}x3#{!^|e~0d~-IuK&>J`G)f|uv!{l52HULd0>i%Q}7QE%|J zx$|vuoR z=u-*aaW6%}(i_QCm^fPM z+m49$Pk;`SRHhO;`QT!*uW(31!0^gPm z8ht6ROcKp@xp88@RE4$ga5TItRAz~K`=3WaP1SgW3IDcu*j4pL@cTTGQp#AAQ2)4$ zzu+k0#=ESY519oP|8xxj40_*AI}|n*spxvcgGG3SC;y$^5N^odq%`&V2kBu1;dTw>RRpC+ z1z4XrIs16NN~WQv2bS+8u?+rYHE%clm$l`bI2g|8iEW`LBe42 z&eJPyFueK~>7P@zP^HGmj;L}k9O*^yHR3!oE8A55>4z&_YY!RKq}XjLuf!aF z_*CTBm=`s)L!5nK;s2b^8n8JP@{P{+pURDrVj@iuyz z*iIH3U^uRX64O5SH%?4*rKQ>cet{rWok`AKl%&hTGHvxp%0bNYPsFRiWvP z>`;q`nMjZ|X@;8=WUU?QsW0n6JH_b9^`6=&{vp_=eS~f*q9QK;aEBLr`OUC+@^Z^5 z>)LYS;8`z?}lQ!Cy zbrz*b=Jfeh9iRSp(sQ`$6t?7xLPqLl=g`3=RrjhXbcbs*xH$ez7DA=8ZZ1P;8oEwV zUePxTP3CX~^Jc!}@;!gwBIOMOhu1rj8gZ`XAdnE13#!7hK;e^%rqWWvz* zR#u6!r+lf^8lJuq#l~^dvoCFKEVwd5L{0AB!av#Z6U=;#KH$TUePg~6Q{Z`8Wls;q z3G<{>oV~%5vwR+^C$ZsZG$KW=&Zr}6rKVl%^?hX&P? zL{Ir|Xw64bW4!D8@#$bS_l^qf{P=&e5+|m*HA*2I&2dMgGXr4e#d@Cn(X#b9F?)vp zpCDEc5C>XMTelwc$t`az=Xk59*rC4t(C*5`a8}Hd@oXEBf0$w-d$z48uDj?S!QN#w zoP$6%Cw_rpCE$?+^C86KJl$A_CfH0gdfar}eF9GMz`u~qF{;yz8Yc`Dy*Nv-p;26P zN~^B*2S+;5J1@s`AH&nXTun+xS9@H1TU37jzoj1G&cmmg`o~sq=?q@i{9*GWv~SaD zicT6tmZ6Y7m|Cx1>FSs6H%=v@r_}Ub;>PhujQatf&xKSfc=(sF9Ah5-fR>)6pi}vm zaU*icrYZ~9R}XYp(<#7FZR@1T2aDW)9MpAHCgP;lE{xoSi>cKi>}cX4Iz3JiN5ifn zL4xOOxL1}MYT_19k_^@Cj}mFJbpP!SOQ6Fc#QNdOGTH$s+aPBAm@$FW7&ht?R-Ry| zDu=~z;D%WkT_ASziMK|;@rsp!7u3Hy3aFQ9Io#V>9bYauzUI7Qo38Ll0CA2ADRchg zL7i?i$wXnV`7tbwZ0Yx2rjK%cWiS^6haU)Xm6g>8CCABG_cYCXn4g84Xc2itTU?SB zzbhvc0dua(X=1D^BZDfa1#%Huk=mO?LGLKcw9&DO6N(er7e&jR$7DdSIo`ceH|B0i zQ1~+@{NUrxxcAsIQc#M5$e2)_H$FJGe(pU)m7)VGY=snqI@X5TkLOq^74&Uix-Q)zf@2G1vT~*xf(Gy zIWEV1143rFpWpjkr&s6qXOE&%$jv@M0h$uyEu+UznLirvzf}jbF}th^qrcQp-eCy* zCB+9JuFFSJmjoQ+5NV|6X>a)}F8dcp4K0*^{6LVvr_b8Lm^|tlaz9X9U67{YZuqfp zb`IZewjy$fRNYx_ILFQN5i+2Q5}rslz66Mf+>pFV2DU7U>_~!s_0mQ18Z)*JAsg6j zFja@xk(s2-zlP-;l>!$^!yx`Hf&!#m{v6z&mHqsF?K25cM)G{I*A*N zBYgmg*{z-m2S69mn7mP)>_K&2n^6F}i&zOZt2?-@IsM2y6~RTT)UkLe)gobstu)Xc zyr&NCquD9GM3uP>d0?uI&8axrmq!Czo^(AzAP@OWT6^2WZwAOsbmvNbQ&Ue#!mJ+T zZ_LKWxZY(^pC$(rWf~ZzUKLI+k?C<$Jo!XDCxrIjy&aI&UqO(IJtLLc(IA;CYc@bp=Ys1V1c zWy8QX?Q(P~CRDi9*;v)T9e%A2T>QhzU=n6`!*qp&=^}IZiP3$a^%aqT+@+J%vK5tm z#Yb%r*RX`+X1%!`7b}O^$3aVA2LM`wWufLe6Yps9GI!-|r(%1Z$oq0B>6kzlm2ac= zb^NoM)8uwM4jQ0E+#thA1C5F#9<1p^)w)zS`Ovqph{_dZ z52kMyF^A_`<3pWxIQ%yeX41elQjW@^mjnFmG3@2JewXyXLvr_5B=w6shEbz&p*wqZ zqEUY>XGvF%rue)mly*GKCHg~qsg>9#GByg-ID=fLE1aP>294t;9-qa?SFS0;))+y@ z%jH08lFNG=lq(flTB6~kT7UKjy3K?g`G^L2vf4kF5MjUT8<-~r0<8w{^o`p^9|1L0 z69xDryUs@sf9H$e8^5BTL#_DQQISr+t{trFiin2OpNYqpwl-~QkhhTLHTQmYINbT_ z_zPC{lqHJ7V!RTFY2!v8`c*CO7&Z7I@Sj5MUc$OQh0f&_?V%zC@DrCs+=*&yJw{m^ z+MYWkqn0R<{?A7JZdy4dQv$80G>`72_W~03f$=07>*3{}!3yOIFITWJa^MZxnW2M`)ekn| zU`%dABaJ|bDfd>O9YzF&D+eZjWf=Nk-g4GP!JH4VsPRf~%uBHXag+{a$AM;W*|zLf zU=LlnFVx~_ltC;X;BOsDJ=unJZHNgriWR_0PJ%2iKroYLENr{%vu&|R{B10-Jm4ed zEz8SDf9b!z?Z|*AKCGP-TR^g4L(I?i<^Y{OU&L1$C8u@<>d!xo=81X#So0#3AStGs?PL*>2EU#tOby!E=E~cRnM6FqUy%c zd6(ho$-lr^v6|Z{&oO};cU~P`_GR_Hea_LO=A8-#=Jlx#fmfa$Ig&-!u0GgqH#W)c zS%;i;>gib7UcvaK+M(c4Wz-=r`mlCE+Rdjka{=C2^p3GFc)jEmZyIS{)`zDOt0Gci z;rf)Cfm(BMn5*830R zI!KU&vCj^nOv+IWdp+{E^Ih-Jx0yM@b`(Jr*8w*l4KAq52rH2B#+1_@9_awB7x-hS zVs*hzVv{~DiRy7fNB)-%rF+Ooi0V2Aw=wRlJN0RQhes}<%&DLIC$-L8wDBQt3h%j8 zZgC$sH#<;3!AB(p>n1AsgS=q8$?|#I?`}Y7#$8k-ANuIl)m)#5BPxtOj{ym;f;cVN ztmYiv0yQqT*;8Ewv>rGH2>o#GUYs~@ya^~3b`=>xZ#Tk{q-{!$fH9&$M@X$QpkOPa z?xb&nBp-qABCZfD-099*y(5I};gS5o0Wt7lgAQ+;l;Iqv9|or;AwY^if$>k&DWJk^ zXjmhC9jxTT4YeV4lRV>_0o?+-S=Ga3dzH2ew`w0h((_R;`GDE=xe7PeRv`uBVx3|YcD+d7mz`xhyQzsZ$#%^q8;A*!tz;D@}S4s`3DDH z8Qc%PN^9Bi!Yzq>NLbYN5+s(v_SxsFJGmOcxg=A;a_)XUbHuT$FNUh-vc&fWHGx88 zkc^^r)do-}LlWRm!H>g0l=`W*Z50(HE=S zqHN^=vF1i@$yNqy;hNAuOWm!d^CKZJ?f3&)A8(&`3%!ay(17! zzyJ+G;Y4>m#@`h5r!D(95+Pmbkp2ax1p_kHI{>MPm?%yJ7f5)pW2HHSpRIuaT^gNv-sFf6>Y=4HowWHSO2}e%?>z0Z%qboRl*n}Tdw0V9_!qpb6p{HeH|2V4}nbqV_xhT4GBSxF|Hv z#E`{-)ybBEPucghOVjF)u%S4pAg0BQzA81^vsX9-Ufe^V^=kSm#gAn7MNGK_m)7Hg zcCi6S#Z*_DgbjRaftHjj57}YqjP~QKb1K#!W)&tLB!vt1st8VXi}w*mG-> zixrBnZv#)ty*O}lD>q#~(?d$%1?gw@`SI+;2>+UNUetLAI~s5D_;im_Vct6%T*Q&! ztc1)JWq!GTrY8$1+}G&>T3>Q?Q3l}lwgBTb%KPvr9esj?sVuqm%NNd&)}l? zTRyDo6RXiHi4$f8qWj|Iwn8>(EMK6qbeYUq}SW6a=9MVFpc7#Q|5M!475 zs3By@g6ioP#Stp79POIAWa(fbZ+N=;B36l8kL`8wUCV9xs=UtjW~{hw?0Zv?@# zvKEN(6^1?sJvMGCoOpHckXR(q8cXi(Pu_tP#Suz^Wa=R|_Pqz4XRl77O&n0NclwYP z;(_h+8U-Mbo7Bhv+>&C9M;o9tR{m*P4izL_?_N*QO{A4IP-6^3;3ddybD(AlWPl7W zYMPH-{c;B%ljJ|cT!4Tr-<=AxptH8}tz<9sYT&qztHzky`%x{FIStb9)lcM*OMH#Y z;X>6$pBfmAS!G;W-Z5=h(N2D+RnDsC7s|kCt*0^1mdI=W<0sH5qkq+9cxlw*j6RLh zfs}hJM9nFAzmy1am1Z2bn@B;QOFgB~BTJ5(#yTShsdRs9C3IYd;(kKU0%2Vr{cDVn zf%L~iT+oYK+Tbm!=e#Bi_kX$%mg#!SxIc}H#TeoyDBQi1g+#V$vlig<{?8KGv3bS~ z)j2-rid><-K4j5)4nv{exYzGltph~|#gKc4iq3zh*&x=te%ARD`Hk^Y-SD%2^5iu8 zO1}1F*>~Gw=W|@^veg0VHQn>$p!ywg_Ud07-mMH6?=i?m;?Nl7FN5{{r=G69^lf*s7)=~PPAp=1i}(oo9RdG- zFTe^&ws^$hf>wZ{q3qL~q#e$2D3uFy{_TV`J_K6j7DZ29#Zup~=$nK*vRH^CmPm47 zTA|Tv5u*}k!G>EE8%Xp~YdV0)7%%(g#HdCC5VJw~P^@|-g`PvV`$G2tYe#ynB%_Ze zlr)sXoQuf=RPifJ_;!t5ND9pm=pm9&h!gLK@_pWpJuX>oh^!Kg2_z9W{KbUwb%w(F z3!utsJW>j5A4W8i1_gN2-!GU9A%|-E9Y1rC*Uv)q(~$Vr%8fJ<^A%0%TiCps?i;w3 ztptbtExbX?o!xjXuwl>aySvxO{IVGO+8>Ci`wkHZWg{{Hh@Fe9*t0%jmgm7CXC)4e z*?%2UMl}GY(3=x!72d6`^&GRc%72Fu#ZBxOkNRjp;WVv)snNC@Wu2WlDKLtNHsq)l zzhU%rCqDG+fvp<^Cs1Sx^>|pMK&z-qgct?TI;bEjgZmDA6Qp@_qgu#lcWw+JiY45^ zAz0uG2a~J-9Ud>mE%F+BWABsqvwS1OOHEQ^2X>~KWFv~Usd4}#$$G2 z>&L_U-UKzF^*QNzAIKm6$oOkBX2Gs~oo3YlQD_<(5ZhU#T%G@YuY|FVmU@5_eRQs7 zr)hjn`U3)=#Cz>fO>Sq$+&xiCRTX*+jF6L1bYPk^o9Zibe1`f`zq2q*L^Y-e<8_i( z(12K;IE8W~kdFw$-|wo=Q{0_vbjW~cmK--V+?l^a_5Y7~elGJf)l0p5n95ol#2}f- z!URSvDz$O?M)thlwjjkJW?Kt(3{k#c=iqC$bM(K&>g0x+$)QK|Ks~7^Z{6KHbF2=H zcASXskfW1vmnlSo7K?w&GKj6`JfN1{Soz?vnJpCJSH(qOp!XB>g9W0*_s5QgG)-9e z1EB}eA)k${-QekMrkzJ@(Gy$^=x3s3)&hudGAJvj8J|5zw&Xu*T@-cywuuf70srEN zUE=@zx}KlD@F{F%{Qx zT${#eb)a%p!69=@gOh!DKoqb+P$iJmy!`&~EzM4PeZRpBGd@X1q0(F_K2c8py-Pef zCt4qDXckLL{%>|702Pu>3LXsKaD=V#jz#1PU>|Xaz;TxW+13Jg=jGvMi^o7JahlBP zU+JIM=C^Lc#JvYBl%^mlYZ`1&f~zH%E;6-`GrqNyyQTa;)IPfDQ@{Z?Vk*RGS#aMO zs{GAX=il?6k0VkVf0su+f^^lGo?h&QxIRiw%k!N|A&<81Rj8j=m9?b1Y4ckgL2iZezUWO;UM0`Q*(u+?&9(tPx@Tq={wK0JoUCvR?ml+IY z9^4|#{}M#dTl)=!&M@41j$}^#`0Fbrk0APJMuJdNK50zCP)3rz(&iQd`>mqatnr*q z39KcU+?HQO8b1}q6TEQrNnD&r@H5t}Xxd<#{_@C|eyhc#Thx!HY8Yu9-N-mFCZC|9 zPLII>o2Q+F4K)=5gmB$Q%PXmz(k*1*?1jNql9p$osCwPz_7>LVk*)ugy-AdsK^HV(pp;M$ z5Iwjvkxl3=d2}?sD%M{ovq=p}%OGDy7B=tgl9;+C#0-x8mhuj{&VoAxUJ!lr8_+v} z0y1NM5P0e6=Rjpp4Qy1g$(8pS{luKSc;(svFYLrJbndG1e* zlq|wg)Me>m>@d6Gp-kY{OTtiDnB8vEPHT}13I~25W0K$;!JU;*|KmXtL`1i`!KjMG zT*bL96*EUZPN0*=66!E7cVvd7M~>>QQ!&)(w(a>~`j!uCKDsg7Pp;=6c6!QipGC^{ zzorsY1H?~^<&Kig6`qe#HQ~VaNEKO&GA6gOw~l zn*H^21_{>rgnv(n;r~Z5LNgeEEq=WRj?#gyX&1UF8obYg;S;iyySE#u6~iD^I-|Xa z0(V2`ymtKxE}uS(rEuY%Key;x^cw%aMSgP#F47O=MiC0{lwBiXT_-Af##)DsPP5C6 zD$O_d>klN><^=3cP8-t`dDtBpQ6wqIh`*%TB^4&9@^US_?wS5G{UTD}tRVsxV#1-8 z9L^#7m6)UMB##v6C(~TRuM$r5HDb=|S8I6!)F?o>lg)XJsr7k;K)pd93Y%X}zr1qr zAxLmheOvI{3hoi#jT5L=bk@r7lmluc`1iAtvmZ3(B774gnwFc$9_X0Xj_F#vwdK9 zJ5)+DY86Pqx^bd!l$ctQlIcoea_E<9AP|w57pDJTd*A&}_51(Nac~ad*fWliy^@)A zP@${}*&`#Hglxx7)@d1O5Fs;LI2RpI_^EKCkC>J+8;~ zxE_!D{r!;E1v~g_@Nrf=+q6^p_-!;AI^>DN@HTw)HU=M^Oq$|9lEZ&L8OPCP+ z-49j^D~U~1DJXZae!VgY79u>v9u-sfs;&*H#;zXFWnBmG~XsmnVtO)j*vEB1{ z10PoR7s@@|pmF`jEQH<&Y2a)n+!!V=rOe+ttKlK$gg_B*Z)%cbP4h?j`xHD^Og_7# zCvt}gb*_2z`~T{rg78`9rpe72*Q9{2A_fxr1n~PduYV!ur%cP=EB#jiJ12^n`Ft6- z&!KieFmqdg9QmGVo@c^_I=G`u68r0{g4Y3E8<+7r&a`rjXGHBOk92RaKe9F=rsDk% zE8#|;!-eY~g6V@6GVX#bKHmI5 z)b@s;cl$6gG#I`5A^7t-BuBmA^Z0wP=8u4W8Fy+h{DK&V9fK9pvC~{IUBh}4cp5%n zHxZsV>Sfa>tf8-!dP;v*Ee?V{-3Ts_oluYEVKFYNNQnY_$j*6PS!?JJH(K>68xM5` zOIb?J14jlQXX<3cv!V8Uk}t3n-(xa-9scDO{wt>oaQ~c z_@Dl08iRV8HVa$&u8)7c9|gv0TJ zGOfIO1{+9=O`$0k{B(j!p${b0Us)+|b9bWi#YIpP-jSPpk)!3)@pO-as9mMFbAZL` zU(_ztl_~86on#GtIzjs4)2@8pTmFLcldYMT>|b_^7X%g3H#tx7rr|;BV4)rfV&N~%+7#BHNHaU4Mp#4MIi|oJko0dDbz0p>?+uPD)j?9Q6Uw~hR{i~jK@l?mQwzk5&Yxfy&CnFoA4JOY3i zm6v?zmg*@*46xq>p6c3jUP(46bJILL~S|@Zr}E5{Pte@3~(~ckKsq}>dL0x(nMe_$pZNC$gmFdo+E{m$RWwG`(w=v2=Ruq8f2ahBJn zwCzbwY+k$=56wbQnLJB(0eel-tOzOlRhK1JSKf`M;L@UTqz(3X1z z-pzvAo6@=`FKe^G_9EBs%UPY-2_WMdE*|6=iqw^i%MoeBrTx{nY$oe_&_9DL0rxQc z0)iZ8CAeak9=~PPu&*qknSABaNkH@4{_>c30jvRG@@`%v{L~V1xiF6WHy=$M86`Oe z2d?@xb^#wi)oj9mMH=XY{D=9HWYpN=QNBW;X>XX9{hI=LG0OV=4J>Cp-Oao`k+WIn z=f!;3=I(JwIYsFW!ZaBqT?yd&z9+RY?OS58x@-210%Mvd`<8`_C*Uc^yKS4^nLEw~ z-`IhpzwF`GOYTX~C4Bk`R)km%QWhCw2l~yeR-gL9%c9xrmR#p8_2Avx^lC_rm^&FBN0LV`YVS84qQqb3+?S8 zT6>AY%v{;6k9yCTHGAK|Sc-U1tWNhh_dPG*U?IgTP)#{Ioa{<`UP8uy`Ye-(yqn+q zGar_0YlvxdWPU$=C`uyKMfYE3U0^*7*&&E+`aMr97sbL`cYZAJ!I}MofOztXIvhC? zHkK;k=Qs&2d&_TUS;>8?i^3wRn#bJFfSS|kxB>Ie-&q%M z<|L-qU&lVVzu)+B z6U1LxC)EC&dydae(^p9Q7ed>^n@wpiS6P!^mof5m_C>|F^Pa^rlXM=+p4ljxsH#h~ zwRRSSvj80n4$0B(io@Pm+rXYAoA0Nn8Duu9mvxSx+him7z<3bSE)?ybp8WlG0`s}` z-aB7!e>wp-5klmkrBR9enHQh#4T$RC5zX%yXT6pp;rDa4TOg+klLdiKshgV$UI#fj zOKjjcNJqTVh0wd#aPZbC?+`^+Yl^Y@q@Css<)(YmQIzSNzwA zN1z6arT-qb-w6f@W@&N&oRlUb*p$N_kwt1o4o9|@JN%QJAH7jb0X>#w}x zes`Xq*KAGLk`Xv@MohPm4|aA|rM{`V(a2X8ITPCER9}peZ?s5N&~v|)dcKtI zov~Zh8H%ihaaGANRr)&nY$tNj^f5tc_d>7}7D?J;L5>V&&0@>GaN@Q?3n4M?FV!ZZ z3xmXuy8*ot&xP(yb2A2L3j`;C;Uwlx9jYnv8>L+wvrieU&-$)8##Z=Q|O= z8Nltk4tnrC_pG(o#H%EJv(u55D8kBXsl{!Od)c z)k!I^m+z!P+T|KNEL*N0i$0nnku!c`e|j2=!EC8G8keagwt23~DnT)jLa+x2ew1B> zt_#;bNIK<)T!z^;SP$-h&u|bdxUNFZ%9yN%l!UWu%VouEK19~#?9mQM4uk@|5 z)xfJG$5+aTYprz-McbWNMf>UVXk$;It=hy-qZ9W7!nUV=1d6&7bZ2jFcXmQCs)dN< zC_gk&OO78oea=brf-8VhoW)3V7eVN-&UOmFK^4=nPFMANgxGysQK3G1>w{k2tKD4|=D8U@xQ z=|VtytUjpF&J6aD-(uRB3Bu@26#v@p0XZcQh@ezLsey`w^D#j-vC!hiJ1C|vqCpgC zCDx9JF^375H?xR+MiR(qi_S1|z7)A& zo~I|za2gcoz|AJGwZLm{^-EmtW?Rn4oJ{;d&xxkv3o^1QzaKji!g)Bk(R^nmTtDwl zEI#QFN(d#XzDdcbRJVWg7Y>2S`xq7I&Ur}wH<7btANj&*@GgG1$PygE4EG$-_(^D@ zW5&!WV@~j4Sa>GtT>Jx1lZMCMw@dyCpT@>nH0?)|>iyAi+|}2FZ3(mP=0~teNPZsH zQ!5c1EW8SYCU00_dD?ax|5%U>2B56aq;b^I79vE|?77*zWiaH6)aK2wxHZ+9Z56>g zMXmUA7)2JU;`WrNUfy}mpp^)ybUoT1e$BMvc*xI}?=S@`HoL(9DBQq@1k9)iUFh-4kI=9}KfIi2(X0;h2^HLI)uu+VROt02=l32PeZRdP$tpjZ zTT~E*e>X23ldZ2_PXITTgMw0kFpF@lENvFh{GyrUS*nviSbMjc4D6lf;7c}gz3U9P z*jKDDtN(`g0h-O-UwM>`Z<%^jcB;5lwykEE^KDlDgxwANE(%SBUL)h6R_3{cao=cG zC2$;9<(#_1WXtx~rhWaE|Ie3|C-+|mF7e*nNZ%o+nM_S;<-#jK0`Yt~qis54FGsCc zCWW`NnVq-cJtKqjcW!`H$MJ?fVP~j6W^kJukie{D`_C&pA?VE@4BHAjx7ll;@sqN` z{>91OL*r1of7B?G+1q7q0NnX&6T#U#X(ZQ=Ij5@r>N^{r;PqpONowRMnU#|4Ql_qb z(f6^F*5?hgH4La-DnDLd5tVZov<&bcrG`30K_)@FVlku*#asgaw>ucbMDF z;98JyCoigQ1*BGFLCmmg(56L}LUOL-eDAS)bBV#4%r06!xM68|IZ@N0+*{ai+|t_9OT zQM%k^#mTeysbJ<V()Z~c_yaen9H_fq#&nkQ%G$t5xgXtFqir{a3l!bucUxF4P;6}f$S!QO{{KF7gP z|2gQ9%y6>8_6yVT55$;i*RuMz(`dJGrHn3CQ={|v&!(eCC_VOcGa62((vn&9+tGw# zrnPPP4fjcCR7cZHfkY6Lgpo-iu)vjJ3!A7C9hd0&3WjVnmDtcL2&Ho48RNj{B1 zgQ=P1PWDJhw5vOo!#eHvHU%xJS8|{^iX?rR;2!ZqOb~JO`(YUE)*)@?bOXY)d`=jj z8Y}~nDIzyK&a>`4Xe(p8<&Pq9{CjRC{A;iI%jsD~je#+sKMa$kV>)OSbYy?0-9t%{ zUo(~m#kg(!y7P)Bq7HxLRvt{YnY=^L)z;#A2m`cRQ)lRwWabw zcIK0)yUSDBc41DQ1I8<&cby4OY;i2n9Oa_)Jrtov&9gopyUg4_t1A4u-n4qWt!Nm_vkku; zpE7Kesiwt?KKDINY~{*RRtrwsX_j(lu>Kl6;zOHrhgIG<_r(TH|95v~R9_d;qUNik zjPnpQ*#f~M7&^7tyS*cLg2Z#il*MZ@tx^u9(gK$KCCU8g7Pq%trpZ69lnLKf^z&al zI>Luug1zkwCuHL`C~xg|Z(WZ9t*-`#C<1{6W>3eM&we`FF<2f&ea?l^Yrwrtm*cK* zsC^1q-8>qb`cgXn$|t`Lz0h#9a8zD;f2GxVdQzmv;ZRirgem#ds+8#`Eb2edY`>9% z7=f+|`%?A~5A0q&bR*<(A6uGSGvs^Qr)+a^rh`u#HE!@EB z)q4gaWUy9gONygZ|04Jc62BiMQCouU2(%(02Yb-w4zu@o!YGOT6AF#%% z8m=?zUv#}@WE;wo@P~{EK;*M#muU+tGgCVI~{om|BJ2pI+(JVhl3 zT+^mDopylzt-kXn@8BHgvF3(kflY^z6d^>DID2JNV|bl2kVkRY$qh&b-In=RN~?5} zQI&l`#Cq(4(k&Oj66gYAk_FfaeElFtpXz0~X_*>N-(x67t8l>Aly!&O3|F zN`>2`zb~MWdEmkPRFA*uKXP7T#{T_HkIHslTbF;en0$xBXE^32OwMxeS5mr6F3@zg zV?ZcaIAF7=q5~r()}IVpj}c=i=eP@Q!I(4XLzdNg$tR8Ct=wT73=Yh3igfz$TMy+~b$Rj~nVT?qCa4hXFRuR!hY)WijHPAR|ARR2W?LWI8+8BN4 zp*>-^oxKMX?QhcLK%Yh-)Ji8QIT!k>a$;vb?1_VB1W&mfR? zO;nj!EJDJbetbKsAOr+apU2e#uA)&yK-PMRRO#)rH#VDrlCr+nEFuXcJQt+Lt3swi zOx3CW>|dhXy_?3I@v$BYJ{APTcns6ij6;*9X26Zo!}Ac2WN5CXIuccE{c_pI_zMkE z&k*O4Px0n2$K>4Za8D=i2%rkMypWg>4J{YTE)MuBFj-$|VQIMiZcp?QEiKa(pw2yv z>1d9&?qnSsu^j_2$|b|>aRFq+BJ_sBCo%gAZVG8SRt@Osl4jw}iXQo=@+PBM0bmS( zK{_Ea-BOz-e0Z+o?Z*4YK4v#o7|P{ zU$YFCfm-}9+ByWq_iq9)U+$}29rAMz`W!U||C;!k;KZeFYPE!c7}rRxP5FeCU(*j~l>Yju zbJMwLKEC7&L?g~8#YgB>OOETF%){|jVQ&CT15F83Uo~&|&+YQ-Q!_jOQulI#_v-IL znj;W=8+WDt1*#-&{(B4ZRe1$CRd}2L(u>aR&QM-cI)h9FP|@~YZyVVq3@-o6mo2*F z6e-nDfs(pF{>BWonyW{kR_Aau3*aj!l|3>o22HCnG!V7wjHn=vnjhT?^B?fMcqL!3 zIPwoI;<8{x>cXuHMzOCgKzomR=nukkTUi}qxpzb_V!2*~iYVowdv1QRBayG#@m}xT zy!&q-Oc&7GcjFQ7kD0<0@ESZg&hiY>ZJ}b5{#W_b4HBkqx7X;ec;M^y%fDpD2JFU* zG7qE>Oo9;w7GWCUd45KboF<^l&D=+v|1Ne+V!PSsFnT@u{=R98j@G6A{YORee1eSy z6~J6ao03AVqhJ8a{Ts+p_h0&P!$goP7I(PfeNe2vVmRl}XWP8#594qpYcrt)eBtQ7 zUx%+-RU>{h_2hY3m?d`a`TVL{8zNqgHY_|A_9_>7rc!*d-fN&TuD~@1%n0q**&t}t z0z-(EWKylJ+@Vo^hRJJI-27{z%Y!%!mc2;myijM}nPTi1UP{>x<9pIkuW4s`0KFP&}$VU+$uCByKI6kJ9Zt2NG z70Noe?*%2+tWxLpxO83x_QdrB4q8EQQk&Y1yY5kI44QAGPTgnSJ9u+AH)F*3%RFH{ z{e6=7oJT$>J5}-Z7csvifnBrQH8n5}wN`sXetkssLwWfsACtUDEX<X z^7TxLpNOn_2Tug`x``71q@jNoqR4WAE?m@Rb;dBiW-1PD5+J>h0z`Gbc9}I2Z$v@e zk!e+$(=V(_zXg;z5YExZHSE^WlM) z!QmfD<*4VDY8}lzMK{AbR~moJIIjMDJ9BHD9s2FxdL>q|8+4MHbyo{mjl$6}=0@|? zTj)RJsS{YetLNvK@OI|c`w(pZ`r0Mox=JZ3EUnn#p+vD&M!F?!idFSMbCKo5Zc936B@(iv;uW;_u%YvGvBG7p% zn+|a$+r>v-8;yd5{tb)PUF6NZo@QhMVr~&9KSw~e80-(^XuYE0apSlOI z!uC$C`+L#22;I?hU7lL1;Dfx|1_OCfyeQY0dnUcNtqBj%9ecB{U!Q+uw7l$-W1{sF(>>v}i#GC7P3zlq=o zCd27}2YYXig9HzH6HOBAJpvAgLZ;#?Z*N88&KzkJLyC0795r)I(GpuIJEU*RN5OiZ z$3L;O?AtUL%_>Q$-yzgY@-RW8VZO{BvP+z;9PR&IA%uD?+zI@@lnFWeRjc_)OdI=? z&118W;~l@}(iI#)-IErfaDZ^Z&X>;K1mn7C(i^N#U)_*iWRtW~q-0=zb2id)B8d75 zmkh?0yXOvSwR*e3oIT2HV#}4nnGazK7#$Nzm7*!Q4J@bblC{=@kDcI#um_H|#I0|! zhmU(+_5n=n-OMDWT9;5|b_iK|BqmlMe+%%B#Qj%g zdrX3deiFPihADv8IfbGK2{~tpTcVQ(-LY}T={Vsq6e(fG&#PX|g_rx^86=!_E6UA8 zBiG34IZe*z_vXP}7<~_ZOs(y-HjD%p9=P)VDOrlNy$)f7LJ=2K!Fibj5YT(i-DjN2 zWFdCYS5=c<{R=m+xXY3R>^3~TT|_Jj|TAq z0>gYrFPANeai>c!;BYpeZ<;Wc`V8krh~<=T54`G3nxqLBX{*9s1;=xdYgMsVRy`2Z z4xkr+WDC&Z(}je!e>3U1gm>a)WOILId)6k`WzqHwkh9hhydKwqJ~)mqjtzC`OD;qy zEg;Ws)Bn|vOP4@L3P}5X`3E%oTpk>%gAKL$x>p%whg6p>t+vCXA4u&j2JA1sEHL!qczAECL{)Z@#n#{b0Q6qF*#j9FobGrYZQkq zB*O+1Rn7_(1*?`EaUdVs2xSj%-`ix@jV5&-3#(nn*=PcqGIJODP!G`W zs!1w_f(xOIntL?JRT+)a0}XFZx-C5+-gtb)?flra^ba_e%Yz*4b@4m<651CEfV5y2 zux@;79wgvDSx`%y-yl@p3eismU8Y6a41ap%ey;Qm=R1CDEGEiZ5L8_Ie=G= z%9@ggIzm4$&>QPMd)Qb0_WXs_F!Us)oeCg$fRDw4*paJdR(0aK6VI0HimX-S2xTUp_`uEItqLW6Gm`o zf**O~8`>0b;Z7}VT@oXNguuXN=VyV#*y7U*LCd>gFoTO*CEh@29wF|MPqdekZ)VAK z{3k?>fFg&bENBUcsh$evgCe(;Xy{awo8Md8BGBfjN>`C}+uG*!ut(kv+oRWbjX)?S z)pV*JG{?D);+JbpzHhI>eIh#mZ2~)L=4A^BH4A outputs; final BitcoinTransactionPriority? priority; final int? feeRate; final UnspentCoinType coinTypeToSpendFrom; + final String? payjoinUri; } diff --git a/cw_bitcoin/lib/bitcoin_wallet.dart b/cw_bitcoin/lib/bitcoin_wallet.dart index 7135d0a7a..a23b72660 100644 --- a/cw_bitcoin/lib/bitcoin_wallet.dart +++ b/cw_bitcoin/lib/bitcoin_wallet.dart @@ -3,22 +3,33 @@ import 'dart:convert'; import 'package:bip39/bip39.dart' as bip39; import 'package:bitcoin_base/bitcoin_base.dart'; import 'package:blockchain_utils/blockchain_utils.dart'; +import 'package:cw_bitcoin/address_from_output.dart'; import 'package:cw_bitcoin/bitcoin_address_record.dart'; import 'package:cw_bitcoin/bitcoin_mnemonic.dart'; -import 'package:cw_bitcoin/psbt_transaction_builder.dart'; -import 'package:cw_core/encryption_file_utils.dart'; -import 'package:cw_bitcoin/electrum_derivations.dart'; +import 'package:cw_bitcoin/bitcoin_transaction_credentials.dart'; import 'package:cw_bitcoin/bitcoin_wallet_addresses.dart'; import 'package:cw_bitcoin/electrum_balance.dart'; +import 'package:cw_bitcoin/electrum_derivations.dart'; import 'package:cw_bitcoin/electrum_wallet.dart'; import 'package:cw_bitcoin/electrum_wallet_snapshot.dart'; +import 'package:cw_bitcoin/payjoin/manager.dart'; +import 'package:cw_bitcoin/payjoin/storage.dart'; +import 'package:cw_bitcoin/pending_bitcoin_transaction.dart'; +import 'package:cw_bitcoin/psbt/signer.dart'; +import 'package:cw_bitcoin/psbt/transaction_builder.dart'; +import 'package:cw_bitcoin/psbt/v0_deserialize.dart'; +import 'package:cw_bitcoin/psbt/v0_finalizer.dart'; import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/encryption_file_utils.dart'; +import 'package:cw_core/payjoin_session.dart'; +import 'package:cw_core/pending_transaction.dart'; import 'package:cw_core/unspent_coins_info.dart'; import 'package:cw_core/wallet_info.dart'; import 'package:cw_core/wallet_keys_file.dart'; import 'package:flutter/foundation.dart'; import 'package:hive/hive.dart'; import 'package:ledger_bitcoin/ledger_bitcoin.dart'; +import 'package:ledger_bitcoin/psbt.dart'; import 'package:ledger_flutter_plus/ledger_flutter_plus.dart'; import 'package:mobx/mobx.dart'; @@ -31,6 +42,7 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store { required String password, required WalletInfo walletInfo, required Box unspentCoinsInfo, + required Box payjoinBox, required EncryptionFileUtils encryptionFileUtils, Uint8List? seedBytes, String? mnemonic, @@ -71,20 +83,21 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store { // String derivationPath = walletInfo.derivationInfo!.derivationPath!; // String sideDerivationPath = derivationPath.substring(0, derivationPath.length - 1) + "1"; // final hd = bitcoin.HDWallet.fromSeed(seedBytes, network: networkType); - walletAddresses = BitcoinWalletAddresses( - walletInfo, - initialAddresses: initialAddresses, - initialRegularAddressIndex: initialRegularAddressIndex, - initialChangeAddressIndex: initialChangeAddressIndex, - initialSilentAddresses: initialSilentAddresses, - initialSilentAddressIndex: initialSilentAddressIndex, - mainHd: hd, - sideHd: accountHD.childKey(Bip32KeyIndex(1)), - network: networkParam ?? network, - masterHd: - seedBytes != null ? Bip32Slip10Secp256k1.fromSeed(seedBytes) : null, - isHardwareWallet: walletInfo.isHardwareWallet, - ); + + payjoinManager = PayjoinManager(PayjoinStorage(payjoinBox), this); + walletAddresses = BitcoinWalletAddresses(walletInfo, + initialAddresses: initialAddresses, + initialRegularAddressIndex: initialRegularAddressIndex, + initialChangeAddressIndex: initialChangeAddressIndex, + initialSilentAddresses: initialSilentAddresses, + initialSilentAddressIndex: initialSilentAddressIndex, + mainHd: hd, + sideHd: accountHD.childKey(Bip32KeyIndex(1)), + network: networkParam ?? network, + masterHd: + seedBytes != null ? Bip32Slip10Secp256k1.fromSeed(seedBytes) : null, + isHardwareWallet: walletInfo.isHardwareWallet, + payjoinManager: payjoinManager); autorun((_) { this.walletAddresses.isEnabledAutoGenerateSubaddress = @@ -100,6 +113,7 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store { required String password, required WalletInfo walletInfo, required Box unspentCoinsInfo, + required Box payjoinBox, required EncryptionFileUtils encryptionFileUtils, String? passphrase, String? addressPageType, @@ -122,9 +136,11 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store { break; case DerivationType.electrum: default: - seedBytes = await mnemonicToSeedBytes(mnemonic, passphrase: passphrase ?? ""); + seedBytes = + await mnemonicToSeedBytes(mnemonic, passphrase: passphrase ?? ""); break; } + return BitcoinWallet( mnemonic: mnemonic, passphrase: passphrase ?? "", @@ -141,6 +157,7 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store { initialChangeAddressIndex: initialChangeAddressIndex, addressPageType: addressPageType, networkParam: network, + payjoinBox: payjoinBox, ); } @@ -148,6 +165,7 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store { required String name, required WalletInfo walletInfo, required Box unspentCoinsInfo, + required Box payjoinBox, required String password, required EncryptionFileUtils encryptionFileUtils, required bool alwaysScan, @@ -204,7 +222,8 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store { if (mnemonic != null) { switch (walletInfo.derivationInfo!.derivationType) { case DerivationType.electrum: - seedBytes = await mnemonicToSeedBytes(mnemonic, passphrase: passphrase ?? ""); + seedBytes = + await mnemonicToSeedBytes(mnemonic, passphrase: passphrase ?? ""); break; case DerivationType.bip39: default: @@ -217,24 +236,24 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store { } return BitcoinWallet( - mnemonic: mnemonic, - xpub: keysData.xPub, - password: password, - passphrase: passphrase, - walletInfo: walletInfo, - unspentCoinsInfo: unspentCoinsInfo, - initialAddresses: snp?.addresses, - initialSilentAddresses: snp?.silentAddresses, - initialSilentAddressIndex: snp?.silentAddressIndex ?? 0, - initialBalance: snp?.balance, - encryptionFileUtils: encryptionFileUtils, - seedBytes: seedBytes, - initialRegularAddressIndex: snp?.regularAddressIndex, - initialChangeAddressIndex: snp?.changeAddressIndex, - addressPageType: snp?.addressPageType, - networkParam: network, - alwaysScan: alwaysScan, - ); + mnemonic: mnemonic, + xpub: keysData.xPub, + password: password, + passphrase: passphrase, + walletInfo: walletInfo, + unspentCoinsInfo: unspentCoinsInfo, + initialAddresses: snp?.addresses, + initialSilentAddresses: snp?.silentAddresses, + initialSilentAddressIndex: snp?.silentAddressIndex ?? 0, + initialBalance: snp?.balance, + encryptionFileUtils: encryptionFileUtils, + seedBytes: seedBytes, + initialRegularAddressIndex: snp?.regularAddressIndex, + initialChangeAddressIndex: snp?.changeAddressIndex, + addressPageType: snp?.addressPageType, + networkParam: network, + alwaysScan: alwaysScan, + payjoinBox: payjoinBox); } LedgerConnection? _ledgerConnection; @@ -247,20 +266,25 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store { derivationPath: walletInfo.derivationInfo!.derivationPath!); } - @override - Future buildHardwareWalletTransaction({ + late final PayjoinManager payjoinManager; + + bool get isPayjoinAvailable => unspentCoinsInfo.values + .where((element) => + element.walletId == id && element.isSending && !element.isFrozen) + .isNotEmpty; + + Future buildPsbt({ required List outputs, required BigInt fee, required BasedUtxoNetwork network, required List utxos, required Map publicKeys, + required Uint8List masterFingerprint, String? memo, bool enableRBF = false, BitcoinOrdering inputOrdering = BitcoinOrdering.bip69, BitcoinOrdering outputOrdering = BitcoinOrdering.bip69, }) async { - final masterFingerprint = await _bitcoinLedgerApp!.getMasterFingerprint(); - final psbtReadyInputs = []; for (final utxo in utxos) { final rawTx = @@ -278,13 +302,128 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store { )); } - final psbt = PSBTTransactionBuild( - inputs: psbtReadyInputs, outputs: outputs, enableRBF: enableRBF); + return PSBTTransactionBuild( + inputs: psbtReadyInputs, outputs: outputs, enableRBF: enableRBF) + .psbt; + } - final rawHex = await _bitcoinLedgerApp!.signPsbt(psbt: psbt.psbt); + @override + Future buildHardwareWalletTransaction({ + required List outputs, + required BigInt fee, + required BasedUtxoNetwork network, + required List utxos, + required Map publicKeys, + String? memo, + bool enableRBF = false, + BitcoinOrdering inputOrdering = BitcoinOrdering.bip69, + BitcoinOrdering outputOrdering = BitcoinOrdering.bip69, + }) async { + final masterFingerprint = await _bitcoinLedgerApp!.getMasterFingerprint(); + + final psbt = await buildPsbt( + outputs: outputs, + fee: fee, + network: network, + utxos: utxos, + publicKeys: publicKeys, + masterFingerprint: masterFingerprint, + memo: memo, + enableRBF: enableRBF, + inputOrdering: inputOrdering, + outputOrdering: outputOrdering, + ); + + final rawHex = await _bitcoinLedgerApp!.signPsbt(psbt: psbt); return BtcTransaction.fromRaw(BytesUtils.toHexString(rawHex)); } + @override + Future createTransaction(Object credentials) async { + credentials = credentials as BitcoinTransactionCredentials; + + final tx = (await super.createTransaction(credentials)) + as PendingBitcoinTransaction; + + final payjoinUri = credentials.payjoinUri; + if (payjoinUri == null) return tx; + + final transaction = await buildPsbt( + utxos: tx.utxos, + outputs: tx.outputs + .map((e) => BitcoinOutput( + address: addressFromScript(e.scriptPubKey), + value: e.amount, + isSilentPayment: e.isSilentPayment, + isChange: e.isChange, + )) + .toList(), + fee: BigInt.from(tx.fee), + network: network, + memo: credentials.outputs.first.memo, + outputOrdering: BitcoinOrdering.none, + enableRBF: true, + publicKeys: tx.publicKeys!, + masterFingerprint: Uint8List(0)); + + final originalPsbt = await signPsbt( + base64.encode(transaction.asPsbtV0()), getUtxoWithPrivateKeys()); + + tx.commitOverride = () async { + final sender = await payjoinManager.initSender( + payjoinUri, originalPsbt, int.parse(tx.feeRate)); + payjoinManager.spawnNewSender( + sender: sender, pjUrl: payjoinUri, amount: BigInt.from(tx.amount)); + }; + + return tx; + } + + List getUtxoWithPrivateKeys() => unspentCoins + .where((e) => (e.isSending && !e.isFrozen)) + .map((unspent) => UtxoWithPrivateKey.fromUnspent(unspent, this)) + .toList(); + + Future commitPsbt(String finalizedPsbt) { + final psbt = PsbtV2()..deserializeV0(base64.decode(finalizedPsbt)); + + final btcTx = + BtcTransaction.fromRaw(BytesUtils.toHexString(psbt.extract())); + + return PendingBitcoinTransaction( + btcTx, + type, + electrumClient: electrumClient, + amount: 0, + fee: 0, + feeRate: "", + network: network, + hasChange: true, + ).commit(); + } + + Future signPsbt( + String preProcessedPsbt, List utxos) async { + final psbt = PsbtV2()..deserializeV0(base64Decode(preProcessedPsbt)); + + await psbt.signWithUTXO(utxos, (txDigest, utxo, key, sighash) { + return utxo.utxo.isP2tr() + ? key.signTapRoot( + txDigest, + sighash: sighash, + tweak: utxo.utxo.isSilentPayment != true, + ) + : key.signInput(txDigest, sigHash: sighash); + }, (txId, vout) async { + final txHex = await electrumClient.getTransactionHex(hash: txId); + final output = BtcTransaction.fromRaw(txHex).outputs[vout]; + return TaprootAmountScriptPair(output.amount, output.scriptPubKey); + }); + + psbt.finalizeV0(); + return base64Encode(psbt.asPsbtV0()); + } + @override Future signMessage(String message, {String? address = null}) async { if (walletInfo.isHardwareWallet) { diff --git a/cw_bitcoin/lib/bitcoin_wallet_addresses.dart b/cw_bitcoin/lib/bitcoin_wallet_addresses.dart index 1a122ef9e..0fefe4e57 100644 --- a/cw_bitcoin/lib/bitcoin_wallet_addresses.dart +++ b/cw_bitcoin/lib/bitcoin_wallet_addresses.dart @@ -1,10 +1,13 @@ import 'package:bitcoin_base/bitcoin_base.dart'; import 'package:blockchain_utils/bip/bip/bip32/bip32.dart'; import 'package:cw_bitcoin/electrum_wallet_addresses.dart'; +import 'package:cw_bitcoin/payjoin/manager.dart'; import 'package:cw_bitcoin/utils.dart'; import 'package:cw_core/unspent_coin_type.dart'; +import 'package:cw_core/utils/print_verbose.dart'; import 'package:cw_core/wallet_info.dart'; import 'package:mobx/mobx.dart'; +import 'package:payjoin_flutter/receive.dart' as payjoin; part 'bitcoin_wallet_addresses.g.dart'; @@ -17,6 +20,7 @@ abstract class BitcoinWalletAddressesBase extends ElectrumWalletAddresses with S required super.sideHd, required super.network, required super.isHardwareWallet, + required this.payjoinManager, super.initialAddresses, super.initialRegularAddressIndex, super.initialChangeAddressIndex, @@ -25,6 +29,15 @@ abstract class BitcoinWalletAddressesBase extends ElectrumWalletAddresses with S super.masterHd, }) : super(walletInfo); + final PayjoinManager payjoinManager; + + @observable + payjoin.Receiver? currentPayjoinReceiver; + + @computed + String? get payjoinEndpoint => + currentPayjoinReceiver?.pjUriBuilder().build().pjEndpoint(); + @override String getAddress( {required int index, @@ -45,4 +58,17 @@ abstract class BitcoinWalletAddressesBase extends ElectrumWalletAddresses with S return generateP2WPKHAddress(hd: hd, index: index, network: network); } + + Future initPayjoin() async { + currentPayjoinReceiver = await payjoinManager.initReceiver(primaryAddress); + + payjoinManager.resumeSessions(); + } + + Future newPayjoinReceiver() async { + currentPayjoinReceiver = await payjoinManager.initReceiver(primaryAddress); + + printV("Initializing new Payjoin Receiver"); + payjoinManager.spawnNewReceiver(receiver: currentPayjoinReceiver!); + } } diff --git a/cw_bitcoin/lib/bitcoin_wallet_service.dart b/cw_bitcoin/lib/bitcoin_wallet_service.dart index 7ee1534bf..317b25bcd 100644 --- a/cw_bitcoin/lib/bitcoin_wallet_service.dart +++ b/cw_bitcoin/lib/bitcoin_wallet_service.dart @@ -5,6 +5,7 @@ import 'package:cw_bitcoin/bitcoin_mnemonics_bip39.dart'; import 'package:cw_bitcoin/mnemonic_is_incorrect_exception.dart'; import 'package:cw_bitcoin/bitcoin_wallet_creation_credentials.dart'; import 'package:cw_core/encryption_file_utils.dart'; +import 'package:cw_core/payjoin_session.dart'; import 'package:cw_core/unspent_coins_info.dart'; import 'package:cw_core/wallet_base.dart'; import 'package:cw_core/wallet_service.dart'; @@ -21,10 +22,12 @@ class BitcoinWalletService extends WalletService< BitcoinRestoreWalletFromSeedCredentials, BitcoinRestoreWalletFromWIFCredentials, BitcoinRestoreWalletFromHardware> { - BitcoinWalletService(this.walletInfoSource, this.unspentCoinsInfoSource, this.alwaysScan, this.isDirect); + BitcoinWalletService(this.walletInfoSource, this.unspentCoinsInfoSource, + this.payjoinSessionSource, this.alwaysScan, this.isDirect); final Box walletInfoSource; final Box unspentCoinsInfoSource; + final Box payjoinSessionSource; final bool alwaysScan; final bool isDirect; @@ -55,6 +58,7 @@ class BitcoinWalletService extends WalletService< passphrase: credentials.passphrase, walletInfo: credentials.walletInfo!, unspentCoinsInfo: unspentCoinsInfoSource, + payjoinBox: payjoinSessionSource, network: network, encryptionFileUtils: encryptionFileUtilsFor(isDirect), ); @@ -79,6 +83,7 @@ class BitcoinWalletService extends WalletService< name: name, walletInfo: walletInfo, unspentCoinsInfo: unspentCoinsInfoSource, + payjoinBox: payjoinSessionSource, alwaysScan: alwaysScan, encryptionFileUtils: encryptionFileUtilsFor(isDirect), ); @@ -92,6 +97,7 @@ class BitcoinWalletService extends WalletService< name: name, walletInfo: walletInfo, unspentCoinsInfo: unspentCoinsInfoSource, + payjoinBox: payjoinSessionSource, alwaysScan: alwaysScan, encryptionFileUtils: encryptionFileUtilsFor(isDirect), ); @@ -126,6 +132,7 @@ class BitcoinWalletService extends WalletService< name: currentName, walletInfo: currentWalletInfo, unspentCoinsInfo: unspentCoinsInfoSource, + payjoinBox: payjoinSessionSource, alwaysScan: alwaysScan, encryptionFileUtils: encryptionFileUtilsFor(isDirect), ); @@ -147,7 +154,6 @@ class BitcoinWalletService extends WalletService< credentials.walletInfo?.network = network.value; credentials.walletInfo?.derivationInfo?.derivationPath = credentials.hwAccountData.derivationPath; - final wallet = await BitcoinWallet( password: credentials.password!, xpub: credentials.hwAccountData.xpub, @@ -155,6 +161,7 @@ class BitcoinWalletService extends WalletService< unspentCoinsInfo: unspentCoinsInfoSource, networkParam: network, encryptionFileUtils: encryptionFileUtilsFor(isDirect), + payjoinBox: payjoinSessionSource, ); await wallet.save(); await wallet.init(); @@ -182,6 +189,7 @@ class BitcoinWalletService extends WalletService< mnemonic: credentials.mnemonic, walletInfo: credentials.walletInfo!, unspentCoinsInfo: unspentCoinsInfoSource, + payjoinBox: payjoinSessionSource, network: network, encryptionFileUtils: encryptionFileUtilsFor(isDirect), ); diff --git a/cw_bitcoin/lib/electrum_wallet.dart b/cw_bitcoin/lib/electrum_wallet.dart index 14fd1d3a9..35c15682c 100644 --- a/cw_bitcoin/lib/electrum_wallet.dart +++ b/cw_bitcoin/lib/electrum_wallet.dart @@ -1188,6 +1188,7 @@ abstract class ElectrumWalletBase isSendAll: estimatedTx.isSendAll, hasTaprootInputs: hasTaprootInputs, utxos: estimatedTx.utxos, + publicKeys: estimatedTx.publicKeys )..addListener((transaction) async { transactionHistory.addOne(transaction); if (estimatedTx.spendsSilentPayment) { @@ -1965,6 +1966,11 @@ abstract class ElectrumWalletBase } } + bool isMine(Script script) { + final derivedAddress = addressFromOutputScript(script, network); + return addressesSet.contains(derivedAddress); + } + @override Future> fetchTransactions() async { try { diff --git a/cw_bitcoin/lib/payjoin/manager.dart b/cw_bitcoin/lib/payjoin/manager.dart new file mode 100644 index 000000000..b80fa777c --- /dev/null +++ b/cw_bitcoin/lib/payjoin/manager.dart @@ -0,0 +1,298 @@ +import 'dart:async'; +import 'dart:isolate'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:bitcoin_base/bitcoin_base.dart'; +import 'package:cw_bitcoin/bitcoin_wallet.dart'; +import 'package:cw_bitcoin/bitcoin_wallet_addresses.dart'; +import 'package:cw_bitcoin/payjoin/payjoin_receive_worker.dart'; +import 'package:cw_bitcoin/payjoin/payjoin_send_worker.dart'; +import 'package:cw_bitcoin/payjoin/payjoin_session_errors.dart'; +import 'package:cw_bitcoin/payjoin/storage.dart'; +import 'package:cw_bitcoin/psbt/signer.dart'; +import 'package:cw_bitcoin/psbt/utils.dart'; +import 'package:cw_core/utils/print_verbose.dart'; +import 'package:payjoin_flutter/common.dart'; +import 'package:payjoin_flutter/receive.dart'; +import 'package:payjoin_flutter/send.dart'; +import 'package:payjoin_flutter/uri.dart' as PayjoinUri; + +class PayjoinManager { + PayjoinManager(this._payjoinStorage, this._wallet); + + final PayjoinStorage _payjoinStorage; + final BitcoinWalletBase _wallet; + final Map _activePollers = {}; + + static const List ohttpRelayUrls = [ + 'https://pj.bobspacebkk.com', + 'https://ohttp.achow101.com', + ]; + + static Future randomOhttpRelayUrl() => PayjoinUri.Url.fromStr( + ohttpRelayUrls[Random.secure().nextInt(ohttpRelayUrls.length)]); + + static const payjoinDirectoryUrl = 'https://payjo.in'; + + Future resumeSessions() async { + final allSessions = _payjoinStorage.readAllOpenSessions(_wallet.id); + + final spawnedSessions = allSessions.map((session) { + if (session.isSenderSession) { + printV("Resuming Payjoin Sender Session ${session.pjUri!}"); + return _spawnSender( + sender: Sender.fromJson(session.sender!), + pjUri: session.pjUri!, + ); + } + final receiver = Receiver.fromJson(session.receiver!); + printV("Resuming Payjoin Receiver Session ${receiver.id()}"); + return _spawnReceiver(receiver: receiver); + }); + + printV("Resumed ${spawnedSessions.length} Payjoin Sessions"); + await Future.wait(spawnedSessions); + } + + Future initSender( + String pjUriString, String originalPsbt, int networkFeesSatPerVb) async { + try { + final pjUri = + (await PayjoinUri.Uri.fromStr(pjUriString)).checkPjSupported(); + final minFeeRateSatPerKwu = BigInt.from(networkFeesSatPerVb * 250); + final senderBuilder = await SenderBuilder.fromPsbtAndUri( + psbtBase64: originalPsbt, + pjUri: pjUri, + ); + return senderBuilder.buildRecommended(minFeeRate: minFeeRateSatPerKwu); + } catch (e) { + throw Exception('Error initializing Payjoin Sender: $e'); + } + } + + Future spawnNewSender({ + required Sender sender, + required String pjUrl, + required BigInt amount, + bool isTestnet = false, + }) async { + final pjUri = Uri.parse(pjUrl).queryParameters['pj']!; + await _payjoinStorage.insertSenderSession( + sender, pjUri, _wallet.id, amount); + + return _spawnSender(isTestnet: isTestnet, sender: sender, pjUri: pjUri); + } + + Future _spawnSender({ + required Sender sender, + required String pjUri, + bool isTestnet = false, + }) async { + final completer = Completer(); + final receivePort = ReceivePort(); + + receivePort.listen((message) async { + if (message is Map) { + try { + switch (message['type'] as PayjoinSenderRequestTypes) { + case PayjoinSenderRequestTypes.requestPosted: + return; + case PayjoinSenderRequestTypes.psbtToSign: + final proposalPsbt = message['psbt'] as String; + final utxos = _wallet.getUtxoWithPrivateKeys(); + final finalizedPsbt = await _wallet.signPsbt(proposalPsbt, utxos); + final txId = getTxIdFromPsbtV0(finalizedPsbt); + _wallet.commitPsbt(finalizedPsbt); + + _cleanupSession(pjUri); + await _payjoinStorage.markSenderSessionComplete(pjUri, txId); + completer.complete(); + } + } catch (e) { + _cleanupSession(pjUri); + printV(e); + await _payjoinStorage.markSenderSessionUnrecoverable(pjUri); + completer.completeError(e); + } + } else if (message is PayjoinSessionError) { + _cleanupSession(pjUri); + if (message is UnrecoverableError) { + printV(message.message); + await _payjoinStorage.markSenderSessionUnrecoverable(pjUri); + completer.complete(); + } else if (message is RecoverableError) { + completer.complete(); + } else { + completer.completeError(message); + } + } + }); + + final isolate = await Isolate.spawn( + PayjoinSenderWorker.run, + [receivePort.sendPort, sender.toJson(), pjUri], + ); + + _activePollers[pjUri] = PayjoinPollerSession(isolate, receivePort); + + return completer.future; + } + + Future initReceiver(String address, + [bool isTestnet = false]) async { + try { + final payjoinDirectory = + await PayjoinUri.Url.fromStr(payjoinDirectoryUrl); + + final ohttpKeys = await PayjoinUri.fetchOhttpKeys( + ohttpRelay: await randomOhttpRelayUrl(), + payjoinDirectory: payjoinDirectory, + ); + + final receiver = await Receiver.create( + address: address, + network: isTestnet ? Network.testnet : Network.bitcoin, + directory: payjoinDirectory, + ohttpKeys: ohttpKeys, + ohttpRelay: await randomOhttpRelayUrl(), + ); + + await _payjoinStorage.insertReceiverSession(receiver, _wallet.id); + + return receiver; + } catch (e) { + throw Exception('Error initializing Payjoin Receiver: $e'); + } + } + + Future spawnNewReceiver({ + required Receiver receiver, + bool isTestnet = false, + }) async { + await _payjoinStorage.insertReceiverSession(receiver, _wallet.id); + return _spawnReceiver(isTestnet: isTestnet, receiver: receiver); + } + + Future _spawnReceiver({ + required Receiver receiver, + bool isTestnet = false, + }) async { + final completer = Completer(); + final receivePort = ReceivePort(); + + SendPort? mainToIsolateSendPort; + List utxos = []; + String rawAmount = '0'; + + receivePort.listen((message) async { + if (message is Map) { + try { + switch (message['type'] as PayjoinReceiverRequestTypes) { + case PayjoinReceiverRequestTypes.processOriginalTx: + final tx = message['tx'] as String; + rawAmount = getOutputAmountFromTx(tx, _wallet); + break; + case PayjoinReceiverRequestTypes.checkIsOwned: + (_wallet.walletAddresses as BitcoinWalletAddresses).newPayjoinReceiver(); + _payjoinStorage.markReceiverSessionInProgress(receiver.id()); + + final inputScript = message['input_script'] as Uint8List; + final isOwned = + _wallet.isMine(Script.fromRaw(byteData: inputScript)); + mainToIsolateSendPort?.send({ + 'requestId': message['requestId'], + 'result': isOwned, + }); + break; + + case PayjoinReceiverRequestTypes.checkIsReceiverOutput: + final outputScript = message['output_script'] as Uint8List; + final isReceiverOutput = + _wallet.isMine(Script.fromRaw(byteData: outputScript)); + mainToIsolateSendPort?.send({ + 'requestId': message['requestId'], + 'result': isReceiverOutput, + }); + break; + + case PayjoinReceiverRequestTypes.getCandidateInputs: + utxos = _wallet.getUtxoWithPrivateKeys(); + mainToIsolateSendPort?.send({ + 'requestId': message['requestId'], + 'result': utxos, + }); + break; + + case PayjoinReceiverRequestTypes.processPsbt: + final psbt = message['psbt'] as String; + final signedPsbt = await _wallet.signPsbt(psbt, utxos); + mainToIsolateSendPort?.send({ + 'requestId': message['requestId'], + 'result': signedPsbt, + }); + break; + + case PayjoinReceiverRequestTypes.proposalSent: + _cleanupSession(receiver.id()); + final psbt = message['psbt'] as String; + await _payjoinStorage.markReceiverSessionComplete( + receiver.id(), getTxIdFromPsbtV0(psbt), rawAmount); + completer.complete(); + } + } catch (e) { + _cleanupSession(receiver.id()); + await _payjoinStorage.markReceiverSessionUnrecoverable( + receiver.id(), e.toString()); + completer.completeError(e); + } + } else if (message is PayjoinSessionError) { + _cleanupSession(receiver.id()); + if (message is UnrecoverableError) { + await _payjoinStorage.markReceiverSessionUnrecoverable( + receiver.id(), message.message); + completer.complete(); + } else if (message is RecoverableError) { + completer.complete(); + } else { + completer.completeError(message); + } + } else if (message is SendPort) { + mainToIsolateSendPort = message; + } + }); + + final isolate = await Isolate.spawn( + PayjoinReceiverWorker.run, + [receivePort.sendPort, receiver.toJson()], + ); + + _activePollers[receiver.id()] = PayjoinPollerSession(isolate, receivePort); + + return completer.future; + } + + void cleanupSessions() { + final sessionIds = _activePollers.keys.toList(); + for (final sessionId in sessionIds) { + _cleanupSession(sessionId); + } + } + + void _cleanupSession(String sessionId) { + _activePollers[sessionId]?.close(); + _activePollers.remove(sessionId); + } +} + +class PayjoinPollerSession { + final Isolate isolate; + final ReceivePort port; + + PayjoinPollerSession(this.isolate, this.port); + + void close() { + isolate.kill(); + port.close(); + } +} diff --git a/cw_bitcoin/lib/payjoin/payjoin_receive_worker.dart b/cw_bitcoin/lib/payjoin/payjoin_receive_worker.dart new file mode 100644 index 000000000..a499660b0 --- /dev/null +++ b/cw_bitcoin/lib/payjoin/payjoin_receive_worker.dart @@ -0,0 +1,219 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:isolate'; +import 'dart:typed_data'; + +import 'package:blockchain_utils/blockchain_utils.dart'; +import 'package:cw_bitcoin/payjoin/payjoin_session_errors.dart'; +import 'package:cw_bitcoin/psbt/signer.dart'; +import 'package:cw_core/utils/print_verbose.dart'; +import 'package:http/http.dart' as http; +import 'package:payjoin_flutter/bitcoin_ffi.dart'; +import 'package:payjoin_flutter/common.dart'; +import 'package:payjoin_flutter/receive.dart'; +import 'package:payjoin_flutter/src/generated/frb_generated.dart' as pj; + +enum PayjoinReceiverRequestTypes { + processOriginalTx, + proposalSent, + getCandidateInputs, + checkIsOwned, + checkIsReceiverOutput, + processPsbt; +} + +class PayjoinReceiverWorker { + final SendPort sendPort; + final pendingRequests = >{}; + + PayjoinReceiverWorker._(this.sendPort); + + static Future run(List args) async { + await pj.core.init(); + + final sendPort = args[0] as SendPort; + final receiverJson = args[1] as String; + + final worker = PayjoinReceiverWorker._(sendPort); + final receivePort = ReceivePort(); + + sendPort.send(receivePort.sendPort); + receivePort.listen(worker.handleMessage); + + try { + final httpClient = http.Client(); + final receiver = Receiver.fromJson(receiverJson); + + final uncheckedProposal = + await worker.receiveUncheckedProposal(httpClient, receiver); + + final originalTx = await uncheckedProposal.extractTxToScheduleBroadcast(); + sendPort.send({ + 'type': PayjoinReceiverRequestTypes.processOriginalTx, + 'tx': BytesUtils.toHexString(originalTx), + }); + + final payjoinProposal = await worker.processPayjoinProposal( + uncheckedProposal, + ); + final psbt = await worker.sendFinalProposal(httpClient, payjoinProposal); + sendPort.send({ + 'type': PayjoinReceiverRequestTypes.proposalSent, + 'psbt': psbt, + }); + } catch (e) { + if (e is HttpException || + (e is http.ClientException && + e.message.contains("Software caused connection abort"))) { + sendPort.send(PayjoinSessionError.recoverable(e.toString())); + } else { + sendPort.send(PayjoinSessionError.unrecoverable(e.toString())); + } + } + } + + void handleMessage(dynamic message) async { + if (message is Map) { + final requestId = message['requestId'] as String?; + if (requestId != null && pendingRequests.containsKey(requestId)) { + pendingRequests[requestId]!.complete(message['result']); + pendingRequests.remove(requestId); + } + } + } + + Future _sendRequest(PayjoinReceiverRequestTypes type, + [Map data = const {}]) async { + final completer = Completer(); + final requestId = DateTime.now().millisecondsSinceEpoch.toString(); + pendingRequests[requestId] = completer; + + sendPort.send({ + ...data, + 'type': type, + 'requestId': requestId, + }); + + return completer.future; + } + + Future receiveUncheckedProposal( + http.Client httpClient, Receiver session) async { + while (true) { + printV("Polling for Proposal (${session.id()})"); + final extractReq = await session.extractReq(); + final request = extractReq.$1; + + final url = Uri.parse(request.url.asString()); + final httpRequest = await httpClient.post(url, + headers: {'Content-Type': request.contentType}, body: request.body); + + final proposal = await session.processRes( + body: httpRequest.bodyBytes, ctx: extractReq.$2); + if (proposal != null) return proposal; + } + } + + Future sendFinalProposal( + http.Client httpClient, PayjoinProposal finalProposal) async { + final req = await finalProposal.extractV2Req(); + final proposalReq = req.$1; + final proposalCtx = req.$2; + + final request = await httpClient.post( + Uri.parse(proposalReq.url.asString()), + headers: {"Content-Type": proposalReq.contentType}, + body: proposalReq.body, + ); + + await finalProposal.processRes( + res: request.bodyBytes, + ohttpContext: proposalCtx, + ); + + return await finalProposal.psbt(); + } + + Future processPayjoinProposal( + UncheckedProposal proposal) async { + await proposal.extractTxToScheduleBroadcast(); + // TODO Handle this. send to the main port on a timer? + + try { + // Receive Check 1: can broadcast + final pj1 = await proposal.assumeInteractiveReceiver(); + + // Receive Check 2: original PSBT has no receiver-owned inputs + final pj2 = await pj1.checkInputsNotOwned( + isOwned: (inputScript) async { + final result = await _sendRequest( + PayjoinReceiverRequestTypes.checkIsOwned, + {'input_script': inputScript}, + ); + return result as bool; + }, + ); + // Receive Check 3: sender inputs have not been seen before (prevent probing attacks) + final pj3 = await pj2.checkNoInputsSeenBefore(isKnown: (input) => false); + + // Identify receiver outputs + final pj4 = await pj3.identifyReceiverOutputs( + isReceiverOutput: (outputScript) async { + final result = await _sendRequest( + PayjoinReceiverRequestTypes.checkIsReceiverOutput, + {'output_script': outputScript}, + ); + return result as bool; + }, + ); + final pj5 = await pj4.commitOutputs(); + + final listUnspent = + await _sendRequest(PayjoinReceiverRequestTypes.getCandidateInputs); + final unspent = listUnspent as List; + if (unspent.isEmpty) throw Exception('No unspent outputs available'); + + final selectedUtxo = await _inputPairFromUtxo(unspent[0]); + final pj6 = await pj5.contributeInputs(replacementInputs: [selectedUtxo]); + final pj7 = await pj6.commitInputs(); + + // Finalize proposal + final payjoinProposal = await pj7.finalizeProposal( + processPsbt: (String psbt) async { + final result = await _sendRequest( + PayjoinReceiverRequestTypes.processPsbt, {'psbt': psbt}); + return result as String; + }, + // TODO set maxFeeRateSatPerVb + maxFeeRateSatPerVb: BigInt.from(10000), + ); + return payjoinProposal; + } catch (e) { + printV('Error occurred while finalizing proposal: $e'); + rethrow; + } + } + + Future _inputPairFromUtxo(UtxoWithPrivateKey utxo) async { + final txout = TxOut( + value: utxo.utxo.value, + scriptPubkey: Uint8List.fromList( + utxo.ownerDetails.address.toScriptPubKey().toBytes()), + ); + + final psbtin = + PsbtInput(witnessUtxo: txout, redeemScript: null, witnessScript: null); + + final previousOutput = + OutPoint(txid: utxo.utxo.txHash, vout: utxo.utxo.vout); + + final txin = TxIn( + previousOutput: previousOutput, + scriptSig: await Script.newInstance(rawOutputScript: []), + witness: [], + sequence: 0, + ); + + return InputPair.newInstance(txin, psbtin); + } +} diff --git a/cw_bitcoin/lib/payjoin/payjoin_send_worker.dart b/cw_bitcoin/lib/payjoin/payjoin_send_worker.dart new file mode 100644 index 000000000..f720bac01 --- /dev/null +++ b/cw_bitcoin/lib/payjoin/payjoin_send_worker.dart @@ -0,0 +1,119 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:isolate'; + +import 'package:cw_bitcoin/payjoin/manager.dart'; +import 'package:cw_bitcoin/payjoin/payjoin_session_errors.dart'; +import 'package:cw_core/utils/print_verbose.dart'; +import 'package:http/http.dart' as http; +import 'package:payjoin_flutter/common.dart'; +import 'package:payjoin_flutter/send.dart'; +import 'package:payjoin_flutter/src/generated/frb_generated.dart' as pj; + +enum PayjoinSenderRequestTypes { + requestPosted, + psbtToSign; +} + +class PayjoinSenderWorker { + final SendPort sendPort; + final pendingRequests = >{}; + final String pjUrl; + + PayjoinSenderWorker._(this.sendPort, this.pjUrl); + + static Future run(List args) async { + await pj.core.init(); + + final sendPort = args[0] as SendPort; + final senderJson = args[1] as String; + final pjUrl = args[2] as String; + + final sender = Sender.fromJson(senderJson); + final worker = PayjoinSenderWorker._(sendPort, pjUrl); + + try { + final proposalPsbt = await worker.runSender(sender); + sendPort.send({ + 'type': PayjoinSenderRequestTypes.psbtToSign, + 'psbt': proposalPsbt, + }); + } catch (e) { + sendPort.send(e); + } + } + + /// Run a payjoin sender (V2 protocol first, fallback to V1). + Future runSender(Sender sender) async { + final httpClient = http.Client(); + + try { + return await _runSenderV2(sender, httpClient); + } catch (e) { + printV(e); + if (e is PayjoinException && + // TODO condition on error type instead of message content + e.message?.contains('parse receiver public key') == true) { + return await _runSenderV1(sender, httpClient); + } else if (e is HttpException) { + printV(e); + throw Exception(PayjoinSessionError.recoverable(e.toString())); + } else { + throw Exception(PayjoinSessionError.unrecoverable(e.toString())); + } + } + } + + /// Attempt to send payjoin using the V2 of the protocol. + Future _runSenderV2(Sender sender, http.Client httpClient) async { + try { + final postRequest = await sender.extractV2( + ohttpProxyUrl: await PayjoinManager.randomOhttpRelayUrl(), + ); + + final postResult = await _postRequest(httpClient, postRequest.$1); + final getContext = + await postRequest.$2.processResponse(response: postResult); + + sendPort.send({'type': PayjoinSenderRequestTypes.requestPosted, "pj": pjUrl}); + + while (true) { + printV('Polling V2 Proposal Request (${pjUrl})'); + + final getRequest = await getContext.extractReq( + ohttpRelay: await PayjoinManager.randomOhttpRelayUrl(), + ); + final getRes = await _postRequest(httpClient, getRequest.$1); + final proposalPsbt = await getContext.processResponse( + response: getRes, + ohttpCtx: getRequest.$2, + ); + printV("$proposalPsbt"); + if (proposalPsbt != null) return proposalPsbt; + } + } catch (e) { + rethrow; + } + } + + /// Attempt to send payjoin using the V1 of the protocol. + Future _runSenderV1(Sender sender, http.Client httpClient) async { + try { + final postRequest = await sender.extractV1(); + final response = await _postRequest(httpClient, postRequest.$1); + + sendPort.send({'type': PayjoinSenderRequestTypes.requestPosted}); + + return await postRequest.$2.processResponse(response: response); + } catch (e) { + throw PayjoinSessionError.unrecoverable('Send V1 payjoin error: $e'); + } + } + + Future> _postRequest(http.Client client, Request req) async { + final httpRequest = await client.post(Uri.parse(req.url.asString()), + headers: {'Content-Type': req.contentType}, body: req.body); + + return httpRequest.bodyBytes; + } +} diff --git a/cw_bitcoin/lib/payjoin/payjoin_session_errors.dart b/cw_bitcoin/lib/payjoin/payjoin_session_errors.dart new file mode 100644 index 000000000..06e0a5431 --- /dev/null +++ b/cw_bitcoin/lib/payjoin/payjoin_session_errors.dart @@ -0,0 +1,16 @@ +class PayjoinSessionError { + final String message; + + const PayjoinSessionError._(this.message); + + factory PayjoinSessionError.recoverable(String message) = RecoverableError; + factory PayjoinSessionError.unrecoverable(String message) = UnrecoverableError; +} + +class RecoverableError extends PayjoinSessionError { + const RecoverableError(super.message) : super._(); +} + +class UnrecoverableError extends PayjoinSessionError { + const UnrecoverableError(super.message) : super._(); +} diff --git a/cw_bitcoin/lib/payjoin/storage.dart b/cw_bitcoin/lib/payjoin/storage.dart new file mode 100644 index 000000000..9c1c83253 --- /dev/null +++ b/cw_bitcoin/lib/payjoin/storage.dart @@ -0,0 +1,95 @@ +import 'package:cw_core/payjoin_session.dart'; +import 'package:hive/hive.dart'; +import 'package:payjoin_flutter/receive.dart'; +import 'package:payjoin_flutter/send.dart'; + +class PayjoinStorage { + PayjoinStorage(this._payjoinSessionSources); + + final Box _payjoinSessionSources; + + static const String _receiverPrefix = 'pj_recv_'; + static const String _senderPrefix = 'pj_send_'; + + Future insertReceiverSession( + Receiver receiver, + String walletId, + ) => + _payjoinSessionSources.put( + "$_receiverPrefix${receiver.id()}", + PayjoinSession( + walletId: walletId, + receiver: receiver.toJson(), + ), + ); + + Future markReceiverSessionComplete( + String sessionId, String txId, String amount) async { + final session = _payjoinSessionSources.get("$_receiverPrefix${sessionId}")!; + + session.status = PayjoinSessionStatus.success.name; + session.txId = txId; + session.rawAmount = amount; + await session.save(); + } + + Future markReceiverSessionUnrecoverable( + String sessionId, String reason) async { + final session = _payjoinSessionSources.get("$_receiverPrefix${sessionId}")!; + + session.status = PayjoinSessionStatus.unrecoverable.name; + session.error = reason; + await session.save(); + } + + Future markReceiverSessionInProgress(String sessionId) async { + final session = _payjoinSessionSources.get("$_receiverPrefix${sessionId}")!; + + session.status = PayjoinSessionStatus.inProgress.name; + session.inProgressSince = DateTime.now(); + await session.save(); + } + + Future insertSenderSession( + Sender sender, + String pjUrl, + String walletId, + BigInt amount, + ) => + _payjoinSessionSources.put( + "$_senderPrefix$pjUrl", + PayjoinSession( + walletId: walletId, + pjUri: pjUrl, + sender: sender.toJson(), + status: PayjoinSessionStatus.inProgress.name, + inProgressSince: DateTime.now(), + rawAmount: amount.toString(), + ), + ); + + Future markSenderSessionComplete(String pjUrl, String txId) async { + final session = _payjoinSessionSources.get("$_senderPrefix$pjUrl")!; + + session.status = PayjoinSessionStatus.success.name; + session.txId = txId; + await session.save(); + } + + Future markSenderSessionUnrecoverable(String pjUrl) async { + final session = _payjoinSessionSources.get("$_senderPrefix$pjUrl")!; + + session.status = PayjoinSessionStatus.unrecoverable.name; + await session.save(); + } + + List readAllOpenSessions(String walletId) => + _payjoinSessionSources.values + .where((session) => + session.walletId == walletId && + ![ + PayjoinSessionStatus.success.name, + PayjoinSessionStatus.unrecoverable.name + ].contains(session.status)) + .toList(); +} diff --git a/cw_bitcoin/lib/pending_bitcoin_transaction.dart b/cw_bitcoin/lib/pending_bitcoin_transaction.dart index 411c7de16..6930524eb 100644 --- a/cw_bitcoin/lib/pending_bitcoin_transaction.dart +++ b/cw_bitcoin/lib/pending_bitcoin_transaction.dart @@ -1,3 +1,4 @@ +import 'package:cw_bitcoin/electrum_wallet.dart'; import 'package:grpc/grpc.dart'; import 'package:cw_bitcoin/exceptions.dart'; import 'package:bitcoin_base/bitcoin_base.dart'; @@ -25,6 +26,8 @@ class PendingBitcoinTransaction with PendingTransaction { this.hasTaprootInputs = false, this.isMweb = false, this.utxos = const [], + this.publicKeys, + this.commitOverride, }) : _listeners = []; final WalletType type; @@ -43,6 +46,8 @@ class PendingBitcoinTransaction with PendingTransaction { String? idOverride; String? hexOverride; List? outputAddresses; + final Map? publicKeys; + Future Function()? commitOverride; @override String get id => idOverride ?? _tx.txId(); @@ -129,6 +134,10 @@ class PendingBitcoinTransaction with PendingTransaction { @override Future commit() async { + if (commitOverride != null) { + return commitOverride?.call(); + } + if (isMweb) { await _ltcCommit(); } else { diff --git a/cw_bitcoin/lib/psbt/signer.dart b/cw_bitcoin/lib/psbt/signer.dart new file mode 100644 index 000000000..1d0ceba8b --- /dev/null +++ b/cw_bitcoin/lib/psbt/signer.dart @@ -0,0 +1,263 @@ +import 'dart:typed_data'; + +import 'package:bitcoin_base/bitcoin_base.dart'; +import 'package:blockchain_utils/blockchain_utils.dart'; +import 'package:collection/collection.dart'; +import 'package:cw_bitcoin/bitcoin_address_record.dart'; +import 'package:cw_bitcoin/bitcoin_unspent.dart'; +import 'package:cw_bitcoin/bitcoin_wallet.dart'; +import 'package:cw_bitcoin/utils.dart'; +import 'package:ledger_bitcoin/psbt.dart'; +import 'package:ledger_bitcoin/src/utils/buffer_writer.dart'; + +extension PsbtSigner on PsbtV2 { + Uint8List extractUnsignedTX({bool getSegwit = true}) { + final tx = BufferWriter()..writeUInt32(getGlobalTxVersion()); + + final isSegwit = getInputWitnessUtxo(0) != null; + if (isSegwit && getSegwit) { + tx.writeSlice(Uint8List.fromList([0, 1])); + } + + final inputCount = getGlobalInputCount(); + tx.writeVarInt(inputCount); + + for (var i = 0; i < inputCount; i++) { + tx + ..writeSlice(getInputPreviousTxid(i)) + ..writeUInt32(getInputOutputIndex(i)) + ..writeVarSlice(Uint8List(0)) + ..writeUInt32(getInputSequence(i)); + } + + final outputCount = getGlobalOutputCount(); + tx.writeVarInt(outputCount); + for (var i = 0; i < outputCount; i++) { + tx.writeUInt64(getOutputAmount(i)); + tx.writeVarSlice(getOutputScript(i)); + } + tx.writeUInt32(getGlobalFallbackLocktime() ?? 0); + return tx.buffer(); + } + + Future signWithUTXO( + List utxos, UTXOSignerCallBack signer, + [UTXOGetterCallBack? getTaprootPair]) async { + final raw = BytesUtils.toHexString(extractUnsignedTX(getSegwit: false)); + final tx = BtcTransaction.fromRaw(raw); + + /// when the transaction is taproot and we must use getTaproot transaction + /// digest we need all of inputs amounts and owner script pub keys + List taprootAmounts = []; + List