From 06742cc968a79fb3bf3ef699506af5e5b9fecd26 Mon Sep 17 00:00:00 2001 From: bot Date: Sat, 20 Jun 2026 09:48:28 -0400 Subject: [PATCH] add bal/core --- bal/core/__init__.py | 21 + bal/core/__pycache__/__init__.cpython-311.pyc | Bin 0 -> 1038 bytes bal/core/__pycache__/heirs.cpython-311.pyc | Bin 0 -> 41929 bytes .../__pycache__/plugin_base.cpython-311.pyc | Bin 0 -> 20111 bytes bal/core/__pycache__/util.cpython-311.pyc | Bin 0 -> 25440 bytes bal/core/__pycache__/will.cpython-311.pyc | Bin 0 -> 57069 bytes .../__pycache__/willexecutors.cpython-311.pyc | Bin 0 -> 35142 bytes bal/core/heirs.py | 850 ++++++++++++ bal/core/plugin_base.py | 400 ++++++ bal/core/util.py | 551 ++++++++ bal/core/will.py | 1149 +++++++++++++++++ bal/core/willexecutors.py | 788 +++++++++++ 12 files changed, 3759 insertions(+) create mode 100644 bal/core/__init__.py create mode 100644 bal/core/__pycache__/__init__.cpython-311.pyc create mode 100644 bal/core/__pycache__/heirs.cpython-311.pyc create mode 100644 bal/core/__pycache__/plugin_base.cpython-311.pyc create mode 100644 bal/core/__pycache__/util.cpython-311.pyc create mode 100644 bal/core/__pycache__/will.cpython-311.pyc create mode 100644 bal/core/__pycache__/willexecutors.cpython-311.pyc create mode 100644 bal/core/heirs.py create mode 100644 bal/core/plugin_base.py create mode 100644 bal/core/util.py create mode 100644 bal/core/will.py create mode 100644 bal/core/willexecutors.py diff --git a/bal/core/__init__.py b/bal/core/__init__.py new file mode 100644 index 0000000..6e1c1b2 --- /dev/null +++ b/bal/core/__init__.py @@ -0,0 +1,21 @@ +""" +bal.core +======== + +Pure business-logic layer of the Bitcoin After Life (BAL) Electrum plugin. + +Everything in this sub-package MUST stay completely free of any GUI / Qt +imports. The rule of thumb is: + + * ``bal.core`` -> "what the plugin does" (inheritance rules, building + and validating transactions, talking to + will-executor servers, persistence helpers). + * ``bal.gui`` -> "how it looks" (Qt widgets, dialogs, list views). + +Keeping the two apart is the main motivation behind this rewrite: the original +code mixed transaction-building logic and presentation inside a single +4000-line ``qt.py`` module, which made the delicate Bitcoin logic hard to audit. + +No behaviour is changed with respect to the original plugin; the code has only +been reorganised and documented. +""" diff --git a/bal/core/__pycache__/__init__.cpython-311.pyc b/bal/core/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5edf22c5c26e1032e61bb2b65b785025f77797c3 GIT binary patch literal 1038 zcmaJ=v2N5r5cMSyAWP|J=tik>C}*cY6agY7QiOmI5hZD8e0$!BJ!HLWcE{&j$6xRX zh+m@R2O_0Q#jGzU6d|$2YIi*I{N9_{ADf$LB5Up2%P*(vN%A{hR%>*BxQ!{_6P2xJ%I*k#4$v$lL zwc>&Kk{4))VGFj&*64|qEtiO4M`V-H!ghdL0{pWpt6{DTX~j_&du4+vnVb~>R_1Dm zy8l7+WXuGwvFzA+2=WMfPe=uJch|Su0x!%bvIf zq0-8z#{3AmQgc7IuZG~DtX2V>HH&nHXr;{gN?8hm6D4U^c8zF4ouwz^(XYovA)%u9 zncT)#cekI{uEAV7s!XnN?u^c5KJ!M|4$XvA&KNl4SvG{@JIsrMEOAkY2X{|iFFn}z dj|48h*!+fk{R7N)Mdttj literal 0 HcmV?d00001 diff --git a/bal/core/__pycache__/heirs.cpython-311.pyc b/bal/core/__pycache__/heirs.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a0865696b3e7807f017d7ea3ca41b0ec0ff76711 GIT binary patch literal 41929 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 literal 0 HcmV?d00001 diff --git a/bal/core/__pycache__/plugin_base.cpython-311.pyc b/bal/core/__pycache__/plugin_base.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9110e83bcce06cba5e73aa8148f2cd60fec94002 GIT binary patch literal 20111 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 J19BP7{a=_tA?^SG literal 0 HcmV?d00001 diff --git a/bal/core/__pycache__/util.cpython-311.pyc b/bal/core/__pycache__/util.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..94cc75df1829692ab5f8bf82ae70dd6c3eb76cae GIT binary patch literal 25440 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 V*GrW)+rg%xVM9UK8*oqyB;)6s=Jmd$^lGtF{ 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
!(Zbu0T@Y#fankA=SeM>6~6wATu{I4C!-GL zosTDDETvstdZb5#_ja#ACd?^#caJ-6G2_@<`XS>pS%(?yF&MO^awD^&H^Q?sLOI$Z zc;KXR$ER_S(U}0iq?3HiV<~gU2$(Q-`hUClc=3uRiwxTroTSnv*Q@Ee?H!XWI&Y{0)1N z&9|yJQ#`NB`V&j5^`X7XI&##p0j*-?wY8N;-C#i=b>Pc#EUGVfZ0=OHx!to@Ysxf?2(;f5n!WZap4 z)BiQ@A`3Q zLU;{(5Td@%cro>9D^VUyeXH5@x@pFip7OjI@o7S_2l)^VBfncm32~L*?KcS7lHaYP zgxAXN#v0LnwUfVE)1^Nl6d-6B9toDD@+QH)1Ta8s1WGBKBZnc&ui!1^Cp>p@=Gv@G zM!pRoFBD<)6@hw1-Zv{KCXr=`%_daQmmNsLTqxxm1qcd43r|SHTxc{r773>c!{fr_ zt;i@Jl3xIa4YM33!=uxaGoe&23I%9{n4=>y$$NA(?d6u!+?u-zR4!GbMKJjLHGi$( zV2-?_6Osa=5o-z~Fv;70Dn};ThiRqJ7N6eai!%BEkc|R1WCy&DwBh}jbMKfwcL60_ z*|$8oJoRy(=xUZ+&9tGM*s7@6tmxRN=)g{Y@l)rgZn5IHRB;@10pHS@rL##_Aw@fw zbbDowO?TUdyKU{7=sqC14a-}0H|vzz`s8~#0_KM110wkh9vm^q96I3|{R zBwxd(uX)4QEc#j`UkiLazF1^2x)>$G1HW3nTD4i$zERdLmUT*Ho$LNjE2XXhscaA= zroCMcdtx@p+q`YD7M3O3_DO96v3|+llJK{DwH4q{!NM}lL4{)d*cl7UurofXsgK!~ z-N|6b!)nT-DcQbXYVW1jy`W?~JWFput%$F-E7CO&)4cm-x4ZA8<(DV(`rYPJ?N7-oK4I(SkGTeCJ<9bbHp$ zyJ#kUi<VA(-R3Bqs{QYa2iM_M4NjHXZ^=wg##0w9$ zl=0lnOc!-1qy04MjOHqk>#uel=jS=M@{Fk$Tv6A~Csj0q&X6%jU2*li(zoeZJxL|g z+>L0H+r--=w4EzYG|$-g#@=C6Teo3_si$5#2l`9pMBUN6s9P%t7!BAIKg8&NqRs%2 zr-D&ioWSs`CwtDKpPr+|U{APovu#Jn$=FBKBQf*R{}#2!)iYUN9b;_tK^`N=d=(W)Ins0o`J^V4C6%jij#KuKAmAV!BF{XcOUT4L;SzZi&CCkZ5YL(qA-XKz z#ZVs-FB1ZYA(T=yw|oG~EEA!U{13nhdgODH$g8RRSt3V-6}C#st^Z6pfll!uKnS5! z4qkCQrkq3wU}RQ7K6hC?!!qE&DrcF!oia}f)NL|~MdQzoU(J!RtpcqXl4KDbrY80XjaJ(v`$I;E=4#WS(-_l6gTlb+H=8_SF_cq*LxpPJw- z_B{cHW?eqA+_ULx*zh%oz9z707WgqIWM2Ly!Z~KI5XdPV&|z zyme_GKE7{tQuH?C3{H5P4Zf>Wo7LSL)!kzCeyMuD=sh5L4BUi9vhy!#T~eaY%N%0HG93%~D5dP`BgSok}?xA=R>^6HiF zYH+o6v%Gzyyj?8sl*&6}xeC)NW=VRBHoc7--o`i@p=7pI*$}_*qsu?I{Lvc^-ViIh zrONJ@Eq3vJ=jUa>sXnfcU;n7}L91BSA(eH2Y*JXloLKLczjV{zxZ!V1mR7Ee$GhU! z*Tx>)SZ{so{OsUUtEIT)*Csd^pS0sJ6_#&$xeYHD_le#{$=jIlHYWE3e>DGt`H!Lx zqG*n=Ja=#YgZcNP%TdwW@SE*CQ*i~6VBQ~I9ww-Y;dP}78M-6@n5*!;p~aymzDk-0 z)yx5p&QlcQ7pXf4(u$p7G-)~T85ElYW}XqxGa$qnRN<)kVFu4^!D@gxtl+-@j?_?S zU^o_SCyUxf)xC_zUOKAcXo!`As;~$C>h21E_0)sEdNyU`szMJ@8&R%@gBE$M5?BvT z3GLt`L6<;VuRu73K$!}!lJhlkUMJ@=IbSE|4RYQj=NoW>c9t3OX731N#029%3O)#~UrZDo5sQvUMMoBMw+brb zUrQ9Ui3M#^K^xexbXZU zi!ya?Dvi_IOoD(ig%F&SW#+a)5`a3K0i}PPh3so+w4U*x4J2>QWV9_A1>Gz2`1;YTs zFH&9AnhPHy5AAD3|HbFfu*;7=jox38Jh)|On=VIQ2hKgCH*_`HJ;hDd4uQ2Hqdavv zJhO~lVkrQk+&4P5lQxd%v)%l4HP`o@(l4EQTHJGuo8!90h~y|^wp;nT_y?V1Ax!uF z(0E#yFAY)<*MkEG=5~w1U;>LI&EJX;_vLoG&>m?Am7jyi1xfAdm{O+zNPx96OFXe= z1QSz+hsO-_K&BAZBk>QeIW6`Zq#PicH$6=pGZKs=SwfY4!Q}ZA$zW6rd@z(t+#%w% z5t)Gp7vpPbJ{m~_6ZpXKlh>{Rz9HF5Fqg*es`)X7#25m{!JMO(%F^gywzW*oj0+G% z4uv7Jh=8%_**UPC@OqbH%$D0@V9kJm(@K77hE$|pP!;gsb*LCnxoVHTJUqbl_rA^z z^j-)!Th0~4bMRpAskZVF|)^G z1cmSz<1k5@lH7x$^^k_iTDa3p8oyZ~%y4_bm;yhMh&@UV1eMNAC!xO(rqt@G)}BII zxQnXPI0SrYIxsd|1_M$Sb*BXBr%jgTrP0&)rE|hmc1%jq!n*D%Mr#z(^c;7BW0K&S z$L=*u7szqV^4N#iY`~a--v(x1h!jMoiBE?-t}!eAuYuHKCh zN0g}5p->o$K#?GQMvrNkFd+Q)BzR@bq4w+ToZ1?N8K}-szNM8If)m8G3xT(`W2Lxs z*}-%P*?-YOrZ|(Hi|HA=ONNlX#q=hADyCIh-g_K_a1^thErdH*ei(8Jzu0?jV3&N- z#jq(%@sw4l3VEmNgN2{n<88`>WK7}A>7sArbG6PmVIdK3oPqNAWlzF3)<%#wXCar& zv-&IR1wzUYG@hHD;r6LZ=h?HdjnhV6U;mxo^{q;Gq$U+0ODD)9G(G{5ff;ohY6Tyd zI|}*bv7=XpUmn%8Os>d61JvmlM0L#4nbUxeO<`-~RoxOa9y^$Ev*Mc5QxfcBepV5a zr#3J_A;<{Xpq&zC2=NGnc#1=J#;4}^kb&tQPFb<$Q;zi1{}q8_fKwSy&!_Dq&V1G;5nTOCvDRY(e3f2?@4)2m@o>rQXOXZbi z1+?J#Vg}h}djAT!gfB26@|QiPl5(lIZSCsA(8HO>U5VlWv3Ni#9$3uVauqBJOLI%# z6kXMltNLSK{PIUN4{A1Coe5XxmpLYXspM-+xEnwB_$1Gsq`P40bnNQ#M7%KG8_#*f zZ(A+iyr(8Oz_W-SZ3F(}F!`$!?y4uQ{G|g#OsF62e?bU6cU$bK?%b{SGoowJ^d6mH(UkZ(x!JS`~DgTTBcLOrBBQI`=6 z&Y}nY<$@>bih8tmYN&>W2*vgNpUyf%2wD|A{&?5?Gz7(>0L9;ps2nmNPP+b$sN6CL zTcfW(QTtoyj}QS&d33xzU20>SjpImosQeiHbz`e?-qJDYFP}}#C4!ss>_P)|MlGNM zW8^Y4(sE0^vymcXeDG|*V%-~cSW}>d9&oeu`pmKT@#OB*kdtBvJrt40B zqXou3V{~-g?2qO|y;Q4RYofMKpKIpqe#Sg?8_PG2Iip*9BKrO&&QsPywTpT{xz^2R zqFP6Dvgy{K82_4RyNteT8y=KTtDsM#)yiB`B6(^gSDA@Q1b4THLEiiBW%r^z>GH;~!M}F`V$j>By8QB|xi`(UqF*3RUuorR6tCOEO)C#4`#t~#aCpj6tvS=zl(+AWsumrD0PvTu|gPm~@{ zR@TM4R?kS4ZBpfdN4|~9o?L98-9e6zUXS}%g zC28+VEB$fb>Y3HE$x6^G>1DIBbE6WBR)I<~X-AT4Sh0VV^bz3_oLGP9lM2!UII`NaS<$*t(JEH7OBL-g zN77fZa%aQ0C*j+Z^jB^A8#eq6@z+FuyX0?A_}h~$?Q7wO!PuFV3DLhtA^#c)$+4lW zDzp@b@}7^T9!!Z<`=lxer8|ou&<sH57a$Ig?8mqV zU?soHxGz>vN6kVpxJNtgVVPV~VIpeN$ZGND*=->Cfzr+z)oFH?j4{>`FhqtrP&DVd z6>vgs)bh4j$c;L#o3XFUT;wi#(@xKwdA?DHad!jX?A+3~i2x@YMvnz^8QX@*LCV_? zTh){+W6O|e743aNr_9$mK>>LXhe+=zv=cypa#G$6*t&Nq3OQga3BqlJhl#YOu78C; zLsIivJ_>)v=s7~0z@H*5Hi6>6Vjfrv_bz{M`TaMR-@rU`_AuwY0mz99NnaJ_QGQRn z=tosQs9L)%)*g~-4>7M~S;cDchn4p$zgN9ljcuT;X4{lgh{SMWy~(=z2f@v{?v1)` zv2MRqw?Fn0HiZ=)>-c*7t(A^gUoud)){+S9$1l0>;CkaqUi=tVTcWh%NoigDm{i)a zS$c4z^x%3}EXCKc1p7fwt}NjwlVM2xJX7bdEj@psqY_6WvmfK!z>Jz;^Vb6Ezit*r znTlm5VVKuRbZWC#BPL=ESdrril2ZCzaicZ`W@^HWGqZVU$2`PLq;q+xp5Aox(3ag2 zoZj}Vd3bVZGHPaggK>Qa5x7O5*%wW42*}n8q-p?&1|rH@V#G}b|DhOv1j5vW4mdiA zUZD%$^W6-IGEOrRBvMhmY)z2~nuUZKLQ~|G^|wA9`Sfejfq{2qJrSXsiehEkW$g$n za?{mt(B`|LO!>IXVbVw8lv`gNnai1j)bjd~7f;1A8ZggHeK19{U5caL z4*xf-K~L%$5{{ZDW%Zk7!Hu$@ShiOx+q-SbbslHVduOn=-rlOHUU@sQcYuBsgJQ*? zR5AGYEve#+RB=|SxU_gSS&ReNo{uzeaPIp2u~(M&FCUV8_3`M2Z(qW>n>Y-PUh0Wabf%WmPAY0kmflM zuLVa$2h#I414@-~@ECg4t+jNQa7x6O~=`a(PM#SvHgP<4+1mVc?H2E?HwL%^T;6NBvmf+WjaypES zHhP8)=`c1zZe)4Y;;f{j-~t5QG8UFch?A^*oU%b@XO>}L!XHwhd4xqNVb&Q^U7i87 z#ZW_nwq#U{Iw4F=R-r7s$OzU0_|BKJV{gYg2y_~pMb`t!f@r@@mVF_ym#yM5S@C$y z|EM8B`o|}w;*$WwN-ICiyPx+x_o{o_RO|%a1?Rofi+wR4c;TC#h7C_c{IcljlsugP z(elfazLJ&t)us>k-rxJZw$(NT^{A+c^=tUYMOid8;SVM|x`BI)uU`A`=KY)Bn_it3 z{jJIBT71vkpAoCurRw%w@DD5DA8?HLN4!4%*4m!++J`OcHy#H+dmZv3gn7UrR7`Pl zpy!-f^So!({E3P={eN5Y-+&HVgyYxdWXdANi@lJ;%`YY-xG**p8~SRCjPCJe3A&Rzv|x0AG|F38zsGDLpl-u z?!1E#cxpU-AP%WdwF`VOnL++rfK>@YIvo(MWrxzNnQ(>KG~>tqrxGDeCZ^K@H3X*9 zV*92|(`hl3K*&PY6U`70!X7?IU_3x`lkft{*I9JXNUA_ajUnp!4hRO|xdy;fJO&M9 zq!=YIls$ki40&mSLra58Lkd}IMEC(BLkx&WNv_pmwsoQQmRCO`m-%rc6g7 z4w+0sVs8Zm%<8sDS(q@5g`P)3*mS_|$V7n{*CmxhJLm0Cn7Tc^FUs(xU0gjURd&Rj$+Fsb{c4p| z7L4UQ;hNWc5AtJ<72o^$$)*l^S{FS|3reAdB zow8Veam>{-cDx%DDdUg}wxZxhp| zQ2_)EkB!qdlJywVGpSk|Kxm-cGv=i|QCql@mJ76J)Eu7&7-yiyO~P4!Yz43Y9TA3Y zsuUAV!$nv|mC`yS9NuMtR_pD^9U(@aj4YCg)&&Tyo0z-~p;>uik3tZHGzBZ!6x}8C zPiVSQrZ~V=Ga?Qrj$SyJ8$~6t@N#gu6|erX#erm?JT3M}im7yBGODSsWce5nwoTt2 zoX;|QZfzvt>%tF2yx0H|#|F0TCVw~hYktz`$nQ+HcEqf)zU7xT{Y@MGCehz4`I|*= z5X6tPOx(DBlcX(&A#DE^qxO7Tz7e$n*F*-A)N%u;_C&H=hqWEqB)?o?2qQe)va2er7^Ct z4&b3c=K!gTffVhW+L@VRsdwoN)}$Lfkk9CsZ<3U{CeHU*4KD z-yfU4pCQbOHQhDgS6LtTwiO-Gt;X1ybMW-Zi<~ef7T2BlN%9G7k{ff?+5s)W| zGMUPSWFg7Q%@^to%))-eQEnbWDO03j**xzE!g3@8yqf3jL9XX`=X^1&U2$`fJF}zU z%*g_d@D7$R$SUR0%&Uw}!RD$!cx%c@hX$ny3*Vw|8(9^UN&XCBC8&=bR;fHq`BB#J zXr`nJPfiis3x(N<#Q1ue%|U_4`>7JycA%_{2^)fn8fVH0O-(r$J)d%NR`|~;RRM$m zCt>jcTAyL4oCw#*)6R#k&Rq`{>s3ie(rpyNZxF75#)yYt!cncojQfzXvxcTT*CwYT zuxz2C)ZR2hBHeGP!tps6^q7f^GV{fevFTgFJu2C9JIoO3RIV}%seD6+r)>0@axhQy zaSCEOWQmN_S4ayr6c#w-BNL!$uBNP1ae7NbpoYIPIO2HAcWOEw5bkBrB>C zj?%5lnvZP{oXmn)Wvf)#3SLFBrt!n*{pk0;x%y4AI6>rG=kfPmUVLe#C|L{*7#mS| z;iweemETYJ+VM;J>o@%^8~&EHuC-Sl?ic-?lD{+I?*!*0(Q-5qpkK1Id9$=*1EgM; zj=t-pJo8LG|04LY<%{_MG68-+gsSp*Zid$Fa*6utmgs4NLGdLkzV{YZ-rDeR2@khb(kPYmB*B|~Me^@i3#{+^ zaTRD6fg)raD2iEu5tKAb#T`k1ZM-jjVEyppS5SCK5ehG%I3-0f3komv5zBl9%0Has z_QT|C(A6&14gP-UbwxT1GJ@(v`t z1B{IDx!d>MbKg0aDDM*8-IBXI;qHbq@?w8B-;2&%2Ky0cOl+Mv{$@YM(+W^gH-nM> zJ_f8xcQtjDaYBTxApcom>tmmSvdYF{9DOgJJAZayWMD)%iVCJpzNvx_MnSzC>~Gi* z=Uog&mp{IwrTQw;0wgafPn-H8$_QvNaNZjPv8-?h+g6opul($dpSi{#{T=Q1Eh=|B~*euyr9{?3K!@qf*_*S28SvlKP72tZs0#T0Y|Ft?HN zUE+M`0U)rc9XkWsnJTv7y}QQD0N%qTyT;70ZV7Pi23uN21y*rH9(mC)s-o^hv!2gdUoR;%0Vs05!JDKwk1b|4~V|nLs zs@A?!xrzyK;SoYpjH-*d|a#7VluN zUO5%Ugvcf+hK0W>8!*&qmk5NU!0sVqcIa5y1e*!JB!@7PRDr@ykSS+8co~00-cBOa zQK4ZCd}HJe;lHO~Sa4-E>Zg4EA^GH`p)*kLEVpNc7I^N=%OGWCWz9^9xsDApd9Y7fU=Ojf`yn7g>ooUCYE z^Cv3W@JrS<#&1ZqT`Q-OHMJiu++P5Nz2>k~1DP>%)eGh%SNEg*ALOsyc+?%3TbOsYJVs66)7(_Ccn z6gs$NPY+=XzC6qbtQ8xMij@~d#~#T65dh_v4OW3^?m4D>$BQQJwOnAq)~_+VO~uR0Sp!Ngwl4r=Ic^Q8!(qkbpkrwG8OWc$kol z62_n~FTDw>%Z>+QG;2fFix;5AG|ueV?Z`63cl_@7ig<;2=|_5sk#?-0Ots~=U^fQs zHGD(G)v}|Cs~Pgsxf-SDACPVU?epxJ!7W$-BpjL#!gXIP91{Fdmogfl;%OtK@AI2% zRzVpxcLtgS3+@?Wio28}Q1y+vca{<7bF*!j;4o{${|os4Ej|a*S&p%vVse}t6K-mH zfIPE5$u8Y#1K9vK4C#&!X2kN!6-9H4?pjcleN+`nW{OlsopI3$ENshaW=wCApM`nS z;g?6C9>*+el5XJ`KLssJsG|v_5vAq_ZBFP--WsC{%radmS=p{lR-9s2E=`U@n=>4O zCMPMfWoQWQ(acWL^%1h7-<2!S=#;fIIVC4t2Zd`fpt#3wPLdU$^mj**ew$+5Qmq_Q zIaAe}NRKkDu%;-YHM5&VVA%?WG3CoI()y7US=J2IDL9Qla|$$uxhZCLHGUZRV++VQ`Vd%-8vE%hca9Go*qg}v>i=56Z8{KHW#L<8tP^g zQw0{-4M804nVOiHgqdeDlS=x(bQ26yZk-81Ul7{7Ov9E;+(N62*{#Lwq}xce83jxZ z@pK(4Uk1XohoPKC3ae9ap`JV09%^SzpBfYBE*^9gNNZ51)mjUiuDBeFMWTDfFq@H& zZ0MHS32tUaSx^C0b!~AunKn7;iV|EAfCrGF~RL8?1G?i zy+z-C`93bvgT~UkJB_sm+pO|UW~B0rzN2|SccVc~`Y1D<8yXv*pk)SQ!Pn$#b<{@E zV8I44Ok=ZjY>g=@*4%BTa*aD^LaKToQ^sWTKH?Pw!URg}z0Rh-@j7G6|ay-X-VTW-6i(t4gz4RGQ%TwTmzFOwVO3@oqR` z;FJ_N1yWvd`SLK5&pF zWBWh}4AH7^IXH6ad!|WRtoi`B{Xn?ofi95n!@#njm7TSC;!&We+0{iT)a^9);zme;<)kYPDKMM%uD#*95S>ayCRGz^F%3in zP7{Lv3JDdnYIe5M3C-}4_s%r+12@a47g>Tx%;>|_h?O#r8%V^$-@;Sfz4WPg8kHr4 zVpIYZ*cuiii;=Yp5W;l07cVY3HyxE5j>?rc*ZM_Am*nV5IJzK*7qc!M0po%w$(@R| zEvb0VW^vm_ahq7&0hRz0&R`C9F0X&nTfgCj`W4jB`qvsnZ-?aVNU$GC>_Je391G+* zSd#3yK=Xjjg>T@6=0Y5Qv!6`b9ocNwM)`Th5=u#DkuPjIyNLM+g?Tb3oATg=Sz%EO z)unO(L0q4RpwrnA#f;6_D5{~~Qyy7a9;xN{ilyfa-zgddB0AC1DBkqcZg^_rb)u(H z@-$*3NF%;})6=ryX%RiGk_Vi!R=JC)OVSTbKh{OKCYZX&6iVrZYFArPqmB00RH+sbIP) zD^Kt~`B);b zm{}h}iW#m&r2@3DyF}z{Ct;_FkYQADEPPYVP`$6@HeKd1!L*ctv495f@}&y~?CtHz zF?K@=+dkT4JC}rhWB15Hmaklq17PQ;6EuLV2R2bKY|3rL%;pojZIc-)QVI8J9VvMW z8mr`a{J5NH^QB=mE^Ou?PXe8(U#R2KSAileLKGIjGqP;!LPeD5!Am2TE)3vhZssPgvz&oI$_Z9Yh8i-SgFH-E zHPcseuZ`)f=jbLjL&n*Cpjr3WUKM7?co1qLD8tZT%s<_AxXp+%J_eeJ{@z?YdYhga zSTIV@(}gA=9!-JDKq4v_5?CE&*WI)$^p?@^-I?(TVRnWjP%tGmLp)cmjJ!5_df?2^ z>5);nSB`US-5`S?7cE3Lq#PS&jY^UoO&KOcq`@<0q;xof_7UAo?n@w&{|5Vuut^T> z|0G95e}DCNc!puOhGBF;3MNEUCzQ(2wy(&KU=}hZFQaV?A_+QD4%#fpU{@M;WP(F< zoXXHlKfRHtDshr-F@0V$^4LM5x==n;HGqhpFJZ86mOoWJ+>Ttpr)f3?YVOT^Fe8>U zNhM9&rV@7f9l0`x{E5GK`SiB67`NIzfzo>K>S>s%+4Oa8_&V1sMBj1AcYNDUp+I*0 zmB#Qg(Fb|fW81kbJkL~F1x^2B_m2^Nw{7wk(sg;{#`?CZ>!4Gz78a}bN!9yeL&-pO z{6-?si67(Abp`Q5j2H1~j9FiJ`Rt6z7vSz+NzcT-XtNoGt2ky16 z>v%K7o~hllb|_KXjUTF5TgT$O{>UbB$0hD~g8d*%Ufa0I?c3n?t@k}D)i^d^ZMSJ1 zB7+jOImXp(sx@pV#sGjZ6#@n1oPlPP9O2ajl`nFj|ee zyNo%&$IW^oKU#pE$l&LH%e?eD!#N7DbDLASxXO}D?XmSepFcQx2gjOBM`dDwCysN! z;!)9g_xX@8u7)^+-0@njtFyCF_MlZ8es9vy=@=*;kJmsB;tjVNYkc7c?%GhW_ zGBpX7p}$Q5`LySx>lPON7xF7o#qdb9k!=~U^-M}|S1!Rj` zDHnTJ0!sAJpyLsl7-e}C$Y9B+BHAbXD-~;lB{X_ z6Dzi@yiZ>iJNu>1ezB%M=1%%*;yoL_j)bpctGqo~dMh4Il-|Pc(Z$E*KOdFf6R*6f zyj-1;Ur^}^vgBNWLSf1It8Hhxe0lc+8Q`UnWEJO4C|D_u4 z%+@7U8>j&pMcWnm7wfR1C#EQf?L}pU-;(3RFft)#A&$>fk-Y9`_ZSV`xitwEh442B zfCUpGy0DEzV4#bS66TWgM-=dVIAN-oVHxLX*(t;_&EuD}fPaA$khFEUHyvdgj5+S*AB@Idmx(Qlc`Q=(hNF5V62H7&B03IBj>8GZ;m>zbpxvr#+N?XZ zQFrR`QL%1VsvD+Esi2w-7@c<4CmIi7DLBd4o)gCQ;C$(TX}=wnaZ&bnJ>T)bD9M?% z$oi|QCK{B{@XMy<&>W(9`^6h@pn`*LN72?}!sN@4^i@uIGpneW zhRpPqsG2et>2pt!T!FRyKRfo5V?RFr@HoCj-vtSEe`7M?xgmOPNS+%B#|?SPsFD__ z3sGg*lzD^<0chGl3brv)Fl0~gZfEbttx6pyq*Whm0#;HT&D;p0HlfY1X`!6#hi$B| zr-seM@G!(iy{|6 zjA};E_*FmhnJ*+dl}zyhU!KTZ^Ls9Wqpv2@NL}ezkQ*Uuv;2H6om1TGO)y9q>dm1_ zsVeP=Q2}74DI{!&wUO`@zNW{=s|Ezn5OU%Qf@KFe$? zlWQf3u0GMxFFE=Xj((W>BU@ixFy*@GI{*u?&^<_ci=lRrgy~jq9TY*H=I8nW@WLUZ zUtM2%O@R)vq*E&COn5tYJBUAbc)siYjyq9&M06aL97hw5qe&MmB{-br$x_H?=8}3W zoR|$)w~+F+=xe}TX>nm~=p)6j5jd=?J!`hL8$X-<$*kCZQffabR=glpK(NwYT)tIW z^P&5`JJERfQNzDz{nOTe*8cN$vGj~odL~hNh63~N=f`jSX!ZxQV$&h1>5y1@SSmf7 zC_TJYeK1)$04Sh_OjXp#Rw}*%7~u3$jbpE?&CxG`X}`zkFc$+o_2VrJ-t4dYagtS58x6#lhd?G{GObQd$~bJJw`H5P+$ z!IP@EA+HSkKQ~!jga$p<4?2ahL|y5?;Gy#X7e^N`_^eII(6>9XFnGtabJyEV(nK3G z87I%PO{131U2@Clh3CsHv$f!pK3)0^qgdn(&o{_h>Etyg!)GA8gHe!%(uu>vz%qrZ zL3Qk~j;^dZ-;jMW4_Ef$f#|=#6o4Y5Qk_FX0@#$y=T*h*E>Kqg?2y zZRTlw#E!zk@34(d-C2&Q5P6b2w8z=4ZlaF^4~=NtMW_6ZiCIHjH3-|+hp4FmgQ0X zqp_biJudt?(bEm&7rq8hTal~!qQM!AL~n#>8`og@@x9@ zGNn9kxoUYzU*odpxaB|4*Rw@!f!lmSZZL5wAi>_sqJo3b{Ua#b{k zFm1AhseUxi-URDLumSnKueTUvc zgT75_KRrenBrV4z=JB5WWcAaa)=zWDrClri5S-3t{-=PfpU37#=4=L`#75VuOhOyX{OPdCOc{UP zsvO7QwqG<{`~7^mLz}_^J2PYoE>Dw_U@BuOxh!f|SPf+cJq9|`>BdJj9uD_xwu4K% zFo|hy{C5j(fCFwG;J~+aR!?O~{g^rYPmMLvQtNNDgoO?BF47Lj*@$c1F)tKAx$*UA zfgTJ3lVOL_g?_}+i#g@%ztVLSy3lHMOoYJ*outZx^bV~b^(Om_wG#FkWAaYkr0)+t z53&_y`rAS^%?W+!M$iqK52giAq$*t-$mFT48Dn`-&qJ57MzGRRtEEF3s%gp_go3fh zSlU8i)Eh07dnH_i9@VwnSehIMx1Kw89kmPs*g_HPd06LBDvVdU=_G(g1;@&xJ=P@5 zg3U(qh*=MxVUHd=UbYdbMt0+khR zj0;a*hgS!Q(lGU$`8`xP)R9O>i^pAcnl+d-DU6YWj{eq+d|EliW}PRoO#wD;^5|YM zSzUR)3`8`L-H7x?p0YL>YuUW>6lhU%x1b_4&+NUZIzfgkBJ=IkEmZ9IEak1tQ0Zq#$~!zu@xyAw6iAGOeGl9Tjn749DW~&LDMOcOoO_`V z?T{<5Lo}j421{kYnYu$DilWd;NfeG68CI0L(K9n^tdCD$J2E9imxj35ILRNvpHr49 zES|F8ra)+?us=5W11$P*DCH(2MCqPOxgg`Ke%Qz9PQ{d4Us}q=JQZ;P^8}latlyZG zLm9=-c4c%dh~~}LQOC7wNWZ!cH41JA=0W%$;&_KL3ZvBJe?~URTHn>aMdSV^|;U@c7Rbn$ui%XP4u-%K5*Yp znVpSKnG4e~^7YM&y|5{=ba!QF!vmdx`mK`I_1r|;5Ppw(e||=O6H87@C8rbK({w!w ztorQFKlYRyT!s>;i-*=ut-Z2#>cQj&$ftph^nD~RuMN;u%>&7rh7a%Fzq?ip&5fD^ zQq6%x;6N<$)YMgcj4m57uO31Uy$>cfD%x?|$ol?A=1&hj3O(-q`NYP)L2=(8lu8;J z9~|7aT5DUj+7CbSgQgJp^Lnv;P--7!zEAeRn9qaTYr9J1$fvE< zD`%l`v$uV1>>>YfT5Rc&T6$Jq+IE|2+tyth+_40AELq>YR`}pBc&NP(Z*SBeOw=EI z(!B3yo}YLg*&n?nHup-+y>WZ;z|lvq{G-?7uC)P?>&7q1a;QC)s6CccM8GbK++m43 zylpkr^(1?aGoE0n*mFUGhzI#W-m9Tmsy{CG%V)OFUilw$#rpG7{dtg#Yn!%OUP$hR z^oJ$b3kfxH@ws035E<&c4jp^c`{`}HmmK%0)wZ|k*CseHq14#)n=h-=L>!B`svE-x zyT4W4z#s9k2lfoqkM<7jfm8y>>N{>FhZZ?&6Zi5%g z)viV`&laOZu)^KkdleH;?njrT&c4UElx9dO9g5l66>QB(KNoM?tlPIyw{QLI<6>$5 z&}V*`{|(BMpG+3{i%?+U84~Av!8~Ao{MJ9an}F#f_xdHN`IzYM`3(XJxp)`u>yCNe z_at#Ew<}4Pgy%SM{S2JNUUq|BaIF!vu4d4>_KKc^lIP$fi0}6&JpD;;#mZZvmy5Y@ zNx{lXxB&;hEq?_Wc}Ub9Uh9G*|3v>0$$uo_KeAO$7ZsFuOXb}Oe>bpfbc)&ef_bZ` zE>V9%EIKI_olLk-CS9=2V|E^n*R1z$)E-FG9@r{sSvw{c9g>O;CEPHr)Sx$HMFy^* z-~>mpNFj^X6v-+E=!N`)u$rByCi9#4sa5P-?@QDvKhfVW`TG<8{;l%Pb)Q&%Kq@Cq zs;{=vT#W(IF(^3(6OKWRN$_LK@Nm#3-#{3m-S#dy^cFM^%Gww9;o-r-;b2qBF*-Vf zdsatBQ+cDK)3f~C6urAgN8g$on^HpDBvU;(P8!^^ywFIQ5e6ZUmX&PTcYxkrB-WV7_&Jg~G|7UFE@2LNh_QGw8!|uZ*xBDQr zl~eTY8?r|U-nT7wJ0>3-HHbXCEXZsh!XhSD4P;(9EYxZTy%&xeMjm-A$Y+P(2OKqs zJX%;#jvX8-IBF1i$i+EOQ&h504I&ReOI2it6%jaU5P3ASpiVpPt4}*>IC=197GcM> zmz#>~-#Pb$`sO5MkapAz$m5iSWzb~DqJg7^k;i#Cb2o*h9W?@ZRI;>g`yLA4qYK)j z1U1@^Y@6VyLF6$kXIpBAB{?{17l;iy66QO<%or~~1sLF92&&e3Ye3WuYH zk%v55oV|J51V;@bkA6AXNjug#95swQlzuVWp+O2q4I>YEZrJTO1JaHfP96oUrWQLu z061z8dC0X1+DW#VTs4q+-I6O*Y{!NPM-3#8c9v;@9iq{2)FATUSdiI%lg;F`t43h~ ztYEjv<4cy5C(CP+9zX64+x9iv{oAIrqoyDaT zc-`_}LXn8abxG_EHldU^2Z&#?UmovL-rPJ$Z!BH`?@`{oJWRqezarkJy!rWJ<*kGd zC~u{FnetZ7S150le3kN6&DSVz9AB%v)$#SpTLa(7H(j?(nm;yQw(|S=J@1+?+xX^p zO_%Nb0lo!aIs9IHN#=DXqM^ZVcy@Ij!VhuA5R za%ork_TvM(72}h)$S}fS%600(%f0=5y%$FY5OgZ7(E&ldUi54#w{Ideelx_U^7>8> z^u07X*n8&eyqkG)gJaN!;u%hw%DKp}O{Vpba`vTFZUmk(u@4BstdPnbcV@Ok+HmF$4mtbiJ&9B0m@{mx9we!^c-F0M1G<|fY+D`0O61|xu_rJUz4T|6z< z&2@e*Ji%R)tM&q=%OM}slNbsx$`!gbH8xJ=To|}G@-kHieqkV1i1P0!O710QXr7nN zd)Z$lTPY*sI9=3-$V&A6#S=E?qNW>akp6O6y`4S1r9Oy4A)Q7LQwWt7;?PD-cg&iY z91m^GFlI4GtTw%n&8{WtEa@4`m`4rSe~_xiEu$(+=2`XHwG=ZEzn}I*9@wklq+&Y38%P%85KQkQlDq#6|4E6G6#>>bjs~oy= zFIXsBd;6`qecnbUZc@rsGd4CY*|gG>eFoXAfpDOv{zH=&n%^(rk1&OS7p}m8M=v}^ zn0L^I^)u2ta_ORQ3Z5|I2Ka0)EYRKty;>W|{s^}z>8tQD=*u!aXIW4A3}oTk=^jxz z^^Y+_!zD=cjtTn5OUGhw;}YqWwEX5 zqbqN(y#2`X&&M9y{>=5+p!CAU#KIT=bSC%FZS{^h>sM#B9s1l_ALsj8F9Lf$DCetSjN6U$XN6 z3p@vFuWLXS+7P%>4nzgNWcxlA_LAfU!Y6w7COh^skF%0DxK=BATPboy!qb3Xa<5#< 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 NpL%oJj3A@={{k!z)}{ae literal 0 HcmV?d00001 diff --git a/bal/core/__pycache__/willexecutors.cpython-311.pyc b/bal/core/__pycache__/willexecutors.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..067e7b09f9282ddddfd2f795750cc03cbda3d50c GIT binary patch literal 35142 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 Q{_&yo?pL4Ugcarg14UiSQUCw| literal 0 HcmV?d00001 diff --git a/bal/core/heirs.py b/bal/core/heirs.py new file mode 100644 index 0000000..f8d93e4 --- /dev/null +++ b/bal/core/heirs.py @@ -0,0 +1,850 @@ +""" +bal.core.heirs +============== + +Heir management and inheritance-transaction building. + +This is the heart of the plugin's Bitcoin logic and the most delicate part of +the whole codebase, so the implementation below is kept byte-for-byte identical +to the original ``heirs.py``; only the dead commented-out imports were removed +and documentation was added. + +An *heir* is stored as a small list addressed by the ``HEIR_*`` column +constants defined below. ``Heirs`` is a ``dict`` subclass persisted inside the +wallet DB under the ``"heirs"`` key. + +The ``prepare_transactions`` / ``Heirs.buildTransactions`` functions turn the +heir list plus the wallet UTXOs into a set of time-locked inheritance +transactions (optionally including a will-executor fee output). + +Will-executor "heirs" are synthetic entries whose key starts with the +``w!ll3x3c"`` marker; they are skipped by most heir comparisons. +""" + +import math +import random +import re +import threading +from typing import ( + TYPE_CHECKING, + Any, + Dict, + Optional, + Tuple, +) + +import dns +from dns.exception import DNSException +from electrum import ( + bitcoin, + constants, + dnssec, +) +from electrum.logging import Logger, get_logger +from electrum.transaction import ( + PartialTransaction, + PartialTxInput, + PartialTxOutput, + TxOutpoint, +) +from electrum.util import ( + BitcoinException, + bfh, + read_json_file, + to_string, + trigger_callback, + write_json_file, +) + +from .util import Util +from .willexecutors import Willexecutors + +if TYPE_CHECKING: + from electrum.simple_config import SimpleConfig + + +_logger = get_logger(__name__) + +# Column layout of a stored heir list. These indices are part of the on-disk +# wallet format and are relied upon all over the codebase, so they must NEVER +# be reordered. +HEIR_ADDRESS = 0 # destination Bitcoin address +HEIR_AMOUNT = 1 # requested amount (satoshis or "%") +HEIR_LOCKTIME = 2 # locktime after which the heir may claim the funds +HEIR_REAL_AMOUNT = 3 # resolved amount once percentages are computed +HEIR_DUST_AMOUNT = 4 # amount when below dust threshold (marked "DUST: ...") +TRANSACTION_LABEL = "inheritance transaction" + + +class AliasNotFoundException(Exception): + pass + + +def reduce_outputs(in_amount, out_amount, fee, outputs): + if in_amount < out_amount: + for output in outputs: + output.value = math.floor((in_amount - fee) / out_amount * output.value) + + +def create_op_return_script(data_hex: str) -> bytes: + """Build an OP_RETURN scriptPubKey (as raw bytes) from hex-encoded data. + + Used to embed a small data payload (max 80 bytes) into a transaction + output. Raises ``ValueError`` when the payload exceeds the 80-byte limit. + """ + data = bytes.fromhex(data_hex) + + if len(data) > 80: + raise ValueError("OP_RETURN data too big (max 80 bytes)") + + # Manual construction: OP_RETURN opcode followed by the data push. + if len(data) <= 75: + # Most common form: OP_RETURN + 1-byte length prefix + data. + script = b'\x6a' + bytes([len(data)]) + data + else: + # For larger payloads (up to 80 bytes) use OP_PUSHDATA1. + script = b'\x6a\x4c' + bytes([len(data)]) + data + + return script + +def prepare_transactions(locktimes, available_utxos, fees, wallet): + available_utxos = sorted( + available_utxos, + key=lambda x: "{}:{}:{}".format( + x.value_sats(), x.prevout.txid, x.prevout.out_idx + ), + ) + # total_used_utxos = [] + txsout = {} + locktimes_list = Util.get_lowest_locktimes(locktimes) + if not locktimes_list: + _logger.info("prepare transactions, no locktime") + return + locktime = locktimes_list[0] + + heirs = locktimes[locktime] + true = True + while true: + true = False + fee = fees.get(locktime, 0) + out_amount = fee + description = "" + outputs = [] + paid_heirs = {} + for name, heir in heirs.items(): + if len(heir) > HEIR_REAL_AMOUNT and "DUST" not in str( + heir[HEIR_REAL_AMOUNT] + ): + try: + real_amount = heir[HEIR_REAL_AMOUNT] + outputs.append( + PartialTxOutput.from_address_and_value( + heir[HEIR_ADDRESS], real_amount + ) + ) + out_amount += real_amount + description += f"{name}\n" + except BitcoinException as e: + _logger.info("exception decoding output {} - {}".format(type(e), e)) + heir[HEIR_REAL_AMOUNT] = e + + except Exception as e: + heir[HEIR_REAL_AMOUNT] = e + _logger.error(f"error preparing transactions: {e}") + pass + paid_heirs[name] = heir + + in_amount = 0.0 + used_utxos = [] + try: + while utxo := available_utxos.pop(): + value = utxo.value_sats() + in_amount += value + used_utxos.append(utxo) + if in_amount >= out_amount: + break + + except IndexError as e: + _logger.error( + f"error preparing transactions index error {e} {in_amount}, {out_amount}" + ) + pass + if int(in_amount) < int(out_amount): + _logger.error( + "error preparing transactions in_amount < out_amount ({} < {}) " + ) + continue + heirsvalue = out_amount + change = get_change_output(wallet, in_amount, out_amount, fee) + if change: + outputs.append(change) + for i in range(0, 100): + random.shuffle(outputs) + + #op_return_text = "Hello Bal!" + + ## Convert text to hex + #op_return_hex = op_return_text.encode('utf-8').hex() + #op_return_script = create_op_return_script(op_return_hex) + #outputs.append(PartialTxOutput(value=0, scriptpubkey=op_return_script)) + tx = PartialTransaction.from_io( + used_utxos, + outputs, + locktime=Util.parse_locktime_string(locktime), + version=2, + ) + if len(description) > 0: + tx.description = description[:-1] + else: + tx.description = "" + tx.heirsvalue = heirsvalue + tx.set_rbf(True) + tx.remove_signatures() + txid = tx.txid() + if txid is None: + raise Exception(f"txid is none: {tx}") + + tx.heirs = paid_heirs + tx.my_locktime = locktime + txsout[txid] = tx + + if change: + change_idx = tx.get_output_idxs_from_address(change.address) + prevout = TxOutpoint(txid=bfh(tx.txid()), out_idx=change_idx.pop()) + txin = PartialTxInput(prevout=prevout) + txin._trusted_value_sats = change.value + txin.script_descriptor = change.script_descriptor + txin.is_mine = True + txin._TxInput__address = change.address + txin._TxInput__scriptpubkey = change.scriptpubkey + txin._TxInput__value_sats = change.value + txin.utxo = tx + available_utxos.append(txin) + txsout[txid].available_utxos = available_utxos[:] + return txsout + + +def get_utxos_from_inputs(tx_inputs, tx, utxos): + for tx_input in tx_inputs: + prevoutstr = tx_input.prevout.to_str() + utxos[prevoutstr] = utxos.get(prevoutstr, {"input": tx_input, "txs": []}) + utxos[prevoutstr]["txs"].append(tx) + return utxos + + +# TODO calculate de minimum inputs to be invalidated +def invalidate_inheritance_transactions(wallet): + # listids = [] + utxos = {} + dtxs = {} + for k, v in wallet.get_all_labels().items(): + tx = None + if TRANSACTION_LABEL == v: + tx = wallet.adb.get_transaction(k) + if tx: + dtxs[tx.txid()] = tx + get_utxos_from_inputs(tx.inputs(), tx, utxos) + + for key, utxo in utxos.items(): + txid = key.split(":")[0] + if txid in dtxs: + for tx in utxo["txs"]: + txid = tx.txid() + del dtxs[txid] + + utxos = {} + for txid, tx in dtxs.items(): + get_utxos_from_inputs(tx.inputs(), tx, utxos) + + utxos = sorted(utxos.items(), key=lambda item: len(item[1])) + + remaining = {} + invalidated = [] + for key, value in utxos: + for tx in value["txs"]: + txid = tx.txid() + if txid not in invalidated: + invalidated.append(tx.txid()) + remaining[key] = value + + +def print_transaction(heirs, tx, locktimes, tx_fees): + jtx = tx.to_json() + print(f"TX: {tx.txid()}\t-\tLocktime: {jtx['locktime']}") + print("---") + for inp in jtx["inputs"]: + print(f"{inp['address']}: {inp['value_sats']}") + print("---") + for out in jtx["outputs"]: + heirname = "" + for key in heirs.keys(): + heir = heirs[key] + if heir[HEIR_ADDRESS] == out["address"] and str(heir[HEIR_LOCKTIME]) == str( + jtx["locktime"] + ): + heirname = key + print(f"{heirname}\t{out['address']}: {out['value_sats']}") + + print() + size = tx.estimated_size() + print( + "fee: {}\texpected: {}\tsize: {}".format( + tx.input_value() - tx.output_value(), size * tx_fees, size + ) + ) + + print() + try: + print(tx.serialize_to_network()) + except Exception: + print("impossible to serialize") + print() + + +def get_change_output(wallet, in_amount, out_amount, fee): + change_amount = int(in_amount - out_amount - fee) + if change_amount > wallet.dust_threshold(): + change_addresses = wallet.get_change_addresses_for_new_transaction() + out = PartialTxOutput.from_address_and_value(change_addresses[0], change_amount) + out.is_change = True + return out + + +def _json_safe(value, _path="heirs", _depth=0): + """Return a JSON-serializable deep copy of *value*. + + The wallet DB persists the heirs dict via ``json_db.put``, which calls + ``copy.deepcopy`` on the value. If any nested element is a live runtime + object (e.g. one holding a ``threading.RLock``), deepcopy raises + ``TypeError: cannot pickle '_thread.RLock' object`` and the whole + "Build will" task fails. + + To make persistence robust we coerce the structure to plain + JSON-compatible types (dict / list / str / int / float / bool / None). + Anything else is converted to ``str(value)`` and logged with its path so + the offending field can be identified, instead of crashing the task. + """ + # Primitive JSON scalars are kept as-is. + if value is None or isinstance(value, (bool, int, float, str)): + return value + if isinstance(value, dict): + return { + str(k): _json_safe(v, "{}[{!r}]".format(_path, k), _depth + 1) + for k, v in value.items() + } + if isinstance(value, (list, tuple)): + return [ + _json_safe(v, "{}[{}]".format(_path, i), _depth + 1) + for i, v in enumerate(value) + ] + # Unexpected runtime object: do not let it reach deepcopy. Log where it + # was found so the real source can be fixed, then store a safe string. + _logger.error( + "heirs.save: non-serializable value at {} (type={}); coercing to str. " + "value={!r}".format(_path, type(value).__name__, value) + ) + return str(value) + + +class Heirs(dict, Logger): + + def __init__(self, wallet): + Logger.__init__(self) + self.db = wallet.db + self.wallet = wallet + d = self.db.get("heirs", {}) + try: + self.update(d) + except Exception: + return + + def invalidate_transactions(self, wallet): + invalidate_inheritance_transactions(wallet) + + def save(self): + # Sanitise the heirs mapping before handing it to the wallet DB: this + # guarantees only JSON-serializable values are stored and prevents the + # "cannot pickle '_thread.RLock' object" failure that aborted the + # Build-will task when a runtime object slipped into an heir value. + self.db.put("heirs", _json_safe(dict(self))) + + def import_file(self, path): + data = read_json_file(path) + data = Heirs._validate(data) + self.update(data) + self.save() + + def export_file(self, path): + write_json_file(path, self) + + def __setitem__(self, key, value): + dict.__setitem__(self, key, value) + self.save() + + def pop(self, key): + if key in self.keys(): + res = dict.pop(self, key) + self.save() + return res + + def get_locktimes(self, from_locktime, a=False): + locktimes = {} + for key in self.keys(): + locktime = Util.parse_locktime_string(self[key][HEIR_LOCKTIME]) + if locktime > from_locktime and not a or locktime <= from_locktime and a: + locktimes[int(locktime)] = None + return list(locktimes.keys()) + + def check_locktime(self): + return False + + def normalize_perc( + self, heir_list, total_balance, relative_balance, wallet, real=False + ): + amount = 0 + for key, v in heir_list.items(): + try: + column = HEIR_AMOUNT + if real: + column = HEIR_REAL_AMOUNT + if "DUST" in str(v[column]): + column = HEIR_DUST_AMOUNT + value = int( + math.floor( + total_balance + / relative_balance + * self.amount_to_float(v[column]) + ) + ) + if value > wallet.dust_threshold(): + heir_list[key].insert(HEIR_REAL_AMOUNT, value) + amount += value + else: + heir_list[key].insert(HEIR_REAL_AMOUNT, f"DUST: {value}") + heir_list[key].insert(HEIR_DUST_AMOUNT, value) + _logger.info(f"{key}, {value} is dust will be ignored") + + except Exception as e: + raise e + return amount + + def amount_to_float(self, amount): + try: + return float(amount) + except Exception: + try: + return float(amount[:-1]) + except Exception: + return 0.0 + + def fixed_percent_lists_amount(self, from_locktime, dust_threshold, reverse=False): + fixed_heirs = {} + fixed_amount = 0.0 + percent_heirs = {} + percent_amount = 0.0 + fixed_amount_with_dust = 0.0 + for key in self.keys(): + try: + cmp = ( + Util.parse_locktime_string(self[key][HEIR_LOCKTIME]) - from_locktime + ) + if cmp <= 0: + _logger.debug( + "cmp < 0 {} {} {} {}".format( + cmp, key, self[key][HEIR_LOCKTIME], from_locktime + ) + ) + continue + if Util.is_perc(self[key][HEIR_AMOUNT]): + percent_amount += float(self[key][HEIR_AMOUNT][:-1]) + percent_heirs[key] = list(self[key]) + else: + heir_amount = int(math.floor(float(self[key][HEIR_AMOUNT]))) + fixed_amount_with_dust += heir_amount + fixed_heirs[key] = list(self[key]) + if heir_amount > dust_threshold: + fixed_amount += heir_amount + fixed_heirs[key].insert(HEIR_REAL_AMOUNT, heir_amount) + else: + fixed_heirs[key] = list(self[key]) + fixed_heirs[key].insert( + HEIR_REAL_AMOUNT, f"DUST: {heir_amount}" + ) + fixed_heirs[key].insert(HEIR_DUST_AMOUNT, heir_amount) + except Exception as e: + _logger.error(e) + return ( + fixed_heirs, + fixed_amount, + percent_heirs, + percent_amount, + fixed_amount_with_dust, + ) + + def prepare_lists( + self, balance, total_fees, wallet, willexecutor=False, from_locktime=0 + ): + if balance int(from_locktime): + try: + base_fee = int(willexecutor["base_fee"]) + willexecutors_amount += base_fee + h = [None] * 4 + h[HEIR_AMOUNT] = base_fee + h[HEIR_REAL_AMOUNT] = base_fee + h[HEIR_LOCKTIME] = locktime + h[HEIR_ADDRESS] = willexecutor["address"] + willexecutors[ + 'w!ll3x3c"' + willexecutor["url"] + '"' + str(locktime) + ] = h + except Exception: + return [], False + else: + _logger.error( + f"heir excluded from will locktime({locktime}){Util.int_locktime(locktime)} newbalance: + fixed_amount = self.normalize_perc( + fixed_heirs, newbalance, fixed_amount, wallet + ) + onlyfixed = True + + heir_list.update(fixed_heirs) + + newbalance -= fixed_amount + if newbalance > 0: + perc_amount = self.normalize_perc( + percent_heirs, newbalance, percent_amount, wallet + ) + newbalance -= perc_amount + heir_list.update(percent_heirs) + if newbalance > 0: + newbalance += fixed_amount + fixed_amount = self.normalize_perc( + fixed_heirs, newbalance, fixed_amount_with_dust, wallet, real=True + ) + newbalance -= fixed_amount + heir_list.update(fixed_heirs) + + heir_list = sorted( + heir_list.items(), + key=lambda item: Util.parse_locktime_string(item[1][HEIR_LOCKTIME]), + ) + + locktimes = {} + for key, value in heir_list: + locktime = Util.parse_locktime_string(value[HEIR_LOCKTIME]) + if locktime not in locktimes: + locktimes[locktime] = {key: value} + else: + locktimes[locktime][key] = value + return locktimes, onlyfixed + + def is_perc(self, key): + return Util.is_perc(self[key][HEIR_AMOUNT]) + + def buildTransactions( + self, bal_plugin, wallet, tx_fees=None, utxos=None, from_locktime=0 + ): + Heirs._validate(self) + if len(self) <= 0: + _logger.info("while building transactions there was no heirs") + return + balance = 0.0 + len_utxo_set = 0 + available_utxos = [] + if not utxos: + utxos = wallet.get_utxos() + willexecutors = Willexecutors.get_willexecutors(bal_plugin) or {} + self.decimal_point = bal_plugin.get_decimal_point() + no_willexecutors = bal_plugin.NO_WILLEXECUTOR.get() + for utxo in utxos: + if utxo.value_sats() > 0 * tx_fees: + balance += utxo.value_sats() + len_utxo_set += 1 + available_utxos.append(utxo) + if len_utxo_set == 0: + _logger.info("no usable utxos") + return + j = -2 + willexecutorsitems = list(willexecutors.items()) + willexecutorslen = len(willexecutorsitems) + alltxs = {} + while True: + j += 1 + if j >= willexecutorslen: + break + elif 0 <= j: + url, willexecutor = willexecutorsitems[j] + if not Willexecutors.is_selected(willexecutor) or willexecutor["base_fee"] < wallet.dust_threshold(): + continue + else: + willexecutor["url"] = url + elif j == -1: + if not no_willexecutors: + continue + url = willexecutor = False + else: + break + fees = {} + i = 0 + while i < 10: + txs = {} + redo = False + i += 1 + total_fees = 0 + for fee in fees: + total_fees += int(fees[fee]) + # newbalance = balance + try: + locktimes, onlyfixed = self.prepare_lists( + balance, total_fees, wallet, willexecutor, from_locktime + ) + except WillExecutorFeeException: + i = 10 + continue + if locktimes: + try: + txs = prepare_transactions( + locktimes, available_utxos[:], fees, wallet + ) + if not txs: + return {} + except Exception as e: + _logger.error( + f"build transactions: error preparing transactions: {e}" + ) + try: + if "w!ll3x3c" in e.heirname: + Willexecutors.is_selected( + e.heirname[len("w!ll3x3c") :], False + ) + break + except Exception: + raise e + total_fees = 0 + total_fees_real = 0 + total_in = 0 + for txid, tx in txs.items(): + tx.willexecutor = willexecutor + fee = tx.estimated_size() * tx_fees + txs[txid].tx_fees = tx_fees + total_fees += fee + total_fees_real += tx.get_fee() + total_in += tx.input_value() + rfee = tx.input_value() - tx.output_value() + if rfee < fee or rfee > fee + wallet.dust_threshold(): + redo = True + # oldfees = fees.get(tx.my_locktime, 0) + fees[tx.my_locktime] = fee + + if balance - total_in > wallet.dust_threshold(): + redo = True + if not redo: + break + if i >= 10: + break + else: + _logger.info( + f"no locktimes for willexecutor {willexecutor} skipped" + ) + break + alltxs.update(txs) + + return alltxs + + def get_transactions( + self, bal_plugin, wallet, tx_fees, utxos=None, from_locktime=0 + ): + txs = self.buildTransactions(bal_plugin, wallet, tx_fees, utxos, from_locktime) + if txs: + temp_txs = {} + for txid in txs: + if txs[txid].available_utxos: + temp_txs.update( + self.get_transactions( + bal_plugin, + wallet, + tx_fees, + txs[txid].available_utxos, + txs[txid].locktime, + ) + ) + txs.update(temp_txs) + return txs + + def resolve(self, k): + if bitcoin.is_address(k): + return {"address": k, "type": "address"} + if k in self.keys(): + _type, addr = self[k] + if _type == "address": + return {"address": addr, "type": "heir"} + if openalias := self.resolve_openalias(k): + return openalias + raise AliasNotFoundException("Invalid Bitcoin address or alias", k) + + @classmethod + def resolve_openalias(cls, url: str) -> Dict[str, Any]: + out = cls._resolve_openalias(url) + if out: + address, name, validated = out + return { + "address": address, + "name": name, + "type": "openalias", + "validated": validated, + } + return {} + + def by_name(self, name): + for k in self.keys(): + _type, addr = self[k] + if addr.casefold() == name.casefold(): + return {"name": addr, "type": _type, "address": k} + return None + + def fetch_openalias(self, config: "SimpleConfig"): + self.alias_info = None + alias = config.OPENALIAS_ID + if alias: + alias = str(alias) + + def f(): + self.alias_info = self._resolve_openalias(alias) + trigger_callback("alias_received") + + t = threading.Thread(target=f) + t.daemon = True + t.start() + + @classmethod + def _resolve_openalias(cls, url: str) -> Optional[Tuple[str, str, bool]]: + # support email-style addresses, per the OA standard + url = url.replace("@", ".") + try: + records, validated = dnssec.query(url, dns.rdatatype.TXT) + except DNSException as e: + _logger.info(f"Error resolving openalias: {repr(e)}") + return None + prefix = "btc" + for record in records: + string = to_string(record.strings[0], "utf8") + if string.startswith("oa1:" + prefix): + address = cls.find_regex(string, r"recipient_address=([A-Za-z0-9]+)") + name = cls.find_regex(string, r"recipient_name=([^;]+)") + if not name: + name = address + if not address: + continue + return address, name, validated + + @staticmethod + def find_regex(haystack, needle): + regex = re.compile(needle) + try: + return regex.search(haystack).groups()[0] + except AttributeError: + return None + + def validate_address(address): + if not bitcoin.is_address(address, net=constants.net): + raise NotAnAddress(f"not an address,{address}") + return address + + def validate_amount(amount): + try: + famount = float(amount[:-1]) if Util.is_perc(amount) else float(amount) + if famount <= 0.00000001: + raise AmountNotValid(f"amount have to be positive {famount} < 0") + except Exception as e: + raise AmountNotValid(f"amount not properly formatted, {e}") + return amount + + def validate_locktime(locktime, timestamp_to_check=False): + try: + if timestamp_to_check: + if Util.parse_locktime_string(locktime, None) < timestamp_to_check: + raise HeirExpiredException() + except Exception as e: + raise LocktimeNotValid(f"locktime string not properly formatted, {e}") + return locktime + + def validate_heir(k, v, timestamp_to_check=False): + address = Heirs.validate_address(v[HEIR_ADDRESS]) + amount = Heirs.validate_amount(v[HEIR_AMOUNT]) + locktime = Heirs.validate_locktime(v[HEIR_LOCKTIME], timestamp_to_check) + return (address, amount, locktime) + + def _validate(data, timestamp_to_check=False): + + for k, v in list(data.items()): + if k == "heirs": + return Heirs._validate(v, timestamp_to_check) + try: + Heirs.validate_heir(k, v, timestamp_to_check) + except Exception as e: + _logger.info(f"exception heir removed {e}") + data.pop(k) + return data + + +class NotAnAddress(ValueError): + pass + + +class AmountNotValid(ValueError): + pass + + +class LocktimeNotValid(ValueError): + pass + + +class HeirExpiredException(LocktimeNotValid): + pass + + +class HeirAmountIsDustException(Exception): + pass + + +class NoHeirsException(Exception): + pass + + +class WillExecutorFeeException(Exception): + def __init__(self, willexecutor): + self.willexecutor = willexecutor + + def __str__(self): + return "WillExecutorFeeException: {} fee:{}".format( + self.willexecutor["url"], self.willexecutor["base_fee"] + ) +class BalanceTooLowException(Exception): + def __init__(self,balance, dust_threshold, fees): + self.balance=balance + self.dust_threshold = dust_threshold + self.fees = fees + def __str__(self): + return f"Balance too low, balance: {self.balance}, dust threshold: {self.dust_threshold}, fees: {self.fees}" diff --git a/bal/core/plugin_base.py b/bal/core/plugin_base.py new file mode 100644 index 0000000..211a08c --- /dev/null +++ b/bal/core/plugin_base.py @@ -0,0 +1,400 @@ +""" +bal.core.plugin_base +===================== + +GUI-agnostic foundation of the plugin. + +It contains: + * :class:`BalConfig` - a thin typed wrapper around an Electrum config key + with a default value. + * :class:`BalPlugin` - the base plugin class (extends Electrum's + ``BasePlugin``) holding every configuration option + and the default "will settings". The Qt-specific + ``Plugin`` subclass lives in ``bal.gui.qt.plugin``. + * :class:`BalTimestamp`- helper to convert between relative durations + (``"30d"``, ``"1y"``) and absolute timestamps. + +It also registers the three custom persisted dictionaries (``heirs``, +``will`` and ``will_settings``) with Electrum's JSON database so they are +serialised together with the wallet file. + +This module performs **no** GUI work and imports nothing from PyQt / electrum.gui. +""" + +import os +import platform +from datetime import date, datetime, timedelta + +from electrum import constants, json_db +from electrum.logging import get_logger +from electrum.plugin import BasePlugin +from electrum.transaction import tx_from_any + +_logger = get_logger(__name__) + + +# --------------------------------------------------------------------------- # +# Wallet-DB registration +# --------------------------------------------------------------------------- # +# Electrum needs to know how to (de)serialise the custom dictionaries the +# plugin stores inside the wallet file. ``register_dict`` associates a key +# name with a conversion callable applied to each value when the wallet is +# loaded. ``will`` values run through ``get_will`` so the stored transaction +# hex is turned back into a ``Transaction`` object. +def get_will(x): + """Deserialise a stored will entry, rebuilding its ``tx`` object.""" + try: + x["tx"] = tx_from_any(x["tx"]) + except Exception as e: + raise e + return x + + +json_db.register_dict("heirs", tuple, None) +json_db.register_dict("will", dict, None) +json_db.register_dict("will_settings", lambda x: x, None) + + +class BalConfig: + """Typed accessor for a single Electrum configuration key. + + Wraps ``config.get`` / ``config.set_key`` and supplies a default value + when the key is missing. + """ + + def __init__(self, config, name, default): + self.config = config + self.name = name + self.default = default + + def get(self, default=None): + """Return the stored value, falling back to ``default`` then ``self.default``.""" + v = self.config.get(self.name, default) + if v is None: + if default is not None: + v = default + else: + v = self.default + return v + + def set(self, value, save=True): + """Persist ``value`` for this key.""" + self.config.set_key(self.name, value, save=save) + + +class BalPlugin(BasePlugin): + """Base plugin: holds configuration and default inheritance settings. + + The GUI layer subclasses this in ``bal.gui.qt.plugin.Plugin`` and adds the + Electrum ``@hook`` methods. Keeping the configuration here means the CLI + layer (or unit tests) can use the plugin logic without importing Qt. + """ + + _version = None + __version__ = "0.3.3" # AUTOMATICALLY GENERATED DO NOT EDIT + + # Command used to open an .ics calendar file, per operating system. + default_app = { + "Linux": "xdg-open", + "Windows": "cmd /c start", + "Darwin": "open", + } + + # Human-readable chain name ("bitcoin", "testnet", "regtest", ...). + chainname = ( + constants.net.NET_NAME if constants.net.NET_NAME != "mainnet" else "bitcoin" + ) + + # Default geometry hint for some dialogs (kept from the original code). + SIZE = (159, 97) + + def version(self): + """Return the plugin version, read once from the ``VERSION`` file.""" + if not self._version: + try: + f = "" + with open("{}/VERSION".format(self.plugin_dir), "r") as fi: + f = str(fi.read()) + self._version = f.strip() + except Exception as e: + _logger.error(f"failed to get version: {e}") + self._version = "unknown" + return self._version + + def __init__(self, parent, config, name): + self.logger = get_logger(__name__) + BasePlugin.__init__(self, parent, config, name) + + # Base directory for plugin data inside the Electrum data dir. + self.base_dir = os.path.join(config.electrum_path(), "bal") + self.plugin_dir = os.path.split(os.path.realpath(__file__))[0] + + # Make the plugin importable when loaded from a zip (legacy behaviour: + # the parent directory of this file is added to ``sys.path``). + zipfile = "/".join(self.plugin_dir.split("/")[:-1]) + import sys + + sys.path.insert(0, zipfile) + + self.parent = parent + self.config = config + self.name = name + + # ---------------------------------------------------------------- # + # Configuration options (all persisted via Electrum's config). + # ---------------------------------------------------------------- # + self.ASK_BROADCAST = BalConfig(config, "bal_ask_broadcast", True) + self.BROADCAST = BalConfig(config, "bal_broadcast", True) + self.LOCKTIME_TIME = BalConfig(config, "bal_locktime_time", 90) + self.LOCKTIMEDELTA_TIME = BalConfig(config, "bal_locktimedelta_time", 7) + self.ENABLE_MULTIVERSE = BalConfig(config, "bal_enable_multiverse", False) + self.TX_FEES = BalConfig(config, "bal_tx_fees", 100) + self.INVALIDATE = BalConfig(config, "bal_invalidate", True) + self.ASK_INVALIDATE = BalConfig(config, "bal_ask_invalidate", True) + self.PREVIEW = BalConfig(config, "bal_preview", True) + self.SAVE_TXS = BalConfig(config, "bal_save_txs", True) + + self.NO_WILLEXECUTOR = BalConfig(config, "bal_no_willexecutor", True) + self.HIDE_REPLACED = BalConfig(config, "bal_hide_replaced", True) + self.HIDE_INVALIDATED = BalConfig(config, "bal_hide_invalidated", True) + self.ALLOW_REPUSH = BalConfig(config, "bal_allow_repush", True) + self.FIRST_EXECUTION = BalConfig(config, "bal_first_execution", True) + self.AUTO_SIGN = BalConfig(config, "bal_auto_sign", True) + self.ALARM_NUMBER = BalConfig(config, "bal_alarm_number", 3) + self.WELIST_SERVER = BalConfig( + config, "bal_welist_server", "https://welist.bitcoin-after.life/" + ) + self.EVENT_DESCRIPTION = BalConfig( + config, + "bal_event_description", + "BAL will execution of $wallet_name\r\n heirs list: \r\n$heirs_complete", + ) + self.EVENT_SUMMARY = BalConfig( + config, "bal_event_summary", "BAL -Will execution of $wallet_name" + ) + + # Default will-executor servers, keyed by network. + self.WILLEXECUTORS = BalConfig( + config, + "bal_willexecutors", + { + "mainnet": { + "https://we.bitcoin-after.life": { + "base_fee": 100000, + "status": "New", + "info": "Bitcoin After Life Will Executor", + "address": "bc1qusymuetsz2psaqzqxv8qmzcy64d9meckj3lxxf", + "selected": True, + } + }, + "testnet": { + "https://we.bitcoin-after.life": { + "base_fee": 100000, + "status": "New", + "info": "Bitcoin After Life Will Executor", + "address": "bcrt1qa5cntu4hgadw8zd3n6sq2nzjy34sxdtd9u0gp7", + "selected": True, + } + }, + "testnet4": { + "https://we.bitcoin-after.life": { + "base_fee": 100000, + "status": "New", + "info": "Bitcoin After Life Will Executor", + "address": "bcrt1qa5cntu4hgadw8zd3n6sq2nzjy34sxdtd9u0gp7", + "selected": True, + } + }, + "regtest": { + "https://we.bitcoin-after.life": { + "base_fee": 100000, + "status": "New", + "info": "Bitcoin After Life Will Executor", + "address": "bcrt1qa5cntu4hgadw8zd3n6sq2nzjy34sxdtd9u0gp7", + "selected": True, + } + }, + }, + ) + self.WILL_SETTINGS = BalConfig( + config, + "bal_will_settings", + BalPlugin.default_will_settings(), + ) + + self.system = platform.system() + self.CALENDAR_APP = BalConfig( + config, "bal_open_app", self.default_app.get(self.system, "") + ) + + # Cached toggles used by the GUI list filters. + self._hide_invalidated = self.HIDE_INVALIDATED.get() + self._hide_replaced = self.HIDE_REPLACED.get() + + def resource_path(self, *parts): + """Absolute path to a file bundled inside the plugin directory.""" + return os.path.join(self.plugin_dir, *parts) + + def sync_hide_filters(self): + """Re-read the "hide" filter flags from the persisted config. + + The cached ``_hide_invalidated`` / ``_hide_replaced`` flags are used by + the GUI list to decide which rows to skip. They can be changed from two + different places: + + * the list toolbar buttons, which call :meth:`hide_invalidated` / + :meth:`hide_replaced` (a toggle that updates both the cache and the + config), and + * the Settings dialog checkboxes, which write the config directly + (``BalConfig.set``) without touching the cached flags. + + In the second case the cache and the config would drift apart and the + transaction list would keep filtering with the *old* value, so the + toggled rows never appear/disappear until Electrum is restarted. + Re-syncing the cache from the config here (called by ``update_all``) + keeps every code path coherent regardless of where the change came + from. + """ + self._hide_invalidated = self.HIDE_INVALIDATED.get() + self._hide_replaced = self.HIDE_REPLACED.get() + + def hide_invalidated(self): + """Toggle (and persist) the "hide invalidated transactions" filter.""" + self._hide_invalidated = not self._hide_invalidated + self.HIDE_INVALIDATED.set(self._hide_invalidated) + + def hide_replaced(self): + """Toggle (and persist) the "hide replaced transactions" filter.""" + self._hide_replaced = not self._hide_replaced + self.HIDE_REPLACED.set(self._hide_replaced) + + def validate_will_settings(self, will_settings): + """Fill in any missing will-setting with its default value.""" + defaults = BalPlugin.default_will_settings() + if not will_settings: + will_settings = [] + if int(will_settings.get("baltx_fees", 0)) < 1: + will_settings["baltx_fees"] = defaults['baltx_fees'] + if not will_settings.get("threshold"): + will_settings["threshold"] = defaults['threshold'] + if not will_settings.get("locktime"): + will_settings["locktime"] = defaults['locktime'] + return will_settings + + @staticmethod + def default_will_settings(): + """Default will settings: a fee rate plus absolute threshold/locktime.""" + will_settings = {"baltx_fees": 100} + will_settings.update(BalPlugin.default_will_settings_absolute()) + return will_settings + + @staticmethod + def default_will_settings_absolute(): + """Convert the default relative dates into absolute timestamps (from today).""" + relative_dates = BalPlugin.default_will_settings_relative() + today = date.today() + dt = datetime(today.year, today.month, today.day, 0, 0, 0) + threshold = ( + dt + timedelta(days=BalTimestamp(relative_dates["threshold"]).duration_to_days()) + ).timestamp() + locktime = ( + dt + timedelta(days=BalTimestamp(relative_dates["locktime"]).duration_to_days()) + ).timestamp() + return {"threshold": threshold, "locktime": locktime} + + @staticmethod + def default_will_settings_relative(): + """Default relative dates: 30 days threshold, 1 year locktime.""" + return {"threshold": "30d", "locktime": "1y"} + + +class BalTimestamp: + """Parse and convert relative durations / absolute timestamps. + + A value may be: + * ``"y"`` -> ``n`` years (unit ``"y"``) + * ``"d"`` -> ``n`` days (unit ``"d"``) + * an integer -> an absolute UNIX timestamp (``unit is None``) + """ + + value = None + unit = None + + def __init__(self, value): + str_value = str(value) + if str_value and str_value[-1].lower() in ("y", "d"): + self.value = int(str_value[:-1]) + self.unit = str_value[-1] + else: + try: + self.value = int(value) + except Exception as _e: + self.value = 1 + self.unit = None + + def duration_to_days(self): + """Return the duration expressed in days (years are ``*365``).""" + return self.value * 365 if self.unit == 'y' else self.value + + @staticmethod + def _safe_fromtimestamp(ts): + """``datetime.fromtimestamp`` that never raises ``OverflowError``. + + On Windows ``time_t`` is 32-bit, so ``datetime.fromtimestamp`` raises + ``OverflowError: Python int too large to convert to C int`` for any + timestamp past the year-2038 limit (e.g. ``NLOCKTIME_MAX = 2**32 - 1``, + used as the default/sentinel locktime). On 64-bit Linux the same call + succeeds, which is why this only crashed on the user's Windows build. + + We clamp out-of-range timestamps to INT32_MAX, mirroring Electrum's own + ``get_max_allowed_timestamp`` workaround (see Electrum issue #6170). + """ + INT32_MAX = 2 ** 31 - 1 + try: + return datetime.fromtimestamp(ts) + except (OSError, OverflowError, ValueError): + try: + return datetime.fromtimestamp(min(int(ts), INT32_MAX)) + except (OSError, OverflowError, ValueError): + return datetime.fromtimestamp(INT32_MAX) + + def to_date(self, from_date=None, reverse=False): + """Resolve to a ``datetime``. + + For absolute values the stored timestamp is returned; for relative ones + the duration is added to (or, if ``reverse``, subtracted from) + ``from_date`` (defaulting to *now*), normalised to midnight. + """ + if self.unit is None: + return self._safe_fromtimestamp(self.value) + else: + if from_date is None: + from_date = datetime.now() + if isinstance(from_date, (int, float)): + from_date = self._safe_fromtimestamp(from_date) + reverse = 1 if not reverse else -1 + try: + return ( + from_date + (reverse * timedelta(days=self.duration_to_days())) + ).replace(hour=0, minute=0, second=0, microsecond=0) + except (OverflowError, OSError, ValueError): + # Duration overflowed datetime's range; clamp to INT32_MAX. + return self._safe_fromtimestamp(2 ** 31 - 1).replace( + hour=0, minute=0, second=0, microsecond=0 + ) + + def to_timestamp(self, from_date=None, reverse=False): + """Same as :meth:`to_date` but returns a UNIX timestamp.""" + return self.to_date(from_date, reverse).timestamp() + + def __str__(self): + if self.unit is None: + return self._safe_fromtimestamp(self.value).isoformat() + else: + return f"{self.value}{self.unit}" + + def __repr__(self): + if self.unit is None: + return self._safe_fromtimestamp(self.value).isoformat() + else: + return f"{self.value}{self.unit}" diff --git a/bal/core/util.py b/bal/core/util.py new file mode 100644 index 0000000..3629f5a --- /dev/null +++ b/bal/core/util.py @@ -0,0 +1,551 @@ +""" +bal.core.util +============= + +Small, stateless helper functions shared across the whole plugin. + +This module is intentionally GUI-free: it only deals with locktimes, amount +encoding/decoding, and comparing transactions / inputs / outputs / heirs. + +Only UNIX timestamps are used for locktimes; block-height locktimes have been +removed. A locktime is always an absolute UNIX timestamp (seconds since epoch) +or a relative string like "30d" (30 days) or "1y" (1 year). +""" + +from datetime import datetime, timedelta + + +class Util: + """Namespace of static helpers (kept as a class to preserve the original + ``Util.method(...)`` call sites used throughout the plugin).""" + + # ------------------------------------------------------------------ # + # Locktime helpers + # ------------------------------------------------------------------ # + @staticmethod + def locktime_to_str(locktime): + """Render a locktime for display as an ISO date string.""" + try: + locktime = int(locktime) + dt = datetime.fromtimestamp(locktime).isoformat() + return dt + except Exception: + pass + return str(locktime) + + @staticmethod + def str_to_locktime(locktime): + """Parse a user-entered locktime string into its stored form. + + Relative values keep their suffix (``"30d"``, ``"1y"``); + absolute ISO dates are converted to an integer UNIX timestamp. + """ + try: + if locktime[-1] in ("y", "d"): + return locktime + else: + return int(locktime) + except Exception: + pass + dt_object = datetime.fromisoformat(locktime) + timestamp = dt_object.timestamp() + return int(timestamp) + + @staticmethod + def parse_locktime_string(locktime, now=None): + """Resolve a (possibly relative) locktime string into a concrete int. + + Supported forms: + * plain int / timestamp -> returned unchanged + * ``"y"`` -> n years from now (as a timestamp) + * ``"d"`` -> n days from now (as a timestamp) + + When *now* is provided (a ``datetime``), relative strings are resolved + relative to that instant instead of ``datetime.now()``. This is used + when checking a signed will so that ``"30d"`` always resolves to the + *original* creation-time + 30 days, preventing spurious postpone + detections on every check. + """ + try: + return int(locktime) + except Exception: + pass + try: + if now is None: + now = datetime.now() + if locktime[-1] == "y": + locktime = str(int(locktime[:-1]) * 365) + "d" + if locktime[-1] == "d": + return int( + (now + timedelta(days=int(locktime[:-1]))) + .replace(hour=0, minute=0, second=0, microsecond=0) + .timestamp() + ) + return int(locktime) + except Exception: + pass + return 0 + + @staticmethod + def int_locktime(seconds=0, minutes=0, hours=0, days=0): + """Convert a human duration into seconds.""" + return int( + seconds + + minutes * 60 + + hours * 60 * 60 + + days * 60 * 60 * 24 + ) + + # ------------------------------------------------------------------ # + # Amount helpers + # ------------------------------------------------------------------ # + @staticmethod + def encode_amount(amount, decimal_point): + """Convert a displayed BTC amount into integer satoshis. + + Percentage amounts (e.g. ``"50%"``) are passed through unchanged, since + they are resolved later against the wallet balance. + """ + if Util.is_perc(amount): + return amount + else: + try: + return int(float(amount) * pow(10, decimal_point)) + except Exception: + return 0 + + @staticmethod + def decode_amount(amount, decimal_point): + """Inverse of :meth:`encode_amount`: satoshis -> displayed string.""" + if Util.is_perc(amount): + return amount + else: + basestr = "{{:0.{}f}}".format(decimal_point) + try: + return basestr.format(float(amount) / pow(10, decimal_point)) + except Exception: + return str(amount) + + @staticmethod + def is_perc(value): + """True if ``value`` is a percentage string such as ``"25%"``.""" + try: + return value[-1] == "%" + except Exception: + return False + + # ------------------------------------------------------------------ # + # Heir / will-executor comparison helpers + # ------------------------------------------------------------------ # + @staticmethod + def cmp_array(heira, heirb): + """Element-wise equality of two sequences (length-safe).""" + try: + if len(heira) != len(heirb): + return False + for h in range(0, len(heira)): + if heira[h] != heirb[h]: + return False + return True + except Exception: + return False + + @staticmethod + def cmp_heir(heira, heirb): + """Two heirs are "the same" when address (0) and amount (1) match.""" + if heira[0] == heirb[0] and heira[1] == heirb[1]: + return True + return False + + @staticmethod + def cmp_willexecutor(willexecutora, willexecutorb): + """Compare two will-executor dicts by url / address / base_fee.""" + if willexecutora == willexecutorb: + return True + try: + if ( + willexecutora["url"] == willexecutorb["url"] + and willexecutora["address"] == willexecutorb["address"] + and willexecutora["base_fee"] == willexecutorb["base_fee"] + ): + return True + except Exception: + return False + return False + + @staticmethod + def search_heir_by_values(heirs, heir, values): + """Return the key of the first heir in ``heirs`` matching ``heir`` on + every column listed in ``values`` (or ``False`` if none).""" + for h, v in heirs.items(): + found = False + for val in values: + if val in v and v[val] != heir[val]: + found = True + + if not found: + return h + return False + + @staticmethod + def cmp_heir_by_values(heira, heirb, values): + """True when two heirs agree on every column index in ``values``.""" + for v in values: + if heira[v] != heirb[v]: + return False + return True + + @staticmethod + def cmp_heirs_by_values( + heirsa, heirsb, values, exclude_willexecutors=False, reverse=True + ): + """Set-equality of two heir collections, comparing only ``values``. + + When ``exclude_willexecutors`` is set, synthetic will-executor heirs + (those whose key contains the ``w!ll3x3c"`` marker) are skipped. The + ``reverse`` flag makes the comparison symmetric by running it both ways. + """ + for heira in heirsa: + if ( + exclude_willexecutors and 'w!ll3x3c"' not in heira + ) or not exclude_willexecutors: + found = False + for heirb in heirsb: + if Util.cmp_heir_by_values(heirsa[heira], heirsb[heirb], values): + found = True + if not found: + return False + if reverse: + return Util.cmp_heirs_by_values( + heirsb, + heirsa, + values, + exclude_willexecutors=exclude_willexecutors, + reverse=False, + ) + else: + return True + + @staticmethod + def cmp_heirs( + heirsa, + heirsb, + cmp_function=lambda x, y: x[0] == y[0] and x[3] == y[3], + reverse=True, + ): + """Compare two heir collections using a custom ``cmp_function``. + + Will-executor entries are ignored. As with + :meth:`cmp_heirs_by_values`, ``reverse`` makes the relation symmetric. + """ + try: + for heir in heirsa: + if 'w!ll3x3c"' not in heir: + if heir not in heirsb or not cmp_function( + heirsa[heir], heirsb[heir] + ): + if not Util.search_heir_by_values(heirsb, heirsa[heir], [0, 3]): + return False + if reverse: + return Util.cmp_heirs(heirsb, heirsa, cmp_function, False) + else: + return True + except Exception as e: + raise e + + # ------------------------------------------------------------------ # + # Transaction input/output comparison helpers + # ------------------------------------------------------------------ # + @staticmethod + def cmp_inputs(inputsa, inputsb): + """True when both input lists reference the same set of UTXOs.""" + if len(inputsa) != len(inputsb): + return False + for inputa in inputsa: + if not Util.in_utxo(inputa, inputsb): + return False + return True + + @staticmethod + def cmp_outputs(outputsa, outputsb, willexecutor_output=None): + """True when both output lists contain the same (address, value) pairs. + + The optional ``willexecutor_output`` is treated as a wildcard match so + that the will-executor's fee output does not break the comparison. + """ + if len(outputsa) != len(outputsb): + return False + for outputa in outputsa: + if not Util.cmp_output(outputa, willexecutor_output): + if not Util.in_output(outputa, outputsb): + return False + return True + + @staticmethod + def cmp_txs(txa, txb): + """Two transactions are equivalent when their inputs and outputs match.""" + if not Util.cmp_inputs(txa.inputs(), txb.inputs()): + return False + if not Util.cmp_outputs(txa.outputs(), txb.outputs()): + return False + return True + + @staticmethod + def get_value_amount(txa, txb): + """Sum of the values of outputs that appear (same addr+value) in both + transactions. Returns ``False`` as soon as an output of ``txa`` shares + neither amount nor address with any output of ``txb``.""" + outputsa = txa.outputs() + value_amount = 0 + + for outa in outputsa: + same_amount, same_address = Util.din_output(outa, txb.outputs()) + if not (same_amount or same_address): + return False + if same_amount and same_address: + value_amount += outa.value + if same_amount: + pass + if same_address: + pass + + return value_amount + + # ------------------------------------------------------------------ # + # Locktime arithmetic + # ------------------------------------------------------------------ # + @staticmethod + def chk_locktime(timestamp_to_check, locktime): + """Return True if ``locktime`` is still in the future.""" + locktime = int(locktime) + return locktime > timestamp_to_check + + @staticmethod + def anticipate_locktime(locktime, hours=0, days=0): + """Move a locktime earlier by the given amount (only timestamp locktimes). + + Never returns a value below 1. + """ + locktime = int(locktime) + try: + dt = datetime.fromtimestamp(locktime) + except (OverflowError, OSError, ValueError): + dt = datetime.fromtimestamp(min(locktime, 2 ** 31 - 1)) + dt -= timedelta(seconds=hours * 3600 + days * 86400) + out = dt.timestamp() + if out < 1: + out = 1 + return out + + @staticmethod + def cmp_locktime(locktimea, locktimeb): + """Compare two relative locktime strings sharing the same unit.""" + if locktimea == locktimeb: + return 0 + strlocktimea = str(locktimea) + strlocktimeb = str(locktimeb) + if locktimea[-1] in "yd": + if locktimeb[-1] == locktimea[-1]: + return int(strlocktimea[-1]) - int(strlocktimeb[-1]) + else: + return int(locktimea) - (locktimeb) + + @staticmethod + def is_locktime_increased(old, new): + """True when *new* locktime spec is longer/greater than *old*.""" + def _to_days(v): + if isinstance(v, str) and v[-1:] in ("d", "y"): + n = int(v[:-1]) + return n * 365 if v[-1] == "y" else n + return int(v) + return _to_days(new) > _to_days(old) + + @staticmethod + def get_locktimes(will): + """Return the distinct locktimes used by the transactions in ``will``.""" + locktimes = {} + for txid, willitem in will.items(): + locktimes[willitem["tx"].locktime] = True + return locktimes.keys() + + @staticmethod + def get_lowest_locktimes(locktimes): + """Return sorted list of timestamp locktimes.""" + sorted_timestamps = [] + for locktime in locktimes: + locktime = Util.parse_locktime_string(locktime) + if locktime is not None: + sorted_timestamps.append(locktime) + return sorted(sorted_timestamps) + + @staticmethod + def search_willtx_per_io(will, tx): + """Find a will entry whose tx has the same inputs/outputs as ``tx``.""" + for wid, w in will.items(): + if Util.cmp_txs(w["tx"], tx["tx"]): + return wid, w + return None, None + + @staticmethod + def invalidate_will(will): + raise Exception("not implemented") + + @staticmethod + def get_will_spent_utxos(will): + """Collect every input spent by any transaction in ``will``.""" + utxos = [] + for txid, willitem in will.items(): + utxos += willitem["tx"].inputs() + + return utxos + + # ------------------------------------------------------------------ # + # UTXO helpers + # ------------------------------------------------------------------ # + @staticmethod + def utxo_to_str(utxo): + """Best-effort conversion of a UTXO / input object to its ``txid:n`` str.""" + try: + return utxo.to_str() + except Exception: + pass + try: + return utxo.prevout.to_str() + except Exception: + pass + return str(utxo) + + @staticmethod + def cmp_utxo(utxoa, utxob): + """True when two UTXOs refer to the same outpoint.""" + utxoa = Util.utxo_to_str(utxoa) + utxob = Util.utxo_to_str(utxob) + if utxoa == utxob: + return True + else: + return False + + @staticmethod + def in_utxo(utxo, utxos): + """Membership test for a UTXO inside an iterable of UTXOs.""" + for s_u in utxos: + if Util.cmp_utxo(s_u, utxo): + return True + return False + + @staticmethod + def txid_in_utxo(txid, utxos): + """True if any UTXO in ``utxos`` is spent from transaction ``txid``.""" + for s_u in utxos: + if s_u.prevout.txid == txid: + return True + return False + + @staticmethod + def cmp_output(outputa, outputb): + """Two outputs are equal when both address and value match.""" + return outputa.address == outputb.address and outputa.value == outputb.value + + @staticmethod + def in_output(output, outputs): + """Membership test for an output inside an iterable of outputs.""" + for s_o in outputs: + if Util.cmp_output(s_o, output): + return True + return False + + # check all output with the same amount if none have the same address it can be a change + # return true true same address same amount + # return true false same amount different address + # return false false different amount, different address not found + @staticmethod + def din_output(out, outputs): + """Detailed output lookup used to tell a change output apart. + + Returns a ``(same_amount, same_address)`` tuple: + * ``(True, True)`` -> an output with same amount *and* address + * ``(True, False)`` -> same amount but different address (maybe change) + * ``(False, False)``-> no output with this amount + """ + same_amount = [] + for s_o in outputs: + if int(out.value) == int(s_o.value): + same_amount.append(s_o) + if out.address == s_o.address: + return True, True + else: + pass + + if len(same_amount) > 0: + return True, False + else: + return False, False + + @staticmethod + def get_change_output(wallet, in_amount, out_amount, fee): + """Build a change ``PartialTxOutput`` if the leftover exceeds dust.""" + change_amount = int(in_amount - out_amount - fee) + if change_amount > wallet.dust_threshold(): + change_addresses = wallet.get_change_addresses_for_new_transaction() + out = PartialTxOutput.from_address_and_value( + change_addresses[0], change_amount + ) + out.is_change = True + return out + + @staticmethod + def get_current_height(network): + """Return the current UNIX timestamp for locktime purposes. + + Returns time.time() as the reference timestamp. + """ + return int(datetime.now().timestamp()) + + # ------------------------------------------------------------------ # + # Misc helpers + # ------------------------------------------------------------------ # + @staticmethod + def copy(dicto, dictfrom): + """Shallow copy of ``dictfrom`` entries into ``dicto`` (in place).""" + for k, v in dictfrom.items(): + dicto[k] = v + + @staticmethod + def fix_will_settings_tx_fees(will_settings): + """Migrate the legacy ``tx_fees`` key to ``baltx_fees`` in settings. + + Returns True when a migration was performed (caller should persist). + """ + tx_fees = will_settings.get("tx_fees", False) + have_to_update = False + if tx_fees: + will_settings["baltx_fees"] = tx_fees + del will_settings["tx_fees"] + have_to_update = True + return have_to_update + + @staticmethod + def fix_will_tx_fees(will): + """Same legacy migration as above but applied to every will entry.""" + have_to_update = False + for txid, willitem in will.items(): + tx_fees = willitem.get("tx_fees", False) + if tx_fees: + will[txid]["baltx_fees"] = tx_fees + del will[txid]["tx_fees"] + have_to_update = True + return have_to_update + + @staticmethod + def text_to_hex(text: str) -> str: + """Convert text to a hexadecimal string (used for OP_RETURN payloads).""" + hex_string = text.encode('utf-8').hex() + return hex_string + + @staticmethod + def hex_to_text(hex_string: str) -> str: + """Convert a hexadecimal string back to text (for verification).""" + try: + return bytes.fromhex(hex_string).decode('utf-8') + except Exception: + return "Error: Invalid hex string" diff --git a/bal/core/will.py b/bal/core/will.py new file mode 100644 index 0000000..2e36d9f --- /dev/null +++ b/bal/core/will.py @@ -0,0 +1,1149 @@ +""" +bal.core.will +============= + +The "will": the set of time-locked inheritance transactions plus all the logic +to keep it coherent over time. + +Two classes live here: + + * :class:`Will` - a namespace of static methods operating on a *will* + dictionary (mapping ``txid -> WillItem``): building + the parent/child tree, anticipating locktimes, + detecting replaced/invalidated/confirmed entries, + validating that the will still matches the heirs and + will-executors, and building an "invalidation" + transaction. + * :class:`WillItem` - a single will transaction together with its status + flags, heirs, will-executor and fee. + +Transaction states: + * ANTICIPATED - Transaction was generated with a locktime 1-day earlier + than a pre-existing transaction sharing the same heirs. + Remains VALID. + * REPLACED - Transaction was replaced because at least one of its + inputs is spent by a new transaction with a lower + locktime. Loses VALID status. Propagates to children. + * INVALIDATED - Transaction can no longer be spent because at least one + of its inputs has been spent by a mempool/confirmed + transaction and the previous transaction no longer + exists in the will. Loses VALID status. + * UPDATED - Transaction is spendable and valid, but a new + transaction replaces it with the same locktime and + heirs. + * PENDING - Transaction is pending in the mempool (unconfirmed). + * CONFIRMED - Transaction is confirmed in the blockchain. + +Separation of concerns +----------------------- +The original ``WillItem`` carried a ``get_color()`` method returning hard-coded +hex colours for the GUI. That was pure presentation living inside the core +logic, so it has been **moved** to ``bal.gui.qt.theme.status_color(will_item)``. +The status flags themselves (the source of truth) stay here; only the mapping +"status -> colour" now lives in the GUI layer. No behaviour changed. +""" + +import copy +import time +from datetime import datetime + +from electrum.i18n import _ +from electrum.logging import Logger, get_logger +from electrum.transaction import ( + PartialTransaction, + PartialTxInput, + PartialTxOutput, + Transaction, + TxOutpoint, + tx_from_any, +) +from electrum.util import ( + bfh, +) + +from .util import Util +from .willexecutors import Willexecutors + +MIN_LOCKTIME = 1 +_logger = get_logger(__name__) + + +class Will: + @staticmethod + def get_children(will, willid): + out = [] + for _id in will: + inputs = will[_id].tx.inputs() + for idi in range(0, len(inputs)): + _input = inputs[idi] + if _input.prevout.txid.hex() == willid: + out.append([_id, idi, _input.prevout.out_idx]) + return out + + # build a tree with parent transactions + @staticmethod + def add_willtree(will): + for willid in will: + will[willid].children = Will.get_children(will, willid) + for child in will[willid].children: + if not will[child[0]].father: + will[child[0]].father = willid + + # return a list of will sorted by locktime + @staticmethod + def get_sorted_will(will): + return sorted(will.items(), key=lambda x: x[1]["tx"].locktime) + + @staticmethod + def only_valid(will): + for k, v in will.items(): + if v.get_status("VALID"): + yield k + + @staticmethod + def needs_server_check(w): + """Return True if ``w`` should be queried on its will-executor server + when the user presses Check (or on Electrum close). + + A will is queried only when it is VALID, has a will-executor assigned, + was actually PUSHED (sent), and is not yet CHECKED. The ``PUSHED`` + condition is essential: querying the server for a will that was *never* + sent would make the server (correctly) answer "I don't have this tx", + which ``WillItem.set_check_willexecutor`` then records as CHECK_FAIL. + A freshly signed-but-not-sent will would therefore turn red, even though + it is merely "signed, not sent" (which must stay blue / #2bc8ed, as in + the original BAL behaviour). Restricting the check to PUSHED wills + matches the original ``check()`` logic and avoids that false failure. + """ + return bool( + w.get_status("VALID") + and w.we + and w.get_status("PUSHED") + and not w.get_status("CHECKED") + ) + + @staticmethod + def search_equal_tx(will, tx, wid): + for w in will: + if w != wid and not tx.to_json() != will[w]["tx"].to_json(): + if will[w]["tx"].txid() != tx.txid(): + if Util.cmp_txs(will[w]["tx"], tx): + return will[w]["tx"] + return False + + @staticmethod + def get_tx_from_any(x): + try: + a = str(x) + return tx_from_any(a) + + except Exception as e: + raise e + + return x + + @staticmethod + def add_info_from_will(will, wid, wallet): + if isinstance(will[wid].tx, str): + will[wid].tx = Will.get_tx_from_any(will[wid].tx) + if wallet: + will[wid].tx.add_info_from_wallet(wallet) + for txin in will[wid].tx.inputs(): + txid = txin.prevout.txid.hex() + if txid in will: + change = will[txid].tx.outputs()[txin.prevout.out_idx] + txin._trusted_value_sats = change.value + try: + txin.script_descriptor = change.script_descriptor + except Exception: + pass + txin.is_mine = True + txin._TxInput__address = change.address + txin._TxInput__scriptpubkey = change.scriptpubkey + txin._TxInput__value_sats = change.value + txin._trusted_value_sats = change.value + + @staticmethod + def normalize_will(will, wallet=None, others_inputs=None): + others_input = others_inputs if others_inputs is not None else {} + to_delete = [] + to_add = {} + # add info from wallet + willitems = {} + for wid in will: + Will.add_info_from_will(will, wid, wallet) + willitems[wid] = WillItem(will[wid]) + will = willitems + errors = {} + for wid in will: + + txid = will[wid].tx.txid() + + if txid is None: + _logger.error("##########") + _logger.error(wid) + _logger.error(will[wid]) + _logger.error(will[wid].tx.to_json()) + + _logger.error("txid is none") + will[wid].set_status("ERROR", True) + errors[wid] = will[wid] + continue + + if txid != wid: + outputs = will[wid].tx.outputs() + ow = will[wid] + ow.normalize_locktime(others_input) + will[wid] = WillItem(ow.to_dict()) + + for i in range(0, len(outputs)): + Will.change_input( + will, wid, i, outputs[i], others_input, to_delete, to_add + ) + + to_delete.append(wid) + to_add[ow.tx.txid()] = ow.to_dict() + + # for eid, err in errors.items(): + # new_txid = err.tx.txid() + + for k, w in to_add.items(): + will[k] = w + + for wid in to_delete: + if wid in will: + del will[wid] + + @staticmethod + def new_input(txid, idx, change): + prevout = TxOutpoint(txid=bfh(txid), out_idx=idx) + inp = PartialTxInput(prevout=prevout) + inp._trusted_value_sats = change.value + inp.is_mine = True + inp._TxInput__address = change.address + inp._TxInput__scriptpubkey = change.scriptpubkey + inp._TxInput__value_sats = change.value + return inp + + # Sentinel returned by ``check_anticipate`` meaning "do not anticipate": + # it is larger than any valid 32-bit locktime, so ``set_anticipate``'s + # ``min(old_locktime, sentinel)`` keeps the old locktime untouched. + NO_ANTICIPATE = 4294967295 + 1 + + @staticmethod + def check_anticipate(ow: "WillItem", nw: "WillItem"): + """Decide the locktime the *new* will item should take w.r.t. an old one. + + Both ``ow`` (old) and ``nw`` (new) spend (at least) one common input, so + only one of them can ever be mined. When the new will differs in a way + that affects the heirs or *increases* the amount they must wait for, the + new transaction is given a locktime ONE DAY EARLIER than the old one + (``anticipate``), so it would be mined first and supersede the old one. + + The decision tree: + + * Heirs (address + requested amount) differ -> anticipate + * Same heirs, both have a will-executor: + - same will-executor url: + * old base_fee > new base_fee -> anticipate + (heirs effectively receive more -> bring it forward) + * the per-byte ``tx_fees`` changed -> anticipate + * otherwise (e.g. base_fee merely *increased*, nothing else + changed) -> keep the old locktime + (this is the UPDATED case: a new tx with the SAME + locktime and SAME heirs replaces the old one) + - different will-executor url -> keep the old locktime + * Same heirs, no will-executor change: + - the resolved heir amounts (column 3) differ -> anticipate + - otherwise -> keep the locktime + + Returns the chosen locktime, or :data:`NO_ANTICIPATE` when the new + locktime is already earlier than ``anticipate`` (nothing to do). + """ + anticipate = Util.anticipate_locktime(ow.tx.locktime, days=1) + if int(nw.tx.locktime) < int(anticipate): + # The new will is already earlier than one day before the old one; + # there is nothing to anticipate. + return Will.NO_ANTICIPATE + + if not Util.cmp_heirs_by_values( + ow.heirs, nw.heirs, [0, 1], exclude_willexecutors=True + ): + # Heirs (address / requested amount) changed -> bring it forward. + return anticipate + + if nw.we and ow.we: + if ow.we["url"] == nw.we["url"]: + if int(ow.we["base_fee"]) > int(nw.we["base_fee"]): + # Will-executor now takes a SMALLER fee -> heirs get more, + # so anticipate. + return anticipate + if int(ow.tx_fees) != int(nw.tx_fees): + # Mining fee rate changed -> anticipate. + return anticipate + # Same url, base_fee not lowered, same tx_fees: this is a plain + # update (e.g. base_fee increased). Keep the same locktime. + return ow.tx.locktime + # Different will-executor URL: keep the same locktime. + return ow.tx.locktime + + # No will-executor on at least one side. + if nw.we == ow.we: + if not Util.cmp_heirs_by_values(ow.heirs, nw.heirs, [0, 3]): + # Resolved heir amounts differ -> anticipate. + return anticipate + return ow.tx.locktime + # One has a will-executor, the other doesn't: keep the same locktime. + return ow.tx.locktime + + @staticmethod + def change_input(will, otxid, idx, change, others_inputs, to_delete, to_append): + ow = will[otxid] + ntxid = ow.tx.txid() + if otxid != ntxid: + for wid in will: + w = will[wid] + inputs = w.tx.inputs() + outputs = w.tx.outputs() + found = False + old_txid = w.tx.txid() + # ntx = None + for i in range(0, len(inputs)): + if ( + inputs[i].prevout.txid.hex() == otxid + and inputs[i].prevout.out_idx == idx + ): + if isinstance(w.tx, Transaction): + will[wid].tx = PartialTransaction.from_tx(w.tx) + will[wid].tx.set_rbf(True) + will[wid].tx._inputs[i] = Will.new_input(wid, idx, change) + found = True + if found: + pass + + new_txid = will[wid].tx.txid() + if old_txid != new_txid: + to_delete.append(old_txid) + to_append[new_txid] = will[wid] + outputs = will[wid].tx.outputs() + for i in range(0, len(outputs)): + Will.change_input( + will, + wid, + i, + outputs[i], + others_inputs, + to_delete, + to_append, + ) + + @staticmethod + def get_all_inputs(will, only_valid=False): + all_inputs = {} + for w, wi in will.items(): + if not only_valid or wi.get_status("VALID"): + inputs = wi.tx.inputs() + for i in inputs: + prevout_str = i.prevout.to_str() + inp = [w, will[w], i] + if prevout_str not in all_inputs: + all_inputs[prevout_str] = [inp] + else: + all_inputs[prevout_str].append(inp) + return all_inputs + + @staticmethod + def get_all_inputs_min_locktime(all_inputs): + all_inputs_min_locktime = {} + + for i, values in all_inputs.items(): + min_locktime = min(values, key=lambda x: x[1].tx.locktime)[1].tx.locktime + for w in values: + if w[1].tx.locktime == min_locktime: + if i not in all_inputs_min_locktime: + all_inputs_min_locktime[i] = [w] + else: + all_inputs_min_locktime[i].append(w) + + return all_inputs_min_locktime + + @staticmethod + def search_anticipate_rec(will, old_inputs): + redo = False + to_delete = [] + to_append = {} + new_inputs = Will.get_all_inputs(will, only_valid=True) + for nid, nwi in will.items(): + if nwi.search_anticipate(new_inputs): + if nid != nwi.tx.txid(): + redo = True + to_delete.append(nid) + to_append[nwi.tx.txid()] = nwi + outputs = nwi.tx.outputs() + for i in range(0, len(outputs)): + Will.change_input( + will, nid, i, outputs[i], new_inputs, to_delete, to_append + ) + if nwi.search_anticipate(old_inputs): + if nid != nwi.tx.txid(): + redo = True + + to_delete.append(nid) + to_append[nwi.tx.txid()] = nwi + outputs = nwi.tx.outputs() + for i in range(0, len(outputs)): + Will.change_input( + will, nid, i, outputs[i], new_inputs, to_delete, to_append + ) + + for w in to_delete: + try: + del will[w] + except Exception: + pass + for k, w in to_append.items(): + will[k] = w + if redo: + + Will.search_anticipate_rec(will, old_inputs) + + @staticmethod + def update_will(old_will, new_will): + all_old_inputs = Will.get_all_inputs(old_will, only_valid=True) + # all_inputs_min_locktime = Will.get_all_inputs_min_locktime(all_old_inputs) + # all_new_inputs = Will.get_all_inputs(new_will) + # check if the new input is already spent by other transaction + # if it is use the same locktime, or anticipate. + Will.search_anticipate_rec(new_will, all_old_inputs) + other_inputs = Will.get_all_inputs(old_will, {}) + try: + Will.normalize_will(new_will, others_inputs=other_inputs) + except Exception as e: + raise e + + for oid in Will.only_valid(old_will): + if oid in new_will: + new_heirs = new_will[oid].heirs + new_we = new_will[oid].we + + new_will[oid] = old_will[oid] + new_will[oid].heirs = new_heirs + new_will[oid].we = new_we + + continue + else: + continue + + @staticmethod + def get_higher_input_for_tx(will): + out = {} + for wid in will: + wtx = will[wid].tx + found = False + for inp in wtx.inputs(): + if inp.prevout.txid.hex() in will: + found = True + break + if not found: + out[inp.prevout.to_str()] = inp + return out + + @staticmethod + def invalidate_will(will, wallet, fees_per_byte): + will_only_valid = Will.only_valid_list(will) + inputs = Will.get_all_inputs(will_only_valid) + utxos = wallet.get_utxos() + filtered_inputs = [] + prevout_to_spend = [] + current_timestamp = int(time.time()) + for prevout_str, ws in inputs.items(): + for w in ws: + if w[0] not in filtered_inputs: + filtered_inputs.append(w[0]) + if prevout_str not in prevout_to_spend: + prevout_to_spend.append(prevout_str) + balance = 0 + utxo_to_spend = [] + for utxo in utxos: + utxo_str = utxo.prevout.to_str() + if utxo_str in prevout_to_spend: + balance += inputs[utxo_str][0][2].value_sats() + utxo_to_spend.append(utxo) + if len(utxo_to_spend) > 0: + change_addresses = wallet.get_change_addresses_for_new_transaction() + out = PartialTxOutput.from_address_and_value(change_addresses[0], balance) + out.is_change = True + locktime = current_timestamp + tx = PartialTransaction.from_io( + utxo_to_spend, [out], locktime=locktime, version=2 + ) + tx.set_rbf(True) + fee = tx.estimated_size() * fees_per_byte + if balance - fee > 0: + out = PartialTxOutput.from_address_and_value( + change_addresses[0], balance - fee + ) + tx = PartialTransaction.from_io( + utxo_to_spend, [out], locktime=locktime, version=2 + ) + tx.set_rbf(True) + + _logger.debug(f"invalidation tx: {tx}") + return tx + + else: + _logger.debug(f"balance({balance}) - fee({fee}) <=0") + pass + else: + _logger.debug("len utxo_to_spend <=0") + pass + + @staticmethod + def is_new(will): + for wid, w in will.items(): + if w.get_status("VALID") and not w.get_status("COMPLETE"): + return True + + @staticmethod + def search_rai(all_inputs, all_utxos, will, wallet): + # will_only_valid = Will.only_valid_or_replaced_list(will) + for inp, ws in all_inputs.items(): + inutxo = Util.in_utxo(inp, all_utxos) + for w in ws: + wi = w[1] + if ( + wi.get_status("VALID") + or wi.get_status("CONFIRMED") + or wi.get_status("PENDING") + ): + prevout_id = w[2].prevout.txid.hex() + if not inutxo: + if prevout_id in will: + wo = will[prevout_id] + if wo.get_status("REPLACED"): + wi.set_status("REPLACED", True) + if wo.get_status("INVALIDATED"): + wi.set_status("INVALIDATED", True) + + else: + if wallet.db.get_transaction(wi._id): + wi.set_status("CONFIRMED", True) + else: + wi.set_status("INVALIDATED", True) + + for child in wi.search(all_inputs): + if child.tx.locktime < wi.tx.locktime: + _logger.debug("a child was found") + wi.set_status("REPLACED", True) + else: + pass + Will.search_updated(all_inputs) + + @staticmethod + def search_updated(all_inputs): + """Mark superseded-but-still-valid transactions as ``UPDATED``. + + When the user changes something that does NOT move the deadline nor the + money paid to the heirs -- the classic case being a will-executor's + ``base_fee`` increase -- the plugin rebuilds the inheritance keeping the + SAME locktime and the SAME heirs (same destination address and amount). + The result is two transactions that: + + * spend the same wallet UTXO(s), + * share the exact same ``tx.locktime``, + * pay the same heirs the same amounts, + + but have different txids (because the will-executor fee output changed). + + Both remain technically spendable, so neither must lose ``VALID``. The + newer transaction (the one created later, i.e. the larger ``time``) + becomes the active one, and the older transaction it superseded is + flagged ``UPDATED`` while keeping ``VALID`` (per the state spec). + + Detection is symmetric over every shared input: for each pair of valid + will items sharing an input we compare locktime and heirs; the older of + the two (by creation ``time``) is the one that was updated. + """ + seen_pairs = set() + for inp, ws in all_inputs.items(): + # Only valid transactions can be "updated"; replaced/invalidated + # ones already lost their VALID status and are handled elsewhere. + valid_ws = [w for w in ws if w[1].get_status("VALID")] + for i in range(len(valid_ws)): + for j in range(i + 1, len(valid_ws)): + wa = valid_ws[i][1] + wb = valid_ws[j][1] + if wa._id == wb._id: + continue + # Avoid processing the same pair twice (it may share more + # than one input). + pair_key = tuple(sorted((wa._id, wb._id))) + if pair_key in seen_pairs: + continue + seen_pairs.add(pair_key) + if int(wa.tx.locktime) != int(wb.tx.locktime): + # Different deadlines -> this is an anticipate/replace + # case, not an update. + continue + if not Util.cmp_heirs_by_values( + wa.heirs or {}, + wb.heirs or {}, + [0, 1], + exclude_willexecutors=True, + ): + # Heirs (address + requested amount) differ -> not a + # plain update. + continue + # Same locktime and same heirs: the older transaction was + # superseded by the newer one. Pick the older by creation + # time (fall back to a stable order when time is missing). + ta = wa.time if wa.time is not None else 0 + tb = wb.time if wb.time is not None else 0 + if ta == tb: + older = wa if wa._id <= wb._id else wb + else: + older = wa if ta < tb else wb + older.set_status("UPDATED", True) + + @staticmethod + def utxos_strs(utxos): + return [Util.utxo_to_str(u) for u in utxos] + + @staticmethod + def set_invalidate(wid, will=None): + will = will if will is not None else {} + will[wid].set_status("INVALIDATED", True) + if will[wid].children: + for c in will[wid].children.items(): + Will.set_invalidate(c[0], will) + + @staticmethod + def check_tx_height(tx, wallet): + info = wallet.get_tx_info(tx) + return info.tx_mined_status.height() + + # check if transactions are still technically valid + @staticmethod + def check_invalidated(willtree, utxos_list, wallet): + """Reconcile each will transaction against the wallet's view of its inputs. + + For every will item whose parent is gone/confirmed/pending, we look at + its inputs. If an input is no longer an unspent output of the wallet + (it is not in ``utxos_list``), then *something* has spent it, and we ask + Electrum what happened to OUR transaction via its mined-status height: + + * ``height > 0`` -> confirmed in a block -> CONFIRMED + * ``height == 0`` (UNCONFIRMED) -> seen in the mempool -> PENDING + * ``height == -1`` (UNCONF_PARENT)-> mempool (unconf parent)-> PENDING + * anything else (LOCAL / FUTURE / unknown) -> the input was spent by + a different transaction, so ours can no longer be broadcast -> + INVALIDATED (cascades to children). + + Electrum's height sentinels (from ``electrum.address_synchronizer``): + ``TX_HEIGHT_LOCAL = -2``, ``TX_HEIGHT_UNCONFIRMED = 0``, + ``TX_HEIGHT_UNCONF_PARENT = -1``, ``TX_HEIGHT_FUTURE = -3``. + """ + for wid, w in willtree.items(): + if ( + not w.father + or willtree[w.father].get_status("CONFIRMED") + or willtree[w.father].get_status("PENDING") + ): + for inp in w.tx.inputs(): + inp_str = Util.utxo_to_str(inp) + if inp_str not in utxos_list: + if wallet: + height = Will.check_tx_height(w.tx, wallet) + if height > 0: + # Mined in a block. + w.set_status("CONFIRMED", True) + elif height in (0, -1): + # Seen in the mempool (unconfirmed, possibly with + # an unconfirmed parent). + w.set_status("PENDING", True) + else: + # LOCAL / FUTURE / unknown: the spent input was + # taken by some other transaction, so this will + # can no longer be broadcast. + Will.set_invalidate(wid, willtree) + + # def reflect_to_children(treeitem): + # if not treeitem.get_status("VALID"): + # _logger.debug(f"{tree:item._id} status not valid looking for children") + # for child in treeitem.children: + # wc = willtree[child] + # if wc.get_status("VALID"): + # if treeitem.get_status("INVALIDATED"): + # wc.set_status("INVALIDATED", True) + # if treeitem.get_status("REPLACED"): + # wc.set_status("REPLACED", True) + # if wc.children: + # Will.reflect_to_children(wc) + + @staticmethod + def check_amounts(heirs, willexecutors, all_utxos, timestamp_to_check, dust): + fixed_heirs, fixed_amount, perc_heirs, perc_amount, fixed_amount_with_dust = ( + heirs.fixed_percent_lists_amount(timestamp_to_check, dust, reverse=True) + ) + wallet_balance = 0 + for utxo in all_utxos: + wallet_balance += utxo.value_sats() + + if fixed_amount >= wallet_balance: + raise FixedAmountException( + f"Fixed amount({fixed_amount}) >= {wallet_balance}" + ) + if perc_amount != 100: + raise PercAmountException(f"Perc amount({perc_amount}) =! 100%") + + for url, wex in willexecutors.items(): + if Willexecutors.is_selected(wex): + temp_balance = wallet_balance - int(wex["base_fee"]) + if fixed_amount >= temp_balance: + raise FixedAmountException( + f"Willexecutor{url} excess base fee({wex['base_fee']}), {fixed_amount} >={temp_balance}" + ) + + @staticmethod + def check_will(will, all_utxos, wallet, timestamp_to_check): + Will.add_willtree(will) + utxos_list = Will.utxos_strs(all_utxos) + + Will.check_invalidated(will, utxos_list, wallet) + + all_inputs = Will.get_all_inputs(will, only_valid=True) + all_inputs_min_locktime = Will.get_all_inputs_min_locktime(all_inputs) + Will.check_will_expired( + all_inputs_min_locktime, timestamp_to_check + ) + + all_inputs = Will.get_all_inputs(will, only_valid=True) + + Will.search_rai(all_inputs, all_utxos, will, wallet) + + @staticmethod + def get_min_locktime(will,default_value=None): + return min((v.tx.locktime for v in will.values() if v.get_status('VALID')), default=default_value) + + + + @staticmethod + def is_will_valid( + will, + timestamp_to_check, + tx_fees, + all_utxos, + heirs=None, + willexecutors=None, + self_willexecutor=False, + wallet=False, + callback_not_valid_tx=None, + ): + heirs = heirs if heirs is not None else {} + willexecutors= willexecutors if willexecutors is not None else {} + + Will.check_will(will, all_utxos, wallet, timestamp_to_check) + if heirs: + if not Will.check_willexecutors_and_heirs( + will, + heirs, + willexecutors, + self_willexecutor, + timestamp_to_check, + tx_fees, + ): + raise NotCompleteWillException() + + all_inputs = Will.get_all_inputs(will, only_valid=True) + + _logger.info("check all utxo in wallet are spent") + if all_inputs: + for utxo in all_utxos: + if utxo.value_sats() > 68 * tx_fees: + if not Util.in_utxo(utxo, all_inputs.keys()): + _logger.info("utxo is not spent", utxo.to_json()) + _logger.debug(all_inputs.keys()) + raise NotCompleteWillException( + "Some utxo in the wallet is not included" + ) + + _logger.info("will ok") + return True + + @staticmethod + def check_will_expired(all_inputs_min_locktime, timestamp_to_check): + _logger.info("check if some transaction is expired") + for prevout_str, wid in all_inputs_min_locktime.items(): + for w in wid: + if w[1].get_status("VALID"): + locktime = int(wid[0][1].tx.locktime) + if locktime < int(timestamp_to_check): + raise WillExpiredException( + f"Will Expired {wid[0][0]}: {locktime}<{timestamp_to_check}" + ) + else: + _logger.debug(f"Will Not Expired {wid[0][0]}: {datetime.fromtimestamp(locktime).isoformat()} > {datetime.fromtimestamp(timestamp_to_check).isoformat()}") + + # def check_all_input_spent_are_in_wallet(): + # _logger.info("check all input spent are in wallet or valid txs") + # for inp, ws in all_inputs.items(): + # if not Util.in_utxo(inp, all_utxos): + # for w in ws: + # if w[1].get_status("VALID"): + # prevout_id = w[2].prevout.txid.hex() + # parentwill = will.get(prevout_id, False) + # if not parentwill or not parentwill.get_status("VALID"): + # w[1].set_status("INVALIDATED", True) + + @staticmethod + def only_valid_list(will): + out = {} + for wid, w in will.items(): + if w.get_status("VALID"): + out[wid] = w + return out + + @staticmethod + def only_valid_or_replaced_list(will): + out = [] + for wid, w in will.items(): + wi = w + if wi.get_status("VALID") or wi.get_status("REPLACED"): + out.append(wid) + return out + + @staticmethod + def check_willexecutors_and_heirs( + will, heirs, willexecutors, self_willexecutor, check_date, tx_fees + ): + _logger.debug("check willexecutors heirs") + no_willexecutor = 0 + willexecutors_found = {} + heirs_found = {} + will_only_valid = Will.only_valid_list(will) + if len(will_only_valid) < 1: + return False + for wid in Will.only_valid_list(will): + w = will[wid] + if w.tx_fees != tx_fees: + raise TxFeesChangedException(f"{tx_fees}: {w.tx_fees}") + for wheir in w.heirs: + if not 'w!ll3x3c"' == wheir[:9]: + their = will[wid].heirs[wheir] + if heir := heirs.get(wheir, None): + + if heir[0] == their[0] and heir[1] == their[1]: + # The requested (possibly new) locktime for this heir. + _base = ( + datetime.fromtimestamp(w.time) + if w.time and isinstance(heir[2], str) + and heir[2][-1:] in ("d", "y") + else None + ) + new_locktime = Util.parse_locktime_string( + heir[2], now=_base + ) + tx_locktime = int(w.tx.locktime) + # Count the heir as found when the resolved locktime + # matches OR when the raw locktime spec is unchanged + # (the latter covers anticipation, which reduces + # tx.locktime but leaves the stored spec intact). + if ( + new_locktime == tx_locktime + or their[2] == heir[2] + ): + count = heirs_found.get(wheir, 0) + heirs_found[wheir] = count + 1 + elif new_locktime > tx_locktime and ( + w.get_status("COMPLETE") or w.get_status("PUSHED") + ): + # POSTPONE: compare raw specs to avoid false + # positives from anticipation. + if Util.is_locktime_increased(their[2], heir[2]): + raise WillPostponedException( + f"{wheir}: locktime postponed " + f"{their[2]}->{heir[2]} " + f"on a signed/sent will" + ) + # new_locktime < tx_locktime (anticipate) is left to + # check_will_expired -> WillExpiredException. + else: + # The will still carries this heir, but the heir is no + # longer present in the current heirs set: the user + # removed it. This must trigger a rebuild exactly like + # "heir added" does, otherwise the removed heir would + # silently stay in the inheritance transaction. Raising + # HeirNotFoundException reuses the same rebuild path used + # by the Check button and by on_close (Electrum quit). + _logger.debug( + f"heir removed, transaction is not valid:" + f"{wheir} {wid}, {w}" + ) + raise HeirNotFoundException(wheir) + + if willexecutor := w.we: + count = willexecutors_found.get(willexecutor["url"], 0) + if Util.cmp_willexecutor( + willexecutor, willexecutors.get(willexecutor["url"], None) + ): + willexecutors_found[willexecutor["url"]] = count + 1 + + else: + no_willexecutor += 1 + count_heirs = 0 + for h in heirs: + + if Util.parse_locktime_string(heirs[h][2]) >= check_date: + count_heirs += 1 + if h not in heirs_found: + _logger.debug(f"heir: {h} not found") + raise HeirNotFoundException(h) + if not count_heirs: + raise NoHeirsException("there are not valid heirs") + if self_willexecutor and no_willexecutor == 0: + raise NoWillExecutorNotPresent("Backup tx") + for url, we in willexecutors.items(): + if Willexecutors.is_selected(we): + if url not in willexecutors_found: + _logger.debug(f"will-executor: {url} not fount") + raise WillExecutorNotPresent(url) + _logger.info("will is coherent with heirs and will-executors") + return True + + + +class WillItem(Logger): + STATUS_DEFAULT = { + "ANTICIPATED": ["Anticipated", False], + "BROADCASTED": ["Broadcasted", False], + "CHECKED": ["Checked", False], + "CHECK_FAIL": ["Check Failed", False], + "COMPLETE": ["Signed", False], + "CONFIRMED": ["Confirmed", False], + "ERROR": ["Error", False], + "EXPIRED": ["Expired", False], + "EXPORTED": ["Exported", False], + "IMPORTED": ["Imported", False], + "INVALIDATED": ["Invalidated", False], + "PENDING": ["Pending", False], + "PUSH_FAIL": ["Push failed", False], + "PUSHED": ["Pushed", False], + "REPLACED": ["Replaced", False], + "RESTORED": ["Restored", False], + "UPDATED": ["Updated", False], + "VALID": ["Valid", True], + } + + def set_status(self, status, value=True): + if self.STATUS[status][1] == bool(value): + return None + + self.status += "." + (("NOT " if not value else "") + _(self.STATUS[status][0])) + self.STATUS[status][1] = bool(value) + if value: + # ANITICIPATED: valid remains, no other changes + # UPDATED: valid remains, no other changes + if status in ["INVALIDATED", "REPLACED", "CONFIRMED", "PENDING"]: + self.STATUS["VALID"][1] = False + + if status in ["CONFIRMED", "PENDING"]: + self.STATUS["INVALIDATED"][1] = False + + if status in ["PUSHED"]: + self.STATUS["PUSH_FAIL"][1] = False + self.STATUS["CHECK_FAIL"][1] = False + + if status in ["CHECKED"]: + self.STATUS["PUSHED"][1] = True + self.STATUS["PUSH_FAIL"][1] = False + + return value + + def get_status(self, status): + return self.STATUS[status][1] + + def __init__(self, w, _id=None, wallet=None): + if isinstance( + w, + WillItem, + ): + self.__dict__ = w.__dict__.copy() + else: + self.tx = Will.get_tx_from_any(w["tx"]) + self.heirs = w.get("heirs", None) + self.we = w.get("willexecutor", None) + self.status = w.get("status", None) + self.description = w.get("description", None) + self.time = w.get("time", None) + self.change = w.get("change", None) + self.tx_fees = w.get("baltx_fees", 0) + self.father = w.get("Father", None) + self.children = w.get("Children", None) + self.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT) + for s in self.STATUS: + self.STATUS[s][1] = w.get(s, WillItem.STATUS_DEFAULT[s][1]) + if not _id: + self._id = self.tx.txid() + else: + self._id = _id + + if not self._id: + self.status += "ERROR!!!" + self.valid = False + + if wallet: + self.tx.add_info_from_wallet(wallet) + + def to_dict(self): + out = { + "_id": self._id, + "tx": self.tx, + "heirs": self.heirs, + "willexecutor": self.we, + "status": self.status, + "description": self.description, + "time": self.time, + "change": self.change, + "baltx_fees": self.tx_fees, + } + for key in self.STATUS: + try: + out[key] = self.STATUS[key][1] + except Exception as e: + _logger.error(f"{key},{self.STATUS[key]} {e}") + + return out + + def __repr__(self): + return str(self) + + def __str__(self): + return str(self.to_dict()) + + def set_anticipate(self, ow: "WillItem"): + nl = min(ow.tx.locktime, Will.check_anticipate(ow, self)) + if int(nl) < self.tx.locktime: + self.tx.locktime = int(nl) + self.set_status("ANTICIPATED", True) + return True + else: + return False + + def search_anticipate(self, all_inputs): + anticipated = False + for ow in self.search(all_inputs): + if self.set_anticipate(ow): + anticipated = True + return anticipated + + def search(self, all_inputs): + for inp in self.tx.inputs(): + prevout_str = inp.prevout.to_str() + oinps = all_inputs.get(prevout_str, []) + for oinp in oinps: + ow = oinp[1] + if ow._id != self._id: + yield ow + + def normalize_locktime(self, all_inputs): + outputs = self.tx.outputs() + for idx in range(0, len(outputs)): + inps = all_inputs.get(f"{self._id}:{idx}", []) + _logger.debug("****check locktime***") + for inp in inps: + if inp[0] != self._id: + iw = inp[1] + self.set_anticipate(iw) + + def set_check_willexecutor(self,resp): + try: + if resp : + if "tx" in resp and resp["tx"] == str(self.tx): + self.set_status("PUSHED") + self.set_status("CHECKED") + else: + self.set_status("CHECK_FAIL") + self.set_status("PUSHED", False) + return True + else: + self.set_status("CHECK_FAIL") + self.set_status("PUSHED", False) + return False + except Exception as e: + _logger.error(f"exception checking transaction: {e}") + self.set_status("CHECK_FAIL") + + # NOTE: the former ``get_color()`` method (which returned hard-coded hex + # colours for the GUI) has been moved out of the core logic to + # ``bal.gui.qt.theme.status_color``. The status flags above remain the + # single source of truth; the GUI maps them to colours. + + +class WillException(Exception): + def __init__(self,msg="WillException"): + self.msg=msg + Exception.__init__(self) + def __str__(self): + return self.msg + + + +class WillExpiredException(WillException): + pass + + +class NotCompleteWillException(WillException): + pass + + +class HeirChangeException(NotCompleteWillException): + pass + + +class TxFeesChangedException(NotCompleteWillException): + pass + + +class HeirNotFoundException(NotCompleteWillException): + pass + + +class WillPostponedException(NotCompleteWillException): + """An already signed/sent will is being postponed. + + When a will that has already been signed (``COMPLETE``) and/or pushed to + will-executors (``PUSHED``) gets its locktime moved to a LATER date, the + previously committed coins must be invalidated on-chain BEFORE rebuilding + the new inheritance. Otherwise a will-executor could broadcast the old + (earlier-locktime) transaction and execute the inheritance too early to + collect the fees. Invalidating spends the same UTXOs now, permanently + voiding the old pre-signed transaction. + """ + + pass + + +class WillexecutorChangeException(NotCompleteWillException): + pass + + +class NoWillExecutorNotPresent(NotCompleteWillException): + pass + + +class WillExecutorNotPresent(NotCompleteWillException): + pass + + +class NoHeirsException(WillException): + pass +class AmountException(WillException): + pass + + +class PercAmountException(AmountException): + pass + + +class FixedAmountException(AmountException): + pass diff --git a/bal/core/willexecutors.py b/bal/core/willexecutors.py new file mode 100644 index 0000000..b1bd643 --- /dev/null +++ b/bal/core/willexecutors.py @@ -0,0 +1,788 @@ +""" +bal.core.willexecutors +======================= + +Client logic for talking to *will-executor* servers. + +A will-executor is an optional third-party service that, for a small fee, +stores the signed inheritance transactions off-line and broadcasts them once +their locktime expires (acting as a dead-man's switch backup). + +This module only contains the networking / data-shaping logic (downloading the +server list, pinging servers for their fee and address, pushing transactions, +checking whether a tx is already stored). It is GUI-free: all user +interaction is handled by the Qt layer. +""" + +import json +import time +from datetime import datetime + +from aiohttp import ClientResponse +from electrum.i18n import _ +from electrum.logging import get_logger +from electrum.network import Network + +from .plugin_base import BalPlugin + +# Per-request timeout (seconds) for interactive operations (ping / info / +# list download). These fail fast (no retries) so a dead server does not +# block the UI. +DEFAULT_TIMEOUT = 5 + +# Broadcast (pushtxs) timeouts. Broadcasting a will is important, so we keep a +# couple of quick retries to survive a transient hiccup -- but far from the old +# 10s x 10 retries + 30s sleeps (~140s) that froze the wizard on a dead server. +# Worst case per server is now ~ PUSH_TIMEOUT * (1 + PUSH_MAX_RETRIES) +# + PUSH_RETRY_SLEEP * PUSH_MAX_RETRIES = 8 * 3 + 1 * 2 = ~26s, and the wizard +# also enforces a global deadline on top of this (see push_transactions_parallel). +PUSH_TIMEOUT = 8 +PUSH_MAX_RETRIES = 2 +PUSH_RETRY_SLEEP = 1 + +# Global wall-clock deadline (seconds) for the whole parallel broadcast. Once +# it elapses we stop waiting for the still-pending servers, mark them as +# "Timeout" and let the wizard proceed instead of appearing stuck. +PUSH_GLOBAL_DEADLINE = 30 + +# Check (searchtx) timeouts. Used when the user presses "Check" to verify that +# each will-executor still holds the transaction. Like the broadcast path, the +# old defaults (10s x 10 retries + 30s sleeps ~= 140s per server) froze the +# "checking transaction" dialog on a single dead server. Fail fast with one +# quick retry, and cap the whole batch with a global deadline. +CHECK_TIMEOUT = 8 +CHECK_MAX_RETRIES = 1 +CHECK_RETRY_SLEEP = 1 +CHECK_GLOBAL_DEADLINE = 30 + +_logger = get_logger(__name__) + + +chainname = BalPlugin.chainname + + +class Willexecutors: + + # Expose the networking constants as class attributes so the GUI layer can + # reference them (e.g. to show the "Xs / DEADLINEs" countdown) without + # importing module-level names. Single source of truth: the module + # constants defined above. + DEFAULT_TIMEOUT = DEFAULT_TIMEOUT + PUSH_TIMEOUT = PUSH_TIMEOUT + PUSH_MAX_RETRIES = PUSH_MAX_RETRIES + PUSH_RETRY_SLEEP = PUSH_RETRY_SLEEP + PUSH_GLOBAL_DEADLINE = PUSH_GLOBAL_DEADLINE + CHECK_TIMEOUT = CHECK_TIMEOUT + CHECK_MAX_RETRIES = CHECK_MAX_RETRIES + CHECK_RETRY_SLEEP = CHECK_RETRY_SLEEP + CHECK_GLOBAL_DEADLINE = CHECK_GLOBAL_DEADLINE + + @staticmethod + def save(bal_plugin, willexecutors): + _logger.debug(f"save {willexecutors},{chainname}") + aw = bal_plugin.WILLEXECUTORS.get() + aw[chainname] = willexecutors + bal_plugin.WILLEXECUTORS.set(aw) + _logger.debug(f"saved: {aw}") + # bal_plugin.WILLEXECUTORS.set(willexecutors) + + @staticmethod + def get_willexecutors( + bal_plugin, update=False, bal_window=False, force=False, task=True + ): + willexecutors = bal_plugin.WILLEXECUTORS.get() + willexecutors = willexecutors.get(chainname, {}) + to_del = [] + for w in willexecutors: + if not isinstance(willexecutors[w], dict): + to_del.append(w) + continue + Willexecutors.initialize_willexecutor(willexecutors[w], w) + for w in to_del: + _logger.error( + "error Willexecutor to delete type:{} {}".format( + type(willexecutors[w]), w + ) + ) + del willexecutors[w] + bal = bal_plugin.WILLEXECUTORS.default.get(chainname, {}) + for bal_url, bal_executor in bal.items(): + if bal_url not in willexecutors: + _logger.debug(f"force add {bal_url} willexecutor") + willexecutors[bal_url] = bal_executor + # if update: + # found = False + # for url, we in willexecutors.items(): + # if Willexecutors.is_selected(we): + # found = True + # if found or force: + # if bal_plugin.PING_WILLEXECUTORS.get() or force: + # ping_willexecutors = True + # if bal_plugin.ASK_PING_WILLEXECUTORS.get() and not force: + # if bal_window: + # ping_willexecutors = bal_window.window.question( + # _( + # "Contact willexecutors servers to update payment informations?" + # ) + # ) + + # if ping_willexecutors: + # if task: + # bal_window.ping_willexecutors(willexecutors, task) + # else: + # bal_window.ping_willexecutors_task(willexecutors) + w_sorted = dict( + sorted( + willexecutors.items(), key=lambda w: w[1].get("sort", 0), reverse=True + ) + ) + return w_sorted + + @staticmethod + def is_selected(willexecutor, value=None): + if not willexecutor: + return False + if value is not None: + willexecutor["selected"] = value + try: + return willexecutor["selected"] + except Exception: + willexecutor["selected"] = False + return False + + @staticmethod + def get_willexecutor_transactions(will, force=False): + willexecutors = {} + for wid, willitem in will.items(): + if willitem.get_status("VALID"): + if willitem.get_status("COMPLETE"): + if not willitem.get_status("PUSHED") or force: + if willexecutor := willitem.we: + url = willexecutor["url"] + if willexecutor and Willexecutors.is_selected(willexecutor): + if url not in willexecutors: + willexecutor["txs"] = "" + willexecutor["txsids"] = [] + willexecutor["broadcast_status"] = _("Waiting...") + willexecutors[url] = willexecutor + willexecutors[url]["txs"] += str(willitem.tx) + "\n" + willexecutors[url]["txsids"].append(wid) + + return willexecutors + + # def only_selected_list(willexecutors): + # out = {} + # for url, v in willexecutors.items(): + # if Willexecutors.is_selected(url): + # out[url] = v + + # def push_transactions_to_willexecutors(will): + # willexecutors = Willexecutors.get_transactions_to_be_pushed() + # for url in willexecutors: + # willexecutor = willexecutors[url] + # if Willexecutors.is_selected(willexecutor): + # if "txs" in willexecutor: + # Willexecutors.push_transactions_to_willexecutor( + # willexecutors[url]["txs"], url + # ) + + @staticmethod + def send_request( + method, url, data=None, *, timeout=10, handle_response=None, count_reply=0, + max_retries=10, retry_sleep=3, + ): + """Send an HTTP request to a will-executor server. + + ``max_retries`` / ``retry_sleep`` control the timeout-retry behaviour: + + * For *critical* operations (pushing inheritance transactions) the + historical default of up to 10 retries with a 3s back-off is kept, so + a transient network hiccup does not lose a transaction. + * For *interactive* operations (ping / info / list download) callers + should pass ``max_retries=0`` so a dead server fails fast (one short + timeout) instead of blocking the UI for minutes. See + :meth:`ping_servers_parallel`. + """ + network = Network.get_instance() + if not network: + raise Exception("You are offline.") + _logger.debug(f"<-- {method} {url} {data}") + headers = {} + headers["user-agent"] = f"BalPlugin v:{BalPlugin.__version__}" + headers["Content-Type"] = "text/plain" + if not handle_response: + handle_response = Willexecutors.handle_response + try: + if method == "get": + response = Network.send_http_on_proxy( + method, + url, + params=data, + headers=headers, + on_finish=handle_response, + timeout=timeout, + ) + elif method == "post": + response = Network.send_http_on_proxy( + method, + url, + body=data, + headers=headers, + on_finish=handle_response, + timeout=timeout, + ) + else: + raise Exception(f"unexpected {method=!r}") + except TimeoutError: + if count_reply < max_retries: + _logger.debug( + f"timeout({count_reply}) error: retry in {retry_sleep} sec..." + ) + if retry_sleep: + time.sleep(retry_sleep) + return Willexecutors.send_request( + method, + url, + data, + timeout=timeout, + handle_response=handle_response, + count_reply=count_reply + 1, + max_retries=max_retries, + retry_sleep=retry_sleep, + ) + else: + _logger.debug(f"Too many timeouts: {count_reply}") + except Exception as e: + raise e + else: + _logger.debug(f"--> {response}") + return response + + @staticmethod + def get_we_url_from_response(resp): + url_slices = str(resp.url).split("/") + if len(url_slices) > 2: + url_slices = url_slices[:-2] + return "/".join(url_slices) + + @staticmethod + async def handle_response(resp: ClientResponse): + r = await resp.text() + try: + + r = json.loads(r) + # url = Willexecutors.get_we_url_from_response(resp) + # r["url"]= url + # r["status"]=resp.status + except Exception as e: + _logger.debug(f"error handling response:{e}") + pass + return r + + @staticmethod + class AlreadyPresentException(Exception): + pass + + @staticmethod + def push_transactions_to_willexecutor( + willexecutor, *, timeout=PUSH_TIMEOUT, max_retries=PUSH_MAX_RETRIES, + retry_sleep=PUSH_RETRY_SLEEP, + ): + # ``timeout`` / ``max_retries`` / ``retry_sleep`` are forwarded to + # send_request so the broadcast fails fast on a dead/slow server instead + # of hanging for ~140s (the old default was 10s timeout x 10 retries + + # 30s of sleeps). A small number of quick retries still protects + # against a transient hiccup without freezing the wizard. + out = True + try: + _logger.debug(f"{willexecutor['url']}: {willexecutor['txs']}") + if w := Willexecutors.send_request( + "post", + willexecutor["url"] + "/" + chainname + "/pushtxs", + data=willexecutor["txs"].encode("ascii"), + timeout=timeout, + max_retries=max_retries, + retry_sleep=retry_sleep, + ): + willexecutor["broadcast_status"] = _("Success") + _logger.debug(f"pushed: {w}") + if w != "thx": + _logger.debug(f"error: {w}") + raise Exception(w) + else: + raise Exception("empty reply from:{willexecutor['url']}") + except Exception as e: + _logger.debug(f"error:{e}") + if str(e) == "already present": + raise Willexecutors.AlreadyPresentException() + out = False + willexecutor["broadcast_status"] = _("Failed") + + return out + + @staticmethod + def ping_servers(willexecutors): + for url, we in willexecutors.items(): + Willexecutors.get_info_task(url, we) + + @staticmethod + def get_info_task(url, willexecutor, *, timeout=DEFAULT_TIMEOUT, + max_retries=0, retry_sleep=0): + w = None + try: + _logger.info("GETINFO_WILLEXECUTOR") + _logger.debug(url) + # Fast-fail by default (max_retries=0): a dead server returns after a + # single short timeout instead of retrying 10x with sleeps, which + # used to freeze the UI for minutes per unreachable server. + w = Willexecutors.send_request( + "get", url + "/" + chainname + "/info", + timeout=timeout, max_retries=max_retries, retry_sleep=retry_sleep, + ) + if isinstance(w, dict): + willexecutor["url"] = url + willexecutor["status"] = 200 + willexecutor["base_fee"] = w["base_fee"] + willexecutor["address"] = w["address"] + willexecutor["info"] = w["info"] + else: + # No dict reply (timeout / empty) -> mark as unreachable. + willexecutor["status"] = "KO" + _logger.debug(f"response_data {w}") + except Exception as e: + _logger.error(f"error {e} contacting {url}: {w}") + willexecutor["status"] = "KO" + + willexecutor["last_update"] = datetime.now().timestamp() + return willexecutor + + @staticmethod + def ping_servers_parallel(willexecutors, *, on_each=None, max_workers=8, + timeout=DEFAULT_TIMEOUT, on_tick=None, + tick_interval=1.0): + """Ping every will-executor concurrently and report results as they + arrive. + + Network requests run in a thread pool: each ``send_http_on_proxy`` call + schedules its coroutine on Electrum's shared asyncio loop and blocks + only its *own* worker thread, so the total wall-clock time is roughly + that of the slowest server rather than the *sum* of all of them. A + single dead server can no longer stall the whole batch. + + Args: + willexecutors: ``{url: we_dict}`` mapping (mutated in place with the + ping result, exactly like the old sequential ``ping_servers``). + on_each: optional ``callback(url, we_dict, ok: bool)`` invoked from a + worker thread each time a server answers (or fails), so the GUI + can update its list live. Must be thread-safe / marshalled to + the GUI thread by the caller. + max_workers: maximum number of concurrent pings. + timeout: per-request timeout in seconds (fast-fail, no retries). + + on_tick: optional ``callback()`` invoked periodically (every + ``tick_interval`` seconds) **from the calling thread** while + waiting for servers, so a Qt caller can refresh an elapsed-time + counter from the same thread that drives ``on_each``. + + Returns: + The same ``willexecutors`` mapping, updated in place. + """ + from concurrent.futures import ThreadPoolExecutor, wait + from concurrent.futures import FIRST_COMPLETED + + items = list(willexecutors.items()) + if not items: + return willexecutors + + def _ping_one(url, we): + we = Willexecutors.get_info_task( + url, we, timeout=timeout, max_retries=0, retry_sleep=0 + ) + ok = we.get("status") == 200 + return url, we, ok + + def _fire_tick(): + if on_tick is not None: + try: + on_tick() + except Exception as cb_err: + _logger.error(f"ping on_tick callback error: {cb_err}") + + workers = max(1, min(max_workers, len(items))) + # Manual pool (no ``with``) so we can poll futures in short slices and + # drive ``on_tick`` from THIS thread between waits (reliable Qt repaint). + pool = ThreadPoolExecutor(max_workers=workers, thread_name_prefix="bal-ping") + futures = {pool.submit(_ping_one, url, we) for url, we in items} + try: + pending = set(futures) + while pending: + done, pending = wait( + pending, timeout=tick_interval, return_when=FIRST_COMPLETED + ) + for fut in done: + try: + url, we, ok = fut.result() + except Exception as e: # defensive: one server never crashes all + _logger.error(f"ping_servers_parallel worker error: {e}") + continue + willexecutors[url] = we + if on_each is not None: + try: + on_each(url, we, ok) + except Exception as cb_err: + _logger.error(f"ping on_each callback error: {cb_err}") + # Drive the elapsed-time counter from the calling thread. + _fire_tick() + finally: + try: + pool.shutdown(wait=False, cancel_futures=True) + except TypeError: + pool.shutdown(wait=False) + return willexecutors + + @staticmethod + def push_transactions_parallel(willexecutors, *, on_each=None, max_workers=8, + deadline=PUSH_GLOBAL_DEADLINE, on_timeout=None, + on_tick=None, tick_interval=1.0): + """Push transactions to multiple will-executors concurrently. + + Like :meth:`ping_servers_parallel` but for the ``pushtxs`` operation. + Each server keeps a short retry behaviour + (:meth:`push_transactions_to_willexecutor`) so a real transaction is not + lost to a transient hiccup, but servers are contacted in parallel and + results are reported via ``on_each(url, we_dict, ok, exc)`` as they + complete. + + A global wall-clock ``deadline`` (seconds) caps the whole operation: if + some servers are still pending when it elapses, we stop waiting, mark + them via ``on_timeout(url, we_dict)`` and return, so the caller (the + wizard) is never stuck behind one unresponsive server. Pass + ``deadline=None`` to wait indefinitely (old behaviour). + + ``on_tick()`` is invoked periodically (every ``tick_interval`` seconds) + **from the calling thread** while waiting for workers. This lets a Qt + caller refresh an elapsed-time counter from the same thread that drives + ``on_each`` (so its pyqtSignal repaints reliably), instead of relying on + a separate heartbeat thread whose signal emissions are not marshalled. + + Returns ``{url: (ok, exception_or_None)}`` for the servers that + answered in time (timed-out servers are reported via ``on_timeout``). + """ + from concurrent.futures import ThreadPoolExecutor, wait + from concurrent.futures import FIRST_COMPLETED + + targets = [(url, we) for url, we in willexecutors.items() if "txs" in we] + results = {} + if not targets: + return results + + def _push_one(url, we): + try: + ok = Willexecutors.push_transactions_to_willexecutor(we) + return url, we, ok, None + except Willexecutors.AlreadyPresentException as ape: + return url, we, False, ape + except Exception as e: + return url, we, False, e + + def _fire_tick(): + if on_tick is not None: + try: + on_tick() + except Exception as cb_err: + _logger.error(f"push on_tick callback error: {cb_err}") + + workers = max(1, min(max_workers, len(targets))) + # NOTE: we do not use ``with ThreadPoolExecutor(...)`` here because its + # __exit__ calls shutdown(wait=True), which would block on a hung worker + # and defeat the whole point of the global deadline. We shut the pool + # down without waiting once the deadline elapses; the daemon worker(s) + # stuck on a dead socket will be torn down when their request finally + # times out (PUSH_TIMEOUT), without holding up the wizard. + pool = ThreadPoolExecutor(max_workers=workers, thread_name_prefix="bal-push") + fut_to_url = {pool.submit(_push_one, url, we): (url, we) + for url, we in targets} + start = time.time() + try: + # Poll the futures in short slices so we can call ``on_tick`` from + # THIS thread between waits. ``wait(..., timeout=tick_interval)`` + # returns as soon as a future completes OR the slice elapses, + # whichever comes first, so the counter advances ~once per second + # while the parallel push runs. + pending = set(fut_to_url.keys()) + while pending: + if deadline is not None and (time.time() - start) >= deadline: + break + slice_timeout = tick_interval + if deadline is not None: + remaining = deadline - (time.time() - start) + slice_timeout = max(0.0, min(tick_interval, remaining)) + done, pending = wait( + pending, timeout=slice_timeout, return_when=FIRST_COMPLETED + ) + for fut in done: + try: + url, we, ok, exc = fut.result() + except Exception as e: + _logger.error( + f"push_transactions_parallel worker error: {e}" + ) + continue + results[url] = (ok, exc) + if on_each is not None: + try: + on_each(url, we, ok, exc) + except Exception as cb_err: + _logger.error(f"push on_each callback error: {cb_err}") + # Drive the elapsed-time counter from the calling thread. + _fire_tick() + # Any server still pending here hit the global deadline. + if pending: + elapsed = time.time() - start + _logger.warning( + f"push global deadline ({deadline}s) reached after " + f"{elapsed:.1f}s; {len(pending)} server(s) " + f"did not answer in time" + ) + for fut in pending: + url, we = fut_to_url[fut] + if url in results: + continue + if on_timeout is not None: + try: + on_timeout(url, we) + except Exception as cb_err: + _logger.error( + f"push on_timeout callback error: {cb_err}" + ) + finally: + # Do not block on still-running workers (Python 3.9+: cancel queued). + try: + pool.shutdown(wait=False, cancel_futures=True) + except TypeError: + pool.shutdown(wait=False) + return results + + @staticmethod + def check_transactions_parallel(items, *, on_each=None, max_workers=8, + deadline=CHECK_GLOBAL_DEADLINE, + on_timeout=None, on_tick=None, + tick_interval=1.0): + """Check (searchtx) several will-executors concurrently. + + Same design as :meth:`push_transactions_parallel`, but for the "Check" + operation: it verifies that each will-executor still holds its + transaction. ``items`` is an iterable of ``(wid, url)`` pairs (one per + will-item that has a will-executor). + + Each server is contacted in parallel with a short fail-fast retry + (:meth:`check_transaction`), results are reported via + ``on_each(wid, url, result_or_None, exc)`` as they arrive, ``on_tick()`` + is called periodically from the calling thread to refresh a counter, and + a global ``deadline`` guarantees the dialog never freezes behind one + unresponsive server (pending servers are reported via + ``on_timeout(wid, url)``). + + Returns ``{wid: (result_or_None, exception_or_None)}`` for the servers + that answered in time. + """ + from concurrent.futures import ThreadPoolExecutor, wait + from concurrent.futures import FIRST_COMPLETED + + targets = [(wid, url) for wid, url in items if url] + results = {} + if not targets: + return results + + def _check_one(wid, url): + try: + res = Willexecutors.check_transaction(wid, url) + return wid, url, res, None + except Exception as e: + return wid, url, None, e + + def _fire_tick(): + if on_tick is not None: + try: + on_tick() + except Exception as cb_err: + _logger.error(f"check on_tick callback error: {cb_err}") + + workers = max(1, min(max_workers, len(targets))) + # Manual pool (no ``with``): we must not block on a hung worker when the + # global deadline elapses (see push_transactions_parallel for details). + pool = ThreadPoolExecutor(max_workers=workers, thread_name_prefix="bal-check") + fut_to_target = {pool.submit(_check_one, wid, url): (wid, url) + for wid, url in targets} + start = time.time() + try: + pending = set(fut_to_target.keys()) + while pending: + if deadline is not None and (time.time() - start) >= deadline: + break + slice_timeout = tick_interval + if deadline is not None: + remaining = deadline - (time.time() - start) + slice_timeout = max(0.0, min(tick_interval, remaining)) + done, pending = wait( + pending, timeout=slice_timeout, return_when=FIRST_COMPLETED + ) + for fut in done: + try: + wid, url, res, exc = fut.result() + except Exception as e: + _logger.error( + f"check_transactions_parallel worker error: {e}" + ) + continue + results[wid] = (res, exc) + if on_each is not None: + try: + on_each(wid, url, res, exc) + except Exception as cb_err: + _logger.error(f"check on_each callback error: {cb_err}") + # Drive the elapsed-time counter from the calling thread. + _fire_tick() + # Any server still pending here hit the global deadline. + if pending: + elapsed = time.time() - start + _logger.warning( + f"check global deadline ({deadline}s) reached after " + f"{elapsed:.1f}s; {len(pending)} server(s) " + f"did not answer in time" + ) + for fut in pending: + wid, url = fut_to_target[fut] + if wid in results: + continue + if on_timeout is not None: + try: + on_timeout(wid, url) + except Exception as cb_err: + _logger.error( + f"check on_timeout callback error: {cb_err}" + ) + finally: + try: + pool.shutdown(wait=False, cancel_futures=True) + except TypeError: + pool.shutdown(wait=False) + return results + + @staticmethod + def initialize_willexecutor(willexecutor, url, status=None, old_willexecutor=None): + old_willexecutor=old_willexecutor if old_willexecutor is not None else {} + willexecutor["url"] = url + if status is not None: + willexecutor["status"] = status + else: + willexecutor["status"] = old_willexecutor.get("status",willexecutor.get("status","Ko")) + willexecutor["selected"]=Willexecutors.is_selected(old_willexecutor) or willexecutor.get("selected",False) + willexecutor["address"]=old_willexecutor.get("address",willexecutor.get("address","")) + willexecutor["promo_code"]=old_willexecutor.get("promo_code",willexecutor.get("promo_code")) + + + + @staticmethod + def download_list(old_willexecutors,welist_server): + try: + welist_server = welist_server if welist_server[-1] == '/' else welist_server+'/' + willexecutors = Willexecutors.send_request( + "get", + f"{welist_server}data/{chainname}?page=0&limit=100", + ) + # del willexecutors["status"] + for w in willexecutors: + if w not in ("status", "url"): + Willexecutors.initialize_willexecutor( + willexecutors[w], w, None, old_willexecutors.get(w,None) + ) + # bal_plugin.WILLEXECUTORS.set(l) + # bal_plugin.config.set_key(bal_plugin.WILLEXECUTORS,l,save=True) + return willexecutors + + except Exception as e: + _logger.error(f"Failed to download willexecutors list: {e}") + return {} + + @staticmethod + def get_willexecutors_list_from_json(): + try: + with open("willexecutors.json") as f: + willexecutors = json.load(f) + for w in willexecutors: + willexecutor = willexecutors[w] + Willexecutors.initialize_willexecutor(willexecutor, w, "New", False) + # bal_plugin.WILLEXECUTORS.set(willexecutors) + return willexecutors + except Exception as e: + _logger.error(f"error opening willexecutors json: {e}") + + return {} + + @staticmethod + def check_transaction(txid, url, *, timeout=CHECK_TIMEOUT, + max_retries=CHECK_MAX_RETRIES, + retry_sleep=CHECK_RETRY_SLEEP): + _logger.debug(f"{url}:{txid}") + try: + w = Willexecutors.send_request( + "post", url + "/searchtx", data=txid.encode("ascii"), + timeout=timeout, max_retries=max_retries, retry_sleep=retry_sleep, + ) + return w + except Exception as e: + _logger.error(f"error contacting {url} for checking txs {e}") + raise e + + @staticmethod + def compute_id(willexecutor): + return "{}-{}".format(willexecutor.get("url"), willexecutor.get("chain")) + + +#class WillExecutor: +# def __init__( +# self, +# url, +# base_fee, +# chain, +# info, +# version, +# status, +# is_selected=False, +# promo_code="", +# ): +# self.url = url +# self.base_fee = base_fee +# self.chain = chain +# self.info = info +# self.version = version +# self.status = status +# self.promo_code = promo_code +# self.is_selected = is_selected +# self.id = self.compute_id() +# +# def from_dict(d): +# return WillExecutor( +# url=d.get("url", "http://localhost:8000"), +# base_fee=d.get("base_fee", 1000), +# chain=d.get("chain", chainname), +# info=d.get("info", ""), +# version=d.get("version", 0), +# status=d.get("status", "Ko"), +# is_selected=d.get("is_selected", "False"), +# promo_code=d.get("promo_code", ""), +# ) +# +# def to_dict(self): +# return { +# "url": self.url, +# "base_fee": self.base_fee, +# "chain": self.chain, +# "info": self.info, +# "version": self.version, +# "promo_code": self.promo_code, +# } +# +# def compute_id(self): +# return f"{self.url}-{self.chain}"