Compare commits
627 Commits
v0.3.0-pre
...
main
| Author | SHA1 | Date |
|---|---|---|
|
|
bb4143d75b | |
|
|
d12bbb17be | |
|
|
19428a8cb7 | |
|
|
183b2bef59 | |
|
|
1bdb9bb336 | |
|
|
d7bbb19571 | |
|
|
420dfbfd9f | |
|
|
cfbf1b21f3 | |
|
|
1f15daa6c5 | |
|
|
8ae9e217ff | |
|
|
12f8fa67fc | |
|
|
b310fde426 | |
|
|
81a58edaca | |
|
|
debc8605df | |
|
|
dee9c511e5 | |
|
|
546c0060da | |
|
|
b81ae634a6 | |
|
|
0cccc0e2cd | |
|
|
cd938f4a34 | |
|
|
84fa3e5e19 | |
|
|
adeeadb495 | |
|
|
42a28efe74 | |
|
|
91b8cca41c | |
|
|
02cc79d67a | |
|
|
7bc8547a96 | |
|
|
caffb124d2 | |
|
|
141db46356 | |
|
|
f57b0f9c26 | |
|
|
c852f24a72 | |
|
|
cb3c7623dc | |
|
|
f2692a50ed | |
|
|
ed9f817fae | |
|
|
6bcb4af028 | |
|
|
106a287260 | |
|
|
30dc2f6665 | |
|
|
e1fb70e2ee | |
|
|
f3d4799efc | |
|
|
839feab97d | |
|
|
465e398040 | |
|
|
c6c875849a | |
|
|
ce95c40c84 | |
|
|
e6d966e89f | |
|
|
270c17829e | |
|
|
289ac0190c | |
|
|
467d637ccc | |
|
|
c9690b0d36 | |
|
|
7a65ab3319 | |
|
|
e99b5347da | |
|
|
29dd1affe1 | |
|
|
a15dcafc03 | |
|
|
d404521841 | |
|
|
09b15da3cb | |
|
|
901247366d | |
|
|
0abc04b9cb | |
|
|
2b083991d0 | |
|
|
8f616dd45b | |
|
|
1008672af9 | |
|
|
f4380604d9 | |
|
|
3b61f2e095 | |
|
|
25608babd6 | |
|
|
bd0f98cfb3 | |
|
|
a2adddbf3d | |
|
|
d6064294d7 | |
|
|
36c3ada6a6 | |
|
|
13e94db450 | |
|
|
feb871bcf1 | |
|
|
4292d5da66 | |
|
|
a7a2282ba7 | |
|
|
fa6826dde3 | |
|
|
eff71a6b22 | |
|
|
0bbb52284c | |
|
|
7588d18fff | |
|
|
e6e44d9a43 | |
|
|
bf004bab52 | |
|
|
a03b883350 | |
|
|
2a79c83715 | |
|
|
ef330a2687 | |
|
|
6594845d4c | |
|
|
77b682c8a8 | |
|
|
6ec79a6672 | |
|
|
631fe2bf31 | |
|
|
d968efcad4 | |
|
|
5a4970d7d9 | |
|
|
703c12e9f6 | |
|
|
8199bc4d66 | |
|
|
aef6feb2cd | |
|
|
22523aba14 | |
|
|
a01fd3aa86 | |
|
|
d58e8b44ee | |
|
|
30949af577 | |
|
|
1fbb88f773 | |
|
|
5eae4464ef | |
|
|
d43a70de93 | |
|
|
471702d562 | |
|
|
dbf97ae487 | |
|
|
fdfc2d6700 | |
|
|
3999d7cc51 | |
|
|
20eabbb85f | |
|
|
65bd4f9b65 | |
|
|
2f3a0f3652 | |
|
|
56ff8290c1 | |
|
|
1d7d38a82c | |
|
|
f84e8b4700 | |
|
|
fe2d4c3bcf | |
|
|
416867b1d5 | |
|
|
5af39193e4 | |
|
|
44f235b185 | |
|
|
eba4b3b6e5 | |
|
|
b1525bdfa6 | |
|
|
5e16adc1ec | |
|
|
e981e622d4 | |
|
|
5447545486 | |
|
|
2a4cb829fe | |
|
|
3591271a3b | |
|
|
e00c81153b | |
|
|
41f142124b | |
|
|
9037c2da97 | |
|
|
ff67319171 | |
|
|
70135938c4 | |
|
|
577f626972 | |
|
|
82a3c7a2c3 | |
|
|
61da3652f5 | |
|
|
94d8075970 | |
|
|
c31d64550b | |
|
|
1b3704b68d | |
|
|
5c76c9f62c | |
|
|
bfafd6d34c | |
|
|
34e22d3c7f | |
|
|
d68ee398ab | |
|
|
ff3a614804 | |
|
|
22fe23914f | |
|
|
95e009966e | |
|
|
e71f2aadfc | |
|
|
fe332fdb3f | |
|
|
add405aa65 | |
|
|
a89f4c829d | |
|
|
23dabb0219 | |
|
|
8d97daa524 | |
|
|
01ff873264 | |
|
|
ea789f7fec | |
|
|
40fbdec47c | |
|
|
e337a1dda4 | |
|
|
1c33dd7bf3 | |
|
|
bf5a16939f | |
|
|
30e1867eb0 | |
|
|
52c573d507 | |
|
|
04fd7b946a | |
|
|
dbe9ab223f | |
|
|
c0d0088b8e | |
|
|
9642901710 | |
|
|
8e30438433 | |
|
|
025cc6871b | |
|
|
7fe954e563 | |
|
|
1b8791fe5d | |
|
|
180e5ad057 | |
|
|
4ca4fc9135 | |
|
|
bc191791e8 | |
|
|
9a34e9d399 | |
|
|
c141c3f6cd | |
|
|
9e9a7364b9 | |
|
|
2025c6ce36 | |
|
|
3074748d15 | |
|
|
5ad71e2e4b | |
|
|
5cff606e87 | |
|
|
7b310c554b | |
|
|
4635fea693 | |
|
|
30b04c6376 | |
|
|
11eb1f8a04 | |
|
|
a4e1859fd2 | |
|
|
93c9007045 | |
|
|
cbdb449533 | |
|
|
4cbdf0b503 | |
|
|
40745ca580 | |
|
|
489966fae9 | |
|
|
50854f04d5 | |
|
|
0d06080760 | |
|
|
273f2f1d96 | |
|
|
350ce28c40 | |
|
|
24412794e6 | |
|
|
ff27195be2 | |
|
|
5cab38c7f1 | |
|
|
5c302bfca8 | |
|
|
5d880f011e | |
|
|
63d73af135 | |
|
|
cab36fccf1 | |
|
|
978dfcb2bf | |
|
|
83a2800941 | |
|
|
3c73d510b1 | |
|
|
a4090cc285 | |
|
|
f790d2bbe5 | |
|
|
6d619c0a02 | |
|
|
05f98def6d | |
|
|
6fedebf020 | |
|
|
3fe6bdbbf0 | |
|
|
033f94c0c2 | |
|
|
1a7c73e531 | |
|
|
fc3efe6a27 | |
|
|
dc27044dab | |
|
|
e0f529799f | |
|
|
582beb4f81 | |
|
|
49b1571bba | |
|
|
e83b3d420c | |
|
|
99550a2a9d | |
|
|
3fe4f82906 | |
|
|
2a22d7d669 | |
|
|
0f1b4df583 | |
|
|
8fc527b918 | |
|
|
5dab829995 | |
|
|
7a68668aa9 | |
|
|
608e22a8e7 | |
|
|
4d5c9e7c49 | |
|
|
a0229e653e | |
|
|
c35d90e94f | |
|
|
461b11b310 | |
|
|
849f346891 | |
|
|
ace1e8673b | |
|
|
a539b33dff | |
|
|
5ee6caa190 | |
|
|
a40e314c94 | |
|
|
5006a5a170 | |
|
|
5f76108579 | |
|
|
4b55c63e71 | |
|
|
05a8168a31 | |
|
|
a14daae222 | |
|
|
0e05139c01 | |
|
|
73381a4376 | |
|
|
1256bc2fdd | |
|
|
849fa77df0 | |
|
|
6caae7c860 | |
|
|
a749a3b9e1 | |
|
|
dd77dc65d1 | |
|
|
8e2073bc5a | |
|
|
5727192719 | |
|
|
26dce24e75 | |
|
|
36d7b7ebfe | |
|
|
1b425e09c9 | |
|
|
673e5ff772 | |
|
|
f26a796244 | |
|
|
6261679f5a | |
|
|
4bb995f2c2 | |
|
|
9fca17e7ed | |
|
|
e28fe56489 | |
|
|
25ad627377 | |
|
|
86f2c85f8d | |
|
|
fe7dda396f | |
|
|
341e319fd3 | |
|
|
42e6b5c27c | |
|
|
51456373a9 | |
|
|
7f72c1e1ec | |
|
|
c4ee8ed6a9 | |
|
|
c032f30f7b | |
|
|
319a787c43 | |
|
|
576aad8691 | |
|
|
c1de1daea8 | |
|
|
1c3e7809ad | |
|
|
86091097a6 | |
|
|
d3fecc42c1 | |
|
|
81f8422758 | |
|
|
0467e17032 | |
|
|
0a433eca40 | |
|
|
a36bdcdda5 | |
|
|
ca55a81263 | |
|
|
ee5f841034 | |
|
|
28ad8c2e2f | |
|
|
15a5fb6c14 | |
|
|
2a09fca728 | |
|
|
b0434184ae | |
|
|
3f79361fde | |
|
|
6ffde0f4c6 | |
|
|
821c70bf38 | |
|
|
f7278b6196 | |
|
|
c8c2e63da6 | |
|
|
7c3bf4f068 | |
|
|
a17f408653 | |
|
|
eaead7d4f3 | |
|
|
d9f9ae5122 | |
|
|
dd1531fbb8 | |
|
|
57239e81dd | |
|
|
bc73b078bd | |
|
|
dab4b0674d | |
|
|
8eb5b410cc | |
|
|
4ee355a7cd | |
|
|
96695575d5 | |
|
|
414fe95d04 | |
|
|
fabfbb73fe | |
|
|
ca1bc74b2a | |
|
|
7a4a207bed | |
|
|
f7b2267583 | |
|
|
990f218051 | |
|
|
96a84cc281 | |
|
|
1a617e02f8 | |
|
|
d79fd9273b | |
|
|
d1a52e74a0 | |
|
|
00264a721e | |
|
|
4d944b06e5 | |
|
|
45736c4daf | |
|
|
51114f265d | |
|
|
641612a5d0 | |
|
|
217be89c43 | |
|
|
53df97839d | |
|
|
ee6a092a1a | |
|
|
347e5ce3de | |
|
|
1676e82cc6 | |
|
|
2553d05902 | |
|
|
b5105d6bd1 | |
|
|
bc38ec6ec0 | |
|
|
68841abbf4 | |
|
|
38fff077dd | |
|
|
65bd6a857f | |
|
|
c65d02ebea | |
|
|
a86116fef4 | |
|
|
ca337bcdb7 | |
|
|
9050a4adca | |
|
|
e2b2c17d38 | |
|
|
f55ac7e9cb | |
|
|
178c484f04 | |
|
|
8f5b4df3d1 | |
|
|
53bc39b65b | |
|
|
6a58a55997 | |
|
|
c9626ac82b | |
|
|
1ebfee7228 | |
|
|
d1e04152dc | |
|
|
8298f2e371 | |
|
|
23994a23be | |
|
|
6a05150017 | |
|
|
da5bb98cb7 | |
|
|
bab30dbeba | |
|
|
97bcaa2dfc | |
|
|
58e3e34373 | |
|
|
d303cf076b | |
|
|
3ed72499a0 | |
|
|
fff386c000 | |
|
|
f0bf4d8c5d | |
|
|
a39ee76e9a | |
|
|
2781ffccc1 | |
|
|
475acf71cc | |
|
|
7176bbd5c2 | |
|
|
eccc637a02 | |
|
|
1c5fd9eaad | |
|
|
59469055c7 | |
|
|
5f6ecf9670 | |
|
|
f15bdeaef8 | |
|
|
b49776fadb | |
|
|
3d31e8beb9 | |
|
|
d293ec10e4 | |
|
|
83f84b9d7c | |
|
|
c4cec836d9 | |
|
|
6b55b69d0d | |
|
|
7d3483b565 | |
|
|
b5ebf8a615 | |
|
|
cc17f6a38e | |
|
|
667b240915 | |
|
|
84aa8181a9 | |
|
|
c7c793f128 | |
|
|
842bc42579 | |
|
|
a719294dda | |
|
|
38fa1f807d | |
|
|
603f41f9ca | |
|
|
c22ce4ecc4 | |
|
|
db833fdf45 | |
|
|
02fa87f6c8 | |
|
|
04a23a30a4 | |
|
|
4f2f808484 | |
|
|
28f1e26400 | |
|
|
d16ad81d62 | |
|
|
6350b36e1a | |
|
|
27e64819b7 | |
|
|
07fe3e3140 | |
|
|
06fc8aa5d9 | |
|
|
230e0d98b6 | |
|
|
78fa354117 | |
|
|
5c7cb616a7 | |
|
|
4f55d86050 | |
|
|
fa7b45ec2f | |
|
|
1b237778ee | |
|
|
efb428ef31 | |
|
|
6f52956c42 | |
|
|
9a4c984bd2 | |
|
|
1354055f09 | |
|
|
eb40b658f6 | |
|
|
d28723f141 | |
|
|
7766a9caba | |
|
|
fa0fd3adb3 | |
|
|
7744abf57d | |
|
|
8dba325499 | |
|
|
e1aec6c2c3 | |
|
|
d6b3f04612 | |
|
|
d53b1c7499 | |
|
|
95cb3510aa | |
|
|
e9c0196d68 | |
|
|
90ca62b594 | |
|
|
463e70131d | |
|
|
fd602e104d | |
|
|
e01c7efc3c | |
|
|
2a1d6a6bcc | |
|
|
8c29603f5a | |
|
|
4e201d3a66 | |
|
|
2d9d6ceed7 | |
|
|
ce1d342269 | |
|
|
d400290652 | |
|
|
ead1aac60c | |
|
|
272b4ffdbf | |
|
|
4dcdfb8a3c | |
|
|
4e5d9685a1 | |
|
|
9953f0eee5 | |
|
|
4df9895863 | |
|
|
2c8263754f | |
|
|
305514b246 | |
|
|
b947fe8205 | |
|
|
fe8e9a9bb6 | |
|
|
64bd82b77b | |
|
|
fa1931b3b6 | |
|
|
4f3660f05e | |
|
|
24bcc45d5a | |
|
|
36a83397a8 | |
|
|
2be9a2d9c2 | |
|
|
898521d236 | |
|
|
84fb6b8500 | |
|
|
4b5270f130 | |
|
|
283553a474 | |
|
|
b9911ab460 | |
|
|
99b725db0a | |
|
|
bbafe58e86 | |
|
|
069c549bc4 | |
|
|
bf1c8d2228 | |
|
|
7dc25b75d2 | |
|
|
4c6e64a604 | |
|
|
65cb574f59 | |
|
|
5204d24c88 | |
|
|
573e58c89b | |
|
|
ec71121907 | |
|
|
8b80e45524 | |
|
|
5419b15bf1 | |
|
|
81ad8adf93 | |
|
|
2a31e1ba6d | |
|
|
d274444ca9 | |
|
|
e6da0cbb05 | |
|
|
6c78e22000 | |
|
|
bdc6ba524f | |
|
|
2136b7a144 | |
|
|
3b3342de5c | |
|
|
ac0e73afac | |
|
|
191b37a5de | |
|
|
66ace25935 | |
|
|
0f3c26c6fa | |
|
|
44a1023cdd | |
|
|
c3c15b7880 | |
|
|
49cdeb4aef | |
|
|
229dff1a9d | |
|
|
56f2fd206d | |
|
|
6d5c5f7e4c | |
|
|
838d5c1d3b | |
|
|
83384ff198 | |
|
|
1c4def2867 | |
|
|
e95316c5f4 | |
|
|
6e395ce58c | |
|
|
99b2b10ba0 | |
|
|
04545c86a5 | |
|
|
cb35f21661 | |
|
|
8d97ed2720 | |
|
|
599e0ba281 | |
|
|
f94083df36 | |
|
|
21c8f1906a | |
|
|
251fee4f1e | |
|
|
46b68e8652 | |
|
|
8148f7a52a | |
|
|
aa58b9e745 | |
|
|
cb59a964dd | |
|
|
ea93bafe7e | |
|
|
0d14cc2197 | |
|
|
dacefa2b51 | |
|
|
52afe72f17 | |
|
|
0991d5d484 | |
|
|
5026661fa8 | |
|
|
bdc3cdd75e | |
|
|
bc1d4a62c6 | |
|
|
c8f2d5edff | |
|
|
0753f036bd | |
|
|
258aff8bf7 | |
|
|
f77ecff659 | |
|
|
af0b9d38c0 | |
|
|
ec528a7226 | |
|
|
190bf8257b | |
|
|
30cb245301 | |
|
|
67c7d9149c | |
|
|
4ba86ea618 | |
|
|
16d895d460 | |
|
|
ef6b2ceb22 | |
|
|
f5afb65df8 | |
|
|
f0f44aeb39 | |
|
|
ef80a2f23b | |
|
|
439dcb95ac | |
|
|
083c0fd540 | |
|
|
5ad21ee097 | |
|
|
50f960ecea | |
|
|
4a3658e770 | |
|
|
825b80b319 | |
|
|
1345b97303 | |
|
|
9c17140b33 | |
|
|
17b9c09381 | |
|
|
35a812c058 | |
|
|
e08959263a | |
|
|
d81e230639 | |
|
|
dcd6f2ce18 | |
|
|
d5fee8d8c6 | |
|
|
dfdd8ed65a | |
|
|
a609600cd8 | |
|
|
d614d18e97 | |
|
|
288d894746 | |
|
|
036696878f | |
|
|
cbbef170e8 | |
|
|
13dd42d2be | |
|
|
c5c4e1667e | |
|
|
f5f0ff2822 | |
|
|
f7913cd04e | |
|
|
789d921fd7 | |
|
|
47a7e4a4da | |
|
|
06d3489b49 | |
|
|
ed463d67ab | |
|
|
8c8a049f77 | |
|
|
2e7de8a1ef | |
|
|
582e80b750 | |
|
|
ff038f31f9 | |
|
|
28e0396a65 | |
|
|
04a8c56ad6 | |
|
|
e2cf3c3d7e | |
|
|
fea0b42223 | |
|
|
c392142562 | |
|
|
8173e1f973 | |
|
|
47e4ef2b33 | |
|
|
9f33e375d0 | |
|
|
9b9612bd5f | |
|
|
b3822e48eb | |
|
|
2365a50b1b | |
|
|
f8de55e671 | |
|
|
001f0ac480 | |
|
|
0bd764e1d1 | |
|
|
ecd7a2a2dc | |
|
|
e865153e8e | |
|
|
da76037d04 | |
|
|
16e1e9159c | |
|
|
fd56de5c00 | |
|
|
3576af0f25 | |
|
|
b30017f3a7 | |
|
|
fc86af918f | |
|
|
ad79679ee2 | |
|
|
ed55be2b86 | |
|
|
480251b85f | |
|
|
ee1cfe082d | |
|
|
04eeadf7a7 | |
|
|
7346b3518a | |
|
|
549b21f298 | |
|
|
b7fc488dcf | |
|
|
e2451874ea | |
|
|
eb3f71fa2e | |
|
|
cc56b8fadf | |
|
|
f305a8cd97 | |
|
|
13d1e58b84 | |
|
|
d90c722c7d | |
|
|
50cb10d6a8 | |
|
|
c3d5da46f7 | |
|
|
136a5ba851 | |
|
|
444b720f8d | |
|
|
a3ee831193 | |
|
|
06e374e747 | |
|
|
d8be40b8b0 | |
|
|
2b0920f9b1 | |
|
|
f7de1e8d09 | |
|
|
77bbb43eb5 | |
|
|
fd5f4d10ed | |
|
|
7f66ed0ebe | |
|
|
5f484f6579 | |
|
|
c239ac65ee | |
|
|
543bee6d26 | |
|
|
9a4dd9729c | |
|
|
d5325efa2a | |
|
|
131c14742c | |
|
|
8541c83bf5 | |
|
|
c5f52190ef | |
|
|
4d62316d17 | |
|
|
7b6d6de801 | |
|
|
ff995a827b | |
|
|
66a718ea72 | |
|
|
d051178801 | |
|
|
c0229a1139 | |
|
|
0f8e9cf228 | |
|
|
d18733deb1 | |
|
|
b5512d421c | |
|
|
51c0f59924 | |
|
|
4a00c8066a | |
|
|
7a82a56ae5 | |
|
|
3564f30f27 | |
|
|
7ab28dced0 | |
|
|
24ff1409d0 | |
|
|
4dcc7d37ba | |
|
|
b876c9dfba | |
|
|
b231667aba | |
|
|
1708a03aaf | |
|
|
d0c504dcf3 | |
|
|
54121fa494 | |
|
|
e81757ad83 | |
|
|
ca69ebc839 | |
|
|
5ebdd4d592 | |
|
|
75b15acda2 | |
|
|
94ab63db30 | |
|
|
99fa003b12 | |
|
|
a09e163704 | |
|
|
2a95dd107f | |
|
|
042212eae6 | |
|
|
e284a46e83 | |
|
|
8193549aba | |
|
|
742419c0bf | |
|
|
da189ca3d4 | |
|
|
cd63643ba4 | |
|
|
138650d943 | |
|
|
9f898ccf44 | |
|
|
227d04bde3 | |
|
|
c1e32a8c04 | |
|
|
4d65b8dd83 | |
|
|
cfbda7bbc7 | |
|
|
ebbc483b35 | |
|
|
4089b9da6c | |
|
|
c1e749e532 | |
|
|
cd1d2cf8d2 | |
|
|
b688b0176e | |
|
|
879fc3a816 | |
|
|
ebea74e57b |
|
|
@ -483,7 +483,299 @@
|
|||
"Bash(git cherry-pick:*)",
|
||||
"Bash(git stash:*)",
|
||||
"Bash(docker compose build:*)",
|
||||
"Bash(git log:*)"
|
||||
"Bash(git log:*)",
|
||||
"Bash(git tag -a v0.3.0-pre-transfer -m \"$\\(cat <<''EOF''\nPre-transfer development checkpoint\n\nCompleted features:\n- Co-keygen: Multi-party key generation with TSS \\(GG20\\)\n- Service-party-app: Electron desktop application\n - Create shared wallet \\(keygen initiator\\)\n - Join wallet creation \\(keygen participant\\)\n - Wallet management \\(list, export, delete\\)\n - Kava network switch \\(mainnet/testnet\\)\n - EVM address derivation and balance display\n\nNot yet implemented:\n- Co-sign: Multi-party transaction signing\n- Transfer functionality\n\nThis tag marks the stable state before transfer feature development.\nEOF\n\\)\")",
|
||||
"Bash(tasklist:*)",
|
||||
"Bash(docker port:*)",
|
||||
"Bash(docker rm:*)",
|
||||
"Bash(netstat:*)",
|
||||
"Bash(start \"\" \"C:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\\\\backend\\\\mpc-system\\\\services\\\\service-party-app\\\\release\\\\win-unpacked\\\\榴莲皇后绿积分共管账户服务.exe\")",
|
||||
"Bash(go test:*)",
|
||||
"Bash(./tss-party.exe sign:*)",
|
||||
"Bash(git -C /c/Users/dong/Desktop/rwadurian log --oneline --all)",
|
||||
"Bash(git -C /c/Users/dong/Desktop/rwadurian diff --name-only HEAD~5..HEAD)",
|
||||
"Bash(git -C /c/Users/dong/Desktop/rwadurian log --all --oneline --grep=\"co-sign\\\\|co-managed\\\\|CoManaged\")",
|
||||
"Bash(git -C /c/Users/dong/Desktop/rwadurian show e038f178 --stat)",
|
||||
"Bash(git -C /c/Users/dong/Desktop/rwadurian show e114723a --stat)",
|
||||
"Bash(git -C /c/Users/dong/Desktop/rwadurian show c457d158 -- backend/mpc-system/services/account/adapters/input/http/account_handler.go)",
|
||||
"Bash(git -C /c/Users/dong/Desktop/rwadurian log --oneline -- backend/mpc-system/services/account/adapters/input/http/account_handler.go)",
|
||||
"Bash(git rev-list:*)",
|
||||
"Bash(dir /d \"C:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\")",
|
||||
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(service-party-app\\): implement co-sign multi-party signing\n\nAdd complete co-sign functionality for multi-party transaction signing:\n\nFrontend \\(React\\):\n- CoSignCreate.tsx: Create signing session with share selection\n- CoSignJoin.tsx: Join signing session via invite code\n- CoSignSession.tsx: Monitor signing progress and results\n- Add routes in App.tsx for new pages\n\nBackend \\(Electron\\):\n- main.ts: Add IPC handlers for co-sign operations\n- tss-handler.ts: Add participateSign\\(\\) for TSS signing\n- preload.ts: Expose cosign API to renderer\n- account-client.ts: Add sign session API types\n\nTSS Party \\(Go\\):\n- main.go: Implement ''sign'' command for GG20 signing protocol\n- integration_test.go: Add comprehensive tests for signing flow\n\nInfrastructure:\n- docker-compose.windows.yml: Expose gRPC port 50051\n\nThis is a pure additive change that does not affect existing\npersistent role keygen/sign functionality.\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(service-party-app\\): add transfer functionality with co-sign integration\n\nAdd complete KAVA transfer feature to the wallet home page:\n\nFrontend \\(React\\):\n- Home.tsx: Add transfer modal with address/amount input, transaction\n confirmation, and co-sign session initiation\n- Home.module.css: Transfer modal styles \\(form, confirm, error states\\)\n- CoSignSession.tsx: Add transaction broadcast after signing completion,\n with block explorer link\n\nUtils:\n- transaction.ts: EIP-1559 transaction building, RLP encoding, Keccak-256\n hashing, nonce/gas fetching, transaction broadcast via JSON-RPC\n\nFlow: Wallet -> Transfer Modal -> Prepare TX -> Confirm -> Co-Sign ->\n Sign Session -> Broadcast -> Block Explorer\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(powershell -Command:*)",
|
||||
"Bash(powershell -Command \"\n$content = Get-Content ''main.ts'' -Raw\n\n# 修改 threshold 部分\n$old1 = @''\n threshold: {\n t: activeCoSignSession?.threshold?.t || 0,\n n: activeCoSignSession?.threshold?.n || 0,\n },\n''@\n\n$new1 = @''\n threshold: {\n // 优先使用 API 返回的阈值,回退到 activeCoSignSession\n t: result?.threshold_t || activeCoSignSession?.threshold?.t || 0,\n n: result?.threshold_n || activeCoSignSession?.threshold?.n || 0,\n },\n''@\n\n$content = $content.Replace\\($old1, $new1\\)\n\n# 修改 participants 部分\n$old2 = ''participants: result?.parties?.map\\(\\(p: { party_id: string; party_index: number }, idx: number\\) => \\({''\n$new2 = ''participants: \\(\\(result as { participants?: Array<{ party_id: string; party_index: number; status: string }> }\\)?.participants || []\\).map\\(\\(p, idx\\) => \\({''\n\n$content = $content.Replace\\($old2, $new2\\)\n\n# 修改 status 部分\n$old3 = \"\" status: ''ready'',\"\"\n$new3 = \"\" status: p.status || ''waiting'',\"\"\n\n$content = $content.Replace\\($old3, $new3\\)\n\n# 修改结尾部分\n$old4 = '' }\\)\\) || [],''\n$new4 = '' }\\)\\),''\n\n$content = $content.Replace\\($old4, $new4\\)\n\nSet-Content ''main.ts'' -Value $content -NoNewline\nWrite-Output ''Done''\n\")",
|
||||
"Bash(node fix_main.js:*)",
|
||||
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(co-sign\\): add debug logs for auto-join flow in CoSignJoin\n\nAdd console.log statements to trace the auto-join logic:\n- Log loaded shares with sessionId\n- Log auto-select share matching check\n- Log auto-join conditions and share match status\n- Log validateInviteCode results including joinToken\n- Log handleJoinSession parameters\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(git commit -m \"$\\(cat <<''EOF''\nfix\\(co-sign\\): use keygen session threshold_n for TSS signing\n\n- Query keygen session from mpc_sessions table to get correct threshold_n\n- Pass keygenThresholdN to CreateSigningSessionAuto instead of len\\(parties\\)\n- Return parties list and correct threshold values in GetSignSessionByInviteCode\n- This fixes TSS signing failure \"U doesn 't equal T\" caused by mismatched n values\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(Get-Item \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\\\\backend\\\\mpc-system\\\\services\\\\service-party-app\\\\bin\\\\win32-x64\\\\tss-party.exe\")",
|
||||
"Bash(Select-Object Name, LastWriteTime, Length)",
|
||||
"Bash(Get-Item \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\\\\backend\\\\mpc-system\\\\services\\\\service-party-app\\\\release\\\\win-unpacked\\\\resources\\\\bin\\\\tss-party.exe\")",
|
||||
"Bash(git commit -m \"$\\(cat <<''EOF''\nfix\\(tss\\): use BuildLocalSaveDataSubset for threshold signing with party subsets\n\nWhen signing with fewer parties than keygen \\(e.g., 2-of-3 signing with only 2 parties\\),\nthe TSS-lib requires filtered save data containing only the participating parties.\n\nWithout this fix, signing fails with \"U doesn 't equal T\" error because:\n- Keygen creates save data for all N parties \\(e.g., 3 parties with indices 0, 1, 2\\)\n- Sign uses only T parties \\(e.g., 2 parties with indices 1, 2\\)\n- TSS-lib internal index validation fails due to mismatch\n\nChanges:\n- pkg/tss/signing.go: Use len\\(sortedPartyIDs\\) for partyCount and call BuildLocalSaveDataSubset\n- tss-party/main.go: Add BuildLocalSaveDataSubset call for Electron app\n- tss-wasm/main.go: Add BuildLocalSaveDataSubset call for WASM builds\n\nThis fix is backward compatible - when all parties participate, the subset equals the original data.\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(dir \"c:\\\\Android\")",
|
||||
"Bash(dir \"c:\\\\android-sdk\")",
|
||||
"Bash(dir \"%LOCALAPPDATA%\\\\Android\\\\Sdk\")",
|
||||
"Bash(cmd /c \"echo %LOCALAPPDATA%\")",
|
||||
"Bash(powershell:*)",
|
||||
"Bash(dir \"C:\\\\Users\\\\dong\\\\AppData\\\\Local\\\\Android\\\\Sdk\")",
|
||||
"Bash(dir /b C: 2)",
|
||||
"Bash(gradle --version:*)",
|
||||
"Bash(chmod:*)",
|
||||
"Bash(java -version:*)",
|
||||
"Bash(./gradlew assembleDebug:*)",
|
||||
"Bash(go version:*)",
|
||||
"Bash(export PATH=\"$PATH:/c/Users/dong/go/bin\")",
|
||||
"Bash(gomobile version:*)",
|
||||
"Bash(export ANDROID_HOME=\"/c/Android\")",
|
||||
"Bash(gomobile init:*)",
|
||||
"Bash(go install:*)",
|
||||
"Bash(go get:*)",
|
||||
"Bash(cmd /c \"gradlew.bat assembleDebug --no-daemon 2>&1\")",
|
||||
"Bash(./gradlew.bat assembleDebug:*)",
|
||||
"Bash(wc:*)",
|
||||
"Bash(./gradlew assembleRelease:*)",
|
||||
"Bash(./gradlew clean:*)",
|
||||
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(android\\): add Android TSS Party app with full API implementation\n\nMajor changes:\n- Add complete Android app \\(service-party-android\\) with Jetpack Compose UI\n- Implement real account-service API calls for keygen and sign sessions:\n - POST /api/v1/co-managed/sessions \\(create keygen session\\)\n - GET /api/v1/co-managed/sessions/by-invite-code/{code} \\(validate invite\\)\n - POST /api/v1/co-managed/sessions/{id}/join \\(join keygen session\\)\n - POST /api/v1/co-managed/sign \\(create sign session\\)\n - GET /api/v1/co-managed/sign/by-invite-code/{code} \\(validate sign invite\\)\n - POST /api/v1/co-managed/sign/{id}/join \\(join sign session\\)\n- Add QR code generation and scanning for session invites\n- Remove password requirement \\(use empty string\\)\n- Add floating action button for wallet creation\n- Add network type aware explorer links \\(mainnet/testnet\\)\n\nNetwork configuration:\n- Change default network to Kava mainnet for both Electron and Android apps\n- Electron: main.ts, transaction.ts, Settings.tsx, Layout.tsx\n- Android: Models.kt \\(NetworkType.MAINNET default\\)\n\nFeatures:\n- Full TSS keygen and sign protocol via gomobile bindings\n- gRPC message routing for multi-party communication\n- Cross-platform compatibility with service-party-app \\(Electron\\)\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(cmd /c \"build-apk.bat help\")",
|
||||
"Bash(go clean:*)",
|
||||
"Bash(gomobile bind:*)",
|
||||
"Bash(GOPROXY=https://proxy.golang.org,direct go get:*)",
|
||||
"Bash(go mod download:*)",
|
||||
"Bash(go env:*)",
|
||||
"Bash(cmd /c \"set GOFLAGS=-mod=mod && go get golang.org/x/mobile/bind && go mod tidy && gomobile bind -v -target=android -androidapi 21 -o ..\\\\app\\\\libs\\\\tsslib.aar .\")",
|
||||
"Bash(\"/c/Users/dong/go/bin/go1.22.10.exe\" download)",
|
||||
"Bash(\"/c/Users/dong/go/bin/go1.22.10.exe\" version)",
|
||||
"Bash(\"/c/Users/dong/go/bin/go1.22.10.exe\" install golang.org/x/mobile/cmd/gomobile@latest)",
|
||||
"Bash(\"/c/Users/dong/go/bin/go1.22.10.exe\" install golang.org/x/mobile/cmd/gobind@latest)",
|
||||
"Bash(\"/c/Users/dong/go/bin/go1.22.10.exe\" install golang.org/x/mobile/cmd/gomobile@v0.0.0-20250807114141-395d808d53cd)",
|
||||
"Bash(\"/c/Users/dong/go/bin/go1.22.10.exe\" install golang.org/x/mobile/cmd/gomobile@v0.0.0-20250808145247-395d808d53cd)",
|
||||
"Bash(\"/c/Users/dong/go/bin/go1.22.10.exe\" install golang.org/x/mobile/cmd/gomobile@c31d5b91ecc32c0d598b8fe8457d244ca0b4e815)",
|
||||
"Bash(\"/c/Users/dong/go/bin/go1.22.10.exe\" install golang.org/x/mobile/cmd/gobind@c31d5b91ecc32c0d598b8fe8457d244ca0b4e815)",
|
||||
"Bash(\"/c/Users/dong/go/bin/go1.22.10.exe\" mod tidy)",
|
||||
"Bash(adb devices:*)",
|
||||
"Bash(adb logcat:*)",
|
||||
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(android\\): add 5-minute polling timeout mechanism for keygen/sign\n\nImplements Electron''s checkAndTriggerKeygen\\(\\) polling fallback:\n- Adds polling every 2 seconds with 5-minute timeout\n- Triggers keygen/sign via synthetic session_started event on in_progress status\n- Handles gRPC stream disconnection when app goes to background\n- Shows timeout error in UI via existing error mechanism\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(go list:*)",
|
||||
"Bash(adb install:*)",
|
||||
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(tss\\): add real-time round progress from msg.Type\\(\\) parsing\n\nExtract current round number from tss-lib message type string using\nregex pattern `Round\\(\\\\d+\\)`. This enables real-time progress updates\n\\(1/4, 2/4... for keygen, 1/9, 2/9... for signing\\) instead of only\nshowing completion status.\n\nChanges across all three platforms:\n- tss-wasm/main.go: Add extractRoundFromMessageType\\(\\) and call\n OnProgress with parsed round on each outgoing message\n- service-party-android/tsslib/tsslib.go: Same implementation for\n Android gomobile binding\n- service-party-app/tss-party/main.go: Same implementation for\n Electron subprocess, with isKeygen parameter to distinguish\n keygen \\(4 rounds\\) vs signing \\(9 rounds\\)\n\nSafe fallback: Returns 0 if parsing fails, which doesn''t affect\nprotocol execution - only UI display.\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(node --input-type=module -e:*)",
|
||||
"Bash(npx solc:*)",
|
||||
"Bash(node /c/Users/dong/Desktop/rwadurian/contracts/deploy.mjs:*)",
|
||||
"Bash(npm init:*)",
|
||||
"Bash(node deploy.mjs:*)",
|
||||
"Bash(npx solcjs@0.8.19:*)",
|
||||
"Bash(node compile.mjs:*)",
|
||||
"Bash(node verify-sig.mjs:*)",
|
||||
"Bash(node deploy-ethers.mjs:*)",
|
||||
"Bash(node transfer-all.mjs:*)",
|
||||
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(android\\): add share export and import functionality\n\nAdd ability to backup wallet shares to files and restore from backups:\n\n- Add ShareBackup data class in Models.kt for backup format\n- Add exportShareBackup\\(\\) and importShareBackup\\(\\) in TssRepository\n- Add export/import state and methods in MainViewModel\n- Add file picker integration in MainActivity using ActivityResultContracts\n- Add import FAB button in WalletsScreen\n- Export saves as .tss-backup file with address and timestamp in filename\n- Import validates backup format and checks for duplicate wallets\n\nThe backup file contains all necessary data to restore a wallet share:\nsessionId, publicKey, encryptedShare, threshold, partyIndex, address.\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(dir /s /b \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\\\\backend\\\\services\\\\identity-service\\\\src\\\\api\\\\controllers\\\\*.ts\")",
|
||||
"Bash(dir /s /b \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\\\\backend\\\\services\\\\identity-service\\\\src\\\\infrastructure\\\\persistence\\\\repositories\\\\*.ts\")",
|
||||
"Bash(head:*)",
|
||||
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(pending-actions\\): add user pending actions system\n\nAdd a fully optional pending actions system that allows admins to configure\nspecific tasks that users must complete after login.\n\nBackend \\(identity-service\\):\n- Add UserPendingAction model to Prisma schema\n- Add migration for user_pending_actions table\n- Add PendingActionService with full CRUD operations\n- Add user-facing API \\(GET list, POST complete\\)\n- Add admin API \\(CRUD, batch create\\)\n\nAdmin Web:\n- Add pending actions management page\n- Support single/batch create, edit, cancel, delete\n- View action details including completion time\n- Filter by userId, actionCode, status\n\nFlutter Mobile App:\n- Add PendingActionService and PendingActionCheckService\n- Add PendingActionsPage for forced task execution\n- Integrate into splash_page login flow\n- Users must complete all pending tasks in priority order\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(npm run type-check:*)",
|
||||
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(settlement\\): implement settle-to-balance with detailed source tracking\n\nAdd complete settlement-to-balance feature that transfers settleable\nearnings directly to wallet USDT balance \\(no currency swap\\). Key changes:\n\nBackend \\(wallet-service\\):\n- Add SettleToBalanceCommand for settlement operations\n- Add settleToBalance method to WalletAccountAggregate\n- Add settleToBalance application service with ledger recording\n- Add internal API endpoint POST /api/v1/wallets/settle-to-balance\n\nBackend \\(reward-service\\):\n- Add settleToBalance client method for wallet-service communication\n- Add settleRewardsToBalance application service method\n- Add user-facing API endpoint POST /rewards/settle-to-balance\n- Build detailed settlement memo with source user tracking per reward\n\nFrontend \\(mobile-app\\):\n- Add SettleToBalanceResult model class\n- Add settleToBalance\\(\\) method to RewardService\n- Update pending_actions_page to handle SETTLE_REWARDS action\n- Add completion detection via settleableUsdt balance check\n\nSettlement memo now includes detailed breakdown by right type with\nsource user accountSequence for each reward entry, e.g.:\n 结算 1000.00 绿积分到钱包余额\n 涉及 5 笔奖励\n - SHARE_RIGHT: 500.00 绿积分\n 来自 D2512120001: 288.00 绿积分\n 来自 D2512120002: 212.00 绿积分\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(withdrawal\\): implement fiat withdrawal with bank/alipay/wechat\n\nAdd complete fiat withdrawal feature that allows users to withdraw\ngreen credits \\(绿积分\\) to their bank card, Alipay, or WeChat account\nwith 1:1 CNY conversion. Key changes:\n\nBackend \\(wallet-service\\):\n- Update Prisma schema with fiat withdrawal fields \\(paymentMethod,\n bankName, bankCardNo, cardHolderName, alipay*, wechat*, review fields\\)\n- Rewrite withdrawal status enum for fiat flow: PENDING → FROZEN →\n REVIEWING → APPROVED → PAYING → COMPLETED \\(or REJECTED/FAILED\\)\n- Add PaymentMethod enum: BANK_CARD, ALIPAY, WECHAT\n- Update WithdrawalOrderAggregate with new fiat withdrawal methods\n- Add review/payment workflow methods in WalletApplicationService\n- Add internal API endpoints for admin withdrawal management\n- Remove blockchain withdrawal event handler \\(no longer needed\\)\n\nFrontend \\(admin-web\\):\n- Add withdrawal review management page at /withdrawals\n- Add tabs for reviewing/approved/paying order states\n- Add withdrawal service and React Query hooks\n- Add types for withdrawal orders and payment methods\n- Add sidebar menu item for withdrawal review\n\nFrontend \\(mobile-app\\):\n- Add withdrawFiat\\(\\) method to WalletService\n- Add PaymentMethod enum with BANK_CARD/ALIPAY/WECHAT\n- Create new WithdrawFiatPage for fiat withdrawal input\n- Create WithdrawFiatConfirmPage with SMS + password verification\n- Add routes for /withdraw/fiat and /withdraw/fiat/confirm\n- Keep existing withdraw/usdt \\(划转\\) pages unchanged\n\nNote: The existing withdraw_usdt_page.dart is for point-to-point\ntransfer \\(划转\\), which is a different feature from fiat withdrawal.\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(git grep:*)",
|
||||
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(fiat-withdrawal\\): add complete fiat withdrawal system\n\n实现完整的法币提现功能,支持银行卡、支付宝、微信三种收款方式。\n此功能与现有的区块链划转功能完全独立,互不影响。\n\n## 后端 \\(wallet-service\\)\n\n### 数据库\n- 新增 `fiat_withdrawal_orders` 表存储法币提现订单\n- 与现有 `withdrawal_orders` 表\\(区块链划转\\)完全分离\n- 添加完整索引支持高效查询\n\n### 领域层\n- 新增 `FiatWithdrawalStatus` 枚举(与 WithdrawalStatus 独立)\n - 流程: PENDING -> FROZEN -> REVIEWING -> APPROVED -> PAYING -> COMPLETED\n - 或 REJECTED / FAILED / CANCELLED\n- 新增 `PaymentMethod` 枚举: BANK_CARD / ALIPAY / WECHAT\n- 新增 `FiatWithdrawalOrder` 聚合根\n- 新增 `IFiatWithdrawalOrderRepository` 仓储接口\n- 新增 `FIAT_WITHDRAWAL` 账本流水类型\n\n### 应用层\n- 新增 `FiatWithdrawalApplicationService` 处理业务逻辑\n - 发送短信验证码\n - 申请法币提现(冻结余额)\n - 提交审核\n - 审核通过/驳回\n - 开始打款\n - 完成打款\n\n### API层\n- 新增 `FiatWithdrawalController` 提供用户端API\n - POST /wallet/fiat-withdrawal/send-sms - 发送验证码\n - POST /wallet/fiat-withdrawal - 申请提现\n - GET /wallet/fiat-withdrawal - 获取提现记录\n- 新增内部API供管理端调用\n - GET /api/v1/wallets/fiat-withdrawals - 查询订单\n - POST /api/v1/wallets/fiat-withdrawals/:orderNo/review - 审核\n - POST /api/v1/wallets/fiat-withdrawals/:orderNo/start-payment - 开始打款\n - POST /api/v1/wallets/fiat-withdrawals/:orderNo/complete-payment - 完成打款\n\n## 前端 \\(admin-web\\)\n\n- 新增法币提现审核管理页面 `/withdrawals`\n- 支持按状态分 Tab 查看订单\n- 支持审核通过/驳回\n- 支持打款操作\n- 支持查看订单详情\n\n## 前端 \\(mobile-app\\)\n\n- 新增 `WithdrawFiatPage` 法币提现页面\n - 支持选择银行卡/支付宝/微信\n - 输入收款账户信息\n- 新增 `WithdrawFiatConfirmPage` 确认页面\n - 短信验证码验证\n - 密码验证\n- 在 `WalletService` 中添加法币提现相关方法和模型\n\n## 重要说明\n\n此功能与现有的区块链划转功能 \\(withdraw_usdt_page.dart\\) 完全独立:\n- 独立的数据库表\n- 独立的聚合根\n- 独立的状态枚举\n- 独立的API端点\n- 独立的前端页面\n\n原有的区块链划转功能保持不变,不受任何影响。\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(pending-actions\\): add special deduction feature for admin-created user actions\n\n实现特殊扣减功能,允许管理员为用户创建扣减待办操作,由用户在移动端确认执行。\n\n## 后端 \\(wallet-service\\)\n\n### 领域层\n- 新增 `SPECIAL_DEDUCTION` 到 LedgerEntryType 枚举\n 用于记录特殊扣减的账本流水类型\n\n### 应用层\n- 新增 `executeSpecialDeduction` 方法\n - 验证用户钱包存在性\n - 检查余额是否充足\n - 乐观锁控制并发\n - 扣减余额并记录账本流水\n - 返回操作结果和新余额\n\n### API层\n- 新增内部API: POST /api/v1/wallets/special-deduction/execute\n 供移动端调用执行特殊扣减操作\n\n## 前端 \\(admin-web\\)\n\n### 类型定义\n- 新增 `SPECIAL_DEDUCTION` 到 ACTION_CODES\n- 新增 `SpecialDeductionParams` 接口定义扣减参数\n - amount: 扣减金额\n - reason: 扣减原因\n\n### 页面\n- 更新待办操作管理页面\n - 当选择 SPECIAL_DEDUCTION 时显示扣减金额和原因输入框\n - 验证扣减金额必须大于0\n - 验证扣减原因不能为空\n\n### 样式\n- 新增特殊扣减表单区域样式\n\n## 前端 \\(mobile-app\\)\n\n### 服务层\n- 新增 `executeSpecialDeduction` 方法到 WalletService\n- 新增 `SpecialDeductionResult` 结果类\n- 新增 `specialDeduction` 到 PendingActionCode 枚举\n\n### 页面\n- 新增 `SpecialDeductionPage` 特殊扣减确认页面\n - 显示扣减金额和管理员备注\n - 显示当前余额和扣减后余额\n - 余额不足时禁用确认按钮\n - 温馨提示说明操作性质\n\n- 更新 `PendingActionsPage`\n - 处理 SPECIAL_DEDUCTION 类型的待办操作\n - 从 actionParams 解析 amount 和 reason\n - 导航到特殊扣减确认页面\n\n## 工作流程\n\n1. 管理员在 admin-web 创建 SPECIAL_DEDUCTION 待办操作\n - 选择目标用户\n - 输入扣减金额\n - 输入扣减原因\n\n2. 用户在 mobile-app 待办操作列表看到该操作\n\n3. 用户点击后进入特殊扣减确认页面\n - 查看扣减详情\n - 确认余额充足\n - 点击确认执行扣减\n\n4. 后端执行扣减并记录账本流水\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(git check-ignore:*)",
|
||||
"Bash(git hash-object:*)",
|
||||
"Bash(git commit -m \"$\\(cat <<''EOF''\nfix\\(planting\\): draw signature directly on page instead of using form field\n\nThe PDF signature field is only 92x51 points, which causes signatures to\nappear too small or invisible. Changed to use drawImage\\(\\) directly on\nthe page at the field''s position with a larger size \\(150x80 max\\).\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(pnpm exec tsc:*)",
|
||||
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(wallet-service\\): add offline settlement deduction feature\n\nAdd new functionality for admins to automatically deduct all settled\nearnings when creating special deductions with amount=0, marking\neach record to prevent duplicate deductions.\n\n- Add OfflineSettlementDeduction model to track deducted records\n- Add API endpoints for querying unprocessed settlements and executing batch deduction\n- Add mode selection UI in admin-web pending-actions\n- Add offline settlement card display in mobile-app special deduction page\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(git commit -m \"$\\(cat <<''EOF''\nfix\\(wallet-service\\): convert BigInt to string for JSON serialization in getUnprocessedSettlements\n\nThe entry.id field is BigInt type from Prisma which cannot be JSON serialized directly.\nConvert to string for API response and back to BigInt when storing to database.\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(mobile-app\\): improve empty state display for offline settlement deduction\n\nWhen there are no settlement records to deduct, show a more informative message:\n- If user has balance from deposits/transfers: explain it''s not from earnings\n- If user has no balance: explain there are no settlement records\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(xargs:*)",
|
||||
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(wallet/blockchain\\): 热钱包余额预检查及接收方钱包自动创建\n\n1. blockchain-service: 新增热钱包 dUSDT 余额定时更新调度器\n - 每 5 秒查询热钱包在 KAVA 链上的 dUSDT 余额\n - 更新到 Redis DB 0,key 格式: hot_wallet:dusdt_balance:{chainType}\n - TTL 30 秒,服务故障时缓存自动过期\n\n2. wallet-service: 新增热钱包余额缓存服务\n - 从 Redis DB 0 读取热钱包余额缓存\n - 严格模式:无法获取余额或余额不足时拒绝转账\n - 提示信息:\"财务系统审计中,请稍后再试\"\n\n3. wallet-service: 转账确认时自动创建接收方钱包\n - 解决接收方钱包不存在导致入账失败的问题\n - 使用 upsert 避免并发创建冲突\n - 在同一事务中完成创建和入账\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(wallet-service\\): 添加内部转账入账修复脚本\n\n新增一次性修复脚本用于补录因接收方钱包未创建导致入账失败的内部转账。\n\n脚本特性:\n- DRY_RUN 模式:默认只检查不执行,需手动改为 false 才真正修复\n- 完整验证:订单状态、类型、接收方信息、txHash\n- 幂等性检查:确认接收方没有 TRANSFER_IN 流水\n- 转出方验证:确认转出方有 TRANSFER_OUT 流水(已扣款)\n- 乐观锁:使用 version 字段防止并发修改\n- 审计追踪:payloadJson.dataFix=true 标记修复操作\n- 详细日志:每步操作都有时间戳和日志级别\n\n使用方法:\n1. 在 wallet-service 容器内执行 DRY_RUN 检查\n2. 确认无误后将 DRY_RUN 改为 false 再次执行\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(mobile-app\\): 添加待办操作轮询机制\n\n解决老版本 App 升级后不重启导致无法激活待办事项的问题。\n\n- 新增 PendingActionPollingService 定时轮询服务(每4秒检查)\n- App启动时无待办则启动轮询,有待办则直接进入待办页面\n- 轮询检测到待办后自动停止并跳转,防止重入问题\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(mobile-app\\): 用户资料页添加\"同伴认种\"标题和快捷标签\n\n- 在统计卡片上方添加\"同伴认种\"标题(紫色)\n- 在统计卡片下方添加\"引荐\"、\"同伴\"、\"本人\"快捷标签\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(git commit -m \"$\\(cat <<''EOF''\nfix\\(mobile-app\\): 用户资料页术语修改\n\n- 直推 → 引荐\n- 伞下 → 同伴\n- 个人认种 → 本人认种\n- 团队认种 → 同伴认种\n- 推荐人 → 引荐人\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(git commit -m \"$\\(cat <<''EOF''\nfix\\(mobile-app\\): 已结算数据改为从流水统计API获取\n\n- 从 wallet-service 的 getLedgerStatistics\\(\\) 获取 REWARD_SETTLED 类型的总金额\n- 与流水明细中的结算记录统计来自同一数据源,确保数据一致性\n- 添加调试日志对比 summary 和流水统计的数据\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(git commit -m \"$\\(cat <<''EOF''\nfix\\(authorization\\): 火柴人排名过滤已撤销授权的考核记录\n\n- findRankingsByMonthAndRegion 和 findRankingsByMonthAndRoleType 增加过滤条件\n- 排除 authorization.status = ''REVOKED'' 的记录\n- 解决同一用户因有多条授权记录(含已撤销)而重复显示的问题\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(git commit -m \"$\\(cat <<''EOF''\nfix\\(reward-service\\): 修复 WalletServiceClient 未正确解析 wallet-service 响应格式的 Bug\n\n问题原因:\nwallet-service 使用全局 TransformInterceptor 拦截器,会将所有响应包装成:\n{ success: true, data: { success: boolean, ... }, timestamp: \"...\" }\n\n原代码直接读取外层的 success 字段(始终为 true),导致即使业务失败\n(内层 data.success = false)也被误判为成功。\n\n具体案例:\n用户 D25122700024 点击结算时,wallet-service 因余额不足返回:\n{ success: true, data: { success: false, error: \"Insufficient...\" }, ... }\nreward-service 误读为成功,导致奖励被标记为 SETTLED 但钱包余额未变更。\n\n修复内容:\n1. settleToBalance: 解析 response_data.data 获取真实业务结果\n2. confirmPlantingDeduction: 同上\n3. allocateFunds: 同上\n\n所有方法现在会:\n- 使用 response_data.data || response_data 兼容包装和非包装格式\n- 严格检查 data.success !== true 来判断业务是否成功\n- 失败时记录详细错误日志\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(git commit -m \"$\\(cat <<''EOF''\nfix\\(wallet-service\\): 统一奖励分配到 settleable_usdt,与 reward-service 保持一致\n\n问题原因:\nwallet-service 对不同类型奖励的分配方式不一致:\n- SHARE_RIGHT: 正确使用 addSettleableReward\\(\\) → settleable_usdt\n- CITY_TEAM_RIGHT/COMMUNITY_RIGHT: 错误使用 addAvailableBalance\\(\\) → usdt_available\n\n这导致 reward-service 记录的 SETTLEABLE 奖励总额与 wallet-service 的\nsettleable_usdt 字段不匹配。用户 D25122700024 的案例中:\n- reward-service: 3条奖励共 4464 USDT \\(SHARE_RIGHT 3600 + CITY_TEAM_RIGHT 288 + COMMUNITY_RIGHT 576\\)\n- wallet-service: settleable_usdt = 3600 \\(仅 SHARE_RIGHT\\)\n差额 864 USDT 被错误地放入了 usdt_available\n\n修复内容:\n1. allocateCommunityRight: 改用 addSettleableReward\\(\\) 替代 addAvailableBalance\\(\\)\n2. allocateToRegionAccount: 改用 addSettleableReward\\(\\) 替代 addAvailableBalance\\(\\)\n3. 流水类型统一使用 REWARD_TO_SETTLEABLE 替代 SYSTEM_ALLOCATION\n4. 日志和备注更新以反映新的分配方式\n\n设计原则:\n- reward-service 是奖励的权威来源\n- wallet-service 应跟随 reward-service 的设计\n- 所有奖励都应进入 settleable_usdt,用户主动结算后才转入 usdt_available\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(ls \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\\\\backend\\\\services\\\\reward-service\\\\prisma\"\" 2>/dev/null || dir \"c:UsersdongDesktoprwadurianbackendservicesreward-serviceprisma\"\")",
|
||||
"Bash(git commit -m \"$\\(cat <<''EOF''\nfix\\(wallet-service\\): 修复 settleToBalance 方法缺少事务保护的严重 Bug\n\n问题原因:\nsettleToBalance 方法先执行 wallet.save\\(\\) 更新账户余额,再执行\nledgerRepo.save\\(\\) 写入流水记录。两个操作不在同一个事务中。\n\n当流水写入失败时(如 memo 字段超过 VarChar\\(500\\) 限制),账户余额\n已经被修改,但流水记录未写入,导致数据不一致。\n\n具体案例:\n用户 D25122700023 点击结算时,memo 内容超长(66笔奖励详情),\nwallet-service 先把 settleable_usdt 转入 usdt_available,然后\n写流水失败。账户余额被改但没有对应流水。\n\n修复内容:\n1. settleToBalance: 使用 prisma.$transaction 确保原子性\n - 账户余额更新和流水记录在同一事务中\n - 任一操作失败整个事务回滚\n2. schema: memo 字段从 VarChar\\(500\\) 改为 Text 类型,无长度限制\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(wallet-service\\): 实现 Unit of Work 模式保证 settleToBalance 事务原子性\n\n- 新增 UnitOfWork 接口和实现,使用 Prisma Interactive Transaction\n- 修改 IWalletAccountRepository 和 ILedgerEntryRepository 接口支持可选事务参数\n- 修改仓库实现,支持在事务中执行数据库操作\n- 修改 settleToBalance 方法使用 UnitOfWork,确保钱包更新和流水记录原子性\n- 注册 UnitOfWorkService 到 InfrastructureModule\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(ls -la \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\\\\backend\\\\services\\\\wallet-service\\\\prisma\\\\migrations\"\" 2>/dev/null || dir \"c:UsersdongDesktoprwadurianbackendserviceswallet-serviceprismamigrations \")",
|
||||
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(admin-web\\): 添加系统账户收益类型汇总统计功能\n\n在数据统计-系统账户中新增5个统计Tab:\n- 手续费账户汇总:统计成本费、运营费、总部社区基础费、RWAD底池注入\n- 省团队收益汇总:统计省团队权益收益\n- 市团队收益汇总:统计市团队权益收益\n- 分享引荐收益汇总:统计分享权益收益\n- 社区收益汇总:统计社区权益收益\n\n后端变更:\n- reward-service: 添加 getRewardsSummaryByType、getAllRewardTypeSummaries 方法\n- reporting-service: 聚合收益类型汇总统计接口\n\n前端变更:\n- 添加 RewardTypeSummary、FeeAccountSummary 类型定义\n- 添加 getRewardTypeSummaries API 方法\n- 添加 FeeAccountSection、RewardTypeSummarySection 组件\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(wallet-service\\): 实现手续费归集账户功能\n\n- 新增系统账户 S0000000006 \\(user_id=-6\\) 用于归集提现手续费\n- 新增 FEE_COLLECTION 流水类型记录手续费归集\n- 区块链提现完成时使用 UnitOfWork 事务归集手续费\n- 法币提现完成时在事务中归集手续费\n- WithdrawalOrderRepository 添加事务支持\n- 所有手续费归集操作使用乐观锁保护\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(backend/services/blockchain-service/src/application/application.module.ts )",
|
||||
"Bash(backend/services/blockchain-service/src/application/event-handlers/system-withdrawal-requested.handler.ts )",
|
||||
"Bash(backend/services/blockchain-service/src/infrastructure/kafka/withdrawal-event-consumer.service.ts )",
|
||||
"Bash(backend/services/wallet-service/src/api/api.module.ts )",
|
||||
"Bash(backend/services/wallet-service/src/api/controllers/index.ts )",
|
||||
"Bash(backend/services/wallet-service/src/api/controllers/system-withdrawal.controller.ts )",
|
||||
"Bash(backend/services/wallet-service/src/application/services/index.ts )",
|
||||
"Bash(backend/services/wallet-service/src/application/services/system-withdrawal-application.service.ts )",
|
||||
"Bash(backend/services/wallet-service/src/application/event-handlers/system-withdrawal-status.handler.ts )",
|
||||
"Bash(backend/services/wallet-service/src/infrastructure/external/identity/identity-client.service.ts )",
|
||||
"Bash(backend/services/wallet-service/src/infrastructure/kafka/withdrawal-event-consumer.service.ts)",
|
||||
"Bash(backend/services/planting-service/src/api/controllers/planting-stats.controller.ts )",
|
||||
"Bash(backend/services/planting-service/src/api/dto/response/planting-stats.response.ts )",
|
||||
"Bash(backend/services/planting-service/src/domain/repositories/planting-order.repository.interface.ts )",
|
||||
"Bash(backend/services/planting-service/src/infrastructure/persistence/repositories/planting-order.repository.impl.ts )",
|
||||
"Bash(frontend/admin-web/src/app/\\\\\\(dashboard\\\\\\)/statistics/page.tsx )",
|
||||
"Bash(frontend/admin-web/src/app/\\\\\\(dashboard\\\\\\)/statistics/statistics.module.scss )",
|
||||
"Bash(frontend/admin-web/src/services/dashboardService.ts )",
|
||||
"Bash(frontend/admin-web/src/types/dashboard.types.ts)",
|
||||
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(wallet/blockchain/identity\\): implement system account withdrawal feature\n\n- Add SystemWithdrawalApplicationService to handle system account transfers\n- Add SystemWithdrawalController with endpoints for request, query, and account listing\n- Add SystemWithdrawalStatusHandler to process blockchain confirmation/failure events\n- Add SystemWithdrawalRequestedHandler in blockchain-service to execute ERC20 transfers\n- Add getUserByAccountSequence endpoint in identity-service for user lookup\n- Support dynamic memo generation based on actual source account name\n- Dual-sided ledger entries for system account transfers\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(frontend/admin-web/src/hooks/index.ts )",
|
||||
"Bash(frontend/admin-web/src/hooks/useSystemWithdrawal.ts )",
|
||||
"Bash(frontend/admin-web/src/services/systemWithdrawalService.ts )",
|
||||
"Bash(frontend/admin-web/src/types/system-withdrawal.types.ts )",
|
||||
"Bash(\"frontend/admin-web/src/app/\\(dashboard\\)/system-transfer/\")",
|
||||
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(admin-web\\): add system account transfer management page\n\n- Add system-transfer page with transfer form and order history\n- Add SystemWithdrawalService for API calls\n- Add useSystemWithdrawal hooks for React Query integration\n- Add system-withdrawal types definitions\n- Add navigation menu item for system transfer\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(PGPASSWORD=rwa_dev_password psql:*)",
|
||||
"Bash(where psql:*)",
|
||||
"Bash(git commit -m \"$\\(cat <<''EOF''\nfix\\(reporting-service\\): 修复面对面结算数据解包问题\n\nwallet-service 返回 { success, data, timestamp } 包装格式,\ngetOfflineSettlementSummary 需要用 response.data.data 解包才能获取真正的数据。\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(git commit -m \"$\\(cat <<''EOF''\nfix\\(wallet/reporting\\): 修复手续费归集统计 API 的数据库表名和响应解包问题\n\n- wallet-service: 修复 getFeeCollectionSummary 中原生 SQL 使用错误表名\n - 将 ledger_entries 改为 wallet_ledger_entries(Prisma 映射表名)\n- reporting-service: 修复 getFeeCollectionSummary/Entries 响应解包\n - wallet-service 返回 { success, data, timestamp } 格式需要解包 data\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(wallet-service\\): 添加手续费归集统计的历史数据兼容\n\n当 FEE_COLLECTION 流水为空时,自动从提现订单表查询历史手续费:\n- getFeeCollectionSummary: 从 withdrawal_orders 和 fiat_withdrawal_orders 聚合统计\n- getFeeCollectionEntries: 从两个订单表查询明细列表,支持分页和类型筛选\n- 按月统计使用 UNION ALL 合并两种提现订单数据\n- 明细记录添加备注说明区分来源(区块链/法币)\n\n回滚方式:删除 fallback 代码块和两个私有方法\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(dir /s /b *.yml)",
|
||||
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(mobile-app\\): 添加联系客服功能\n\n在个人中心设置菜单中添加\"联系客服\"入口,点击后显示弹窗,\n用户可以查看客服的QQ号和微信号,并支持一键复制到剪贴板。\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(git commit -m \"$\\(cat <<''EOF''\nfix\\(wallet-service, admin-web\\): 修复系统账户划转金额类型问题\n\n- wallet-service: 支持 amount 为字符串或数字类型,添加类型转换\n- admin-web: 改进错误处理,正确提取 Axios 错误消息\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(mobile-app\\): 更新客服联系方式\n\n- 客服微信1: liulianhuanghou1\n- 客服微信2: liulianhuanghou2\n- 客服QQ1: 1502109619\n- 客服QQ2: 2171447109\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(wallet-service\\): 添加运营1和积分股池到系统划转账户列表\n\n- 添加 S0000000002 \\(运营1\\) 和 S0000000004 \\(积分股池\\) 到允许转出白名单\n- 更新系统账户名称映射与前端保持一致\n- 为 S0000000006 手续费归集账户添加兼容逻辑,当余额为0时从提现订单表统计历史手续费\n- 优化过期奖励处理,按分配类型分别记录流水便于明细查看\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(git commit -m \"$\\(cat <<''EOF''\nfix\\(wallet-service\\): 修复系统账户余额统计不一致问题\n\n- 账户余额改为 usdtAvailable + settleableUsdt,与累计收入统计保持一致\n- 解决社区权益进入 settleableUsdt 导致的余额与累计收入不匹配问题\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(npx eslint:*)",
|
||||
"Bash(backend/services/admin-service/src/infrastructure/kafka/cdc-consumer.service.ts )",
|
||||
"Bash(backend/services/admin-service/src/infrastructure/kafka/index.ts )",
|
||||
"Bash(backend/services/admin-service/src/infrastructure/kafka/kafka.module.ts )",
|
||||
"Bash(backend/services/deploy.sh )",
|
||||
"Bash(backend/services/docker-compose.yml )",
|
||||
"Bash(backend/services/scripts/init-databases.sh )",
|
||||
"Bash(backend/services/scripts/debezium/)",
|
||||
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(admin-service\\): 实现 Debezium CDC 数据同步\n\n- 新增 CdcConsumerService 消费 PostgreSQL WAL 变更事件\n- 配置 Debezium Connect 服务和 PostgreSQL 逻辑复制\n- 更新 deploy.sh 支持 Debezium 启动和连接器管理\n- 新增 identity-postgres-connector 配置同步 user_accounts 表\n- 保留原有 Outbox 机制用于业务领域事件\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(git commit -m \"$\\(cat <<''EOF''\nfix\\(referral-service\\): 修复 Kafka 消费异常被吞掉的问题\n\n- kafka.service.ts: 抛出异常让 KafkaJS 触发重试\n- user-registered.handler.ts: 传播异常到 KafkaService\n\n修复前处理失败的消息不会重试,导致推荐关系可能丢失\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(git commit -m \"$\\(cat <<''EOF''\nfix\\(leaderboard-service\\): 修复健康检查 API 路径\n\n将 Dockerfile 和 docker-compose.yml 中的健康检查路径从\n/api/health 修改为 /api/v1/health,与实际 API 路由保持一致\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(backend/services/admin-service/prisma/migrations/20250107100000_add_referral_query_view/ )",
|
||||
"Bash(backend/services/admin-service/src/infrastructure/kafka/referral-cdc-consumer.service.ts )",
|
||||
"Bash(backend/services/scripts/debezium/referral-connector.json)",
|
||||
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(admin-service\\): 添加 referral-service CDC 数据同步\n\n- 新增 ReferralQueryView schema 和 migration\n- 新增 ReferralCdcConsumerService 消费推荐关系变更\n- 配置 referral-postgres-connector 用于 Debezium CDC\n- 更新 deploy.sh 自动注册 referral connector\n- 更新 init-databases.sh 配置 rwa_referral 逻辑复制权限\n\nCDC 同步的字段:\n- user_id, account_sequence, referrer_id\n- my_referral_code, used_referral_code\n- ancestor_path, depth\n- direct_referral_count, active_direct_count\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(admin-service\\): 添加 CDC 分类账流水同步\n\n新增 wallet/planting/authorization 服务的 CDC 数据同步:\n\n状态表同步:\n- WalletAccountQueryView: 钱包账户余额状态\n- WithdrawalOrderQueryView: 提现订单状态\n- FiatWithdrawalOrderQueryView: 法币提现订单\n- PlantingOrderQueryView: 认种订单状态\n- PlantingPositionQueryView: 持仓状态\n- ContractSigningTaskQueryView: 合同签约任务\n- AuthorizationRoleQueryView: 授权角色\n- MonthlyAssessmentQueryView: 月度考核\n- SystemAccountQueryView: 系统账户余额\n\n分类账流水同步:\n- WalletLedgerEntryView: 钱包流水分类账\n- FundAllocationView: 认种资金分配记录\n- SystemAccountLedgerView: 系统账户流水\n\n其他:\n- Debezium Connect 端口改为 8084 避免冲突\n- 更新连接器配置添加流水表\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash($env:DATABASE_URL=\"postgresql://test:test@localhost:5432/test\")",
|
||||
"Bash(DATABASE_URL=\"postgresql://test:test@localhost:5432/test\" npx prisma validate:*)",
|
||||
"Bash(DATABASE_URL=\"postgresql://test:test@localhost:5432/test\" npx prisma format:*)",
|
||||
"Bash(timeout 60 npx tsc:*)",
|
||||
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(wallet-service\\): 三层保护机制确保内部转账接收方钱包存在\n\n新增三层保护机制:\n1. 用户注册时:监听 identity.UserAccountCreated 事件自动创建钱包\n2. 发起转账时:检测内部转账后调用 ensureWalletExists\\(\\) 预创建钱包\n3. 链上确认时:原有 upsert 逻辑兜底(保持不变)\n\n新增文件:\n- identity-event-consumer.service.ts: 消费 identity 用户注册事件\n- user-account-created.handler.ts: 处理用户注册事件创建钱包\n\n新增 API:\n- POST /wallets/ensure-wallet: 确保单个钱包存在\n- POST /wallets/ensure-wallets: 批量确保钱包存在\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(git -C \"c:/Users/dong/Desktop/rwadurian\" add -A)",
|
||||
"Bash(git -C \"c:/Users/dong/Desktop/rwadurian\" commit -m \"$\\(cat <<''EOF''\nfix\\(planting-service\\): 修复合同PDF签署日期显示为UTC时间的问题\n\n合同生成时使用 new Date\\(\\).toISOString\\(\\).split\\(''T''\\)[0] 获取日期,\n该方法返回UTC时间,导致北京时间凌晨签署的合同显示为前一天日期。\n\n修复方案:新增 getBeijingDateString\\(\\) 函数,将UTC时间转换为北京时间\\(UTC+8\\)\n\n影响范围:仅影响PDF合同上显示的签署日期,不影响数据库时间戳或业务逻辑\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(git -C \"c:/Users/dong/Desktop/rwadurian\" push origin main)",
|
||||
"Bash(git -C \"c:/Users/dong/Desktop/rwadurian\" tag -a v1.0.0 -m \"$\\(cat <<''EOF''\nRelease v1.0.0 - 正式发布\n\n主要功能:\n- 用户身份认证与KYC实名认证\n- 榴莲树认种与合同签署系统\n- 钱包与资产管理(USDT/绿积分/算力)\n- 推荐关系与团队管理\n- 收益分配与奖励系统\n- 排行榜系统\n- 后台管理系统\n- MPC多方计算钱包\n- 区块链服务(KAVA链)\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\nEOF\n\\)\")",
|
||||
"Bash(git -C \"c:/Users/dong/Desktop/rwadurian\" push origin v1.0.0)",
|
||||
"Bash(git -C \"c:/Users/dong/Desktop/rwadurian\" commit -m \"$\\(cat <<''EOF''\nfix\\(admin-service\\): 修复用户数据CDC同步使用userId导致的数据不一致问题\n\n问题原因:\n- 旧的Kafka事件消费者和CDC消费者同时运行\n- 旧消费者写入的数据userId可能为0\n- CDC消费者使用userId作为upsert条件,导致唯一键冲突失败\n- 用户的nickname和kycStatus等信息没有正确同步\n\n修复方案:\n- upsert方法改用accountSequence作为唯一键\n- CDC消费者的handleUpdate使用accountSequence检查和更新\n- 更新时同时修复可能错误的userId\n- 新增existsByAccountSequence和updateKycStatusByAccountSequence方法\n\n影响范围:\n- admin-web用户管理页面现在能正确显示用户昵称和KYC状态\n- 新用户注册后数据能正确同步\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(git -C \"c:/Users/dong/Desktop/rwadurian\" diff backend/services/docker-compose.yml)",
|
||||
"Bash(git -C \"c:/Users/dong/Desktop/rwadurian\" add backend/services/docker-compose.yml)",
|
||||
"Bash(git -C \"c:/Users/dong/Desktop/rwadurian\" commit -m \"$\\(cat <<''EOF''\nfix\\(admin-service\\): 添加uploads目录的volume持久化配置\n\n问题:admin-service重新部署后,上传的APK文件会丢失\n原因:主docker-compose.yml中admin-service未配置volume挂载,\n 导致容器重建时/app/uploads目录数据丢失\n\n修复:\n- 添加admin_uploads_data volume挂载到/app/uploads\n- 添加UPLOAD_DIR环境变量\n- 在volumes部分声明admin_uploads_data\n\n影响范围:仅影响admin-service的文件存储持久化\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(git -C \"c:/Users/dong/Desktop/rwadurian\" diff frontend/admin-web/src/app/\\\\\\(dashboard\\\\\\)/authorization/page.tsx)",
|
||||
"Bash(git -C \"c:/Users/dong/Desktop/rwadurian\" add frontend/admin-web/src/app/\\\\\\(dashboard\\\\\\)/authorization/page.tsx)",
|
||||
"Bash(git -C \"c:/Users/dong/Desktop/rwadurian\" commit -m \"$\\(cat <<''EOF''\nfix\\(admin-web\\): 优化授权页面错误提示,显示后端真实错误信息\n\n问题:创建授权失败时只显示\"Request failed with status code 400\"\n用户无法了解失败的真实原因(如用户未种树、授权冲突等)\n\n修复:\n- handleCreate和handleRevoke的catch块优先从err.response.data.message提取后端错误\n- 后端已有完善的错误提示如\"用户尚未认种任何树,无法授权\"\n- 前端现在能正确显示这些提示帮助管理员了解真实情况\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(git -C \"c:/Users/dong/Desktop/rwadurian\" diff frontend/mobile-app/lib/features/pending_actions/presentation/pages/pending_actions_page.dart)",
|
||||
"Bash(git -C \"c:/Users/dong/Desktop/rwadurian\" checkout -- frontend/mobile-app/lib/features/pending_actions/presentation/pages/pending_actions_page.dart)",
|
||||
"Bash(git -C \"c:/Users/dong/Desktop/rwadurian\" add frontend/mobile-app/lib/features/pending_actions/presentation/pages/pending_actions_page.dart)",
|
||||
"Bash(git -C \"c:/Users/dong/Desktop/rwadurian\" commit -m \"$\\(cat <<''EOF''\nfix\\(mobile-app\\): 修复认种向导待办操作无法正确标记完成的问题\n\n问题:用户完成认种并签署合同后,ADOPTION_WIZARD待办操作没有被标记为完成,\n导致用户被卡在待办操作页面无法进入App。\n\n原因:原来的检查逻辑只检查是否有\"待签合同\",当用户已签署合同后,\npendingTasks为空,返回false,导致待办操作无法完成。\n\n修复方案:\n- 改为检查用户是否有已支付的认种订单(PAID/FUND_ALLOCATED状态)\n- 通过比较订单创建时间和待办操作创建时间来判断\n- 订单在待办操作之后创建 → 已完成\n- 订单在待办操作之前但相差不超过24小时 → 也认为已完成(兼容延迟)\n- 保留待签合同的备用检查逻辑\n\n影响范围:仅影响ADOPTION_WIZARD待办操作的完成检测\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(contribution-service\\): 添加算力管理微服务\n\n## 概述\n为榴莲生态2.0添加 contribution-service 微服务,负责算力计算、分配和快照管理。\n\n## 架构设计\n- 采用 DDD + Hexagonal Architecture \\(六边形架构\\)\n- 使用 NestJS 框架 + Prisma ORM\n- 通过 Kafka CDC \\(Debezium\\) 从 user-service 同步数据\n- 使用 accountSequence \\(而非 userId\\) 进行跨服务关联\n\n## 核心功能模块\n\n### 1. Domain Layer \\(领域层\\)\n- ContributionAccountAggregate: 算力账户聚合根\n- ContributionRecordAggregate: 算力记录聚合根\n- ContributionAmount: 算力金额值对象 \\(基于 Decimal.js\\)\n- DistributionRate: 分配比例值对象\n- ContributionSourceType: 算力来源类型枚举 \\(PERSONAL/TEAM_LEVEL/TEAM_BONUS\\)\n\n### 2. Application Layer \\(应用层\\)\n- ContributionCalculationService: 算力计算核心服务\n - 个人算力: 认种金额 × 10\n - 团队等级奖励: 基于直推有效认种人数\n - 团队极差奖励: 多级分销算法\n- SnapshotService: 每日算力快照服务\n- CDC Event Handlers: 处理用户、认种、引荐关系同步事件\n\n### 3. Infrastructure Layer \\(基础设施层\\)\n- Prisma Repositories: \n - ContributionAccountRepository\n - ContributionRecordRepository\n - SyncedDataRepository \\(同步数据\\)\n - OutboxRepository \\(发件箱模式\\)\n - SystemAccountRepository\n - UnallocatedContributionRepository\n- Kafka CDC Consumer: 消费 Debezium CDC 事件\n- Redis: 缓存支持\n- UnitOfWork: 事务管理\n\n### 4. API Layer \\(接口层\\)\n- ContributionController: 算力查询接口\n- SnapshotController: 快照管理接口\n- HealthController: 健康检查\n\n## 数据模型 \\(Prisma Schema\\)\n- ContributionAccount: 算力账户\n- ContributionRecord: 算力记录 \\(支持过期\\)\n- DailyContributionSnapshot: 每日快照\n- SyncedUser/SyncedAdoption/SyncedReferral: CDC 同步数据\n- OutboxEvent: 发件箱事件\n- SystemContributionAccount: 系统账户\n- UnallocatedContribution: 未分配算力\n\n## TypeScript 类型修复\n- 修复所有 Repository 接口与实现的类型不匹配\n- 修复 ContributionAmount.multiply\\(\\) 返回值类型\n- 修复 isZero getter vs method 问题\n- 修复 bigint vs string 类型转换\n- 统一使用 items/total 返回格式\n- 修复 Prisma schema 字段名映射 \\(unallocType, contributionBalance 等\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(mining-ecosystem\\): 添加挖矿生态系统完整微服务与前端\n\n## 概述\n为榴莲生态2.0添加完整的挖矿系统,包含3个后端微服务、1个管理后台和1个用户端App。\n\n---\n\n## 后端微服务\n\n### 1. mining-service \\(挖矿服务\\) - Port 3021\n**核心功能:**\n- 积分股每日分配(基于算力快照)\n- 每分钟定时销毁(进入黑洞)\n- 价格计算:价格 = 积分股池 ÷ \\(100.02亿 - 黑洞 - 流通池\\)\n- 全局状态管理(黑洞量、流通池、价格)\n\n**关键文件:**\n- src/application/services/mining-distribution.service.ts - 挖矿分配核心逻辑\n- src/application/schedulers/mining.scheduler.ts - 定时任务调度\n- src/domain/services/mining-calculator.service.ts - 分配计算\n- src/infrastructure/persistence/repositories/black-hole.repository.ts - 黑洞管理\n\n### 2. trading-service \\(交易服务\\) - Port 3022\n**核心功能:**\n- 积分股买卖撮合\n- K线数据生成\n- 手续费处理(10%买入/卖出)\n- 流通池管理\n- 卖出倍数计算:倍数 = \\(100亿 - 销毁量\\) ÷ \\(200万 - 流通池量\\)\n\n**关键文件:**\n- src/domain/services/matching-engine.service.ts - 撮合引擎\n- src/application/services/order.service.ts - 订单处理\n- src/application/services/transfer.service.ts - 划转服务\n- src/domain/aggregates/order.aggregate.ts - 订单聚合根\n\n### 3. mining-admin-service \\(挖矿管理服务\\) - Port 3023\n**核心功能:**\n- 系统配置管理(分配参数、手续费率等)\n- 老用户数据初始化\n- 系统监控仪表盘\n- 审计日志\n\n**关键文件:**\n- src/application/services/config.service.ts - 配置管理\n- src/application/services/initialization.service.ts - 数据初始化\n- src/application/services/dashboard.service.ts - 仪表盘数据\n\n---\n\n## 前端应用\n\n### 1. mining-admin-web \\(管理后台\\) - Next.js 14\n**技术栈:**\n- Next.js 14 + React 18\n- TailwindCSS + Radix UI\n- React Query + Zustand\n- ECharts 图表\n\n**功能模块:**\n- 登录认证\n- 仪表盘(实时数据、价格走势)\n- 用户查询(算力详情、挖矿记录、交易订单)\n- 系统配置管理\n- 数据初始化任务\n- 审计日志查看\n\n### 2. mining-app \\(用户端App\\) - Flutter 3.x\n**技术栈:**\n- Flutter 3.x + Dart\n- Riverpod 状态管理\n- GoRouter 路由\n- Clean Architecture \\(3层\\)\n\n**功能模块:**\n- 首页资产总览\n- 实时收益显示(每秒更新)\n- 贡献值展示(个人/团队)\n- 积分股买卖交易\n- K线图与价格显示\n- 个人中心\n\n---\n\n## 架构文档\n- docs/mining-ecosystem-architecture.md - 系统架构总览\n - 服务职责与端口分配\n - 数据流向图\n - Kafka Topics 定义\n - 跨服务关联(account_sequence)\n - 配置参数说明\n - 开发顺序建议\n\n---\n\n## .gitignore 更新\n- 添加 Flutter/Dart 构建文件忽略\n- 添加 iOS/Android 构建产物忽略\n- 添加 Next.js 构建目录忽略\n- 添加 TypeScript 缓存文件忽略\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(mining\\): 添加 2.0 挖矿系统独立部署管理脚本\n\n添加 deploy-mining.sh 脚本用于管理 2.0 挖矿生态系统,\n该系统与 1.0 完全隔离,可随时重置而不影响 1.0。\n\n## 功能\n\n### 服务管理\n- up/down/restart - 启动/停止/重启 2.0 服务\n- status - 查看服务状态\n- logs [service] - 查看日志\n- build - 构建服务\n\n### 数据库管理\n- db-create - 创建 2.0 数据库\n- db-migrate - 运行 Prisma 迁移\n- db-reset - 删除并重建数据库(危险操作)\n- db-status - 查看数据库状态\n\n### CDC 同步管理\n- sync-reset - 重置 CDC 消费者偏移量到开始位置\n- sync-status - 查看 CDC 消费者组状态\n\n### 完整重置\n- full-reset - 完整系统重置\n 1. 停止所有 2.0 服务\n 2. 删除所有 2.0 数据库\n 3. 重建数据库\n 4. 运行迁移\n 5. 重置 CDC 偏移量\n 6. 重启服务(从 1.0 重新同步)\n\n### 健康监控\n- health - 检查所有组件健康状态\n- stats - 显示系统统计信息\n\n## 2.0 服务\n- contribution-service \\(3020\\)\n- mining-service \\(3021\\)\n- trading-service \\(3022\\)\n- mining-admin-service \\(3023\\)\n\n## 2.0 数据库\n- rwa_contribution\n- rwa_mining\n- rwa_trading\n- rwa_mining_admin\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(npx prisma format:*)",
|
||||
"Bash(while read svc)",
|
||||
"Bash(do echo \"=== $svc ===\")",
|
||||
"Bash(for svc in admin-service auth-service authorization-service backup-service blockchain-service contribution-service identity-service leaderboard-service mining-admin-service mining-service mpc-service planting-service presence-service referral-service reporting-service reward-service trading-service wallet-service)",
|
||||
"Bash(ssh ceshi@14.215.128.96 \"curl -s -o /dev/null -w ''%{http_code}'' https://madmin.szaiai.com/ --connect-timeout 10\")",
|
||||
"Bash(curl -s -o /dev/null -w '%{http_code}' https://madmin.szaiai.com/ --connect-timeout 15)",
|
||||
"Bash(ssh ceshi@14.215.128.96 \"docker network ls | grep rwa\")",
|
||||
"Bash(ssh ceshi@14.215.128.96 \"cd /home/ceshi/rwadurian/backend/services && git pull && ./deploy-mining.sh rebuild mining-admin-service --no-cache\")",
|
||||
"Bash(dir /s /b \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\\\\backend\\\\services\\\\mining-admin-service\\\\src\\\\*.ts\")",
|
||||
"Bash(DATABASE_URL=\"postgresql://user:pass@localhost:5432/db\" npx prisma migrate:*)",
|
||||
"Bash(ssh ceshi@103.39.231.231 \"ls -la /etc/nginx/sites-enabled/ && cat /etc/nginx/sites-available/rwaapi.szaiai.com 2>/dev/null | head -100\")",
|
||||
"Bash(ssh ceshi@14.215.128.96 \"cd /home/ceshi/rwadurian/frontend/mining-admin-web && git pull && cat .env.production\")",
|
||||
"Bash(ssh ceshi@14.215.128.96 \"cd /home/ceshi/rwadurian/frontend/mining-admin-web && docker compose down && docker compose build --no-cache && docker compose up -d\")",
|
||||
"Bash(ssh ceshi@14.215.128.96 \"docker ps | grep -E ''mining-admin|rwa-mining''\")",
|
||||
"Bash(ssh ceshi@103.39.231.231 \"cd /home/ceshi/rwadurian && git pull && grep -A10 ''mining-admin-service'' backend/api-gateway/kong.yml | head -15\")",
|
||||
"Bash(ssh ceshi@103.39.231.231 \"cd /home/ceshi/rwadurian/backend/api-gateway && docker compose exec kong kong reload 2>/dev/null || docker exec kong kong reload\")",
|
||||
"Bash(ssh ceshi@103.39.231.231 \"curl -s http://192.168.1.111:3023/health 2>/dev/null || echo ''Service not reachable''\")",
|
||||
"Bash(ssh ceshi@103.39.231.231 \"curl -s http://192.168.1.111:3023/auth/login -X POST -H ''Content-Type: application/json'' -d ''{\"\"username\"\":\"\"admin\"\",\"\"password\"\":\"\"test\"\"}''\")",
|
||||
"Bash(ssh ceshi@103.39.231.231 \"cd /home/ceshi/rwadurian && git pull && docker exec kong kong reload\")",
|
||||
"Bash(ssh ceshi@103.39.231.231 \"docker ps | grep -i kong\")",
|
||||
"Bash(ssh ceshi@103.39.231.231 \"curl -s http://localhost:8000/api/v2/mining-admin/auth/login -X POST -H ''Content-Type: application/json'' -d ''{\"\"username\"\":\"\"admin\"\",\"\"password\"\":\"\"admin123\"\"}''\")",
|
||||
"Bash(ssh ceshi@103.39.231.231 \"curl -s http://localhost:8000/api/v2/mining-admin/auth/profile -H ''Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI3ODRlNTA0MS1hYTM2LTQ0ZTctYTM1NS0yY2I2ZjYwYmY1YmIiLCJ1c2VybmFtZSI6ImFkbWluIiwicm9sZSI6IlNVUEVSX0FETUlOIiwiaWF0IjoxNzY4MTIyMjc3LCJleHAiOjE3NjgyMDg2Nzd9.XL0i0_tQlybkT9ktLIP90WQZDujPbbARL20h6fLmeRE''\")",
|
||||
"Bash(user \")",
|
||||
"mcp__UIPro__getCodeFromUIProPlugin",
|
||||
"Bash(flutter create:*)",
|
||||
"Bash(DATABASE_URL=\"postgresql://postgres:postgres@localhost:5432/rwa_auth?schema=public\" npx prisma migrate dev:*)",
|
||||
"Bash(curl -s http://103.118.40.14:8001/routes/contribution-v2-api)",
|
||||
"Bash(curl -s http://103.118.40.14:8001/services/contribution-service-v2)",
|
||||
"Bash(ssh ceshi@103.39.231.231 \"cd /data/rwadurian/backend/api-gateway && git pull origin main && docker-compose restart kong\")",
|
||||
"Bash(ssh ceshi@103.39.231.231 \"ls -la /data/ 2>/dev/null || ls -la / | grep -E ''data|home|opt''\")",
|
||||
"Bash(TOKEN=\"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJEMjUxMjI3MDAwMjIiLCJwaG9uZSI6IjE4OTI2NzYyNzIxIiwic291cmNlIjoiVjEiLCJpYXQiOjE3NjgxODM5NTIsImV4cCI6MTc2ODc4ODc1Mn0.Uq6TCFWHO64fD_MUP2IoBJzaXo99HDcp0H5s5A14EXQ\")",
|
||||
"Bash(ssh ceshi@103.39.231.231 \"ssh ceshi@192.168.1.111 ''cd /home/durian/rwadurian && git pull && cd backend/services && ./deploy.sh rebuild auth-service''\")",
|
||||
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(mining-admin-web\\): 复用admin-web用户管理功能\n\n- 更新用户列表:添加头像、个人/团队认种、推荐人、状态徽章\n- 更新用户详情:添加头像、KYC状态、认种统计卡片\n- 新增引荐关系Tab:展示引荐人链和直推下级树\n- 新增认种信息Tab:认种汇总和认种分类账明细\n- 新增钱包信息Tab:钱包汇总和钱包分类账明细\n- 更新类型定义和API hooks\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(ssh ceshi@14.215.128.96 \"cd /home/ceshi/rwadurian/frontend/mining-admin-web && git pull && ls -la deploy.sh\")",
|
||||
"Bash(git -C \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\" diff)",
|
||||
"Bash(git -C \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\" push origin main)",
|
||||
"Bash(git -C \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\" add frontend/mining-admin-web/next.config.js)",
|
||||
"Bash(git -C \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\" commit -m \"$\\(cat <<''EOF''\nfix\\(mining-admin-web\\): 修复 API rewrite 路径为 v2\n\n将 next.config.js 中的 API rewrite 从 /api/v1 改为 /api/v2,\n与 mining-admin-service 的实际 API 前缀保持一致。\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(git -C \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\" log --oneline -3)",
|
||||
"Bash(git -C \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\" add backend/services/deploy-mining.sh)",
|
||||
"Bash(git -C \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\" commit -m \"$\\(cat <<''EOF''\nfeat\\(deploy\\): 添加 mining-wallet-service 到 deploy-mining.sh\n\n将 mining-wallet-service 加入 2.0 系统管理脚本:\n\n- 添加到 MINING_SERVICES 数组\n- 添加别名 wallet -> mining-wallet-service\n- 添加数据库 rwa_mining_wallet\n- 添加 SERVICE_DB 映射\n- 添加端口 3025\n- 更新帮助文档\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(git -C \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\" commit -m \"$\\(cat <<''EOF''\nrefactor\\(deploy\\): 移除 mining-admin-web 从 deploy-mining.sh\n\nmining-admin-web 是前端项目,不应该在后端服务部署脚本中管理。\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(git -C \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\" add backend/services/contribution-service/Dockerfile backend/services/mining-admin-service/Dockerfile)",
|
||||
"Bash(git -C \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\" commit -m \"$\\(cat <<''EOF''\nfix\\(docker\\): 修复 contribution-service 和 mining-admin-service Dockerfile healthcheck 路径\n\n将 healthcheck 路径从 /api/v1/health 改为 /api/v2/health,\n与 main.ts 中的 API 前缀保持一致。\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(git -C \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\" log --oneline -5)",
|
||||
"Bash(ssh -o ProxyCommand=\"ssh -W %h:%p ceshi@103.39.231.231\" ceshi@192.168.1.111 \"cd /home/ceshi/rwadurian/backend/services && git pull origin main\")",
|
||||
"Bash(ssh -o ProxyCommand=\"ssh -W %h:%p ceshi@103.39.231.231\" ceshi@192.168.1.111 \"cd /home/ceshi/rwadurian/backend/services/auth-service && npm run build 2>&1 | tail -20\")",
|
||||
"Bash(ssh -o ProxyCommand=\"ssh -W %h:%p ceshi@103.39.231.231\" ceshi@192.168.1.111 \"cd /home/ceshi/rwadurian/backend/services && ./deploy-mining.sh rebuild auth-service 2>&1 | tail -50\")",
|
||||
"Bash(ssh -o ProxyCommand=\"ssh -W %h:%p ceshi@103.39.231.231\" ceshi@192.168.1.111 \"cd /home/ceshi/rwadurian/backend/services && ./deploy-mining.sh rebuild contribution-service 2>&1 | tail -50\")",
|
||||
"Bash(ssh -o ProxyCommand=\"ssh -W %h:%p ceshi@103.39.231.231\" ceshi@192.168.1.111 \"cd /home/ceshi/rwadurian/backend/services && ./deploy-mining.sh rebuild mining-admin-service 2>&1 | tail -50\")",
|
||||
"Bash(ssh -o ProxyCommand=\"ssh -W %h:%p ceshi@103.39.231.231\" ceshi@192.168.1.111 \"cd /home/ceshi/rwadurian/backend/services && ./deploy-mining.sh status\")",
|
||||
"Bash(git commit -m \"$\\(cat <<''EOF''\nfix\\(auth\\): 修复 LegacyUserCdcConsumer 的 OutboxService 依赖注入\n\n- 在 ApplicationModule 中导出 OutboxService\n- 在 InfrastructureModule 中使用 forwardRef 导入 ApplicationModule\n- 解决循环依赖问题\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(git commit -m \"$\\(cat <<''EOF''\nfix\\(deploy\\): 修正 Debezium Connect 默认端口为 8084\n\ndocker-compose 中 Debezium Connect 映射到 8084 端口\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(git commit -m \"$\\(cat <<''EOF''\nfix\\(debezium\\): 修复 outbox connector 配置中的数据库凭证\n\n使用实际的用户名和密码替代环境变量占位符,\n因为 envsubst 不支持带默认值的变量语法\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(ssh -o ProxyCommand=\"ssh -W %h:%p ceshi@103.39.231.231\" ceshi@192.168.1.111 \"docker exec rwa-postgres psql -U rwa_user -d rwa_mining_admin -c \"\"\nSELECT ''synced_users'' as table_name, COUNT\\(*\\) as count FROM synced_users\nUNION ALL SELECT ''synced_contribution_accounts'', COUNT\\(*\\) FROM synced_contribution_accounts\nUNION ALL SELECT ''synced_mining_accounts'', COUNT\\(*\\) FROM synced_mining_accounts\nUNION ALL SELECT ''synced_trading_accounts'', COUNT\\(*\\) FROM synced_trading_accounts\nUNION ALL SELECT ''synced_mining_configs'', COUNT\\(*\\) FROM synced_mining_configs\nUNION ALL SELECT ''synced_circulation_pools'', COUNT\\(*\\) FROM synced_circulation_pools\nUNION ALL SELECT ''synced_system_contributions'', COUNT\\(*\\) FROM synced_system_contributions\nUNION ALL SELECT ''synced_daily_mining_stats'', COUNT\\(*\\) FROM synced_daily_mining_stats\nUNION ALL SELECT ''synced_day_klines'', COUNT\\(*\\) FROM synced_day_klines;\n\"\"\")",
|
||||
"Bash(ssh -o ProxyCommand=\"ssh -W %h:%p ceshi@103.39.231.231\" ceshi@192.168.1.111 \"docker exec rwa-postgres psql -U rwa_user -d rwa_mining_admin -t -c \"\"\nSELECT ''synced_users'' as tbl, COUNT\\(*\\) FROM synced_users\nUNION ALL SELECT ''synced_contribution_accounts'', COUNT\\(*\\) FROM synced_contribution_accounts\nUNION ALL SELECT ''synced_mining_accounts'', COUNT\\(*\\) FROM synced_mining_accounts\nUNION ALL SELECT ''synced_trading_accounts'', COUNT\\(*\\) FROM synced_trading_accounts;\n\"\"\")",
|
||||
"Bash(ssh -o ProxyCommand=\"ssh -W %h:%p ceshi@103.39.231.231\" ceshi@192.168.1.111 \"docker exec rwa-postgres psql -U rwa_user -d postgres -c \"\"SELECT count\\(*\\) FROM pg_stat_activity;\"\"\")",
|
||||
"Bash(ssh -o ProxyCommand=\"ssh -W %h:%p ceshi@103.39.231.231\" ceshi@192.168.1.111 \"docker restart rwa-postgres && sleep 10 && docker exec rwa-postgres psql -U rwa_user -d rwa_mining_admin -t -c \"\"\nSELECT ''synced_users'' as tbl, COUNT\\(*\\) FROM synced_users\nUNION ALL SELECT ''synced_contribution_accounts'', COUNT\\(*\\) FROM synced_contribution_accounts\nUNION ALL SELECT ''synced_mining_accounts'', COUNT\\(*\\) FROM synced_mining_accounts\nUNION ALL SELECT ''synced_trading_accounts'', COUNT\\(*\\) FROM synced_trading_accounts;\n\"\"\")",
|
||||
"Bash(ssh -o ProxyCommand=\"ssh -W %h:%p ceshi@103.39.231.231\" ceshi@192.168.1.111 \"docker exec rwa-postgres psql -U rwa_user -d postgres -c \"\"SELECT datname, count\\(*\\) FROM pg_stat_activity GROUP BY datname ORDER BY count DESC;\"\"\")",
|
||||
"Bash(ssh -o ProxyCommand=\"ssh -W %h:%p ceshi@103.39.231.231\" ceshi@192.168.1.111 \"docker exec rwa-postgres psql -U rwa_user -d postgres -c \"\"SHOW max_connections;\"\" && docker exec rwa-postgres psql -U rwa_user -d postgres -c \"\"SELECT count\\(*\\) as current_connections FROM pg_stat_activity;\"\"\")",
|
||||
"Bash(git commit -m \"$\\(cat <<''EOF''\nfix\\(postgres\\): 增加数据库最大连接数到 300\n\n- max_connections: 100 -> 300\n- max_replication_slots: 10 -> 20 \n- max_wal_senders: 10 -> 20\n\n支持更多服务和 Debezium connectors 同时连接\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(ssh -o ProxyCommand=\"ssh -W %h:%p ceshi@103.39.231.231\" ceshi@192.168.1.111 \"docker exec rwa-postgres psql -U rwa_user -d rwa_mining_admin -c ''SELECT * FROM synced_users LIMIT 2;''\")",
|
||||
"Bash(ssh -o ProxyCommand=\"ssh -W %h:%p ceshi@103.39.231.231\" ceshi@192.168.1.111 \"docker exec rwa-postgres psql -U rwa_user -d rwa_mining_admin -c ''SELECT * FROM synced_contribution_accounts LIMIT 2;''\")",
|
||||
"Bash(ssh -o ProxyCommand=\"ssh -W %h:%p ceshi@103.39.231.231\" ceshi@192.168.1.111 \"docker exec rwa-postgres psql -U rwa_user -d rwa_contribution -c ''SELECT account_sequence, has_adopted, direct_referral_adopted_count, unlocked_level_depth FROM contribution_accounts LIMIT 5;''\")",
|
||||
"Bash(ssh -o ProxyCommand=\"ssh -W %h:%p ceshi@103.39.231.231\" ceshi@192.168.1.111 \"docker exec rwa-postgres psql -U rwa_user -d rwa_contribution -c ''SELECT account_sequence, adopter_count FROM synced_users LIMIT 5;''\")",
|
||||
"Bash(ssh -o ProxyCommand=\"ssh -W %h:%p ceshi@103.39.231.231\" ceshi@192.168.1.111 \"docker exec rwa-postgres psql -U rwa_user -d rwa_contribution -c ''\\\\d synced_users''\")",
|
||||
"Bash(ssh -o ProxyCommand=\"ssh -W %h:%p ceshi@103.39.231.231\" ceshi@192.168.1.111 \"docker exec rwa-postgres psql -U rwa_user -d rwa_contribution -c ''SELECT * FROM synced_adoptions LIMIT 3;''\")",
|
||||
"Bash(ssh -o ProxyCommand=\"ssh -W %h:%p ceshi@103.39.231.231\" ceshi@192.168.1.111 \"docker exec rwa-postgres psql -U rwa_user -d rwa_contribution -c ''SELECT * FROM synced_referrals LIMIT 3;''\")",
|
||||
"Bash(ssh -o ProxyCommand=\"ssh -W %h:%p ceshi@103.39.231.231\" ceshi@192.168.1.111 \"docker exec rwa-postgres psql -U rwa_user -d rwa_mining_admin -c ''\\\\d synced_users''\")",
|
||||
"Bash(ssh -o ProxyCommand=\"ssh -W %h:%p ceshi@103.39.231.231\" ceshi@192.168.1.111 \"docker exec rwa-postgres psql -U rwa_user -d rwa_mining_admin -c \"\"SELECT table_name FROM information_schema.tables WHERE table_schema=''public'' ORDER BY table_name;\"\"\")",
|
||||
"Bash(dir /b \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\\\\backend\\\\services\\\\contribution-service\\\\src\\\\domain\\\\events\")",
|
||||
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(sync\\): 完善 CDC 数据同步 - 添加推荐关系、认种记录和昵称字段\n\n- auth-service:\n - SyncedLegacyUser 表添加 nickname 字段\n - LegacyUserMigratedEvent 添加 nickname 参数\n - CDC consumer 同步 nickname 字段\n - SyncedLegacyUserData 接口添加 nickname\n\n- contribution-service:\n - 新增 ReferralSyncedEvent 事件类\n - 新增 AdoptionSyncedEvent 事件类\n - admin.controller 添加 publish-all APIs:\n - POST /admin/referrals/publish-all\n - POST /admin/adoptions/publish-all\n\n- mining-admin-service:\n - SyncedUser 表添加 nickname 字段\n - 新增 SyncedReferral 表 \\(推荐关系\\)\n - 新增 SyncedAdoption 表 \\(认种记录\\)\n - handleReferralSynced 处理器\n - handleAdoptionSynced 处理器\n - handleLegacyUserMigrated 处理 nickname\n\n- deploy-mining.sh:\n - full_reset 更新为 14 步\n - Step 13: 发布推荐关系\n - Step 14: 发布认种记录\n\n解决 mining-admin-web 缺少昵称、推荐人、认种数据的问题\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(ssh -o StrictHostKeyChecking=no ceshi@103.39.231.231 \"ssh -o StrictHostKeyChecking=no ceshi@192.168.1.111 ''cd /home/ceshi/rwadurian/backend/services && git pull''\")",
|
||||
"Bash(ssh -o StrictHostKeyChecking=no ceshi@103.39.231.231 \"ssh -o StrictHostKeyChecking=no -i ~/.ssh/id_rsa ceshi@192.168.1.111 ''cd /home/ceshi/rwadurian/backend/services && git pull''\")",
|
||||
"Bash(set DATABASE_URL=postgresql://user:pass@localhost:5432/db)",
|
||||
"Bash(cmd /c \"set DATABASE_URL=postgresql://user:pass@localhost:5432/db && npx prisma migrate dev --name add_nickname_to_synced_legacy_users --create-only\")",
|
||||
"Bash(dir \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\\\\frontend\")",
|
||||
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(mining-app\\): fix login bugs and connect contribution page to real API\n\nLogin fixes:\n- Add AuthEventBus for global 401 error handling with auto-logout\n- Add route guards with GoRouter redirect to protect authenticated routes\n- Remove setMockUser\\(\\) security vulnerability and legacy login\\(\\) dead code\n- Remove unused AuthInterceptor class\n\nContribution page:\n- Add ContributionRecord entity and model for records API\n- Connect contribution details card to GET /accounts/{id}/records endpoint\n- Display real team stats \\(direct referrals, unlocked levels/tiers\\)\n- Calculate expiration countdown from actual record data\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(dependency of a provider changed\" error when 401 responses triggered\nlogout during provider rebuilds.\n\nNow 401 handling is done through normal exception flow in splash page\nand route guards respond to isLoggedInProvider state changes.\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(ssh ceshi@rwa-colocation-1-lan:*)",
|
||||
"Bash(git -C \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\" diff frontend/mining-app/lib/presentation/pages/)",
|
||||
"Bash(git -C \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\" add frontend/mining-app/lib/presentation/pages/asset/asset_page.dart frontend/mining-app/lib/presentation/pages/auth/login_page.dart frontend/mining-app/lib/presentation/pages/auth/register_page.dart frontend/mining-app/lib/presentation/pages/contribution/contribution_page.dart frontend/mining-app/lib/presentation/pages/profile/profile_page.dart frontend/mining-app/lib/presentation/pages/trading/trading_page.dart)",
|
||||
"Bash(git -C \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\" commit -m \"$\\(cat <<''EOF''\nfix\\(mining-app\\): unify color scheme and fix scroll issues\n\n- Update login/register pages to use orange color scheme \\(#FF6B00\\)\n matching the navigation pages design\n- Fix SafeArea bottom: false on all navigation pages since MainShell\n handles bottom safe area via bottomNavigationBar\n- Add AlwaysScrollableScrollPhysics to asset page for consistent scroll\n- Increase bottom padding to 100px on all navigation pages to clear\n the navigation bar\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(git -C \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\" push)",
|
||||
"Bash(git -C \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\" add frontend/mining-app/lib/presentation/pages/splash/splash_page.dart frontend/mining-app/lib/presentation/providers/user_providers.dart)",
|
||||
"Bash(git -C \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\" commit -m \"$\\(cat <<''EOF''\nfix\\(mining-app\\): update splash page theme and fix token refresh\n\n- Update splash_page.dart to orange theme \\(#FF6B00\\) matching other pages\n- Change app name from \"榴莲挖矿\" to \"榴莲生态\"\n- Fix refreshTokenIfNeeded to properly throw on failure instead of\n silently calling logout \\(which caused Riverpod ref errors\\)\n- Clear local storage directly on refresh failure without remote API call\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(python3 -c \" import sys content = sys.stdin.read\\(\\) old = '''''' done # 清空 processed_cdc_events 表(因为 migration 时可能已经消费了一些消息) # 这是事务性幂等消费的关键:重置 Kafka offset 后必须同时清空幂等记录 log_info \"\"Truncating processed_cdc_events tables to allow re-consumption...\"\" for db in \"\"rwa_contribution\"\" \"\"rwa_auth\"\"; do if run_psql \"\"$db\"\" \"\"TRUNCATE TABLE processed_cdc_events;\"\" 2>/dev/null; then log_success \"\"Truncated processed_cdc_events in $db\"\" else log_warn \"\"Could not truncate processed_cdc_events in $db \\(table may not exist yet\\)\"\" fi done log_step \"\"Step 9/18: Starting 2.0 services...\"\"'''''' new = '''''' done # 清空 processed_cdc_events 表(因为 migration 时可能已经消费了一些消息) # 这是事务性幂等消费的关键:重置 Kafka offset 后必须同时清空幂等记录 log_info \"\"Truncating processed_cdc_events tables to allow re-consumption...\"\" for db in \"\"rwa_contribution\"\" \"\"rwa_auth\"\"; do if run_psql \"\"$db\"\" \"\"TRUNCATE TABLE processed_cdc_events;\"\" 2>/dev/null; then log_success \"\"Truncated processed_cdc_events in $db\"\" else log_warn \"\"Could not truncate processed_cdc_events in $db \\(table may not exist yet\\)\"\" fi done log_step \"\"Step 9/18: Starting 2.0 services...\"\"'''''' print\\(content.replace\\(old, new\\)\\) \")",
|
||||
"Bash(git rm:*)",
|
||||
"Bash(echo \"请在服务器运行以下命令检查 outbox 事件:\n\ndocker exec -it rwa-postgres psql -U rwa_user -d rwa_contribution -c \"\"\nSELECT id, event_type, aggregate_id, \n payload->>''sourceType'' as source_type,\n payload->>''accountSequence'' as account_seq,\n payload->>''sourceAccountSequence'' as source_account_seq,\n payload->>''bonusTier'' as bonus_tier\nFROM outbox_events \nWHERE payload->>''accountSequence'' = ''D25122900007''\nORDER BY id;\n\"\"\")",
|
||||
"Bash(ssh -o ConnectTimeout=10 ceshi@14.215.128.96 'find /home/ceshi/rwadurian/frontend/mining-admin-web -name \"\"*.tsx\"\" -o -name \"\"*.ts\"\" | xargs grep -l \"\"用户管理\\\\|users\"\" 2>/dev/null | head -10')",
|
||||
"Bash(dir /s /b \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\")",
|
||||
"Bash(dir /b \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\\\\backend\\\\services\")",
|
||||
"Bash(ssh -J ceshi@103.39.231.231 ceshi@192.168.1.111 \"curl -s http://localhost:3021/api/v2/admin/status\")",
|
||||
"Bash(del \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\\\\frontend\\\\mining-app\\\\lib\\\\domain\\\\usecases\\\\trading\\\\buy_shares.dart\")",
|
||||
"Bash(del \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\\\\frontend\\\\mining-app\\\\lib\\\\domain\\\\usecases\\\\trading\\\\sell_shares.dart\")",
|
||||
"Bash(ls -la \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\\\\frontend\\\\mining-app\\\\lib\\\\presentation\\\\pages\"\" 2>/dev/null || dir /b \"c:UsersdongDesktoprwadurianfrontendmining-applibpresentationpages \")",
|
||||
"Bash(cd:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
|
|
|||
|
|
@ -2,3 +2,130 @@ nul
|
|||
|
||||
# Claude Code settings
|
||||
.claude/
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
.pnp/
|
||||
.pnp.js
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
build/
|
||||
out/
|
||||
.next/
|
||||
.nuxt/
|
||||
.output/
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
*.env
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*.sublime-*
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
Desktop.ini
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Test coverage
|
||||
coverage/
|
||||
.nyc_output/
|
||||
|
||||
# Cache
|
||||
.cache/
|
||||
*.cache
|
||||
.eslintcache
|
||||
.stylelintcache
|
||||
.turbo/
|
||||
|
||||
# Prisma
|
||||
prisma/migrations/**/migration_lock.toml
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
|
||||
# Flutter/Dart
|
||||
.dart_tool/
|
||||
.flutter-plugins
|
||||
.flutter-plugins-dependencies
|
||||
.packages
|
||||
.pub-cache/
|
||||
.pub/
|
||||
build/
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
.idea/
|
||||
*.lock
|
||||
pubspec.lock
|
||||
|
||||
# iOS
|
||||
ios/Pods/
|
||||
ios/.symlinks/
|
||||
ios/Flutter/Flutter.framework
|
||||
ios/Flutter/Flutter.podspec
|
||||
ios/Flutter/App.framework
|
||||
ios/Flutter/engine/
|
||||
ios/Flutter/Generated.xcconfig
|
||||
**/ios/Flutter/.last_build_id
|
||||
**/ios/Podfile.lock
|
||||
|
||||
# Android
|
||||
android/.gradle/
|
||||
android/captures/
|
||||
android/gradlew
|
||||
android/gradlew.bat
|
||||
android/local.properties
|
||||
**/android/app/debug
|
||||
**/android/app/profile
|
||||
**/android/app/release
|
||||
*.apk
|
||||
*.aab
|
||||
*.dex
|
||||
*.class
|
||||
*.jks
|
||||
*.keystore
|
||||
|
||||
# macOS
|
||||
macos/Flutter/GeneratedPluginRegistrant.swift
|
||||
macos/Flutter/ephemeral/
|
||||
|
||||
# Windows
|
||||
windows/flutter/generated_plugin_registrant.cc
|
||||
windows/flutter/generated_plugin_registrant.h
|
||||
windows/flutter/generated_plugins.cmake
|
||||
|
||||
# Linux
|
||||
linux/flutter/generated_plugin_registrant.cc
|
||||
linux/flutter/generated_plugin_registrant.h
|
||||
linux/flutter/generated_plugins.cmake
|
||||
|
||||
# Web
|
||||
web/favicon.png
|
||||
web/icons/
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
*.swp
|
||||
*~
|
||||
|
||||
# Package lock files (keep for reproducible builds)
|
||||
# package-lock.json
|
||||
# yarn.lock
|
||||
# pnpm-lock.yaml
|
||||
|
|
|
|||
|
|
@ -48,6 +48,10 @@ services:
|
|||
paths:
|
||||
- /api/v1/identity/health
|
||||
strip_path: true
|
||||
- name: identity-admin-pending-actions
|
||||
paths:
|
||||
- /api/v1/admin/pending-actions
|
||||
strip_path: false
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Wallet Service - 钱包服务
|
||||
|
|
@ -173,6 +177,11 @@ services:
|
|||
paths:
|
||||
- /api/v1/export
|
||||
strip_path: false
|
||||
# [2026-01-04] 新增:系统账户报表路由
|
||||
- name: reporting-system-accounts
|
||||
paths:
|
||||
- /api/v1/system-account-reports
|
||||
strip_path: false
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Authorization Service - 授权服务
|
||||
|
|
@ -261,6 +270,105 @@ services:
|
|||
- /api/v1/co-managed
|
||||
strip_path: false
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# RWA 2.0 Services - 新架构微服务
|
||||
# ===========================================================================
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Contribution Service 2.0 - 算力服务
|
||||
# 前端路径: /api/v2/contribution/...
|
||||
# 后端路径: /api/v2/contribution/... (strip_path: false, 直接透传)
|
||||
# ---------------------------------------------------------------------------
|
||||
- name: contribution-service-v2
|
||||
url: http://192.168.1.111:3020
|
||||
routes:
|
||||
- name: contribution-v2-api
|
||||
paths:
|
||||
- /api/v2/contribution
|
||||
strip_path: false
|
||||
- name: contribution-v2-health
|
||||
paths:
|
||||
- /api/v2/contribution/health
|
||||
strip_path: false
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mining Service 2.0 - 挖矿服务
|
||||
# ---------------------------------------------------------------------------
|
||||
- name: mining-service-v2
|
||||
url: http://192.168.1.111:3021
|
||||
routes:
|
||||
- name: mining-v2-api
|
||||
paths:
|
||||
- /api/v2/mining
|
||||
strip_path: false
|
||||
- name: mining-v2-health
|
||||
paths:
|
||||
- /api/v2/mining/health
|
||||
strip_path: false
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Trading Service 2.0 - 交易服务
|
||||
# ---------------------------------------------------------------------------
|
||||
- name: trading-service-v2
|
||||
url: http://192.168.1.111:3022
|
||||
routes:
|
||||
- name: trading-v2-api
|
||||
paths:
|
||||
- /api/v2/trading
|
||||
strip_path: false
|
||||
- name: trading-v2-health
|
||||
paths:
|
||||
- /api/v2/trading/health
|
||||
strip_path: false
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mining Admin Service 2.0 - 挖矿管理后台服务
|
||||
# ---------------------------------------------------------------------------
|
||||
- name: mining-admin-service
|
||||
url: http://192.168.1.111:3023/api/v1
|
||||
routes:
|
||||
- name: mining-admin-api
|
||||
paths:
|
||||
- /api/v2/mining-admin
|
||||
strip_path: true
|
||||
- name: mining-admin-health
|
||||
paths:
|
||||
- /api/v2/mining-admin/health
|
||||
strip_path: true
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth Service 2.0 - 用户认证服务
|
||||
# 前端路径: /api/v2/auth/...
|
||||
# 后端路径: /api/v2/auth/... (strip_path: false, 直接透传)
|
||||
# ---------------------------------------------------------------------------
|
||||
- name: auth-service-v2
|
||||
url: http://192.168.1.111:3024
|
||||
routes:
|
||||
- name: auth-v2-api
|
||||
paths:
|
||||
- /api/v2/auth
|
||||
strip_path: false
|
||||
- name: auth-v2-health
|
||||
paths:
|
||||
- /api/v2/auth/health
|
||||
strip_path: false
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mining Wallet Service 2.0 - 挖矿钱包服务
|
||||
# ---------------------------------------------------------------------------
|
||||
- name: mining-wallet-service
|
||||
url: http://192.168.1.111:3025
|
||||
routes:
|
||||
- name: mining-wallet-api
|
||||
paths:
|
||||
- /api/v2/mining-wallet
|
||||
strip_path: false
|
||||
- name: mining-wallet-health
|
||||
paths:
|
||||
- /api/v2/mining-wallet/health
|
||||
strip_path: false
|
||||
|
||||
# =============================================================================
|
||||
# Plugins - 全局插件配置
|
||||
# =============================================================================
|
||||
|
|
@ -270,10 +378,12 @@ plugins:
|
|||
config:
|
||||
origins:
|
||||
- "https://rwaadmin.szaiai.com"
|
||||
- "https://madmin.szaiai.com"
|
||||
- "https://update.szaiai.com"
|
||||
- "https://app.rwadurian.com"
|
||||
- "http://localhost:3000"
|
||||
- "http://localhost:3020"
|
||||
- "http://localhost:3100"
|
||||
methods:
|
||||
- GET
|
||||
- POST
|
||||
|
|
@ -298,8 +408,8 @@ plugins:
|
|||
# 请求限流
|
||||
- name: rate-limiting
|
||||
config:
|
||||
minute: 100
|
||||
hour: 5000
|
||||
minute: 10000
|
||||
hour: 500000
|
||||
policy: local
|
||||
|
||||
# 请求日志
|
||||
|
|
|
|||
|
|
@ -159,6 +159,7 @@ services:
|
|||
dockerfile: services/message-router/Dockerfile
|
||||
container_name: mpc-message-router
|
||||
ports:
|
||||
- "50051:50051" # gRPC for party connections
|
||||
- "8082:8080"
|
||||
environment:
|
||||
MPC_SERVER_GRPC_PORT: 50051
|
||||
|
|
|
|||
|
|
@ -117,8 +117,11 @@ func NewKeygenSession(
|
|||
sortedPartyIDs := tss.SortPartyIDs(tssPartyIDs)
|
||||
|
||||
// Create peer context and parameters
|
||||
// IMPORTANT: TSS-lib threshold convention: threshold=t means (t+1) signers required
|
||||
// User says "2-of-3" meaning 2 signers needed, so we pass (Threshold-1) to TSS-lib
|
||||
peerCtx := tss.NewPeerContext(sortedPartyIDs)
|
||||
params := tss.NewParameters(tss.S256(), peerCtx, selfTSSID, len(sortedPartyIDs), config.Threshold)
|
||||
tssThreshold := config.Threshold - 1
|
||||
params := tss.NewParameters(tss.S256(), peerCtx, selfTSSID, len(sortedPartyIDs), tssThreshold)
|
||||
|
||||
return &KeygenSession{
|
||||
config: config,
|
||||
|
|
|
|||
|
|
@ -132,10 +132,15 @@ func NewSigningSession(
|
|||
keygenIndexToSortedIndex, selfParty.PartyID)
|
||||
|
||||
// Create peer context and parameters
|
||||
// IMPORTANT: Use TotalParties from keygen, not len(sortedPartyIDs) which is current signers
|
||||
// For 2-of-3: threshold=2, TotalParties=3, but only 2 parties might participate in signing
|
||||
// IMPORTANT: TSS-lib threshold convention: threshold=t means (t+1) signers required
|
||||
// This MUST match keygen exactly! Both use (Threshold-1)
|
||||
// The BuildLocalSaveDataSubset call in Start() will filter the save data to match
|
||||
peerCtx := tss.NewPeerContext(sortedPartyIDs)
|
||||
params := tss.NewParameters(tss.S256(), peerCtx, selfTSSID, config.TotalParties, config.Threshold)
|
||||
tssThreshold := config.Threshold - 1
|
||||
params := tss.NewParameters(tss.S256(), peerCtx, selfTSSID, len(sortedPartyIDs), tssThreshold)
|
||||
|
||||
fmt.Printf("[TSS-SIGN] NewParameters: partyCount=%d, tssThreshold=%d (from config.Threshold=%d) party_id=%s\n",
|
||||
len(sortedPartyIDs), tssThreshold, config.Threshold, selfParty.PartyID)
|
||||
|
||||
// Convert message hash to big.Int
|
||||
msgHash := new(big.Int).SetBytes(messageHash)
|
||||
|
|
@ -167,8 +172,17 @@ func (s *SigningSession) Start(ctx context.Context) (*SigningResult, error) {
|
|||
s.started = true
|
||||
s.mu.Unlock()
|
||||
|
||||
// Create local party for signing
|
||||
s.localParty = signing.NewLocalParty(s.messageHash, s.params, *s.saveData, s.outCh, s.endCh)
|
||||
// CRITICAL: Build a subset of the save data for the current signing parties
|
||||
// When signing with fewer parties than keygen (e.g., 2-of-3 signing with only 2 parties),
|
||||
// we must filter the save data to only include the participating parties' data.
|
||||
// This ensures TSS-lib's internal indices match the actual signers.
|
||||
subsetSaveData := keygen.BuildLocalSaveDataSubset(*s.saveData, s.tssPartyIDs)
|
||||
|
||||
fmt.Printf("[TSS-SIGN] Built save data subset for %d signing parties (original keygen had %d parties) party_id=%s\n",
|
||||
len(s.tssPartyIDs), len(s.saveData.Ks), s.selfParty.PartyID)
|
||||
|
||||
// Create local party for signing with the SUBSET save data
|
||||
s.localParty = signing.NewLocalParty(s.messageHash, s.params, subsetSaveData, s.outCh, s.endCh)
|
||||
|
||||
// Start the local party
|
||||
go func() {
|
||||
|
|
|
|||
|
|
@ -833,9 +833,11 @@ func (h *AccountHTTPHandler) CreateSigningSession(c *gin.Context) {
|
|||
zap.String("keygen_session_id", accountOutput.Account.KeygenSessionID.String()))
|
||||
}
|
||||
|
||||
// CRITICAL: Pass keygenThresholdN (original n from keygen) for correct TSS math
|
||||
resp, err := h.sessionCoordinatorClient.CreateSigningSessionAuto(
|
||||
ctx,
|
||||
int32(accountOutput.Account.ThresholdT),
|
||||
int32(accountOutput.Account.ThresholdN),
|
||||
signingParties,
|
||||
messageHash,
|
||||
600, // 10 minutes expiry
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ func (h *CoManagedHTTPHandler) RegisterRoutes(router *gin.RouterGroup) {
|
|||
|
||||
// Sign session routes (new - does not affect existing functionality)
|
||||
coManaged.POST("/sign", h.CreateSignSession)
|
||||
coManaged.GET("/sign/:sessionId", h.GetSignSessionStatus)
|
||||
coManaged.GET("/sign/by-invite-code/:inviteCode", h.GetSignSessionByInviteCode)
|
||||
}
|
||||
}
|
||||
|
|
@ -498,14 +499,40 @@ func (h *CoManagedHTTPHandler) CreateSignSession(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
// Validate party count >= threshold + 1
|
||||
if len(req.Parties) < req.ThresholdT+1 {
|
||||
// Validate party count == threshold_t (for t-of-n signing, exactly t parties are needed)
|
||||
if len(req.Parties) != req.ThresholdT {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": fmt.Sprintf("need at least %d parties for threshold %d", req.ThresholdT+1, req.ThresholdT),
|
||||
"error": fmt.Sprintf("need exactly %d parties for threshold %d, got %d", req.ThresholdT, req.ThresholdT, len(req.Parties)),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Call session coordinator via gRPC
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// CRITICAL: Query keygen session to get the original threshold_n
|
||||
// This is required for TSS signing to work correctly - the n value must match keygen
|
||||
var keygenThresholdN, keygenThresholdT int
|
||||
if h.db != nil {
|
||||
err = h.db.QueryRowContext(ctx, `
|
||||
SELECT threshold_n, threshold_t
|
||||
FROM mpc_sessions
|
||||
WHERE id = $1
|
||||
`, req.KeygenSessionID).Scan(&keygenThresholdN, &keygenThresholdT)
|
||||
if err != nil {
|
||||
logger.Error("Failed to query keygen session for threshold values",
|
||||
zap.String("keygen_session_id", req.KeygenSessionID),
|
||||
zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to lookup keygen session"})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
logger.Error("Database connection not available for keygen session lookup")
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "service configuration error"})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate invite code for sign session
|
||||
inviteCode := generateInviteCode()
|
||||
|
||||
|
|
@ -518,22 +545,22 @@ func (h *CoManagedHTTPHandler) CreateSignSession(c *gin.Context) {
|
|||
}
|
||||
}
|
||||
|
||||
// Call session coordinator via gRPC
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
logger.Info("Creating co-managed sign session",
|
||||
zap.String("keygen_session_id", req.KeygenSessionID),
|
||||
zap.String("wallet_name", req.WalletName),
|
||||
zap.Int("threshold_t", req.ThresholdT),
|
||||
zap.Int("num_parties", len(req.Parties)),
|
||||
zap.Int("keygen_threshold_n", keygenThresholdN),
|
||||
zap.Int("keygen_threshold_t", keygenThresholdT),
|
||||
zap.Int("signing_threshold_t", req.ThresholdT),
|
||||
zap.Int("num_signing_parties", len(req.Parties)),
|
||||
zap.String("invite_code", inviteCode))
|
||||
|
||||
// Create signing session
|
||||
// Note: delegateUserShare is nil for co-managed wallets (no delegate party)
|
||||
// CRITICAL: Pass keygenThresholdN (original n from keygen) for correct TSS math
|
||||
resp, err := h.sessionCoordinatorClient.CreateSigningSessionAuto(
|
||||
ctx,
|
||||
int32(req.ThresholdT),
|
||||
int32(keygenThresholdN),
|
||||
parties,
|
||||
messageHash,
|
||||
86400, // 24 hour expiry
|
||||
|
|
@ -562,7 +589,7 @@ func (h *CoManagedHTTPHandler) CreateSignSession(c *gin.Context) {
|
|||
}
|
||||
}
|
||||
|
||||
// Get wildcard join token for participants
|
||||
// Get wildcard token for backward compatibility (join_token field)
|
||||
wildcardToken := ""
|
||||
if token, ok := resp.JoinTokens["*"]; ok {
|
||||
wildcardToken = token
|
||||
|
|
@ -571,23 +598,94 @@ func (h *CoManagedHTTPHandler) CreateSignSession(c *gin.Context) {
|
|||
logger.Info("Co-managed sign session created successfully",
|
||||
zap.String("session_id", resp.SessionID),
|
||||
zap.String("invite_code", inviteCode),
|
||||
zap.Int("num_parties", len(resp.SelectedParties)))
|
||||
zap.Int("num_parties", len(resp.SelectedParties)),
|
||||
zap.Int("num_join_tokens", len(resp.JoinTokens)))
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"session_id": resp.SessionID,
|
||||
"keygen_session_id": req.KeygenSessionID,
|
||||
"wallet_name": req.WalletName,
|
||||
"invite_code": inviteCode,
|
||||
"join_token": wildcardToken,
|
||||
"threshold_t": req.ThresholdT,
|
||||
"selected_parties": resp.SelectedParties,
|
||||
"status": "waiting_for_participants",
|
||||
"current_participants": 0,
|
||||
"session_id": resp.SessionID,
|
||||
"keygen_session_id": req.KeygenSessionID,
|
||||
"wallet_name": req.WalletName,
|
||||
"invite_code": inviteCode,
|
||||
"join_token": wildcardToken, // Backward compatible: wildcard token (may be empty)
|
||||
"join_tokens": resp.JoinTokens, // New: all join tokens (map[partyID]token)
|
||||
"threshold_n": keygenThresholdN, // Original N from keygen (required for TSS)
|
||||
"threshold_t": req.ThresholdT,
|
||||
"selected_parties": resp.SelectedParties,
|
||||
"status": "waiting_for_participants",
|
||||
"current_participants": 0,
|
||||
"required_participants": len(req.Parties),
|
||||
"expires_at": resp.ExpiresAt,
|
||||
"expires_at": resp.ExpiresAt,
|
||||
})
|
||||
}
|
||||
|
||||
// GetSignSessionStatus handles getting the status of a co-managed sign session
|
||||
func (h *CoManagedHTTPHandler) GetSignSessionStatus(c *gin.Context) {
|
||||
sessionID := c.Param("sessionId")
|
||||
if sessionID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "session_id is required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate session ID format
|
||||
if _, err := uuid.Parse(sessionID); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session_id format"})
|
||||
return
|
||||
}
|
||||
|
||||
// Call session coordinator via gRPC
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
resp, err := h.sessionCoordinatorClient.GetSessionStatus(ctx, sessionID)
|
||||
if err != nil {
|
||||
logger.Error("Failed to get sign session status", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Get invite_code from database
|
||||
var inviteCode string
|
||||
if h.db != nil {
|
||||
row := h.db.QueryRowContext(ctx, `SELECT invite_code FROM mpc_sessions WHERE id = $1`, sessionID)
|
||||
row.Scan(&inviteCode) // Ignore error, invite_code is optional
|
||||
}
|
||||
|
||||
result := gin.H{
|
||||
"session_id": sessionID,
|
||||
"status": resp.Status,
|
||||
"session_type": resp.SessionType,
|
||||
"threshold_t": resp.ThresholdT,
|
||||
"threshold_n": resp.ThresholdN,
|
||||
"completed_parties": resp.CompletedParties,
|
||||
"total_parties": resp.TotalParties,
|
||||
}
|
||||
|
||||
// Add invite_code if available
|
||||
if inviteCode != "" {
|
||||
result["invite_code"] = inviteCode
|
||||
}
|
||||
|
||||
// Add signature if sign completed
|
||||
if resp.SessionType == "sign" && len(resp.Signature) > 0 {
|
||||
result["signature"] = hex.EncodeToString(resp.Signature)
|
||||
}
|
||||
|
||||
// Include participants with party_index
|
||||
if len(resp.Participants) > 0 {
|
||||
participants := make([]gin.H, len(resp.Participants))
|
||||
for i, p := range resp.Participants {
|
||||
participants[i] = gin.H{
|
||||
"party_id": p.PartyID,
|
||||
"party_index": p.PartyIndex,
|
||||
"status": p.Status,
|
||||
}
|
||||
}
|
||||
result["participants"] = participants
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// GetSignSessionByInviteCode handles looking up a sign session by its invite code
|
||||
// This is a completely new endpoint that does not affect existing functionality
|
||||
func (h *CoManagedHTTPHandler) GetSignSessionByInviteCode(c *gin.Context) {
|
||||
|
|
@ -611,16 +709,19 @@ func (h *CoManagedHTTPHandler) GetSignSessionByInviteCode(c *gin.Context) {
|
|||
var sessionID string
|
||||
var walletName string
|
||||
var keygenSessionID string
|
||||
var thresholdN, thresholdT int
|
||||
var status string
|
||||
var expiresAt time.Time
|
||||
var messageHash []byte
|
||||
|
||||
// Query sign session basic info
|
||||
err := h.db.QueryRowContext(ctx, `
|
||||
SELECT id, COALESCE(wallet_name, ''), COALESCE(keygen_session_id::text, ''),
|
||||
threshold_n, threshold_t, status, expires_at
|
||||
status, expires_at, COALESCE(message_hash, '')
|
||||
FROM mpc_sessions
|
||||
WHERE invite_code = $1 AND session_type = 'sign'
|
||||
`, inviteCode).Scan(&sessionID, &walletName, &keygenSessionID, &thresholdN, &thresholdT, &status, &expiresAt)
|
||||
WHERE invite_code = $1 AND session_type = 'sign' AND status != 'failed'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
`, inviteCode).Scan(&sessionID, &walletName, &keygenSessionID, &status, &expiresAt, &messageHash)
|
||||
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
|
|
@ -645,6 +746,60 @@ func (h *CoManagedHTTPHandler) GetSignSessionByInviteCode(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
// Get threshold_n and threshold_t from the KEYGEN session (the authoritative source)
|
||||
// This is critical for TSS signing to work correctly
|
||||
var keygenThresholdN, keygenThresholdT int
|
||||
if keygenSessionID != "" {
|
||||
err = h.db.QueryRowContext(ctx, `
|
||||
SELECT threshold_n, threshold_t
|
||||
FROM mpc_sessions
|
||||
WHERE id = $1
|
||||
`, keygenSessionID).Scan(&keygenThresholdN, &keygenThresholdT)
|
||||
if err != nil {
|
||||
logger.Error("Failed to query keygen session for threshold values",
|
||||
zap.String("keygen_session_id", keygenSessionID),
|
||||
zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to lookup keygen session"})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
logger.Error("Sign session has no keygen_session_id",
|
||||
zap.String("session_id", sessionID))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "sign session missing keygen reference"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get the signing parties list from the sign session's participants table
|
||||
// These are the parties that were selected for this signing session
|
||||
var parties []gin.H
|
||||
rows, err := h.db.QueryContext(ctx, `
|
||||
SELECT party_id, party_index
|
||||
FROM participants
|
||||
WHERE session_id = $1
|
||||
ORDER BY party_index
|
||||
`, sessionID)
|
||||
if err != nil {
|
||||
logger.Error("Failed to query sign session participants",
|
||||
zap.String("session_id", sessionID),
|
||||
zap.Error(err))
|
||||
// Continue without parties list, frontend will fallback
|
||||
} else {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var partyID string
|
||||
var partyIndex int
|
||||
if err := rows.Scan(&partyID, &partyIndex); err != nil {
|
||||
logger.Warn("Failed to scan participant row",
|
||||
zap.Error(err))
|
||||
continue
|
||||
}
|
||||
parties = append(parties, gin.H{
|
||||
"party_id": partyID,
|
||||
"party_index": partyIndex,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Get session status from coordinator
|
||||
statusResp, err := h.sessionCoordinatorClient.GetSessionStatus(ctx, sessionID)
|
||||
if err != nil {
|
||||
|
|
@ -656,28 +811,54 @@ func (h *CoManagedHTTPHandler) GetSignSessionByInviteCode(c *gin.Context) {
|
|||
"session_id": sessionID,
|
||||
"keygen_session_id": keygenSessionID,
|
||||
"wallet_name": walletName,
|
||||
"threshold_n": thresholdN,
|
||||
"threshold_t": thresholdT,
|
||||
"message_hash": hex.EncodeToString(messageHash),
|
||||
"threshold_n": keygenThresholdN,
|
||||
"threshold_t": keygenThresholdT,
|
||||
"status": status,
|
||||
"joined_count": 0,
|
||||
"expires_at": expiresAt.UnixMilli(),
|
||||
"parties": parties,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate join token for this session (wildcard token that allows any party to join)
|
||||
var joinToken string
|
||||
if h.jwtService != nil {
|
||||
sessionUUID, parseErr := uuid.Parse(sessionID)
|
||||
if parseErr == nil {
|
||||
token, err := h.jwtService.GenerateJoinToken(sessionUUID, "*", time.Hour) // Wildcard party ID, 1 hour expiry
|
||||
if err != nil {
|
||||
logger.Warn("Failed to generate join token for sign session",
|
||||
zap.String("session_id", sessionID),
|
||||
zap.Error(err))
|
||||
} else {
|
||||
joinToken = token
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.Info("Found sign session for invite_code",
|
||||
zap.String("invite_code", inviteCode),
|
||||
zap.String("session_id", sessionID),
|
||||
zap.String("wallet_name", walletName))
|
||||
zap.String("wallet_name", walletName),
|
||||
zap.String("keygen_session_id", keygenSessionID),
|
||||
zap.Int("keygen_threshold_n", keygenThresholdN),
|
||||
zap.Int("keygen_threshold_t", keygenThresholdT),
|
||||
zap.Int("parties_count", len(parties)),
|
||||
zap.Bool("has_join_token", joinToken != ""))
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"session_id": sessionID,
|
||||
"keygen_session_id": keygenSessionID,
|
||||
"wallet_name": walletName,
|
||||
"threshold_n": thresholdN,
|
||||
"threshold_t": thresholdT,
|
||||
"message_hash": hex.EncodeToString(messageHash),
|
||||
"threshold_n": keygenThresholdN,
|
||||
"threshold_t": keygenThresholdT,
|
||||
"status": statusResp.Status,
|
||||
"completed_parties": statusResp.CompletedParties,
|
||||
"total_parties": statusResp.TotalParties,
|
||||
"joined_count": statusResp.CompletedParties,
|
||||
"expires_at": expiresAt.UnixMilli(),
|
||||
"join_token": joinToken,
|
||||
"parties": parties,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -137,9 +137,19 @@ type SigningPartyInfo struct {
|
|||
// CreateSigningSessionAuto creates a new signing session with automatic party selection
|
||||
// Coordinator will select parties from the provided party info (from account shares)
|
||||
// delegateUserShare is required if any of the parties is a delegate party
|
||||
// keygenThresholdN is the original threshold_n from the keygen session (required for TSS math)
|
||||
//
|
||||
// BREAKING CHANGE WARNING (for co-sign feature, commit 042212ea):
|
||||
// Original code: ThresholdN = int32(len(parties)) - used participant count as N
|
||||
// New code: ThresholdN = keygenThresholdN - uses original N from keygen session
|
||||
// This change affects PERSISTENT SIGN flow. The original approach made threshold_n
|
||||
// equal to participant count (T+1), which worked with the old N-based validation.
|
||||
// If issues arise with persistent sign, REVERT to: ThresholdN: int32(len(parties))
|
||||
// Related files: session_coordinator.go, mpc_session.go, account_handler.go
|
||||
func (c *SessionCoordinatorClient) CreateSigningSessionAuto(
|
||||
ctx context.Context,
|
||||
thresholdT int32,
|
||||
keygenThresholdN int32,
|
||||
parties []SigningPartyInfo,
|
||||
messageHash []byte,
|
||||
expiresInSeconds int64,
|
||||
|
|
@ -155,9 +165,11 @@ func (c *SessionCoordinatorClient) CreateSigningSessionAuto(
|
|||
}
|
||||
}
|
||||
|
||||
// CRITICAL: Use keygenThresholdN (original n from keygen), NOT len(parties)
|
||||
// TSS signing requires the same n value used during keygen for correct mathematical operations
|
||||
req := &coordinatorpb.CreateSessionRequest{
|
||||
SessionType: "sign",
|
||||
ThresholdN: int32(len(parties)),
|
||||
ThresholdN: keygenThresholdN,
|
||||
ThresholdT: thresholdT,
|
||||
Participants: pbParticipants,
|
||||
MessageHash: messageHash,
|
||||
|
|
@ -174,12 +186,14 @@ func (c *SessionCoordinatorClient) CreateSigningSessionAuto(
|
|||
}
|
||||
logger.Info("Sending CreateSigningSession gRPC request with delegate user share",
|
||||
zap.Int32("threshold_t", thresholdT),
|
||||
zap.Int("num_parties", len(parties)),
|
||||
zap.Int32("keygen_threshold_n", keygenThresholdN),
|
||||
zap.Int("num_signing_parties", len(parties)),
|
||||
zap.String("delegate_party_id", delegateUserShare.DelegatePartyID))
|
||||
} else {
|
||||
logger.Info("Sending CreateSigningSession gRPC request",
|
||||
zap.Int32("threshold_t", thresholdT),
|
||||
zap.Int("num_parties", len(parties)))
|
||||
zap.Int32("keygen_threshold_n", keygenThresholdN),
|
||||
zap.Int("num_signing_parties", len(parties)))
|
||||
}
|
||||
|
||||
resp, err := c.client.CreateSession(ctx, req)
|
||||
|
|
|
|||
|
|
@ -350,8 +350,10 @@ func (s *MessageRouterServer) SubscribeSessionEvents(
|
|||
zap.String("party_id", req.PartyId))
|
||||
|
||||
// Subscribe to events
|
||||
eventCh := s.eventBroadcaster.Subscribe(req.PartyId)
|
||||
defer s.eventBroadcaster.Unsubscribe(req.PartyId)
|
||||
// The channel is used for identity check in Unsubscribe to prevent
|
||||
// accidentally removing a newer subscription when this stream exits
|
||||
eventCh, _ := s.eventBroadcaster.Subscribe(req.PartyId)
|
||||
defer s.eventBroadcaster.Unsubscribe(req.PartyId, eventCh)
|
||||
|
||||
// Stream events
|
||||
for {
|
||||
|
|
|
|||
|
|
@ -2,8 +2,11 @@ package domain
|
|||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
pb "github.com/rwadurian/mpc-system/api/grpc/router/v1"
|
||||
"github.com/rwadurian/mpc-system/pkg/logger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// SessionEventBroadcaster manages session event subscriptions and broadcasting
|
||||
|
|
@ -20,32 +23,51 @@ func NewSessionEventBroadcaster() *SessionEventBroadcaster {
|
|||
}
|
||||
|
||||
// Subscribe subscribes a party to session events
|
||||
// If the party already has an active subscription, the old channel is closed first
|
||||
// to prevent memory leaks and ensure clean reconnection
|
||||
func (b *SessionEventBroadcaster) Subscribe(partyID string) <-chan *pb.SessionEvent {
|
||||
// Returns the channel for receiving events and a unique subscription ID
|
||||
// The subscription ID is used to safely unsubscribe without affecting newer subscriptions
|
||||
func (b *SessionEventBroadcaster) Subscribe(partyID string) (<-chan *pb.SessionEvent, int64) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
// Close existing channel if party is re-subscribing (e.g., after reconnect)
|
||||
// This will cause the old gRPC stream to exit cleanly
|
||||
if oldCh, exists := b.subscribers[partyID]; exists {
|
||||
close(oldCh)
|
||||
logger.Debug("Closed old subscription channel for re-subscribing party",
|
||||
zap.String("party_id", partyID))
|
||||
}
|
||||
|
||||
// Create buffered channel for this subscriber
|
||||
ch := make(chan *pb.SessionEvent, 100)
|
||||
b.subscribers[partyID] = ch
|
||||
|
||||
return ch
|
||||
// Generate a unique subscription ID (using current time in nanoseconds)
|
||||
subscriptionID := time.Now().UnixNano()
|
||||
|
||||
return ch, subscriptionID
|
||||
}
|
||||
|
||||
// Unsubscribe removes a party's subscription
|
||||
func (b *SessionEventBroadcaster) Unsubscribe(partyID string) {
|
||||
// Unsubscribe removes a party's subscription only if the channel matches
|
||||
// This prevents a race condition where a newer subscription is accidentally removed
|
||||
// when an old gRPC stream exits after the party has already re-subscribed
|
||||
func (b *SessionEventBroadcaster) Unsubscribe(partyID string, ch <-chan *pb.SessionEvent) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
if ch, exists := b.subscribers[partyID]; exists {
|
||||
close(ch)
|
||||
delete(b.subscribers, partyID)
|
||||
if currentCh, exists := b.subscribers[partyID]; exists {
|
||||
// Only delete if the channel matches (i.e., this is still our subscription)
|
||||
// If the channel doesn't match, a newer subscription has been created
|
||||
// and we should not delete it
|
||||
if currentCh == ch {
|
||||
// Don't close the channel here - it was already closed by Subscribe
|
||||
// when the new subscription was created, or we're the last one
|
||||
delete(b.subscribers, partyID)
|
||||
logger.Debug("Unsubscribed party from session events",
|
||||
zap.String("party_id", partyID))
|
||||
} else {
|
||||
logger.Debug("Skipping unsubscribe - channel mismatch (newer subscription exists)",
|
||||
zap.String("party_id", partyID))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -69,16 +91,34 @@ func (b *SessionEventBroadcaster) BroadcastToParties(event *pb.SessionEvent, par
|
|||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
|
||||
sentCount := 0
|
||||
missedParties := []string{}
|
||||
|
||||
for _, partyID := range partyIDs {
|
||||
if ch, exists := b.subscribers[partyID]; exists {
|
||||
// Non-blocking send
|
||||
select {
|
||||
case ch <- event:
|
||||
sentCount++
|
||||
default:
|
||||
// Channel full, skip this subscriber
|
||||
missedParties = append(missedParties, partyID+" (channel full)")
|
||||
}
|
||||
} else {
|
||||
// Party not subscribed - this is a problem for session_started events!
|
||||
missedParties = append(missedParties, partyID+" (not subscribed)")
|
||||
}
|
||||
}
|
||||
|
||||
// Log if any parties were missed (helps debug event delivery issues)
|
||||
if len(missedParties) > 0 {
|
||||
logger.Warn("Some parties missed session event broadcast",
|
||||
zap.String("event_type", event.EventType),
|
||||
zap.String("session_id", event.SessionId),
|
||||
zap.Int("sent_count", sentCount),
|
||||
zap.Int("missed_count", len(missedParties)),
|
||||
zap.Strings("missed_parties", missedParties))
|
||||
}
|
||||
}
|
||||
|
||||
// SubscriberCount returns the number of active subscribers
|
||||
|
|
|
|||
|
|
@ -0,0 +1,99 @@
|
|||
# Built application files
|
||||
*.apk
|
||||
*.aar
|
||||
*.ap_
|
||||
*.aab
|
||||
|
||||
# Files for the ART/Dalvik VM
|
||||
*.dex
|
||||
|
||||
# Java class files
|
||||
*.class
|
||||
|
||||
# Generated files
|
||||
bin/
|
||||
gen/
|
||||
out/
|
||||
release/
|
||||
|
||||
# Gradle files
|
||||
.gradle/
|
||||
build/
|
||||
app/build/
|
||||
|
||||
# Local configuration file (sdk path, etc)
|
||||
local.properties
|
||||
|
||||
# Proguard folder generated by Eclipse
|
||||
proguard/
|
||||
|
||||
# Log Files
|
||||
*.log
|
||||
|
||||
# Android Studio Navigation editor temp files
|
||||
.navigation/
|
||||
|
||||
# Android Studio captures folder
|
||||
captures/
|
||||
|
||||
# IntelliJ
|
||||
*.iml
|
||||
.idea/
|
||||
.idea/workspace.xml
|
||||
.idea/tasks.xml
|
||||
.idea/gradle.xml
|
||||
.idea/assetWizardSettings.xml
|
||||
.idea/dictionaries
|
||||
.idea/libraries
|
||||
.idea/caches
|
||||
.idea/modules.xml
|
||||
.idea/misc.xml
|
||||
.idea/vcs.xml
|
||||
|
||||
# Keystore files (DO NOT COMMIT production keystores)
|
||||
*.jks
|
||||
*.keystore
|
||||
|
||||
# External native build folder generated in Android Studio 2.2 and later
|
||||
.externalNativeBuild
|
||||
.cxx/
|
||||
|
||||
# Google Services (e.g. APIs or Firebase)
|
||||
google-services.json
|
||||
|
||||
# Freeline
|
||||
freeline.py
|
||||
freeline/
|
||||
freeline_project_description.json
|
||||
|
||||
# fastlane
|
||||
fastlane/report.xml
|
||||
fastlane/Preview.html
|
||||
fastlane/screenshots
|
||||
fastlane/test_output
|
||||
fastlane/readme.md
|
||||
|
||||
# Version control
|
||||
vcs.xml
|
||||
|
||||
# lint
|
||||
lint/intermediates/
|
||||
lint/generated/
|
||||
lint/outputs/
|
||||
lint/tmp/
|
||||
|
||||
# Kotlin
|
||||
.kotlin/
|
||||
|
||||
# OS-specific files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
*.swp
|
||||
*~
|
||||
|
||||
# Signing configs - don't commit
|
||||
signing.properties
|
||||
keystore.properties
|
||||
|
||||
# Auto-generated version file
|
||||
app/version.properties
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
# TSS Party Android
|
||||
|
||||
Android 版本的 TSS (Threshold Signature Scheme) Party 应用,用于多方共管钱包的密钥生成和签名。
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
service-party-android/
|
||||
├── app/ # Android 应用模块
|
||||
│ ├── src/main/
|
||||
│ │ ├── java/com/durian/tssparty/
|
||||
│ │ │ ├── data/ # 数据层
|
||||
│ │ │ │ ├── local/ # 本地存储 (Room, TSS Bridge)
|
||||
│ │ │ │ ├── remote/ # 远程通信 (gRPC)
|
||||
│ │ │ │ └── repository/ # 数据仓库
|
||||
│ │ │ ├── domain/model/ # 领域模型
|
||||
│ │ │ ├── presentation/ # UI 层
|
||||
│ │ │ │ ├── screens/ # Compose 屏幕
|
||||
│ │ │ │ └── viewmodel/ # ViewModels
|
||||
│ │ │ ├── di/ # Hilt 依赖注入
|
||||
│ │ │ ├── ui/theme/ # Material Theme
|
||||
│ │ │ └── util/ # 工具类
|
||||
│ │ ├── proto/ # gRPC Proto 文件
|
||||
│ │ └── res/ # Android 资源
|
||||
│ └── libs/ # TSS 原生库 (.aar)
|
||||
├── tsslib/ # Go TSS 库源码
|
||||
│ ├── tsslib.go # gomobile 绑定
|
||||
│ ├── go.mod
|
||||
│ ├── build.sh # Linux/macOS 构建脚本
|
||||
│ └── build.bat # Windows 构建脚本
|
||||
└── gradle/ # Gradle Wrapper
|
||||
```
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **UI**: Jetpack Compose + Material 3
|
||||
- **架构**: MVVM + Repository Pattern
|
||||
- **依赖注入**: Hilt
|
||||
- **数据库**: Room
|
||||
- **网络**: gRPC (protobuf-lite)
|
||||
- **TSS 核心**: Go + gomobile (BnB Chain tss-lib v2)
|
||||
|
||||
## 构建步骤
|
||||
|
||||
### 1. 构建 TSS 原生库 (可选,需要 Go 环境)
|
||||
|
||||
```bash
|
||||
# 安装 gomobile
|
||||
go install golang.org/x/mobile/cmd/gomobile@latest
|
||||
gomobile init
|
||||
|
||||
# 构建 Android AAR
|
||||
cd tsslib
|
||||
./build.sh # Linux/macOS
|
||||
# 或
|
||||
build.bat # Windows
|
||||
```
|
||||
|
||||
这将在 `app/libs/` 生成 `tsslib.aar`。
|
||||
|
||||
> **注意**: 当前版本使用 Kotlin stub 实现,无需编译 Go 库即可构建 APK。
|
||||
> 实际运行需要真正的 `tsslib.aar`。
|
||||
|
||||
### 2. 构建 APK
|
||||
|
||||
```bash
|
||||
# Debug 版本
|
||||
./gradlew assembleDebug
|
||||
|
||||
# Release 版本 (需要签名配置)
|
||||
./gradlew assembleRelease
|
||||
```
|
||||
|
||||
APK 输出路径: `app/build/outputs/apk/debug/app-debug.apk`
|
||||
|
||||
## 功能
|
||||
|
||||
1. **加入 Keygen 会话** - 扫描/输入邀请码,参与多方密钥生成
|
||||
2. **查看钱包** - 显示已创建的共管钱包列表
|
||||
3. **签名交易** - 使用密钥份额参与多方签名
|
||||
4. **设置** - 配置 Message Router 服务器地址
|
||||
|
||||
## 配置
|
||||
|
||||
默认服务器配置:
|
||||
- Message Router: `localhost:50051`
|
||||
- Kava RPC: `https://evm.kava.io`
|
||||
|
||||
## 与 Electron 版本的对应关系
|
||||
|
||||
| Electron 版本 | Android 版本 |
|
||||
|---------------|--------------|
|
||||
| `electron/main.ts` | `TssNativeBridge.kt` + `GrpcClient.kt` |
|
||||
| `electron/preload.ts` | `TssRepository.kt` |
|
||||
| `src/pages/*.tsx` | `presentation/screens/*.kt` |
|
||||
| `tss-party/` (Go 子进程) | `tsslib/` (gomobile .aar) |
|
||||
| sql.js | Room Database |
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT
|
||||
|
|
@ -0,0 +1,200 @@
|
|||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("com.google.dagger.hilt.android")
|
||||
id("com.google.protobuf")
|
||||
kotlin("kapt")
|
||||
}
|
||||
|
||||
// Auto-increment version code from file
|
||||
val versionFile = file("version.properties")
|
||||
val versionProps = Properties()
|
||||
if (versionFile.exists()) {
|
||||
versionProps.load(versionFile.inputStream())
|
||||
}
|
||||
val autoVersionCode = (versionProps.getProperty("VERSION_CODE")?.toIntOrNull() ?: 0) + 1
|
||||
val autoVersionName = "1.0.${autoVersionCode}"
|
||||
|
||||
// Save new version code
|
||||
versionProps.setProperty("VERSION_CODE", autoVersionCode.toString())
|
||||
versionFile.outputStream().use { versionProps.store(it, "Auto-generated version properties") }
|
||||
|
||||
android {
|
||||
namespace = "com.durian.tssparty"
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.durian.tssparty"
|
||||
minSdk = 26
|
||||
targetSdk = 34
|
||||
versionCode = autoVersionCode
|
||||
versionName = autoVersionName
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
vectorDrawables {
|
||||
useSupportLibrary = true
|
||||
}
|
||||
|
||||
// NDK configuration for TSS native library
|
||||
ndk {
|
||||
abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64")
|
||||
}
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
// Use debug keystore for now - replace with production keystore for real release
|
||||
storeFile = file("${System.getProperty("user.home")}/.android/debug.keystore")
|
||||
storePassword = "android"
|
||||
keyAlias = "androiddebugkey"
|
||||
keyPassword = "android"
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false // Disable minification for easier debugging
|
||||
isShrinkResources = false
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
debug {
|
||||
isMinifyEnabled = false
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = "1.5.6"
|
||||
}
|
||||
|
||||
packaging {
|
||||
resources {
|
||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||
}
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
getByName("main") {
|
||||
// Include the compiled TSS .aar library
|
||||
jniLibs.srcDirs("libs")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Protobuf configuration for gRPC
|
||||
protobuf {
|
||||
protoc {
|
||||
artifact = "com.google.protobuf:protoc:3.25.1"
|
||||
}
|
||||
plugins {
|
||||
create("grpc") {
|
||||
artifact = "io.grpc:protoc-gen-grpc-java:1.60.0"
|
||||
}
|
||||
}
|
||||
generateProtoTasks {
|
||||
all().forEach { task ->
|
||||
task.builtins {
|
||||
create("java") {
|
||||
option("lite")
|
||||
}
|
||||
}
|
||||
task.plugins {
|
||||
create("grpc") {
|
||||
option("lite")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// TSS Library (gomobile generated)
|
||||
implementation(files("libs/tsslib.aar"))
|
||||
|
||||
// Core Android
|
||||
implementation("androidx.core:core-ktx:1.12.0")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2")
|
||||
implementation("androidx.activity:activity-compose:1.8.2")
|
||||
|
||||
// Compose
|
||||
implementation(platform("androidx.compose:compose-bom:2023.10.01"))
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.compose.ui:ui-graphics")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||
implementation("androidx.compose.material3:material3")
|
||||
implementation("androidx.compose.material:material-icons-extended")
|
||||
implementation("androidx.navigation:navigation-compose:2.7.6")
|
||||
|
||||
// Hilt DI
|
||||
implementation("com.google.dagger:hilt-android:2.48.1")
|
||||
kapt("com.google.dagger:hilt-android-compiler:2.48.1")
|
||||
implementation("androidx.hilt:hilt-navigation-compose:1.1.0")
|
||||
|
||||
// Room Database
|
||||
implementation("androidx.room:room-runtime:2.6.1")
|
||||
implementation("androidx.room:room-ktx:2.6.1")
|
||||
kapt("androidx.room:room-compiler:2.6.1")
|
||||
|
||||
// gRPC
|
||||
implementation("io.grpc:grpc-okhttp:1.60.0")
|
||||
implementation("io.grpc:grpc-protobuf-lite:1.60.0")
|
||||
implementation("io.grpc:grpc-stub:1.60.0")
|
||||
implementation("io.grpc:grpc-kotlin-stub:1.4.1")
|
||||
implementation("com.google.protobuf:protobuf-kotlin-lite:3.25.1")
|
||||
|
||||
// Networking
|
||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
|
||||
implementation("com.squareup.retrofit2:retrofit:2.9.0")
|
||||
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
|
||||
|
||||
// Coroutines
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
|
||||
|
||||
// JSON
|
||||
implementation("com.google.code.gson:gson:2.10.1")
|
||||
|
||||
// QR Code
|
||||
implementation("com.google.zxing:core:3.5.2")
|
||||
implementation("com.journeyapps:zxing-android-embedded:4.3.0")
|
||||
|
||||
// Crypto
|
||||
implementation("org.bouncycastle:bcprov-jdk18on:1.77")
|
||||
|
||||
// DataStore for preferences
|
||||
implementation("androidx.datastore:datastore-preferences:1.0.0")
|
||||
|
||||
// Testing
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
androidTestImplementation("androidx.test.ext:junit:1.1.5")
|
||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
|
||||
androidTestImplementation(platform("androidx.compose:compose-bom:2023.10.01"))
|
||||
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
debugImplementation("androidx.compose.ui:ui-test-manifest")
|
||||
}
|
||||
|
||||
kapt {
|
||||
correctErrorTypes = true
|
||||
}
|
||||
Binary file not shown.
|
|
@ -0,0 +1,21 @@
|
|||
# Add project specific ProGuard rules here.
|
||||
|
||||
# Keep gRPC classes
|
||||
-keep class io.grpc.** { *; }
|
||||
-keep class com.google.protobuf.** { *; }
|
||||
-keep class com.durian.tssparty.grpc.** { *; }
|
||||
|
||||
# Keep tsslib (gomobile generated)
|
||||
-keep class tsslib.** { *; }
|
||||
|
||||
# Keep Hilt generated classes
|
||||
-keep class dagger.hilt.** { *; }
|
||||
-keep class javax.inject.** { *; }
|
||||
|
||||
# Keep Room entities
|
||||
-keep class com.durian.tssparty.data.local.** { *; }
|
||||
|
||||
# Gson
|
||||
-keepattributes Signature
|
||||
-keepattributes *Annotation*
|
||||
-keep class com.durian.tssparty.domain.model.** { *; }
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<!-- Network permissions -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<!-- Camera permission for QR code scanning (optional) -->
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
|
||||
<application
|
||||
android:name=".TssPartyApplication"
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.TssParty"
|
||||
android:usesCleartextTraffic="true"
|
||||
tools:targetApi="31">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.TssParty">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Portrait-only QR code scanner activity -->
|
||||
<activity
|
||||
android:name=".presentation.screens.PortraitCaptureActivity"
|
||||
android:screenOrientation="portrait"
|
||||
android:stateNotNeeded="true"
|
||||
android:theme="@style/zxing_CaptureTheme"
|
||||
android:windowSoftInputMode="stateAlwaysHidden" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
|
@ -0,0 +1,526 @@
|
|||
package com.durian.tssparty
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.durian.tssparty.domain.model.AppReadyState
|
||||
import com.durian.tssparty.domain.model.ShareBackup
|
||||
import com.durian.tssparty.domain.model.TokenType
|
||||
import com.durian.tssparty.presentation.components.BottomNavItem
|
||||
import com.durian.tssparty.presentation.components.TssBottomNavigation
|
||||
import com.durian.tssparty.presentation.screens.*
|
||||
import com.durian.tssparty.presentation.viewmodel.MainViewModel
|
||||
import com.durian.tssparty.presentation.viewmodel.ConnectionTestResult as ViewModelConnectionTestResult
|
||||
import com.durian.tssparty.ui.theme.TssPartyTheme
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContent {
|
||||
TssPartyTheme {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
TssPartyApp(
|
||||
onCopyToClipboard = { text ->
|
||||
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
val clip = ClipData.newPlainText("邀请码", text)
|
||||
clipboard.setPrimaryClip(clip)
|
||||
Toast.makeText(this, "邀请码已复制", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun TssPartyApp(
|
||||
viewModel: MainViewModel = hiltViewModel(),
|
||||
onCopyToClipboard: (String) -> Unit = {}
|
||||
) {
|
||||
val navController = rememberNavController()
|
||||
val appState by viewModel.appState.collectAsState()
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val shares by viewModel.shares.collectAsState()
|
||||
val sessionStatus by viewModel.sessionStatus.collectAsState()
|
||||
val settings by viewModel.settings.collectAsState()
|
||||
val createdInviteCode by viewModel.createdInviteCode.collectAsState()
|
||||
val balances by viewModel.balances.collectAsState()
|
||||
val walletBalances by viewModel.walletBalances.collectAsState()
|
||||
val currentSessionId by viewModel.currentSessionId.collectAsState()
|
||||
val sessionParticipants by viewModel.sessionParticipants.collectAsState()
|
||||
val currentRound by viewModel.currentRound.collectAsState()
|
||||
val publicKey by viewModel.publicKey.collectAsState()
|
||||
val hasEnteredSession by viewModel.hasEnteredSession.collectAsState()
|
||||
|
||||
// Transfer state
|
||||
val preparedTx by viewModel.preparedTx.collectAsState()
|
||||
val signSessionId by viewModel.signSessionId.collectAsState()
|
||||
val signInviteCode by viewModel.signInviteCode.collectAsState()
|
||||
val signParticipants by viewModel.signParticipants.collectAsState()
|
||||
val signCurrentRound by viewModel.signCurrentRound.collectAsState()
|
||||
val signature by viewModel.signature.collectAsState()
|
||||
val txHash by viewModel.txHash.collectAsState()
|
||||
|
||||
// Join keygen state
|
||||
val joinSessionInfo by viewModel.joinSessionInfo.collectAsState()
|
||||
val joinKeygenParticipants by viewModel.joinKeygenParticipants.collectAsState()
|
||||
val joinKeygenRound by viewModel.joinKeygenRound.collectAsState()
|
||||
val joinKeygenPublicKey by viewModel.joinKeygenPublicKey.collectAsState()
|
||||
|
||||
// CoSign state
|
||||
val coSignSessionInfo by viewModel.coSignSessionInfo.collectAsState()
|
||||
val coSignParticipants by viewModel.coSignParticipants.collectAsState()
|
||||
val coSignRound by viewModel.coSignRound.collectAsState()
|
||||
val coSignSignature by viewModel.coSignSignature.collectAsState()
|
||||
|
||||
// Settings test connection results
|
||||
val messageRouterTestResult by viewModel.messageRouterTestResult.collectAsState()
|
||||
val accountServiceTestResult by viewModel.accountServiceTestResult.collectAsState()
|
||||
val kavaApiTestResult by viewModel.kavaApiTestResult.collectAsState()
|
||||
|
||||
// Export/Import state
|
||||
val exportResult by viewModel.exportResult.collectAsState()
|
||||
val importResult by viewModel.importResult.collectAsState()
|
||||
|
||||
// Current transfer wallet
|
||||
var transferWalletId by remember { mutableStateOf<Long?>(null) }
|
||||
|
||||
// Export/Import file handling
|
||||
val context = LocalContext.current
|
||||
var pendingExportJson by remember { mutableStateOf<String?>(null) }
|
||||
var pendingExportAddress by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
// File picker for saving backup
|
||||
val createDocumentLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.CreateDocument(ShareBackup.MIME_TYPE)
|
||||
) { uri: Uri? ->
|
||||
uri?.let { targetUri ->
|
||||
pendingExportJson?.let { json ->
|
||||
try {
|
||||
context.contentResolver.openOutputStream(targetUri)?.use { outputStream ->
|
||||
outputStream.write(json.toByteArray(Charsets.UTF_8))
|
||||
}
|
||||
Toast.makeText(context, "备份文件已保存", Toast.LENGTH_SHORT).show()
|
||||
} catch (e: Exception) {
|
||||
Toast.makeText(context, "保存失败: ${e.message}", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
pendingExportJson = null
|
||||
pendingExportAddress = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// File picker for importing backup
|
||||
val openDocumentLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenDocument()
|
||||
) { uri: Uri? ->
|
||||
uri?.let { sourceUri ->
|
||||
try {
|
||||
context.contentResolver.openInputStream(sourceUri)?.use { inputStream ->
|
||||
val json = inputStream.bufferedReader().readText()
|
||||
viewModel.importShareBackup(json)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Toast.makeText(context, "读取文件失败: ${e.message}", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle export result - trigger file save dialog
|
||||
LaunchedEffect(pendingExportJson) {
|
||||
pendingExportJson?.let { json ->
|
||||
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
|
||||
val addressSuffix = pendingExportAddress?.take(8) ?: "wallet"
|
||||
val fileName = "tss_backup_${addressSuffix}_$timestamp.${ShareBackup.FILE_EXTENSION}"
|
||||
createDocumentLauncher.launch(fileName)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle import result - show toast
|
||||
LaunchedEffect(importResult) {
|
||||
importResult?.let { result ->
|
||||
when {
|
||||
result.isSuccess -> {
|
||||
Toast.makeText(context, result.message ?: "导入成功", Toast.LENGTH_SHORT).show()
|
||||
viewModel.clearExportImportResult()
|
||||
}
|
||||
result.error != null -> {
|
||||
Toast.makeText(context, result.error, Toast.LENGTH_LONG).show()
|
||||
viewModel.clearExportImportResult()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Track if startup is complete
|
||||
var startupComplete by remember { mutableStateOf(false) }
|
||||
|
||||
// Handle success messages
|
||||
LaunchedEffect(uiState.successMessage) {
|
||||
if (uiState.successMessage != null) {
|
||||
// Navigate back to wallets on success
|
||||
navController.navigate(BottomNavItem.Wallets.route) {
|
||||
popUpTo(BottomNavItem.Wallets.route) { inclusive = true }
|
||||
}
|
||||
// Reset all session states so next time user enters a fresh state
|
||||
viewModel.resetSessionState()
|
||||
viewModel.resetJoinKeygenState()
|
||||
viewModel.resetCoSignState()
|
||||
viewModel.resetTransferState()
|
||||
viewModel.clearSuccess()
|
||||
viewModel.clearCreatedInviteCode()
|
||||
}
|
||||
}
|
||||
|
||||
// Show startup check screen if not complete
|
||||
if (!startupComplete) {
|
||||
StartupCheckScreen(
|
||||
appState = appState,
|
||||
onEnterApp = { startupComplete = true },
|
||||
onRetry = { viewModel.checkAllServices() }
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Main app with bottom navigation
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentRoute = navBackStackEntry?.destination?.route ?: BottomNavItem.Wallets.route
|
||||
|
||||
Scaffold(
|
||||
bottomBar = {
|
||||
TssBottomNavigation(
|
||||
currentRoute = currentRoute,
|
||||
onNavigate = { item ->
|
||||
navController.navigate(item.route) {
|
||||
// Pop up to the start destination to avoid building up a large stack
|
||||
popUpTo(BottomNavItem.Wallets.route) {
|
||||
saveState = true
|
||||
}
|
||||
// Avoid multiple copies of the same destination
|
||||
launchSingleTop = true
|
||||
// Restore state when reselecting a previously selected item
|
||||
restoreState = true
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = BottomNavItem.Wallets.route,
|
||||
modifier = Modifier.padding(paddingValues)
|
||||
) {
|
||||
// Tab 1: My Wallets (我的钱包)
|
||||
composable(BottomNavItem.Wallets.route) {
|
||||
// Fetch balances when entering wallets screen
|
||||
LaunchedEffect(shares) {
|
||||
viewModel.fetchAllBalances()
|
||||
}
|
||||
|
||||
WalletsScreen(
|
||||
shares = shares,
|
||||
isConnected = uiState.isConnected,
|
||||
balances = balances,
|
||||
walletBalances = walletBalances,
|
||||
networkType = settings.networkType,
|
||||
onDeleteShare = { viewModel.deleteShare(it) },
|
||||
onRefreshBalance = { address -> viewModel.fetchBalance(address) },
|
||||
onTransfer = { shareId ->
|
||||
transferWalletId = shareId
|
||||
navController.navigate("transfer/$shareId")
|
||||
},
|
||||
onExportBackup = { shareId, _ ->
|
||||
// Get address for filename
|
||||
val share = shares.find { it.id == shareId }
|
||||
pendingExportAddress = share?.address
|
||||
// Export and save to file
|
||||
viewModel.exportShareBackup(shareId) { json ->
|
||||
pendingExportJson = json
|
||||
}
|
||||
},
|
||||
onImportBackup = {
|
||||
// Open file picker to select backup file
|
||||
openDocumentLauncher.launch(arrayOf("*/*"))
|
||||
},
|
||||
onCreateWallet = {
|
||||
navController.navigate(BottomNavItem.Create.route)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Transfer Screen
|
||||
composable("transfer/{shareId}") { backStackEntry ->
|
||||
val shareId = backStackEntry.arguments?.getString("shareId")?.toLongOrNull()
|
||||
val wallet = shareId?.let { viewModel.getWalletById(it) }
|
||||
|
||||
if (wallet != null) {
|
||||
TransferScreen(
|
||||
wallet = wallet,
|
||||
balance = balances[wallet.address],
|
||||
walletBalance = walletBalances[wallet.address],
|
||||
sessionStatus = sessionStatus,
|
||||
participants = signParticipants,
|
||||
currentRound = signCurrentRound,
|
||||
totalRounds = 9,
|
||||
preparedTx = preparedTx,
|
||||
signSessionId = signSessionId,
|
||||
inviteCode = signInviteCode,
|
||||
signature = signature,
|
||||
txHash = txHash,
|
||||
isLoading = uiState.isLoading,
|
||||
error = uiState.error,
|
||||
networkType = settings.networkType,
|
||||
rpcUrl = settings.kavaRpcUrl,
|
||||
onPrepareTransaction = { toAddress, amount, tokenType ->
|
||||
viewModel.prepareTransfer(shareId, toAddress, amount, tokenType)
|
||||
},
|
||||
onConfirmTransaction = {
|
||||
viewModel.initiateSignSession(shareId, "")
|
||||
},
|
||||
onCopyInviteCode = {
|
||||
signInviteCode?.let { onCopyToClipboard(it) }
|
||||
},
|
||||
onBroadcastTransaction = {
|
||||
viewModel.broadcastTransaction()
|
||||
},
|
||||
onCancel = {
|
||||
viewModel.resetTransferState()
|
||||
viewModel.clearError()
|
||||
navController.popBackStack()
|
||||
},
|
||||
onBackToWallets = {
|
||||
viewModel.resetTransferState()
|
||||
navController.navigate(BottomNavItem.Wallets.route) {
|
||||
popUpTo(BottomNavItem.Wallets.route) { inclusive = true }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Tab 2: Create Wallet (创建钱包)
|
||||
composable(BottomNavItem.Create.route) {
|
||||
CreateWalletScreen(
|
||||
isLoading = uiState.isLoading,
|
||||
error = uiState.error,
|
||||
inviteCode = createdInviteCode,
|
||||
sessionId = currentSessionId,
|
||||
sessionStatus = sessionStatus,
|
||||
hasEnteredSession = hasEnteredSession,
|
||||
participants = sessionParticipants,
|
||||
currentRound = currentRound,
|
||||
totalRounds = 9,
|
||||
publicKey = publicKey,
|
||||
countdownSeconds = uiState.countdownSeconds,
|
||||
onCreateSession = { name, t, n, participantName ->
|
||||
viewModel.createKeygenSession(name, t, n, participantName)
|
||||
},
|
||||
onCopyInviteCode = {
|
||||
createdInviteCode?.let { onCopyToClipboard(it) }
|
||||
},
|
||||
onEnterSession = {
|
||||
viewModel.enterSession()
|
||||
},
|
||||
onCancel = {
|
||||
viewModel.cancelSession()
|
||||
viewModel.clearError()
|
||||
viewModel.resetSessionState()
|
||||
navController.navigate(BottomNavItem.Wallets.route) {
|
||||
popUpTo(BottomNavItem.Wallets.route) { inclusive = true }
|
||||
}
|
||||
},
|
||||
onBackToHome = {
|
||||
viewModel.resetSessionState()
|
||||
navController.navigate(BottomNavItem.Wallets.route) {
|
||||
popUpTo(BottomNavItem.Wallets.route) { inclusive = true }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Tab 3: Join Keygen (加入创建)
|
||||
composable(BottomNavItem.JoinKeygen.route) {
|
||||
// Convert JoinKeygenSessionInfo to JoinSessionInfo for the screen
|
||||
val screenSessionInfo = joinSessionInfo?.let {
|
||||
JoinSessionInfo(
|
||||
sessionId = it.sessionId,
|
||||
walletName = it.walletName,
|
||||
thresholdT = it.thresholdT,
|
||||
thresholdN = it.thresholdN,
|
||||
initiator = it.initiator,
|
||||
currentParticipants = it.currentParticipants,
|
||||
totalParticipants = it.totalParticipants
|
||||
)
|
||||
}
|
||||
|
||||
JoinKeygenScreen(
|
||||
sessionStatus = sessionStatus,
|
||||
isLoading = uiState.isLoading,
|
||||
error = uiState.error,
|
||||
sessionInfo = screenSessionInfo,
|
||||
participants = joinKeygenParticipants,
|
||||
currentRound = joinKeygenRound,
|
||||
totalRounds = 9,
|
||||
publicKey = joinKeygenPublicKey,
|
||||
countdownSeconds = uiState.countdownSeconds,
|
||||
onValidateInviteCode = { inviteCode ->
|
||||
viewModel.validateInviteCode(inviteCode)
|
||||
},
|
||||
onJoinKeygen = { inviteCode, password ->
|
||||
viewModel.joinKeygen(inviteCode, password)
|
||||
},
|
||||
onCancel = {
|
||||
// Cancel from input screen - navigate away
|
||||
viewModel.cancelSession()
|
||||
viewModel.clearError()
|
||||
viewModel.resetJoinKeygenState()
|
||||
navController.navigate(BottomNavItem.Wallets.route) {
|
||||
popUpTo(BottomNavItem.Wallets.route) { inclusive = true }
|
||||
}
|
||||
},
|
||||
onResetState = {
|
||||
// Reset from confirm/joining/progress screens - stay on page
|
||||
viewModel.cancelSession()
|
||||
viewModel.clearError()
|
||||
viewModel.resetJoinKeygenState()
|
||||
},
|
||||
onBackToHome = {
|
||||
viewModel.resetJoinKeygenState()
|
||||
navController.navigate(BottomNavItem.Wallets.route) {
|
||||
popUpTo(BottomNavItem.Wallets.route) { inclusive = true }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Tab 4: Co-Sign (参与签名)
|
||||
composable(BottomNavItem.CoSign.route) {
|
||||
// Convert CoSignSessionInfo to SignSessionInfo for the screen
|
||||
val screenSignSessionInfo = coSignSessionInfo?.let {
|
||||
SignSessionInfo(
|
||||
sessionId = it.sessionId,
|
||||
keygenSessionId = it.keygenSessionId,
|
||||
walletName = it.walletName,
|
||||
messageHash = it.messageHash,
|
||||
thresholdT = it.thresholdT,
|
||||
thresholdN = it.thresholdN,
|
||||
currentParticipants = it.currentParticipants
|
||||
)
|
||||
}
|
||||
|
||||
CoSignJoinScreen(
|
||||
shares = shares,
|
||||
sessionStatus = sessionStatus,
|
||||
isLoading = uiState.isLoading,
|
||||
error = uiState.error,
|
||||
signSessionInfo = screenSignSessionInfo,
|
||||
participants = coSignParticipants,
|
||||
currentRound = coSignRound,
|
||||
totalRounds = 9,
|
||||
signature = coSignSignature,
|
||||
countdownSeconds = uiState.countdownSeconds,
|
||||
onValidateInviteCode = { inviteCode ->
|
||||
viewModel.validateSignInviteCode(inviteCode)
|
||||
},
|
||||
onJoinSign = { inviteCode, shareId, password ->
|
||||
viewModel.joinSign(inviteCode, shareId, password)
|
||||
},
|
||||
onCancel = {
|
||||
// Cancel from input screen - navigate away
|
||||
viewModel.cancelSession()
|
||||
viewModel.clearError()
|
||||
viewModel.resetCoSignState()
|
||||
navController.navigate(BottomNavItem.Wallets.route) {
|
||||
popUpTo(BottomNavItem.Wallets.route) { inclusive = true }
|
||||
}
|
||||
},
|
||||
onResetState = {
|
||||
// Reset from select_share/joining/signing screens - stay on page
|
||||
viewModel.cancelSession()
|
||||
viewModel.clearError()
|
||||
viewModel.resetCoSignState()
|
||||
},
|
||||
onBackToHome = {
|
||||
viewModel.resetCoSignState()
|
||||
navController.navigate(BottomNavItem.Wallets.route) {
|
||||
popUpTo(BottomNavItem.Wallets.route) { inclusive = true }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Tab 5: Settings (设置)
|
||||
composable(BottomNavItem.Settings.route) {
|
||||
// Convert ViewModel ConnectionTestResult to Screen ConnectionTestResult
|
||||
val screenMessageRouterStatus: ConnectionTestResult? = messageRouterTestResult?.let {
|
||||
ConnectionTestResult(
|
||||
success = it.success,
|
||||
message = it.message,
|
||||
latency = it.latency
|
||||
)
|
||||
}
|
||||
val screenAccountServiceStatus: ConnectionTestResult? = accountServiceTestResult?.let {
|
||||
ConnectionTestResult(
|
||||
success = it.success,
|
||||
message = it.message,
|
||||
latency = it.latency
|
||||
)
|
||||
}
|
||||
val screenKavaApiStatus: ConnectionTestResult? = kavaApiTestResult?.let {
|
||||
ConnectionTestResult(
|
||||
success = it.success,
|
||||
message = it.message,
|
||||
latency = it.latency
|
||||
)
|
||||
}
|
||||
|
||||
SettingsScreen(
|
||||
settings = settings,
|
||||
isConnected = uiState.isConnected,
|
||||
messageRouterStatus = screenMessageRouterStatus,
|
||||
accountServiceStatus = screenAccountServiceStatus,
|
||||
kavaApiStatus = screenKavaApiStatus,
|
||||
onSaveSettings = { newSettings ->
|
||||
viewModel.updateSettings(newSettings)
|
||||
},
|
||||
onTestMessageRouter = { url ->
|
||||
viewModel.testMessageRouter(url)
|
||||
},
|
||||
onTestAccountService = { url ->
|
||||
viewModel.testAccountService(url)
|
||||
},
|
||||
onTestKavaApi = { url ->
|
||||
viewModel.testKavaApi(url)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package com.durian.tssparty
|
||||
|
||||
import android.app.Application
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
|
||||
@HiltAndroidApp
|
||||
class TssPartyApplication : Application()
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
package com.durian.tssparty.data.local
|
||||
|
||||
import androidx.room.*
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/**
|
||||
* Entity for storing TSS share records
|
||||
*/
|
||||
@Entity(tableName = "share_records")
|
||||
data class ShareRecordEntity(
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
val id: Long = 0,
|
||||
|
||||
@ColumnInfo(name = "session_id")
|
||||
val sessionId: String,
|
||||
|
||||
@ColumnInfo(name = "public_key")
|
||||
val publicKey: String,
|
||||
|
||||
@ColumnInfo(name = "encrypted_share")
|
||||
val encryptedShare: String,
|
||||
|
||||
@ColumnInfo(name = "threshold_t")
|
||||
val thresholdT: Int,
|
||||
|
||||
@ColumnInfo(name = "threshold_n")
|
||||
val thresholdN: Int,
|
||||
|
||||
@ColumnInfo(name = "party_index")
|
||||
val partyIndex: Int,
|
||||
|
||||
@ColumnInfo(name = "address")
|
||||
val address: String,
|
||||
|
||||
@ColumnInfo(name = "created_at")
|
||||
val createdAt: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
/**
|
||||
* DAO for share records
|
||||
*/
|
||||
@Dao
|
||||
interface ShareRecordDao {
|
||||
@Query("SELECT * FROM share_records ORDER BY created_at DESC")
|
||||
fun getAllShares(): Flow<List<ShareRecordEntity>>
|
||||
|
||||
@Query("SELECT * FROM share_records WHERE id = :id")
|
||||
suspend fun getShareById(id: Long): ShareRecordEntity?
|
||||
|
||||
@Query("SELECT * FROM share_records WHERE session_id = :sessionId")
|
||||
suspend fun getShareBySessionId(sessionId: String): ShareRecordEntity?
|
||||
|
||||
@Query("SELECT * FROM share_records WHERE address = :address")
|
||||
suspend fun getShareByAddress(address: String): ShareRecordEntity?
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertShare(share: ShareRecordEntity): Long
|
||||
|
||||
@Delete
|
||||
suspend fun deleteShare(share: ShareRecordEntity)
|
||||
|
||||
@Query("DELETE FROM share_records WHERE id = :id")
|
||||
suspend fun deleteShareById(id: Long)
|
||||
|
||||
@Query("SELECT COUNT(*) FROM share_records")
|
||||
suspend fun getShareCount(): Int
|
||||
}
|
||||
|
||||
/**
|
||||
* Entity for storing app settings (like persistent partyId)
|
||||
*/
|
||||
@Entity(tableName = "app_settings")
|
||||
data class AppSettingEntity(
|
||||
@PrimaryKey
|
||||
val key: String,
|
||||
|
||||
@ColumnInfo(name = "value")
|
||||
val value: String
|
||||
)
|
||||
|
||||
/**
|
||||
* DAO for app settings
|
||||
*/
|
||||
@Dao
|
||||
interface AppSettingDao {
|
||||
@Query("SELECT value FROM app_settings WHERE `key` = :key")
|
||||
suspend fun getValue(key: String): String?
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun setValue(setting: AppSettingEntity)
|
||||
}
|
||||
|
||||
/**
|
||||
* Room database
|
||||
*/
|
||||
@Database(
|
||||
entities = [ShareRecordEntity::class, AppSettingEntity::class],
|
||||
version = 2,
|
||||
exportSchema = false
|
||||
)
|
||||
abstract class TssDatabase : RoomDatabase() {
|
||||
abstract fun shareRecordDao(): ShareRecordDao
|
||||
abstract fun appSettingDao(): AppSettingDao
|
||||
}
|
||||
|
|
@ -0,0 +1,172 @@
|
|||
package com.durian.tssparty.data.local
|
||||
|
||||
import com.durian.tssparty.domain.model.KeygenResult
|
||||
import com.durian.tssparty.domain.model.Participant
|
||||
import com.durian.tssparty.domain.model.SignResult
|
||||
import com.durian.tssparty.domain.model.TssOutgoingMessage
|
||||
import com.google.gson.Gson
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.withContext
|
||||
import tsslib.MessageCallback
|
||||
import tsslib.Tsslib
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Bridge between Kotlin and Go TSS library via gomobile bindings
|
||||
*/
|
||||
@Singleton
|
||||
class TssNativeBridge @Inject constructor(
|
||||
private val gson: Gson
|
||||
) {
|
||||
private val _outgoingMessages = Channel<TssOutgoingMessage>(Channel.BUFFERED)
|
||||
val outgoingMessages: Flow<TssOutgoingMessage> = _outgoingMessages.receiveAsFlow()
|
||||
|
||||
private val _progress = Channel<Pair<Int, Int>>(Channel.BUFFERED)
|
||||
val progress: Flow<Pair<Int, Int>> = _progress.receiveAsFlow()
|
||||
|
||||
private val _errors = Channel<String>(Channel.BUFFERED)
|
||||
val errors: Flow<String> = _errors.receiveAsFlow()
|
||||
|
||||
private val _logs = Channel<String>(Channel.BUFFERED)
|
||||
val logs: Flow<String> = _logs.receiveAsFlow()
|
||||
|
||||
private val callback = object : MessageCallback {
|
||||
override fun onOutgoingMessage(messageJSON: String) {
|
||||
try {
|
||||
val message = gson.fromJson(messageJSON, TssOutgoingMessage::class.java)
|
||||
_outgoingMessages.trySend(message)
|
||||
} catch (e: Exception) {
|
||||
_errors.trySend("Failed to parse outgoing message: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onProgress(round: Long, totalRounds: Long) {
|
||||
_progress.trySend(Pair(round.toInt(), totalRounds.toInt()))
|
||||
}
|
||||
|
||||
override fun onError(errorMessage: String) {
|
||||
_errors.trySend(errorMessage)
|
||||
}
|
||||
|
||||
override fun onLog(message: String) {
|
||||
_logs.trySend(message)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a keygen session
|
||||
*/
|
||||
suspend fun startKeygen(
|
||||
sessionId: String,
|
||||
partyId: String,
|
||||
partyIndex: Int,
|
||||
thresholdT: Int,
|
||||
thresholdN: Int,
|
||||
participants: List<Participant>,
|
||||
password: String
|
||||
): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val participantsJson = gson.toJson(participants)
|
||||
Tsslib.startKeygen(
|
||||
sessionId,
|
||||
partyId,
|
||||
partyIndex.toLong(),
|
||||
thresholdT.toLong(),
|
||||
thresholdN.toLong(),
|
||||
participantsJson,
|
||||
password,
|
||||
callback
|
||||
)
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a sign session
|
||||
*/
|
||||
suspend fun startSign(
|
||||
sessionId: String,
|
||||
partyId: String,
|
||||
partyIndex: Int,
|
||||
thresholdT: Int,
|
||||
thresholdN: Int,
|
||||
participants: List<Participant>,
|
||||
messageHash: String,
|
||||
shareData: String,
|
||||
password: String
|
||||
): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val participantsJson = gson.toJson(participants)
|
||||
Tsslib.startSign(
|
||||
sessionId,
|
||||
partyId,
|
||||
partyIndex.toLong(),
|
||||
thresholdT.toLong(),
|
||||
thresholdN.toLong(),
|
||||
participantsJson,
|
||||
messageHash,
|
||||
shareData,
|
||||
password,
|
||||
callback
|
||||
)
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send incoming message from another party
|
||||
*/
|
||||
suspend fun sendIncomingMessage(
|
||||
fromPartyIndex: Int,
|
||||
isBroadcast: Boolean,
|
||||
payload: String
|
||||
): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
Tsslib.sendIncomingMessage(fromPartyIndex.toLong(), isBroadcast, payload)
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for keygen result
|
||||
*/
|
||||
suspend fun waitForKeygenResult(password: String): Result<KeygenResult> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val resultJson = Tsslib.waitForKeygenResult(password)
|
||||
val result = gson.fromJson(resultJson, KeygenResult::class.java)
|
||||
Result.success(result)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for sign result
|
||||
*/
|
||||
suspend fun waitForSignResult(): Result<SignResult> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val resultJson = Tsslib.waitForSignResult()
|
||||
val result = gson.fromJson(resultJson, SignResult::class.java)
|
||||
Result.success(result)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel current session
|
||||
*/
|
||||
fun cancelSession() {
|
||||
Tsslib.cancelSession()
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,89 @@
|
|||
package com.durian.tssparty.di
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import com.durian.tssparty.data.local.AppSettingDao
|
||||
import com.durian.tssparty.data.local.ShareRecordDao
|
||||
import com.durian.tssparty.data.local.TssDatabase
|
||||
import com.durian.tssparty.data.local.TssNativeBridge
|
||||
import com.durian.tssparty.data.remote.GrpcClient
|
||||
import com.durian.tssparty.data.repository.TssRepository
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.GsonBuilder
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object AppModule {
|
||||
|
||||
// Migration from version 1 to 2: add app_settings table
|
||||
private val MIGRATION_1_2 = object : Migration(1, 2) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
database.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS `app_settings` (" +
|
||||
"`key` TEXT NOT NULL PRIMARY KEY, " +
|
||||
"`value` TEXT NOT NULL)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideGson(): Gson {
|
||||
return GsonBuilder().create()
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideDatabase(@ApplicationContext context: Context): TssDatabase {
|
||||
return Room.databaseBuilder(
|
||||
context,
|
||||
TssDatabase::class.java,
|
||||
"tss_party.db"
|
||||
)
|
||||
.addMigrations(MIGRATION_1_2)
|
||||
.build()
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideShareRecordDao(database: TssDatabase): ShareRecordDao {
|
||||
return database.shareRecordDao()
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAppSettingDao(database: TssDatabase): AppSettingDao {
|
||||
return database.appSettingDao()
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideGrpcClient(): GrpcClient {
|
||||
return GrpcClient()
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideTssNativeBridge(gson: Gson): TssNativeBridge {
|
||||
return TssNativeBridge(gson)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideTssRepository(
|
||||
grpcClient: GrpcClient,
|
||||
tssNativeBridge: TssNativeBridge,
|
||||
shareRecordDao: ShareRecordDao,
|
||||
appSettingDao: AppSettingDao
|
||||
): TssRepository {
|
||||
return TssRepository(grpcClient, tssNativeBridge, shareRecordDao, appSettingDao)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
package com.durian.tssparty.domain.model
|
||||
|
||||
/**
|
||||
* Application ready state
|
||||
*/
|
||||
enum class AppReadyState {
|
||||
INITIALIZING,
|
||||
READY,
|
||||
ERROR
|
||||
}
|
||||
|
||||
/**
|
||||
* Service check status
|
||||
*/
|
||||
data class ServiceStatus(
|
||||
val isOnline: Boolean = false,
|
||||
val message: String = "",
|
||||
val latency: Long? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* Environment state - tracks all service statuses
|
||||
*/
|
||||
data class EnvironmentState(
|
||||
val database: ServiceStatus = ServiceStatus(),
|
||||
val messageRouter: ServiceStatus = ServiceStatus(),
|
||||
val kavaApi: ServiceStatus = ServiceStatus()
|
||||
)
|
||||
|
||||
/**
|
||||
* Operation progress for keygen/sign
|
||||
*/
|
||||
data class OperationProgress(
|
||||
val isActive: Boolean = false,
|
||||
val type: OperationType = OperationType.NONE,
|
||||
val sessionId: String? = null,
|
||||
val currentRound: Int = 0,
|
||||
val totalRounds: Int = 0,
|
||||
val status: String = ""
|
||||
)
|
||||
|
||||
enum class OperationType {
|
||||
NONE,
|
||||
KEYGEN,
|
||||
SIGN
|
||||
}
|
||||
|
||||
/**
|
||||
* Global app state (similar to Zustand store in Electron version)
|
||||
*/
|
||||
data class AppState(
|
||||
val appReady: AppReadyState = AppReadyState.INITIALIZING,
|
||||
val appError: String? = null,
|
||||
val environment: EnvironmentState = EnvironmentState(),
|
||||
val operation: OperationProgress = OperationProgress(),
|
||||
val partyId: String? = null,
|
||||
val walletCount: Int = 0
|
||||
)
|
||||
|
|
@ -0,0 +1,234 @@
|
|||
package com.durian.tssparty.domain.model
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
/**
|
||||
* Participant in a TSS session
|
||||
*/
|
||||
data class Participant(
|
||||
@SerializedName("partyId")
|
||||
val partyId: String,
|
||||
@SerializedName("partyIndex")
|
||||
val partyIndex: Int,
|
||||
@SerializedName("name")
|
||||
val name: String = ""
|
||||
)
|
||||
|
||||
/**
|
||||
* TSS Session information
|
||||
*/
|
||||
data class TssSession(
|
||||
val sessionId: String,
|
||||
val sessionType: SessionType,
|
||||
val thresholdT: Int,
|
||||
val thresholdN: Int,
|
||||
val participants: List<Participant>,
|
||||
val status: SessionStatus,
|
||||
val inviteCode: String? = null,
|
||||
val messageHash: String? = null,
|
||||
val createdAt: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
enum class SessionType {
|
||||
KEYGEN,
|
||||
SIGN
|
||||
}
|
||||
|
||||
enum class SessionStatus {
|
||||
WAITING,
|
||||
IN_PROGRESS,
|
||||
COMPLETED,
|
||||
FAILED
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of key generation
|
||||
*/
|
||||
data class KeygenResult(
|
||||
@SerializedName("publicKey")
|
||||
val publicKey: String, // base64 encoded
|
||||
@SerializedName("encryptedShare")
|
||||
val encryptedShare: String // base64 encoded
|
||||
)
|
||||
|
||||
/**
|
||||
* Result of signing
|
||||
*/
|
||||
data class SignResult(
|
||||
@SerializedName("signature")
|
||||
val signature: String, // base64 encoded (r || s || v, 65 bytes)
|
||||
@SerializedName("recoveryId")
|
||||
val recoveryId: Int
|
||||
)
|
||||
|
||||
/**
|
||||
* Outgoing TSS message
|
||||
*/
|
||||
data class TssOutgoingMessage(
|
||||
@SerializedName("type")
|
||||
val type: String,
|
||||
@SerializedName("isBroadcast")
|
||||
val isBroadcast: Boolean,
|
||||
@SerializedName("toParties")
|
||||
val toParties: List<String>?,
|
||||
@SerializedName("payload")
|
||||
val payload: String // base64 encoded
|
||||
)
|
||||
|
||||
/**
|
||||
* Share record stored in local database
|
||||
*/
|
||||
data class ShareRecord(
|
||||
val id: Long = 0,
|
||||
val sessionId: String,
|
||||
val publicKey: String,
|
||||
val encryptedShare: String,
|
||||
val thresholdT: Int,
|
||||
val thresholdN: Int,
|
||||
val partyIndex: Int,
|
||||
val address: String,
|
||||
val createdAt: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
/**
|
||||
* Account balance information
|
||||
*/
|
||||
data class AccountBalance(
|
||||
val address: String,
|
||||
val balance: String,
|
||||
val denom: String = "ukava"
|
||||
)
|
||||
|
||||
/**
|
||||
* Sign session request
|
||||
*/
|
||||
data class SignSessionRequest(
|
||||
val sessionId: String,
|
||||
val messageHash: String, // hex encoded
|
||||
val participants: List<Participant>
|
||||
)
|
||||
|
||||
/**
|
||||
* Settings
|
||||
* Matches service-party-app settings structure
|
||||
*/
|
||||
data class AppSettings(
|
||||
val messageRouterUrl: String = "mpc-grpc.szaiai.com:443",
|
||||
val accountServiceUrl: String = "https://rwaapi.szaiai.com",
|
||||
val kavaRpcUrl: String = "https://evm.kava.io",
|
||||
val networkType: NetworkType = NetworkType.MAINNET
|
||||
)
|
||||
|
||||
enum class NetworkType {
|
||||
MAINNET,
|
||||
TESTNET
|
||||
}
|
||||
|
||||
/**
|
||||
* Token type for transfers
|
||||
*/
|
||||
enum class TokenType {
|
||||
KAVA, // Native KAVA token
|
||||
GREEN_POINTS // 绿积分 (dUSDT) ERC-20 token
|
||||
}
|
||||
|
||||
/**
|
||||
* Green Points (绿积分) Token Contract Configuration
|
||||
* dUSDT - Fixed supply ERC-20 token on Kava EVM
|
||||
*/
|
||||
object GreenPointsToken {
|
||||
const val CONTRACT_ADDRESS = "0xA9F3A35dBa8699c8C681D8db03F0c1A8CEB9D7c3"
|
||||
const val NAME = "绿积分"
|
||||
const val SYMBOL = "dUSDT"
|
||||
const val DECIMALS = 6
|
||||
|
||||
// ERC-20 function signatures (first 4 bytes of keccak256 hash)
|
||||
const val BALANCE_OF_SELECTOR = "0x70a08231" // balanceOf(address)
|
||||
const val TRANSFER_SELECTOR = "0xa9059cbb" // transfer(address,uint256)
|
||||
const val APPROVE_SELECTOR = "0x095ea7b3" // approve(address,uint256)
|
||||
const val ALLOWANCE_SELECTOR = "0xdd62ed3e" // allowance(address,address)
|
||||
const val TOTAL_SUPPLY_SELECTOR = "0x18160ddd" // totalSupply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Wallet balance containing both native and token balances
|
||||
*/
|
||||
data class WalletBalance(
|
||||
val address: String,
|
||||
val kavaBalance: String = "0", // Native KAVA balance
|
||||
val greenPointsBalance: String = "0" // 绿积分 (dUSDT) balance
|
||||
)
|
||||
|
||||
/**
|
||||
* Share backup data for export/import
|
||||
* Contains all necessary information to restore a wallet share
|
||||
*/
|
||||
data class ShareBackup(
|
||||
@SerializedName("version")
|
||||
val version: Int = 1, // Backup format version for future compatibility
|
||||
|
||||
@SerializedName("sessionId")
|
||||
val sessionId: String,
|
||||
|
||||
@SerializedName("publicKey")
|
||||
val publicKey: String, // base64 encoded
|
||||
|
||||
@SerializedName("encryptedShare")
|
||||
val encryptedShare: String, // base64 encoded, encrypted with user password
|
||||
|
||||
@SerializedName("thresholdT")
|
||||
val thresholdT: Int,
|
||||
|
||||
@SerializedName("thresholdN")
|
||||
val thresholdN: Int,
|
||||
|
||||
@SerializedName("partyIndex")
|
||||
val partyIndex: Int,
|
||||
|
||||
@SerializedName("address")
|
||||
val address: String,
|
||||
|
||||
@SerializedName("createdAt")
|
||||
val createdAt: Long,
|
||||
|
||||
@SerializedName("exportedAt")
|
||||
val exportedAt: Long = System.currentTimeMillis()
|
||||
) {
|
||||
companion object {
|
||||
const val FILE_EXTENSION = "tss-backup"
|
||||
const val MIME_TYPE = "application/octet-stream"
|
||||
|
||||
/**
|
||||
* Create backup from ShareRecord
|
||||
*/
|
||||
fun fromShareRecord(share: ShareRecord): ShareBackup {
|
||||
return ShareBackup(
|
||||
sessionId = share.sessionId,
|
||||
publicKey = share.publicKey,
|
||||
encryptedShare = share.encryptedShare,
|
||||
thresholdT = share.thresholdT,
|
||||
thresholdN = share.thresholdN,
|
||||
partyIndex = share.partyIndex,
|
||||
address = share.address,
|
||||
createdAt = share.createdAt
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert backup to ShareRecord for database storage
|
||||
*/
|
||||
fun toShareRecord(): ShareRecord {
|
||||
return ShareRecord(
|
||||
id = 0, // Will be auto-generated
|
||||
sessionId = sessionId,
|
||||
publicKey = publicKey,
|
||||
encryptedShare = encryptedShare,
|
||||
thresholdT = thresholdT,
|
||||
thresholdN = thresholdN,
|
||||
partyIndex = partyIndex,
|
||||
address = address,
|
||||
createdAt = createdAt
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
package com.durian.tssparty.presentation.components
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
|
||||
/**
|
||||
* Navigation destinations for bottom tabs
|
||||
*/
|
||||
sealed class BottomNavItem(
|
||||
val route: String,
|
||||
val title: String,
|
||||
val selectedIcon: ImageVector,
|
||||
val unselectedIcon: ImageVector
|
||||
) {
|
||||
data object Wallets : BottomNavItem(
|
||||
route = "wallets",
|
||||
title = "我的钱包",
|
||||
selectedIcon = Icons.Filled.Lock,
|
||||
unselectedIcon = Icons.Outlined.Lock
|
||||
)
|
||||
|
||||
data object Create : BottomNavItem(
|
||||
route = "create",
|
||||
title = "创建钱包",
|
||||
selectedIcon = Icons.Filled.Add,
|
||||
unselectedIcon = Icons.Outlined.Add
|
||||
)
|
||||
|
||||
data object JoinKeygen : BottomNavItem(
|
||||
route = "join_keygen",
|
||||
title = "加入创建",
|
||||
selectedIcon = Icons.Filled.Handshake,
|
||||
unselectedIcon = Icons.Outlined.Handshake
|
||||
)
|
||||
|
||||
data object CoSign : BottomNavItem(
|
||||
route = "cosign",
|
||||
title = "参与签名",
|
||||
selectedIcon = Icons.Filled.Create,
|
||||
unselectedIcon = Icons.Outlined.Create
|
||||
)
|
||||
|
||||
data object Settings : BottomNavItem(
|
||||
route = "settings",
|
||||
title = "设置",
|
||||
selectedIcon = Icons.Filled.Settings,
|
||||
unselectedIcon = Icons.Outlined.Settings
|
||||
)
|
||||
}
|
||||
|
||||
val bottomNavItems = listOf(
|
||||
BottomNavItem.Wallets,
|
||||
BottomNavItem.JoinKeygen,
|
||||
BottomNavItem.CoSign,
|
||||
BottomNavItem.Settings
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun TssBottomNavigation(
|
||||
currentRoute: String,
|
||||
onNavigate: (BottomNavItem) -> Unit
|
||||
) {
|
||||
NavigationBar {
|
||||
bottomNavItems.forEach { item ->
|
||||
val selected = currentRoute == item.route ||
|
||||
(item == BottomNavItem.Wallets && currentRoute.startsWith("wallet_detail"))
|
||||
|
||||
NavigationBarItem(
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = if (selected) item.selectedIcon else item.unselectedIcon,
|
||||
contentDescription = item.title
|
||||
)
|
||||
},
|
||||
label = { Text(item.title) },
|
||||
selected = selected,
|
||||
onClick = { onNavigate(item) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,258 @@
|
|||
package com.durian.tssparty.presentation.screens
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.durian.tssparty.domain.model.GreenPointsToken
|
||||
import com.durian.tssparty.domain.model.ShareRecord
|
||||
import com.durian.tssparty.domain.model.WalletBalance
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun HomeScreen(
|
||||
shares: List<ShareRecord>,
|
||||
walletBalances: Map<String, WalletBalance>,
|
||||
isConnected: Boolean,
|
||||
onNavigateToJoin: () -> Unit,
|
||||
onNavigateToSign: (Long) -> Unit,
|
||||
onNavigateToSettings: () -> Unit,
|
||||
onDeleteShare: (Long) -> Unit,
|
||||
onRefreshBalances: () -> Unit = {}
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("TSS Party") },
|
||||
actions = {
|
||||
// Refresh button
|
||||
IconButton(onClick = onRefreshBalances) {
|
||||
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
|
||||
}
|
||||
// Connection status indicator
|
||||
Icon(
|
||||
imageVector = if (isConnected) Icons.Default.CheckCircle else Icons.Default.Warning,
|
||||
contentDescription = if (isConnected) "Connected" else "Disconnected",
|
||||
tint = if (isConnected) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.error
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
IconButton(onClick = onNavigateToSettings) {
|
||||
Icon(Icons.Default.Settings, contentDescription = "Settings")
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(onClick = onNavigateToJoin) {
|
||||
Icon(Icons.Default.Add, contentDescription = "Join Session")
|
||||
}
|
||||
}
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "My Wallets",
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
if (shares.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.AccountBalanceWallet,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(64.dp),
|
||||
tint = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = "No wallets yet",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Tap + to join a keygen session",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
items(shares) { share ->
|
||||
WalletCard(
|
||||
share = share,
|
||||
walletBalance = walletBalances[share.address],
|
||||
onSign = { onNavigateToSign(share.id) },
|
||||
onDelete = { onDeleteShare(share.id) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun WalletCard(
|
||||
share: ShareRecord,
|
||||
walletBalance: WalletBalance?,
|
||||
onSign: () -> Unit,
|
||||
onDelete: () -> Unit
|
||||
) {
|
||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "Address",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
Text(
|
||||
text = share.address,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Balance section
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
// KAVA balance
|
||||
Column {
|
||||
Text(
|
||||
text = "KAVA",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
Text(
|
||||
text = walletBalance?.kavaBalance ?: "Loading...",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
// Green Points balance
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
Text(
|
||||
text = GreenPointsToken.NAME,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
Text(
|
||||
text = walletBalance?.greenPointsBalance ?: "Loading...",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = "${share.thresholdT}-of-${share.thresholdN}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
Text(
|
||||
text = "Party #${share.partyIndex}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
TextButton(onClick = { showDeleteDialog = true }) {
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text("Delete")
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Button(onClick = onSign) {
|
||||
Icon(
|
||||
Icons.Default.Edit,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text("Sign")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showDeleteDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showDeleteDialog = false },
|
||||
title = { Text("Delete Wallet") },
|
||||
text = { Text("Are you sure you want to delete this wallet? This action cannot be undone.") },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
onDelete()
|
||||
showDeleteDialog = false
|
||||
}
|
||||
) {
|
||||
Text("Delete", color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showDeleteDialog = false }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,847 @@
|
|||
package com.durian.tssparty.presentation.screens
|
||||
|
||||
import android.app.Activity
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.durian.tssparty.domain.model.SessionStatus
|
||||
import com.journeyapps.barcodescanner.ScanContract
|
||||
import com.journeyapps.barcodescanner.ScanOptions
|
||||
|
||||
/**
|
||||
* Session info returned from validateInviteCode API
|
||||
* Matches service-party-app SessionInfo type
|
||||
*/
|
||||
data class JoinSessionInfo(
|
||||
val sessionId: String,
|
||||
val walletName: String,
|
||||
val thresholdT: Int,
|
||||
val thresholdN: Int,
|
||||
val initiator: String,
|
||||
val currentParticipants: Int,
|
||||
val totalParticipants: Int
|
||||
)
|
||||
|
||||
/**
|
||||
* Format countdown seconds to mm:ss display
|
||||
*/
|
||||
private fun formatCountdown(seconds: Long): String {
|
||||
if (seconds < 0) return ""
|
||||
val minutes = seconds / 60
|
||||
val secs = seconds % 60
|
||||
return "%d:%02d".format(minutes, secs)
|
||||
}
|
||||
|
||||
/**
|
||||
* JoinKeygen screen matching service-party-app/src/renderer/src/pages/Join.tsx
|
||||
* Simplified flow without password: input → confirm → joining
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun JoinKeygenScreen(
|
||||
sessionStatus: SessionStatus,
|
||||
isLoading: Boolean,
|
||||
error: String?,
|
||||
sessionInfo: JoinSessionInfo? = null,
|
||||
participants: List<String> = emptyList(),
|
||||
currentRound: Int = 0,
|
||||
totalRounds: Int = 9,
|
||||
publicKey: String? = null,
|
||||
countdownSeconds: Long = -1L, // 5-minute countdown: -1 = not counting, >0 = remaining seconds
|
||||
onValidateInviteCode: (inviteCode: String) -> Unit,
|
||||
onJoinKeygen: (inviteCode: String, password: String) -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
onResetState: () -> Unit = {}, // Reset ViewModel state without navigating
|
||||
onBackToHome: () -> Unit = {}
|
||||
) {
|
||||
var inviteCode by remember { mutableStateOf("") }
|
||||
var validationError by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
// 3-step flow: input → confirm → joining
|
||||
var step by remember { mutableStateOf("input") }
|
||||
var autoJoinAttempted by remember { mutableStateOf(false) }
|
||||
|
||||
// Handle session info received (validation success)
|
||||
LaunchedEffect(sessionInfo) {
|
||||
if (sessionInfo != null && step == "input") {
|
||||
step = "confirm"
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-join when we have session info (password is empty string)
|
||||
LaunchedEffect(step, sessionInfo, autoJoinAttempted, isLoading) {
|
||||
if (step == "confirm" && sessionInfo != null && !autoJoinAttempted && !isLoading && error == null) {
|
||||
autoJoinAttempted = true
|
||||
step = "joining"
|
||||
onJoinKeygen(inviteCode, "") // Empty password
|
||||
}
|
||||
}
|
||||
|
||||
// Handle session status changes
|
||||
LaunchedEffect(sessionStatus) {
|
||||
when (sessionStatus) {
|
||||
SessionStatus.IN_PROGRESS -> {
|
||||
step = "progress"
|
||||
}
|
||||
SessionStatus.COMPLETED -> {
|
||||
step = "completed"
|
||||
}
|
||||
SessionStatus.FAILED -> {
|
||||
if (step == "joining") {
|
||||
step = "confirm"
|
||||
}
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
// Reset auto-join on error
|
||||
LaunchedEffect(error) {
|
||||
if (error != null && step == "joining") {
|
||||
step = "confirm"
|
||||
autoJoinAttempted = false
|
||||
}
|
||||
}
|
||||
|
||||
// Reset to input state (used by cancel buttons in confirm/joining/progress screens)
|
||||
// This resets UI state to input screen WITHOUT navigating away from JoinKeygen page
|
||||
val resetToInput: () -> Unit = {
|
||||
step = "input"
|
||||
inviteCode = ""
|
||||
validationError = null
|
||||
autoJoinAttempted = false
|
||||
onResetState() // Clear ViewModel state (sessionInfo, joinToken, etc.) without navigating
|
||||
}
|
||||
|
||||
when (step) {
|
||||
"input" -> InputScreen(
|
||||
inviteCode = inviteCode,
|
||||
isLoading = isLoading,
|
||||
error = error,
|
||||
validationError = validationError,
|
||||
onInviteCodeChange = { inviteCode = it },
|
||||
onValidateCode = {
|
||||
when {
|
||||
inviteCode.isBlank() -> validationError = "请输入邀请码"
|
||||
else -> {
|
||||
validationError = null
|
||||
onValidateInviteCode(inviteCode)
|
||||
}
|
||||
}
|
||||
},
|
||||
onCancel = onCancel // In input state, cancel navigates away
|
||||
)
|
||||
"confirm" -> ConfirmScreen(
|
||||
sessionInfo = sessionInfo,
|
||||
isLoading = isLoading,
|
||||
error = error,
|
||||
onBack = {
|
||||
step = "input"
|
||||
autoJoinAttempted = false
|
||||
},
|
||||
onRetry = {
|
||||
autoJoinAttempted = false
|
||||
},
|
||||
onCancel = resetToInput // Reset to input state, stay on page
|
||||
)
|
||||
"joining" -> JoiningScreen(
|
||||
countdownSeconds = countdownSeconds,
|
||||
onCancel = resetToInput
|
||||
) // Reset to input state, stay on page
|
||||
"progress" -> KeygenProgressScreen(
|
||||
sessionStatus = sessionStatus,
|
||||
participants = participants,
|
||||
currentRound = currentRound,
|
||||
totalRounds = totalRounds,
|
||||
countdownSeconds = countdownSeconds,
|
||||
onCancel = resetToInput // Reset to input state, stay on page
|
||||
)
|
||||
"completed" -> KeygenCompletedScreen(
|
||||
publicKey = publicKey,
|
||||
onBackToHome = onBackToHome
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun InputScreen(
|
||||
inviteCode: String,
|
||||
isLoading: Boolean,
|
||||
error: String?,
|
||||
validationError: String?,
|
||||
onInviteCodeChange: (String) -> Unit,
|
||||
onValidateCode: () -> Unit,
|
||||
onCancel: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
// QR Scanner launcher
|
||||
val scanLauncher = rememberLauncherForActivityResult(
|
||||
contract = ScanContract()
|
||||
) { result ->
|
||||
if (result.contents != null) {
|
||||
// Parse the scanned content (could be invite code or deep link)
|
||||
val scannedContent = result.contents
|
||||
val extractedCode = if (scannedContent.startsWith("tssparty://join/")) {
|
||||
scannedContent.removePrefix("tssparty://join/")
|
||||
} else {
|
||||
scannedContent
|
||||
}
|
||||
onInviteCodeChange(extractedCode)
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp)
|
||||
) {
|
||||
// Header
|
||||
Text(
|
||||
text = "加入共管钱包",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = "扫描二维码或输入邀请码加入多方钱包创建会话",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// Scan QR Button
|
||||
Card(
|
||||
onClick = {
|
||||
val options = ScanOptions().apply {
|
||||
setDesiredBarcodeFormats(ScanOptions.QR_CODE)
|
||||
setPrompt("扫描邀请二维码")
|
||||
setCameraId(0)
|
||||
setBeepEnabled(true)
|
||||
setBarcodeImageEnabled(false)
|
||||
setOrientationLocked(true)
|
||||
setCaptureActivity(PortraitCaptureActivity::class.java)
|
||||
}
|
||||
scanLauncher.launch(options)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(20.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.QrCodeScanner,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(32.dp),
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(
|
||||
text = "扫描二维码",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// Divider with "或"
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Divider(modifier = Modifier.weight(1f))
|
||||
Text(
|
||||
text = " 或 ",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Divider(modifier = Modifier.weight(1f))
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// Invite Code Input
|
||||
OutlinedTextField(
|
||||
value = inviteCode,
|
||||
onValueChange = onInviteCodeChange,
|
||||
label = { Text("邀请码") },
|
||||
placeholder = { Text("粘贴邀请码") },
|
||||
leadingIcon = {
|
||||
Icon(Icons.Default.Key, contentDescription = null)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
enabled = !isLoading
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Info card
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Info,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "请向会话发起者获取邀请二维码或邀请码",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Error display
|
||||
error?.let {
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Error,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = it,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
validationError?.let {
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Warning,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = it,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
// Buttons
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = onCancel,
|
||||
modifier = Modifier.weight(1f),
|
||||
enabled = !isLoading
|
||||
) {
|
||||
Text("取消")
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = onValidateCode,
|
||||
modifier = Modifier.weight(1f),
|
||||
enabled = !isLoading && inviteCode.isNotBlank()
|
||||
) {
|
||||
if (isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp),
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("验证中...")
|
||||
} else {
|
||||
Text("加入会话")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConfirmScreen(
|
||||
sessionInfo: JoinSessionInfo?,
|
||||
isLoading: Boolean,
|
||||
error: String?,
|
||||
onBack: () -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
onCancel: () -> Unit
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
// Header
|
||||
Text(
|
||||
text = "确认会话信息",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// Session info card
|
||||
if (sessionInfo != null) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
InfoRow("钱包名称", sessionInfo.walletName)
|
||||
Divider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
InfoRow("阈值设置", "${sessionInfo.thresholdT}-of-${sessionInfo.thresholdN}")
|
||||
Divider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
InfoRow("发起者", sessionInfo.initiator)
|
||||
Divider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
InfoRow("当前参与者", "${sessionInfo.currentParticipants} / ${sessionInfo.totalParticipants}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// Error or auto-joining state
|
||||
if (error != null) {
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Error,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = error,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = onCancel,
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text("取消")
|
||||
}
|
||||
Button(
|
||||
onClick = onRetry,
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text("重试")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Auto-joining state
|
||||
CircularProgressIndicator(modifier = Modifier.size(48.dp))
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = "正在自动加入会话...",
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// Cancel button during auto-join
|
||||
OutlinedButton(onClick = onCancel) {
|
||||
Icon(Icons.Default.Cancel, contentDescription = null)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("取消")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun JoiningScreen(
|
||||
countdownSeconds: Long = -1L,
|
||||
onCancel: () -> Unit
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(80.dp),
|
||||
strokeWidth = 6.dp
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
Text(
|
||||
text = "正在加入会话...",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = "请稍候,正在连接到其他参与者",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
// Countdown timer (if counting down)
|
||||
if (countdownSeconds > 0) {
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.tertiaryContainer
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Timer,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "等待启动: ${formatCountdown(countdownSeconds)}",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onTertiaryContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
// Cancel button
|
||||
OutlinedButton(onClick = onCancel) {
|
||||
Icon(Icons.Default.Cancel, contentDescription = null)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("取消")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun KeygenProgressScreen(
|
||||
sessionStatus: SessionStatus,
|
||||
participants: List<String>,
|
||||
currentRound: Int,
|
||||
totalRounds: Int,
|
||||
countdownSeconds: Long = -1L,
|
||||
onCancel: () -> Unit
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
// Header
|
||||
Text(
|
||||
text = "密钥生成中",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = "请保持应用在前台,直到密钥生成完成",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
// Progress indicator
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(80.dp),
|
||||
strokeWidth = 6.dp
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// Progress card
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = "协议进度",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
Text(
|
||||
text = "$currentRound / $totalRounds",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
LinearProgressIndicator(
|
||||
progress = if (totalRounds > 0) currentRound.toFloat() / totalRounds else 0f,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Countdown timer (if counting down - waiting for keygen to start)
|
||||
if (countdownSeconds > 0) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.tertiaryContainer
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Timer,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column {
|
||||
Text(
|
||||
text = "等待密钥生成启动",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onTertiaryContainer
|
||||
)
|
||||
Text(
|
||||
text = formatCountdown(countdownSeconds),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onTertiaryContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Participants card
|
||||
if (participants.isNotEmpty()) {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
text = "参与方 (${participants.size})",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
participants.forEach { participant ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Person,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = participant,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
// Cancel button
|
||||
OutlinedButton(onClick = onCancel) {
|
||||
Icon(Icons.Default.Cancel, contentDescription = null)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("取消")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun KeygenCompletedScreen(
|
||||
publicKey: String?,
|
||||
onBackToHome: () -> Unit
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
// Success icon
|
||||
Icon(
|
||||
Icons.Default.CheckCircle,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(80.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Text(
|
||||
text = "密钥生成成功!",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = "您的钱包已创建成功,可以在「我的钱包」中查看",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
// Public key info
|
||||
if (publicKey != null) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
text = "公钥",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "${publicKey.take(20)}...${publicKey.takeLast(20)}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
Button(
|
||||
onClick = onBackToHome,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Icon(Icons.Default.Home, contentDescription = null)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("返回首页")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InfoRow(label: String, value: String) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,229 @@
|
|||
package com.durian.tssparty.presentation.screens
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.durian.tssparty.domain.model.SessionStatus
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun JoinScreen(
|
||||
sessionStatus: SessionStatus,
|
||||
isLoading: Boolean,
|
||||
error: String?,
|
||||
onJoinKeygen: (inviteCode: String, password: String) -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
var inviteCode by remember { mutableStateOf("") }
|
||||
var password by remember { mutableStateOf("") }
|
||||
var confirmPassword by remember { mutableStateOf("") }
|
||||
var showPassword by remember { mutableStateOf(false) }
|
||||
var passwordError by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Join Keygen") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack, enabled = !isLoading) {
|
||||
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
// Instructions
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Info,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(
|
||||
text = "Enter the invite code shared by the session creator and set a password to protect your key share.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Invite code input
|
||||
OutlinedTextField(
|
||||
value = inviteCode,
|
||||
onValueChange = { inviteCode = it },
|
||||
label = { Text("Invite Code") },
|
||||
placeholder = { Text("session-id:join-token") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = !isLoading,
|
||||
singleLine = true,
|
||||
leadingIcon = {
|
||||
Icon(Icons.Default.QrCode, contentDescription = null)
|
||||
}
|
||||
)
|
||||
|
||||
// Password input
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = {
|
||||
password = it
|
||||
passwordError = null
|
||||
},
|
||||
label = { Text("Password") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = !isLoading,
|
||||
singleLine = true,
|
||||
visualTransformation = if (showPassword) VisualTransformation.None
|
||||
else PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
leadingIcon = {
|
||||
Icon(Icons.Default.Lock, contentDescription = null)
|
||||
},
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { showPassword = !showPassword }) {
|
||||
Icon(
|
||||
if (showPassword) Icons.Default.VisibilityOff
|
||||
else Icons.Default.Visibility,
|
||||
contentDescription = if (showPassword) "Hide password" else "Show password"
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Confirm password
|
||||
OutlinedTextField(
|
||||
value = confirmPassword,
|
||||
onValueChange = {
|
||||
confirmPassword = it
|
||||
passwordError = null
|
||||
},
|
||||
label = { Text("Confirm Password") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = !isLoading,
|
||||
singleLine = true,
|
||||
visualTransformation = if (showPassword) VisualTransformation.None
|
||||
else PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
leadingIcon = {
|
||||
Icon(Icons.Default.Lock, contentDescription = null)
|
||||
},
|
||||
isError = passwordError != null,
|
||||
supportingText = passwordError?.let { { Text(it) } }
|
||||
)
|
||||
|
||||
// Error message
|
||||
error?.let {
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Error,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onErrorContainer
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(
|
||||
text = it,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Progress indicator
|
||||
if (isLoading) {
|
||||
Card {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = when (sessionStatus) {
|
||||
SessionStatus.WAITING -> "Waiting for other parties..."
|
||||
SessionStatus.IN_PROGRESS -> "Generating keys..."
|
||||
SessionStatus.COMPLETED -> "Completed!"
|
||||
SessionStatus.FAILED -> "Failed"
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
// Action buttons
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
if (isLoading) {
|
||||
OutlinedButton(
|
||||
onClick = onCancel,
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text("Cancel")
|
||||
}
|
||||
} else {
|
||||
Button(
|
||||
onClick = {
|
||||
if (password != confirmPassword) {
|
||||
passwordError = "Passwords do not match"
|
||||
return@Button
|
||||
}
|
||||
if (password.length < 4) {
|
||||
passwordError = "Password must be at least 4 characters"
|
||||
return@Button
|
||||
}
|
||||
if (inviteCode.isBlank()) {
|
||||
return@Button
|
||||
}
|
||||
onJoinKeygen(inviteCode.trim(), password)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = inviteCode.isNotBlank() && password.isNotBlank() && confirmPassword.isNotBlank()
|
||||
) {
|
||||
Icon(Icons.Default.PlayArrow, contentDescription = null)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Join Keygen")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
package com.durian.tssparty.presentation.screens
|
||||
|
||||
import com.journeyapps.barcodescanner.CaptureActivity
|
||||
|
||||
/**
|
||||
* Portrait-only barcode capture activity
|
||||
* Used to force the QR scanner to use portrait orientation
|
||||
*/
|
||||
class PortraitCaptureActivity : CaptureActivity()
|
||||
|
|
@ -0,0 +1,542 @@
|
|||
package com.durian.tssparty.presentation.screens
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.durian.tssparty.BuildConfig
|
||||
import com.durian.tssparty.domain.model.AppSettings
|
||||
import com.durian.tssparty.domain.model.NetworkType
|
||||
|
||||
/**
|
||||
* Connection test result
|
||||
*/
|
||||
data class ConnectionTestResult(
|
||||
val success: Boolean,
|
||||
val message: String,
|
||||
val latency: Long? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* Settings screen matching service-party-app/src/pages/Settings.tsx
|
||||
* Full implementation with test connection buttons and Account Service URL
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
settings: AppSettings,
|
||||
isConnected: Boolean,
|
||||
messageRouterStatus: ConnectionTestResult? = null,
|
||||
accountServiceStatus: ConnectionTestResult? = null,
|
||||
kavaApiStatus: ConnectionTestResult? = null,
|
||||
onSaveSettings: (AppSettings) -> Unit,
|
||||
onTestMessageRouter: (String) -> Unit = {},
|
||||
onTestAccountService: (String) -> Unit = {},
|
||||
onTestKavaApi: (String) -> Unit = {}
|
||||
) {
|
||||
var messageRouterUrl by remember { mutableStateOf(settings.messageRouterUrl) }
|
||||
var accountServiceUrl by remember { mutableStateOf(settings.accountServiceUrl) }
|
||||
var kavaRpcUrl by remember { mutableStateOf(settings.kavaRpcUrl) }
|
||||
var networkType by remember { mutableStateOf(settings.networkType) }
|
||||
var hasChanges by remember { mutableStateOf(false) }
|
||||
|
||||
// Test connection states
|
||||
var isTestingMessageRouter by remember { mutableStateOf(false) }
|
||||
var isTestingAccountService by remember { mutableStateOf(false) }
|
||||
var isTestingKavaApi by remember { mutableStateOf(false) }
|
||||
|
||||
// Local test results (for display)
|
||||
var localMessageRouterResult by remember { mutableStateOf<ConnectionTestResult?>(null) }
|
||||
var localAccountServiceResult by remember { mutableStateOf<ConnectionTestResult?>(null) }
|
||||
var localKavaApiResult by remember { mutableStateOf<ConnectionTestResult?>(null) }
|
||||
|
||||
// Update local results when props change
|
||||
LaunchedEffect(messageRouterStatus) {
|
||||
if (messageRouterStatus != null) {
|
||||
localMessageRouterResult = messageRouterStatus
|
||||
isTestingMessageRouter = false
|
||||
}
|
||||
}
|
||||
LaunchedEffect(accountServiceStatus) {
|
||||
if (accountServiceStatus != null) {
|
||||
localAccountServiceResult = accountServiceStatus
|
||||
isTestingAccountService = false
|
||||
}
|
||||
}
|
||||
LaunchedEffect(kavaApiStatus) {
|
||||
if (kavaApiStatus != null) {
|
||||
localKavaApiResult = kavaApiStatus
|
||||
isTestingKavaApi = false
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp)
|
||||
) {
|
||||
// Header
|
||||
Text(
|
||||
text = "设置",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = "配置应用程序连接和网络设置",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// Connection status overview
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (isConnected)
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
else
|
||||
MaterialTheme.colorScheme.errorContainer
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
if (isConnected) Icons.Default.CheckCircle else Icons.Default.Warning,
|
||||
contentDescription = null,
|
||||
tint = if (isConnected)
|
||||
MaterialTheme.colorScheme.onPrimaryContainer
|
||||
else
|
||||
MaterialTheme.colorScheme.onErrorContainer
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column {
|
||||
Text(
|
||||
text = if (isConnected) "应用就绪" else "连接异常",
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = if (isConnected)
|
||||
MaterialTheme.colorScheme.onPrimaryContainer
|
||||
else
|
||||
MaterialTheme.colorScheme.onErrorContainer
|
||||
)
|
||||
Text(
|
||||
text = if (isConnected) "所有服务正常运行" else "请检查网络设置",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (isConnected)
|
||||
MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f)
|
||||
else
|
||||
MaterialTheme.colorScheme.onErrorContainer.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// Section: Connection Settings
|
||||
Text(
|
||||
text = "连接设置",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Message Router URL
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
text = "消息路由服务",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "TSS 多方计算消息中继服务器",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = messageRouterUrl,
|
||||
onValueChange = {
|
||||
messageRouterUrl = it
|
||||
hasChanges = true
|
||||
localMessageRouterResult = null
|
||||
},
|
||||
label = { Text("服务地址") },
|
||||
placeholder = { Text("mpc-grpc.szaiai.com:443") },
|
||||
modifier = Modifier.weight(1f),
|
||||
singleLine = true,
|
||||
leadingIcon = {
|
||||
Icon(Icons.Default.Cloud, contentDescription = null)
|
||||
}
|
||||
)
|
||||
Button(
|
||||
onClick = {
|
||||
isTestingMessageRouter = true
|
||||
localMessageRouterResult = null
|
||||
onTestMessageRouter(messageRouterUrl)
|
||||
},
|
||||
enabled = !isTestingMessageRouter && messageRouterUrl.isNotBlank()
|
||||
) {
|
||||
if (isTestingMessageRouter) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
} else {
|
||||
Text("测试")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test result
|
||||
localMessageRouterResult?.let { result ->
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
if (result.success) Icons.Default.CheckCircle else Icons.Default.Error,
|
||||
contentDescription = null,
|
||||
tint = if (result.success)
|
||||
MaterialTheme.colorScheme.primary
|
||||
else
|
||||
MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = result.message + (result.latency?.let { " (${it}ms)" } ?: ""),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (result.success)
|
||||
MaterialTheme.colorScheme.primary
|
||||
else
|
||||
MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Account Service URL
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
text = "账户服务",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "会话管理和账户 API 服务",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = accountServiceUrl,
|
||||
onValueChange = {
|
||||
accountServiceUrl = it
|
||||
hasChanges = true
|
||||
localAccountServiceResult = null
|
||||
},
|
||||
label = { Text("API 地址") },
|
||||
placeholder = { Text("https://rwaapi.szaiai.com") },
|
||||
modifier = Modifier.weight(1f),
|
||||
singleLine = true,
|
||||
leadingIcon = {
|
||||
Icon(Icons.Default.Api, contentDescription = null)
|
||||
}
|
||||
)
|
||||
Button(
|
||||
onClick = {
|
||||
isTestingAccountService = true
|
||||
localAccountServiceResult = null
|
||||
onTestAccountService(accountServiceUrl)
|
||||
},
|
||||
enabled = !isTestingAccountService && accountServiceUrl.isNotBlank()
|
||||
) {
|
||||
if (isTestingAccountService) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
} else {
|
||||
Text("测试")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test result
|
||||
localAccountServiceResult?.let { result ->
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
if (result.success) Icons.Default.CheckCircle else Icons.Default.Error,
|
||||
contentDescription = null,
|
||||
tint = if (result.success)
|
||||
MaterialTheme.colorScheme.primary
|
||||
else
|
||||
MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = result.message + (result.latency?.let { " (${it}ms)" } ?: ""),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (result.success)
|
||||
MaterialTheme.colorScheme.primary
|
||||
else
|
||||
MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// Section: Blockchain Network
|
||||
Text(
|
||||
text = "区块链网络",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = "选择要连接的 Kava 区块链网络",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
FilterChip(
|
||||
selected = networkType == NetworkType.MAINNET,
|
||||
onClick = {
|
||||
networkType = NetworkType.MAINNET
|
||||
kavaRpcUrl = "https://evm.kava.io"
|
||||
hasChanges = true
|
||||
localKavaApiResult = null
|
||||
},
|
||||
label = { Text("主网 (Kava)") },
|
||||
leadingIcon = if (networkType == NetworkType.MAINNET) {
|
||||
{ Icon(Icons.Default.Check, contentDescription = null, Modifier.size(18.dp)) }
|
||||
} else null
|
||||
)
|
||||
FilterChip(
|
||||
selected = networkType == NetworkType.TESTNET,
|
||||
onClick = {
|
||||
networkType = NetworkType.TESTNET
|
||||
kavaRpcUrl = "https://evm.testnet.kava.io"
|
||||
hasChanges = true
|
||||
localKavaApiResult = null
|
||||
},
|
||||
label = { Text("测试网") },
|
||||
leadingIcon = if (networkType == NetworkType.TESTNET) {
|
||||
{ Icon(Icons.Default.Check, contentDescription = null, Modifier.size(18.dp)) }
|
||||
} else null
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Kava RPC URL
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
text = "Kava RPC 节点",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "区块链交易和查询 API",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = kavaRpcUrl,
|
||||
onValueChange = {
|
||||
kavaRpcUrl = it
|
||||
hasChanges = true
|
||||
localKavaApiResult = null
|
||||
},
|
||||
label = { Text("RPC 地址") },
|
||||
placeholder = { Text("https://evm.kava.io") },
|
||||
modifier = Modifier.weight(1f),
|
||||
singleLine = true,
|
||||
leadingIcon = {
|
||||
Icon(Icons.Default.Link, contentDescription = null)
|
||||
}
|
||||
)
|
||||
Button(
|
||||
onClick = {
|
||||
isTestingKavaApi = true
|
||||
localKavaApiResult = null
|
||||
onTestKavaApi(kavaRpcUrl)
|
||||
},
|
||||
enabled = !isTestingKavaApi && kavaRpcUrl.isNotBlank()
|
||||
) {
|
||||
if (isTestingKavaApi) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
} else {
|
||||
Text("测试")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test result
|
||||
localKavaApiResult?.let { result ->
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
if (result.success) Icons.Default.CheckCircle else Icons.Default.Error,
|
||||
contentDescription = null,
|
||||
tint = if (result.success)
|
||||
MaterialTheme.colorScheme.primary
|
||||
else
|
||||
MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = result.message + (result.latency?.let { " (${it}ms)" } ?: ""),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (result.success)
|
||||
MaterialTheme.colorScheme.primary
|
||||
else
|
||||
MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// Section: About
|
||||
Text(
|
||||
text = "关于",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
AboutRow("应用名称", "TSS Party")
|
||||
Divider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
AboutRow("版本", BuildConfig.VERSION_NAME)
|
||||
Divider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
AboutRow("版本号", BuildConfig.VERSION_CODE.toString())
|
||||
Divider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
AboutRow("构建类型", if (BuildConfig.DEBUG) "Debug" else "Release")
|
||||
Divider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
AboutRow("TSS 协议", "GG20")
|
||||
Divider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
AboutRow("区块链", "Kava EVM")
|
||||
Divider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
AboutRow("项目", "RWADurian MPC System")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
// Save button
|
||||
Button(
|
||||
onClick = {
|
||||
onSaveSettings(
|
||||
AppSettings(
|
||||
messageRouterUrl = messageRouterUrl,
|
||||
accountServiceUrl = accountServiceUrl,
|
||||
kavaRpcUrl = kavaRpcUrl,
|
||||
networkType = networkType
|
||||
)
|
||||
)
|
||||
hasChanges = false
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(56.dp),
|
||||
enabled = hasChanges
|
||||
) {
|
||||
Icon(Icons.Default.Save, contentDescription = null)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("保存设置")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AboutRow(label: String, value: String) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,199 @@
|
|||
package com.durian.tssparty.presentation.screens
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.durian.tssparty.domain.model.SessionStatus
|
||||
import com.durian.tssparty.domain.model.ShareRecord
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SignScreen(
|
||||
share: ShareRecord?,
|
||||
sessionStatus: SessionStatus,
|
||||
isLoading: Boolean,
|
||||
error: String?,
|
||||
onJoinSign: (inviteCode: String, shareId: Long, password: String) -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
var inviteCode by remember { mutableStateOf("") }
|
||||
var password by remember { mutableStateOf("") }
|
||||
var showPassword by remember { mutableStateOf(false) }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Sign Transaction") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack, enabled = !isLoading) {
|
||||
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
// Wallet info
|
||||
share?.let { s ->
|
||||
Card {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
text = "Signing with wallet",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = s.address,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "${s.thresholdT}-of-${s.thresholdN} • Party #${s.partyIndex}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Invite code input
|
||||
OutlinedTextField(
|
||||
value = inviteCode,
|
||||
onValueChange = { inviteCode = it },
|
||||
label = { Text("Sign Session Code") },
|
||||
placeholder = { Text("session-id:join-token") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = !isLoading,
|
||||
singleLine = true,
|
||||
leadingIcon = {
|
||||
Icon(Icons.Default.QrCode, contentDescription = null)
|
||||
}
|
||||
)
|
||||
|
||||
// Password input
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
label = { Text("Password") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = !isLoading,
|
||||
singleLine = true,
|
||||
visualTransformation = if (showPassword) VisualTransformation.None
|
||||
else PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
leadingIcon = {
|
||||
Icon(Icons.Default.Lock, contentDescription = null)
|
||||
},
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { showPassword = !showPassword }) {
|
||||
Icon(
|
||||
if (showPassword) Icons.Default.VisibilityOff
|
||||
else Icons.Default.Visibility,
|
||||
contentDescription = if (showPassword) "Hide password" else "Show password"
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Error message
|
||||
error?.let {
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Error,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onErrorContainer
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(
|
||||
text = it,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Progress indicator
|
||||
if (isLoading) {
|
||||
Card {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = when (sessionStatus) {
|
||||
SessionStatus.WAITING -> "Waiting for other parties..."
|
||||
SessionStatus.IN_PROGRESS -> "Signing in progress..."
|
||||
SessionStatus.COMPLETED -> "Signed successfully!"
|
||||
SessionStatus.FAILED -> "Signing failed"
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
// Action buttons
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
if (isLoading) {
|
||||
OutlinedButton(
|
||||
onClick = onCancel,
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text("Cancel")
|
||||
}
|
||||
} else {
|
||||
Button(
|
||||
onClick = {
|
||||
share?.let {
|
||||
onJoinSign(inviteCode.trim(), it.id, password)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = share != null && inviteCode.isNotBlank() && password.isNotBlank()
|
||||
) {
|
||||
Icon(Icons.Default.Edit, contentDescription = null)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Sign")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,273 @@
|
|||
package com.durian.tssparty.presentation.screens
|
||||
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.durian.tssparty.domain.model.AppReadyState
|
||||
import com.durian.tssparty.domain.model.AppState
|
||||
import com.durian.tssparty.domain.model.ServiceStatus
|
||||
|
||||
@Composable
|
||||
fun StartupCheckScreen(
|
||||
appState: AppState,
|
||||
onEnterApp: () -> Unit,
|
||||
onRetry: () -> Unit
|
||||
) {
|
||||
val canEnter = appState.appReady == AppReadyState.READY || appState.appReady == AppReadyState.ERROR
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
// App Logo/Icon
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(100.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primaryContainer),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Lock,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(50.dp),
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// App Title
|
||||
Text(
|
||||
text = "TSS Party",
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "多方安全计算钱包",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(48.dp))
|
||||
|
||||
// Service Check Cards
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "服务状态",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Database Status
|
||||
ServiceCheckItem(
|
||||
icon = Icons.Default.Storage,
|
||||
title = "本地数据库",
|
||||
status = appState.environment.database,
|
||||
extraInfo = if (appState.walletCount > 0) "${appState.walletCount} 个钱包" else null
|
||||
)
|
||||
|
||||
Divider(modifier = Modifier.padding(vertical = 12.dp))
|
||||
|
||||
// Message Router Status
|
||||
ServiceCheckItem(
|
||||
icon = Icons.Default.Cloud,
|
||||
title = "消息路由服务",
|
||||
status = appState.environment.messageRouter,
|
||||
extraInfo = appState.partyId?.take(8)?.let { "Party: $it..." }
|
||||
)
|
||||
|
||||
Divider(modifier = Modifier.padding(vertical = 12.dp))
|
||||
|
||||
// Kava API Status
|
||||
ServiceCheckItem(
|
||||
icon = Icons.Default.Language,
|
||||
title = "Kava 区块链",
|
||||
status = appState.environment.kavaApi,
|
||||
extraInfo = appState.environment.kavaApi.latency?.let { "${it}ms" }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
// Status Message
|
||||
when (appState.appReady) {
|
||||
AppReadyState.INITIALIZING -> {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(20.dp),
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(
|
||||
text = "正在检查服务...",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
AppReadyState.READY -> {
|
||||
Text(
|
||||
text = "所有服务就绪",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
AppReadyState.ERROR -> {
|
||||
Text(
|
||||
text = appState.appError ?: "部分服务不可用",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
// Action Buttons
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
if (appState.appReady == AppReadyState.ERROR) {
|
||||
OutlinedButton(
|
||||
onClick = onRetry,
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Refresh,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("重试")
|
||||
}
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = onEnterApp,
|
||||
enabled = canEnter,
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text(
|
||||
text = when (appState.appReady) {
|
||||
AppReadyState.READY -> "进入应用"
|
||||
AppReadyState.ERROR -> "继续使用"
|
||||
else -> "加载中..."
|
||||
}
|
||||
)
|
||||
if (canEnter) {
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Icon(
|
||||
Icons.Default.ArrowForward,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ServiceCheckItem(
|
||||
icon: ImageVector,
|
||||
title: String,
|
||||
status: ServiceStatus,
|
||||
extraInfo: String? = null
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Icon with status indicator
|
||||
Box {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(32.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
// Status dot
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(12.dp)
|
||||
.align(Alignment.BottomEnd)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
when {
|
||||
status.message.isEmpty() -> Color.Gray
|
||||
status.isOnline -> Color(0xFFD4AF37) // Gold for success
|
||||
else -> Color(0xFFFF5722)
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
if (status.message.isNotEmpty()) {
|
||||
Text(
|
||||
text = status.message,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (status.isOnline)
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
else
|
||||
MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Extra info (wallet count, latency, etc.)
|
||||
extraInfo?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,716 @@
|
|||
package com.durian.tssparty.presentation.screens
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
// Note: Some unused imports kept for ExportBackupDialog which still uses password
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import com.durian.tssparty.domain.model.GreenPointsToken
|
||||
import com.durian.tssparty.domain.model.NetworkType
|
||||
import com.durian.tssparty.domain.model.ShareRecord
|
||||
import com.durian.tssparty.domain.model.WalletBalance
|
||||
import com.google.zxing.BarcodeFormat
|
||||
import com.google.zxing.qrcode.QRCodeWriter
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun WalletsScreen(
|
||||
shares: List<ShareRecord>,
|
||||
isConnected: Boolean,
|
||||
balances: Map<String, String> = emptyMap(),
|
||||
walletBalances: Map<String, WalletBalance> = emptyMap(),
|
||||
networkType: NetworkType = NetworkType.MAINNET,
|
||||
onDeleteShare: (Long) -> Unit,
|
||||
onRefreshBalance: ((String) -> Unit)? = null,
|
||||
onTransfer: ((shareId: Long) -> Unit)? = null,
|
||||
onExportBackup: ((shareId: Long, password: String) -> Unit)? = null,
|
||||
onImportBackup: (() -> Unit)? = null,
|
||||
onCreateWallet: (() -> Unit)? = null
|
||||
) {
|
||||
var selectedWallet by remember { mutableStateOf<ShareRecord?>(null) }
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
// Header with connection status
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "我的钱包",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
// Connection status
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (isConnected) Icons.Default.CheckCircle else Icons.Default.Warning,
|
||||
contentDescription = if (isConnected) "已连接" else "未连接",
|
||||
tint = if (isConnected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = if (isConnected) "已连接" else "离线",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (isConnected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = "共 ${shares.size} 个钱包",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
if (shares.isEmpty()) {
|
||||
// Empty state
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.AccountBalanceWallet,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(80.dp),
|
||||
tint = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = "暂无钱包",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "使用「创建钱包」发起新钱包",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
Text(
|
||||
text = "或使用「加入创建」参与他人的会话",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Wallet list
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
contentPadding = PaddingValues(bottom = 80.dp) // Space for FAB
|
||||
) {
|
||||
items(shares) { share ->
|
||||
WalletItemCard(
|
||||
share = share,
|
||||
balance = balances[share.address],
|
||||
walletBalance = walletBalances[share.address],
|
||||
onViewDetails = { selectedWallet = share },
|
||||
onTransfer = {
|
||||
onTransfer?.invoke(share.id)
|
||||
},
|
||||
onDelete = { onDeleteShare(share.id) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Floating Action Buttons - Import and Create
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
// Import button (smaller, secondary)
|
||||
if (onImportBackup != null) {
|
||||
FloatingActionButton(
|
||||
onClick = onImportBackup,
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Upload,
|
||||
contentDescription = "导入备份"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Create wallet button (primary)
|
||||
if (onCreateWallet != null) {
|
||||
FloatingActionButton(
|
||||
onClick = onCreateWallet,
|
||||
containerColor = MaterialTheme.colorScheme.primary
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Add,
|
||||
contentDescription = "创建钱包",
|
||||
tint = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Wallet detail dialog
|
||||
selectedWallet?.let { wallet ->
|
||||
WalletDetailDialog(
|
||||
wallet = wallet,
|
||||
networkType = networkType,
|
||||
onDismiss = { selectedWallet = null },
|
||||
onTransfer = {
|
||||
selectedWallet = null
|
||||
onTransfer?.invoke(wallet.id)
|
||||
},
|
||||
onExport = onExportBackup?.let { export ->
|
||||
{ password -> export(wallet.id, password) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WalletItemCard(
|
||||
share: ShareRecord,
|
||||
balance: String? = null,
|
||||
walletBalance: WalletBalance? = null,
|
||||
onViewDetails: () -> Unit,
|
||||
onTransfer: () -> Unit,
|
||||
onDelete: () -> Unit
|
||||
) {
|
||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onViewDetails() }
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
// Header with threshold badge
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Threshold badge
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
shape = RoundedCornerShape(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "${share.thresholdT}-of-${share.thresholdN}",
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
|
||||
// Party index
|
||||
Text(
|
||||
text = "参与者 #${share.partyIndex}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Address
|
||||
Text(
|
||||
text = "地址",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
Text(
|
||||
text = share.address,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
fontFamily = FontFamily.Monospace
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Balance display - now shows both KAVA and Green Points
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
// KAVA balance
|
||||
Column {
|
||||
Text(
|
||||
text = "KAVA",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
Icons.Default.AccountBalance,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = walletBalance?.kavaBalance ?: balance ?: "加载中...",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (walletBalance != null || balance != null)
|
||||
MaterialTheme.colorScheme.primary
|
||||
else
|
||||
MaterialTheme.colorScheme.outline,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Green Points (绿积分) balance
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
Text(
|
||||
text = GreenPointsToken.NAME,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
Icons.Default.Stars,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = Color(0xFF4CAF50) // Green color for Green Points
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = walletBalance?.greenPointsBalance ?: "加载中...",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (walletBalance != null)
|
||||
Color(0xFF4CAF50)
|
||||
else
|
||||
MaterialTheme.colorScheme.outline,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Divider()
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Actions
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
TextButton(onClick = onViewDetails) {
|
||||
Icon(
|
||||
Icons.Default.QrCode,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text("详情")
|
||||
}
|
||||
|
||||
TextButton(onClick = onTransfer) {
|
||||
Icon(
|
||||
Icons.Default.Send,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text("转账")
|
||||
}
|
||||
|
||||
TextButton(
|
||||
onClick = { showDeleteDialog = true },
|
||||
colors = ButtonDefaults.textButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.error
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text("删除")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete confirmation dialog
|
||||
if (showDeleteDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showDeleteDialog = false },
|
||||
icon = {
|
||||
Icon(
|
||||
Icons.Default.Warning,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
},
|
||||
title = { Text("删除钱包") },
|
||||
text = {
|
||||
Text("确定要删除这个钱包吗?此操作无法撤销,删除后您将无法使用此密钥份额参与签名。")
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
onDelete()
|
||||
showDeleteDialog = false
|
||||
},
|
||||
colors = ButtonDefaults.textButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.error
|
||||
)
|
||||
) {
|
||||
Text("删除")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showDeleteDialog = false }) {
|
||||
Text("取消")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WalletDetailDialog(
|
||||
wallet: ShareRecord,
|
||||
networkType: NetworkType = NetworkType.MAINNET,
|
||||
onDismiss: () -> Unit,
|
||||
onTransfer: () -> Unit,
|
||||
onExport: ((String) -> Unit)?
|
||||
) {
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
var showExportDialog by remember { mutableStateOf(false) }
|
||||
var copySuccess by remember { mutableStateOf(false) }
|
||||
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
// QR Code
|
||||
val qrBitmap = remember(wallet.address) {
|
||||
generateQRCode(wallet.address, 240)
|
||||
}
|
||||
qrBitmap?.let { bitmap ->
|
||||
Image(
|
||||
bitmap = bitmap.asImageBitmap(),
|
||||
contentDescription = "QR Code",
|
||||
modifier = Modifier
|
||||
.size(200.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(Color.White)
|
||||
.padding(8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Threshold badge
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "${wallet.thresholdT}-of-${wallet.thresholdN} 多签钱包",
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Address
|
||||
Text(
|
||||
text = "Kava EVM 地址",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = wallet.address,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Action buttons row
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
// Copy button
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
clipboardManager.setText(AnnotatedString(wallet.address))
|
||||
copySuccess = true
|
||||
scope.launch {
|
||||
delay(2000)
|
||||
copySuccess = false
|
||||
}
|
||||
},
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Icon(
|
||||
if (copySuccess) Icons.Default.Check else Icons.Default.ContentCopy,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(if (copySuccess) "已复制" else "复制地址")
|
||||
}
|
||||
|
||||
// Explorer button
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
val baseUrl = if (networkType == NetworkType.TESTNET) {
|
||||
"https://testnet.kavascan.com"
|
||||
} else {
|
||||
"https://kavascan.com"
|
||||
}
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("$baseUrl/address/${wallet.address}"))
|
||||
context.startActivity(intent)
|
||||
},
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.OpenInNew,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text("浏览器")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Divider()
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Info rows
|
||||
InfoRow("门限设置", "${wallet.thresholdT}-of-${wallet.thresholdN}")
|
||||
InfoRow("您的序号", "#${wallet.partyIndex}")
|
||||
InfoRow("会话ID", wallet.sessionId.take(16) + "...")
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// Action buttons
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = onTransfer,
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Send,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text("转账")
|
||||
}
|
||||
|
||||
if (onExport != null) {
|
||||
OutlinedButton(
|
||||
onClick = { showExportDialog = true },
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Download,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text("导出")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("关闭")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export dialog
|
||||
if (showExportDialog && onExport != null) {
|
||||
ExportBackupDialog(
|
||||
onDismiss = { showExportDialog = false },
|
||||
onConfirm = { password ->
|
||||
onExport(password)
|
||||
showExportDialog = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InfoRow(label: String, value: String) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ExportBackupDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (password: String) -> Unit
|
||||
) {
|
||||
var password by remember { mutableStateOf("") }
|
||||
var showPassword by remember { mutableStateOf(false) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
icon = {
|
||||
Icon(Icons.Default.Download, contentDescription = null)
|
||||
},
|
||||
title = { Text("导出备份") },
|
||||
text = {
|
||||
Column {
|
||||
Text("导出加密备份文件,请妥善保管。")
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
label = { Text("密码") },
|
||||
singleLine = true,
|
||||
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { showPassword = !showPassword }) {
|
||||
Icon(
|
||||
if (showPassword) Icons.Default.VisibilityOff else Icons.Default.Visibility,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = { onConfirm(password) },
|
||||
enabled = password.isNotBlank()
|
||||
) {
|
||||
Text("导出")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("取消")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate QR code bitmap
|
||||
*/
|
||||
private fun generateQRCode(content: String, size: Int): Bitmap? {
|
||||
return try {
|
||||
val writer = QRCodeWriter()
|
||||
val bitMatrix = writer.encode(content, BarcodeFormat.QR_CODE, size, size)
|
||||
val width = bitMatrix.width
|
||||
val height = bitMatrix.height
|
||||
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.RGB_565)
|
||||
for (x in 0 until width) {
|
||||
for (y in 0 until height) {
|
||||
bitmap.setPixel(x, y, if (bitMatrix[x, y]) android.graphics.Color.BLACK else android.graphics.Color.WHITE)
|
||||
}
|
||||
}
|
||||
bitmap
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,100 @@
|
|||
package com.durian.tssparty.ui.theme
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.core.view.WindowCompat
|
||||
|
||||
// Dark Gray & Gold Theme Colors
|
||||
private val Gold = Color(0xFFD4AF37) // Classic gold
|
||||
private val GoldLight = Color(0xFFFFD966) // Light gold
|
||||
private val GoldDark = Color(0xFFB8960C) // Dark gold
|
||||
private val DarkGray = Color(0xFF1A1A1A) // Deep dark gray
|
||||
private val MediumGray = Color(0xFF2D2D2D) // Medium dark gray
|
||||
private val LightGray = Color(0xFF3D3D3D) // Lighter gray for surfaces
|
||||
private val TextGray = Color(0xFFB0B0B0) // Gray for secondary text
|
||||
|
||||
private val DarkColorScheme = darkColorScheme(
|
||||
primary = Gold,
|
||||
onPrimary = Color.Black,
|
||||
primaryContainer = GoldDark,
|
||||
onPrimaryContainer = Color.White,
|
||||
secondary = GoldLight,
|
||||
onSecondary = Color.Black,
|
||||
secondaryContainer = LightGray,
|
||||
onSecondaryContainer = GoldLight,
|
||||
tertiary = GoldLight,
|
||||
onTertiary = Color.Black,
|
||||
background = DarkGray,
|
||||
onBackground = Color.White,
|
||||
surface = MediumGray,
|
||||
onSurface = Color.White,
|
||||
surfaceVariant = LightGray,
|
||||
onSurfaceVariant = TextGray,
|
||||
outline = Color(0xFF5A5A5A),
|
||||
outlineVariant = Color(0xFF404040),
|
||||
error = Color(0xFFCF6679),
|
||||
onError = Color.Black
|
||||
)
|
||||
|
||||
private val LightColorScheme = lightColorScheme(
|
||||
primary = GoldDark,
|
||||
onPrimary = Color.White,
|
||||
primaryContainer = GoldLight,
|
||||
onPrimaryContainer = Color.Black,
|
||||
secondary = Gold,
|
||||
onSecondary = Color.Black,
|
||||
secondaryContainer = Color(0xFFFFF3CD),
|
||||
onSecondaryContainer = GoldDark,
|
||||
tertiary = GoldDark,
|
||||
onTertiary = Color.White,
|
||||
background = Color(0xFFF5F5F5),
|
||||
onBackground = Color(0xFF2D2D2D),
|
||||
surface = Color.White,
|
||||
onSurface = Color(0xFF2D2D2D),
|
||||
surfaceVariant = Color(0xFFE8E8E8),
|
||||
onSurfaceVariant = Color(0xFF5A5A5A),
|
||||
outline = Color(0xFFB0B0B0),
|
||||
outlineVariant = Color(0xFFD0D0D0),
|
||||
error = Color(0xFFB00020),
|
||||
onError = Color.White
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun TssPartyTheme(
|
||||
darkTheme: Boolean = true, // Default to dark theme for dark gray & gold look
|
||||
dynamicColor: Boolean = false, // Disable dynamic colors to use our custom theme
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val colorScheme = when {
|
||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
val context = LocalContext.current
|
||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
}
|
||||
darkTheme -> DarkColorScheme
|
||||
else -> LightColorScheme
|
||||
}
|
||||
|
||||
val view = LocalView.current
|
||||
if (!view.isInEditMode) {
|
||||
SideEffect {
|
||||
val window = (view.context as Activity).window
|
||||
// Use dark background color for status bar to match the dark theme
|
||||
window.statusBarColor = colorScheme.background.toArgb()
|
||||
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
|
||||
}
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
package com.durian.tssparty.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
val Typography = Typography(
|
||||
bodyLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
),
|
||||
titleLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 22.sp,
|
||||
lineHeight = 28.sp,
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
labelSmall = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
)
|
||||
)
|
||||
|
|
@ -0,0 +1,207 @@
|
|||
package com.durian.tssparty.util
|
||||
|
||||
import org.bouncycastle.jcajce.provider.digest.Keccak
|
||||
import org.bouncycastle.jcajce.provider.digest.RIPEMD160
|
||||
import org.bouncycastle.jcajce.provider.digest.SHA256
|
||||
import java.security.MessageDigest
|
||||
|
||||
/**
|
||||
* Utility functions for address derivation
|
||||
*/
|
||||
object AddressUtils {
|
||||
|
||||
/**
|
||||
* Derive Kava address from compressed public key
|
||||
* Kava uses Bech32 with "kava" prefix
|
||||
*/
|
||||
fun deriveKavaAddress(compressedPubKey: ByteArray): String {
|
||||
// 1. Decompress public key if compressed (33 bytes -> 65 bytes)
|
||||
val uncompressedPubKey = if (compressedPubKey.size == 33) {
|
||||
decompressPublicKey(compressedPubKey)
|
||||
} else {
|
||||
compressedPubKey
|
||||
}
|
||||
|
||||
// 2. For Cosmos/Kava: SHA256 -> RIPEMD160
|
||||
val sha256 = SHA256.Digest().digest(compressedPubKey)
|
||||
val ripemd160 = RIPEMD160.Digest().digest(sha256)
|
||||
|
||||
// 3. Bech32 encode with "kava" prefix
|
||||
return Bech32.encode("kava", convertBits(ripemd160, 8, 5, true))
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if address is in EVM format (0x...)
|
||||
*/
|
||||
fun isEvmAddress(address: String): Boolean {
|
||||
return address.startsWith("0x") && address.length == 42
|
||||
}
|
||||
|
||||
/**
|
||||
* Get EVM address - either returns the address if already EVM format,
|
||||
* or derives it from the public key
|
||||
*/
|
||||
fun getEvmAddress(address: String, publicKeyBase64: String): String {
|
||||
return if (isEvmAddress(address)) {
|
||||
address
|
||||
} else {
|
||||
// Derive EVM address from public key
|
||||
val publicKeyBytes = android.util.Base64.decode(publicKeyBase64, android.util.Base64.NO_WRAP)
|
||||
deriveEvmAddress(publicKeyBytes)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive EVM address from public key (for Kava EVM compatibility)
|
||||
*/
|
||||
fun deriveEvmAddress(compressedPubKey: ByteArray): String {
|
||||
// 1. Decompress if needed
|
||||
val uncompressedPubKey = if (compressedPubKey.size == 33) {
|
||||
decompressPublicKey(compressedPubKey)
|
||||
} else {
|
||||
compressedPubKey
|
||||
}
|
||||
|
||||
// 2. Take last 64 bytes (remove 0x04 prefix)
|
||||
val pubKeyNoPrefix = if (uncompressedPubKey.size == 65) {
|
||||
uncompressedPubKey.sliceArray(1..64)
|
||||
} else {
|
||||
uncompressedPubKey
|
||||
}
|
||||
|
||||
// 3. Keccak256 hash
|
||||
val keccak = Keccak.Digest256().digest(pubKeyNoPrefix)
|
||||
|
||||
// 4. Take last 20 bytes
|
||||
val addressBytes = keccak.sliceArray(12..31)
|
||||
|
||||
// 5. Hex encode with 0x prefix
|
||||
return "0x" + addressBytes.toHexString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Decompress a compressed secp256k1 public key
|
||||
*/
|
||||
private fun decompressPublicKey(compressed: ByteArray): ByteArray {
|
||||
require(compressed.size == 33) { "Invalid compressed public key size" }
|
||||
|
||||
val prefix = compressed[0].toInt() and 0xFF
|
||||
require(prefix == 0x02 || prefix == 0x03) { "Invalid compression prefix" }
|
||||
|
||||
val x = compressed.sliceArray(1..32)
|
||||
|
||||
// secp256k1 curve parameters
|
||||
val p = "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F".toBigInteger(16)
|
||||
val xBigInt = x.toBigInteger()
|
||||
|
||||
// y² = x³ + 7 (mod p)
|
||||
val ySquared = (xBigInt.pow(3) + 7.toBigInteger()).mod(p)
|
||||
|
||||
// Calculate y using modular square root
|
||||
var y = ySquared.modPow((p + 1.toBigInteger()) / 4.toBigInteger(), p)
|
||||
|
||||
// Check parity
|
||||
val isOdd = prefix == 0x03
|
||||
if (y.testBit(0) != isOdd) {
|
||||
y = p - y
|
||||
}
|
||||
|
||||
// Build uncompressed key: 0x04 || x || y
|
||||
val result = ByteArray(65)
|
||||
result[0] = 0x04
|
||||
val xBytes = x
|
||||
val yBytes = y.toByteArray32()
|
||||
System.arraycopy(xBytes, 0, result, 1, 32)
|
||||
System.arraycopy(yBytes, 0, result, 33, 32)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert between bit groups for Bech32
|
||||
*/
|
||||
private fun convertBits(data: ByteArray, fromBits: Int, toBits: Int, pad: Boolean): ByteArray {
|
||||
var acc = 0
|
||||
var bits = 0
|
||||
val result = mutableListOf<Byte>()
|
||||
val maxv = (1 shl toBits) - 1
|
||||
|
||||
for (value in data) {
|
||||
val v = value.toInt() and 0xFF
|
||||
acc = (acc shl fromBits) or v
|
||||
bits += fromBits
|
||||
while (bits >= toBits) {
|
||||
bits -= toBits
|
||||
result.add(((acc shr bits) and maxv).toByte())
|
||||
}
|
||||
}
|
||||
|
||||
if (pad && bits > 0) {
|
||||
result.add(((acc shl (toBits - bits)) and maxv).toByte())
|
||||
}
|
||||
|
||||
return result.toByteArray()
|
||||
}
|
||||
|
||||
private fun ByteArray.toHexString(): String = joinToString("") { "%02x".format(it) }
|
||||
|
||||
private fun ByteArray.toBigInteger(): java.math.BigInteger {
|
||||
return java.math.BigInteger(1, this)
|
||||
}
|
||||
|
||||
private fun java.math.BigInteger.toByteArray32(): ByteArray {
|
||||
val bytes = this.toByteArray()
|
||||
return when {
|
||||
bytes.size == 32 -> bytes
|
||||
bytes.size > 32 -> bytes.sliceArray((bytes.size - 32) until bytes.size)
|
||||
else -> ByteArray(32 - bytes.size) + bytes
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bech32 encoding utilities
|
||||
*/
|
||||
object Bech32 {
|
||||
private const val CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
||||
private val GENERATOR = intArrayOf(0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3)
|
||||
|
||||
fun encode(hrp: String, data: ByteArray): String {
|
||||
val combined = data.map { it.toInt() and 0xFF }.toIntArray()
|
||||
val checksum = createChecksum(hrp, combined)
|
||||
val result = StringBuilder(hrp).append("1")
|
||||
for (d in combined) result.append(CHARSET[d])
|
||||
for (d in checksum) result.append(CHARSET[d])
|
||||
return result.toString()
|
||||
}
|
||||
|
||||
private fun polymod(values: IntArray): Int {
|
||||
var chk = 1
|
||||
for (v in values) {
|
||||
val top = chk shr 25
|
||||
chk = ((chk and 0x1ffffff) shl 5) xor v
|
||||
for (i in 0..4) {
|
||||
if ((top shr i) and 1 == 1) {
|
||||
chk = chk xor GENERATOR[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
return chk
|
||||
}
|
||||
|
||||
private fun hrpExpand(hrp: String): IntArray {
|
||||
val result = IntArray(hrp.length * 2 + 1)
|
||||
for (i in hrp.indices) {
|
||||
result[i] = hrp[i].code shr 5
|
||||
result[i + hrp.length + 1] = hrp[i].code and 31
|
||||
}
|
||||
result[hrp.length] = 0
|
||||
return result
|
||||
}
|
||||
|
||||
private fun createChecksum(hrp: String, data: IntArray): IntArray {
|
||||
val values = hrpExpand(hrp) + data + intArrayOf(0, 0, 0, 0, 0, 0)
|
||||
val polymod = polymod(values) xor 1
|
||||
return IntArray(6) { (polymod shr (5 * (5 - it))) and 31 }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,629 @@
|
|||
package com.durian.tssparty.util
|
||||
|
||||
import com.durian.tssparty.domain.model.GreenPointsToken
|
||||
import com.durian.tssparty.domain.model.TokenType
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.bouncycastle.jcajce.provider.digest.Keccak
|
||||
import java.math.BigDecimal
|
||||
import java.math.BigInteger
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Transaction utilities for Kava EVM
|
||||
* Matches service-party-app/src/utils/transaction.ts
|
||||
*/
|
||||
object TransactionUtils {
|
||||
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
private val jsonMediaType = "application/json; charset=utf-8".toMediaType()
|
||||
|
||||
// Chain IDs
|
||||
const val KAVA_TESTNET_CHAIN_ID = 2221
|
||||
const val KAVA_MAINNET_CHAIN_ID = 2222
|
||||
|
||||
/**
|
||||
* Prepared transaction ready for signing
|
||||
*/
|
||||
data class PreparedTransaction(
|
||||
val nonce: BigInteger,
|
||||
val gasPrice: BigInteger,
|
||||
val gasLimit: BigInteger,
|
||||
val to: String,
|
||||
val from: String,
|
||||
val value: BigInteger,
|
||||
val data: ByteArray = ByteArray(0),
|
||||
val chainId: Int,
|
||||
val signHash: String, // Hash to be signed (hex with 0x prefix)
|
||||
val rawTxForSigning: ByteArray // RLP encoded tx for signing
|
||||
)
|
||||
|
||||
/**
|
||||
* Transaction parameters for preparation
|
||||
*/
|
||||
data class TransactionParams(
|
||||
val from: String,
|
||||
val to: String,
|
||||
val amount: String, // In KAVA or token units (not wei)
|
||||
val rpcUrl: String,
|
||||
val chainId: Int = KAVA_TESTNET_CHAIN_ID,
|
||||
val tokenType: TokenType = TokenType.KAVA // Token type for transfer
|
||||
)
|
||||
|
||||
/**
|
||||
* Prepare a transaction for signing
|
||||
* Gets nonce, gas price, estimates gas, and calculates sign hash
|
||||
* Supports both native KAVA transfers and ERC-20 token transfers (绿积分)
|
||||
*/
|
||||
suspend fun prepareTransaction(params: TransactionParams): Result<PreparedTransaction> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
// 1. Get nonce
|
||||
val nonce = getNonce(params.from, params.rpcUrl).getOrThrow()
|
||||
|
||||
// 2. Get gas price
|
||||
val gasPrice = getGasPrice(params.rpcUrl).getOrThrow()
|
||||
|
||||
// 3. Prepare transaction based on token type
|
||||
val (toAddress, valueWei, txData) = when (params.tokenType) {
|
||||
TokenType.KAVA -> {
|
||||
// Native KAVA transfer
|
||||
Triple(params.to, kavaToWei(params.amount), ByteArray(0))
|
||||
}
|
||||
TokenType.GREEN_POINTS -> {
|
||||
// ERC-20 token transfer (绿积分)
|
||||
// To address is the contract, value is 0
|
||||
// Data is transfer(recipient, amount) encoded
|
||||
val tokenAmount = greenPointsToRaw(params.amount)
|
||||
val transferData = encodeErc20Transfer(params.to, tokenAmount)
|
||||
Triple(GreenPointsToken.CONTRACT_ADDRESS, BigInteger.ZERO, transferData)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Estimate gas
|
||||
val gasLimit = estimateGasWithData(
|
||||
from = params.from,
|
||||
to = toAddress,
|
||||
value = valueWei,
|
||||
data = txData,
|
||||
rpcUrl = params.rpcUrl
|
||||
).getOrElse {
|
||||
// Default gas limits
|
||||
when (params.tokenType) {
|
||||
TokenType.KAVA -> BigInteger.valueOf(21000)
|
||||
TokenType.GREEN_POINTS -> BigInteger.valueOf(65000) // ERC-20 transfers need more gas
|
||||
}
|
||||
}
|
||||
|
||||
// 5. RLP encode for signing (Legacy Type 0 format)
|
||||
// Format: [nonce, gasPrice, gasLimit, to, value, data, chainId, 0, 0]
|
||||
val rawTxForSigning = rlpEncodeForSigning(
|
||||
nonce = nonce,
|
||||
gasPrice = gasPrice,
|
||||
gasLimit = gasLimit,
|
||||
to = toAddress,
|
||||
value = valueWei,
|
||||
data = txData,
|
||||
chainId = params.chainId
|
||||
)
|
||||
|
||||
// 6. Calculate Keccak-256 hash
|
||||
val signHash = keccak256(rawTxForSigning)
|
||||
|
||||
Result.success(PreparedTransaction(
|
||||
nonce = nonce,
|
||||
gasPrice = gasPrice,
|
||||
gasLimit = gasLimit,
|
||||
to = toAddress,
|
||||
from = params.from,
|
||||
value = valueWei,
|
||||
data = txData,
|
||||
chainId = params.chainId,
|
||||
signHash = "0x" + signHash.toHexString(),
|
||||
rawTxForSigning = rawTxForSigning
|
||||
))
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode ERC-20 transfer(address,uint256) function call
|
||||
*/
|
||||
private fun encodeErc20Transfer(to: String, amount: BigInteger): ByteArray {
|
||||
// Function selector: transfer(address,uint256) = 0xa9059cbb
|
||||
val selector = GreenPointsToken.TRANSFER_SELECTOR.removePrefix("0x").hexToByteArray()
|
||||
|
||||
// Encode recipient address (padded to 32 bytes)
|
||||
val paddedAddress = to.removePrefix("0x").lowercase().padStart(64, '0').hexToByteArray()
|
||||
|
||||
// Encode amount (padded to 32 bytes)
|
||||
val amountHex = amount.toString(16).padStart(64, '0')
|
||||
val paddedAmount = amountHex.hexToByteArray()
|
||||
|
||||
return selector + paddedAddress + paddedAmount
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Green Points amount to raw units (6 decimals)
|
||||
*/
|
||||
fun greenPointsToRaw(amount: String): BigInteger {
|
||||
val decimal = BigDecimal(amount)
|
||||
val rawDecimal = decimal.multiply(BigDecimal("1000000")) // 10^6
|
||||
return rawDecimal.toBigInteger()
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert raw units to Green Points display amount
|
||||
*/
|
||||
fun rawToGreenPoints(raw: BigInteger): String {
|
||||
val rawDecimal = BigDecimal(raw)
|
||||
val displayDecimal = rawDecimal.divide(BigDecimal("1000000"), 6, java.math.RoundingMode.DOWN)
|
||||
return displayDecimal.toPlainString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize transaction with signature
|
||||
* Returns the signed raw transaction hex string ready for broadcast
|
||||
*/
|
||||
fun finalizeTransaction(
|
||||
preparedTx: PreparedTransaction,
|
||||
r: ByteArray,
|
||||
s: ByteArray,
|
||||
recoveryId: Int
|
||||
): String {
|
||||
// Calculate EIP-155 v value
|
||||
// v = chainId * 2 + 35 + recovery_id
|
||||
val v = preparedTx.chainId * 2 + 35 + recoveryId
|
||||
|
||||
// RLP encode signed transaction
|
||||
// Format: [nonce, gasPrice, gasLimit, to, value, data, v, r, s]
|
||||
val signedTx = rlpEncodeSigned(
|
||||
nonce = preparedTx.nonce,
|
||||
gasPrice = preparedTx.gasPrice,
|
||||
gasLimit = preparedTx.gasLimit,
|
||||
to = preparedTx.to,
|
||||
value = preparedTx.value,
|
||||
data = preparedTx.data,
|
||||
v = BigInteger.valueOf(v.toLong()),
|
||||
r = BigInteger(1, r),
|
||||
s = BigInteger(1, s)
|
||||
)
|
||||
|
||||
return "0x" + signedTx.toHexString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast signed transaction to the network
|
||||
*/
|
||||
suspend fun broadcastTransaction(signedTx: String, rpcUrl: String): Result<String> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val requestBody = """
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "eth_sendRawTransaction",
|
||||
"params": ["$signedTx"],
|
||||
"id": 1
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(rpcUrl)
|
||||
.post(requestBody.toRequestBody(jsonMediaType))
|
||||
.build()
|
||||
|
||||
val response = client.newCall(request).execute()
|
||||
val responseBody = response.body?.string() ?: return@withContext Result.failure(Exception("Empty response"))
|
||||
|
||||
val json = com.google.gson.JsonParser.parseString(responseBody).asJsonObject
|
||||
if (json.has("error")) {
|
||||
val errorMsg = json.get("error").asJsonObject.get("message").asString
|
||||
return@withContext Result.failure(Exception(errorMsg))
|
||||
}
|
||||
|
||||
val txHash = json.get("result").asString
|
||||
Result.success(txHash)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get transaction receipt (for confirmation)
|
||||
*/
|
||||
suspend fun getTransactionReceipt(txHash: String, rpcUrl: String): Result<TransactionReceipt?> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val requestBody = """
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "eth_getTransactionReceipt",
|
||||
"params": ["$txHash"],
|
||||
"id": 1
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(rpcUrl)
|
||||
.post(requestBody.toRequestBody(jsonMediaType))
|
||||
.build()
|
||||
|
||||
val response = client.newCall(request).execute()
|
||||
val responseBody = response.body?.string() ?: return@withContext Result.failure(Exception("Empty response"))
|
||||
|
||||
val json = com.google.gson.JsonParser.parseString(responseBody).asJsonObject
|
||||
if (json.has("error")) {
|
||||
val errorMsg = json.get("error").asJsonObject.get("message").asString
|
||||
return@withContext Result.failure(Exception(errorMsg))
|
||||
}
|
||||
|
||||
val result = json.get("result")
|
||||
if (result.isJsonNull) {
|
||||
// Transaction not yet mined
|
||||
return@withContext Result.success(null)
|
||||
}
|
||||
|
||||
val receipt = result.asJsonObject
|
||||
Result.success(TransactionReceipt(
|
||||
transactionHash = receipt.get("transactionHash").asString,
|
||||
blockNumber = receipt.get("blockNumber").asString,
|
||||
status = receipt.get("status").asString == "0x1",
|
||||
gasUsed = BigInteger(receipt.get("gasUsed").asString.removePrefix("0x"), 16)
|
||||
))
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
data class TransactionReceipt(
|
||||
val transactionHash: String,
|
||||
val blockNumber: String,
|
||||
val status: Boolean,
|
||||
val gasUsed: BigInteger
|
||||
)
|
||||
|
||||
// ========== RPC Methods ==========
|
||||
|
||||
private suspend fun getNonce(address: String, rpcUrl: String): Result<BigInteger> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val requestBody = """
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "eth_getTransactionCount",
|
||||
"params": ["$address", "pending"],
|
||||
"id": 1
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(rpcUrl)
|
||||
.post(requestBody.toRequestBody(jsonMediaType))
|
||||
.build()
|
||||
|
||||
val response = client.newCall(request).execute()
|
||||
val responseBody = response.body?.string() ?: return@withContext Result.failure(Exception("Empty response"))
|
||||
|
||||
val json = com.google.gson.JsonParser.parseString(responseBody).asJsonObject
|
||||
if (json.has("error")) {
|
||||
return@withContext Result.failure(Exception(json.get("error").asJsonObject.get("message").asString))
|
||||
}
|
||||
|
||||
val hexNonce = json.get("result").asString
|
||||
Result.success(BigInteger(hexNonce.removePrefix("0x"), 16))
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getGasPrice(rpcUrl: String): Result<BigInteger> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val requestBody = """
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "eth_gasPrice",
|
||||
"params": [],
|
||||
"id": 1
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(rpcUrl)
|
||||
.post(requestBody.toRequestBody(jsonMediaType))
|
||||
.build()
|
||||
|
||||
val response = client.newCall(request).execute()
|
||||
val responseBody = response.body?.string() ?: return@withContext Result.failure(Exception("Empty response"))
|
||||
|
||||
val json = com.google.gson.JsonParser.parseString(responseBody).asJsonObject
|
||||
if (json.has("error")) {
|
||||
return@withContext Result.failure(Exception(json.get("error").asJsonObject.get("message").asString))
|
||||
}
|
||||
|
||||
val hexGasPrice = json.get("result").asString
|
||||
Result.success(BigInteger(hexGasPrice.removePrefix("0x"), 16))
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun estimateGas(
|
||||
from: String,
|
||||
to: String,
|
||||
value: BigInteger,
|
||||
rpcUrl: String
|
||||
): Result<BigInteger> = withContext(Dispatchers.IO) {
|
||||
estimateGasWithData(from, to, value, ByteArray(0), rpcUrl)
|
||||
}
|
||||
|
||||
private suspend fun estimateGasWithData(
|
||||
from: String,
|
||||
to: String,
|
||||
value: BigInteger,
|
||||
data: ByteArray,
|
||||
rpcUrl: String
|
||||
): Result<BigInteger> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val valueHex = "0x" + value.toString(16)
|
||||
val dataHex = if (data.isEmpty()) "" else "\"data\": \"0x${data.toHexString()}\","
|
||||
val requestBody = """
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "eth_estimateGas",
|
||||
"params": [{
|
||||
"from": "$from",
|
||||
"to": "$to",
|
||||
"value": "$valueHex"${if (dataHex.isNotEmpty()) ",\n ${dataHex.trimEnd(',')}" else ""}
|
||||
}],
|
||||
"id": 1
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(rpcUrl)
|
||||
.post(requestBody.toRequestBody(jsonMediaType))
|
||||
.build()
|
||||
|
||||
val response = client.newCall(request).execute()
|
||||
val responseBody = response.body?.string() ?: return@withContext Result.failure(Exception("Empty response"))
|
||||
|
||||
val json = com.google.gson.JsonParser.parseString(responseBody).asJsonObject
|
||||
if (json.has("error")) {
|
||||
return@withContext Result.failure(Exception(json.get("error").asJsonObject.get("message").asString))
|
||||
}
|
||||
|
||||
val hexGas = json.get("result").asString
|
||||
// Add 10% buffer
|
||||
val gas = BigInteger(hexGas.removePrefix("0x"), 16)
|
||||
val gasWithBuffer = gas.multiply(BigInteger.valueOf(110)).divide(BigInteger.valueOf(100))
|
||||
Result.success(gasWithBuffer)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Utility Methods ==========
|
||||
|
||||
fun kavaToWei(kava: String): BigInteger {
|
||||
val decimal = BigDecimal(kava)
|
||||
val weiDecimal = decimal.multiply(BigDecimal("1000000000000000000"))
|
||||
return weiDecimal.toBigInteger()
|
||||
}
|
||||
|
||||
fun weiToKava(wei: BigInteger): String {
|
||||
val weiDecimal = BigDecimal(wei)
|
||||
val kavaDecimal = weiDecimal.divide(BigDecimal("1000000000000000000"), 6, java.math.RoundingMode.DOWN)
|
||||
return kavaDecimal.toPlainString()
|
||||
}
|
||||
|
||||
fun weiToGwei(wei: BigInteger): String {
|
||||
val weiDecimal = BigDecimal(wei)
|
||||
val gweiDecimal = weiDecimal.divide(BigDecimal("1000000000"), 2, java.math.RoundingMode.DOWN)
|
||||
return gweiDecimal.toPlainString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate maximum transferable amount after deducting gas fee
|
||||
* @param balance Current balance in KAVA
|
||||
* @param rpcUrl RPC endpoint URL
|
||||
* @return Maximum amount in KAVA string, or "0" if insufficient balance
|
||||
*/
|
||||
suspend fun calculateMaxTransferAmount(balance: String, rpcUrl: String): Result<String> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val balanceKava = BigDecimal(balance)
|
||||
if (balanceKava <= BigDecimal.ZERO) {
|
||||
return@withContext Result.success("0")
|
||||
}
|
||||
|
||||
// Get current gas price
|
||||
val gasPriceResult = getGasPrice(rpcUrl)
|
||||
val gasPrice = gasPriceResult.getOrElse {
|
||||
// Default to 1 gwei if failed
|
||||
BigInteger.valueOf(1000000000)
|
||||
}
|
||||
|
||||
// Add 10% buffer to gas price
|
||||
val gasPriceWithBuffer = gasPrice.multiply(BigInteger.valueOf(110)).divide(BigInteger.valueOf(100))
|
||||
|
||||
// Simple transfer gas limit is 21000
|
||||
val gasLimit = BigInteger.valueOf(21000)
|
||||
val gasFee = gasPriceWithBuffer.multiply(gasLimit)
|
||||
|
||||
// Convert gas fee to KAVA
|
||||
val gasFeeKava = BigDecimal(gasFee).divide(BigDecimal("1000000000000000000"), 8, java.math.RoundingMode.UP)
|
||||
|
||||
// Calculate max amount = balance - gas fee
|
||||
val maxAmount = balanceKava.subtract(gasFeeKava)
|
||||
|
||||
if (maxAmount <= BigDecimal.ZERO) {
|
||||
return@withContext Result.success("0")
|
||||
}
|
||||
|
||||
// Round down to 6 decimal places
|
||||
val formattedMax = maxAmount.setScale(6, java.math.RoundingMode.DOWN).stripTrailingZeros().toPlainString()
|
||||
Result.success(formattedMax)
|
||||
} catch (e: Exception) {
|
||||
// Fallback: use default gas estimate (21000 * 1 gwei = 0.000021 KAVA)
|
||||
try {
|
||||
val balanceKava = BigDecimal(balance)
|
||||
val defaultGasFee = BigDecimal("0.000021")
|
||||
val maxAmount = balanceKava.subtract(defaultGasFee)
|
||||
if (maxAmount <= BigDecimal.ZERO) {
|
||||
Result.success("0")
|
||||
} else {
|
||||
val formattedMax = maxAmount.setScale(6, java.math.RoundingMode.DOWN).stripTrailingZeros().toPlainString()
|
||||
Result.success(formattedMax)
|
||||
}
|
||||
} catch (e2: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun keccak256(data: ByteArray): ByteArray {
|
||||
val keccak = Keccak.Digest256()
|
||||
return keccak.digest(data)
|
||||
}
|
||||
|
||||
// ========== RLP Encoding ==========
|
||||
|
||||
/**
|
||||
* RLP encode transaction for signing (EIP-155)
|
||||
* Format: [nonce, gasPrice, gasLimit, to, value, data, chainId, 0, 0]
|
||||
*/
|
||||
private fun rlpEncodeForSigning(
|
||||
nonce: BigInteger,
|
||||
gasPrice: BigInteger,
|
||||
gasLimit: BigInteger,
|
||||
to: String,
|
||||
value: BigInteger,
|
||||
data: ByteArray,
|
||||
chainId: Int
|
||||
): ByteArray {
|
||||
val items = listOf(
|
||||
rlpEncodeInteger(nonce),
|
||||
rlpEncodeInteger(gasPrice),
|
||||
rlpEncodeInteger(gasLimit),
|
||||
rlpEncodeAddress(to),
|
||||
rlpEncodeInteger(value),
|
||||
rlpEncodeBytes(data),
|
||||
rlpEncodeInteger(BigInteger.valueOf(chainId.toLong())),
|
||||
rlpEncodeInteger(BigInteger.ZERO),
|
||||
rlpEncodeInteger(BigInteger.ZERO)
|
||||
)
|
||||
return rlpEncodeList(items)
|
||||
}
|
||||
|
||||
/**
|
||||
* RLP encode signed transaction
|
||||
* Format: [nonce, gasPrice, gasLimit, to, value, data, v, r, s]
|
||||
*/
|
||||
private fun rlpEncodeSigned(
|
||||
nonce: BigInteger,
|
||||
gasPrice: BigInteger,
|
||||
gasLimit: BigInteger,
|
||||
to: String,
|
||||
value: BigInteger,
|
||||
data: ByteArray,
|
||||
v: BigInteger,
|
||||
r: BigInteger,
|
||||
s: BigInteger
|
||||
): ByteArray {
|
||||
val items = listOf(
|
||||
rlpEncodeInteger(nonce),
|
||||
rlpEncodeInteger(gasPrice),
|
||||
rlpEncodeInteger(gasLimit),
|
||||
rlpEncodeAddress(to),
|
||||
rlpEncodeInteger(value),
|
||||
rlpEncodeBytes(data),
|
||||
rlpEncodeInteger(v),
|
||||
rlpEncodeInteger(r),
|
||||
rlpEncodeInteger(s)
|
||||
)
|
||||
return rlpEncodeList(items)
|
||||
}
|
||||
|
||||
private fun rlpEncodeInteger(value: BigInteger): ByteArray {
|
||||
if (value == BigInteger.ZERO) {
|
||||
return byteArrayOf(0x80.toByte())
|
||||
}
|
||||
val bytes = value.toByteArray()
|
||||
// Remove leading zero if present
|
||||
val trimmed = if (bytes[0] == 0.toByte() && bytes.size > 1) {
|
||||
bytes.copyOfRange(1, bytes.size)
|
||||
} else {
|
||||
bytes
|
||||
}
|
||||
return rlpEncodeBytes(trimmed)
|
||||
}
|
||||
|
||||
private fun rlpEncodeAddress(address: String): ByteArray {
|
||||
val cleanAddress = address.removePrefix("0x")
|
||||
val bytes = cleanAddress.hexToByteArray()
|
||||
return rlpEncodeBytes(bytes)
|
||||
}
|
||||
|
||||
private fun rlpEncodeBytes(bytes: ByteArray): ByteArray {
|
||||
return when {
|
||||
bytes.size == 1 && bytes[0].toInt() and 0xFF < 0x80 -> bytes
|
||||
bytes.size <= 55 -> {
|
||||
val result = ByteArray(1 + bytes.size)
|
||||
result[0] = (0x80 + bytes.size).toByte()
|
||||
System.arraycopy(bytes, 0, result, 1, bytes.size)
|
||||
result
|
||||
}
|
||||
else -> {
|
||||
val lengthBytes = bytes.size.toBigInteger().toByteArray().let { arr ->
|
||||
if (arr[0] == 0.toByte() && arr.size > 1) arr.copyOfRange(1, arr.size) else arr
|
||||
}
|
||||
val result = ByteArray(1 + lengthBytes.size + bytes.size)
|
||||
result[0] = (0xB7 + lengthBytes.size).toByte()
|
||||
System.arraycopy(lengthBytes, 0, result, 1, lengthBytes.size)
|
||||
System.arraycopy(bytes, 0, result, 1 + lengthBytes.size, bytes.size)
|
||||
result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun rlpEncodeList(items: List<ByteArray>): ByteArray {
|
||||
val concatenated = items.fold(ByteArray(0)) { acc, item -> acc + item }
|
||||
return when {
|
||||
concatenated.size <= 55 -> {
|
||||
val result = ByteArray(1 + concatenated.size)
|
||||
result[0] = (0xC0 + concatenated.size).toByte()
|
||||
System.arraycopy(concatenated, 0, result, 1, concatenated.size)
|
||||
result
|
||||
}
|
||||
else -> {
|
||||
val lengthBytes = concatenated.size.toBigInteger().toByteArray().let { arr ->
|
||||
if (arr[0] == 0.toByte() && arr.size > 1) arr.copyOfRange(1, arr.size) else arr
|
||||
}
|
||||
val result = ByteArray(1 + lengthBytes.size + concatenated.size)
|
||||
result[0] = (0xF7 + lengthBytes.size).toByte()
|
||||
System.arraycopy(lengthBytes, 0, result, 1, lengthBytes.size)
|
||||
System.arraycopy(concatenated, 0, result, 1 + lengthBytes.size, concatenated.size)
|
||||
result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Extension Functions ==========
|
||||
|
||||
private fun ByteArray.toHexString(): String = joinToString("") { "%02x".format(it) }
|
||||
|
||||
private fun String.hexToByteArray(): ByteArray {
|
||||
val len = this.length
|
||||
val data = ByteArray(len / 2)
|
||||
var i = 0
|
||||
while (i < len) {
|
||||
data[i / 2] = ((Character.digit(this[i], 16) shl 4) + Character.digit(this[i + 1], 16)).toByte()
|
||||
i += 2
|
||||
}
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,215 @@
|
|||
syntax = "proto3";
|
||||
|
||||
package mpc.router.v1;
|
||||
|
||||
option java_package = "com.durian.tssparty.grpc";
|
||||
option java_outer_classname = "MessageRouterProto";
|
||||
option java_multiple_files = true;
|
||||
|
||||
// MessageRouter service handles MPC message routing
|
||||
service MessageRouter {
|
||||
// RouteMessage routes a message from one party to others
|
||||
rpc RouteMessage(RouteMessageRequest) returns (RouteMessageResponse);
|
||||
|
||||
// SubscribeMessages subscribes to messages for a party (streaming)
|
||||
rpc SubscribeMessages(SubscribeMessagesRequest) returns (stream MPCMessage);
|
||||
|
||||
// GetPendingMessages retrieves pending messages (polling alternative)
|
||||
rpc GetPendingMessages(GetPendingMessagesRequest) returns (GetPendingMessagesResponse);
|
||||
|
||||
// AcknowledgeMessage acknowledges receipt of a message
|
||||
rpc AcknowledgeMessage(AcknowledgeMessageRequest) returns (AcknowledgeMessageResponse);
|
||||
|
||||
// RegisterParty registers a party with the message router
|
||||
rpc RegisterParty(RegisterPartyRequest) returns (RegisterPartyResponse);
|
||||
|
||||
// Heartbeat sends a heartbeat to keep the party alive
|
||||
rpc Heartbeat(HeartbeatRequest) returns (HeartbeatResponse);
|
||||
|
||||
// SubscribeSessionEvents subscribes to session lifecycle events
|
||||
rpc SubscribeSessionEvents(SubscribeSessionEventsRequest) returns (stream SessionEvent);
|
||||
|
||||
// JoinSession joins a session (proxied to Session Coordinator)
|
||||
rpc JoinSession(JoinSessionRequest) returns (JoinSessionResponse);
|
||||
|
||||
// MarkPartyReady marks a party as ready
|
||||
rpc MarkPartyReady(MarkPartyReadyRequest) returns (MarkPartyReadyResponse);
|
||||
|
||||
// ReportCompletion reports protocol completion
|
||||
rpc ReportCompletion(ReportCompletionRequest) returns (ReportCompletionResponse);
|
||||
|
||||
// GetSessionStatus gets session status
|
||||
rpc GetSessionStatus(GetSessionStatusRequest) returns (GetSessionStatusResponse);
|
||||
}
|
||||
|
||||
message RouteMessageRequest {
|
||||
string session_id = 1;
|
||||
string from_party = 2;
|
||||
repeated string to_parties = 3;
|
||||
int32 round_number = 4;
|
||||
string message_type = 5;
|
||||
bytes payload = 6;
|
||||
}
|
||||
|
||||
message RouteMessageResponse {
|
||||
bool success = 1;
|
||||
string message_id = 2;
|
||||
}
|
||||
|
||||
message SubscribeMessagesRequest {
|
||||
string session_id = 1;
|
||||
string party_id = 2;
|
||||
}
|
||||
|
||||
message MPCMessage {
|
||||
string message_id = 1;
|
||||
string session_id = 2;
|
||||
string from_party = 3;
|
||||
bool is_broadcast = 4;
|
||||
int32 round_number = 5;
|
||||
string message_type = 6;
|
||||
bytes payload = 7;
|
||||
int64 created_at = 8;
|
||||
}
|
||||
|
||||
message GetPendingMessagesRequest {
|
||||
string session_id = 1;
|
||||
string party_id = 2;
|
||||
int64 after_timestamp = 3;
|
||||
}
|
||||
|
||||
message GetPendingMessagesResponse {
|
||||
repeated MPCMessage messages = 1;
|
||||
}
|
||||
|
||||
message NotificationChannel {
|
||||
string email = 1;
|
||||
string phone = 2;
|
||||
string push_token = 3;
|
||||
}
|
||||
|
||||
message RegisterPartyRequest {
|
||||
string party_id = 1;
|
||||
string party_role = 2;
|
||||
string version = 3;
|
||||
NotificationChannel notification = 4;
|
||||
}
|
||||
|
||||
message RegisterPartyResponse {
|
||||
bool success = 1;
|
||||
string message = 2;
|
||||
int64 registered_at = 3;
|
||||
}
|
||||
|
||||
message SubscribeSessionEventsRequest {
|
||||
string party_id = 1;
|
||||
repeated string event_types = 2;
|
||||
}
|
||||
|
||||
message SessionEvent {
|
||||
string event_id = 1;
|
||||
string event_type = 2;
|
||||
string session_id = 3;
|
||||
int32 threshold_n = 4;
|
||||
int32 threshold_t = 5;
|
||||
repeated string selected_parties = 6;
|
||||
map<string, string> join_tokens = 7;
|
||||
bytes message_hash = 8;
|
||||
int64 created_at = 9;
|
||||
int64 expires_at = 10;
|
||||
}
|
||||
|
||||
message AcknowledgeMessageRequest {
|
||||
string message_id = 1;
|
||||
string party_id = 2;
|
||||
string session_id = 3;
|
||||
bool success = 4;
|
||||
string error_message = 5;
|
||||
}
|
||||
|
||||
message AcknowledgeMessageResponse {
|
||||
bool success = 1;
|
||||
string message = 2;
|
||||
}
|
||||
|
||||
message HeartbeatRequest {
|
||||
string party_id = 1;
|
||||
int64 timestamp = 2;
|
||||
}
|
||||
|
||||
message HeartbeatResponse {
|
||||
bool success = 1;
|
||||
int64 server_timestamp = 2;
|
||||
int32 pending_messages = 3;
|
||||
}
|
||||
|
||||
message DeviceInfo {
|
||||
string device_type = 1;
|
||||
string device_id = 2;
|
||||
string platform = 3;
|
||||
string app_version = 4;
|
||||
}
|
||||
|
||||
message PartyInfo {
|
||||
string party_id = 1;
|
||||
int32 party_index = 2;
|
||||
DeviceInfo device_info = 3;
|
||||
}
|
||||
|
||||
message SessionInfo {
|
||||
string session_id = 1;
|
||||
string session_type = 2;
|
||||
int32 threshold_n = 3;
|
||||
int32 threshold_t = 4;
|
||||
bytes message_hash = 5;
|
||||
string status = 6;
|
||||
string keygen_session_id = 7;
|
||||
}
|
||||
|
||||
message JoinSessionRequest {
|
||||
string session_id = 1;
|
||||
string party_id = 2;
|
||||
string join_token = 3;
|
||||
DeviceInfo device_info = 4;
|
||||
}
|
||||
|
||||
message JoinSessionResponse {
|
||||
bool success = 1;
|
||||
SessionInfo session_info = 2;
|
||||
repeated PartyInfo other_parties = 3;
|
||||
int32 party_index = 4;
|
||||
}
|
||||
|
||||
message MarkPartyReadyRequest {
|
||||
string session_id = 1;
|
||||
string party_id = 2;
|
||||
}
|
||||
|
||||
message MarkPartyReadyResponse {
|
||||
bool success = 1;
|
||||
bool all_ready = 2;
|
||||
}
|
||||
|
||||
message ReportCompletionRequest {
|
||||
string session_id = 1;
|
||||
string party_id = 2;
|
||||
bytes public_key = 3;
|
||||
bytes signature = 4;
|
||||
}
|
||||
|
||||
message ReportCompletionResponse {
|
||||
bool success = 1;
|
||||
bool all_completed = 2;
|
||||
}
|
||||
|
||||
message GetSessionStatusRequest {
|
||||
string session_id = 1;
|
||||
}
|
||||
|
||||
message GetSessionStatusResponse {
|
||||
string session_id = 1;
|
||||
string status = 2;
|
||||
int32 threshold_n = 3;
|
||||
int32 threshold_t = 4;
|
||||
repeated PartyInfo participants = 5;
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
|
||||
<!-- Simple wallet icon -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M54,30 L78,30 C80.2,30 82,31.8 82,34 L82,74 C82,76.2 80.2,78 78,78 L30,78 C27.8,78 26,76.2 26,74 L26,34 C26,31.8 27.8,30 30,30 L54,30 Z M54,26 L30,26 C25.6,26 22,29.6 22,34 L22,74 C22,78.4 25.6,82 30,82 L78,82 C82.4,82 86,78.4 86,74 L86,34 C86,29.6 82.4,26 78,26 L54,26 Z"/>
|
||||
|
||||
<!-- Key symbol -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M54,44 C58.4,44 62,47.6 62,52 C62,54.8 60.6,57.2 58.4,58.6 L58.4,66 L50,66 L50,58.6 C47.4,57.2 46,54.8 46,52 C46,47.6 49.6,44 54,44 Z M54,48 C51.8,48 50,49.8 50,52 C50,53.4 50.8,54.6 52,55.2 L52,62 L56,62 L56,55.2 C57.2,54.6 58,53.4 58,52 C58,49.8 56.2,48 54,48 Z"/>
|
||||
</vector>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/green_primary"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/green_primary"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="green_primary">#4CAF50</color>
|
||||
<color name="green_dark">#388E3C</color>
|
||||
<color name="green_light">#81C784</color>
|
||||
<color name="white">#FFFFFF</color>
|
||||
<color name="black">#000000</color>
|
||||
</resources>
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">TSS Party</string>
|
||||
</resources>
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.TssParty" parent="android:Theme.Material.Light.NoActionBar">
|
||||
<item name="android:statusBarColor">@color/green_primary</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<full-backup-content>
|
||||
<include domain="sharedpref" path="."/>
|
||||
<include domain="database" path="."/>
|
||||
<exclude domain="database" path="tss_party.db"/>
|
||||
</full-backup-content>
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<include domain="sharedpref" path="."/>
|
||||
<include domain="database" path="."/>
|
||||
<exclude domain="database" path="tss_party.db"/>
|
||||
</cloud-backup>
|
||||
<device-transfer>
|
||||
<include domain="sharedpref" path="."/>
|
||||
<include domain="database" path="."/>
|
||||
<exclude domain="database" path="tss_party.db"/>
|
||||
</device-transfer>
|
||||
</data-extraction-rules>
|
||||
|
|
@ -0,0 +1,290 @@
|
|||
@echo off
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
echo ========================================
|
||||
echo TSS Party Android APK Builder
|
||||
echo ========================================
|
||||
echo.
|
||||
|
||||
:: Check if gradlew exists
|
||||
if not exist "gradlew.bat" (
|
||||
echo [ERROR] gradlew.bat not found!
|
||||
echo Please run this script from the service-party-android directory.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
:: Check and create local.properties if needed
|
||||
if not exist "local.properties" (
|
||||
echo [INFO] local.properties not found, attempting to detect Android SDK...
|
||||
|
||||
:: Try common SDK locations
|
||||
set SDK_FOUND=0
|
||||
|
||||
:: Check ANDROID_HOME environment variable first
|
||||
if defined ANDROID_HOME (
|
||||
:: Remove any surrounding quotes from ANDROID_HOME
|
||||
set "ANDROID_HOME_CLEAN=!ANDROID_HOME:"=!"
|
||||
if exist "!ANDROID_HOME_CLEAN!\platform-tools" (
|
||||
set "SDK_PATH_CLEAN=!ANDROID_HOME_CLEAN:\=/!"
|
||||
echo sdk.dir=!SDK_PATH_CLEAN!> local.properties
|
||||
echo [INFO] Created local.properties with ANDROID_HOME: !ANDROID_HOME_CLEAN!
|
||||
set SDK_FOUND=1
|
||||
)
|
||||
)
|
||||
|
||||
:: Try common Windows locations
|
||||
if !SDK_FOUND!==0 (
|
||||
for %%P in (
|
||||
"%LOCALAPPDATA%\Android\Sdk"
|
||||
"%USERPROFILE%\AppData\Local\Android\Sdk"
|
||||
"C:\Android\Sdk"
|
||||
"C:\Android"
|
||||
"C:\Users\%USERNAME%\Android\Sdk"
|
||||
) do (
|
||||
if exist "%%~P\platform-tools" (
|
||||
set "SDK_PATH=%%~P"
|
||||
set "SDK_PATH=!SDK_PATH:\=/!"
|
||||
echo sdk.dir=!SDK_PATH!> local.properties
|
||||
echo [INFO] Created local.properties with SDK path: %%~P
|
||||
set SDK_FOUND=1
|
||||
goto :sdk_found
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
:sdk_found
|
||||
if !SDK_FOUND!==0 (
|
||||
echo [ERROR] Android SDK not found!
|
||||
echo.
|
||||
echo Please do one of the following:
|
||||
echo 1. Set ANDROID_HOME environment variable to your SDK path
|
||||
echo 2. Create local.properties file with: sdk.dir=C:/path/to/android/sdk
|
||||
echo 3. Install Android Studio which includes the SDK
|
||||
echo.
|
||||
echo Common SDK locations:
|
||||
echo - %LOCALAPPDATA%\Android\Sdk
|
||||
echo - C:\Android\Sdk
|
||||
echo.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
)
|
||||
|
||||
echo [INFO] Using SDK from local.properties
|
||||
type local.properties
|
||||
echo.
|
||||
|
||||
:: Check and build tsslib.aar if needed
|
||||
if not exist "app\libs\tsslib.aar" (
|
||||
echo [INFO] tsslib.aar not found, attempting to build TSS library...
|
||||
echo.
|
||||
|
||||
:: Check if Go is installed
|
||||
where go >nul 2>nul
|
||||
if !errorlevel! neq 0 (
|
||||
echo [ERROR] Go is not installed or not in PATH!
|
||||
echo Please install Go from https://golang.org/dl/
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
:: Get GOPATH for bin directory
|
||||
for /f "tokens=*" %%G in ('go env GOPATH') do set "GOPATH_DIR=%%G"
|
||||
if not defined GOPATH_DIR set "GOPATH_DIR=%USERPROFILE%\go"
|
||||
set "GOBIN_DIR=!GOPATH_DIR!\bin"
|
||||
|
||||
:: Add GOPATH/bin to PATH if not already there
|
||||
echo !PATH! | findstr /i /c:"!GOBIN_DIR!" >nul 2>nul
|
||||
if !errorlevel! neq 0 (
|
||||
echo [INFO] Adding !GOBIN_DIR! to PATH...
|
||||
set "PATH=!PATH!;!GOBIN_DIR!"
|
||||
)
|
||||
|
||||
:: Show Go version
|
||||
for /f "tokens=3" %%V in ('go version') do set "GO_VERSION=%%V"
|
||||
echo [INFO] Go version: !GO_VERSION!
|
||||
|
||||
:: Get the tsslib directory path (inside service-party-android)
|
||||
set "TSSLIB_DIR=tsslib"
|
||||
|
||||
if not exist "!TSSLIB_DIR!\go.mod" (
|
||||
echo [ERROR] TSS library source not found at !TSSLIB_DIR!
|
||||
echo Please ensure the tsslib source code exists.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
:: IMPORTANT: Add gomobile dependency to go.mod (official recommended step)
|
||||
echo [INFO] Adding gomobile dependency to go.mod...
|
||||
pushd "!TSSLIB_DIR!"
|
||||
go get -d golang.org/x/mobile/cmd/gomobile
|
||||
if !errorlevel! neq 0 (
|
||||
echo [WARNING] go get gomobile failed, continuing anyway...
|
||||
)
|
||||
popd
|
||||
|
||||
:: Install gomobile
|
||||
echo [INFO] Installing gomobile...
|
||||
go install golang.org/x/mobile/cmd/gomobile@latest
|
||||
if !errorlevel! neq 0 (
|
||||
echo [ERROR] Failed to install gomobile!
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
:: Verify gomobile exists
|
||||
if not exist "!GOBIN_DIR!\gomobile.exe" (
|
||||
echo [ERROR] gomobile was not installed correctly!
|
||||
echo Please check your Go installation and GOPATH.
|
||||
echo Expected location: !GOBIN_DIR!\gomobile.exe
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo [INFO] Initializing gomobile...
|
||||
"!GOBIN_DIR!\gomobile.exe" init
|
||||
if !errorlevel! neq 0 (
|
||||
echo [WARNING] gomobile init failed, but continuing...
|
||||
)
|
||||
|
||||
echo [INFO] Building tsslib.aar with gomobile...
|
||||
pushd "!TSSLIB_DIR!"
|
||||
|
||||
:: Build the AAR
|
||||
:: Use -androidapi 21 to ensure compatibility with modern NDK
|
||||
"!GOBIN_DIR!\gomobile.exe" bind -target=android -androidapi 21 -o "..\app\libs\tsslib.aar" .
|
||||
if !errorlevel! neq 0 (
|
||||
echo [ERROR] gomobile bind failed!
|
||||
popd
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
popd
|
||||
|
||||
if exist "app\libs\tsslib.aar" (
|
||||
echo [SUCCESS] tsslib.aar built successfully!
|
||||
for %%F in ("app\libs\tsslib.aar") do echo Size: %%~zF bytes
|
||||
) else (
|
||||
echo [ERROR] tsslib.aar was not created!
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo.
|
||||
) else (
|
||||
echo [INFO] tsslib.aar found, skipping TSS library build
|
||||
for %%F in ("app\libs\tsslib.aar") do echo Size: %%~zF bytes
|
||||
echo.
|
||||
)
|
||||
|
||||
:: Parse command line arguments
|
||||
set BUILD_TYPE=all
|
||||
if "%1"=="debug" set BUILD_TYPE=debug
|
||||
if "%1"=="release" set BUILD_TYPE=release
|
||||
if "%1"=="clean" set BUILD_TYPE=clean
|
||||
if "%1"=="help" goto :show_help
|
||||
|
||||
:: Show build type
|
||||
echo Build type: %BUILD_TYPE%
|
||||
echo.
|
||||
|
||||
:: Clean build
|
||||
if "%BUILD_TYPE%"=="clean" (
|
||||
echo [1/1] Cleaning build files...
|
||||
call gradlew.bat clean --no-daemon
|
||||
if !errorlevel! neq 0 (
|
||||
echo [ERROR] Clean failed!
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo.
|
||||
echo [SUCCESS] Clean completed!
|
||||
goto :end
|
||||
)
|
||||
|
||||
:: Build Debug APK
|
||||
if "%BUILD_TYPE%"=="debug" goto :build_debug
|
||||
if "%BUILD_TYPE%"=="all" goto :build_debug
|
||||
goto :check_release
|
||||
|
||||
:build_debug
|
||||
echo [1/2] Building Debug APK...
|
||||
call gradlew.bat assembleDebug --no-daemon
|
||||
if !errorlevel! neq 0 (
|
||||
echo [ERROR] Debug build failed!
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo [SUCCESS] Debug APK built successfully!
|
||||
echo Location: app\build\outputs\apk\debug\app-debug.apk
|
||||
echo.
|
||||
|
||||
:check_release
|
||||
if "%BUILD_TYPE%"=="debug" goto :show_results
|
||||
|
||||
:: Build Release APK
|
||||
:build_release
|
||||
echo [2/2] Building Release APK...
|
||||
call gradlew.bat assembleRelease --no-daemon
|
||||
if !errorlevel! neq 0 (
|
||||
echo [ERROR] Release build failed!
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo [SUCCESS] Release APK built successfully!
|
||||
echo Location: app\build\outputs\apk\release\app-release.apk
|
||||
echo.
|
||||
|
||||
:show_results
|
||||
echo ========================================
|
||||
echo Build Results
|
||||
echo ========================================
|
||||
echo.
|
||||
|
||||
:: Check and show Debug APK
|
||||
if exist "app\build\outputs\apk\debug\app-debug.apk" (
|
||||
for %%F in ("app\build\outputs\apk\debug\app-debug.apk") do (
|
||||
echo [DEBUG APK]
|
||||
echo Path: %%~fF
|
||||
echo Size: %%~zF bytes
|
||||
)
|
||||
echo.
|
||||
)
|
||||
|
||||
:: Check and show Release APK
|
||||
if exist "app\build\outputs\apk\release\app-release.apk" (
|
||||
for %%F in ("app\build\outputs\apk\release\app-release.apk") do (
|
||||
echo [RELEASE APK]
|
||||
echo Path: %%~fF
|
||||
echo Size: %%~zF bytes
|
||||
)
|
||||
echo.
|
||||
)
|
||||
|
||||
echo ========================================
|
||||
echo Build completed successfully!
|
||||
echo ========================================
|
||||
goto :end
|
||||
|
||||
:show_help
|
||||
echo.
|
||||
echo Usage: build-apk.bat [option]
|
||||
echo.
|
||||
echo Options:
|
||||
echo debug - Build debug APK only
|
||||
echo release - Build release APK only
|
||||
echo all - Build both debug and release APKs (default)
|
||||
echo clean - Clean build files
|
||||
echo help - Show this help message
|
||||
echo.
|
||||
echo Examples:
|
||||
echo build-apk.bat - Build both APKs
|
||||
echo build-apk.bat debug - Build debug APK only
|
||||
echo build-apk.bat release - Build release APK only
|
||||
echo build-apk.bat clean - Clean project
|
||||
echo.
|
||||
|
||||
:end
|
||||
echo.
|
||||
pause
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
// Top-level build file
|
||||
plugins {
|
||||
id("com.android.application") version "8.2.0" apply false
|
||||
id("org.jetbrains.kotlin.android") version "1.9.21" apply false
|
||||
id("com.google.dagger.hilt.android") version "2.48.1" apply false
|
||||
id("com.google.protobuf") version "0.9.4" apply false
|
||||
}
|
||||
|
||||
buildscript {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register("clean", Delete::class) {
|
||||
delete(rootProject.layout.buildDirectory)
|
||||
}
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
# Durian USDT (dUSDT) 代币合约
|
||||
|
||||
## 合约概述
|
||||
|
||||
Durian USDT 是一个部署在 Kava EVM 主网上的固定供应量 ERC-20 代币。该合约**完全禁止增发**,所有代币在部署时一次性铸造给部署者地址。
|
||||
|
||||
## 合约详情
|
||||
|
||||
| 项目 | 值 |
|
||||
|------|-----|
|
||||
| 合约地址 | `0xA9F3A35dBa8699c8C681D8db03F0c1A8CEB9D7c3` |
|
||||
| 代币名称 | Durian USDT |
|
||||
| 代币符号 | dUSDT |
|
||||
| 精度 (Decimals) | 6 |
|
||||
| 总供应量 | 1,000,000,000,000 dUSDT (1万亿) |
|
||||
| 总供应量 (最小单位) | 1,000,000,000,000,000,000 (10^18) |
|
||||
|
||||
## 网络信息
|
||||
|
||||
| 项目 | 值 |
|
||||
|------|-----|
|
||||
| 网络名称 | Kava EVM Mainnet |
|
||||
| Chain ID | 2222 |
|
||||
| RPC URL | https://evm.kava.io |
|
||||
| 区块浏览器 | https://kavascan.com |
|
||||
| 原生代币 | KAVA |
|
||||
|
||||
## 持有人/管理人信息
|
||||
|
||||
| 项目 | 值 |
|
||||
|------|-----|
|
||||
| 地址 | `0x4F7E78d6B7C5FC502Ec7039848690f08c8970F1E` |
|
||||
| 私钥 | `0x886ea4cffe76c386fecf3ff321ac9ae913737c46c17bc6ce2413752144668a2a` |
|
||||
| 初始 dUSDT 余额 | 1,000,000,000,000 dUSDT (全部) |
|
||||
|
||||
> **安全警告**: 私钥必须妥善保管,切勿泄露给他人。
|
||||
|
||||
## 部署信息
|
||||
|
||||
| 项目 | 值 |
|
||||
|------|-----|
|
||||
| 部署时间 | 2026-01-02 |
|
||||
| 部署交易哈希 | `0xa73d7dce17723bc5a9d62767c515dadf4ccccc26327ed5637958ce817edd671d` |
|
||||
| Solidity 版本 | 0.8.19 |
|
||||
| EVM 版本 | Paris (无 PUSH0 操作码) |
|
||||
| 优化 | 启用 (runs: 200) |
|
||||
|
||||
## 合约特性
|
||||
|
||||
### 固定供应量 - 无增发机制
|
||||
|
||||
该合约的核心特性是**完全禁止增发**:
|
||||
|
||||
1. **无 mint 函数**: 合约代码中不存在任何铸造新代币的函数
|
||||
2. **无 owner/admin 权限**: 合约没有特权角色,无人能修改供应量
|
||||
3. **供应量在构造函数中固定**: 所有代币在部署时一次性创建
|
||||
4. **totalSupply 是 constant**: 总供应量声明为常量,无法修改
|
||||
|
||||
### 支持的 ERC-20 标准函数
|
||||
|
||||
| 函数 | 描述 |
|
||||
|------|------|
|
||||
| `name()` | 返回代币名称 "Durian USDT" |
|
||||
| `symbol()` | 返回代币符号 "dUSDT" |
|
||||
| `decimals()` | 返回精度 6 |
|
||||
| `totalSupply()` | 返回总供应量 |
|
||||
| `balanceOf(address)` | 查询地址余额 |
|
||||
| `transfer(address, uint256)` | 转账 |
|
||||
| `approve(address, uint256)` | 授权 |
|
||||
| `allowance(address, address)` | 查询授权额度 |
|
||||
| `transferFrom(address, address, uint256)` | 授权转账 |
|
||||
|
||||
## 查看链接
|
||||
|
||||
- 合约: https://kavascan.com/address/0xA9F3A35dBa8699c8C681D8db03F0c1A8CEB9D7c3
|
||||
- 持有人: https://kavascan.com/address/0x4F7E78d6B7C5FC502Ec7039848690f08c8970F1E
|
||||
- 部署交易: https://kavascan.com/tx/0xa73d7dce17723bc5a9d62767c515dadf4ccccc26327ed5637958ce817edd671d
|
||||
|
||||
## 在钱包中添加代币
|
||||
|
||||
在 MetaMask 或其他钱包中添加自定义代币:
|
||||
|
||||
1. 网络: Kava EVM (Chain ID: 2222)
|
||||
2. 合约地址: `0xA9F3A35dBa8699c8C681D8db03F0c1A8CEB9D7c3`
|
||||
3. 代币符号: `dUSDT`
|
||||
4. 精度: `6`
|
||||
|
||||
## 文件说明
|
||||
|
||||
| 文件 | 描述 |
|
||||
|------|------|
|
||||
| `DurianUSDT.sol` | Solidity 源代码 |
|
||||
| `DurianUSDT.abi` | 合约 ABI (Application Binary Interface) |
|
||||
| `DurianUSDT.bin` | 编译后的字节码 |
|
||||
| `CONTRACT_INFO.md` | 本文档 |
|
||||
|
||||
## 代码审计要点
|
||||
|
||||
该合约经过精简设计,关键安全特性:
|
||||
|
||||
1. **无 owner 模式**: 没有特权地址可以执行管理操作
|
||||
2. **无升级机制**: 合约不可升级,代码永久固定
|
||||
3. **无暂停功能**: 转账功能无法被暂停
|
||||
4. **无黑名单功能**: 没有地址可以被限制转账
|
||||
5. **使用 unchecked 块**: 在已验证的情况下使用,节省 gas
|
||||
|
||||
## 与标准 USDT 的对比
|
||||
|
||||
| 特性 | dUSDT | 标准 USDT |
|
||||
|------|-------|----------|
|
||||
| 精度 | 6 | 6 |
|
||||
| 可增发 | 否 | 是 |
|
||||
| 可暂停 | 否 | 是 |
|
||||
| 黑名单功能 | 否 | 是 |
|
||||
| 中心化管理 | 否 | 是 |
|
||||
|
|
@ -0,0 +1,229 @@
|
|||
[
|
||||
{
|
||||
"inputs": [],
|
||||
"stateMutability": "nonpayable",
|
||||
"type": "constructor"
|
||||
},
|
||||
{
|
||||
"anonymous": false,
|
||||
"inputs": [
|
||||
{
|
||||
"indexed": true,
|
||||
"internalType": "address",
|
||||
"name": "owner",
|
||||
"type": "address"
|
||||
},
|
||||
{
|
||||
"indexed": true,
|
||||
"internalType": "address",
|
||||
"name": "spender",
|
||||
"type": "address"
|
||||
},
|
||||
{
|
||||
"indexed": false,
|
||||
"internalType": "uint256",
|
||||
"name": "value",
|
||||
"type": "uint256"
|
||||
}
|
||||
],
|
||||
"name": "Approval",
|
||||
"type": "event"
|
||||
},
|
||||
{
|
||||
"anonymous": false,
|
||||
"inputs": [
|
||||
{
|
||||
"indexed": true,
|
||||
"internalType": "address",
|
||||
"name": "from",
|
||||
"type": "address"
|
||||
},
|
||||
{
|
||||
"indexed": true,
|
||||
"internalType": "address",
|
||||
"name": "to",
|
||||
"type": "address"
|
||||
},
|
||||
{
|
||||
"indexed": false,
|
||||
"internalType": "uint256",
|
||||
"name": "value",
|
||||
"type": "uint256"
|
||||
}
|
||||
],
|
||||
"name": "Transfer",
|
||||
"type": "event"
|
||||
},
|
||||
{
|
||||
"inputs": [
|
||||
{
|
||||
"internalType": "address",
|
||||
"name": "owner",
|
||||
"type": "address"
|
||||
},
|
||||
{
|
||||
"internalType": "address",
|
||||
"name": "spender",
|
||||
"type": "address"
|
||||
}
|
||||
],
|
||||
"name": "allowance",
|
||||
"outputs": [
|
||||
{
|
||||
"internalType": "uint256",
|
||||
"name": "",
|
||||
"type": "uint256"
|
||||
}
|
||||
],
|
||||
"stateMutability": "view",
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"inputs": [
|
||||
{
|
||||
"internalType": "address",
|
||||
"name": "spender",
|
||||
"type": "address"
|
||||
},
|
||||
{
|
||||
"internalType": "uint256",
|
||||
"name": "amount",
|
||||
"type": "uint256"
|
||||
}
|
||||
],
|
||||
"name": "approve",
|
||||
"outputs": [
|
||||
{
|
||||
"internalType": "bool",
|
||||
"name": "",
|
||||
"type": "bool"
|
||||
}
|
||||
],
|
||||
"stateMutability": "nonpayable",
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"inputs": [
|
||||
{
|
||||
"internalType": "address",
|
||||
"name": "account",
|
||||
"type": "address"
|
||||
}
|
||||
],
|
||||
"name": "balanceOf",
|
||||
"outputs": [
|
||||
{
|
||||
"internalType": "uint256",
|
||||
"name": "",
|
||||
"type": "uint256"
|
||||
}
|
||||
],
|
||||
"stateMutability": "view",
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"inputs": [],
|
||||
"name": "decimals",
|
||||
"outputs": [
|
||||
{
|
||||
"internalType": "uint8",
|
||||
"name": "",
|
||||
"type": "uint8"
|
||||
}
|
||||
],
|
||||
"stateMutability": "view",
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"inputs": [],
|
||||
"name": "name",
|
||||
"outputs": [
|
||||
{
|
||||
"internalType": "string",
|
||||
"name": "",
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"stateMutability": "view",
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"inputs": [],
|
||||
"name": "symbol",
|
||||
"outputs": [
|
||||
{
|
||||
"internalType": "string",
|
||||
"name": "",
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"stateMutability": "view",
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"inputs": [],
|
||||
"name": "totalSupply",
|
||||
"outputs": [
|
||||
{
|
||||
"internalType": "uint256",
|
||||
"name": "",
|
||||
"type": "uint256"
|
||||
}
|
||||
],
|
||||
"stateMutability": "view",
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"inputs": [
|
||||
{
|
||||
"internalType": "address",
|
||||
"name": "to",
|
||||
"type": "address"
|
||||
},
|
||||
{
|
||||
"internalType": "uint256",
|
||||
"name": "amount",
|
||||
"type": "uint256"
|
||||
}
|
||||
],
|
||||
"name": "transfer",
|
||||
"outputs": [
|
||||
{
|
||||
"internalType": "bool",
|
||||
"name": "",
|
||||
"type": "bool"
|
||||
}
|
||||
],
|
||||
"stateMutability": "nonpayable",
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"inputs": [
|
||||
{
|
||||
"internalType": "address",
|
||||
"name": "from",
|
||||
"type": "address"
|
||||
},
|
||||
{
|
||||
"internalType": "address",
|
||||
"name": "to",
|
||||
"type": "address"
|
||||
},
|
||||
{
|
||||
"internalType": "uint256",
|
||||
"name": "amount",
|
||||
"type": "uint256"
|
||||
}
|
||||
],
|
||||
"name": "transferFrom",
|
||||
"outputs": [
|
||||
{
|
||||
"internalType": "bool",
|
||||
"name": "",
|
||||
"type": "bool"
|
||||
}
|
||||
],
|
||||
"stateMutability": "nonpayable",
|
||||
"type": "function"
|
||||
}
|
||||
]
|
||||
|
|
@ -0,0 +1 @@
|
|||
608060405234801561001057600080fd5b5033600081815260208181526040808320670de0b6b3a76400009081905590519081527fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef910160405180910390a36106fb8061006d6000396000f3fe608060405234801561001057600080fd5b50600436106100935760003560e01c8063313ce56711610066578063313ce5671461012b57806370a082311461014557806395d89b411461016e578063a9059cbb14610192578063dd62ed3e146101a557600080fd5b806306fdde0314610098578063095ea7b3146100d857806318160ddd146100fb57806323b872dd14610118575b600080fd5b6100c26040518060400160405280600b81526020016a111d5c9a585b881554d11560aa1b81525081565b6040516100cf91906105a0565b60405180910390f35b6100eb6100e636600461060a565b6101de565b60405190151581526020016100cf565b61010a670de0b6b3a764000081565b6040519081526020016100cf565b6100eb610126366004610634565b6102a0565b610133600681565b60405160ff90911681526020016100cf565b61010a610153366004610670565b6001600160a01b031660009081526020819052604090205490565b6100c260405180604001604052806005815260200164191554d11560da1b81525081565b6100eb6101a036600461060a565b61049a565b61010a6101b3366004610692565b6001600160a01b03918216600090815260016020908152604080832093909416825291909152205490565b60006001600160a01b03831661023b5760405162461bcd60e51b815260206004820152601760248201527f417070726f766520746f207a65726f206164647265737300000000000000000060448201526064015b60405180910390fd5b3360008181526001602090815260408083206001600160a01b03881680855290835292819020869055518581529192917f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b92591015b60405180910390a350600192915050565b60006001600160a01b0384166102f85760405162461bcd60e51b815260206004820152601a60248201527f5472616e736665722066726f6d207a65726f20616464726573730000000000006044820152606401610232565b6001600160a01b0383166103495760405162461bcd60e51b81526020600482015260186024820152775472616e7366657220746f207a65726f206164647265737360401b6044820152606401610232565b6001600160a01b0384166000908152602081905260409020548211156103a85760405162461bcd60e51b8152602060048201526014602482015273496e73756666696369656e742062616c616e636560601b6044820152606401610232565b6001600160a01b03841660009081526001602090815260408083203384529091529020548211156104145760405162461bcd60e51b8152602060048201526016602482015275496e73756666696369656e7420616c6c6f77616e636560501b6044820152606401610232565b6001600160a01b03848116600081815260208181526040808320805488900390559387168083528483208054880190558383526001825284832033845282529184902080548790039055925185815290927fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef910160405180910390a35060019392505050565b60006001600160a01b0383166104ed5760405162461bcd60e51b81526020600482015260186024820152775472616e7366657220746f207a65726f206164647265737360401b6044820152606401610232565b336000908152602081905260409020548211156105435760405162461bcd60e51b8152602060048201526014602482015273496e73756666696369656e742062616c616e636560601b6044820152606401610232565b33600081815260208181526040808320805487900390556001600160a01b03871680845292819020805487019055518581529192917fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef910161028f565b600060208083528351808285015260005b818110156105cd578581018301518582016040015282016105b1565b506000604082860101526040601f19601f8301168501019250505092915050565b80356001600160a01b038116811461060557600080fd5b919050565b6000806040838503121561061d57600080fd5b610626836105ee565b946020939093013593505050565b60008060006060848603121561064957600080fd5b610652846105ee565b9250610660602085016105ee565b9150604084013590509250925092565b60006020828403121561068257600080fd5b61068b826105ee565b9392505050565b600080604083850312156106a557600080fd5b6106ae836105ee565b91506106bc602084016105ee565b9050925092905056fea264697066735822122028c97073f6e7db0ad943d101cb6873b31c3eb19bcea3eda83148447ab676a5ee64736f6c63430008130033
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
// SPDX-License-Identifier: MIT
|
||||
pragma solidity 0.8.19;
|
||||
|
||||
/**
|
||||
* @title DurianUSDT
|
||||
* @dev Fixed supply ERC-20 token - NO MINTING CAPABILITY
|
||||
* Total Supply: 1,000,000,000,000 (1 Trillion) tokens with 6 decimals (matching USDT)
|
||||
*
|
||||
* IMPORTANT: This contract has NO mint function and NO way to increase supply.
|
||||
* All tokens are minted to the deployer at construction time.
|
||||
*/
|
||||
contract DurianUSDT {
|
||||
string public constant name = "Durian USDT";
|
||||
string public constant symbol = "dUSDT";
|
||||
uint8 public constant decimals = 6;
|
||||
|
||||
// Fixed total supply: 1 trillion tokens (1,000,000,000,000 * 10^6)
|
||||
uint256 public constant totalSupply = 1_000_000_000_000 * 10**6;
|
||||
|
||||
mapping(address => uint256) private _balances;
|
||||
mapping(address => mapping(address => uint256)) private _allowances;
|
||||
|
||||
event Transfer(address indexed from, address indexed to, uint256 value);
|
||||
event Approval(address indexed owner, address indexed spender, uint256 value);
|
||||
|
||||
/**
|
||||
* @dev Constructor - mints entire fixed supply to deployer
|
||||
* No mint function exists - supply is permanently fixed
|
||||
*/
|
||||
constructor() {
|
||||
_balances[msg.sender] = totalSupply;
|
||||
emit Transfer(address(0), msg.sender, totalSupply);
|
||||
}
|
||||
|
||||
function balanceOf(address account) public view returns (uint256) {
|
||||
return _balances[account];
|
||||
}
|
||||
|
||||
function transfer(address to, uint256 amount) public returns (bool) {
|
||||
require(to != address(0), "Transfer to zero address");
|
||||
require(_balances[msg.sender] >= amount, "Insufficient balance");
|
||||
|
||||
unchecked {
|
||||
_balances[msg.sender] -= amount;
|
||||
_balances[to] += amount;
|
||||
}
|
||||
|
||||
emit Transfer(msg.sender, to, amount);
|
||||
return true;
|
||||
}
|
||||
|
||||
function allowance(address owner, address spender) public view returns (uint256) {
|
||||
return _allowances[owner][spender];
|
||||
}
|
||||
|
||||
function approve(address spender, uint256 amount) public returns (bool) {
|
||||
require(spender != address(0), "Approve to zero address");
|
||||
_allowances[msg.sender][spender] = amount;
|
||||
emit Approval(msg.sender, spender, amount);
|
||||
return true;
|
||||
}
|
||||
|
||||
function transferFrom(address from, address to, uint256 amount) public returns (bool) {
|
||||
require(from != address(0), "Transfer from zero address");
|
||||
require(to != address(0), "Transfer to zero address");
|
||||
require(_balances[from] >= amount, "Insufficient balance");
|
||||
require(_allowances[from][msg.sender] >= amount, "Insufficient allowance");
|
||||
|
||||
unchecked {
|
||||
_balances[from] -= amount;
|
||||
_balances[to] += amount;
|
||||
_allowances[from][msg.sender] -= amount;
|
||||
}
|
||||
|
||||
emit Transfer(from, to, amount);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
# Kava EVM 网络配置
|
||||
|
||||
## 主网配置
|
||||
|
||||
| 项目 | 值 |
|
||||
|------|-----|
|
||||
| 网络名称 | Kava EVM Mainnet |
|
||||
| Chain ID | 2222 |
|
||||
| Currency Symbol | KAVA |
|
||||
| RPC URL | https://evm.kava.io |
|
||||
| WebSocket URL | wss://wevm.kava.io |
|
||||
| 区块浏览器 | https://kavascan.com |
|
||||
|
||||
## 测试网配置
|
||||
|
||||
| 项目 | 值 |
|
||||
|------|-----|
|
||||
| 网络名称 | Kava EVM Testnet |
|
||||
| Chain ID | 2221 |
|
||||
| Currency Symbol | KAVA |
|
||||
| RPC URL | https://evm.testnet.kava.io |
|
||||
| 区块浏览器 | https://testnet.kavascan.com |
|
||||
| 水龙头 | https://faucet.kava.io |
|
||||
|
||||
## RPC 端点列表
|
||||
|
||||
### 主网 RPC
|
||||
|
||||
```
|
||||
https://evm.kava.io
|
||||
https://kava-evm.publicnode.com
|
||||
https://kava.api.onfinality.io/public
|
||||
https://evm.kava.chainstacklabs.com
|
||||
```
|
||||
|
||||
### WebSocket (主网)
|
||||
|
||||
```
|
||||
wss://wevm.kava.io
|
||||
wss://kava-evm.publicnode.com
|
||||
```
|
||||
|
||||
## Gas 配置
|
||||
|
||||
| 项目 | 值 |
|
||||
|------|-----|
|
||||
| Gas Price | ~1 Gwei (动态) |
|
||||
| 合约部署 Gas Limit | ~500,000 - 1,000,000 |
|
||||
| 代币转账 Gas Limit | ~65,000 |
|
||||
| 原生转账 Gas Limit | ~21,000 |
|
||||
|
||||
## 在 MetaMask 中添加网络
|
||||
|
||||
### 主网
|
||||
|
||||
1. 打开 MetaMask
|
||||
2. 点击网络选择器 > 添加网络
|
||||
3. 填写以下信息:
|
||||
- 网络名称: `Kava EVM`
|
||||
- RPC URL: `https://evm.kava.io`
|
||||
- Chain ID: `2222`
|
||||
- 货币符号: `KAVA`
|
||||
- 区块浏览器: `https://kavascan.com`
|
||||
|
||||
### 测试网
|
||||
|
||||
1. 打开 MetaMask
|
||||
2. 点击网络选择器 > 添加网络
|
||||
3. 填写以下信息:
|
||||
- 网络名称: `Kava EVM Testnet`
|
||||
- RPC URL: `https://evm.testnet.kava.io`
|
||||
- Chain ID: `2221`
|
||||
- 货币符号: `KAVA`
|
||||
- 区块浏览器: `https://testnet.kavascan.com`
|
||||
|
||||
## Kava 双地址系统
|
||||
|
||||
Kava 网络支持两种地址格式:
|
||||
|
||||
| 类型 | 格式 | 示例 |
|
||||
|------|------|------|
|
||||
| Cosmos 地址 | kava1... | `kava1...abc` |
|
||||
| EVM 地址 | 0x... | `0x4F7E78d6B7C5FC502Ec7039848690f08c8970F1E` |
|
||||
|
||||
同一个私钥可以派生出两种地址,它们共享相同的余额。
|
||||
|
||||
## EVM 兼容性
|
||||
|
||||
Kava EVM 兼容以太坊 EVM,支持:
|
||||
|
||||
- Solidity 智能合约
|
||||
- ERC-20/ERC-721/ERC-1155 代币标准
|
||||
- Web3.js / ethers.js
|
||||
- MetaMask 等以太坊钱包
|
||||
|
||||
### 注意事项
|
||||
|
||||
- Kava EVM **不支持 PUSH0 操作码** (Shanghai 升级的特性)
|
||||
- 编译合约时需要使用 `evmVersion: "paris"` 或更早版本
|
||||
- 推荐使用 Solidity 0.8.19 或更早版本
|
||||
|
||||
## 常用合约地址
|
||||
|
||||
### 主网
|
||||
|
||||
| 代币 | 地址 |
|
||||
|------|------|
|
||||
| WKAVA (Wrapped KAVA) | `0xc86c7C0eFbd6A49B35E8714C5f59D99De09A225b` |
|
||||
| USDT (官方) | `0x919C1c267BC06a7039e03fcc2eF738525769109c` |
|
||||
| USDC | `0xfA9343C3897324496A05fC75abeD6bAC29f8A40f` |
|
||||
| DAI | `0x765277EebeCA2e31912C9946eAe1021199B39C61` |
|
||||
| **dUSDT (绿积分)** | `0xA9F3A35dBa8699c8C681D8db03F0c1A8CEB9D7c3` ⭐ 当前使用 |
|
||||
|
||||
## 资源链接
|
||||
|
||||
- 官网: https://www.kava.io/
|
||||
- 文档: https://docs.kava.io/
|
||||
- GitHub: https://github.com/Kava-Labs
|
||||
- 区块浏览器: https://kavascan.com/
|
||||
- Discord: https://discord.com/invite/kQzh3Uv
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
# 钱包密钥信息
|
||||
|
||||
> **重要安全警告**: 本文件包含私钥,仅供内部使用。切勿将此文件提交到公开仓库或分享给他人。
|
||||
|
||||
## 管理员钱包
|
||||
|
||||
该钱包用于部署和管理 dUSDT 代币合约。
|
||||
|
||||
### 地址信息
|
||||
|
||||
| 项目 | 值 |
|
||||
|------|-----|
|
||||
| EVM 地址 | `0x4F7E78d6B7C5FC502Ec7039848690f08c8970F1E` |
|
||||
| 私钥 | `0x886ea4cffe76c386fecf3ff321ac9ae913737c46c17bc6ce2413752144668a2a` |
|
||||
|
||||
### 余额信息
|
||||
|
||||
| 代币 | 余额 | 备注 |
|
||||
|------|------|------|
|
||||
| KAVA | ~0.45 KAVA | 用于支付 Gas 费用 |
|
||||
| dUSDT | 1,000,000,000,000 | 1万亿,全部供应量 |
|
||||
|
||||
### 查看链接
|
||||
|
||||
- 地址: https://kavascan.com/address/0x4F7E78d6B7C5FC502Ec7039848690f08c8970F1E
|
||||
|
||||
## 导入钱包
|
||||
|
||||
### MetaMask
|
||||
|
||||
1. 打开 MetaMask
|
||||
2. 点击账户图标 > 导入账户
|
||||
3. 选择类型: 私钥
|
||||
4. 粘贴私钥: `886ea4cffe76c386fecf3ff321ac9ae913737c46c17bc6ce2413752144668a2a`
|
||||
5. 点击导入
|
||||
|
||||
### ethers.js
|
||||
|
||||
```javascript
|
||||
import { ethers } from 'ethers';
|
||||
|
||||
const PRIVATE_KEY = '0x886ea4cffe76c386fecf3ff321ac9ae913737c46c17bc6ce2413752144668a2a';
|
||||
const provider = new ethers.JsonRpcProvider('https://evm.kava.io');
|
||||
const wallet = new ethers.Wallet(PRIVATE_KEY, provider);
|
||||
|
||||
console.log('Address:', wallet.address);
|
||||
// Output: 0x4F7E78d6B7C5FC502Ec7039848690f08c8970F1E
|
||||
```
|
||||
|
||||
### Android/Kotlin
|
||||
|
||||
```kotlin
|
||||
// 使用 Web3j 或其他库
|
||||
val privateKey = "886ea4cffe76c386fecf3ff321ac9ae913737c46c17bc6ce2413752144668a2a"
|
||||
val credentials = Credentials.create(privateKey)
|
||||
println("Address: ${credentials.address}")
|
||||
// Output: 0x4F7E78d6B7C5FC502Ec7039848690f08c8970F1E
|
||||
```
|
||||
|
||||
## 地址派生
|
||||
|
||||
该地址是通过以下步骤从私钥派生的:
|
||||
|
||||
1. 私钥 (32 bytes): `886ea4cffe76c386fecf3ff321ac9ae913737c46c17bc6ce2413752144668a2a`
|
||||
2. 公钥 (65 bytes, uncompressed): `047e0b2f84204a2f859f51be78e09af3c504e9525f49d8ab1c537ab9c2a4deb28c3b16870449f50b9b79e959649a78144a5329958a95f6697534be0156b421588b`
|
||||
3. Keccak-256(公钥[1:65])
|
||||
4. 取后 20 bytes: `4f7e78d6b7c5fc502ec7039848690f08c8970f1e`
|
||||
5. 添加 0x 前缀: `0x4F7E78d6B7C5FC502Ec7039848690f08c8970F1E` (含校验和)
|
||||
|
||||
## 安全建议
|
||||
|
||||
1. **备份私钥**: 将私钥安全存储在离线环境中
|
||||
2. **不要分享**: 永远不要将私钥分享给任何人
|
||||
3. **不要提交**: 确保 .gitignore 包含此文件
|
||||
4. **硬件钱包**: 考虑将大额资产转移到硬件钱包
|
||||
5. **多签**: 对于生产环境,考虑使用多签钱包
|
||||
|
||||
## 相关交易
|
||||
|
||||
### 合约部署交易
|
||||
|
||||
- 交易哈希: `0xa73d7dce17723bc5a9d62767c515dadf4ccccc26327ed5637958ce817edd671d`
|
||||
- 查看: https://kavascan.com/tx/0xa73d7dce17723bc5a9d62767c515dadf4ccccc26327ed5637958ce817edd671d
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
# Project-wide Gradle settings
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
org.gradle.parallel=true
|
||||
org.gradle.caching=true
|
||||
|
||||
# Android settings
|
||||
android.useAndroidX=true
|
||||
android.nonTransitiveRClass=true
|
||||
|
||||
# Kotlin settings
|
||||
kotlin.code.style=official
|
||||
BIN
backend/mpc-system/services/service-party-android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
backend/mpc-system/services/service-party-android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
backend/mpc-system/services/service-party-android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
backend/mpc-system/services/service-party-android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
|
|
@ -0,0 +1,184 @@
|
|||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX://www.opengroup.org/onlinepubs/009695399/utilities/xcu_chap02.html
|
||||
#
|
||||
# (2) You need a Java Runtime Environment (JRE) to run Gradle.
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
# Resolve links: $0 may be a link
|
||||
PRG="$0"
|
||||
# Need this for relative symlinks.
|
||||
while [ -h "$PRG" ] ; do
|
||||
ls=`ls -ld "$PRG"`
|
||||
link=`expr "$ls" : '.*-> \(.*\)$'`
|
||||
if expr "$link" : '/.*' > /dev/null; then
|
||||
PRG="$link"
|
||||
else
|
||||
PRG=`dirname "$PRG"`"/$link"
|
||||
fi
|
||||
done
|
||||
SAVED="`pwd`"
|
||||
cd "`dirname \"$PRG\"`/" >/dev/null
|
||||
APP_HOME="`pwd -P`"
|
||||
cd "$SAVED" >/dev/null
|
||||
|
||||
APP_NAME="Gradle"
|
||||
APP_BASE_NAME=`basename "$0"`
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD="maximum"
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
}
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
}
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "`uname`" in
|
||||
CYGWIN* )
|
||||
cygwin=true
|
||||
;;
|
||||
Darwin* )
|
||||
darwin=true
|
||||
;;
|
||||
MSYS* | MINGW* )
|
||||
msys=true
|
||||
;;
|
||||
NONSTOP* )
|
||||
nonstop=true
|
||||
;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD="$JAVA_HOME/jre/sh/java"
|
||||
else
|
||||
JAVACMD="$JAVA_HOME/bin/java"
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD="java"
|
||||
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
|
||||
MAX_FD_LIMIT=`ulimit -H -n`
|
||||
if [ $? -eq 0 ] ; then
|
||||
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
|
||||
MAX_FD="$MAX_FD_LIMIT"
|
||||
fi
|
||||
ulimit -n $MAX_FD
|
||||
if [ $? -ne 0 ] ; then
|
||||
warn "Could not set maximum file descriptor limit: $MAX_FD"
|
||||
fi
|
||||
else
|
||||
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
|
||||
fi
|
||||
fi
|
||||
|
||||
# For Darwin, add options to specify how the application appears in the dock
|
||||
if $darwin; then
|
||||
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
|
||||
fi
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
|
||||
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
|
||||
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
|
||||
JAVACMD=`cygpath --unix "$JAVACMD"`
|
||||
|
||||
# We build the pattern for arguments to be converted via cygpath
|
||||
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
|
||||
SEP=""
|
||||
for dir in $ROOTDIRSRAW ; do
|
||||
ROOTDIRS="$ROOTDIRS$SEP$dir"
|
||||
SEP="|"
|
||||
done
|
||||
OURCYGPATTERN="(^($ROOTDIRS))"
|
||||
# Add a user-defined pattern to the cygpath arguments
|
||||
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
|
||||
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
|
||||
fi
|
||||
# Now convert the arguments - kass://www.gradle.org/
|
||||
i=0
|
||||
for arg in "$@" ; do
|
||||
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
|
||||
CHECK2=`echo "$arg"|egrep -c "^-"` ### Annoying
|
||||
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Annoying
|
||||
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
|
||||
else
|
||||
eval `echo args$i`="\"$arg\""
|
||||
fi
|
||||
i=`expr $i + 1`
|
||||
done
|
||||
case $i in
|
||||
0) set -- ;;
|
||||
1) set -- "$args0" ;;
|
||||
2) set -- "$args0" "$args1" ;;
|
||||
3) set -- "$args0" "$args1" "$args2" ;;
|
||||
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
|
||||
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
|
||||
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
|
||||
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
|
||||
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
|
||||
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Escape application args
|
||||
save () {
|
||||
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
|
||||
echo " "
|
||||
}
|
||||
APP_ARGS=`save "$@"`
|
||||
|
||||
# Collect all arguments for the java command, following the shell://www.gnu.org/software/bash/manual/html_node/Quoting.html
|
||||
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "TSSPartyAndroid"
|
||||
include(":app")
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
@echo off
|
||||
REM Build TSS library for Android using gomobile
|
||||
|
||||
echo === Building TSS Library for Android ===
|
||||
|
||||
REM Check if gomobile is available
|
||||
where gomobile >nul 2>&1
|
||||
if %ERRORLEVEL% NEQ 0 (
|
||||
echo Installing gomobile...
|
||||
go install golang.org/x/mobile/cmd/gomobile@latest
|
||||
gomobile init
|
||||
)
|
||||
|
||||
REM Download dependencies
|
||||
echo Downloading Go dependencies...
|
||||
go mod tidy
|
||||
|
||||
REM Build for Android
|
||||
echo Building Android AAR...
|
||||
gomobile bind -target=android -androidapi=26 -o ..\app\libs\tsslib.aar .
|
||||
|
||||
echo === Build complete! ===
|
||||
echo Output: ..\app\libs\tsslib.aar
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
#!/bin/bash
|
||||
# Build TSS library for Android using gomobile
|
||||
|
||||
set -e
|
||||
|
||||
echo "=== Building TSS Library for Android ==="
|
||||
|
||||
# Check if gomobile is installed
|
||||
if ! command -v gomobile &> /dev/null; then
|
||||
echo "Installing gomobile..."
|
||||
go install golang.org/x/mobile/cmd/gomobile@latest
|
||||
gomobile init
|
||||
fi
|
||||
|
||||
# Download dependencies
|
||||
echo "Downloading Go dependencies..."
|
||||
go mod tidy
|
||||
|
||||
# Build for Android
|
||||
echo "Building Android AAR..."
|
||||
gomobile bind -target=android -androidapi=26 -o ../app/libs/tsslib.aar .
|
||||
|
||||
echo "=== Build complete! ==="
|
||||
echo "Output: ../app/libs/tsslib.aar"
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
module github.com/rwadurian/tsslib
|
||||
|
||||
go 1.24.0
|
||||
|
||||
require github.com/bnb-chain/tss-lib/v2 v2.0.2
|
||||
|
||||
require (
|
||||
github.com/agl/ed25519 v0.0.0-20200225211852-fd4d107ace12 // indirect
|
||||
github.com/btcsuite/btcd v0.23.4 // indirect
|
||||
github.com/btcsuite/btcd/btcec/v2 v2.3.2 // indirect
|
||||
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1 // indirect
|
||||
github.com/btcsuite/btcutil v1.0.2 // indirect
|
||||
github.com/decred/dcrd/dcrec/edwards/v2 v2.0.3 // indirect
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/google/go-cmp v0.6.0 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||
github.com/ipfs/go-log v1.0.5 // indirect
|
||||
github.com/ipfs/go-log/v2 v2.5.1 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/opentracing/opentracing-go v1.2.0 // indirect
|
||||
github.com/otiai10/primes v0.0.0-20210501021515-f1b2be525a11 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.uber.org/zap v1.26.0 // indirect
|
||||
golang.org/x/crypto v0.13.0 // indirect
|
||||
golang.org/x/mobile v0.0.0-20251209145715-2553ed8ce294 // indirect
|
||||
golang.org/x/mod v0.31.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
golang.org/x/tools v0.40.0 // indirect
|
||||
google.golang.org/protobuf v1.31.0 // indirect
|
||||
)
|
||||
|
||||
// Replace to fix tss-lib dependency issue with ed25519
|
||||
replace github.com/agl/ed25519 => github.com/agl/ed25519 v0.0.0-20170116200512-5312a6153412
|
||||
|
|
@ -0,0 +1,272 @@
|
|||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII=
|
||||
github.com/agl/ed25519 v0.0.0-20170116200512-5312a6153412 h1:w1UutsfOrms1J05zt7ISrnJIXKzwaspym5BTKGx93EI=
|
||||
github.com/agl/ed25519 v0.0.0-20170116200512-5312a6153412/go.mod h1:WPjqKcmVOxf0XSf3YxCJs6N6AOSrOx3obionmG7T0y0=
|
||||
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
|
||||
github.com/bnb-chain/tss-lib/v2 v2.0.2 h1:dL2GJFCSYsYQ0bHkGll+hNM2JWsC1rxDmJJJQEmUy9g=
|
||||
github.com/bnb-chain/tss-lib/v2 v2.0.2/go.mod h1:s4LRfEqj89DhfNb+oraW0dURt5LtOHWXb9Gtkghn0L8=
|
||||
github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ=
|
||||
github.com/btcsuite/btcd v0.22.0-beta.0.20220111032746-97732e52810c/go.mod h1:tjmYdS6MLJ5/s0Fj4DbLgSbDHbEqLJrtnHecBFkdz5M=
|
||||
github.com/btcsuite/btcd v0.23.4 h1:IzV6qqkfwbItOS/sg/aDfPDsjPP8twrCOE2R93hxMlQ=
|
||||
github.com/btcsuite/btcd v0.23.4/go.mod h1:0QJIIN1wwIXF/3G/m87gIwGniDMDQqjVn4SZgnFpsYY=
|
||||
github.com/btcsuite/btcd/btcec/v2 v2.1.0/go.mod h1:2VzYrv4Gm4apmbVVsSq5bqf1Ec8v56E48Vt0Y/umPgA=
|
||||
github.com/btcsuite/btcd/btcec/v2 v2.1.3/go.mod h1:ctjw4H1kknNJmRN4iP1R7bTQ+v3GJkZBd6mui8ZsAZE=
|
||||
github.com/btcsuite/btcd/btcec/v2 v2.3.2 h1:5n0X6hX0Zk+6omWcihdYvdAlGf2DfasC0GMf7DClJ3U=
|
||||
github.com/btcsuite/btcd/btcec/v2 v2.3.2/go.mod h1:zYzJ8etWJQIv1Ogk7OzpWjowwOdXY1W/17j2MW85J04=
|
||||
github.com/btcsuite/btcd/btcutil v1.0.0/go.mod h1:Uoxwv0pqYWhD//tfTiipkxNfdhG9UrLwaeswfjfdF0A=
|
||||
github.com/btcsuite/btcd/btcutil v1.1.0/go.mod h1:5OapHB7A2hBBWLm48mmw4MOHNJCcUBTwmWH/0Jn8VHE=
|
||||
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
|
||||
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1 h1:q0rUy8C/TYNBQS1+CGKw68tLOFYSNEs0TFnxxnS9+4U=
|
||||
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
|
||||
github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA=
|
||||
github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg=
|
||||
github.com/btcsuite/btcutil v1.0.2 h1:9iZ1Terx9fMIOtq1VrwdqfsATL9MC2l8ZrUY6YZ2uts=
|
||||
github.com/btcsuite/btcutil v1.0.2/go.mod h1:j9HUFwoQRsZL3V4n+qG+CUnEGHOarIxfC3Le2Yhbcts=
|
||||
github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg=
|
||||
github.com/btcsuite/goleveldb v0.0.0-20160330041536-7834afc9e8cd/go.mod h1:F+uVaaLLH7j4eDXPRvw78tMflu7Ie2bzYOH4Y8rRKBY=
|
||||
github.com/btcsuite/goleveldb v1.0.0/go.mod h1:QiK9vBlgftBg6rWQIj6wFzbPfRjiykIEhBH4obrXJ/I=
|
||||
github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc=
|
||||
github.com/btcsuite/snappy-go v1.0.0/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc=
|
||||
github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY=
|
||||
github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
|
||||
github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc=
|
||||
github.com/decred/dcrd/dcrec/edwards/v2 v2.0.3 h1:l/lhv2aJCUignzls81+wvga0TFlyoZx8QxRMQgXpZik=
|
||||
github.com/decred/dcrd/dcrec/edwards/v2 v2.0.3/go.mod h1:AKpV6+wZ2MfPRJnTbQ6NPgWrKzbe9RCIlCF/FKzMtM8=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0=
|
||||
github.com/decred/dcrd/lru v1.0.0/go.mod h1:mxKOwFd7lFjN2GZYsiz/ecgqR6kkYAl+0pz0tEMk218=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
||||
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
||||
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
|
||||
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/ipfs/go-log v1.0.5 h1:2dOuUCB1Z7uoczMWgAyDck5JLb72zHzrMnGnCNNbvY8=
|
||||
github.com/ipfs/go-log v1.0.5/go.mod h1:j0b8ZoR+7+R99LD9jZ6+AJsrzkPbSXbZfGakb5JPtIo=
|
||||
github.com/ipfs/go-log/v2 v2.1.3/go.mod h1:/8d0SH3Su5Ooc31QlL1WysJhvyOTDCjcCZ9Axpmri6g=
|
||||
github.com/ipfs/go-log/v2 v2.5.1 h1:1XdUzF7048prq4aBjDQQ4SL5RxftpRGdXhNRwKSAlcY=
|
||||
github.com/ipfs/go-log/v2 v2.5.1/go.mod h1:prSpmC1Gpllc9UYWxDiZDreBYw7zp4Iqp1kOLU9U5UI=
|
||||
github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||
github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
|
||||
github.com/onsi/ginkgo v1.14.0/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY=
|
||||
github.com/onsi/gomega v1.4.1/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA=
|
||||
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
||||
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
|
||||
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
|
||||
github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs=
|
||||
github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=
|
||||
github.com/otiai10/curr v0.0.0-20150429015615-9b4961190c95/go.mod h1:9qAhocn7zKJG+0mI8eUu6xqkFDYS2kb2saOteoSB3cE=
|
||||
github.com/otiai10/curr v1.0.0/go.mod h1:LskTG5wDwr8Rs+nNQ+1LlxRjAtTZZjtJW4rMXl6j4vs=
|
||||
github.com/otiai10/jsonindent v0.0.0-20171116142732-447bf004320b/go.mod h1:SXIpH2WO0dyF5YBc6Iq8jc8TEJYe1Fk2Rc1EVYUdIgY=
|
||||
github.com/otiai10/mint v1.3.0/go.mod h1:F5AjcsTsWUqX+Na9fpHb52P8pcRX2CI6A3ctIT91xUo=
|
||||
github.com/otiai10/mint v1.3.2 h1:VYWnrP5fXmz1MXvjuUvcBrXSjGE6xjON+axB/UrpO3E=
|
||||
github.com/otiai10/mint v1.3.2/go.mod h1:/yxELlJQ0ufhjUwhshSj+wFjZ78CnZ48/1wtmBH1OTc=
|
||||
github.com/otiai10/primes v0.0.0-20210501021515-f1b2be525a11 h1:7x5D/2dkkr27Tgh4WFuX+iCS6OzuE5YJoqJzeqM+5mc=
|
||||
github.com/otiai10/primes v0.0.0-20210501021515-f1b2be525a11/go.mod h1:1DmRMnU78i/OVkMnHzvhXSi4p8IhYUmtLJWhyOavJc0=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc=
|
||||
github.com/urfave/cli v1.22.5/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
|
||||
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
|
||||
go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk=
|
||||
go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo=
|
||||
go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU=
|
||||
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA=
|
||||
go.uber.org/zap v1.16.0/go.mod h1:MA8QOfq0BHJwdXa996Y4dYkAqRKB8/1K1QMMZVaNZjQ=
|
||||
go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI=
|
||||
go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo=
|
||||
go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
|
||||
golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200115085410-6d4e4cb37c7d/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/mobile v0.0.0-20250106192035-c31d5b91ecc3 h1:8LrYkH99trX3onYF3dT9frUSRDXokkceG+9tHBaDAFQ=
|
||||
golang.org/x/mobile v0.0.0-20250106192035-c31d5b91ecc3/go.mod h1:sY92m3V/rTEa4JCJ1FkKHK978K6wxOSX1PStMYo+6wI=
|
||||
golang.org/x/mobile v0.0.0-20251209145715-2553ed8ce294 h1:Cr6kbEvA6nqvdHynE4CtVKlqpZB9dS1Jva/6IsHA19g=
|
||||
golang.org/x/mobile v0.0.0-20251209145715-2553ed8ce294/go.mod h1:RdZ+3sb4CVgpCFnzv+I4haEpwqFfsfzlLHs3L7ok+e0=
|
||||
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4=
|
||||
golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
|
||||
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
|
||||
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
|
||||
golang.org/x/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||
golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.29.0 h1:Xx0h3TtM9rzQpQuR4dKLrdglAmCEN5Oi+P74JdhdzXE=
|
||||
golang.org/x/tools v0.29.0/go.mod h1:KMQVMRsVxU6nHCFXrBPhDB8XncLNLM0lIy/F14RP588=
|
||||
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
|
||||
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
|
||||
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
|
||||
|
|
@ -0,0 +1,657 @@
|
|||
// Package tsslib provides TSS (Threshold Signature Scheme) functionality for Android
|
||||
// This package is designed to be compiled with gomobile for Android integration via JNI
|
||||
//
|
||||
// Based on the verified tss-party implementation from service-party-app (Electron version)
|
||||
package tsslib
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/bnb-chain/tss-lib/v2/common"
|
||||
"github.com/bnb-chain/tss-lib/v2/ecdsa/keygen"
|
||||
"github.com/bnb-chain/tss-lib/v2/ecdsa/signing"
|
||||
"github.com/bnb-chain/tss-lib/v2/tss"
|
||||
)
|
||||
|
||||
// Regex to extract round number from tss-lib message type
|
||||
// Message types look like: "binance.tsslib.ecdsa.keygen.KGRound1Message"
|
||||
// or "binance.tsslib.ecdsa.signing.SignRound3Message"
|
||||
var roundRegex = regexp.MustCompile(`Round(\d+)`)
|
||||
|
||||
// MessageCallback is the interface for receiving TSS protocol messages
|
||||
// Android side implements this interface to handle message routing
|
||||
type MessageCallback interface {
|
||||
// OnOutgoingMessage is called when TSS needs to send a message to other parties
|
||||
// messageJSON contains: type, isBroadcast, toParties, payload (base64)
|
||||
OnOutgoingMessage(messageJSON string)
|
||||
|
||||
// OnProgress is called to report protocol progress
|
||||
OnProgress(round, totalRounds int)
|
||||
|
||||
// OnError is called when an error occurs
|
||||
OnError(errorMessage string)
|
||||
|
||||
// OnLog is called for debug logging
|
||||
OnLog(message string)
|
||||
}
|
||||
|
||||
// Participant represents a party in the TSS protocol
|
||||
type Participant struct {
|
||||
PartyID string `json:"partyId"`
|
||||
PartyIndex int `json:"partyIndex"`
|
||||
}
|
||||
|
||||
// tssSession manages a TSS keygen or signing session
|
||||
type tssSession struct {
|
||||
mu sync.Mutex
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
callback MessageCallback
|
||||
localParty tss.Party
|
||||
partyIndexMap map[int]*tss.PartyID
|
||||
errCh chan error
|
||||
keygenResultCh chan *keygen.LocalPartySaveData
|
||||
signResultCh chan *common.SignatureData
|
||||
isKeygen bool
|
||||
}
|
||||
|
||||
var (
|
||||
currentSession *tssSession
|
||||
sessionMu sync.Mutex
|
||||
)
|
||||
|
||||
// StartKeygen initiates a new key generation session
|
||||
// This is the entry point called from Android via JNI
|
||||
func StartKeygen(
|
||||
sessionID, partyID string,
|
||||
partyIndex, thresholdT, thresholdN int,
|
||||
participantsJSON, password string,
|
||||
callback MessageCallback,
|
||||
) error {
|
||||
sessionMu.Lock()
|
||||
defer sessionMu.Unlock()
|
||||
|
||||
if currentSession != nil {
|
||||
return fmt.Errorf("a session is already in progress")
|
||||
}
|
||||
|
||||
// Parse participants
|
||||
var participants []Participant
|
||||
if err := json.Unmarshal([]byte(participantsJSON), &participants); err != nil {
|
||||
return fmt.Errorf("failed to parse participants: %w", err)
|
||||
}
|
||||
|
||||
if len(participants) != thresholdN {
|
||||
return fmt.Errorf("participant count mismatch: got %d, expected %d", len(participants), thresholdN)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||
|
||||
session := &tssSession{
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
callback: callback,
|
||||
partyIndexMap: make(map[int]*tss.PartyID),
|
||||
errCh: make(chan error, 1),
|
||||
keygenResultCh: make(chan *keygen.LocalPartySaveData, 1),
|
||||
isKeygen: true,
|
||||
}
|
||||
|
||||
// Create TSS party IDs - same as verified Electron version
|
||||
tssPartyIDs := make([]*tss.PartyID, len(participants))
|
||||
var selfTSSID *tss.PartyID
|
||||
|
||||
for i, p := range participants {
|
||||
partyKey := tss.NewPartyID(
|
||||
p.PartyID,
|
||||
fmt.Sprintf("party-%d", p.PartyIndex),
|
||||
big.NewInt(int64(p.PartyIndex+1)),
|
||||
)
|
||||
tssPartyIDs[i] = partyKey
|
||||
if p.PartyID == partyID {
|
||||
selfTSSID = partyKey
|
||||
}
|
||||
}
|
||||
|
||||
if selfTSSID == nil {
|
||||
cancel()
|
||||
return fmt.Errorf("self party not found in participants")
|
||||
}
|
||||
|
||||
// Sort party IDs
|
||||
sortedPartyIDs := tss.SortPartyIDs(tssPartyIDs)
|
||||
|
||||
// Build party index map for incoming messages
|
||||
for _, p := range sortedPartyIDs {
|
||||
for _, orig := range participants {
|
||||
if orig.PartyID == p.Id {
|
||||
session.partyIndexMap[orig.PartyIndex] = p
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create peer context and parameters
|
||||
// IMPORTANT: TSS-lib threshold convention: threshold=t means (t+1) signers required
|
||||
// User says "2-of-3" meaning 2 signers needed, so we pass (thresholdT-1) to TSS-lib
|
||||
// For 2-of-3: thresholdT=2, tss-lib threshold=1, signers_needed=1+1=2 ✓
|
||||
peerCtx := tss.NewPeerContext(sortedPartyIDs)
|
||||
tssThreshold := thresholdT - 1
|
||||
params := tss.NewParameters(tss.S256(), peerCtx, selfTSSID, len(sortedPartyIDs), tssThreshold)
|
||||
|
||||
callback.OnLog(fmt.Sprintf("[TSS-KEYGEN] NewParameters: partyCount=%d, tssThreshold=%d (from thresholdT=%d, means %d signers needed)",
|
||||
len(sortedPartyIDs), tssThreshold, thresholdT, thresholdT))
|
||||
|
||||
// Create channels
|
||||
outCh := make(chan tss.Message, thresholdN*10)
|
||||
endCh := make(chan *keygen.LocalPartySaveData, 1)
|
||||
|
||||
// Create local party
|
||||
localParty := keygen.NewLocalParty(params, outCh, endCh)
|
||||
session.localParty = localParty
|
||||
|
||||
// Start the local party
|
||||
go func() {
|
||||
if err := localParty.Start(); err != nil {
|
||||
session.errCh <- err
|
||||
}
|
||||
}()
|
||||
|
||||
// Handle outgoing messages
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case msg, ok := <-outCh:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
session.handleOutgoingMessage(msg)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Handle completion
|
||||
go func() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
callback.OnError("session timeout or cancelled")
|
||||
case err := <-session.errCh:
|
||||
callback.OnError(fmt.Sprintf("keygen error: %v", err))
|
||||
case saveData := <-endCh:
|
||||
session.keygenResultCh <- saveData
|
||||
}
|
||||
}()
|
||||
|
||||
currentSession = session
|
||||
return nil
|
||||
}
|
||||
|
||||
// StartSign initiates a new signing session
|
||||
// Based on verified executeSign from Electron version
|
||||
func StartSign(
|
||||
sessionID, partyID string,
|
||||
partyIndex, thresholdT, thresholdN int,
|
||||
participantsJSON, messageHashHex, shareDataBase64, password string,
|
||||
callback MessageCallback,
|
||||
) error {
|
||||
sessionMu.Lock()
|
||||
defer sessionMu.Unlock()
|
||||
|
||||
if currentSession != nil {
|
||||
return fmt.Errorf("a session is already in progress")
|
||||
}
|
||||
|
||||
// Parse participants
|
||||
var participants []Participant
|
||||
if err := json.Unmarshal([]byte(participantsJSON), &participants); err != nil {
|
||||
return fmt.Errorf("failed to parse participants: %w", err)
|
||||
}
|
||||
|
||||
// Note: For signing, participant count equals threshold T (not N)
|
||||
// because only T parties participate in signing
|
||||
if len(participants) != thresholdT {
|
||||
return fmt.Errorf("participant count mismatch: got %d, expected %d (threshold T)", len(participants), thresholdT)
|
||||
}
|
||||
|
||||
// Decode and decrypt share data
|
||||
encryptedShare, err := base64.StdEncoding.DecodeString(shareDataBase64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decode share data: %w", err)
|
||||
}
|
||||
|
||||
shareBytes, err := decryptShare(encryptedShare, password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decrypt share: %w", err)
|
||||
}
|
||||
|
||||
// Parse keygen save data
|
||||
var keygenData keygen.LocalPartySaveData
|
||||
if err := json.Unmarshal(shareBytes, &keygenData); err != nil {
|
||||
return fmt.Errorf("failed to parse keygen data: %w", err)
|
||||
}
|
||||
|
||||
// Decode message hash
|
||||
messageHash, err := hex.DecodeString(messageHashHex)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decode message hash: %w", err)
|
||||
}
|
||||
|
||||
if len(messageHash) != 32 {
|
||||
return fmt.Errorf("message hash must be 32 bytes, got %d", len(messageHash))
|
||||
}
|
||||
|
||||
msgBigInt := new(big.Int).SetBytes(messageHash)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||
|
||||
session := &tssSession{
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
callback: callback,
|
||||
partyIndexMap: make(map[int]*tss.PartyID),
|
||||
errCh: make(chan error, 1),
|
||||
signResultCh: make(chan *common.SignatureData, 1),
|
||||
isKeygen: false,
|
||||
}
|
||||
|
||||
// Create TSS party IDs for signing participants
|
||||
// IMPORTANT: For tss-lib signing, we must reconstruct the party IDs in the same way
|
||||
// as during keygen. The signing subset (T parties) must use their original keys from keygen.
|
||||
tssPartyIDs := make([]*tss.PartyID, 0, len(participants))
|
||||
var selfTSSID *tss.PartyID
|
||||
|
||||
for _, p := range participants {
|
||||
partyKey := tss.NewPartyID(
|
||||
p.PartyID,
|
||||
fmt.Sprintf("party-%d", p.PartyIndex),
|
||||
big.NewInt(int64(p.PartyIndex+1)),
|
||||
)
|
||||
tssPartyIDs = append(tssPartyIDs, partyKey)
|
||||
if p.PartyID == partyID {
|
||||
selfTSSID = partyKey
|
||||
}
|
||||
}
|
||||
|
||||
if selfTSSID == nil {
|
||||
cancel()
|
||||
return fmt.Errorf("self party not found in participants")
|
||||
}
|
||||
|
||||
// Sort party IDs (important for tss-lib)
|
||||
sortedPartyIDs := tss.SortPartyIDs(tssPartyIDs)
|
||||
|
||||
// Build party index map for incoming messages
|
||||
for _, p := range sortedPartyIDs {
|
||||
for _, orig := range participants {
|
||||
if orig.PartyID == p.Id {
|
||||
session.partyIndexMap[orig.PartyIndex] = p
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create peer context and parameters
|
||||
// IMPORTANT: TSS-lib threshold convention: threshold=t means (t+1) signers required
|
||||
// This MUST match keygen exactly!
|
||||
peerCtx := tss.NewPeerContext(sortedPartyIDs)
|
||||
tssThreshold := thresholdT - 1
|
||||
params := tss.NewParameters(tss.S256(), peerCtx, selfTSSID, len(sortedPartyIDs), tssThreshold)
|
||||
|
||||
callback.OnLog(fmt.Sprintf("[TSS-SIGN] NewParameters: partyCount=%d, tssThreshold=%d (from thresholdT=%d, means %d signers needed)",
|
||||
len(sortedPartyIDs), tssThreshold, thresholdT, thresholdT))
|
||||
|
||||
callback.OnLog(fmt.Sprintf("[TSS-SIGN] Original keygenData has %d parties (Ks length)", len(keygenData.Ks)))
|
||||
callback.OnLog(fmt.Sprintf("[TSS-SIGN] Building subset for %d signing parties", len(sortedPartyIDs)))
|
||||
|
||||
// CRITICAL: Build a subset of the keygen save data for the current signing parties
|
||||
// This is required when signing with a subset of the original keygen participants.
|
||||
subsetKeygenData := keygen.BuildLocalSaveDataSubset(keygenData, sortedPartyIDs)
|
||||
callback.OnLog(fmt.Sprintf("[TSS-SIGN] Subset keygenData has %d parties (Ks length)", len(subsetKeygenData.Ks)))
|
||||
|
||||
// Create channels
|
||||
outCh := make(chan tss.Message, thresholdT*10)
|
||||
endCh := make(chan *common.SignatureData, 1)
|
||||
|
||||
// Create local party for signing with the SUBSET keygen data
|
||||
localParty := signing.NewLocalParty(msgBigInt, params, subsetKeygenData, outCh, endCh)
|
||||
session.localParty = localParty
|
||||
|
||||
// Start the local party
|
||||
go func() {
|
||||
if err := localParty.Start(); err != nil {
|
||||
session.errCh <- err
|
||||
}
|
||||
}()
|
||||
|
||||
// Handle outgoing messages
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case msg, ok := <-outCh:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
session.handleOutgoingMessage(msg)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Handle completion
|
||||
go func() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
callback.OnError("session timeout or cancelled")
|
||||
case err := <-session.errCh:
|
||||
callback.OnError(fmt.Sprintf("sign error: %v", err))
|
||||
case sigData := <-endCh:
|
||||
session.signResultCh <- sigData
|
||||
}
|
||||
}()
|
||||
|
||||
currentSession = session
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendIncomingMessage delivers a message from another party to the current session
|
||||
func SendIncomingMessage(fromPartyIndex int, isBroadcast bool, payloadBase64 string) error {
|
||||
sessionMu.Lock()
|
||||
session := currentSession
|
||||
sessionMu.Unlock()
|
||||
|
||||
if session == nil {
|
||||
return fmt.Errorf("no active session")
|
||||
}
|
||||
|
||||
session.mu.Lock()
|
||||
defer session.mu.Unlock()
|
||||
|
||||
fromParty, ok := session.partyIndexMap[fromPartyIndex]
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown party index: %d", fromPartyIndex)
|
||||
}
|
||||
|
||||
payload, err := base64.StdEncoding.DecodeString(payloadBase64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decode payload: %w", err)
|
||||
}
|
||||
|
||||
parsedMsg, err := tss.ParseWireMessage(payload, fromParty, isBroadcast)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse message: %w", err)
|
||||
}
|
||||
|
||||
go func() {
|
||||
_, err := session.localParty.Update(parsedMsg)
|
||||
if err != nil {
|
||||
// Only send fatal errors
|
||||
if !isDuplicateError(err) {
|
||||
session.errCh <- err
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// WaitForKeygenResult blocks until keygen completes and returns the result as JSON
|
||||
func WaitForKeygenResult(password string) (string, error) {
|
||||
sessionMu.Lock()
|
||||
session := currentSession
|
||||
sessionMu.Unlock()
|
||||
|
||||
if session == nil {
|
||||
return "", fmt.Errorf("no active session")
|
||||
}
|
||||
|
||||
if !session.isKeygen {
|
||||
return "", fmt.Errorf("current session is not a keygen session")
|
||||
}
|
||||
|
||||
// Track progress - GG20 keygen has 4 rounds
|
||||
totalRounds := 4
|
||||
|
||||
select {
|
||||
case <-session.ctx.Done():
|
||||
return "", session.ctx.Err()
|
||||
case saveData := <-session.keygenResultCh:
|
||||
// Keygen completed successfully
|
||||
session.callback.OnProgress(totalRounds, totalRounds)
|
||||
|
||||
// Get public key - same as Electron version
|
||||
pubKey := saveData.ECDSAPub.ToECDSAPubKey()
|
||||
pubKeyBytes := make([]byte, 33)
|
||||
pubKeyBytes[0] = 0x02 + byte(pubKey.Y.Bit(0))
|
||||
xBytes := pubKey.X.Bytes()
|
||||
copy(pubKeyBytes[33-len(xBytes):], xBytes)
|
||||
|
||||
// Serialize and encrypt save data
|
||||
saveDataBytes, err := json.Marshal(saveData)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to serialize save data: %w", err)
|
||||
}
|
||||
|
||||
// Encrypt with password (same as Electron version)
|
||||
encryptedShare := encryptShare(saveDataBytes, password)
|
||||
|
||||
result := struct {
|
||||
PublicKey string `json:"publicKey"`
|
||||
EncryptedShare string `json:"encryptedShare"`
|
||||
}{
|
||||
PublicKey: base64.StdEncoding.EncodeToString(pubKeyBytes),
|
||||
EncryptedShare: base64.StdEncoding.EncodeToString(encryptedShare),
|
||||
}
|
||||
|
||||
resultJSON, _ := json.Marshal(result)
|
||||
|
||||
// Clean up session
|
||||
session.cancel()
|
||||
sessionMu.Lock()
|
||||
currentSession = nil
|
||||
sessionMu.Unlock()
|
||||
|
||||
return string(resultJSON), nil
|
||||
}
|
||||
}
|
||||
|
||||
// WaitForSignResult blocks until signing completes and returns the result as JSON
|
||||
func WaitForSignResult() (string, error) {
|
||||
sessionMu.Lock()
|
||||
session := currentSession
|
||||
sessionMu.Unlock()
|
||||
|
||||
if session == nil {
|
||||
return "", fmt.Errorf("no active session")
|
||||
}
|
||||
|
||||
if session.isKeygen {
|
||||
return "", fmt.Errorf("current session is not a sign session")
|
||||
}
|
||||
|
||||
// Track progress - GG20 signing has 9 rounds
|
||||
totalRounds := 9
|
||||
|
||||
select {
|
||||
case <-session.ctx.Done():
|
||||
return "", session.ctx.Err()
|
||||
case sigData := <-session.signResultCh:
|
||||
// Signing completed successfully
|
||||
session.callback.OnProgress(totalRounds, totalRounds)
|
||||
|
||||
// Construct signature: R (32 bytes) || S (32 bytes)
|
||||
rBytes := sigData.R
|
||||
sBytes := sigData.S
|
||||
|
||||
signature := make([]byte, 64)
|
||||
copy(signature[32-len(rBytes):32], rBytes)
|
||||
copy(signature[64-len(sBytes):64], sBytes)
|
||||
|
||||
// Recovery ID for Ethereum-style signatures
|
||||
recoveryID := int(sigData.SignatureRecovery[0])
|
||||
|
||||
// Append recovery ID to signature (r + s + v = 64 + 1 = 65 bytes)
|
||||
// This is needed for EVM transaction signing
|
||||
signatureWithV := make([]byte, 65)
|
||||
copy(signatureWithV, signature)
|
||||
signatureWithV[64] = byte(recoveryID)
|
||||
|
||||
result := struct {
|
||||
Signature string `json:"signature"`
|
||||
RecoveryID int `json:"recoveryId"`
|
||||
}{
|
||||
Signature: base64.StdEncoding.EncodeToString(signatureWithV),
|
||||
RecoveryID: recoveryID,
|
||||
}
|
||||
|
||||
resultJSON, _ := json.Marshal(result)
|
||||
|
||||
// Clean up session
|
||||
session.cancel()
|
||||
sessionMu.Lock()
|
||||
currentSession = nil
|
||||
sessionMu.Unlock()
|
||||
|
||||
return string(resultJSON), nil
|
||||
}
|
||||
}
|
||||
|
||||
// CancelSession cancels the current session
|
||||
func CancelSession() {
|
||||
sessionMu.Lock()
|
||||
defer sessionMu.Unlock()
|
||||
|
||||
if currentSession != nil {
|
||||
currentSession.cancel()
|
||||
currentSession = nil
|
||||
}
|
||||
}
|
||||
|
||||
// extractRoundFromMessageType parses the round number from a tss-lib message type string.
|
||||
// Returns 0 if parsing fails (safe fallback).
|
||||
// Example: "binance.tsslib.ecdsa.keygen.KGRound2Message1" -> 2
|
||||
func extractRoundFromMessageType(msgType string) int {
|
||||
matches := roundRegex.FindStringSubmatch(msgType)
|
||||
if len(matches) >= 2 {
|
||||
if round, err := strconv.Atoi(matches[1]); err == nil {
|
||||
return round
|
||||
}
|
||||
}
|
||||
return 0 // Safe fallback - doesn't affect protocol, just shows 0 in UI
|
||||
}
|
||||
|
||||
func (s *tssSession) handleOutgoingMessage(msg tss.Message) {
|
||||
msgBytes, _, err := msg.WireBytes()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var toParties []string
|
||||
if !msg.IsBroadcast() {
|
||||
for _, to := range msg.GetTo() {
|
||||
toParties = append(toParties, to.Id)
|
||||
}
|
||||
}
|
||||
|
||||
outMsg := struct {
|
||||
Type string `json:"type"`
|
||||
IsBroadcast bool `json:"isBroadcast"`
|
||||
ToParties []string `json:"toParties,omitempty"`
|
||||
Payload string `json:"payload"`
|
||||
}{
|
||||
Type: "outgoing",
|
||||
IsBroadcast: msg.IsBroadcast(),
|
||||
ToParties: toParties,
|
||||
Payload: base64.StdEncoding.EncodeToString(msgBytes),
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(outMsg)
|
||||
s.callback.OnOutgoingMessage(string(data))
|
||||
|
||||
// Extract current round from message type and send progress update
|
||||
totalRounds := 4 // GG20 keygen has 4 rounds
|
||||
if !s.isKeygen {
|
||||
totalRounds = 9 // GG20 signing has 9 rounds
|
||||
}
|
||||
currentRound := extractRoundFromMessageType(msg.Type())
|
||||
s.callback.OnProgress(currentRound, totalRounds)
|
||||
}
|
||||
|
||||
func isDuplicateError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
errStr := err.Error()
|
||||
return contains(errStr, "duplicate") || contains(errStr, "already received")
|
||||
}
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// encryptShare encrypts the share data with password
|
||||
// Same implementation as Electron version for compatibility
|
||||
func encryptShare(data []byte, password string) []byte {
|
||||
// TODO: Use proper AES-256-GCM encryption
|
||||
// For now, just prepend a marker and the password hash
|
||||
// This is NOT secure - just a placeholder (same as Electron version)
|
||||
result := make([]byte, len(data)+32)
|
||||
copy(result[:32], hashPassword(password))
|
||||
copy(result[32:], data)
|
||||
return result
|
||||
}
|
||||
|
||||
// decryptShare decrypts the share data with password
|
||||
// Same implementation as Electron version for compatibility
|
||||
func decryptShare(encryptedData []byte, password string) ([]byte, error) {
|
||||
// Match the encryption format: first 32 bytes are password hash, rest is data
|
||||
if len(encryptedData) < 32 {
|
||||
return nil, fmt.Errorf("encrypted data too short")
|
||||
}
|
||||
|
||||
// Verify password (simple check - matches encryptShare)
|
||||
expectedHash := hashPassword(password)
|
||||
actualHash := encryptedData[:32]
|
||||
|
||||
// Simple comparison
|
||||
match := true
|
||||
for i := 0; i < 32; i++ {
|
||||
if expectedHash[i] != actualHash[i] {
|
||||
match = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !match {
|
||||
return nil, fmt.Errorf("incorrect password")
|
||||
}
|
||||
|
||||
return encryptedData[32:], nil
|
||||
}
|
||||
|
||||
// hashPassword creates a simple hash of the password
|
||||
// Same implementation as Electron version for compatibility
|
||||
func hashPassword(password string) []byte {
|
||||
// Simple hash - should use PBKDF2 or Argon2 in production
|
||||
hash := make([]byte, 32)
|
||||
for i := 0; i < len(password) && i < 32; i++ {
|
||||
hash[i] = password[i]
|
||||
}
|
||||
return hash
|
||||
}
|
||||
|
|
@ -77,6 +77,26 @@ let activeKeygenSession: ActiveKeygenSession | null = null;
|
|||
// Keygen 幂等性保护:追踪正在进行的 keygen 会话 ID
|
||||
let keygenInProgressSessionId: string | null = null;
|
||||
|
||||
// ===========================================================================
|
||||
// Co-Sign 相关状态
|
||||
// ===========================================================================
|
||||
|
||||
// 当前正在进行的 Co-Sign 会话信息
|
||||
interface ActiveCoSignSession {
|
||||
sessionId: string;
|
||||
partyIndex: number;
|
||||
participants: Array<{ partyId: string; partyIndex: number; name: string }>;
|
||||
threshold: { t: number; n: number };
|
||||
walletName: string;
|
||||
messageHash: string;
|
||||
shareId: string;
|
||||
sharePassword: string;
|
||||
}
|
||||
let activeCoSignSession: ActiveCoSignSession | null = null;
|
||||
|
||||
// Co-Sign 幂等性保护:追踪正在进行的签名会话 ID
|
||||
let signInProgressSessionId: string | null = null;
|
||||
|
||||
// 会话事件缓存 - 解决前端订阅时可能错过事件的时序问题
|
||||
// 当事件到达时,前端可能还在页面导航中,尚未订阅
|
||||
interface SessionEventData {
|
||||
|
|
@ -394,8 +414,8 @@ async function initServices() {
|
|||
}
|
||||
});
|
||||
|
||||
// 初始化 Kava 交易服务 (从数据库读取网络设置,默认测试网)
|
||||
const kavaNetwork = database.getSetting('kava_network') || 'testnet';
|
||||
// 初始化 Kava 交易服务 (从数据库读取网络设置,默认主网)
|
||||
const kavaNetwork = database.getSetting('kava_network') || 'mainnet';
|
||||
const kavaConfig = kavaNetwork === 'mainnet' ? KAVA_MAINNET_TX_CONFIG : KAVA_TESTNET_TX_CONFIG;
|
||||
kavaTxService = new KavaTxService(kavaConfig);
|
||||
debugLog.info('kava', `Kava network: ${kavaNetwork}`);
|
||||
|
|
@ -619,6 +639,343 @@ async function handleKeygenComplete(result: KeygenResult) {
|
|||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Co-Sign 相关处理函数
|
||||
// ===========================================================================
|
||||
|
||||
// 检查并触发 Co-Sign(在收到 all_joined 事件后调用)
|
||||
async function checkAndTriggerCoSign(sessionId: string) {
|
||||
console.log('[CO-SIGN] checkAndTriggerCoSign called with sessionId:', sessionId);
|
||||
|
||||
if (!activeCoSignSession || activeCoSignSession.sessionId !== sessionId) {
|
||||
console.log('[CO-SIGN] No matching active co-sign session for', sessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[CO-SIGN] Active session found:', {
|
||||
sessionId: activeCoSignSession.sessionId,
|
||||
partyIndex: activeCoSignSession.partyIndex,
|
||||
threshold: activeCoSignSession.threshold,
|
||||
participantCount: activeCoSignSession.participants.length,
|
||||
});
|
||||
|
||||
// 如果 TSS 已经在运行,不重复触发
|
||||
if (tssHandler?.getIsRunning()) {
|
||||
console.log('[CO-SIGN] TSS already running, skip check');
|
||||
return;
|
||||
}
|
||||
|
||||
const pollIntervalMs = 2000; // 2秒轮询间隔
|
||||
const maxWaitMs = 5 * 60 * 1000; // 5分钟超时
|
||||
const startTime = Date.now();
|
||||
|
||||
console.log('[CO-SIGN] Starting to poll session status...');
|
||||
debugLog.info('main', `Starting to poll co-sign session status for ${sessionId}`);
|
||||
|
||||
while (Date.now() - startTime < maxWaitMs) {
|
||||
// 检查会话是否仍然有效
|
||||
if (!activeCoSignSession || activeCoSignSession.sessionId !== sessionId) {
|
||||
debugLog.warn('main', 'Active co-sign session changed, stopping poll');
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果 TSS 已经在运行,停止轮询
|
||||
if (tssHandler?.getIsRunning()) {
|
||||
debugLog.info('main', 'TSS started running, stopping poll');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取签名会话状态
|
||||
const status = await accountClient?.getSignSessionStatus(sessionId);
|
||||
if (!status) {
|
||||
debugLog.warn('main', 'Failed to get sign session status, will retry');
|
||||
await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
|
||||
continue;
|
||||
}
|
||||
|
||||
const expectedT = activeCoSignSession.threshold.t;
|
||||
const currentParticipants = status.joined_count || 0;
|
||||
|
||||
debugLog.debug('main', `Sign session ${sessionId} status: ${status.status}, participants: ${currentParticipants}/${expectedT}`);
|
||||
|
||||
// 检查是否满足启动条件
|
||||
const hasAllParticipants = currentParticipants >= expectedT;
|
||||
const statusReady = status.status === 'in_progress' ||
|
||||
status.status === 'all_joined' ||
|
||||
status.status === 'waiting_for_sign';
|
||||
|
||||
console.log('[CO-SIGN] Check conditions:', {
|
||||
hasAllParticipants,
|
||||
statusReady,
|
||||
currentParticipants,
|
||||
expectedT,
|
||||
status: status.status,
|
||||
});
|
||||
|
||||
if (hasAllParticipants && statusReady) {
|
||||
console.log('[CO-SIGN] Conditions met! Triggering sign...');
|
||||
debugLog.info('main', `All ${expectedT} participants joined (status: ${status.status}), triggering sign...`);
|
||||
|
||||
// 更新参与者列表
|
||||
if (status.parties && status.parties.length > 0) {
|
||||
const myPartyId = grpcClient?.getPartyId();
|
||||
const updatedParticipants: Array<{ partyId: string; partyIndex: number; name: string }> = [];
|
||||
|
||||
for (const p of status.parties) {
|
||||
const existing = activeCoSignSession.participants.find(ep => ep.partyId === p.party_id);
|
||||
updatedParticipants.push({
|
||||
partyId: p.party_id,
|
||||
partyIndex: p.party_index,
|
||||
name: existing?.name || (p.party_id === myPartyId ? '我' : `参与方 ${p.party_index + 1}`),
|
||||
});
|
||||
}
|
||||
|
||||
activeCoSignSession.participants = updatedParticipants;
|
||||
|
||||
const myInfo = updatedParticipants.find(p => p.partyId === myPartyId);
|
||||
if (myInfo) {
|
||||
activeCoSignSession.partyIndex = myInfo.partyIndex;
|
||||
}
|
||||
}
|
||||
|
||||
const selectedParties = activeCoSignSession.participants.map(p => p.partyId);
|
||||
|
||||
await handleCoSignStart({
|
||||
eventType: 'session_started',
|
||||
sessionId: sessionId,
|
||||
thresholdT: activeCoSignSession.threshold.t,
|
||||
thresholdN: activeCoSignSession.threshold.n,
|
||||
selectedParties: selectedParties,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
|
||||
|
||||
} catch (error) {
|
||||
debugLog.error('main', `Failed to check sign session status: ${error}, will retry`);
|
||||
await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
|
||||
}
|
||||
}
|
||||
|
||||
// 超时
|
||||
debugLog.error('main', `Timeout: failed to start sign within 5 minutes for session ${sessionId}`);
|
||||
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send(`cosign:events:${sessionId}`, {
|
||||
type: 'sign_start_timeout',
|
||||
error: '启动签名超时,请重试',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 处理 Co-Sign 会话开始事件 - 触发签名
|
||||
async function handleCoSignStart(event: {
|
||||
eventType: string;
|
||||
sessionId: string;
|
||||
thresholdT: number;
|
||||
thresholdN: number;
|
||||
selectedParties: string[];
|
||||
}) {
|
||||
console.log('[CO-SIGN] handleCoSignStart called:', {
|
||||
eventType: event.eventType,
|
||||
sessionId: event.sessionId,
|
||||
thresholdT: event.thresholdT,
|
||||
thresholdN: event.thresholdN,
|
||||
selectedParties: event.selectedParties?.length,
|
||||
});
|
||||
|
||||
if (!activeCoSignSession) {
|
||||
console.log('[CO-SIGN] No active co-sign session, ignoring');
|
||||
debugLog.debug('main', 'No active co-sign session, ignoring sign start event');
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeCoSignSession.sessionId !== event.sessionId) {
|
||||
debugLog.debug('main', `Session ID mismatch: expected ${activeCoSignSession.sessionId}, got ${event.sessionId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 幂等性保护
|
||||
if (signInProgressSessionId === event.sessionId) {
|
||||
debugLog.debug('main', `Sign already in progress for session ${event.sessionId}, skipping duplicate trigger`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (tssHandler?.getIsRunning()) {
|
||||
debugLog.debug('main', 'TSS already running, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!tssHandler || !('participateSign' in tssHandler)) {
|
||||
debugLog.error('tss', 'TSS handler not initialized or does not support signing');
|
||||
mainWindow?.webContents.send(`cosign:events:${event.sessionId}`, {
|
||||
type: 'failed',
|
||||
error: 'TSS handler not initialized',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 标记签名开始
|
||||
signInProgressSessionId = event.sessionId;
|
||||
|
||||
// 打印当前 activeCoSignSession.participants 状态
|
||||
console.log('[CO-SIGN] Current activeCoSignSession.participants before update:',
|
||||
activeCoSignSession.participants.map(p => ({
|
||||
partyId: p.partyId.substring(0, 8),
|
||||
partyIndex: p.partyIndex,
|
||||
}))
|
||||
);
|
||||
console.log('[CO-SIGN] event.selectedParties:', event.selectedParties?.map(id => id.substring(0, 8)));
|
||||
|
||||
// 从 event.selectedParties 更新参与者列表
|
||||
// 优先使用 activeCoSignSession.participants 中的 partyIndex(来自 signingParties 或 other_parties)
|
||||
if (event.selectedParties && event.selectedParties.length > 0) {
|
||||
const myPartyId = grpcClient?.getPartyId();
|
||||
const updatedParticipants: Array<{ partyId: string; partyIndex: number; name: string }> = [];
|
||||
|
||||
event.selectedParties.forEach((partyId) => {
|
||||
// 查找已有的参与者信息
|
||||
const existingParticipant = activeCoSignSession?.participants.find(p => p.partyId === partyId);
|
||||
if (existingParticipant) {
|
||||
// 使用已有的 partyIndex
|
||||
updatedParticipants.push({
|
||||
partyId: partyId,
|
||||
partyIndex: existingParticipant.partyIndex,
|
||||
name: partyId === myPartyId ? '我' : existingParticipant.name,
|
||||
});
|
||||
} else {
|
||||
// 找不到已有信息,这不应该发生 - 记录警告
|
||||
console.warn(`[CO-SIGN] WARNING: Party ${partyId.substring(0, 8)} not found in activeCoSignSession.participants!`);
|
||||
// 不使用 fallback index,直接跳过,这会导致参与者数量不足,签名会失败
|
||||
// 这样可以更早发现问题
|
||||
}
|
||||
});
|
||||
|
||||
// 检查参与者数量是否符合预期
|
||||
if (updatedParticipants.length !== event.selectedParties.length) {
|
||||
console.error(`[CO-SIGN] ERROR: Participant count mismatch! Expected ${event.selectedParties.length}, got ${updatedParticipants.length}`);
|
||||
}
|
||||
|
||||
// 按 partyIndex 排序
|
||||
updatedParticipants.sort((a, b) => a.partyIndex - b.partyIndex);
|
||||
|
||||
activeCoSignSession.participants = updatedParticipants;
|
||||
console.log('[CO-SIGN] Updated participants from session_started event:', updatedParticipants.map(p => ({
|
||||
partyId: p.partyId.substring(0, 8),
|
||||
partyIndex: p.partyIndex,
|
||||
})));
|
||||
}
|
||||
|
||||
// 获取 share 数据
|
||||
const share = database?.getShare(activeCoSignSession.shareId, activeCoSignSession.sharePassword);
|
||||
if (!share) {
|
||||
debugLog.error('main', 'Failed to get share data');
|
||||
mainWindow?.webContents.send(`cosign:events:${event.sessionId}`, {
|
||||
type: 'failed',
|
||||
error: 'Failed to get share data',
|
||||
});
|
||||
signInProgressSessionId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[CO-SIGN] Calling tssHandler.participateSign with:', {
|
||||
sessionId: activeCoSignSession.sessionId,
|
||||
partyId: grpcClient?.getPartyId(),
|
||||
partyIndex: activeCoSignSession.partyIndex,
|
||||
participants: activeCoSignSession.participants.map(p => ({ partyId: p.partyId.substring(0, 8), partyIndex: p.partyIndex })),
|
||||
threshold: activeCoSignSession.threshold,
|
||||
messageHash: activeCoSignSession.messageHash.substring(0, 16) + '...',
|
||||
});
|
||||
debugLog.info('tss', `Starting sign for session ${event.sessionId}...`);
|
||||
|
||||
try {
|
||||
const result = await (tssHandler as TSSHandler).participateSign(
|
||||
activeCoSignSession.sessionId,
|
||||
grpcClient?.getPartyId() || '',
|
||||
activeCoSignSession.partyIndex,
|
||||
activeCoSignSession.participants,
|
||||
activeCoSignSession.threshold,
|
||||
activeCoSignSession.messageHash,
|
||||
share.raw_share,
|
||||
activeCoSignSession.sharePassword
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
debugLog.info('tss', 'Sign completed successfully');
|
||||
await handleCoSignComplete(result);
|
||||
} else {
|
||||
debugLog.error('tss', `Sign failed: ${result.error}`);
|
||||
mainWindow?.webContents.send(`cosign:events:${activeCoSignSession.sessionId}`, {
|
||||
type: 'failed',
|
||||
error: result.error || 'Sign failed',
|
||||
});
|
||||
signInProgressSessionId = null;
|
||||
}
|
||||
} catch (error) {
|
||||
debugLog.error('tss', `Sign error: ${(error as Error).message}`);
|
||||
mainWindow?.webContents.send(`cosign:events:${activeCoSignSession?.sessionId}`, {
|
||||
type: 'failed',
|
||||
error: (error as Error).message,
|
||||
});
|
||||
signInProgressSessionId = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 处理 Co-Sign 完成 - 保存签名并报告完成
|
||||
async function handleCoSignComplete(result: { success: boolean; signature: Buffer; error?: string }) {
|
||||
if (!activeCoSignSession || !database || !grpcClient) {
|
||||
debugLog.error('main', 'Missing required components for sign completion');
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId = activeCoSignSession.sessionId;
|
||||
const partyId = grpcClient.getPartyId();
|
||||
|
||||
try {
|
||||
const signatureHex = result.signature.toString('hex');
|
||||
|
||||
// 1. 更新签名历史
|
||||
database.updateSigningHistory(sessionId, {
|
||||
status: 'completed',
|
||||
signature: signatureHex,
|
||||
});
|
||||
|
||||
debugLog.info('main', `Signature saved: ${signatureHex.substring(0, 32)}...`);
|
||||
|
||||
// 2. 报告完成给 session-coordinator
|
||||
const allCompleted = await grpcClient.reportCompletion(
|
||||
sessionId,
|
||||
partyId || '',
|
||||
result.signature
|
||||
);
|
||||
|
||||
debugLog.info('grpc', `Reported sign completion to session-coordinator, all_completed: ${allCompleted}`);
|
||||
|
||||
// 3. 通知前端
|
||||
mainWindow?.webContents.send(`cosign:events:${sessionId}`, {
|
||||
type: 'completed',
|
||||
signature: signatureHex,
|
||||
allCompleted: allCompleted,
|
||||
});
|
||||
|
||||
// 4. 清理活跃会话和幂等性标志
|
||||
activeCoSignSession = null;
|
||||
signInProgressSessionId = null;
|
||||
debugLog.info('main', 'Co-Sign session completed and cleaned up');
|
||||
|
||||
} catch (error) {
|
||||
debugLog.error('main', `Failed to handle sign completion: ${error}`);
|
||||
mainWindow?.webContents.send(`cosign:events:${sessionId}`, {
|
||||
type: 'failed',
|
||||
error: (error as Error).message,
|
||||
});
|
||||
signInProgressSessionId = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 连接并注册到 Message Router
|
||||
async function connectAndRegisterToMessageRouter() {
|
||||
if (!grpcClient || !database) {
|
||||
|
|
@ -688,26 +1045,53 @@ async function connectAndRegisterToMessageRouter() {
|
|||
// 转发事件到前端
|
||||
mainWindow?.webContents.send(`session:events:${event.session_id}`, eventData);
|
||||
|
||||
// 根据事件类型处理
|
||||
// 根据事件类型处理 - 区分 Keygen 和 Co-Sign 会话
|
||||
const isCoSignSession = activeCoSignSession?.sessionId === event.session_id;
|
||||
const isKeygenSession = activeKeygenSession?.sessionId === event.session_id;
|
||||
|
||||
if (event.event_type === 'all_joined') {
|
||||
// 收到 all_joined 事件表示所有参与方都已加入
|
||||
// 此时启动 5 分钟倒计时,在此期间完成 keygen 启动
|
||||
debugLog.info('main', `Received all_joined event for session ${event.session_id}, starting 5-minute keygen countdown`);
|
||||
debugLog.info('main', `Received all_joined event for session ${event.session_id}, isCoSign=${isCoSignSession}, isKeygen=${isKeygenSession}`);
|
||||
|
||||
// 使用 setImmediate 确保不阻塞事件处理
|
||||
setImmediate(() => {
|
||||
checkAndTriggerKeygen(event.session_id);
|
||||
});
|
||||
if (isCoSignSession) {
|
||||
// Co-Sign 会话:转发到 cosign 频道并触发签名
|
||||
mainWindow?.webContents.send(`cosign:events:${event.session_id}`, eventData);
|
||||
setImmediate(() => {
|
||||
checkAndTriggerCoSign(event.session_id);
|
||||
});
|
||||
} else if (isKeygenSession) {
|
||||
// Keygen 会话:启动 5 分钟倒计时
|
||||
setImmediate(() => {
|
||||
checkAndTriggerKeygen(event.session_id);
|
||||
});
|
||||
}
|
||||
} else if (event.event_type === 'session_started') {
|
||||
// session_started 事件表示 keygen 可以开始了(所有人已准备就绪)
|
||||
// 直接触发 keygen
|
||||
await handleSessionStart({
|
||||
eventType: event.event_type,
|
||||
sessionId: event.session_id,
|
||||
thresholdN: event.threshold_n,
|
||||
thresholdT: event.threshold_t,
|
||||
selectedParties: event.selected_parties,
|
||||
});
|
||||
// session_started 事件表示可以开始了
|
||||
if (isCoSignSession) {
|
||||
// Co-Sign 会话
|
||||
mainWindow?.webContents.send(`cosign:events:${event.session_id}`, eventData);
|
||||
await handleCoSignStart({
|
||||
eventType: event.event_type,
|
||||
sessionId: event.session_id,
|
||||
thresholdN: event.threshold_n,
|
||||
thresholdT: event.threshold_t,
|
||||
selectedParties: event.selected_parties,
|
||||
});
|
||||
} else if (isKeygenSession) {
|
||||
// Keygen 会话
|
||||
await handleSessionStart({
|
||||
eventType: event.event_type,
|
||||
sessionId: event.session_id,
|
||||
thresholdN: event.threshold_n,
|
||||
thresholdT: event.threshold_t,
|
||||
selectedParties: event.selected_parties,
|
||||
});
|
||||
}
|
||||
} else if (event.event_type === 'participant_joined') {
|
||||
// 参与者加入事件 - 也需要区分会话类型
|
||||
if (isCoSignSession) {
|
||||
mainWindow?.webContents.send(`cosign:events:${event.session_id}`, eventData);
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
|
|
@ -790,8 +1174,13 @@ function setupIpcHandlers() {
|
|||
// 这确保在其他方开始发送 TSS 消息时,我们已经准备好接收和缓冲
|
||||
// 即使 keygen 进程还没启动,消息也不会丢失
|
||||
if (tssHandler && 'prepareForKeygen' in tssHandler) {
|
||||
debugLog.info('tss', `Preparing for keygen: subscribing to messages for session ${sessionId}`);
|
||||
(tssHandler as { prepareForKeygen: (sessionId: string, partyId: string) => void }).prepareForKeygen(sessionId, partyId);
|
||||
try {
|
||||
debugLog.info('tss', `Preparing for keygen: subscribing to messages for session ${sessionId}`);
|
||||
(tssHandler as { prepareForKeygen: (sessionId: string, partyId: string) => void }).prepareForKeygen(sessionId, partyId);
|
||||
} catch (prepareErr) {
|
||||
debugLog.error('tss', `Failed to prepare for keygen: ${(prepareErr as Error).message}`);
|
||||
return { success: false, error: `消息订阅失败: ${(prepareErr as Error).message}` };
|
||||
}
|
||||
}
|
||||
|
||||
// 方案 B: 检查 JoinSession 响应中的 session 状态
|
||||
|
|
@ -903,8 +1292,13 @@ function setupIpcHandlers() {
|
|||
|
||||
// 关键步骤:立即预订阅消息流
|
||||
if (tssHandler && 'prepareForKeygen' in tssHandler) {
|
||||
debugLog.info('tss', `Initiator preparing for keygen: subscribing to messages for session ${result.session_id}`);
|
||||
(tssHandler as { prepareForKeygen: (sessionId: string, partyId: string) => void }).prepareForKeygen(result.session_id, partyId);
|
||||
try {
|
||||
debugLog.info('tss', `Initiator preparing for keygen: subscribing to messages for session ${result.session_id}`);
|
||||
(tssHandler as { prepareForKeygen: (sessionId: string, partyId: string) => void }).prepareForKeygen(result.session_id, partyId);
|
||||
} catch (prepareErr) {
|
||||
debugLog.error('tss', `Failed to prepare for keygen: ${(prepareErr as Error).message}`);
|
||||
return { success: false, error: `消息订阅失败: ${(prepareErr as Error).message}` };
|
||||
}
|
||||
}
|
||||
|
||||
// 方案 B: 检查 JoinSession 响应中的 session 状态
|
||||
|
|
@ -1071,11 +1465,12 @@ function setupIpcHandlers() {
|
|||
messageHash: result?.message_hash,
|
||||
threshold: {
|
||||
t: result?.threshold_t,
|
||||
n: result?.parties?.length || 0,
|
||||
n: result?.threshold_n,
|
||||
},
|
||||
currentParticipants: result?.joined_count || 0,
|
||||
status: result?.status,
|
||||
parties: result?.parties,
|
||||
initiator: '发起者',
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
|
|
@ -1206,6 +1601,383 @@ function setupIpcHandlers() {
|
|||
return accountClient?.getBaseUrl() || 'https://rwaapi.szaiai.com';
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// Co-Sign 相关 IPC 处理器
|
||||
// ===========================================================================
|
||||
|
||||
// 创建 Co-Sign 会话
|
||||
ipcMain.handle('cosign:createSession', async (_event, params: {
|
||||
shareId: string;
|
||||
sharePassword: string;
|
||||
messageHash: string;
|
||||
initiatorName?: string;
|
||||
}) => {
|
||||
try {
|
||||
// 获取当前 party ID
|
||||
const partyId = grpcClient?.getPartyId();
|
||||
if (!partyId) {
|
||||
return { success: false, error: '请先连接到消息路由器' };
|
||||
}
|
||||
|
||||
// 从本地获取 share 信息
|
||||
const share = database?.getShare(params.shareId, params.sharePassword);
|
||||
if (!share) {
|
||||
return { success: false, error: 'Share 不存在或密码错误' };
|
||||
}
|
||||
|
||||
// 从后端获取 keygen 会话的参与者信息(包含正确的 party_index)
|
||||
const keygenStatus = await accountClient?.getSessionStatus(share.session_id);
|
||||
if (!keygenStatus?.participants || keygenStatus.participants.length === 0) {
|
||||
return { success: false, error: '无法获取 keygen 会话的参与者信息' };
|
||||
}
|
||||
|
||||
// 过滤掉 co-managed-party-*(服务器持久方),只保留 temporary/external 用户方
|
||||
// 只有用户方持有签名私钥份额,co-managed-party 是备份/恢复用的
|
||||
const signingParties = keygenStatus.participants
|
||||
.filter(p => !p.party_id.startsWith('co-managed-party-'))
|
||||
.map(p => ({
|
||||
party_id: p.party_id,
|
||||
party_index: p.party_index,
|
||||
}));
|
||||
|
||||
console.log('[CO-SIGN] Signing parties (excluding co-managed-party):', {
|
||||
total_keygen_participants: keygenStatus.participants.length,
|
||||
signing_parties_count: signingParties.length,
|
||||
signing_parties: signingParties.map(p => ({ id: p.party_id, index: p.party_index })),
|
||||
});
|
||||
|
||||
if (signingParties.length < share.threshold_t) {
|
||||
return {
|
||||
success: false,
|
||||
error: `签名参与方不足: 需要 ${share.threshold_t} 个,但只有 ${signingParties.length} 个用户方`
|
||||
};
|
||||
}
|
||||
|
||||
// 创建签名会话
|
||||
const result = await accountClient?.createSignSession({
|
||||
keygen_session_id: share.session_id,
|
||||
wallet_name: share.wallet_name,
|
||||
message_hash: params.messageHash,
|
||||
parties: signingParties,
|
||||
threshold_t: share.threshold_t,
|
||||
initiator_name: params.initiatorName || '发起者',
|
||||
});
|
||||
|
||||
if (!result?.session_id) {
|
||||
return { success: false, error: '创建签名会话失败: 未返回会话ID' };
|
||||
}
|
||||
|
||||
// 创建签名历史记录
|
||||
database?.createSigningHistory({
|
||||
shareId: params.shareId,
|
||||
sessionId: result.session_id,
|
||||
messageHash: params.messageHash,
|
||||
});
|
||||
|
||||
// 发起方自动加入会话
|
||||
// 支持新格式 join_tokens (map[partyID]token) 和旧格式 join_token (单一通配符 token)
|
||||
const joinToken = result.join_tokens?.[partyId] || (result as { join_token?: string }).join_token;
|
||||
if (joinToken) {
|
||||
console.log('[CO-SIGN] Initiator auto-joining session...');
|
||||
const joinResult = await grpcClient?.joinSession(result.session_id, partyId, joinToken);
|
||||
|
||||
if (joinResult?.success) {
|
||||
// 设置活跃的 Co-Sign 会话信息
|
||||
// 使用 signingParties 初始化完整的参与者列表(包含正确的 partyIndex)
|
||||
const signParticipants: Array<{ partyId: string; partyIndex: number; name: string }> = signingParties.map(p => ({
|
||||
partyId: p.party_id,
|
||||
partyIndex: p.party_index,
|
||||
name: p.party_id === partyId ? (params.initiatorName || '发起者') : `参与方 ${p.party_index + 1}`,
|
||||
}));
|
||||
|
||||
console.log('[CO-SIGN] Initiator signParticipants (from signingParties):', signParticipants.map(p => ({
|
||||
partyId: p.partyId.substring(0, 8),
|
||||
partyIndex: p.partyIndex,
|
||||
})));
|
||||
|
||||
activeCoSignSession = {
|
||||
sessionId: result.session_id,
|
||||
partyIndex: joinResult.party_index,
|
||||
participants: signParticipants,
|
||||
threshold: {
|
||||
t: share.threshold_t,
|
||||
n: share.threshold_n,
|
||||
},
|
||||
walletName: share.wallet_name,
|
||||
messageHash: params.messageHash,
|
||||
shareId: params.shareId,
|
||||
sharePassword: params.sharePassword,
|
||||
};
|
||||
|
||||
console.log('[CO-SIGN] Initiator active session set:', {
|
||||
sessionId: activeCoSignSession.sessionId,
|
||||
partyIndex: activeCoSignSession.partyIndex,
|
||||
threshold: activeCoSignSession.threshold,
|
||||
});
|
||||
|
||||
// 预订阅消息流
|
||||
if (tssHandler && 'prepareForSign' in tssHandler) {
|
||||
try {
|
||||
debugLog.info('tss', `Initiator preparing for sign: subscribing to messages for session ${result.session_id}`);
|
||||
(tssHandler as TSSHandler).prepareForSign(result.session_id, partyId);
|
||||
} catch (prepareErr) {
|
||||
debugLog.error('tss', `Failed to prepare for sign: ${(prepareErr as Error).message}`);
|
||||
return { success: false, error: `消息订阅失败: ${(prepareErr as Error).message}` };
|
||||
}
|
||||
}
|
||||
|
||||
// 检查会话状态
|
||||
const sessionStatus = joinResult.session_info?.status;
|
||||
debugLog.info('main', `Initiator JoinSession response: status=${sessionStatus}`);
|
||||
|
||||
if (sessionStatus === 'in_progress') {
|
||||
debugLog.info('main', 'Session already in_progress, triggering sign immediately');
|
||||
// 从 joinResult.other_parties 获取其他参与者,加上发起者自己
|
||||
// 因为此时 activeCoSignSession.participants 只有发起者自己
|
||||
const otherParties = joinResult.other_parties || [];
|
||||
const selectedParties = [partyId, ...otherParties.map((p: { party_id: string }) => p.party_id)];
|
||||
console.log('[CO-SIGN] Initiator using other_parties + self:', selectedParties.map((id: string) => id.substring(0, 8)));
|
||||
|
||||
setImmediate(async () => {
|
||||
await handleCoSignStart({
|
||||
eventType: 'session_started',
|
||||
sessionId: result.session_id,
|
||||
thresholdT: share.threshold_t,
|
||||
thresholdN: share.threshold_n,
|
||||
selectedParties: selectedParties,
|
||||
});
|
||||
});
|
||||
}
|
||||
} else {
|
||||
console.warn('[CO-SIGN] Initiator failed to join session');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
sessionId: result.session_id,
|
||||
inviteCode: result.invite_code,
|
||||
walletName: share.wallet_name,
|
||||
expiresAt: result.expires_at,
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
// 验证 Co-Sign 邀请码
|
||||
ipcMain.handle('cosign:validateInviteCode', async (_event, { code }) => {
|
||||
try {
|
||||
debugLog.info('account', `Validating co-sign invite code: ${code}`);
|
||||
const result = await accountClient?.getSignSessionByInviteCode(code);
|
||||
|
||||
if (!result?.session_id) {
|
||||
return { success: false, error: '无效的邀请码' };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
sessionInfo: {
|
||||
sessionId: result.session_id,
|
||||
keygenSessionId: result.keygen_session_id,
|
||||
walletName: result.wallet_name,
|
||||
messageHash: result.message_hash,
|
||||
threshold: {
|
||||
t: result.threshold_t,
|
||||
n: result.threshold_n,
|
||||
},
|
||||
status: result.status,
|
||||
currentParticipants: result.joined_count || 0,
|
||||
parties: result.parties,
|
||||
},
|
||||
joinToken: result.join_token,
|
||||
};
|
||||
} catch (error) {
|
||||
debugLog.error('account', `Failed to validate co-sign invite code: ${(error as Error).message}`);
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
// 加入 Co-Sign 会话
|
||||
ipcMain.handle('cosign:joinSession', async (_event, params: {
|
||||
sessionId: string;
|
||||
shareId: string;
|
||||
sharePassword: string;
|
||||
joinToken: string;
|
||||
walletName?: string;
|
||||
messageHash: string;
|
||||
threshold: { t: number; n: number };
|
||||
parties?: Array<{ party_id: string; party_index: number }>;
|
||||
}) => {
|
||||
try {
|
||||
const partyId = grpcClient?.getPartyId();
|
||||
if (!partyId) {
|
||||
return { success: false, error: '请先连接到消息路由器' };
|
||||
}
|
||||
|
||||
// 验证 share
|
||||
const share = database?.getShare(params.shareId, params.sharePassword);
|
||||
if (!share) {
|
||||
return { success: false, error: 'Share 不存在或密码错误' };
|
||||
}
|
||||
|
||||
debugLog.info('grpc', `Joining co-sign session: sessionId=${params.sessionId}, partyId=${partyId}`);
|
||||
|
||||
const result = await grpcClient?.joinSession(params.sessionId, partyId, params.joinToken);
|
||||
if (result?.success) {
|
||||
// 设置活跃的 Co-Sign 会话
|
||||
// 优先使用 params.parties(来自 validateInviteCode,包含所有预期参与者)
|
||||
// 而不是 result.other_parties(只包含已加入的参与者)
|
||||
let participants: Array<{ partyId: string; partyIndex: number; name: string }>;
|
||||
|
||||
if (params.parties && params.parties.length > 0) {
|
||||
// 使用完整的 parties 列表
|
||||
participants = params.parties.map(p => ({
|
||||
partyId: p.party_id,
|
||||
partyIndex: p.party_index,
|
||||
name: p.party_id === partyId ? '我' : `参与方 ${p.party_index + 1}`,
|
||||
}));
|
||||
console.log('[CO-SIGN] Participant using params.parties (complete list):', participants.map(p => ({
|
||||
partyId: p.partyId.substring(0, 8),
|
||||
partyIndex: p.partyIndex,
|
||||
})));
|
||||
} else {
|
||||
// Fallback: 使用 other_parties + 自己(可能不完整)
|
||||
console.warn('[CO-SIGN] WARNING: params.parties not available, using other_parties (may be incomplete)');
|
||||
participants = result.other_parties?.map((p: { party_id: string; party_index: number }, idx: number) => ({
|
||||
partyId: p.party_id,
|
||||
partyIndex: p.party_index,
|
||||
name: `参与方 ${idx + 1}`,
|
||||
})) || [];
|
||||
|
||||
// 添加自己
|
||||
participants.push({
|
||||
partyId: partyId,
|
||||
partyIndex: result.party_index,
|
||||
name: '我',
|
||||
});
|
||||
}
|
||||
|
||||
// 按 partyIndex 排序
|
||||
participants.sort((a, b) => a.partyIndex - b.partyIndex);
|
||||
|
||||
activeCoSignSession = {
|
||||
sessionId: params.sessionId,
|
||||
partyIndex: result.party_index,
|
||||
participants: participants,
|
||||
threshold: params.threshold,
|
||||
walletName: params.walletName || share.wallet_name,
|
||||
messageHash: params.messageHash,
|
||||
shareId: params.shareId,
|
||||
sharePassword: params.sharePassword,
|
||||
};
|
||||
|
||||
console.log('[CO-SIGN] Active session set:', {
|
||||
sessionId: activeCoSignSession.sessionId,
|
||||
partyIndex: activeCoSignSession.partyIndex,
|
||||
participantCount: activeCoSignSession.participants.length,
|
||||
threshold: activeCoSignSession.threshold,
|
||||
});
|
||||
|
||||
// 创建签名历史记录
|
||||
database?.createSigningHistory({
|
||||
shareId: params.shareId,
|
||||
sessionId: params.sessionId,
|
||||
messageHash: params.messageHash,
|
||||
});
|
||||
|
||||
// 预订阅消息流
|
||||
if (tssHandler && 'prepareForSign' in tssHandler) {
|
||||
try {
|
||||
debugLog.info('tss', `Preparing for sign: subscribing to messages for session ${params.sessionId}`);
|
||||
(tssHandler as TSSHandler).prepareForSign(params.sessionId, partyId);
|
||||
} catch (prepareErr) {
|
||||
debugLog.error('tss', `Failed to prepare for sign: ${(prepareErr as Error).message}`);
|
||||
return { success: false, error: `消息订阅失败: ${(prepareErr as Error).message}` };
|
||||
}
|
||||
}
|
||||
|
||||
// 检查会话状态
|
||||
const sessionStatus = result.session_info?.status;
|
||||
debugLog.info('main', `JoinSession response: status=${sessionStatus}`);
|
||||
|
||||
if (sessionStatus === 'in_progress') {
|
||||
debugLog.info('main', 'Session already in_progress, triggering sign immediately');
|
||||
// 使用 activeCoSignSession.participants(已从 other_parties + 自己构建)
|
||||
const selectedParties = activeCoSignSession?.participants.map(p => p.partyId) || [];
|
||||
console.log('[CO-SIGN] Participant using activeCoSignSession.participants:', selectedParties.map((id: string) => id.substring(0, 8)));
|
||||
|
||||
setImmediate(async () => {
|
||||
await handleCoSignStart({
|
||||
eventType: 'session_started',
|
||||
sessionId: params.sessionId,
|
||||
thresholdT: params.threshold.t,
|
||||
thresholdN: params.threshold.n,
|
||||
selectedParties: selectedParties,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true, data: result };
|
||||
} else {
|
||||
return { success: false, error: '加入会话失败' };
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
// 获取 Co-Sign 会话状态
|
||||
ipcMain.handle('cosign:getSessionStatus', async (_event, { sessionId }) => {
|
||||
try {
|
||||
const result = await accountClient?.getSignSessionStatus(sessionId);
|
||||
// API 返回的是 participants 字段
|
||||
const apiParticipants = (result as { participants?: Array<{ party_id: string; party_index: number; status: string }> })?.participants || [];
|
||||
return {
|
||||
success: true,
|
||||
session: {
|
||||
sessionId: result?.session_id,
|
||||
status: result?.status,
|
||||
joinedCount: result?.joined_count,
|
||||
inviteCode: (result as { invite_code?: string })?.invite_code || '',
|
||||
threshold: {
|
||||
t: result?.threshold_t || activeCoSignSession?.threshold?.t || 0,
|
||||
n: result?.threshold_n || activeCoSignSession?.threshold?.n || 0,
|
||||
},
|
||||
participants: apiParticipants.map((p, idx) => ({
|
||||
partyId: p.party_id,
|
||||
partyIndex: p.party_index,
|
||||
name: activeCoSignSession?.participants?.find(ap => ap.partyId === p.party_id)?.name || `参与方 ${idx + 1}`,
|
||||
status: p.status || 'waiting',
|
||||
})),
|
||||
messageHash: activeCoSignSession?.messageHash || '',
|
||||
walletName: activeCoSignSession?.walletName || '',
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
// 订阅 Co-Sign 会话事件
|
||||
ipcMain.on('cosign:subscribeSessionEvents', (_event, { sessionId }) => {
|
||||
debugLog.debug('main', `Frontend subscribing to co-sign session events: ${sessionId}`);
|
||||
|
||||
// 获取并发送缓存的事件
|
||||
const cachedEvents = getAndClearCachedEvents(sessionId);
|
||||
if (cachedEvents.length > 0) {
|
||||
debugLog.info('main', `Sending ${cachedEvents.length} cached events to frontend for co-sign session ${sessionId}`);
|
||||
for (const event of cachedEvents) {
|
||||
mainWindow?.webContents.send(`cosign:events:${sessionId}`, event);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 取消订阅 Co-Sign 会话事件
|
||||
ipcMain.on('cosign:unsubscribeSessionEvents', (_event, { sessionId }) => {
|
||||
debugLog.debug('main', `Frontend unsubscribing from co-sign session events: ${sessionId}`);
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// Share 存储相关 (SQLite)
|
||||
// ===========================================================================
|
||||
|
|
@ -1686,9 +2458,18 @@ app.whenReady().then(async () => {
|
|||
}
|
||||
createWindow();
|
||||
|
||||
app.on('activate', () => {
|
||||
app.on('activate', async () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
// macOS: 重新激活时检查并恢复 gRPC 连接
|
||||
if (grpcClient && !grpcClient.isConnected()) {
|
||||
debugLog.info('grpc', 'App activated, reconnecting to Message Router...');
|
||||
try {
|
||||
await connectAndRegisterToMessageRouter();
|
||||
} catch (err) {
|
||||
debugLog.error('grpc', `Failed to reconnect on activate: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -119,11 +119,12 @@ export interface CreateSignSessionResponse {
|
|||
session_id: string;
|
||||
invite_code: string;
|
||||
keygen_session_id: string;
|
||||
message_hash: string;
|
||||
wallet_name: string;
|
||||
threshold_t: number;
|
||||
parties: SignPartyInfo[];
|
||||
selected_parties: string[];
|
||||
expires_at: number;
|
||||
join_token: string;
|
||||
join_token?: string; // Backward compatible: wildcard token (may be empty)
|
||||
join_tokens: Record<string, string>; // New: all join tokens (map[partyID]token)
|
||||
}
|
||||
|
||||
export interface GetSignSessionByInviteCodeResponse {
|
||||
|
|
@ -132,11 +133,28 @@ export interface GetSignSessionByInviteCodeResponse {
|
|||
wallet_name: string;
|
||||
message_hash: string;
|
||||
threshold_t: number;
|
||||
threshold_n: number;
|
||||
status: string;
|
||||
invite_code: string;
|
||||
expires_at: number;
|
||||
parties: SignPartyInfo[];
|
||||
joined_count: number;
|
||||
join_token?: string;
|
||||
}
|
||||
|
||||
export interface GetSignSessionStatusResponse {
|
||||
session_id: string;
|
||||
status: string;
|
||||
session_type: string;
|
||||
threshold_t: number;
|
||||
threshold_n: number;
|
||||
completed_parties: number;
|
||||
total_parties: number;
|
||||
joined_count?: number;
|
||||
parties?: SignPartyInfo[];
|
||||
participants?: Array<{ party_id: string; party_index: number; status: string }>;
|
||||
message_hash?: string;
|
||||
signature?: string;
|
||||
}
|
||||
|
||||
// 错误响应
|
||||
|
|
@ -313,6 +331,16 @@ export class AccountClient {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Sign 会话状态
|
||||
*/
|
||||
async getSignSessionStatus(sessionId: string): Promise<GetSignSessionStatusResponse> {
|
||||
return this.request<GetSignSessionStatusResponse>(
|
||||
'GET',
|
||||
`/api/v1/co-managed/sign/${sessionId}`
|
||||
);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 健康检查
|
||||
// ===========================================================================
|
||||
|
|
|
|||
|
|
@ -473,10 +473,12 @@ export class GrpcClient extends EventEmitter {
|
|||
// 标记已订阅(用于重连后恢复)
|
||||
this.eventStreamSubscribed = true;
|
||||
|
||||
// 取消现有流
|
||||
// 取消现有流 - 先移除事件监听器再取消,防止旧流的 end 事件触发重连
|
||||
if (this.eventStream) {
|
||||
const oldStream = this.eventStream;
|
||||
oldStream.removeAllListeners();
|
||||
try {
|
||||
this.eventStream.cancel();
|
||||
oldStream.cancel();
|
||||
} catch (e) {
|
||||
// 忽略
|
||||
}
|
||||
|
|
@ -485,6 +487,9 @@ export class GrpcClient extends EventEmitter {
|
|||
this.eventStream = (this.client as grpc.Client & { subscribeSessionEvents: (req: unknown) => grpc.ClientReadableStream<SessionEvent> })
|
||||
.subscribeSessionEvents({ party_id: partyId });
|
||||
|
||||
// 保存当前流的引用,用于在事件处理器中检查是否是当前活跃的流
|
||||
const currentStream = this.eventStream;
|
||||
|
||||
this.eventStream.on('data', (event: SessionEvent) => {
|
||||
this.emit('sessionEvent', event);
|
||||
});
|
||||
|
|
@ -493,6 +498,12 @@ export class GrpcClient extends EventEmitter {
|
|||
console.error('[gRPC] Session event stream error:', err.message);
|
||||
this.emit('streamError', err);
|
||||
|
||||
// 只有当前活跃的流才触发重连,防止旧流的事件干扰
|
||||
if (currentStream !== this.eventStream) {
|
||||
console.log('[gRPC] Ignoring error from old event stream');
|
||||
return;
|
||||
}
|
||||
|
||||
// 非主动取消的错误触发重连
|
||||
if (!err.message.includes('CANCELLED') && this.shouldReconnect) {
|
||||
this.triggerReconnect('Event stream error');
|
||||
|
|
@ -503,6 +514,12 @@ export class GrpcClient extends EventEmitter {
|
|||
console.log('[gRPC] Session event stream ended');
|
||||
this.emit('streamEnd');
|
||||
|
||||
// 只有当前活跃的流才触发重连,防止旧流的事件干扰
|
||||
if (currentStream !== this.eventStream) {
|
||||
console.log('[gRPC] Ignoring end from old event stream');
|
||||
return;
|
||||
}
|
||||
|
||||
// 流结束也触发重连
|
||||
if (this.shouldReconnect && this.eventStreamSubscribed) {
|
||||
this.triggerReconnect('Event stream ended');
|
||||
|
|
@ -533,23 +550,39 @@ export class GrpcClient extends EventEmitter {
|
|||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
if (!this.connected) {
|
||||
throw new Error('gRPC client not connected');
|
||||
}
|
||||
|
||||
// 保存订阅状态(用于重连后恢复)
|
||||
this.activeMessageSubscription = { sessionId, partyId };
|
||||
|
||||
// 取消现有流
|
||||
// 取消现有流 - 先移除事件监听器再取消,防止旧流的 end 事件触发重连
|
||||
if (this.messageStream) {
|
||||
const oldStream = this.messageStream;
|
||||
oldStream.removeAllListeners();
|
||||
try {
|
||||
this.messageStream.cancel();
|
||||
oldStream.cancel();
|
||||
} catch (e) {
|
||||
// 忽略
|
||||
console.log('[gRPC] Ignored error while canceling old message stream:', (e as Error).message);
|
||||
}
|
||||
this.messageStream = null;
|
||||
}
|
||||
|
||||
this.messageStream = (this.client as grpc.Client & { subscribeMessages: (req: unknown) => grpc.ClientReadableStream<MPCMessage> })
|
||||
.subscribeMessages({
|
||||
session_id: sessionId,
|
||||
party_id: partyId,
|
||||
});
|
||||
try {
|
||||
this.messageStream = (this.client as grpc.Client & { subscribeMessages: (req: unknown) => grpc.ClientReadableStream<MPCMessage> })
|
||||
.subscribeMessages({
|
||||
session_id: sessionId,
|
||||
party_id: partyId,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[gRPC] Failed to create message stream:', (e as Error).message);
|
||||
this.activeMessageSubscription = null;
|
||||
throw e;
|
||||
}
|
||||
|
||||
// 保存当前流的引用,用于在事件处理器中检查是否是当前活跃的流
|
||||
const currentStream = this.messageStream;
|
||||
|
||||
this.messageStream.on('data', (message: MPCMessage) => {
|
||||
this.emit('mpcMessage', message);
|
||||
|
|
@ -559,6 +592,12 @@ export class GrpcClient extends EventEmitter {
|
|||
console.error('[gRPC] Message stream error:', err.message);
|
||||
this.emit('messageStreamError', err);
|
||||
|
||||
// 只有当前活跃的流才触发重连,防止旧流的事件干扰
|
||||
if (currentStream !== this.messageStream) {
|
||||
console.log('[gRPC] Ignoring error from old message stream');
|
||||
return;
|
||||
}
|
||||
|
||||
// 非主动取消的错误触发重连
|
||||
if (!err.message.includes('CANCELLED') && this.shouldReconnect && this.activeMessageSubscription) {
|
||||
this.triggerReconnect('Message stream error');
|
||||
|
|
@ -569,6 +608,12 @@ export class GrpcClient extends EventEmitter {
|
|||
console.log('[gRPC] Message stream ended');
|
||||
this.emit('messageStreamEnd');
|
||||
|
||||
// 只有当前活跃的流才触发重连,防止旧流的事件干扰
|
||||
if (currentStream !== this.messageStream) {
|
||||
console.log('[gRPC] Ignoring end from old message stream');
|
||||
return;
|
||||
}
|
||||
|
||||
// 流结束也触发重连
|
||||
if (this.shouldReconnect && this.activeMessageSubscription) {
|
||||
this.triggerReconnect('Message stream ended');
|
||||
|
|
|
|||
|
|
@ -123,6 +123,12 @@ export class TSSHandler extends EventEmitter {
|
|||
* @param partyId 自己的 party ID
|
||||
*/
|
||||
prepareForKeygen(sessionId: string, partyId: string): void {
|
||||
// 检查 gRPC 连接状态
|
||||
if (!this.grpcClient.isConnected()) {
|
||||
console.error('[TSS] Cannot prepare for keygen: gRPC client not connected');
|
||||
throw new Error('gRPC client not connected');
|
||||
}
|
||||
|
||||
// 如果已经为同一个 session 准备过,跳过
|
||||
if (this.isPrepared && this.sessionId === sessionId) {
|
||||
console.log('[TSS] Already prepared for same session, skip');
|
||||
|
|
@ -284,8 +290,11 @@ export class TSSHandler extends EventEmitter {
|
|||
this.isPrepared = false;
|
||||
this.messageBuffer = [];
|
||||
this.tssProcess = null;
|
||||
// 清理消息监听器,防止下次 keygen 时重复注册
|
||||
this.sessionId = null;
|
||||
this.partyId = null;
|
||||
// 清理消息监听器和 gRPC 流订阅,防止下次 keygen 时出错
|
||||
this.grpcClient.removeAllListeners('mpcMessage');
|
||||
this.grpcClient.unsubscribeMessages();
|
||||
|
||||
if (code === 0 && resultData) {
|
||||
try {
|
||||
|
|
@ -321,14 +330,19 @@ export class TSSHandler extends EventEmitter {
|
|||
this.isPrepared = false;
|
||||
this.messageBuffer = [];
|
||||
this.tssProcess = null;
|
||||
// 清理消息监听器
|
||||
this.sessionId = null;
|
||||
this.partyId = null;
|
||||
// 清理消息监听器和 gRPC 流订阅
|
||||
this.grpcClient.removeAllListeners('mpcMessage');
|
||||
this.grpcClient.unsubscribeMessages();
|
||||
reject(err);
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
this.isRunning = false;
|
||||
this.isPrepared = false;
|
||||
this.sessionId = null;
|
||||
this.partyId = null;
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
|
|
@ -477,6 +491,253 @@ export class TSSHandler extends EventEmitter {
|
|||
this.messageBuffer = [];
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Co-Sign 相关方法 - 与 Keygen 完全隔离的签名功能
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* 预订阅签名消息流 - 在 joinSession 后立即调用
|
||||
* 与 prepareForKeygen 类似,确保在其他方开始发送消息时已准备好接收和缓冲
|
||||
*
|
||||
* @param sessionId 签名会话 ID
|
||||
* @param partyId 自己的 party ID
|
||||
*/
|
||||
prepareForSign(sessionId: string, partyId: string): void {
|
||||
// 检查 gRPC 连接状态
|
||||
if (!this.grpcClient.isConnected()) {
|
||||
console.error('[TSS-SIGN] Cannot prepare for sign: gRPC client not connected');
|
||||
throw new Error('gRPC client not connected');
|
||||
}
|
||||
|
||||
// 如果已经为同一个 session 准备过,跳过
|
||||
if (this.isPrepared && this.sessionId === sessionId) {
|
||||
console.log('[TSS-SIGN] Already prepared for same session, skip');
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果为不同的 session 准备过,先取消旧的订阅
|
||||
if (this.isPrepared && this.sessionId !== sessionId) {
|
||||
console.log(`[TSS-SIGN] Switching from session ${this.sessionId?.substring(0, 8)}... to ${sessionId.substring(0, 8)}...`);
|
||||
this.grpcClient.removeAllListeners('mpcMessage');
|
||||
this.grpcClient.unsubscribeMessages();
|
||||
this.messageBuffer = [];
|
||||
}
|
||||
|
||||
console.log(`[TSS-SIGN] Preparing for sign: session=${sessionId.substring(0, 8)}..., party=${partyId.substring(0, 8)}...`);
|
||||
|
||||
this.sessionId = sessionId;
|
||||
this.partyId = partyId;
|
||||
this.isPrepared = true;
|
||||
this.messageBuffer = [];
|
||||
|
||||
// 立即订阅消息流,开始缓冲消息
|
||||
this.grpcClient.on('mpcMessage', this.handleIncomingMessage.bind(this));
|
||||
this.grpcClient.subscribeMessages(sessionId, partyId);
|
||||
|
||||
console.log('[TSS-SIGN] Message subscription started, buffering enabled');
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消签名预订阅
|
||||
*/
|
||||
cancelSignPrepare(): void {
|
||||
if (!this.isPrepared) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[TSS-SIGN] Canceling sign prepare');
|
||||
this.isPrepared = false;
|
||||
this.messageBuffer = [];
|
||||
this.grpcClient.removeAllListeners('mpcMessage');
|
||||
this.grpcClient.unsubscribeMessages();
|
||||
this.sessionId = null;
|
||||
this.partyId = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 参与 Co-Sign 协议
|
||||
*
|
||||
* @param sessionId 签名会话 ID
|
||||
* @param partyId 自己的 party ID
|
||||
* @param partyIndex 自己在签名参与方中的索引
|
||||
* @param participants 签名参与方列表 (T 个参与方)
|
||||
* @param threshold 阈值配置 { t: 签名阈值, n: keygen时的总参与方数 }
|
||||
* @param messageHash 待签名的消息哈希 (hex 编码)
|
||||
* @param shareData 本地 share 数据 (base64 编码的加密数据)
|
||||
* @param sharePassword share 解密密码
|
||||
*/
|
||||
async participateSign(
|
||||
sessionId: string,
|
||||
partyId: string,
|
||||
partyIndex: number,
|
||||
participants: ParticipantInfo[],
|
||||
threshold: { t: number; n: number },
|
||||
messageHash: string,
|
||||
shareData: string,
|
||||
sharePassword: string
|
||||
): Promise<SignResult> {
|
||||
if (this.isRunning) {
|
||||
throw new Error('TSS protocol already running');
|
||||
}
|
||||
|
||||
// 检查是否已经预订阅
|
||||
const wasPrepared = this.isPrepared && this.sessionId === sessionId;
|
||||
const bufferedCount = this.messageBuffer.length;
|
||||
|
||||
console.log(`[TSS-SIGN] Starting sign: wasPrepared=${wasPrepared}, bufferedMessages=${bufferedCount}`);
|
||||
|
||||
this.sessionId = sessionId;
|
||||
this.partyId = partyId;
|
||||
this.partyIndex = partyIndex;
|
||||
this.participants = participants;
|
||||
this.isRunning = true;
|
||||
this.isProcessReady = false;
|
||||
// 注意:不清空消息缓冲,保留预订阅阶段收到的消息
|
||||
|
||||
// 构建 party index map
|
||||
this.partyIndexMap.clear();
|
||||
for (const p of participants) {
|
||||
this.partyIndexMap.set(p.partyId, p.partyIndex);
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const binaryPath = this.getTSSBinaryPath();
|
||||
console.log(`[TSS-SIGN] Binary path: ${binaryPath}`);
|
||||
console.log(`[TSS-SIGN] Binary exists: ${fs.existsSync(binaryPath)}`);
|
||||
|
||||
// 构建参与者列表 JSON
|
||||
const participantsJson = JSON.stringify(participants);
|
||||
console.log(`[TSS-SIGN] Participants: ${participantsJson}`);
|
||||
console.log(`[TSS-SIGN] partyIndex=${partyIndex}, threshold=${threshold.t}-of-${threshold.n}`);
|
||||
console.log(`[TSS-SIGN] messageHash=${messageHash.substring(0, 16)}...`);
|
||||
|
||||
// 启动 TSS 子进程 - sign 命令
|
||||
const args = [
|
||||
'sign',
|
||||
'--session-id', sessionId,
|
||||
'--party-id', partyId,
|
||||
'--party-index', partyIndex.toString(),
|
||||
'--threshold-t', threshold.t.toString(),
|
||||
'--threshold-n', threshold.n.toString(),
|
||||
'--participants', participantsJson,
|
||||
'--message-hash', messageHash,
|
||||
'--share-data', shareData,
|
||||
'--password', sharePassword,
|
||||
];
|
||||
console.log(`[TSS-SIGN] Spawning: ${binaryPath} sign ...`);
|
||||
|
||||
this.tssProcess = spawn(binaryPath, args);
|
||||
|
||||
let resultData = '';
|
||||
let stderrData = '';
|
||||
|
||||
// 如果没有预订阅,现在订阅消息
|
||||
if (!wasPrepared) {
|
||||
console.log('[TSS-SIGN] Subscribing to messages (not prepared before)');
|
||||
this.grpcClient.on('mpcMessage', this.handleIncomingMessage.bind(this));
|
||||
this.grpcClient.subscribeMessages(sessionId, partyId);
|
||||
} else {
|
||||
console.log(`[TSS-SIGN] Using existing subscription, ${bufferedCount} messages buffered`);
|
||||
}
|
||||
|
||||
// 处理标准输出 (JSON 消息)
|
||||
this.tssProcess.stdout?.on('data', (data: Buffer) => {
|
||||
const lines = data.toString().split('\n').filter(line => line.trim());
|
||||
|
||||
// 收到第一条输出时,标记进程就绪并发送缓冲的消息
|
||||
if (!this.isProcessReady && this.tssProcess?.stdin) {
|
||||
this.isProcessReady = true;
|
||||
console.log(`[TSS-SIGN] Process ready, flushing ${this.messageBuffer.length} buffered messages`);
|
||||
this.flushMessageBuffer();
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const message: TSSMessage = JSON.parse(line);
|
||||
this.handleTSSMessage(message);
|
||||
|
||||
if (message.type === 'result') {
|
||||
resultData = line;
|
||||
}
|
||||
} catch {
|
||||
// 非 JSON 输出,记录日志
|
||||
console.log('[TSS-SIGN]', line);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 处理标准错误
|
||||
this.tssProcess.stderr?.on('data', (data: Buffer) => {
|
||||
const errorText = data.toString();
|
||||
stderrData += errorText;
|
||||
console.error('[TSS-SIGN stderr]', errorText);
|
||||
});
|
||||
|
||||
// 处理进程退出
|
||||
this.tssProcess.on('close', (code) => {
|
||||
const completedSessionId = this.sessionId;
|
||||
this.isRunning = false;
|
||||
this.isProcessReady = false;
|
||||
this.isPrepared = false;
|
||||
this.messageBuffer = [];
|
||||
this.tssProcess = null;
|
||||
this.sessionId = null;
|
||||
this.partyId = null;
|
||||
// 清理消息监听器和 gRPC 流订阅
|
||||
this.grpcClient.removeAllListeners('mpcMessage');
|
||||
this.grpcClient.unsubscribeMessages();
|
||||
|
||||
if (code === 0 && resultData) {
|
||||
try {
|
||||
const result: TSSMessage = JSON.parse(resultData);
|
||||
if (result.payload) {
|
||||
// 成功完成后清理该会话的已处理消息记录
|
||||
if (this.database && completedSessionId) {
|
||||
this.database.clearProcessedMessages(completedSessionId);
|
||||
}
|
||||
resolve({
|
||||
success: true,
|
||||
signature: Buffer.from(result.payload, 'base64'),
|
||||
});
|
||||
} else {
|
||||
reject(new Error(result.error || 'Sign failed: no result data'));
|
||||
}
|
||||
} catch (e) {
|
||||
reject(new Error(`Failed to parse sign result: ${e}`));
|
||||
}
|
||||
} else {
|
||||
const errorMsg = stderrData.trim() || `Sign process exited with code ${code}`;
|
||||
console.error(`[TSS-SIGN] Process failed: code=${code}, stderr=${stderrData}`);
|
||||
reject(new Error(errorMsg));
|
||||
}
|
||||
});
|
||||
|
||||
// 处理进程错误
|
||||
this.tssProcess.on('error', (err) => {
|
||||
this.isRunning = false;
|
||||
this.isProcessReady = false;
|
||||
this.isPrepared = false;
|
||||
this.messageBuffer = [];
|
||||
this.tssProcess = null;
|
||||
this.sessionId = null;
|
||||
this.partyId = null;
|
||||
// 清理消息监听器和 gRPC 流订阅
|
||||
this.grpcClient.removeAllListeners('mpcMessage');
|
||||
this.grpcClient.unsubscribeMessages();
|
||||
reject(err);
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
this.isRunning = false;
|
||||
this.isPrepared = false;
|
||||
this.sessionId = null;
|
||||
this.partyId = null;
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消正在进行的协议
|
||||
*/
|
||||
|
|
@ -489,7 +750,11 @@ export class TSSHandler extends EventEmitter {
|
|||
this.isProcessReady = false;
|
||||
this.isPrepared = false;
|
||||
this.messageBuffer = [];
|
||||
this.sessionId = null;
|
||||
this.partyId = null;
|
||||
// 清理消息监听器和 gRPC 流订阅
|
||||
this.grpcClient.removeAllListeners('mpcMessage');
|
||||
this.grpcClient.unsubscribeMessages();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||
register: (partyId: string, role: string) =>
|
||||
ipcRenderer.invoke('grpc:register', { partyId, role }),
|
||||
|
||||
// 签名相关
|
||||
// 签名相关 (旧版 - persistent 签名使用)
|
||||
validateSigningSession: (code: string) =>
|
||||
ipcRenderer.invoke('grpc:validateSigningSession', { code }),
|
||||
|
||||
|
|
@ -88,6 +88,53 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||
},
|
||||
},
|
||||
|
||||
// ===========================================================================
|
||||
// Co-Sign 相关 - 全新的多方协作签名 API
|
||||
// ===========================================================================
|
||||
cosign: {
|
||||
// 创建 Co-Sign 会话
|
||||
createSession: (params: {
|
||||
shareId: string;
|
||||
sharePassword: string;
|
||||
messageHash: string;
|
||||
initiatorName?: string;
|
||||
}) => ipcRenderer.invoke('cosign:createSession', params),
|
||||
|
||||
// 验证邀请码
|
||||
validateInviteCode: (code: string) =>
|
||||
ipcRenderer.invoke('cosign:validateInviteCode', { code }),
|
||||
|
||||
// 加入 Co-Sign 会话
|
||||
joinSession: (params: {
|
||||
sessionId: string;
|
||||
shareId: string;
|
||||
sharePassword: string;
|
||||
joinToken: string;
|
||||
walletName?: string;
|
||||
messageHash: string;
|
||||
threshold: { t: number; n: number };
|
||||
}) => ipcRenderer.invoke('cosign:joinSession', params),
|
||||
|
||||
// 获取会话状态
|
||||
getSessionStatus: (sessionId: string) =>
|
||||
ipcRenderer.invoke('cosign:getSessionStatus', { sessionId }),
|
||||
|
||||
// 订阅会话事件
|
||||
subscribeSessionEvents: (sessionId: string, callback: (event: unknown) => void) => {
|
||||
const channel = `cosign:events:${sessionId}`;
|
||||
const listener = (_event: unknown, data: unknown) => callback(data);
|
||||
eventSubscriptions.set(channel, listener);
|
||||
ipcRenderer.on(channel, listener);
|
||||
ipcRenderer.send('cosign:subscribeSessionEvents', { sessionId });
|
||||
|
||||
return () => {
|
||||
ipcRenderer.removeListener(channel, listener);
|
||||
eventSubscriptions.delete(channel);
|
||||
ipcRenderer.send('cosign:unsubscribeSessionEvents', { sessionId });
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
// ===========================================================================
|
||||
// Account 服务相关 (HTTP API)
|
||||
// ===========================================================================
|
||||
|
|
|
|||
|
|
@ -9,6 +9,10 @@ import Create from './pages/Create';
|
|||
import Session from './pages/Session';
|
||||
import Sign from './pages/Sign';
|
||||
import Settings from './pages/Settings';
|
||||
// Co-Sign 页面
|
||||
import CoSignCreate from './pages/CoSignCreate';
|
||||
import CoSignJoin from './pages/CoSignJoin';
|
||||
import CoSignSession from './pages/CoSignSession';
|
||||
|
||||
function App() {
|
||||
const [startupComplete, setStartupComplete] = useState(false);
|
||||
|
|
@ -37,12 +41,20 @@ function App() {
|
|||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
{/* Keygen 路由 */}
|
||||
<Route path="/join" element={<Join />} />
|
||||
<Route path="/join/:inviteCode" element={<Join />} />
|
||||
<Route path="/create" element={<Create />} />
|
||||
<Route path="/session/:sessionId" element={<Session />} />
|
||||
{/* 旧版签名路由 (persistent) */}
|
||||
<Route path="/sign" element={<Sign />} />
|
||||
<Route path="/sign/:sessionId" element={<Sign />} />
|
||||
{/* Co-Sign 路由 */}
|
||||
<Route path="/cosign/create" element={<CoSignCreate />} />
|
||||
<Route path="/cosign/join" element={<CoSignJoin />} />
|
||||
<Route path="/cosign/join/:inviteCode" element={<CoSignJoin />} />
|
||||
<Route path="/cosign/session/:sessionId" element={<CoSignSession />} />
|
||||
{/* 设置 */}
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
|
|
|
|||
|
|
@ -11,14 +11,14 @@ const navItems = [
|
|||
{ path: '/', label: '我的钱包', icon: '🔐' },
|
||||
{ path: '/create', label: '创建钱包', icon: '➕' },
|
||||
{ path: '/join', label: '加入创建', icon: '🤝' },
|
||||
{ path: '/sign', label: '参与签名', icon: '✍️' },
|
||||
{ path: '/cosign/join', label: '参与签名', icon: '🔥' },
|
||||
{ path: '/settings', label: '设置', icon: '⚙️' },
|
||||
];
|
||||
|
||||
export default function Layout({ children }: LayoutProps) {
|
||||
const location = useLocation();
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [kavaNetwork, setKavaNetwork] = useState<'mainnet' | 'testnet'>('testnet');
|
||||
const [kavaNetwork, setKavaNetwork] = useState<'mainnet' | 'testnet'>('mainnet');
|
||||
|
||||
const { environment, operation, checkAllServices, appReady } = useAppStore();
|
||||
|
||||
|
|
@ -26,9 +26,41 @@ export default function Layout({ children }: LayoutProps) {
|
|||
useEffect(() => {
|
||||
checkAllServices();
|
||||
// 获取当前 Kava 网络
|
||||
window.electronAPI?.kava.getNetwork().then(result => {
|
||||
setKavaNetwork(result.network);
|
||||
});
|
||||
const loadNetwork = () => {
|
||||
// 优先从 localStorage 读取(与 transaction.ts 保持一致)
|
||||
const storedNetwork = localStorage.getItem('kava_network') as 'mainnet' | 'testnet' | null;
|
||||
if (storedNetwork) {
|
||||
setKavaNetwork(storedNetwork);
|
||||
} else {
|
||||
// 后备:从 Electron API 读取
|
||||
window.electronAPI?.kava.getNetwork().then(result => {
|
||||
setKavaNetwork(result.network);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
loadNetwork();
|
||||
|
||||
// 监听 localStorage 变化(当其他标签页切换网络时触发)
|
||||
const handleStorageChange = (e: StorageEvent) => {
|
||||
if (e.key === 'kava_network' && e.newValue) {
|
||||
setKavaNetwork(e.newValue as 'mainnet' | 'testnet');
|
||||
}
|
||||
};
|
||||
|
||||
// 监听同一窗口的自定义事件(Settings 页面切换网络时触发)
|
||||
const handleCustomNetworkChange = (e: Event) => {
|
||||
const customEvent = e as CustomEvent<{ network: 'mainnet' | 'testnet' }>;
|
||||
setKavaNetwork(customEvent.detail.network);
|
||||
};
|
||||
|
||||
window.addEventListener('storage', handleStorageChange);
|
||||
window.addEventListener('kava-network-change', handleCustomNetworkChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('storage', handleStorageChange);
|
||||
window.removeEventListener('kava-network-change', handleCustomNetworkChange);
|
||||
};
|
||||
}, [checkAllServices]);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,265 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import styles from './Create.module.css';
|
||||
|
||||
interface Share {
|
||||
id: string;
|
||||
walletName: string;
|
||||
publicKey: string;
|
||||
threshold: { t: number; n: number };
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface CreateCoSignResult {
|
||||
success: boolean;
|
||||
sessionId?: string;
|
||||
inviteCode?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export default function CoSignCreate() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [shares, setShares] = useState<Share[]>([]);
|
||||
const [selectedShareId, setSelectedShareId] = useState('');
|
||||
const [sharePassword, setSharePassword] = useState('');
|
||||
const [messageHash, setMessageHash] = useState('');
|
||||
const [participantName, setParticipantName] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<CreateCoSignResult | null>(null);
|
||||
const [step, setStep] = useState<'config' | 'creating' | 'created'>('config');
|
||||
|
||||
// 加载本地 shares
|
||||
useEffect(() => {
|
||||
const loadShares = async () => {
|
||||
try {
|
||||
const result = await window.electronAPI.storage.listShares();
|
||||
// 兼容不同返回格式
|
||||
const shareList = Array.isArray(result) ? result : ((result as any)?.data || []);
|
||||
setShares(shareList);
|
||||
if (shareList.length > 0) {
|
||||
setSelectedShareId(shareList[0].id);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load shares:', err);
|
||||
}
|
||||
};
|
||||
loadShares();
|
||||
}, []);
|
||||
|
||||
const handleCreateSession = async () => {
|
||||
if (!selectedShareId) {
|
||||
setError('请选择一个钱包');
|
||||
return;
|
||||
}
|
||||
if (!messageHash.trim()) {
|
||||
setError('请输入待签名的消息哈希');
|
||||
return;
|
||||
}
|
||||
if (!/^[0-9a-fA-F]{64}$/.test(messageHash.trim())) {
|
||||
setError('消息哈希必须是 64 位十六进制字符串 (32 字节)');
|
||||
return;
|
||||
}
|
||||
if (!participantName.trim()) {
|
||||
setParticipantName('发起者');
|
||||
}
|
||||
|
||||
setStep('creating');
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const createResult = await window.electronAPI.cosign.createSession({
|
||||
shareId: selectedShareId,
|
||||
sharePassword: sharePassword,
|
||||
messageHash: messageHash.trim().toLowerCase(),
|
||||
initiatorName: participantName.trim() || '发起者',
|
||||
});
|
||||
|
||||
if (createResult.success) {
|
||||
setResult(createResult);
|
||||
setStep('created');
|
||||
} else {
|
||||
setError(createResult.error || '创建签名会话失败');
|
||||
setStep('config');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('创建签名会话失败,请检查网络连接');
|
||||
setStep('config');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyInviteCode = async () => {
|
||||
if (result?.inviteCode) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(result.inviteCode);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoToSession = () => {
|
||||
if (result?.sessionId) {
|
||||
navigate(`/cosign/session/${result.sessionId}`);
|
||||
}
|
||||
};
|
||||
|
||||
const selectedShare = shares.find(s => s.id === selectedShareId);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.card}>
|
||||
<h1 className={styles.title}>发起多方签名</h1>
|
||||
<p className={styles.subtitle}>选择钱包并输入待签名的消息哈希</p>
|
||||
|
||||
{step === 'config' && (
|
||||
<div className={styles.form}>
|
||||
{/* 签名说明 */}
|
||||
<div className={styles.infoBox}>
|
||||
<div className={styles.infoIcon}>i</div>
|
||||
<div className={styles.infoContent}>
|
||||
<strong>多方协作签名说明</strong>
|
||||
<ul className={styles.infoList}>
|
||||
<li><strong>选择钱包</strong>: 使用已创建的共管钱包进行签名</li>
|
||||
<li><strong>消息哈希</strong>: 待签名数据的 SHA256 哈希值</li>
|
||||
<li><strong>阈值签名</strong>: 需要足够数量的参与方共同完成</li>
|
||||
<li><strong>安全性</strong>: 私钥份额永不离开本地设备</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 选择钱包 */}
|
||||
<div className={styles.inputGroup}>
|
||||
<label className={styles.label}>选择钱包</label>
|
||||
{shares.length === 0 ? (
|
||||
<p className={styles.hint}>暂无可用钱包,请先创建或加入共管钱包</p>
|
||||
) : (
|
||||
<select
|
||||
value={selectedShareId}
|
||||
onChange={(e) => setSelectedShareId(e.target.value)}
|
||||
className={styles.input}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{shares.map(share => (
|
||||
<option key={share.id} value={share.id}>
|
||||
{share.walletName} ({share.threshold.t}-of-{share.threshold.n})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{selectedShare && (
|
||||
<p className={styles.hint}>
|
||||
公钥: {selectedShare.publicKey.substring(0, 16)}...
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 钱包密码 */}
|
||||
<div className={styles.inputGroup}>
|
||||
<label className={styles.label}>钱包密码 (可选)</label>
|
||||
<input
|
||||
type="password"
|
||||
value={sharePassword}
|
||||
onChange={(e) => setSharePassword(e.target.value)}
|
||||
placeholder="如果设置了密码,请输入"
|
||||
className={styles.input}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 消息哈希 */}
|
||||
<div className={styles.inputGroup}>
|
||||
<label className={styles.label}>消息哈希 (Hex)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={messageHash}
|
||||
onChange={(e) => setMessageHash(e.target.value)}
|
||||
placeholder="64位十六进制字符串,如: a1b2c3d4..."
|
||||
className={styles.input}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<p className={styles.hint}>
|
||||
待签名数据的 SHA256 哈希值 (32 字节 = 64 个十六进制字符)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 参与者名称 */}
|
||||
<div className={styles.inputGroup}>
|
||||
<label className={styles.label}>您的名称</label>
|
||||
<input
|
||||
type="text"
|
||||
value={participantName}
|
||||
onChange={(e) => setParticipantName(e.target.value)}
|
||||
placeholder="输入您的名称(其他参与者可见)"
|
||||
className={styles.input}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <div className={styles.error}>{error}</div>}
|
||||
|
||||
<div className={styles.actions}>
|
||||
<button
|
||||
className={styles.secondaryButton}
|
||||
onClick={() => navigate('/')}
|
||||
disabled={isLoading}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
className={styles.primaryButton}
|
||||
onClick={handleCreateSession}
|
||||
disabled={isLoading || shares.length === 0}
|
||||
>
|
||||
创建签名会话
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'creating' && (
|
||||
<div className={styles.creating}>
|
||||
<div className={styles.spinner}></div>
|
||||
<p>正在创建签名会话...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'created' && result && (
|
||||
<div className={styles.form}>
|
||||
<div className={styles.successIcon}>OK</div>
|
||||
<h3 className={styles.successTitle}>签名会话创建成功</h3>
|
||||
|
||||
<div className={styles.inviteSection}>
|
||||
<label className={styles.label}>邀请码</label>
|
||||
<div className={styles.inviteCodeWrapper}>
|
||||
<code className={styles.inviteCode}>{result.inviteCode}</code>
|
||||
<button
|
||||
className={styles.copyButton}
|
||||
onClick={handleCopyInviteCode}
|
||||
>
|
||||
复制
|
||||
</button>
|
||||
</div>
|
||||
<p className={styles.hint}>
|
||||
将此邀请码分享给其他参与方,他们可以使用此码加入签名
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<button
|
||||
className={styles.primaryButton}
|
||||
onClick={handleGoToSession}
|
||||
>
|
||||
进入签名会话
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,410 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import styles from './Join.module.css';
|
||||
|
||||
interface Share {
|
||||
id: string;
|
||||
walletName: string;
|
||||
publicKey: string;
|
||||
sessionId: string;
|
||||
threshold: { t: number; n: number };
|
||||
}
|
||||
|
||||
interface SignPartyInfo {
|
||||
party_id: string;
|
||||
party_index: number;
|
||||
}
|
||||
|
||||
interface SessionInfo {
|
||||
sessionId: string;
|
||||
keygenSessionId: string;
|
||||
walletName: string;
|
||||
messageHash: string;
|
||||
threshold: { t: number; n: number };
|
||||
status: string;
|
||||
currentParticipants: number;
|
||||
parties?: SignPartyInfo[];
|
||||
}
|
||||
|
||||
interface ValidateResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
sessionInfo?: SessionInfo;
|
||||
joinToken?: string;
|
||||
}
|
||||
|
||||
export default function CoSignJoin() {
|
||||
const { inviteCode } = useParams<{ inviteCode?: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [code, setCode] = useState(inviteCode || '');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [sessionInfo, setSessionInfo] = useState<SessionInfo | null>(null);
|
||||
const [joinToken, setJoinToken] = useState<string | null>(null);
|
||||
const [step, setStep] = useState<'input' | 'select_share' | 'joining'>('input');
|
||||
|
||||
// Share 选择相关
|
||||
const [shares, setShares] = useState<Share[]>([]);
|
||||
const [selectedShareId, setSelectedShareId] = useState('');
|
||||
const [sharePassword, setSharePassword] = useState('');
|
||||
const [autoJoinAttempted, setAutoJoinAttempted] = useState(false);
|
||||
|
||||
// 加载本地 shares
|
||||
useEffect(() => {
|
||||
const loadShares = async () => {
|
||||
try {
|
||||
const result = await window.electronAPI.storage.listShares();
|
||||
// 兼容不同返回格式
|
||||
const shareList = Array.isArray(result) ? result : ((result as any)?.data || []);
|
||||
console.log('[CoSignJoin] Loaded shares:', shareList.map((s: Share) => ({
|
||||
id: s.id,
|
||||
sessionId: s.sessionId,
|
||||
walletName: s.walletName,
|
||||
})));
|
||||
setShares(shareList);
|
||||
} catch (err) {
|
||||
console.error('Failed to load shares:', err);
|
||||
}
|
||||
};
|
||||
loadShares();
|
||||
}, []);
|
||||
|
||||
// 如果 URL 中有邀请码,自动验证
|
||||
useEffect(() => {
|
||||
if (inviteCode) {
|
||||
handleValidateCode(inviteCode);
|
||||
}
|
||||
}, [inviteCode]);
|
||||
|
||||
// 自动选择匹配的 share
|
||||
useEffect(() => {
|
||||
if (sessionInfo && shares.length > 0 && !selectedShareId) {
|
||||
// 尝试找到匹配的 share(基于 keygen session ID)
|
||||
const matchingShare = shares.find(s => s.sessionId === sessionInfo.keygenSessionId);
|
||||
console.log('[CoSignJoin] Auto-select share check:', {
|
||||
keygenSessionId: sessionInfo.keygenSessionId,
|
||||
sharesSessionIds: shares.map(s => s.sessionId),
|
||||
matchingShare: matchingShare ? { id: matchingShare.id, sessionId: matchingShare.sessionId } : null,
|
||||
});
|
||||
if (matchingShare) {
|
||||
console.log('[CoSignJoin] Auto-selecting matching share:', matchingShare.id);
|
||||
setSelectedShareId(matchingShare.id);
|
||||
} else if (shares.length === 1) {
|
||||
// 如果只有一个 share,自动选择
|
||||
console.log('[CoSignJoin] Auto-selecting only share:', shares[0].id);
|
||||
setSelectedShareId(shares[0].id);
|
||||
} else {
|
||||
console.log('[CoSignJoin] No matching share found, user must select manually');
|
||||
}
|
||||
}
|
||||
}, [sessionInfo, shares, selectedShareId]);
|
||||
|
||||
// 自动加入
|
||||
useEffect(() => {
|
||||
console.log('[CoSignJoin] Auto-join check:', {
|
||||
step,
|
||||
hasSessionInfo: !!sessionInfo,
|
||||
hasJoinToken: !!joinToken,
|
||||
selectedShareId,
|
||||
autoJoinAttempted,
|
||||
isLoading,
|
||||
sharesCount: shares.length,
|
||||
});
|
||||
|
||||
if (
|
||||
step === 'select_share' &&
|
||||
sessionInfo &&
|
||||
joinToken &&
|
||||
selectedShareId &&
|
||||
!autoJoinAttempted &&
|
||||
!isLoading
|
||||
) {
|
||||
// 找到匹配的 share 且未尝试过自动加入,则自动加入
|
||||
const matchingShare = shares.find(s => s.sessionId === sessionInfo.keygenSessionId);
|
||||
console.log('[CoSignJoin] Auto-join conditions met, checking share match:', {
|
||||
keygenSessionId: sessionInfo.keygenSessionId,
|
||||
matchingShareId: matchingShare?.id,
|
||||
selectedShareId,
|
||||
isMatch: matchingShare && matchingShare.id === selectedShareId,
|
||||
});
|
||||
if (matchingShare && matchingShare.id === selectedShareId) {
|
||||
console.log('[CoSignJoin] Auto-joining session...');
|
||||
setAutoJoinAttempted(true);
|
||||
handleJoinSession();
|
||||
} else {
|
||||
console.log('[CoSignJoin] Share mismatch, not auto-joining');
|
||||
}
|
||||
}
|
||||
}, [step, sessionInfo, joinToken, selectedShareId, autoJoinAttempted, isLoading, shares]);
|
||||
|
||||
const handleValidateCode = async (codeToValidate: string) => {
|
||||
console.log('[CoSignJoin] handleValidateCode called:', codeToValidate);
|
||||
if (!codeToValidate.trim()) {
|
||||
setError('请输入邀请码');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result: ValidateResult = await window.electronAPI.cosign.validateInviteCode(codeToValidate);
|
||||
console.log('[CoSignJoin] validateInviteCode result:', {
|
||||
success: result.success,
|
||||
sessionInfo: result.sessionInfo,
|
||||
hasJoinToken: !!result.joinToken,
|
||||
joinTokenPreview: result.joinToken?.substring(0, 20) + '...',
|
||||
error: result.error,
|
||||
});
|
||||
if (result.success && result.sessionInfo) {
|
||||
setSessionInfo(result.sessionInfo);
|
||||
if (result.joinToken) {
|
||||
setJoinToken(result.joinToken);
|
||||
} else {
|
||||
console.warn('[CoSignJoin] WARNING: No joinToken in response!');
|
||||
}
|
||||
setStep('select_share');
|
||||
} else {
|
||||
setError(result.error || '无效的邀请码');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[CoSignJoin] validateInviteCode error:', err);
|
||||
setError('验证邀请码失败,请检查网络连接');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleJoinSession = async () => {
|
||||
console.log('[CoSignJoin] handleJoinSession called:', {
|
||||
hasSessionInfo: !!sessionInfo,
|
||||
hasJoinToken: !!joinToken,
|
||||
selectedShareId,
|
||||
});
|
||||
|
||||
if (!sessionInfo) {
|
||||
setError('会话信息不完整');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!joinToken) {
|
||||
setError('未获取到加入令牌,请重新验证邀请码');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedShareId) {
|
||||
setError('请选择一个钱包');
|
||||
return;
|
||||
}
|
||||
|
||||
setStep('joining');
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
console.log('[CoSignJoin] Calling cosign.joinSession with:', {
|
||||
sessionId: sessionInfo.sessionId,
|
||||
shareId: selectedShareId,
|
||||
walletName: sessionInfo.walletName,
|
||||
messageHash: sessionInfo.messageHash,
|
||||
threshold: sessionInfo.threshold,
|
||||
parties: sessionInfo.parties,
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.cosign.joinSession({
|
||||
sessionId: sessionInfo.sessionId,
|
||||
shareId: selectedShareId,
|
||||
sharePassword: sharePassword,
|
||||
joinToken: joinToken,
|
||||
walletName: sessionInfo.walletName,
|
||||
messageHash: sessionInfo.messageHash,
|
||||
threshold: sessionInfo.threshold,
|
||||
parties: sessionInfo.parties,
|
||||
});
|
||||
console.log('[CoSignJoin] joinSession result:', result);
|
||||
|
||||
if (result.success) {
|
||||
navigate(`/cosign/session/${sessionInfo.sessionId}`);
|
||||
} else {
|
||||
setError(result.error || '加入会话失败');
|
||||
setStep('select_share');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('加入会话失败,请重试');
|
||||
setStep('select_share');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePaste = async () => {
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
setCode(text.trim());
|
||||
} catch (err) {
|
||||
console.error('Failed to read clipboard:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const selectedShare = shares.find(s => s.id === selectedShareId);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.card}>
|
||||
<h1 className={styles.title}>加入多方签名</h1>
|
||||
<p className={styles.subtitle}>输入邀请码加入签名会话</p>
|
||||
|
||||
{step === 'input' && (
|
||||
<div className={styles.form}>
|
||||
<div className={styles.inputGroup}>
|
||||
<label className={styles.label}>邀请码</label>
|
||||
<div className={styles.inputWrapper}>
|
||||
<input
|
||||
type="text"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
placeholder="粘贴签名邀请码"
|
||||
className={styles.input}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<button
|
||||
className={styles.pasteButton}
|
||||
onClick={handlePaste}
|
||||
disabled={isLoading}
|
||||
>
|
||||
粘贴
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className={styles.error}>{error}</div>}
|
||||
|
||||
<div className={styles.actions}>
|
||||
<button
|
||||
className={styles.secondaryButton}
|
||||
onClick={() => navigate('/')}
|
||||
disabled={isLoading}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
className={styles.primaryButton}
|
||||
onClick={() => handleValidateCode(code)}
|
||||
disabled={isLoading || !code.trim()}
|
||||
>
|
||||
{isLoading ? '验证中...' : '下一步'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'select_share' && sessionInfo && (
|
||||
<div className={styles.form}>
|
||||
<div className={styles.sessionInfo}>
|
||||
<h3 className={styles.sessionTitle}>签名会话信息</h3>
|
||||
<div className={styles.infoGrid}>
|
||||
<div className={styles.infoItem}>
|
||||
<span className={styles.infoLabel}>钱包名称</span>
|
||||
<span className={styles.infoValue}>{sessionInfo.walletName}</span>
|
||||
</div>
|
||||
<div className={styles.infoItem}>
|
||||
<span className={styles.infoLabel}>签名阈值</span>
|
||||
<span className={styles.infoValue}>
|
||||
{sessionInfo.threshold.t}-of-{sessionInfo.threshold.n}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.infoItem}>
|
||||
<span className={styles.infoLabel}>消息哈希</span>
|
||||
<span className={styles.infoValue} style={{ fontFamily: 'monospace', fontSize: '12px' }}>
|
||||
{sessionInfo.messageHash.substring(0, 16)}...
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.infoItem}>
|
||||
<span className={styles.infoLabel}>当前参与者</span>
|
||||
<span className={styles.infoValue}>
|
||||
{sessionInfo.currentParticipants} / {sessionInfo.threshold.t}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 选择本地 share */}
|
||||
<div className={styles.inputGroup}>
|
||||
<label className={styles.label}>选择本地钱包</label>
|
||||
{shares.length === 0 ? (
|
||||
<p className={styles.hint}>暂无可用钱包,请先创建或加入共管钱包</p>
|
||||
) : (
|
||||
<select
|
||||
value={selectedShareId}
|
||||
onChange={(e) => setSelectedShareId(e.target.value)}
|
||||
className={styles.input}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<option value="">请选择...</option>
|
||||
{shares.map(share => (
|
||||
<option key={share.id} value={share.id}>
|
||||
{share.walletName} ({share.threshold.t}-of-{share.threshold.n})
|
||||
{share.sessionId === sessionInfo.keygenSessionId ? ' [匹配]' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{selectedShare && (
|
||||
<p className={styles.hint}>
|
||||
公钥: {selectedShare.publicKey.substring(0, 16)}...
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 钱包密码 */}
|
||||
<div className={styles.inputGroup}>
|
||||
<label className={styles.label}>钱包密码 (可选)</label>
|
||||
<input
|
||||
type="password"
|
||||
value={sharePassword}
|
||||
onChange={(e) => setSharePassword(e.target.value)}
|
||||
placeholder="如果设置了密码,请输入"
|
||||
className={styles.input}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <div className={styles.error}>{error}</div>}
|
||||
|
||||
<div className={styles.actions}>
|
||||
<button
|
||||
className={styles.secondaryButton}
|
||||
onClick={() => {
|
||||
setStep('input');
|
||||
setSessionInfo(null);
|
||||
setJoinToken(null);
|
||||
setSelectedShareId('');
|
||||
setError(null);
|
||||
setAutoJoinAttempted(false);
|
||||
}}
|
||||
disabled={isLoading}
|
||||
>
|
||||
返回
|
||||
</button>
|
||||
<button
|
||||
className={styles.primaryButton}
|
||||
onClick={handleJoinSession}
|
||||
disabled={isLoading || !selectedShareId}
|
||||
>
|
||||
{isLoading ? '加入中...' : '加入签名'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'joining' && (
|
||||
<div className={styles.joining}>
|
||||
<div className={styles.spinner}></div>
|
||||
<p>正在加入签名会话...</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,636 @@
|
|||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import styles from './Session.module.css';
|
||||
import {
|
||||
finalizeTransaction,
|
||||
broadcastTransaction,
|
||||
type PreparedTransaction,
|
||||
} from '../utils/transaction';
|
||||
|
||||
// 从 sessionStorage 获取的交易信息
|
||||
interface TransactionInfo {
|
||||
preparedTx: PreparedTransaction;
|
||||
to: string;
|
||||
amount: string;
|
||||
from: string;
|
||||
walletName: string;
|
||||
}
|
||||
|
||||
interface Participant {
|
||||
partyId: string;
|
||||
name: string;
|
||||
status: 'waiting' | 'ready' | 'processing' | 'completed' | 'failed';
|
||||
}
|
||||
|
||||
interface SessionState {
|
||||
sessionId: string;
|
||||
walletName: string;
|
||||
messageHash: string;
|
||||
inviteCode?: string;
|
||||
threshold: { t: number; n: number };
|
||||
status: 'waiting' | 'ready' | 'processing' | 'completed' | 'failed';
|
||||
participants: Participant[];
|
||||
currentRound: number;
|
||||
totalRounds: number;
|
||||
signature?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export default function CoSignSession() {
|
||||
const { sessionId } = useParams<{ sessionId: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [session, setSession] = useState<SessionState | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 交易广播相关状态
|
||||
const [txInfo, setTxInfo] = useState<TransactionInfo | null>(null);
|
||||
const [broadcastStep, setBroadcastStep] = useState<'idle' | 'broadcasting' | 'success' | 'error'>('idle');
|
||||
const [txHash, setTxHash] = useState<string | null>(null);
|
||||
const [broadcastError, setBroadcastError] = useState<string | null>(null);
|
||||
|
||||
const fetchSessionStatus = useCallback(async () => {
|
||||
if (!sessionId) return;
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.cosign.getSessionStatus(sessionId);
|
||||
if (result.success && result.session) {
|
||||
setSession({
|
||||
sessionId: result.session.sessionId || sessionId,
|
||||
walletName: result.session.walletName || '',
|
||||
messageHash: result.session.messageHash || '',
|
||||
inviteCode: result.session.inviteCode || '',
|
||||
threshold: result.session.threshold || { t: 0, n: 0 },
|
||||
status: mapStatus(result.session.status),
|
||||
participants: (result.session.participants || []).map(p => ({
|
||||
...p,
|
||||
status: mapParticipantStatus(p.status),
|
||||
})),
|
||||
currentRound: 0,
|
||||
totalRounds: 9, // GG20 签名有 9 轮
|
||||
});
|
||||
} else {
|
||||
setError(result.error || '获取会话状态失败');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('获取会话状态失败');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [sessionId]);
|
||||
|
||||
// 映射参与者状态
|
||||
const mapParticipantStatus = (status: string): Participant['status'] => {
|
||||
switch (status) {
|
||||
case 'waiting':
|
||||
case 'pending':
|
||||
return 'waiting';
|
||||
case 'ready':
|
||||
case 'joined':
|
||||
return 'ready';
|
||||
case 'processing':
|
||||
case 'signing':
|
||||
return 'processing';
|
||||
case 'completed':
|
||||
return 'completed';
|
||||
case 'failed':
|
||||
return 'failed';
|
||||
default:
|
||||
return 'waiting';
|
||||
}
|
||||
};
|
||||
|
||||
// 映射后端状态到前端状态
|
||||
const mapStatus = (status: string): 'waiting' | 'ready' | 'processing' | 'completed' | 'failed' => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
case 'waiting':
|
||||
return 'waiting';
|
||||
case 'all_joined':
|
||||
case 'ready':
|
||||
return 'ready';
|
||||
case 'in_progress':
|
||||
case 'signing':
|
||||
return 'processing';
|
||||
case 'completed':
|
||||
return 'completed';
|
||||
case 'failed':
|
||||
case 'expired':
|
||||
return 'failed';
|
||||
default:
|
||||
return 'waiting';
|
||||
}
|
||||
};
|
||||
|
||||
// 加载交易信息
|
||||
useEffect(() => {
|
||||
if (sessionId) {
|
||||
const storedTxInfo = sessionStorage.getItem(`tx_${sessionId}`);
|
||||
if (storedTxInfo) {
|
||||
try {
|
||||
const parsed = JSON.parse(storedTxInfo);
|
||||
// 恢复 bigint 类型 (Legacy 交易使用 gasPrice)
|
||||
if (parsed.preparedTx) {
|
||||
parsed.preparedTx.gasLimit = BigInt(parsed.preparedTx.gasLimit);
|
||||
parsed.preparedTx.gasPrice = BigInt(parsed.preparedTx.gasPrice);
|
||||
parsed.preparedTx.value = BigInt(parsed.preparedTx.value);
|
||||
}
|
||||
setTxInfo(parsed);
|
||||
} catch (err) {
|
||||
console.error('Failed to parse transaction info:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [sessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSessionStatus();
|
||||
|
||||
// 订阅会话事件
|
||||
const unsubscribe = window.electronAPI.cosign.subscribeSessionEvents(
|
||||
sessionId!,
|
||||
(event: any) => {
|
||||
console.log('[CoSignSession] Received event:', event);
|
||||
|
||||
if (event.type === 'participant_joined') {
|
||||
setSession(prev => prev ? {
|
||||
...prev,
|
||||
participants: event.participant
|
||||
? [...prev.participants, event.participant]
|
||||
: prev.participants,
|
||||
} : null);
|
||||
// 刷新状态
|
||||
fetchSessionStatus();
|
||||
} else if (event.type === 'status_changed' || event.type === 'all_joined') {
|
||||
setSession(prev => prev ? {
|
||||
...prev,
|
||||
status: event.status ? mapStatus(event.status) : prev.status,
|
||||
} : null);
|
||||
// 刷新状态
|
||||
fetchSessionStatus();
|
||||
} else if (event.type === 'progress') {
|
||||
setSession(prev => prev ? {
|
||||
...prev,
|
||||
status: 'processing',
|
||||
currentRound: event.round || prev.currentRound,
|
||||
totalRounds: event.totalRounds || prev.totalRounds,
|
||||
} : null);
|
||||
} else if (event.type === 'completed') {
|
||||
setSession(prev => prev ? {
|
||||
...prev,
|
||||
status: 'completed',
|
||||
signature: event.signature,
|
||||
} : null);
|
||||
} else if (event.type === 'failed' || event.type === 'sign_start_timeout') {
|
||||
setSession(prev => prev ? {
|
||||
...prev,
|
||||
status: 'failed',
|
||||
error: event.error,
|
||||
} : null);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [sessionId, fetchSessionStatus]);
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
switch (status) {
|
||||
case 'waiting': return '等待参与方';
|
||||
case 'ready': return '准备就绪';
|
||||
case 'processing': return '签名进行中';
|
||||
case 'completed': return '签名完成';
|
||||
case 'failed': return '签名失败';
|
||||
default: return status;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusClass = (status: string) => {
|
||||
switch (status) {
|
||||
case 'waiting': return styles.statusWaiting;
|
||||
case 'ready': return styles.statusReady;
|
||||
case 'processing': return styles.statusProcessing;
|
||||
case 'completed': return styles.statusCompleted;
|
||||
case 'failed': return styles.statusFailed;
|
||||
default: return '';
|
||||
}
|
||||
};
|
||||
|
||||
const getParticipantStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'waiting': return '...';
|
||||
case 'ready': return 'OK';
|
||||
case 'processing': return '*';
|
||||
case 'completed': return 'OK';
|
||||
case 'failed': return 'X';
|
||||
default: return '-';
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopySignature = async () => {
|
||||
if (session?.signature) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(session.signature);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 解析签名
|
||||
const parseSignature = (signatureHex: string): { r: string; s: string; v: number } | null => {
|
||||
try {
|
||||
// 签名格式: r (32 bytes) + s (32 bytes) + v (1 byte) = 65 bytes
|
||||
const sig = signatureHex.startsWith('0x') ? signatureHex.slice(2) : signatureHex;
|
||||
if (sig.length !== 130) {
|
||||
console.error('Invalid signature length:', sig.length);
|
||||
return null;
|
||||
}
|
||||
const r = sig.slice(0, 64);
|
||||
const s = sig.slice(64, 128);
|
||||
const v = parseInt(sig.slice(128, 130), 16);
|
||||
// EIP-1559 recovery id is 0 or 1
|
||||
const recoveryId = v >= 27 ? v - 27 : v;
|
||||
return { r, s, v: recoveryId };
|
||||
} catch (err) {
|
||||
console.error('Failed to parse signature:', err);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// 广播交易
|
||||
const handleBroadcastTransaction = async () => {
|
||||
if (!session?.signature || !txInfo) return;
|
||||
|
||||
setBroadcastStep('broadcasting');
|
||||
setBroadcastError(null);
|
||||
|
||||
try {
|
||||
// 解析签名
|
||||
const parsedSig = parseSignature(session.signature);
|
||||
if (!parsedSig) {
|
||||
throw new Error('无法解析签名');
|
||||
}
|
||||
|
||||
// 构建最终交易
|
||||
const signedTx = finalizeTransaction(txInfo.preparedTx, parsedSig);
|
||||
|
||||
// 广播交易
|
||||
const hash = await broadcastTransaction(signedTx);
|
||||
setTxHash(hash);
|
||||
setBroadcastStep('success');
|
||||
|
||||
// 清除 sessionStorage 中的交易信息
|
||||
sessionStorage.removeItem(`tx_${sessionId}`);
|
||||
} catch (err) {
|
||||
setBroadcastError((err as Error).message);
|
||||
setBroadcastStep('error');
|
||||
}
|
||||
};
|
||||
|
||||
// 获取区块浏览器交易 URL
|
||||
const getTxExplorerUrl = (hash: string): string => {
|
||||
// 从 transaction.ts 获取当前网络
|
||||
const isTestnet = typeof window !== 'undefined' &&
|
||||
window.localStorage?.getItem('kava_network') !== 'mainnet';
|
||||
const baseUrl = isTestnet
|
||||
? 'https://testnet.kavascan.com'
|
||||
: 'https://kavascan.com';
|
||||
return `${baseUrl}/tx/${hash}`;
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.loading}>
|
||||
<div className={styles.spinner}></div>
|
||||
<p>加载签名会话信息...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !session) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.error}>
|
||||
<div className={styles.errorIcon}>!</div>
|
||||
<h3>加载失败</h3>
|
||||
<p>{error || '无法获取会话信息'}</p>
|
||||
<button className={styles.primaryButton} onClick={() => navigate('/')}>
|
||||
返回首页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.card}>
|
||||
<div className={styles.header}>
|
||||
<div>
|
||||
<h1 className={styles.title}>{session.walletName || '多方签名'}</h1>
|
||||
<p className={styles.sessionId}>会话 ID: {session.sessionId?.substring(0, 16)}...</p>
|
||||
</div>
|
||||
<span className={`${styles.status} ${getStatusClass(session.status)}`}>
|
||||
{getStatusText(session.status)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.content}>
|
||||
{/* 邀请码 - 等待状态时显示 */}
|
||||
{session.inviteCode && session.status === 'waiting' && (
|
||||
<div className={styles.section}>
|
||||
<h3 className={styles.sectionTitle}>邀请码</h3>
|
||||
|
||||
{/* QR Code */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 'var(--spacing-md)',
|
||||
}}>
|
||||
<div style={{
|
||||
padding: 'var(--spacing-md)',
|
||||
backgroundColor: 'white',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
border: '1px solid var(--border-color)',
|
||||
}}>
|
||||
<QRCodeSVG
|
||||
value={session.inviteCode}
|
||||
size={180}
|
||||
level="M"
|
||||
includeMargin={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.publicKeyWrapper}>
|
||||
<code className={styles.publicKey}>{session.inviteCode}</code>
|
||||
<button
|
||||
className={styles.copyButton}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(session.inviteCode!);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
}}
|
||||
>
|
||||
复制
|
||||
</button>
|
||||
</div>
|
||||
<p style={{ fontSize: '12px', color: 'var(--text-secondary)', marginTop: 'var(--spacing-xs)', textAlign: 'center' }}>
|
||||
扫描二维码或分享邀请码给其他参与方加入签名
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 消息哈希 */}
|
||||
{session.messageHash && (
|
||||
<div className={styles.section}>
|
||||
<h3 className={styles.sectionTitle}>待签名消息</h3>
|
||||
<code style={{
|
||||
display: 'block',
|
||||
padding: '8px 12px',
|
||||
backgroundColor: 'var(--background-color)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
fontSize: '12px',
|
||||
wordBreak: 'break-all',
|
||||
fontFamily: 'monospace',
|
||||
}}>
|
||||
{session.messageHash}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 进度部分 */}
|
||||
{session.status === 'processing' && (
|
||||
<div className={styles.progress}>
|
||||
<div className={styles.progressHeader}>
|
||||
<span>签名进度</span>
|
||||
<span>{session.currentRound} / {session.totalRounds}</span>
|
||||
</div>
|
||||
<div className={styles.progressBar}>
|
||||
<div
|
||||
className={styles.progressFill}
|
||||
style={{ width: `${(session.currentRound / session.totalRounds) * 100}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 参与方列表 */}
|
||||
<div className={styles.section}>
|
||||
<h3 className={styles.sectionTitle}>
|
||||
参与方 ({(session.participants || []).length} / {session.threshold?.t || 0})
|
||||
</h3>
|
||||
<div className={styles.participantList}>
|
||||
{(session.participants || []).map((participant, index) => (
|
||||
<div key={participant.partyId || index} className={styles.participant}>
|
||||
<div className={styles.participantInfo}>
|
||||
<span className={styles.participantIndex}>#{index + 1}</span>
|
||||
<span className={styles.participantName}>{participant.name || `参与方 ${index + 1}`}</span>
|
||||
</div>
|
||||
<span className={`${styles.participantStatus} ${getStatusClass(participant.status)}`}>
|
||||
{getParticipantStatusIcon(participant.status)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{Array.from({ length: Math.max(0, (session.threshold?.t || 0) - (session.participants || []).length) }).map((_, index) => (
|
||||
<div key={`empty-${index}`} className={`${styles.participant} ${styles.participantEmpty}`}>
|
||||
<div className={styles.participantInfo}>
|
||||
<span className={styles.participantIndex}>#{(session.participants || []).length + index + 1}</span>
|
||||
<span className={styles.participantName}>等待加入...</span>
|
||||
</div>
|
||||
<span className={styles.participantStatus}>...</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 阈值信息 */}
|
||||
{session.threshold && (
|
||||
<div className={styles.section}>
|
||||
<h3 className={styles.sectionTitle}>签名阈值</h3>
|
||||
<div className={styles.thresholdInfo}>
|
||||
<span className={styles.thresholdBadge}>
|
||||
{session.threshold.t}-of-{session.threshold.n}
|
||||
</span>
|
||||
<span className={styles.thresholdText}>
|
||||
需要 {session.threshold.t} 个参与方共同签名
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 完成状态 */}
|
||||
{session.status === 'completed' && session.signature && (
|
||||
<div className={styles.section}>
|
||||
<h3 className={styles.sectionTitle}>签名结果</h3>
|
||||
<div className={styles.publicKeyWrapper}>
|
||||
<code className={styles.publicKey}>{session.signature}</code>
|
||||
<button className={styles.copyButton} onClick={handleCopySignature}>
|
||||
复制
|
||||
</button>
|
||||
</div>
|
||||
<p className={styles.successMessage}>
|
||||
OK 签名已成功生成
|
||||
</p>
|
||||
|
||||
{/* 交易广播部分 */}
|
||||
{txInfo && (
|
||||
<div style={{ marginTop: 'var(--spacing-lg)' }}>
|
||||
<h4 style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-primary)',
|
||||
marginBottom: 'var(--spacing-sm)',
|
||||
}}>
|
||||
交易详情
|
||||
</h4>
|
||||
<div style={{
|
||||
backgroundColor: 'var(--background-color)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
padding: 'var(--spacing-md)',
|
||||
marginBottom: 'var(--spacing-md)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '8px' }}>
|
||||
<span style={{ color: 'var(--text-secondary)', fontSize: '13px' }}>收款地址</span>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '13px' }}>
|
||||
{txInfo.to.slice(0, 10)}...{txInfo.to.slice(-8)}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span style={{ color: 'var(--text-secondary)', fontSize: '13px' }}>转账金额</span>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '13px', fontWeight: 600 }}>
|
||||
{txInfo.amount} KAVA
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{broadcastStep === 'idle' && (
|
||||
<button
|
||||
className={styles.primaryButton}
|
||||
onClick={handleBroadcastTransaction}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
广播交易到区块链
|
||||
</button>
|
||||
)}
|
||||
|
||||
{broadcastStep === 'broadcasting' && (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: 'var(--spacing-md)',
|
||||
color: 'var(--text-secondary)',
|
||||
}}>
|
||||
<div className={styles.spinner} style={{ margin: '0 auto var(--spacing-sm)' }}></div>
|
||||
<p>正在广播交易...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{broadcastStep === 'success' && txHash && (
|
||||
<div style={{
|
||||
backgroundColor: 'rgba(40, 167, 69, 0.1)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
padding: 'var(--spacing-md)',
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: '24px',
|
||||
color: '#28a745',
|
||||
marginBottom: 'var(--spacing-sm)',
|
||||
}}>OK</div>
|
||||
<p style={{
|
||||
color: '#28a745',
|
||||
fontWeight: 600,
|
||||
marginBottom: 'var(--spacing-sm)',
|
||||
}}>
|
||||
交易已成功广播!
|
||||
</p>
|
||||
<div style={{
|
||||
backgroundColor: 'white',
|
||||
padding: 'var(--spacing-sm)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
marginBottom: 'var(--spacing-md)',
|
||||
}}>
|
||||
<code style={{
|
||||
fontSize: '12px',
|
||||
wordBreak: 'break-all',
|
||||
fontFamily: 'monospace',
|
||||
}}>
|
||||
{txHash}
|
||||
</code>
|
||||
</div>
|
||||
<a
|
||||
href={getTxExplorerUrl(txHash)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
padding: 'var(--spacing-sm) var(--spacing-md)',
|
||||
backgroundColor: '#28a745',
|
||||
color: 'white',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
textDecoration: 'none',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
在区块浏览器查看
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{broadcastStep === 'error' && (
|
||||
<div style={{
|
||||
backgroundColor: 'rgba(220, 53, 69, 0.1)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
padding: 'var(--spacing-md)',
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: '24px',
|
||||
color: '#dc3545',
|
||||
marginBottom: 'var(--spacing-sm)',
|
||||
}}>!</div>
|
||||
<p style={{ color: '#dc3545', marginBottom: 'var(--spacing-sm)' }}>
|
||||
广播失败: {broadcastError}
|
||||
</p>
|
||||
<button
|
||||
className={styles.secondaryButton}
|
||||
onClick={() => setBroadcastStep('idle')}
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 失败状态 */}
|
||||
{session.status === 'failed' && session.error && (
|
||||
<div className={styles.section}>
|
||||
<div className={styles.failureMessage}>
|
||||
<span className={styles.failureIcon}>!</span>
|
||||
<span>{session.error}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.footer}>
|
||||
<button className={styles.primaryButton} onClick={() => navigate('/')}>
|
||||
返回首页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -318,3 +318,28 @@
|
|||
.copyButton:hover {
|
||||
background-color: var(--primary-light);
|
||||
}
|
||||
|
||||
/* QR Code Section */
|
||||
.qrSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-lg);
|
||||
background-color: var(--background-color);
|
||||
border-radius: var(--radius-lg);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.qrCodeWrapper {
|
||||
padding: var(--spacing-md);
|
||||
background-color: white;
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.qrHint {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import styles from './Create.module.css';
|
||||
|
||||
interface CreateSessionResult {
|
||||
|
|
@ -220,6 +221,21 @@ export default function Create() {
|
|||
<div className={styles.successIcon}>✓</div>
|
||||
<h3 className={styles.successTitle}>会话创建成功</h3>
|
||||
|
||||
{/* QR Code for mobile scanning */}
|
||||
<div className={styles.qrSection}>
|
||||
<div className={styles.qrCodeWrapper}>
|
||||
<QRCodeSVG
|
||||
value={result.inviteCode || ''}
|
||||
size={180}
|
||||
level="M"
|
||||
includeMargin={true}
|
||||
bgColor="#ffffff"
|
||||
fgColor="#000000"
|
||||
/>
|
||||
</div>
|
||||
<p className={styles.qrHint}>使用手机 App 扫码加入</p>
|
||||
</div>
|
||||
|
||||
<div className={styles.inviteSection}>
|
||||
<label className={styles.label}>邀请码</label>
|
||||
<div className={styles.inviteCodeWrapper}>
|
||||
|
|
|
|||
|
|
@ -377,14 +377,20 @@
|
|||
/* Balance Section */
|
||||
.balanceSection {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-md);
|
||||
margin-bottom: var(--spacing-md);
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.balanceRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.balanceLabel {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
|
|
@ -404,3 +410,241 @@
|
|||
color: rgba(255, 255, 255, 0.7);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Transfer Button */
|
||||
.transferButton {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
border-color: var(--primary-color);
|
||||
margin-right: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.transferButton:hover {
|
||||
background-color: var(--primary-light);
|
||||
border-color: var(--primary-light);
|
||||
}
|
||||
|
||||
/* Transfer Modal */
|
||||
.transferModal {
|
||||
background-color: var(--surface-color);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
max-width: 480px;
|
||||
width: 90%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.transferWalletInfo {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: var(--spacing-md);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.transferWalletName {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.transferWalletBalance {
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.transferNetwork {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
margin-top: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.transferForm {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.transferInputGroup {
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.transferLabel {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.transferInput {
|
||||
width: 100%;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 14px;
|
||||
font-family: monospace;
|
||||
background-color: var(--background-color);
|
||||
color: var(--text-primary);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.transferInput:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.transferInput::placeholder {
|
||||
color: var(--text-secondary);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.transferAmountWrapper {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.transferAmountWrapper .transferInput {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.maxButton {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background-color: transparent;
|
||||
color: var(--primary-color);
|
||||
border: 1px solid var(--primary-color);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.maxButton:hover {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Token Type Selector */
|
||||
.tokenTypeSelector {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.tokenTypeButton {
|
||||
flex: 1;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background-color: transparent;
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.tokenTypeButton:hover {
|
||||
border-color: var(--primary-color);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.tokenTypeActive {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.tokenTypeActive:hover {
|
||||
background-color: var(--primary-light);
|
||||
border-color: var(--primary-light);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.transferError {
|
||||
background-color: rgba(220, 53, 69, 0.1);
|
||||
color: #dc3545;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 13px;
|
||||
margin-bottom: var(--spacing-md);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.transferActions {
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
margin-top: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.transferActions .primaryButton,
|
||||
.transferActions .secondaryButton {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.transferPreparing {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--spacing-xl);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Transfer Confirm */
|
||||
.transferConfirm {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.confirmTitle {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--spacing-md);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.confirmDetails {
|
||||
background-color: var(--background-color);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--spacing-md);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.confirmRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--spacing-sm) 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.confirmRow:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.confirmLabel {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.confirmValue {
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
font-family: monospace;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.confirmNote {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
background-color: rgba(102, 126, 234, 0.1);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: var(--spacing-md);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.confirmNote strong {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,18 @@ import { useNavigate } from 'react-router-dom';
|
|||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import styles from './Home.module.css';
|
||||
import { deriveEvmAddress, formatAddress, getKavaExplorerUrl } from '../utils/address';
|
||||
import {
|
||||
prepareTransaction,
|
||||
isValidAddress,
|
||||
isValidAmount,
|
||||
getCurrentNetwork,
|
||||
getCurrentRpcUrl,
|
||||
getGasPrice,
|
||||
fetchGreenPointsBalance,
|
||||
GREEN_POINTS_TOKEN,
|
||||
type PreparedTransaction,
|
||||
type TokenType,
|
||||
} from '../utils/transaction';
|
||||
|
||||
interface ShareItem {
|
||||
id: string;
|
||||
|
|
@ -19,12 +31,10 @@ interface ShareItem {
|
|||
interface ShareWithAddress extends ShareItem {
|
||||
evmAddress?: string;
|
||||
kavaBalance?: string;
|
||||
greenPointsBalance?: string;
|
||||
balanceLoading?: boolean;
|
||||
}
|
||||
|
||||
// Kava Testnet EVM RPC endpoint
|
||||
const KAVA_TESTNET_RPC = 'https://evm.testnet.kava.io';
|
||||
|
||||
/**
|
||||
* 获取 KAVA 代币余额
|
||||
* @param address EVM 地址
|
||||
|
|
@ -32,7 +42,8 @@ const KAVA_TESTNET_RPC = 'https://evm.testnet.kava.io';
|
|||
*/
|
||||
async function fetchKavaBalance(address: string): Promise<string> {
|
||||
try {
|
||||
const response = await fetch(KAVA_TESTNET_RPC, {
|
||||
const rpcUrl = getCurrentRpcUrl();
|
||||
const response = await fetch(rpcUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
|
|
@ -65,6 +76,76 @@ export default function Home() {
|
|||
const [selectedShare, setSelectedShare] = useState<ShareWithAddress | null>(null);
|
||||
const [showQrModal, setShowQrModal] = useState(false);
|
||||
|
||||
// 转账相关状态
|
||||
const [showTransferModal, setShowTransferModal] = useState(false);
|
||||
const [transferShare, setTransferShare] = useState<ShareWithAddress | null>(null);
|
||||
const [transferTo, setTransferTo] = useState('');
|
||||
const [transferAmount, setTransferAmount] = useState('');
|
||||
const [transferPassword, setTransferPassword] = useState('');
|
||||
const [transferTokenType, setTransferTokenType] = useState<TokenType>('KAVA');
|
||||
const [transferStep, setTransferStep] = useState<'input' | 'confirm' | 'preparing' | 'error'>('input');
|
||||
const [transferError, setTransferError] = useState<string | null>(null);
|
||||
const [preparedTx, setPreparedTx] = useState<PreparedTransaction | null>(null);
|
||||
const [isCalculatingMax, setIsCalculatingMax] = useState(false);
|
||||
const [copySuccess, setCopySuccess] = useState(false);
|
||||
|
||||
// 计算扣除 Gas 费后的最大可转账金额
|
||||
const calculateMaxAmount = async () => {
|
||||
if (!transferShare?.evmAddress) return;
|
||||
|
||||
setIsCalculatingMax(true);
|
||||
try {
|
||||
if (transferTokenType === 'GREEN_POINTS') {
|
||||
// For token transfers, use the full token balance (gas is paid in KAVA)
|
||||
const balance = transferShare.greenPointsBalance || '0';
|
||||
setTransferAmount(balance);
|
||||
setTransferError(null);
|
||||
} else {
|
||||
// For KAVA transfers, deduct gas fee
|
||||
const balance = parseFloat(transferShare.kavaBalance || '0');
|
||||
if (balance <= 0) {
|
||||
setTransferAmount('0');
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取当前 gas 价格
|
||||
const { maxFeePerGas } = await getGasPrice();
|
||||
// 简单转账的 gas 限制是 21000
|
||||
const gasLimit = BigInt(21000);
|
||||
const gasFee = maxFeePerGas * gasLimit;
|
||||
// 转换为 KAVA (18 位小数)
|
||||
const gasFeeKava = Number(gasFee) / 1e18;
|
||||
|
||||
// 计算最大可转账金额 = 余额 - Gas费
|
||||
const maxAmount = balance - gasFeeKava;
|
||||
|
||||
if (maxAmount <= 0) {
|
||||
setTransferError('余额不足以支付 Gas 费');
|
||||
setTransferAmount('0');
|
||||
} else {
|
||||
// 保留 6 位小数,向下取整避免精度问题
|
||||
const formattedMax = Math.floor(maxAmount * 1000000) / 1000000;
|
||||
setTransferAmount(formattedMax.toString());
|
||||
setTransferError(null);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to calculate max amount:', error);
|
||||
if (transferTokenType === 'GREEN_POINTS') {
|
||||
setTransferAmount(transferShare.greenPointsBalance || '0');
|
||||
} else {
|
||||
// 如果获取 Gas 失败,使用默认估算 (1 gwei * 21000)
|
||||
const defaultGasFee = 0.000021; // ~21000 * 1 gwei
|
||||
const balance = parseFloat(transferShare.kavaBalance || '0');
|
||||
const maxAmount = Math.max(0, balance - defaultGasFee);
|
||||
const formattedMax = Math.floor(maxAmount * 1000000) / 1000000;
|
||||
setTransferAmount(formattedMax.toString());
|
||||
}
|
||||
} finally {
|
||||
setIsCalculatingMax(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deriveAddresses = useCallback(async (shareList: ShareItem[]): Promise<ShareWithAddress[]> => {
|
||||
const sharesWithAddresses: ShareWithAddress[] = [];
|
||||
for (const share of shareList) {
|
||||
|
|
@ -79,13 +160,17 @@ export default function Home() {
|
|||
return sharesWithAddresses;
|
||||
}, []);
|
||||
|
||||
// 单独获取所有钱包的余额
|
||||
// 单独获取所有钱包的余额 (KAVA 和 绿积分)
|
||||
const fetchAllBalances = useCallback(async (sharesWithAddrs: ShareWithAddress[]) => {
|
||||
const updatedShares = await Promise.all(
|
||||
sharesWithAddrs.map(async (share) => {
|
||||
if (share.evmAddress) {
|
||||
const kavaBalance = await fetchKavaBalance(share.evmAddress);
|
||||
return { ...share, kavaBalance, balanceLoading: false };
|
||||
// Fetch both balances in parallel
|
||||
const [kavaBalance, greenPointsBalance] = await Promise.all([
|
||||
fetchKavaBalance(share.evmAddress),
|
||||
fetchGreenPointsBalance(share.evmAddress),
|
||||
]);
|
||||
return { ...share, kavaBalance, greenPointsBalance, balanceLoading: false };
|
||||
}
|
||||
return { ...share, balanceLoading: false };
|
||||
})
|
||||
|
|
@ -185,9 +270,137 @@ export default function Home() {
|
|||
setShowQrModal(true);
|
||||
};
|
||||
|
||||
const handleCopyAddress = (address: string) => {
|
||||
navigator.clipboard.writeText(address);
|
||||
alert('地址已复制到剪贴板');
|
||||
const handleCopyAddress = async (address: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(address);
|
||||
setCopySuccess(true);
|
||||
setTimeout(() => setCopySuccess(false), 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy address:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// 打开转账模态框
|
||||
const handleOpenTransfer = (share: ShareWithAddress) => {
|
||||
setTransferShare(share);
|
||||
setTransferTo('');
|
||||
setTransferAmount('');
|
||||
setTransferPassword('');
|
||||
setTransferTokenType('KAVA');
|
||||
setTransferStep('input');
|
||||
setTransferError(null);
|
||||
setPreparedTx(null);
|
||||
setShowTransferModal(true);
|
||||
};
|
||||
|
||||
// 关闭转账模态框
|
||||
const handleCloseTransfer = () => {
|
||||
setShowTransferModal(false);
|
||||
setTransferShare(null);
|
||||
setPreparedTx(null);
|
||||
};
|
||||
|
||||
// 验证转账输入
|
||||
const validateTransferInput = (): string | null => {
|
||||
if (!transferTo.trim()) {
|
||||
return '请输入收款地址';
|
||||
}
|
||||
if (!isValidAddress(transferTo.trim())) {
|
||||
return '收款地址格式无效';
|
||||
}
|
||||
if (!transferAmount.trim()) {
|
||||
return '请输入转账金额';
|
||||
}
|
||||
if (!isValidAmount(transferAmount.trim())) {
|
||||
return '转账金额无效';
|
||||
}
|
||||
const amount = parseFloat(transferAmount);
|
||||
const balance = parseFloat(
|
||||
transferTokenType === 'GREEN_POINTS'
|
||||
? (transferShare?.greenPointsBalance || '0')
|
||||
: (transferShare?.kavaBalance || '0')
|
||||
);
|
||||
if (amount > balance) {
|
||||
return '余额不足';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// 准备交易
|
||||
const handlePrepareTransaction = async () => {
|
||||
const error = validateTransferInput();
|
||||
if (error) {
|
||||
setTransferError(error);
|
||||
return;
|
||||
}
|
||||
|
||||
setTransferStep('preparing');
|
||||
setTransferError(null);
|
||||
|
||||
try {
|
||||
const prepared = await prepareTransaction({
|
||||
from: transferShare!.evmAddress!,
|
||||
to: transferTo.trim().toLowerCase(),
|
||||
value: transferAmount.trim(),
|
||||
tokenType: transferTokenType,
|
||||
});
|
||||
setPreparedTx(prepared);
|
||||
setTransferStep('confirm');
|
||||
} catch (err) {
|
||||
setTransferError('准备交易失败: ' + (err as Error).message);
|
||||
setTransferStep('error');
|
||||
}
|
||||
};
|
||||
|
||||
// 发起签名会话
|
||||
const handleInitiateCoSign = async () => {
|
||||
if (!preparedTx || !transferShare) return;
|
||||
|
||||
try {
|
||||
// 调用 co-sign API 创建签名会话
|
||||
const result = await window.electronAPI.cosign.createSession({
|
||||
shareId: transferShare.id,
|
||||
sharePassword: transferPassword,
|
||||
messageHash: preparedTx.signHash,
|
||||
initiatorName: '发起者',
|
||||
});
|
||||
|
||||
if (result.success && result.sessionId) {
|
||||
// 保存交易信息到 sessionStorage,以便签名完成后使用
|
||||
// 注意: BigInt 无法直接 JSON 序列化,需要转换为字符串
|
||||
const txToStore = {
|
||||
preparedTx: {
|
||||
...preparedTx,
|
||||
gasLimit: preparedTx.gasLimit.toString(),
|
||||
gasPrice: preparedTx.gasPrice.toString(),
|
||||
value: preparedTx.value.toString(),
|
||||
},
|
||||
to: transferTo,
|
||||
amount: transferAmount,
|
||||
from: transferShare.evmAddress,
|
||||
walletName: transferShare.walletName,
|
||||
tokenType: transferTokenType,
|
||||
};
|
||||
sessionStorage.setItem(`tx_${result.sessionId}`, JSON.stringify(txToStore));
|
||||
|
||||
// 关闭模态框并跳转到签名会话页面
|
||||
handleCloseTransfer();
|
||||
navigate(`/cosign/session/${result.sessionId}`);
|
||||
} else {
|
||||
setTransferError(result.error || '创建签名会话失败');
|
||||
setTransferStep('error');
|
||||
}
|
||||
} catch (err) {
|
||||
setTransferError('创建签名会话失败: ' + (err as Error).message);
|
||||
setTransferStep('error');
|
||||
}
|
||||
};
|
||||
|
||||
// 格式化 gas 费用显示
|
||||
const formatGasFee = (gasLimit: bigint, gasPrice: bigint): string => {
|
||||
const maxFee = gasLimit * gasPrice;
|
||||
const feeKava = Number(maxFee) / 1e18;
|
||||
return feeKava.toFixed(8).replace(/\.?0+$/, '');
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
|
|
@ -273,17 +486,29 @@ export default function Home() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* KAVA 余额显示 */}
|
||||
{/* 余额显示 - KAVA 和 绿积分 */}
|
||||
{share.evmAddress && (
|
||||
<div className={styles.balanceSection}>
|
||||
<span className={styles.balanceLabel}>KAVA 余额</span>
|
||||
<span className={styles.balanceValue}>
|
||||
{share.balanceLoading ? (
|
||||
<span className={styles.balanceLoading}>加载中...</span>
|
||||
) : (
|
||||
<>{share.kavaBalance || '0'} KAVA</>
|
||||
)}
|
||||
</span>
|
||||
<div className={styles.balanceRow}>
|
||||
<span className={styles.balanceLabel}>KAVA</span>
|
||||
<span className={styles.balanceValue}>
|
||||
{share.balanceLoading ? (
|
||||
<span className={styles.balanceLoading}>加载中...</span>
|
||||
) : (
|
||||
<>{share.kavaBalance || '0'}</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.balanceRow}>
|
||||
<span className={styles.balanceLabel} style={{ color: '#4CAF50' }}>{GREEN_POINTS_TOKEN.name}</span>
|
||||
<span className={styles.balanceValue} style={{ color: '#4CAF50' }}>
|
||||
{share.balanceLoading ? (
|
||||
<span className={styles.balanceLoading}>加载中...</span>
|
||||
) : (
|
||||
<>{share.greenPointsBalance || '0'}</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -309,6 +534,14 @@ export default function Home() {
|
|||
)}
|
||||
</div>
|
||||
<div className={styles.cardFooter}>
|
||||
{share.evmAddress && (
|
||||
<button
|
||||
className={`${styles.actionButton} ${styles.transferButton}`}
|
||||
onClick={() => handleOpenTransfer(share)}
|
||||
>
|
||||
转账
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className={styles.actionButton}
|
||||
onClick={() => handleExport(share.id)}
|
||||
|
|
@ -327,6 +560,203 @@ export default function Home() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 转账模态框 */}
|
||||
{showTransferModal && transferShare && (
|
||||
<div className={styles.modalOverlay} onClick={handleCloseTransfer}>
|
||||
<div className={styles.transferModal} onClick={(e) => e.stopPropagation()}>
|
||||
<div className={styles.modalHeader}>
|
||||
<h2 className={styles.modalTitle}>转账</h2>
|
||||
<button
|
||||
className={styles.modalClose}
|
||||
onClick={handleCloseTransfer}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.modalBody}>
|
||||
{/* 钱包信息 */}
|
||||
<div className={styles.transferWalletInfo}>
|
||||
<div className={styles.transferWalletName}>{transferShare.walletName}</div>
|
||||
<div className={styles.transferWalletBalance}>
|
||||
KAVA: {transferShare.kavaBalance || '0'} | {GREEN_POINTS_TOKEN.name}: {transferShare.greenPointsBalance || '0'}
|
||||
</div>
|
||||
<div className={styles.transferNetwork}>
|
||||
网络: Kava {getCurrentNetwork() === 'mainnet' ? '主网' : '测试网'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{transferStep === 'input' && (
|
||||
<div className={styles.transferForm}>
|
||||
{/* 代币类型选择 */}
|
||||
<div className={styles.transferInputGroup}>
|
||||
<label className={styles.transferLabel}>转账类型</label>
|
||||
<div className={styles.tokenTypeSelector}>
|
||||
<button
|
||||
className={`${styles.tokenTypeButton} ${transferTokenType === 'KAVA' ? styles.tokenTypeActive : ''}`}
|
||||
onClick={() => { setTransferTokenType('KAVA'); setTransferAmount(''); }}
|
||||
>
|
||||
KAVA
|
||||
</button>
|
||||
<button
|
||||
className={`${styles.tokenTypeButton} ${transferTokenType === 'GREEN_POINTS' ? styles.tokenTypeActive : ''}`}
|
||||
onClick={() => { setTransferTokenType('GREEN_POINTS'); setTransferAmount(''); }}
|
||||
style={transferTokenType === 'GREEN_POINTS' ? { backgroundColor: '#4CAF50', borderColor: '#4CAF50' } : {}}
|
||||
>
|
||||
{GREEN_POINTS_TOKEN.name}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 收款地址 */}
|
||||
<div className={styles.transferInputGroup}>
|
||||
<label className={styles.transferLabel}>收款地址</label>
|
||||
<input
|
||||
type="text"
|
||||
value={transferTo}
|
||||
onChange={(e) => setTransferTo(e.target.value)}
|
||||
placeholder="0x..."
|
||||
className={styles.transferInput}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 转账金额 */}
|
||||
<div className={styles.transferInputGroup}>
|
||||
<label className={styles.transferLabel}>
|
||||
转账金额 ({transferTokenType === 'GREEN_POINTS' ? GREEN_POINTS_TOKEN.name : 'KAVA'})
|
||||
</label>
|
||||
<div className={styles.transferAmountWrapper}>
|
||||
<input
|
||||
type="text"
|
||||
value={transferAmount}
|
||||
onChange={(e) => setTransferAmount(e.target.value)}
|
||||
placeholder="0.0"
|
||||
className={styles.transferInput}
|
||||
/>
|
||||
<button
|
||||
className={styles.maxButton}
|
||||
onClick={calculateMaxAmount}
|
||||
disabled={isCalculatingMax}
|
||||
>
|
||||
{isCalculatingMax ? '...' : 'MAX'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 钱包密码 */}
|
||||
<div className={styles.transferInputGroup}>
|
||||
<label className={styles.transferLabel}>钱包密码 (可选)</label>
|
||||
<input
|
||||
type="password"
|
||||
value={transferPassword}
|
||||
onChange={(e) => setTransferPassword(e.target.value)}
|
||||
placeholder="如果设置了密码,请输入"
|
||||
className={styles.transferInput}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{transferError && (
|
||||
<div className={styles.transferError}>{transferError}</div>
|
||||
)}
|
||||
|
||||
<div className={styles.transferActions}>
|
||||
<button
|
||||
className={styles.secondaryButton}
|
||||
onClick={handleCloseTransfer}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
className={styles.primaryButton}
|
||||
onClick={handlePrepareTransaction}
|
||||
>
|
||||
下一步
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{transferStep === 'preparing' && (
|
||||
<div className={styles.transferPreparing}>
|
||||
<div className={styles.spinner}></div>
|
||||
<p>正在准备交易...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{transferStep === 'confirm' && preparedTx && (
|
||||
<div className={styles.transferConfirm}>
|
||||
<h3 className={styles.confirmTitle}>确认交易</h3>
|
||||
|
||||
<div className={styles.confirmDetails}>
|
||||
<div className={styles.confirmRow}>
|
||||
<span className={styles.confirmLabel}>转账类型</span>
|
||||
<span className={styles.confirmValue} style={transferTokenType === 'GREEN_POINTS' ? { color: '#4CAF50' } : {}}>
|
||||
{transferTokenType === 'GREEN_POINTS' ? GREEN_POINTS_TOKEN.name : 'KAVA'}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.confirmRow}>
|
||||
<span className={styles.confirmLabel}>收款地址</span>
|
||||
<span className={styles.confirmValue}>{formatAddress(transferTo, 8, 6)}</span>
|
||||
</div>
|
||||
<div className={styles.confirmRow}>
|
||||
<span className={styles.confirmLabel}>转账金额</span>
|
||||
<span className={styles.confirmValue} style={transferTokenType === 'GREEN_POINTS' ? { color: '#4CAF50' } : {}}>
|
||||
{transferAmount} {transferTokenType === 'GREEN_POINTS' ? GREEN_POINTS_TOKEN.name : 'KAVA'}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.confirmRow}>
|
||||
<span className={styles.confirmLabel}>预估 Gas 费</span>
|
||||
<span className={styles.confirmValue}>
|
||||
~{formatGasFee(preparedTx.gasLimit, preparedTx.gasPrice)} KAVA
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.confirmRow}>
|
||||
<span className={styles.confirmLabel}>Nonce</span>
|
||||
<span className={styles.confirmValue}>{preparedTx.nonce}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.confirmNote}>
|
||||
<strong>注意:</strong> 这是一个 {transferShare.threshold.t}-of-{transferShare.threshold.n} 共管钱包,
|
||||
需要至少 {transferShare.threshold.t} 方参与签名才能完成交易。
|
||||
</div>
|
||||
|
||||
{transferError && (
|
||||
<div className={styles.transferError}>{transferError}</div>
|
||||
)}
|
||||
|
||||
<div className={styles.transferActions}>
|
||||
<button
|
||||
className={styles.secondaryButton}
|
||||
onClick={() => setTransferStep('input')}
|
||||
>
|
||||
返回
|
||||
</button>
|
||||
<button
|
||||
className={styles.primaryButton}
|
||||
onClick={handleInitiateCoSign}
|
||||
>
|
||||
发起多方签名
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{transferStep === 'error' && (
|
||||
<div className={styles.transferError}>
|
||||
<p>{transferError}</p>
|
||||
<button
|
||||
className={styles.secondaryButton}
|
||||
onClick={() => setTransferStep('input')}
|
||||
>
|
||||
返回重试
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 二维码弹窗 */}
|
||||
{showQrModal && selectedShare && (
|
||||
<div className={styles.modalOverlay} onClick={() => setShowQrModal(false)}>
|
||||
|
|
@ -360,10 +790,10 @@ export default function Home() {
|
|||
className={styles.primaryButton}
|
||||
onClick={() => handleCopyAddress(selectedShare.evmAddress || '')}
|
||||
>
|
||||
复制地址
|
||||
{copySuccess ? '✓ 已复制' : '复制地址'}
|
||||
</button>
|
||||
<a
|
||||
href={getKavaExplorerUrl(selectedShare.evmAddress || '', true)}
|
||||
href={getKavaExplorerUrl(selectedShare.evmAddress || '', getCurrentNetwork() === 'testnet')}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.secondaryButton}
|
||||
|
|
|
|||
|
|
@ -70,6 +70,10 @@ export default function Session() {
|
|||
status: 'completed',
|
||||
publicKey: event.publicKey,
|
||||
} : null);
|
||||
// Auto-navigate to home after a short delay to show completion status
|
||||
setTimeout(() => {
|
||||
navigate('/');
|
||||
}, 2000);
|
||||
} else if (event.type === 'failed') {
|
||||
setSession(prev => prev ? {
|
||||
...prev,
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ export default function Settings() {
|
|||
autoBackup: false,
|
||||
backupPath: '',
|
||||
});
|
||||
const [kavaNetwork, setKavaNetwork] = useState<'mainnet' | 'testnet'>('testnet');
|
||||
const [kavaNetwork, setKavaNetwork] = useState<'mainnet' | 'testnet'>('mainnet');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
|
|
@ -185,6 +185,10 @@ export default function Settings() {
|
|||
const result = await window.electronAPI.kava.switchNetwork('testnet');
|
||||
if (result.success) {
|
||||
setKavaNetwork('testnet');
|
||||
// 同步到 localStorage 供前端工具函数使用
|
||||
localStorage.setItem('kava_network', 'testnet');
|
||||
// 触发自定义事件通知 Layout 更新网络状态显示
|
||||
window.dispatchEvent(new CustomEvent('kava-network-change', { detail: { network: 'testnet' } }));
|
||||
setMessage({ type: 'success', text: '已切换到 Kava 测试网' });
|
||||
}
|
||||
}}
|
||||
|
|
@ -197,6 +201,10 @@ export default function Settings() {
|
|||
const result = await window.electronAPI.kava.switchNetwork('mainnet');
|
||||
if (result.success) {
|
||||
setKavaNetwork('mainnet');
|
||||
// 同步到 localStorage 供前端工具函数使用
|
||||
localStorage.setItem('kava_network', 'mainnet');
|
||||
// 触发自定义事件通知 Layout 更新网络状态显示
|
||||
window.dispatchEvent(new CustomEvent('kava-network-change', { detail: { network: 'mainnet' } }));
|
||||
setMessage({ type: 'success', text: '已切换到 Kava 主网' });
|
||||
}
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -115,10 +115,7 @@ export default function Sign() {
|
|||
setError('请选择要使用的钱包份额');
|
||||
return;
|
||||
}
|
||||
if (!password) {
|
||||
setError('请输入解锁密码');
|
||||
return;
|
||||
}
|
||||
// 密码是可选的,如果创建时没有设置密码,这里也不需要输入
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
|
@ -351,7 +348,7 @@ export default function Sign() {
|
|||
<button
|
||||
className={styles.primaryButton}
|
||||
onClick={handleJoinSigning}
|
||||
disabled={isLoading || !password}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? '加入中...' : '参与签名'}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -410,6 +410,94 @@ interface KavaHealthCheckResult {
|
|||
// Electron API 接口
|
||||
// ===========================================================================
|
||||
|
||||
// ===========================================================================
|
||||
// Co-Sign 相关类型
|
||||
// ===========================================================================
|
||||
|
||||
interface CoSignSessionInfo {
|
||||
sessionId: string;
|
||||
keygenSessionId: string;
|
||||
walletName: string;
|
||||
messageHash: string;
|
||||
threshold: { t: number; n: number };
|
||||
status: string;
|
||||
currentParticipants: number;
|
||||
parties?: Array<{ party_id: string; party_index: number }>;
|
||||
}
|
||||
|
||||
interface CreateCoSignSessionParams {
|
||||
shareId: string;
|
||||
sharePassword: string;
|
||||
messageHash: string;
|
||||
initiatorName?: string;
|
||||
}
|
||||
|
||||
interface CreateCoSignSessionResult {
|
||||
success: boolean;
|
||||
sessionId?: string;
|
||||
inviteCode?: string;
|
||||
walletName?: string;
|
||||
expiresAt?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface ValidateCoSignInviteCodeResult {
|
||||
success: boolean;
|
||||
sessionInfo?: CoSignSessionInfo;
|
||||
joinToken?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface JoinCoSignSessionParams {
|
||||
sessionId: string;
|
||||
shareId: string;
|
||||
sharePassword: string;
|
||||
joinToken: string;
|
||||
walletName?: string;
|
||||
messageHash: string;
|
||||
threshold: { t: number; n: number };
|
||||
parties?: Array<{ party_id: string; party_index: number }>;
|
||||
}
|
||||
|
||||
interface JoinCoSignSessionResult {
|
||||
success: boolean;
|
||||
data?: unknown;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface GetCoSignSessionStatusResult {
|
||||
success: boolean;
|
||||
session?: {
|
||||
sessionId: string;
|
||||
status: string;
|
||||
joinedCount: number;
|
||||
threshold: { t: number; n: number };
|
||||
participants: Array<{
|
||||
partyId: string;
|
||||
partyIndex: number;
|
||||
name: string;
|
||||
status: string;
|
||||
}>;
|
||||
messageHash: string;
|
||||
walletName: string;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface CoSignSessionEvent {
|
||||
type: 'participant_joined' | 'status_changed' | 'all_joined' | 'progress' | 'completed' | 'failed' | 'sign_start_timeout';
|
||||
participant?: {
|
||||
partyId: string;
|
||||
name: string;
|
||||
status: string;
|
||||
};
|
||||
status?: string;
|
||||
round?: number;
|
||||
totalRounds?: number;
|
||||
signature?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface ElectronAPI {
|
||||
// Account 服务相关 (HTTP API)
|
||||
account: {
|
||||
|
|
@ -521,6 +609,15 @@ interface ElectronAPI {
|
|||
unsubscribeLogs: () => void;
|
||||
log: (level: string, source: string, message: string) => void;
|
||||
};
|
||||
|
||||
// Co-Sign 相关 (多方协作签名)
|
||||
cosign: {
|
||||
createSession: (params: CreateCoSignSessionParams) => Promise<CreateCoSignSessionResult>;
|
||||
validateInviteCode: (code: string) => Promise<ValidateCoSignInviteCodeResult>;
|
||||
joinSession: (params: JoinCoSignSessionParams) => Promise<JoinCoSignSessionResult>;
|
||||
getSessionStatus: (sessionId: string) => Promise<GetCoSignSessionStatusResult>;
|
||||
subscribeSessionEvents: (sessionId: string, callback: (event: CoSignSessionEvent) => void) => () => void;
|
||||
};
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,685 @@
|
|||
/**
|
||||
* Kava EVM 交易构建工具
|
||||
* 用于构建 EIP-1559 交易并计算签名哈希
|
||||
* 支持原生 KAVA 和 ERC-20 代币 (绿积分) 转账
|
||||
*/
|
||||
|
||||
// Kava EVM Chain IDs
|
||||
export const KAVA_CHAIN_ID = {
|
||||
mainnet: 2222,
|
||||
testnet: 2221,
|
||||
};
|
||||
|
||||
// RPC URLs
|
||||
export const KAVA_RPC_URL = {
|
||||
mainnet: 'https://evm.kava.io',
|
||||
testnet: 'https://evm.testnet.kava.io',
|
||||
};
|
||||
|
||||
// Token types
|
||||
export type TokenType = 'KAVA' | 'GREEN_POINTS';
|
||||
|
||||
// Green Points (绿积分) Token Configuration
|
||||
export const GREEN_POINTS_TOKEN = {
|
||||
contractAddress: '0xA9F3A35dBa8699c8C681D8db03F0c1A8CEB9D7c3',
|
||||
name: '绿积分',
|
||||
symbol: 'dUSDT',
|
||||
decimals: 6,
|
||||
// ERC-20 function selectors
|
||||
balanceOfSelector: '0x70a08231',
|
||||
transferSelector: '0xa9059cbb',
|
||||
};
|
||||
|
||||
// 当前网络配置 (从 localStorage 读取或使用默认值)
|
||||
export function getCurrentNetwork(): 'mainnet' | 'testnet' {
|
||||
if (typeof window !== 'undefined' && window.localStorage) {
|
||||
const stored = localStorage.getItem('kava_network');
|
||||
if (stored === 'mainnet' || stored === 'testnet') {
|
||||
return stored;
|
||||
}
|
||||
}
|
||||
return 'mainnet'; // 默认主网
|
||||
}
|
||||
|
||||
export function getCurrentChainId(): number {
|
||||
return KAVA_CHAIN_ID[getCurrentNetwork()];
|
||||
}
|
||||
|
||||
export function getCurrentRpcUrl(): string {
|
||||
return KAVA_RPC_URL[getCurrentNetwork()];
|
||||
}
|
||||
|
||||
/**
|
||||
* 交易参数接口
|
||||
*/
|
||||
export interface TransactionParams {
|
||||
to: string; // 收款地址
|
||||
value: string; // 转账金额 (KAVA 或代币单位, 字符串以支持大数)
|
||||
from: string; // 发送地址
|
||||
nonce?: number; // 可选,自动获取
|
||||
gasLimit?: bigint; // 可选,默认 21000
|
||||
maxFeePerGas?: bigint; // 可选,自动获取
|
||||
maxPriorityFeePerGas?: bigint; // 可选,自动获取
|
||||
data?: string; // 可选,合约调用数据
|
||||
tokenType?: TokenType; // 可选,默认 KAVA
|
||||
}
|
||||
|
||||
/**
|
||||
* 准备好的交易 (包含所有必要字段)
|
||||
* 使用 Legacy 交易格式 (Type 0),因为 KAVA 不支持 EIP-1559
|
||||
*/
|
||||
export interface PreparedTransaction {
|
||||
chainId: number;
|
||||
nonce: number;
|
||||
gasPrice: bigint; // Legacy 使用 gasPrice 而不是 maxFeePerGas
|
||||
gasLimit: bigint;
|
||||
to: string;
|
||||
value: bigint;
|
||||
data: string;
|
||||
// 用于签名的哈希
|
||||
signHash: string;
|
||||
// 原始交易数据 (用于签名后广播)
|
||||
rawTxForSigning: string;
|
||||
// 为了兼容性保留这些字段
|
||||
maxPriorityFeePerGas?: bigint;
|
||||
maxFeePerGas?: bigint;
|
||||
accessList?: unknown[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 将数字转换为十六进制字符串 (带 0x 前缀)
|
||||
*/
|
||||
function toHex(value: number | bigint): string {
|
||||
const hex = value.toString(16);
|
||||
return '0x' + hex;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将十六进制字符串转换为 Uint8Array
|
||||
*/
|
||||
function hexToBytes(hex: string): Uint8Array {
|
||||
const cleanHex = hex.startsWith('0x') ? hex.slice(2) : hex;
|
||||
if (cleanHex.length === 0) return new Uint8Array(0);
|
||||
const bytes = new Uint8Array(cleanHex.length / 2);
|
||||
for (let i = 0; i < cleanHex.length; i += 2) {
|
||||
bytes[i / 2] = parseInt(cleanHex.substring(i, i + 2), 16);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 Uint8Array 转换为十六进制字符串
|
||||
*/
|
||||
function bytesToHex(bytes: Uint8Array): string {
|
||||
return Array.from(bytes)
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* RLP 编码
|
||||
* 参考: https://ethereum.org/en/developers/docs/data-structures-and-encoding/rlp/
|
||||
*/
|
||||
function rlpEncode(input: unknown): Uint8Array {
|
||||
if (typeof input === 'string') {
|
||||
// 处理十六进制字符串
|
||||
if (input.startsWith('0x')) {
|
||||
const bytes = hexToBytes(input);
|
||||
return rlpEncodeBytes(bytes);
|
||||
}
|
||||
// 普通字符串
|
||||
const bytes = new TextEncoder().encode(input);
|
||||
return rlpEncodeBytes(bytes);
|
||||
}
|
||||
|
||||
if (typeof input === 'number' || typeof input === 'bigint') {
|
||||
if (input === 0 || input === 0n) {
|
||||
return rlpEncodeBytes(new Uint8Array(0));
|
||||
}
|
||||
// 转换为最小字节表示
|
||||
let hex = input.toString(16);
|
||||
if (hex.length % 2 !== 0) hex = '0' + hex;
|
||||
return rlpEncodeBytes(hexToBytes(hex));
|
||||
}
|
||||
|
||||
if (input instanceof Uint8Array) {
|
||||
return rlpEncodeBytes(input);
|
||||
}
|
||||
|
||||
if (Array.isArray(input)) {
|
||||
// 编码列表
|
||||
const encoded = input.map(item => rlpEncode(item));
|
||||
const totalLength = encoded.reduce((acc, item) => acc + item.length, 0);
|
||||
|
||||
if (totalLength <= 55) {
|
||||
const result = new Uint8Array(1 + totalLength);
|
||||
result[0] = 0xc0 + totalLength;
|
||||
let offset = 1;
|
||||
for (const item of encoded) {
|
||||
result.set(item, offset);
|
||||
offset += item.length;
|
||||
}
|
||||
return result;
|
||||
} else {
|
||||
const lengthBytes = encodeLength(totalLength);
|
||||
const result = new Uint8Array(1 + lengthBytes.length + totalLength);
|
||||
result[0] = 0xf7 + lengthBytes.length;
|
||||
result.set(lengthBytes, 1);
|
||||
let offset = 1 + lengthBytes.length;
|
||||
for (const item of encoded) {
|
||||
result.set(item, offset);
|
||||
offset += item.length;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Unsupported RLP input type');
|
||||
}
|
||||
|
||||
function rlpEncodeBytes(bytes: Uint8Array): Uint8Array {
|
||||
if (bytes.length === 1 && bytes[0] < 0x80) {
|
||||
// 单字节值直接返回
|
||||
return bytes;
|
||||
}
|
||||
|
||||
if (bytes.length <= 55) {
|
||||
const result = new Uint8Array(1 + bytes.length);
|
||||
result[0] = 0x80 + bytes.length;
|
||||
result.set(bytes, 1);
|
||||
return result;
|
||||
}
|
||||
|
||||
const lengthBytes = encodeLength(bytes.length);
|
||||
const result = new Uint8Array(1 + lengthBytes.length + bytes.length);
|
||||
result[0] = 0xb7 + lengthBytes.length;
|
||||
result.set(lengthBytes, 1);
|
||||
result.set(bytes, 1 + lengthBytes.length);
|
||||
return result;
|
||||
}
|
||||
|
||||
function encodeLength(length: number): Uint8Array {
|
||||
let hex = length.toString(16);
|
||||
if (hex.length % 2 !== 0) hex = '0' + hex;
|
||||
return hexToBytes(hex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keccak-256 哈希 (复用 address.ts 中的实现)
|
||||
*/
|
||||
async function keccak256(data: Uint8Array): Promise<Uint8Array> {
|
||||
const RC = [
|
||||
0x0000000000000001n, 0x0000000000008082n, 0x800000000000808an,
|
||||
0x8000000080008000n, 0x000000000000808bn, 0x0000000080000001n,
|
||||
0x8000000080008081n, 0x8000000000008009n, 0x000000000000008an,
|
||||
0x0000000000000088n, 0x0000000080008009n, 0x000000008000000an,
|
||||
0x000000008000808bn, 0x800000000000008bn, 0x8000000000008089n,
|
||||
0x8000000000008003n, 0x8000000000008002n, 0x8000000000000080n,
|
||||
0x000000000000800an, 0x800000008000000an, 0x8000000080008081n,
|
||||
0x8000000000008080n, 0x0000000080000001n, 0x8000000080008008n,
|
||||
];
|
||||
|
||||
const ROTC = [
|
||||
[0, 36, 3, 41, 18],
|
||||
[1, 44, 10, 45, 2],
|
||||
[62, 6, 43, 15, 61],
|
||||
[28, 55, 25, 21, 56],
|
||||
[27, 20, 39, 8, 14],
|
||||
];
|
||||
|
||||
function rotl64(x: bigint, n: number): bigint {
|
||||
return ((x << BigInt(n)) | (x >> BigInt(64 - n))) & 0xffffffffffffffffn;
|
||||
}
|
||||
|
||||
function keccakF(state: bigint[][]): void {
|
||||
for (let round = 0; round < 24; round++) {
|
||||
const C: bigint[] = [];
|
||||
for (let x = 0; x < 5; x++) {
|
||||
C[x] = state[x][0] ^ state[x][1] ^ state[x][2] ^ state[x][3] ^ state[x][4];
|
||||
}
|
||||
const D: bigint[] = [];
|
||||
for (let x = 0; x < 5; x++) {
|
||||
D[x] = C[(x + 4) % 5] ^ rotl64(C[(x + 1) % 5], 1);
|
||||
}
|
||||
for (let x = 0; x < 5; x++) {
|
||||
for (let y = 0; y < 5; y++) {
|
||||
state[x][y] ^= D[x];
|
||||
}
|
||||
}
|
||||
|
||||
const B: bigint[][] = Array(5).fill(null).map(() => Array(5).fill(0n));
|
||||
for (let x = 0; x < 5; x++) {
|
||||
for (let y = 0; y < 5; y++) {
|
||||
B[y][(2 * x + 3 * y) % 5] = rotl64(state[x][y], ROTC[x][y]);
|
||||
}
|
||||
}
|
||||
|
||||
for (let x = 0; x < 5; x++) {
|
||||
for (let y = 0; y < 5; y++) {
|
||||
state[x][y] = B[x][y] ^ (~B[(x + 1) % 5][y] & B[(x + 2) % 5][y]);
|
||||
}
|
||||
}
|
||||
|
||||
state[0][0] ^= RC[round];
|
||||
}
|
||||
}
|
||||
|
||||
const rate = 136;
|
||||
const outputLen = 32;
|
||||
const state: bigint[][] = Array(5).fill(null).map(() => Array(5).fill(0n));
|
||||
const padded = new Uint8Array(Math.ceil((data.length + 1) / rate) * rate);
|
||||
padded.set(data);
|
||||
padded[data.length] = 0x01;
|
||||
padded[padded.length - 1] |= 0x80;
|
||||
|
||||
for (let i = 0; i < padded.length; i += rate) {
|
||||
for (let j = 0; j < rate && i + j < padded.length; j += 8) {
|
||||
const x = Math.floor(j / 8) % 5;
|
||||
const y = Math.floor(Math.floor(j / 8) / 5);
|
||||
let lane = 0n;
|
||||
for (let k = 0; k < 8 && i + j + k < padded.length; k++) {
|
||||
lane |= BigInt(padded[i + j + k]) << BigInt(k * 8);
|
||||
}
|
||||
state[x][y] ^= lane;
|
||||
}
|
||||
keccakF(state);
|
||||
}
|
||||
|
||||
const output = new Uint8Array(outputLen);
|
||||
for (let i = 0; i < outputLen; i += 8) {
|
||||
const x = Math.floor(i / 8) % 5;
|
||||
const y = Math.floor(Math.floor(i / 8) / 5);
|
||||
const lane = state[x][y];
|
||||
for (let k = 0; k < 8 && i + k < outputLen; k++) {
|
||||
output[i + k] = Number((lane >> BigInt(k * 8)) & 0xffn);
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 KAVA 金额转换为 wei (18 位小数)
|
||||
*/
|
||||
export function kavaToWei(kava: string): bigint {
|
||||
const parts = kava.split('.');
|
||||
const whole = BigInt(parts[0] || '0');
|
||||
let fraction = parts[1] || '';
|
||||
|
||||
// 补齐或截断到 18 位
|
||||
if (fraction.length > 18) {
|
||||
fraction = fraction.substring(0, 18);
|
||||
} else {
|
||||
fraction = fraction.padEnd(18, '0');
|
||||
}
|
||||
|
||||
return whole * BigInt(10 ** 18) + BigInt(fraction);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 wei 转换为 KAVA 金额
|
||||
*/
|
||||
export function weiToKava(wei: bigint): string {
|
||||
const weiStr = wei.toString().padStart(19, '0');
|
||||
const whole = weiStr.slice(0, -18) || '0';
|
||||
const fraction = weiStr.slice(-18).replace(/0+$/, '');
|
||||
return fraction ? `${whole}.${fraction}` : whole;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将绿积分金额转换为最小单位 (6 decimals)
|
||||
*/
|
||||
export function greenPointsToRaw(amount: string): bigint {
|
||||
const parts = amount.split('.');
|
||||
const whole = BigInt(parts[0] || '0');
|
||||
let fraction = parts[1] || '';
|
||||
|
||||
// 补齐或截断到 6 位
|
||||
if (fraction.length > 6) {
|
||||
fraction = fraction.substring(0, 6);
|
||||
} else {
|
||||
fraction = fraction.padEnd(6, '0');
|
||||
}
|
||||
|
||||
return whole * BigInt(10 ** 6) + BigInt(fraction);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将最小单位转换为绿积分金额
|
||||
*/
|
||||
export function rawToGreenPoints(raw: bigint): string {
|
||||
const rawStr = raw.toString().padStart(7, '0');
|
||||
const whole = rawStr.slice(0, -6) || '0';
|
||||
const fraction = rawStr.slice(-6).replace(/0+$/, '');
|
||||
return fraction ? `${whole}.${fraction}` : whole;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询绿积分 (ERC-20) 余额
|
||||
*/
|
||||
export async function fetchGreenPointsBalance(address: string): Promise<string> {
|
||||
try {
|
||||
const rpcUrl = getCurrentRpcUrl();
|
||||
// Encode balanceOf(address) call data
|
||||
// Function selector: 0x70a08231
|
||||
// Address parameter: padded to 32 bytes
|
||||
const paddedAddress = address.toLowerCase().replace('0x', '').padStart(64, '0');
|
||||
const callData = GREEN_POINTS_TOKEN.balanceOfSelector + paddedAddress;
|
||||
|
||||
const response = await fetch(rpcUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
method: 'eth_call',
|
||||
params: [
|
||||
{
|
||||
to: GREEN_POINTS_TOKEN.contractAddress,
|
||||
data: callData,
|
||||
},
|
||||
'latest',
|
||||
],
|
||||
id: 1,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.result && data.result !== '0x') {
|
||||
const balanceRaw = BigInt(data.result);
|
||||
return rawToGreenPoints(balanceRaw);
|
||||
}
|
||||
return '0';
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch Green Points balance:', error);
|
||||
return '0';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode ERC-20 transfer function call
|
||||
*/
|
||||
function encodeErc20Transfer(to: string, amount: bigint): string {
|
||||
// Function selector: transfer(address,uint256) = 0xa9059cbb
|
||||
const selector = GREEN_POINTS_TOKEN.transferSelector;
|
||||
// Encode recipient address (padded to 32 bytes)
|
||||
const paddedAddress = to.toLowerCase().replace('0x', '').padStart(64, '0');
|
||||
// Encode amount (padded to 32 bytes)
|
||||
const amountHex = amount.toString(16).padStart(64, '0');
|
||||
return selector + paddedAddress + amountHex;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 RPC 获取账户 nonce
|
||||
*/
|
||||
export async function getNonce(address: string): Promise<number> {
|
||||
const rpcUrl = getCurrentRpcUrl();
|
||||
const response = await fetch(rpcUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
method: 'eth_getTransactionCount',
|
||||
params: [address, 'pending'],
|
||||
id: 1,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.error) {
|
||||
throw new Error(`获取 nonce 失败: ${data.error.message}`);
|
||||
}
|
||||
return parseInt(data.result, 16);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 RPC 获取当前 gas 价格
|
||||
* 使用 eth_gasPrice 方法获取 Legacy 交易的 gasPrice
|
||||
*/
|
||||
export async function getGasPrice(): Promise<{ maxFeePerGas: bigint; maxPriorityFeePerGas: bigint }> {
|
||||
const rpcUrl = getCurrentRpcUrl();
|
||||
|
||||
// 使用 eth_gasPrice 获取建议的 gas 价格
|
||||
const response = await fetch(rpcUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
method: 'eth_gasPrice',
|
||||
params: [],
|
||||
id: 1,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
let gasPrice: bigint;
|
||||
|
||||
if (data.result) {
|
||||
gasPrice = BigInt(data.result);
|
||||
// 增加 10% 以确保交易能被打包
|
||||
gasPrice = gasPrice * 110n / 100n;
|
||||
} else {
|
||||
// 默认 1 gwei
|
||||
gasPrice = BigInt(1000000000);
|
||||
}
|
||||
|
||||
// 为了兼容性,同时返回 maxFeePerGas (实际上用作 gasPrice)
|
||||
return { maxFeePerGas: gasPrice, maxPriorityFeePerGas: gasPrice };
|
||||
}
|
||||
|
||||
/**
|
||||
* 预估 gas 用量
|
||||
*/
|
||||
export async function estimateGas(params: { from: string; to: string; value: string; data?: string; tokenType?: TokenType }): Promise<bigint> {
|
||||
const rpcUrl = getCurrentRpcUrl();
|
||||
const tokenType = params.tokenType || 'KAVA';
|
||||
|
||||
// For token transfers, we need different params
|
||||
let txParams: { from: string; to: string; value: string; data?: string };
|
||||
|
||||
if (tokenType === 'GREEN_POINTS') {
|
||||
// ERC-20 transfer: to is contract, value is 0, data is transfer call
|
||||
const tokenAmount = greenPointsToRaw(params.value);
|
||||
const transferData = encodeErc20Transfer(params.to, tokenAmount);
|
||||
txParams = {
|
||||
from: params.from,
|
||||
to: GREEN_POINTS_TOKEN.contractAddress,
|
||||
value: '0x0',
|
||||
data: transferData,
|
||||
};
|
||||
} else {
|
||||
// Native KAVA transfer
|
||||
txParams = {
|
||||
from: params.from,
|
||||
to: params.to,
|
||||
value: toHex(kavaToWei(params.value)),
|
||||
data: params.data || '0x',
|
||||
};
|
||||
}
|
||||
|
||||
const response = await fetch(rpcUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
method: 'eth_estimateGas',
|
||||
params: [txParams],
|
||||
id: 1,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.error) {
|
||||
// 如果估算失败,使用默认值
|
||||
console.warn('Gas 估算失败,使用默认值:', data.error);
|
||||
return tokenType === 'GREEN_POINTS' ? BigInt(65000) : BigInt(21000);
|
||||
}
|
||||
return BigInt(data.result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 准备 Legacy 交易 (Type 0)
|
||||
* KAVA 不支持 EIP-1559,所以使用 Legacy 格式
|
||||
* 返回交易数据和待签名的哈希
|
||||
* 支持原生 KAVA 和 ERC-20 代币 (绿积分) 转账
|
||||
*/
|
||||
export async function prepareTransaction(params: TransactionParams): Promise<PreparedTransaction> {
|
||||
const chainId = getCurrentChainId();
|
||||
const tokenType = params.tokenType || 'KAVA';
|
||||
|
||||
// 获取或使用提供的参数
|
||||
const nonce = params.nonce ?? await getNonce(params.from);
|
||||
const gasPriceData = await getGasPrice();
|
||||
const gasPrice = params.maxFeePerGas ?? gasPriceData.maxFeePerGas;
|
||||
const gasLimit = params.gasLimit ?? await estimateGas({
|
||||
from: params.from,
|
||||
to: params.to,
|
||||
value: params.value,
|
||||
data: params.data,
|
||||
tokenType,
|
||||
});
|
||||
|
||||
// Prepare transaction based on token type
|
||||
let toAddress: string;
|
||||
let value: bigint;
|
||||
let data: string;
|
||||
|
||||
if (tokenType === 'GREEN_POINTS') {
|
||||
// ERC-20 token transfer
|
||||
// To address is the contract, value is 0
|
||||
// Data is transfer(recipient, amount) encoded
|
||||
const tokenAmount = greenPointsToRaw(params.value);
|
||||
toAddress = GREEN_POINTS_TOKEN.contractAddress.toLowerCase();
|
||||
value = BigInt(0);
|
||||
data = encodeErc20Transfer(params.to, tokenAmount);
|
||||
} else {
|
||||
// Native KAVA transfer
|
||||
toAddress = params.to.toLowerCase();
|
||||
value = kavaToWei(params.value);
|
||||
data = params.data || '0x';
|
||||
}
|
||||
|
||||
// Legacy 交易字段顺序 (EIP-155)
|
||||
// [nonce, gasPrice, gasLimit, to, value, data, chainId, 0, 0]
|
||||
// 最后三个字段用于 EIP-155 replay protection
|
||||
const txFields = [
|
||||
nonce,
|
||||
gasPrice,
|
||||
gasLimit,
|
||||
toAddress,
|
||||
value,
|
||||
data,
|
||||
chainId,
|
||||
0, // EIP-155: v placeholder
|
||||
0, // EIP-155: r placeholder
|
||||
];
|
||||
|
||||
// RLP 编码交易字段
|
||||
const encodedTx = rlpEncode(txFields);
|
||||
|
||||
// 计算签名哈希 (不需要类型前缀,Legacy 交易直接哈希)
|
||||
const signHashBytes = await keccak256(encodedTx);
|
||||
const signHash = bytesToHex(signHashBytes);
|
||||
|
||||
return {
|
||||
chainId,
|
||||
nonce,
|
||||
gasPrice,
|
||||
gasLimit,
|
||||
to: toAddress,
|
||||
value,
|
||||
data,
|
||||
signHash,
|
||||
rawTxForSigning: bytesToHex(encodedTx),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用签名完成交易并返回可广播的原始交易
|
||||
* 使用 Legacy 交易格式 (Type 0)
|
||||
*/
|
||||
export function finalizeTransaction(
|
||||
preparedTx: PreparedTransaction,
|
||||
signature: { r: string; s: string; v: number }
|
||||
): string {
|
||||
// EIP-155: v = chainId * 2 + 35 + recovery_id
|
||||
// recovery_id 是 0 或 1
|
||||
const v = preparedTx.chainId * 2 + 35 + signature.v;
|
||||
|
||||
// Legacy 签名后的交易字段
|
||||
// [nonce, gasPrice, gasLimit, to, value, data, v, r, s]
|
||||
const signedTxFields = [
|
||||
preparedTx.nonce,
|
||||
preparedTx.gasPrice,
|
||||
preparedTx.gasLimit,
|
||||
preparedTx.to,
|
||||
preparedTx.value,
|
||||
preparedTx.data,
|
||||
v,
|
||||
'0x' + signature.r,
|
||||
'0x' + signature.s,
|
||||
];
|
||||
|
||||
const encodedSignedTx = rlpEncode(signedTxFields);
|
||||
|
||||
// Legacy 交易不需要类型前缀
|
||||
return '0x' + bytesToHex(encodedSignedTx);
|
||||
}
|
||||
|
||||
/**
|
||||
* 广播已签名的交易
|
||||
*/
|
||||
export async function broadcastTransaction(signedTx: string): Promise<string> {
|
||||
const rpcUrl = getCurrentRpcUrl();
|
||||
const response = await fetch(rpcUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
method: 'eth_sendRawTransaction',
|
||||
params: [signedTx],
|
||||
id: 1,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.error) {
|
||||
throw new Error(`广播交易失败: ${data.error.message}`);
|
||||
}
|
||||
return data.result; // 返回交易哈希
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取交易收据 (确认状态)
|
||||
*/
|
||||
export async function getTransactionReceipt(txHash: string): Promise<unknown | null> {
|
||||
const rpcUrl = getCurrentRpcUrl();
|
||||
const response = await fetch(rpcUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
method: 'eth_getTransactionReceipt',
|
||||
params: [txHash],
|
||||
id: 1,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
return data.result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证地址格式
|
||||
*/
|
||||
export function isValidAddress(address: string): boolean {
|
||||
return /^0x[a-fA-F0-9]{40}$/.test(address);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证金额格式
|
||||
*/
|
||||
export function isValidAmount(amount: string): boolean {
|
||||
if (!amount || amount.trim() === '') return false;
|
||||
const num = parseFloat(amount);
|
||||
return !isNaN(num) && num > 0;
|
||||
}
|
||||
|
|
@ -6,7 +6,10 @@ require github.com/bnb-chain/tss-lib/v2 v2.0.2
|
|||
|
||||
require (
|
||||
github.com/agl/ed25519 v0.0.0-20200225211852-fd4d107ace12 // indirect
|
||||
github.com/btcsuite/btcd v0.23.4 // indirect
|
||||
github.com/btcsuite/btcd/btcec/v2 v2.3.2 // indirect
|
||||
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1 // indirect
|
||||
github.com/btcsuite/btcutil v1.0.2 // indirect
|
||||
github.com/decred/dcrd/dcrec/edwards/v2 v2.0.3 // indirect
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
|
|
@ -20,6 +23,7 @@ require (
|
|||
github.com/pkg/errors v0.9.1 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.uber.org/zap v1.26.0 // indirect
|
||||
golang.org/x/crypto v0.13.0 // indirect
|
||||
golang.org/x/sys v0.15.0 // indirect
|
||||
google.golang.org/protobuf v1.31.0 // indirect
|
||||
)
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ github.com/bnb-chain/tss-lib/v2 v2.0.2 h1:dL2GJFCSYsYQ0bHkGll+hNM2JWsC1rxDmJJJQE
|
|||
github.com/bnb-chain/tss-lib/v2 v2.0.2/go.mod h1:s4LRfEqj89DhfNb+oraW0dURt5LtOHWXb9Gtkghn0L8=
|
||||
github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ=
|
||||
github.com/btcsuite/btcd v0.22.0-beta.0.20220111032746-97732e52810c/go.mod h1:tjmYdS6MLJ5/s0Fj4DbLgSbDHbEqLJrtnHecBFkdz5M=
|
||||
github.com/btcsuite/btcd v0.23.4 h1:IzV6qqkfwbItOS/sg/aDfPDsjPP8twrCOE2R93hxMlQ=
|
||||
github.com/btcsuite/btcd v0.23.4/go.mod h1:0QJIIN1wwIXF/3G/m87gIwGniDMDQqjVn4SZgnFpsYY=
|
||||
github.com/btcsuite/btcd/btcec/v2 v2.1.0/go.mod h1:2VzYrv4Gm4apmbVVsSq5bqf1Ec8v56E48Vt0Y/umPgA=
|
||||
github.com/btcsuite/btcd/btcec/v2 v2.1.3/go.mod h1:ctjw4H1kknNJmRN4iP1R7bTQ+v3GJkZBd6mui8ZsAZE=
|
||||
|
|
@ -15,9 +16,11 @@ github.com/btcsuite/btcd/btcec/v2 v2.3.2/go.mod h1:zYzJ8etWJQIv1Ogk7OzpWjowwOdXY
|
|||
github.com/btcsuite/btcd/btcutil v1.0.0/go.mod h1:Uoxwv0pqYWhD//tfTiipkxNfdhG9UrLwaeswfjfdF0A=
|
||||
github.com/btcsuite/btcd/btcutil v1.1.0/go.mod h1:5OapHB7A2hBBWLm48mmw4MOHNJCcUBTwmWH/0Jn8VHE=
|
||||
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
|
||||
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1 h1:q0rUy8C/TYNBQS1+CGKw68tLOFYSNEs0TFnxxnS9+4U=
|
||||
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
|
||||
github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA=
|
||||
github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg=
|
||||
github.com/btcsuite/btcutil v1.0.2 h1:9iZ1Terx9fMIOtq1VrwdqfsATL9MC2l8ZrUY6YZ2uts=
|
||||
github.com/btcsuite/btcutil v1.0.2/go.mod h1:j9HUFwoQRsZL3V4n+qG+CUnEGHOarIxfC3Le2Yhbcts=
|
||||
github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg=
|
||||
github.com/btcsuite/goleveldb v0.0.0-20160330041536-7834afc9e8cd/go.mod h1:F+uVaaLLH7j4eDXPRvw78tMflu7Ie2bzYOH4Y8rRKBY=
|
||||
|
|
@ -144,6 +147,7 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
|
|||
golang.org/x/crypto v0.0.0-20200115085410-6d4e4cb37c7d/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
|
||||
|
|
|
|||
|
|
@ -0,0 +1,674 @@
|
|||
// integration_test.go - Integration test for the complete co-sign flow
|
||||
// Tests: session creation, joining, waiting, events, and signing
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/bnb-chain/tss-lib/v2/ecdsa/keygen"
|
||||
"github.com/bnb-chain/tss-lib/v2/tss"
|
||||
)
|
||||
|
||||
const (
|
||||
// Backend service URLs (from docker-compose.windows.yml)
|
||||
accountServiceURL = "http://localhost:4000"
|
||||
grpcRouterAddr = "localhost:50051"
|
||||
)
|
||||
|
||||
// API Response types for co-managed keygen
|
||||
type CreateCoManagedSessionRequest struct {
|
||||
WalletName string `json:"wallet_name"`
|
||||
ThresholdT int `json:"threshold_t"`
|
||||
ThresholdN int `json:"threshold_n"`
|
||||
InitiatorPartyID string `json:"initiator_party_id"`
|
||||
InitiatorName string `json:"initiator_name,omitempty"`
|
||||
PersistentCount int `json:"persistent_count"`
|
||||
}
|
||||
|
||||
type CreateCoManagedSessionResponse struct {
|
||||
SessionID string `json:"session_id"`
|
||||
InviteCode string `json:"invite_code"`
|
||||
WalletName string `json:"wallet_name"`
|
||||
ThresholdT int `json:"threshold_t"`
|
||||
ThresholdN int `json:"threshold_n"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
JoinToken string `json:"join_token"`
|
||||
PartyID string `json:"party_id"`
|
||||
PartyIndex int `json:"party_index"`
|
||||
}
|
||||
|
||||
// API Response types for co-managed sign
|
||||
type CreateSignSessionRequest struct {
|
||||
KeygenSessionID string `json:"keygen_session_id"`
|
||||
WalletName string `json:"wallet_name"`
|
||||
MessageHash string `json:"message_hash"`
|
||||
Parties []SignPartyInfo `json:"parties"`
|
||||
ThresholdT int `json:"threshold_t"`
|
||||
InitiatorName string `json:"initiator_name,omitempty"`
|
||||
}
|
||||
|
||||
type CreateSignSessionResponse struct {
|
||||
SessionID string `json:"session_id"`
|
||||
InviteCode string `json:"invite_code"`
|
||||
KeygenSessionID string `json:"keygen_session_id"`
|
||||
MessageHash string `json:"message_hash"`
|
||||
ThresholdT int `json:"threshold_t"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
JoinToken string `json:"join_token"`
|
||||
}
|
||||
|
||||
type GetSignSessionResponse struct {
|
||||
SessionID string `json:"session_id"`
|
||||
KeygenSessionID string `json:"keygen_session_id"`
|
||||
WalletName string `json:"wallet_name"`
|
||||
MessageHash string `json:"message_hash"`
|
||||
ThresholdT int `json:"threshold_t"`
|
||||
Status string `json:"status"`
|
||||
InviteCode string `json:"invite_code"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
Parties []SignPartyInfo `json:"parties"`
|
||||
JoinedCount int `json:"joined_count"`
|
||||
JoinToken string `json:"join_token,omitempty"`
|
||||
}
|
||||
|
||||
type SignPartyInfo struct {
|
||||
PartyID string `json:"party_id"`
|
||||
PartyIndex int `json:"party_index"`
|
||||
}
|
||||
|
||||
// TestAccountServiceHealth tests if account service is available
|
||||
func TestAccountServiceHealth(t *testing.T) {
|
||||
resp, err := http.Get(accountServiceURL + "/health")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to connect to account service: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("Account service unhealthy, status: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
t.Log("Account service is healthy")
|
||||
}
|
||||
|
||||
// TestCreateSignSession tests creating a new sign session
|
||||
func TestCreateSignSession(t *testing.T) {
|
||||
// This requires an existing keygen session ID
|
||||
// For testing, we'll use a mock one
|
||||
keygenSessionID := "test-keygen-session-" + fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
messageHash := "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
|
||||
|
||||
reqBody := CreateSignSessionRequest{
|
||||
KeygenSessionID: keygenSessionID,
|
||||
MessageHash: messageHash,
|
||||
InitiatorName: "test-initiator",
|
||||
}
|
||||
|
||||
jsonBody, _ := json.Marshal(reqBody)
|
||||
resp, err := http.Post(
|
||||
accountServiceURL+"/api/v1/co-managed/sign",
|
||||
"application/json",
|
||||
bytes.NewBuffer(jsonBody),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create sign session: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
t.Logf("Create session response (status %d): %s", resp.StatusCode, string(body))
|
||||
|
||||
if resp.StatusCode != 200 && resp.StatusCode != 201 {
|
||||
t.Logf("Note: This test requires an existing keygen session in the database")
|
||||
t.Skipf("Sign session creation returned status %d (expected existing keygen session)", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result CreateSignSessionResponse
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
t.Fatalf("Failed to parse response: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("Session created: ID=%s, InviteCode=%s", result.SessionID, result.InviteCode)
|
||||
}
|
||||
|
||||
// TestGetSessionByInviteCode tests retrieving session info by invite code
|
||||
func TestGetSessionByInviteCode(t *testing.T) {
|
||||
// This would need a valid invite code from a real session
|
||||
inviteCode := "TEST123"
|
||||
|
||||
resp, err := http.Get(accountServiceURL + "/api/v1/co-managed/sign/by-invite-code/" + inviteCode)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get session: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
t.Logf("Get session response (status %d): %s", resp.StatusCode, string(body))
|
||||
|
||||
if resp.StatusCode == 404 {
|
||||
t.Skip("No session found with invite code (expected for test)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCoManagedKeygenSessionFlow tests the keygen session creation and join flow
|
||||
func TestCoManagedKeygenSessionFlow(t *testing.T) {
|
||||
t.Log("=== Co-Managed Keygen Session Flow Test ===")
|
||||
|
||||
// Step 1: Create a keygen session
|
||||
t.Log("Step 1: Creating keygen session...")
|
||||
reqBody := CreateCoManagedSessionRequest{
|
||||
WalletName: "test-wallet-" + fmt.Sprintf("%d", time.Now().UnixNano()),
|
||||
ThresholdT: 1, // 2-of-3: t=1 means t+1=2 signers needed
|
||||
ThresholdN: 3,
|
||||
InitiatorPartyID: "test-initiator-party",
|
||||
InitiatorName: "Test User",
|
||||
PersistentCount: 0, // No server parties for this test
|
||||
}
|
||||
|
||||
jsonBody, _ := json.Marshal(reqBody)
|
||||
resp, err := http.Post(
|
||||
accountServiceURL+"/api/v1/co-managed/sessions",
|
||||
"application/json",
|
||||
bytes.NewBuffer(jsonBody),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create session: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
t.Logf("Create session response (status %d): %s", resp.StatusCode, string(body))
|
||||
|
||||
if resp.StatusCode != 200 && resp.StatusCode != 201 {
|
||||
t.Fatalf("Failed to create keygen session, status: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var createResp CreateCoManagedSessionResponse
|
||||
if err := json.Unmarshal(body, &createResp); err != nil {
|
||||
t.Fatalf("Failed to parse response: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("Session created: ID=%s, InviteCode=%s", createResp.SessionID, createResp.InviteCode)
|
||||
|
||||
// Step 2: Get session by invite code
|
||||
t.Log("Step 2: Getting session by invite code...")
|
||||
resp2, err := http.Get(accountServiceURL + "/api/v1/co-managed/sessions/by-invite-code/" + createResp.InviteCode)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get session: %v", err)
|
||||
}
|
||||
defer resp2.Body.Close()
|
||||
|
||||
body2, _ := io.ReadAll(resp2.Body)
|
||||
t.Logf("Get session response (status %d): %s", resp2.StatusCode, string(body2))
|
||||
|
||||
if resp2.StatusCode != 200 {
|
||||
t.Fatalf("Failed to get session by invite code, status: %d", resp2.StatusCode)
|
||||
}
|
||||
|
||||
// Step 3: Get session status
|
||||
t.Log("Step 3: Getting session status...")
|
||||
resp3, err := http.Get(accountServiceURL + "/api/v1/co-managed/sessions/" + createResp.SessionID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get session status: %v", err)
|
||||
}
|
||||
defer resp3.Body.Close()
|
||||
|
||||
body3, _ := io.ReadAll(resp3.Body)
|
||||
t.Logf("Session status response (status %d): %s", resp3.StatusCode, string(body3))
|
||||
|
||||
if resp3.StatusCode != 200 {
|
||||
t.Fatalf("Failed to get session status, status: %d", resp3.StatusCode)
|
||||
}
|
||||
|
||||
t.Log("Keygen session flow test passed!")
|
||||
}
|
||||
|
||||
// TestCoManagedSignSessionFlow tests the full sign session flow
|
||||
func TestCoManagedSignSessionFlow(t *testing.T) {
|
||||
t.Log("=== Co-Managed Sign Session Flow Test ===")
|
||||
|
||||
// First, create a keygen session to get a valid keygen_session_id
|
||||
t.Log("Step 1: Creating keygen session...")
|
||||
keygenReq := CreateCoManagedSessionRequest{
|
||||
WalletName: "test-wallet-for-sign-" + fmt.Sprintf("%d", time.Now().UnixNano()),
|
||||
ThresholdT: 1,
|
||||
ThresholdN: 3,
|
||||
InitiatorPartyID: "test-party-0",
|
||||
InitiatorName: "Test User",
|
||||
PersistentCount: 0,
|
||||
}
|
||||
|
||||
jsonBody, _ := json.Marshal(keygenReq)
|
||||
resp, err := http.Post(
|
||||
accountServiceURL+"/api/v1/co-managed/sessions",
|
||||
"application/json",
|
||||
bytes.NewBuffer(jsonBody),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create keygen session: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
t.Logf("Keygen session response (status %d): %s", resp.StatusCode, string(body))
|
||||
|
||||
if resp.StatusCode != 200 && resp.StatusCode != 201 {
|
||||
t.Fatalf("Failed to create keygen session, status: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var keygenResp CreateCoManagedSessionResponse
|
||||
if err := json.Unmarshal(body, &keygenResp); err != nil {
|
||||
t.Fatalf("Failed to parse keygen response: %v", err)
|
||||
}
|
||||
|
||||
// Step 2: Create a sign session using the keygen session ID
|
||||
// Note: For threshold T, we need T+1 parties to sign
|
||||
// Backend validates that parties.length >= threshold_t + 1
|
||||
t.Log("Step 2: Creating sign session...")
|
||||
signReq := CreateSignSessionRequest{
|
||||
KeygenSessionID: keygenResp.SessionID,
|
||||
WalletName: keygenReq.WalletName,
|
||||
MessageHash: "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
|
||||
ThresholdT: 1, // Threshold T=1 means T+1=2 parties needed to sign
|
||||
Parties: []SignPartyInfo{
|
||||
{PartyID: "test-party-0", PartyIndex: 0},
|
||||
{PartyID: "test-party-1", PartyIndex: 1},
|
||||
},
|
||||
InitiatorName: "Test User",
|
||||
}
|
||||
|
||||
jsonBody2, _ := json.Marshal(signReq)
|
||||
resp2, err := http.Post(
|
||||
accountServiceURL+"/api/v1/co-managed/sign",
|
||||
"application/json",
|
||||
bytes.NewBuffer(jsonBody2),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create sign session: %v", err)
|
||||
}
|
||||
defer resp2.Body.Close()
|
||||
|
||||
body2, _ := io.ReadAll(resp2.Body)
|
||||
t.Logf("Sign session response (status %d): %s", resp2.StatusCode, string(body2))
|
||||
|
||||
if resp2.StatusCode != 200 && resp2.StatusCode != 201 {
|
||||
t.Fatalf("Failed to create sign session, status: %d", resp2.StatusCode)
|
||||
}
|
||||
|
||||
var signResp CreateSignSessionResponse
|
||||
if err := json.Unmarshal(body2, &signResp); err != nil {
|
||||
t.Fatalf("Failed to parse sign response: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("Sign session created: ID=%s, InviteCode=%s", signResp.SessionID, signResp.InviteCode)
|
||||
|
||||
// Step 3: Get sign session by invite code
|
||||
t.Log("Step 3: Getting sign session by invite code...")
|
||||
resp3, err := http.Get(accountServiceURL + "/api/v1/co-managed/sign/by-invite-code/" + signResp.InviteCode)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get sign session: %v", err)
|
||||
}
|
||||
defer resp3.Body.Close()
|
||||
|
||||
body3, _ := io.ReadAll(resp3.Body)
|
||||
t.Logf("Get sign session response (status %d): %s", resp3.StatusCode, string(body3))
|
||||
|
||||
if resp3.StatusCode != 200 {
|
||||
t.Fatalf("Failed to get sign session by invite code, status: %d", resp3.StatusCode)
|
||||
}
|
||||
|
||||
var getSignResp GetSignSessionResponse
|
||||
if err := json.Unmarshal(body3, &getSignResp); err != nil {
|
||||
t.Fatalf("Failed to parse get sign session response: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("Sign session status: %s, JoinedCount: %d, ThresholdT: %d",
|
||||
getSignResp.Status, getSignResp.JoinedCount, getSignResp.ThresholdT)
|
||||
|
||||
t.Log("Sign session flow test passed!")
|
||||
}
|
||||
|
||||
// TestFullSignFlow tests the complete signing flow with mock data
|
||||
func TestFullSignFlow(t *testing.T) {
|
||||
t.Log("=== Full Co-Sign Flow Test ===")
|
||||
|
||||
// Step 1: Generate mock key shares (simulating keygen result)
|
||||
// NOTE: In tss-lib, threshold parameter means "t" where you need t+1 parties to sign.
|
||||
// So for 2-of-3, we use threshold=1 (meaning 1+1=2 parties needed to sign)
|
||||
t.Log("Step 1: Generating mock key shares for 2-of-3 scheme...")
|
||||
thresholdT := 1 // t value: need t+1=2 parties to sign
|
||||
totalN := 3 // total parties
|
||||
shares, err := generateMockKeyShares(thresholdT, totalN)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate key shares: %v", err)
|
||||
}
|
||||
t.Logf("Generated %d key shares", len(shares))
|
||||
|
||||
// Step 2: Prepare signing parameters
|
||||
messageHash := "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
|
||||
sessionID := fmt.Sprintf("test-sign-session-%d", time.Now().UnixNano())
|
||||
password := "test-password"
|
||||
|
||||
// Step 3: Start signing with 2 parties (index 0 and 1)
|
||||
// For tss-party.exe, we pass the signing threshold (t+1=2) and total (n=3)
|
||||
t.Log("Step 2: Starting sign process with 2 parties...")
|
||||
signingParties := []int{0, 1}
|
||||
|
||||
// Pass thresholdT+1=2 as the signing threshold to tss-party.exe
|
||||
signature, err := runSigningProcess(shares, signingParties, messageHash, sessionID, password, thresholdT+1, totalN, t)
|
||||
if err != nil {
|
||||
t.Fatalf("Signing failed: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("Step 3: Signing complete!")
|
||||
t.Logf("Signature (hex): %x", signature)
|
||||
t.Logf("Signature (base64): %s", base64.StdEncoding.EncodeToString(signature))
|
||||
}
|
||||
|
||||
// generateMockKeyShares generates key shares using tss-lib directly
|
||||
func generateMockKeyShares(threshold, total int) ([]*keygen.LocalPartySaveData, error) {
|
||||
fmt.Println("[Keygen] Starting key generation for", total, "parties with threshold", threshold)
|
||||
|
||||
partyIDs := make([]*tss.PartyID, total)
|
||||
for i := 0; i < total; i++ {
|
||||
partyIDs[i] = tss.NewPartyID(
|
||||
fmt.Sprintf("party-%d", i),
|
||||
fmt.Sprintf("party-%d", i),
|
||||
big.NewInt(int64(i+1)),
|
||||
)
|
||||
}
|
||||
|
||||
sortedPartyIDs := tss.SortPartyIDs(partyIDs)
|
||||
peerCtx := tss.NewPeerContext(sortedPartyIDs)
|
||||
|
||||
outChannels := make([]chan tss.Message, total)
|
||||
endChannels := make([]chan *keygen.LocalPartySaveData, total)
|
||||
parties := make([]tss.Party, total)
|
||||
|
||||
for i := 0; i < total; i++ {
|
||||
outChannels[i] = make(chan tss.Message, total*20)
|
||||
endChannels[i] = make(chan *keygen.LocalPartySaveData, 1)
|
||||
params := tss.NewParameters(tss.S256(), peerCtx, sortedPartyIDs[i], total, threshold)
|
||||
parties[i] = keygen.NewLocalParty(params, outChannels[i], endChannels[i])
|
||||
}
|
||||
|
||||
// Start all parties
|
||||
var wg sync.WaitGroup
|
||||
errChan := make(chan error, total)
|
||||
|
||||
for i := 0; i < total; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
if err := parties[idx].Start(); err != nil {
|
||||
errChan <- fmt.Errorf("party %d start error: %w", idx, err)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
results := make([]*keygen.LocalPartySaveData, total)
|
||||
var resultsMu sync.Mutex
|
||||
resultCount := 0
|
||||
|
||||
done := make(chan struct{})
|
||||
|
||||
// Message routing
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
default:
|
||||
for i := 0; i < total; i++ {
|
||||
select {
|
||||
case msg := <-outChannels[i]:
|
||||
wire, _, _ := msg.WireBytes()
|
||||
if msg.IsBroadcast() {
|
||||
for j := 0; j < total; j++ {
|
||||
if j != i {
|
||||
go func(destIdx int) {
|
||||
parsed, _ := tss.ParseWireMessage(wire, msg.GetFrom(), true)
|
||||
parties[destIdx].Update(parsed)
|
||||
}(j)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for _, to := range msg.GetTo() {
|
||||
for j := 0; j < total; j++ {
|
||||
if sortedPartyIDs[j].Id == to.Id {
|
||||
go func(destIdx int) {
|
||||
parsed, _ := tss.ParseWireMessage(wire, msg.GetFrom(), false)
|
||||
parties[destIdx].Update(parsed)
|
||||
}(j)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
case result := <-endChannels[i]:
|
||||
resultsMu.Lock()
|
||||
results[i] = result
|
||||
resultCount++
|
||||
fmt.Printf("[Keygen] Party %d completed\n", i)
|
||||
if resultCount == total {
|
||||
close(done)
|
||||
}
|
||||
resultsMu.Unlock()
|
||||
default:
|
||||
}
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
fmt.Println("[Keygen] All parties completed successfully")
|
||||
case <-time.After(5 * time.Minute):
|
||||
return nil, fmt.Errorf("keygen timeout")
|
||||
}
|
||||
|
||||
select {
|
||||
case err := <-errChan:
|
||||
return nil, err
|
||||
default:
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// runSigningProcess runs the signing process using tss-party.exe
|
||||
func runSigningProcess(
|
||||
shares []*keygen.LocalPartySaveData,
|
||||
signingPartyIndices []int,
|
||||
messageHash, sessionID, password string,
|
||||
thresholdT, thresholdN int,
|
||||
t *testing.T,
|
||||
) ([]byte, error) {
|
||||
// Build participants
|
||||
participants := make([]Participant, len(signingPartyIndices))
|
||||
for i, idx := range signingPartyIndices {
|
||||
participants[i] = Participant{
|
||||
PartyID: fmt.Sprintf("party-%d", idx),
|
||||
PartyIndex: idx,
|
||||
}
|
||||
}
|
||||
participantsJSON, _ := json.Marshal(participants)
|
||||
|
||||
// Prepare encrypted shares
|
||||
encryptedShares := make([]string, len(signingPartyIndices))
|
||||
for i, idx := range signingPartyIndices {
|
||||
shareBytes, _ := json.Marshal(shares[idx])
|
||||
encrypted := encryptShare(shareBytes, password)
|
||||
encryptedShares[i] = base64.StdEncoding.EncodeToString(encrypted)
|
||||
}
|
||||
|
||||
// Find tss-party executable
|
||||
exePath := "./tss-party.exe"
|
||||
if _, err := os.Stat(exePath); os.IsNotExist(err) {
|
||||
exePath = "./tss-party"
|
||||
if _, err := os.Stat(exePath); os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("tss-party executable not found")
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("Using executable: %s", exePath)
|
||||
|
||||
// Start processes
|
||||
processes := make([]*exec.Cmd, len(signingPartyIndices))
|
||||
stdinPipes := make([]io.WriteCloser, len(signingPartyIndices))
|
||||
stdoutPipes := make([]io.ReadCloser, len(signingPartyIndices))
|
||||
|
||||
for i, idx := range signingPartyIndices {
|
||||
args := []string{
|
||||
"sign",
|
||||
"-session-id", sessionID,
|
||||
"-party-id", fmt.Sprintf("party-%d", idx),
|
||||
"-party-index", fmt.Sprintf("%d", idx),
|
||||
"-threshold-t", fmt.Sprintf("%d", thresholdT),
|
||||
"-threshold-n", fmt.Sprintf("%d", thresholdN),
|
||||
"-participants", string(participantsJSON),
|
||||
"-message-hash", messageHash,
|
||||
"-share-data", encryptedShares[i],
|
||||
"-password", password,
|
||||
}
|
||||
|
||||
t.Logf("[Party %d] Starting with args: sign -session-id %s -party-id party-%d ...", idx, sessionID, idx)
|
||||
|
||||
cmd := exec.Command(exePath, args...)
|
||||
stdin, _ := cmd.StdinPipe()
|
||||
stdout, _ := cmd.StdoutPipe()
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
processes[i] = cmd
|
||||
stdinPipes[i] = stdin
|
||||
stdoutPipes[i] = stdout
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, fmt.Errorf("failed to start party %d: %w", idx, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Message routing between parties
|
||||
var wg sync.WaitGroup
|
||||
results := make([][]byte, len(signingPartyIndices))
|
||||
errors := make([]error, len(signingPartyIndices))
|
||||
|
||||
// Create a mutex for stdin writes
|
||||
stdinMutexes := make([]*sync.Mutex, len(signingPartyIndices))
|
||||
for i := range stdinMutexes {
|
||||
stdinMutexes[i] = &sync.Mutex{}
|
||||
}
|
||||
|
||||
for i := range signingPartyIndices {
|
||||
wg.Add(1)
|
||||
go func(partyIdx int) {
|
||||
defer wg.Done()
|
||||
scanner := bufio.NewScanner(stdoutPipes[partyIdx])
|
||||
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
var msg Message
|
||||
if err := json.Unmarshal([]byte(line), &msg); err != nil {
|
||||
t.Logf("[Party %d] Invalid JSON: %s", signingPartyIndices[partyIdx], line)
|
||||
continue
|
||||
}
|
||||
|
||||
switch msg.Type {
|
||||
case "progress":
|
||||
t.Logf("[Party %d] Progress: round %d/%d", signingPartyIndices[partyIdx], msg.Round, msg.TotalRounds)
|
||||
|
||||
case "outgoing":
|
||||
// tss-party.exe outputs "outgoing" messages that need to be routed to other parties
|
||||
t.Logf("[Party %d] Outgoing message (broadcast=%v, toParties=%v)", signingPartyIndices[partyIdx], msg.IsBroadcast, msg.ToParties)
|
||||
// Route to other parties
|
||||
for j := range signingPartyIndices {
|
||||
if j != partyIdx {
|
||||
// If not broadcast, check if this party is in the ToParties list
|
||||
if !msg.IsBroadcast && len(msg.ToParties) > 0 {
|
||||
targetPartyID := fmt.Sprintf("party-%d", signingPartyIndices[j])
|
||||
found := false
|
||||
for _, to := range msg.ToParties {
|
||||
if to == targetPartyID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// tss-party.exe expects "incoming" messages
|
||||
msgToSend := Message{
|
||||
Type: "incoming",
|
||||
IsBroadcast: msg.IsBroadcast,
|
||||
Payload: msg.Payload,
|
||||
FromPartyIndex: signingPartyIndices[partyIdx],
|
||||
}
|
||||
data, _ := json.Marshal(msgToSend)
|
||||
|
||||
stdinMutexes[j].Lock()
|
||||
stdinPipes[j].Write(append(data, '\n'))
|
||||
stdinMutexes[j].Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
case "result":
|
||||
t.Logf("[Party %d] Got result!", signingPartyIndices[partyIdx])
|
||||
signature, _ := base64.StdEncoding.DecodeString(msg.Payload)
|
||||
results[partyIdx] = signature
|
||||
|
||||
case "error":
|
||||
t.Logf("[Party %d] Error: %s", signingPartyIndices[partyIdx], msg.Error)
|
||||
errors[partyIdx] = fmt.Errorf("party error: %s", msg.Error)
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
t.Logf("[Party %d] Scanner error: %v", signingPartyIndices[partyIdx], err)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Wait for processes to complete
|
||||
for i, cmd := range processes {
|
||||
if err := cmd.Wait(); err != nil {
|
||||
t.Logf("[Party %d] Process exit error: %v", signingPartyIndices[i], err)
|
||||
}
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Check for errors
|
||||
for i, err := range errors {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("party %d error: %w", signingPartyIndices[i], err)
|
||||
}
|
||||
}
|
||||
|
||||
// Return first result
|
||||
for _, result := range results {
|
||||
if result != nil {
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no signature received")
|
||||
}
|
||||
|
|
@ -8,20 +8,30 @@ import (
|
|||
"bufio"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"os"
|
||||
"os/signal"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/bnb-chain/tss-lib/v2/common"
|
||||
"github.com/bnb-chain/tss-lib/v2/ecdsa/keygen"
|
||||
"github.com/bnb-chain/tss-lib/v2/ecdsa/signing"
|
||||
"github.com/bnb-chain/tss-lib/v2/tss"
|
||||
)
|
||||
|
||||
// Regex to extract round number from tss-lib message type
|
||||
// Message types look like: "binance.tsslib.ecdsa.keygen.KGRound1Message"
|
||||
// or "binance.tsslib.ecdsa.signing.SignRound3Message"
|
||||
var roundRegex = regexp.MustCompile(`Round(\d+)`)
|
||||
|
||||
// Message types for IPC
|
||||
type Message struct {
|
||||
Type string `json:"type"`
|
||||
|
|
@ -118,9 +128,73 @@ func runKeygen() {
|
|||
}
|
||||
|
||||
func runSign() {
|
||||
// TODO: Implement signing
|
||||
sendError("Signing not implemented yet")
|
||||
os.Exit(1)
|
||||
// Parse sign flags
|
||||
fs := flag.NewFlagSet("sign", flag.ExitOnError)
|
||||
sessionID := fs.String("session-id", "", "Session ID")
|
||||
partyID := fs.String("party-id", "", "Party ID")
|
||||
partyIndex := fs.Int("party-index", 0, "Party index (0-based)")
|
||||
thresholdT := fs.Int("threshold-t", 0, "Threshold T")
|
||||
thresholdN := fs.Int("threshold-n", 0, "Threshold N (total parties in keygen)")
|
||||
participantsJSON := fs.String("participants", "[]", "Participants JSON array")
|
||||
messageHash := fs.String("message-hash", "", "Message hash to sign (hex encoded)")
|
||||
shareData := fs.String("share-data", "", "Encrypted share data (base64 encoded)")
|
||||
password := fs.String("password", "", "Password to decrypt share")
|
||||
|
||||
if err := fs.Parse(os.Args[2:]); err != nil {
|
||||
sendError(fmt.Sprintf("Failed to parse flags: %v", err))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if *sessionID == "" || *partyID == "" || *thresholdT == 0 || *thresholdN == 0 {
|
||||
sendError("Missing required parameters")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if *messageHash == "" {
|
||||
sendError("Missing message hash")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if *shareData == "" {
|
||||
sendError("Missing share data")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Parse participants
|
||||
var participants []Participant
|
||||
if err := json.Unmarshal([]byte(*participantsJSON), &participants); err != nil {
|
||||
sendError(fmt.Sprintf("Failed to parse participants: %v", err))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Note: For signing, participant count equals threshold T (not N)
|
||||
// because only T parties participate in signing
|
||||
if len(participants) != *thresholdT {
|
||||
sendError(fmt.Sprintf("Participant count mismatch: got %d, expected %d (threshold T)", len(participants), *thresholdT))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Run sign protocol
|
||||
result, err := executeSign(
|
||||
*sessionID,
|
||||
*partyID,
|
||||
*partyIndex,
|
||||
*thresholdT,
|
||||
*thresholdN,
|
||||
participants,
|
||||
*messageHash,
|
||||
*shareData,
|
||||
*password,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
sendError(fmt.Sprintf("Sign failed: %v", err))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Send result
|
||||
sendSignResult(result.Signature, result.RecoveryID, *partyIndex)
|
||||
}
|
||||
|
||||
type keygenResult struct {
|
||||
|
|
@ -128,6 +202,11 @@ type keygenResult struct {
|
|||
EncryptedShare []byte
|
||||
}
|
||||
|
||||
type signResult struct {
|
||||
Signature []byte
|
||||
RecoveryID int
|
||||
}
|
||||
|
||||
func executeKeygen(
|
||||
sessionID, partyID string,
|
||||
partyIndex, thresholdT, thresholdN int,
|
||||
|
|
@ -169,8 +248,14 @@ func executeKeygen(
|
|||
sortedPartyIDs := tss.SortPartyIDs(tssPartyIDs)
|
||||
|
||||
// Create peer context and parameters
|
||||
// IMPORTANT: TSS-lib threshold convention: threshold=t means (t+1) signers required
|
||||
// User says "2-of-3" meaning 2 signers needed, so we pass (thresholdT-1) to TSS-lib
|
||||
// For 2-of-3: thresholdT=2, tss-lib threshold=1, signers_needed=1+1=2 ✓
|
||||
peerCtx := tss.NewPeerContext(sortedPartyIDs)
|
||||
params := tss.NewParameters(tss.S256(), peerCtx, selfTSSID, len(sortedPartyIDs), thresholdT)
|
||||
tssThreshold := thresholdT - 1
|
||||
params := tss.NewParameters(tss.S256(), peerCtx, selfTSSID, len(sortedPartyIDs), tssThreshold)
|
||||
fmt.Fprintf(os.Stderr, "[TSS-KEYGEN] NewParameters: partyCount=%d, tssThreshold=%d (from thresholdT=%d, means %d signers needed)\n",
|
||||
len(sortedPartyIDs), tssThreshold, thresholdT, thresholdT)
|
||||
|
||||
// Create channels
|
||||
outCh := make(chan tss.Message, thresholdN*10)
|
||||
|
|
@ -212,7 +297,7 @@ func executeKeygen(
|
|||
if !ok {
|
||||
return
|
||||
}
|
||||
handleOutgoingMessage(msg)
|
||||
handleOutgoingMessage(msg, true) // isKeygen = true
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
|
@ -243,7 +328,7 @@ func executeKeygen(
|
|||
}
|
||||
}()
|
||||
|
||||
// Track progress
|
||||
// Track progress (final completion reporting)
|
||||
totalRounds := 4 // GG20 keygen has 4 rounds
|
||||
|
||||
// Wait for completion
|
||||
|
|
@ -279,7 +364,20 @@ func executeKeygen(
|
|||
}
|
||||
}
|
||||
|
||||
func handleOutgoingMessage(msg tss.Message) {
|
||||
// extractRoundFromMessageType parses the round number from a tss-lib message type string.
|
||||
// Returns 0 if parsing fails (safe fallback).
|
||||
// Example: "binance.tsslib.ecdsa.keygen.KGRound2Message1" -> 2
|
||||
func extractRoundFromMessageType(msgType string) int {
|
||||
matches := roundRegex.FindStringSubmatch(msgType)
|
||||
if len(matches) >= 2 {
|
||||
if round, err := strconv.Atoi(matches[1]); err == nil {
|
||||
return round
|
||||
}
|
||||
}
|
||||
return 0 // Safe fallback - doesn't affect protocol, just shows 0 in UI
|
||||
}
|
||||
|
||||
func handleOutgoingMessage(msg tss.Message, isKeygen bool) {
|
||||
msgBytes, _, err := msg.WireBytes()
|
||||
if err != nil {
|
||||
return
|
||||
|
|
@ -301,6 +399,14 @@ func handleOutgoingMessage(msg tss.Message) {
|
|||
|
||||
data, _ := json.Marshal(outMsg)
|
||||
fmt.Println(string(data))
|
||||
|
||||
// Extract current round from message type and send progress update
|
||||
totalRounds := 4 // GG20 keygen has 4 rounds
|
||||
if !isKeygen {
|
||||
totalRounds = 9 // GG20 signing has 9 rounds
|
||||
}
|
||||
currentRound := extractRoundFromMessageType(msg.Type())
|
||||
sendProgress(currentRound, totalRounds)
|
||||
}
|
||||
|
||||
func handleIncomingMessage(
|
||||
|
|
@ -404,3 +510,266 @@ func sendResult(publicKey, encryptedShare []byte, partyIndex int) {
|
|||
data, _ := json.Marshal(msg)
|
||||
fmt.Println(string(data))
|
||||
}
|
||||
|
||||
func sendSignResult(signature []byte, recoveryID int, partyIndex int) {
|
||||
// Append recovery ID to signature (r + s + v = 64 + 1 = 65 bytes)
|
||||
// This is needed for EVM transaction signing
|
||||
signatureWithV := make([]byte, len(signature)+1)
|
||||
copy(signatureWithV, signature)
|
||||
signatureWithV[len(signature)] = byte(recoveryID)
|
||||
|
||||
msg := Message{
|
||||
Type: "result",
|
||||
Payload: base64.StdEncoding.EncodeToString(signatureWithV),
|
||||
PartyIndex: partyIndex,
|
||||
}
|
||||
data, _ := json.Marshal(msg)
|
||||
fmt.Println(string(data))
|
||||
}
|
||||
|
||||
func decryptShare(encryptedData []byte, password string) ([]byte, error) {
|
||||
// Match the encryption format: first 32 bytes are password hash, rest is data
|
||||
if len(encryptedData) < 32 {
|
||||
return nil, fmt.Errorf("encrypted data too short")
|
||||
}
|
||||
|
||||
// Verify password (simple check - matches encryptShare)
|
||||
expectedHash := hashPassword(password)
|
||||
actualHash := encryptedData[:32]
|
||||
|
||||
// Simple comparison
|
||||
match := true
|
||||
for i := 0; i < 32; i++ {
|
||||
if expectedHash[i] != actualHash[i] {
|
||||
match = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !match {
|
||||
return nil, fmt.Errorf("incorrect password")
|
||||
}
|
||||
|
||||
return encryptedData[32:], nil
|
||||
}
|
||||
|
||||
func executeSign(
|
||||
sessionID, partyID string,
|
||||
partyIndex, thresholdT, thresholdN int,
|
||||
participants []Participant,
|
||||
messageHashHex string,
|
||||
shareDataBase64 string,
|
||||
password string,
|
||||
) (*signResult, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
// Handle signals for graceful shutdown
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||
go func() {
|
||||
<-sigChan
|
||||
cancel()
|
||||
}()
|
||||
|
||||
// Decode and decrypt share data
|
||||
encryptedShare, err := base64.StdEncoding.DecodeString(shareDataBase64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode share data: %w", err)
|
||||
}
|
||||
|
||||
shareBytes, err := decryptShare(encryptedShare, password)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt share: %w", err)
|
||||
}
|
||||
|
||||
// Parse keygen save data
|
||||
var keygenData keygen.LocalPartySaveData
|
||||
if err := json.Unmarshal(shareBytes, &keygenData); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse keygen data: %w", err)
|
||||
}
|
||||
|
||||
// Decode message hash
|
||||
messageHash, err := hex.DecodeString(messageHashHex)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode message hash: %w", err)
|
||||
}
|
||||
|
||||
if len(messageHash) != 32 {
|
||||
return nil, fmt.Errorf("message hash must be 32 bytes, got %d", len(messageHash))
|
||||
}
|
||||
|
||||
msgBigInt := new(big.Int).SetBytes(messageHash)
|
||||
|
||||
// Create TSS party IDs for signing participants
|
||||
// IMPORTANT: For tss-lib signing, we must reconstruct the party IDs in the same way
|
||||
// as during keygen. The signing subset (T parties) must use their original keys from keygen.
|
||||
//
|
||||
// The keygenData.Ks contains the public keys for all N parties from keygen.
|
||||
// We need to create party IDs that match the original keygen party structure,
|
||||
// but only include the T parties that are participating in this signing session.
|
||||
|
||||
// Create party IDs only for the signing participants
|
||||
tssPartyIDs := make([]*tss.PartyID, 0, len(participants))
|
||||
var selfTSSID *tss.PartyID
|
||||
|
||||
for _, p := range participants {
|
||||
// Use the keygen key at this party's index
|
||||
// The party key in tss-lib uses the key's big.Int representation
|
||||
partyKey := tss.NewPartyID(
|
||||
p.PartyID,
|
||||
fmt.Sprintf("party-%d", p.PartyIndex),
|
||||
big.NewInt(int64(p.PartyIndex+1)),
|
||||
)
|
||||
tssPartyIDs = append(tssPartyIDs, partyKey)
|
||||
if p.PartyID == partyID {
|
||||
selfTSSID = partyKey
|
||||
}
|
||||
}
|
||||
|
||||
if selfTSSID == nil {
|
||||
return nil, fmt.Errorf("self party not found in participants")
|
||||
}
|
||||
|
||||
// Sort party IDs (important for tss-lib)
|
||||
sortedPartyIDs := tss.SortPartyIDs(tssPartyIDs)
|
||||
|
||||
// Create peer context and parameters
|
||||
// IMPORTANT: TSS-lib threshold convention: threshold=t means (t+1) signers required
|
||||
// User says "2-of-3" meaning 2 signers needed, so we pass (thresholdT-1) to TSS-lib
|
||||
// This MUST match keygen exactly!
|
||||
// For 2-of-3: thresholdT=2, tss-lib threshold=1, signers_needed=1+1=2 ✓
|
||||
peerCtx := tss.NewPeerContext(sortedPartyIDs)
|
||||
tssThreshold := thresholdT - 1
|
||||
params := tss.NewParameters(tss.S256(), peerCtx, selfTSSID, len(sortedPartyIDs), tssThreshold)
|
||||
fmt.Fprintf(os.Stderr, "[TSS-SIGN] NewParameters: partyCount=%d, tssThreshold=%d (from thresholdT=%d, means %d signers needed)\n",
|
||||
len(sortedPartyIDs), tssThreshold, thresholdT, thresholdT)
|
||||
|
||||
// CRITICAL: Build a subset of the keygen save data for the current signing parties
|
||||
// This is required when signing with a subset of the original keygen participants.
|
||||
// BuildLocalSaveDataSubset filters the Ks, BigXj, NTildej, H1j, H2j, and PaillierPKs
|
||||
// arrays to only include data for the participating signers.
|
||||
fmt.Fprintf(os.Stderr, "[TSS-SIGN] Original keygenData has %d parties (Ks length)\n", len(keygenData.Ks))
|
||||
fmt.Fprintf(os.Stderr, "[TSS-SIGN] Building subset for %d signing parties\n", len(sortedPartyIDs))
|
||||
|
||||
// Debug: print keygenData.Ks keys
|
||||
for i, k := range keygenData.Ks {
|
||||
keyHex := hex.EncodeToString(k.Bytes())
|
||||
fmt.Fprintf(os.Stderr, "[TSS-SIGN] keygenData.Ks[%d] = %s\n", i, keyHex)
|
||||
}
|
||||
// Debug: print sortedPartyIDs keys
|
||||
for i, p := range sortedPartyIDs {
|
||||
keyHex := hex.EncodeToString(p.Key)
|
||||
idPrefix := p.Id
|
||||
if len(idPrefix) > 8 {
|
||||
idPrefix = idPrefix[:8]
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "[TSS-SIGN] sortedPartyIDs[%d]: Id=%s, Moniker=%s, Key=%s\n",
|
||||
i, idPrefix, p.Moniker, keyHex)
|
||||
}
|
||||
|
||||
subsetKeygenData := keygen.BuildLocalSaveDataSubset(keygenData, sortedPartyIDs)
|
||||
fmt.Fprintf(os.Stderr, "[TSS-SIGN] Subset keygenData has %d parties (Ks length)\n", len(subsetKeygenData.Ks))
|
||||
|
||||
// Create channels
|
||||
outCh := make(chan tss.Message, thresholdT*10)
|
||||
endCh := make(chan *common.SignatureData, 1)
|
||||
errCh := make(chan error, 1)
|
||||
|
||||
// Create local party for signing with the SUBSET keygen data
|
||||
localParty := signing.NewLocalParty(msgBigInt, params, subsetKeygenData, outCh, endCh)
|
||||
|
||||
// Build party index map for incoming messages
|
||||
partyIndexMap := make(map[int]*tss.PartyID)
|
||||
for _, p := range sortedPartyIDs {
|
||||
for _, orig := range participants {
|
||||
if orig.PartyID == p.Id {
|
||||
partyIndexMap[orig.PartyIndex] = p
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start the local party
|
||||
go func() {
|
||||
if err := localParty.Start(); err != nil {
|
||||
errCh <- err
|
||||
}
|
||||
}()
|
||||
|
||||
// Handle outgoing messages
|
||||
var outWg sync.WaitGroup
|
||||
outWg.Add(1)
|
||||
go func() {
|
||||
defer outWg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case msg, ok := <-outCh:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
handleOutgoingMessage(msg, false) // isKeygen = false (signing)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Handle incoming messages from stdin
|
||||
var inWg sync.WaitGroup
|
||||
inWg.Add(1)
|
||||
go func() {
|
||||
defer inWg.Done()
|
||||
scanner := bufio.NewScanner(os.Stdin)
|
||||
// 增加 buffer 大小到 1MB
|
||||
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
|
||||
for scanner.Scan() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
var msg Message
|
||||
if err := json.Unmarshal(scanner.Bytes(), &msg); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if msg.Type == "incoming" {
|
||||
handleIncomingMessage(msg, localParty, partyIndexMap, errCh)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Track progress - GG20 signing has 9 rounds
|
||||
totalRounds := 9
|
||||
|
||||
// Wait for completion
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case err := <-errCh:
|
||||
return nil, err
|
||||
case sigData := <-endCh:
|
||||
// Signing completed successfully
|
||||
sendProgress(totalRounds, totalRounds)
|
||||
|
||||
// Construct signature in DER format or raw R||S format
|
||||
// sigData contains R, S, and recovery ID
|
||||
rBytes := sigData.R
|
||||
sBytes := sigData.S
|
||||
|
||||
// Create raw signature: R (32 bytes) || S (32 bytes)
|
||||
signature := make([]byte, 64)
|
||||
copy(signature[32-len(rBytes):32], rBytes)
|
||||
copy(signature[64-len(sBytes):64], sBytes)
|
||||
|
||||
// Recovery ID for Ethereum-style signatures
|
||||
recoveryID := int(sigData.SignatureRecovery[0])
|
||||
|
||||
return &signResult{
|
||||
Signature: signature,
|
||||
RecoveryID: recoveryID,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -171,3 +171,32 @@ func (c *MessageRouterClient) PublishSessionStarted(
|
|||
|
||||
return c.PublishSessionEvent(ctx, event)
|
||||
}
|
||||
|
||||
// PublishParticipantJoined publishes a participant_joined event to all parties in the session
|
||||
// This notifies the initiator and other participants that a new party has joined
|
||||
func (c *MessageRouterClient) PublishParticipantJoined(
|
||||
ctx context.Context,
|
||||
sessionID string,
|
||||
partyID string,
|
||||
selectedParties []string,
|
||||
joinedAt int64,
|
||||
) error {
|
||||
logger.Info("Publishing participant_joined event to Message Router",
|
||||
zap.String("session_id", sessionID),
|
||||
zap.String("joined_party_id", partyID),
|
||||
zap.Strings("notify_parties", selectedParties),
|
||||
zap.Int64("joined_at", joinedAt))
|
||||
|
||||
event := &router.SessionEvent{
|
||||
EventId: uuid.New().String(),
|
||||
EventType: "participant_joined",
|
||||
SessionId: sessionID,
|
||||
SelectedParties: selectedParties,
|
||||
CreatedAt: joinedAt,
|
||||
// Note: We could add a custom field for the joined party ID, but for now
|
||||
// the event itself indicates someone joined. The initiator can refresh
|
||||
// their participant list via API if needed.
|
||||
}
|
||||
|
||||
return c.PublishSessionEvent(ctx, event)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ package use_cases
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
|
@ -16,6 +18,9 @@ import (
|
|||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Maximum retries for optimistic lock conflicts during join session
|
||||
const joinSessionMaxRetries = 3
|
||||
|
||||
// JoinSessionMessageRouterClient defines the interface for publishing session events via gRPC
|
||||
type JoinSessionMessageRouterClient interface {
|
||||
PublishSessionStarted(
|
||||
|
|
@ -27,6 +32,16 @@ type JoinSessionMessageRouterClient interface {
|
|||
joinTokens map[string]string,
|
||||
startedAt int64,
|
||||
) error
|
||||
|
||||
// PublishParticipantJoined broadcasts a participant_joined event to all parties in the session
|
||||
// This allows the initiator's waiting screen to update in real-time when participants join
|
||||
PublishParticipantJoined(
|
||||
ctx context.Context,
|
||||
sessionID string,
|
||||
partyID string,
|
||||
selectedParties []string,
|
||||
joinedAt int64,
|
||||
) error
|
||||
}
|
||||
|
||||
// JoinSessionUseCase implements the join session use case
|
||||
|
|
@ -54,11 +69,35 @@ func NewJoinSessionUseCase(
|
|||
}
|
||||
}
|
||||
|
||||
// Execute executes the join session use case
|
||||
// Execute executes the join session use case with retry logic for optimistic lock conflicts
|
||||
func (uc *JoinSessionUseCase) Execute(
|
||||
ctx context.Context,
|
||||
inputData input.JoinSessionInput,
|
||||
) (*input.JoinSessionOutput, error) {
|
||||
return uc.executeWithRetry(ctx, inputData, 0)
|
||||
}
|
||||
|
||||
// executeWithRetry executes the join session with retry logic for optimistic lock conflicts
|
||||
func (uc *JoinSessionUseCase) executeWithRetry(
|
||||
ctx context.Context,
|
||||
inputData input.JoinSessionInput,
|
||||
retry int,
|
||||
) (*input.JoinSessionOutput, error) {
|
||||
if retry >= joinSessionMaxRetries {
|
||||
logger.Error("max retries exceeded for optimistic lock in join session",
|
||||
zap.String("session_id", inputData.SessionID.String()),
|
||||
zap.String("party_id", inputData.PartyID),
|
||||
zap.Int("retry_count", retry))
|
||||
return nil, fmt.Errorf("max retries exceeded: %w", entities.ErrOptimisticLockConflict)
|
||||
}
|
||||
|
||||
if retry > 0 {
|
||||
logger.Info("retrying join session due to optimistic lock conflict",
|
||||
zap.String("session_id", inputData.SessionID.String()),
|
||||
zap.String("party_id", inputData.PartyID),
|
||||
zap.Int("retry_attempt", retry))
|
||||
}
|
||||
|
||||
// Debug: log token info
|
||||
tokenLen := len(inputData.JoinToken)
|
||||
tokenPreview := ""
|
||||
|
|
@ -102,7 +141,7 @@ func (uc *JoinSessionUseCase) Execute(
|
|||
return nil, err
|
||||
}
|
||||
|
||||
// 3. Load session
|
||||
// 3. Load session (fresh read for each retry attempt)
|
||||
session, err := uc.sessionRepo.FindByUUID(ctx, sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -229,24 +268,67 @@ func (uc *JoinSessionUseCase) Execute(
|
|||
}
|
||||
}
|
||||
|
||||
// 8. Save updated session
|
||||
// 8. Save updated session (with optimistic lock retry)
|
||||
if err := uc.sessionRepo.Update(ctx, session); err != nil {
|
||||
// Check if this is an optimistic lock conflict - if so, retry
|
||||
if errors.Is(err, entities.ErrOptimisticLockConflict) {
|
||||
logger.Warn("optimistic lock conflict detected in join session, retrying",
|
||||
zap.String("session_id", session.ID.String()),
|
||||
zap.String("party_id", inputData.PartyID),
|
||||
zap.Int("retry_attempt", retry+1))
|
||||
return uc.executeWithRetry(ctx, inputData, retry+1)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 9. Publish participant joined event
|
||||
// 9. Publish participant joined event to internal message broker
|
||||
joinedAt := time.Now().UnixMilli()
|
||||
event := output.ParticipantJoinedEvent{
|
||||
SessionID: session.ID.String(),
|
||||
PartyID: inputData.PartyID,
|
||||
JoinedAt: time.Now().UnixMilli(),
|
||||
JoinedAt: joinedAt,
|
||||
}
|
||||
if err := uc.eventPublisher.PublishEvent(ctx, output.TopicParticipantJoined, event); err != nil {
|
||||
logger.Error("failed to publish participant joined event",
|
||||
logger.Error("failed to publish participant joined event to internal broker",
|
||||
zap.String("session_id", session.ID.String()),
|
||||
zap.String("party_id", inputData.PartyID),
|
||||
zap.Error(err))
|
||||
}
|
||||
|
||||
// 9.1 Publish participant joined event via gRPC to message-router (for real-time UI updates)
|
||||
// This notifies the initiator and other participants that a new party has joined
|
||||
if uc.messageRouterClient != nil {
|
||||
// Get all party IDs in the session to notify them
|
||||
allPartyIDs := session.GetPartyIDs()
|
||||
logger.Info("Broadcasting participant_joined event via gRPC",
|
||||
zap.String("session_id", session.ID.String()),
|
||||
zap.String("joined_party_id", inputData.PartyID),
|
||||
zap.Strings("notify_parties", allPartyIDs),
|
||||
zap.Int("total_participants", len(session.Participants)))
|
||||
|
||||
if err := uc.messageRouterClient.PublishParticipantJoined(
|
||||
ctx,
|
||||
session.ID.String(),
|
||||
inputData.PartyID,
|
||||
allPartyIDs,
|
||||
joinedAt,
|
||||
); err != nil {
|
||||
logger.Error("failed to publish participant joined event to message router",
|
||||
zap.String("session_id", session.ID.String()),
|
||||
zap.String("party_id", inputData.PartyID),
|
||||
zap.Error(err))
|
||||
} else {
|
||||
logger.Info("Successfully published participant_joined event to message router",
|
||||
zap.String("session_id", session.ID.String()),
|
||||
zap.String("joined_party_id", inputData.PartyID),
|
||||
zap.Int("notify_count", len(allPartyIDs)))
|
||||
}
|
||||
} else {
|
||||
logger.Warn("messageRouterClient is nil, cannot broadcast participant_joined event",
|
||||
zap.String("session_id", session.ID.String()),
|
||||
zap.String("party_id", inputData.PartyID))
|
||||
}
|
||||
|
||||
// 10. Build response with other parties info
|
||||
otherParties := session.GetOtherParties(partyID)
|
||||
partyInfos := make([]input.PartyInfo, len(otherParties))
|
||||
|
|
|
|||
|
|
@ -98,6 +98,9 @@ func NewMPCSession(
|
|||
|
||||
// AddParticipant adds a participant to the session
|
||||
func (s *MPCSession) AddParticipant(p *Participant) error {
|
||||
// For sign sessions, the max participant check is handled at the API level
|
||||
// (co-managed uses T, persistent uses T+1)
|
||||
// Here we just prevent exceeding N which is the absolute maximum
|
||||
if len(s.Participants) >= s.Threshold.N() {
|
||||
return ErrSessionFull
|
||||
}
|
||||
|
|
@ -140,18 +143,31 @@ func (s *MPCSession) UpdateParticipantStatus(partyID value_objects.PartyID, stat
|
|||
|
||||
// CanStart checks if all participants have joined and the session can start
|
||||
func (s *MPCSession) CanStart() bool {
|
||||
if len(s.Participants) != s.Threshold.N() {
|
||||
return false
|
||||
// For keygen sessions (including co-managed keygen): must have exactly N participants
|
||||
if s.SessionType.IsKeygen() {
|
||||
if len(s.Participants) != s.Threshold.N() {
|
||||
return false
|
||||
}
|
||||
readyCount := 0
|
||||
for _, p := range s.Participants {
|
||||
if p.IsJoined() || p.IsReady() {
|
||||
readyCount++
|
||||
}
|
||||
}
|
||||
return readyCount == s.Threshold.N()
|
||||
}
|
||||
|
||||
readyCount := 0
|
||||
// For sign sessions: check all registered participants have joined
|
||||
// The number of participants was determined at session creation time (T or T+1)
|
||||
if len(s.Participants) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, p := range s.Participants {
|
||||
// Accept participants in either joined or ready status
|
||||
if p.IsJoined() || p.IsReady() {
|
||||
readyCount++
|
||||
if !p.IsJoined() && !p.IsReady() {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return readyCount == s.Threshold.N()
|
||||
return true
|
||||
}
|
||||
|
||||
// Start transitions the session to in_progress
|
||||
|
|
@ -257,7 +273,21 @@ func (s *MPCSession) MarkPartyReady(partyID string) error {
|
|||
|
||||
// AllPartiesReady checks if all participants are ready
|
||||
func (s *MPCSession) AllPartiesReady() bool {
|
||||
if len(s.Participants) != s.Threshold.N() {
|
||||
// For keygen sessions: must have exactly N participants
|
||||
if s.SessionType.IsKeygen() {
|
||||
if len(s.Participants) != s.Threshold.N() {
|
||||
return false
|
||||
}
|
||||
for _, p := range s.Participants {
|
||||
if !p.IsReady() && !p.IsCompleted() {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// For sign sessions: check all registered participants are ready
|
||||
if len(s.Participants) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, p := range s.Participants {
|
||||
|
|
|
|||
|
|
@ -27,9 +27,33 @@ func (s *SessionCoordinatorService) ValidateSessionCreation(
|
|||
return entities.ErrInvalidSessionType
|
||||
}
|
||||
|
||||
// Allow either exact participant count (pre-registered) or 0 (dynamic joining)
|
||||
if participantCount != 0 && participantCount != threshold.N() {
|
||||
return entities.ErrSessionFull
|
||||
// Validate participant count based on session type
|
||||
// For keygen: all n parties must participate (participantCount == n or 0 for dynamic)
|
||||
// For sign: at least t parties required, can have up to n (participantCount >= t && <= n)
|
||||
// - Co-managed sign uses exactly T parties
|
||||
// - Persistent sign uses T+1 parties
|
||||
//
|
||||
// BREAKING CHANGE WARNING (for co-sign feature, commit 94ab63db):
|
||||
// Original code: participantCount == threshold.N() for ALL session types
|
||||
// New code: T <= participantCount <= N for sign sessions
|
||||
// This change affects PERSISTENT SIGN flow because we now pass keygenThresholdN
|
||||
// instead of len(parties) as threshold_n in CreateSigningSessionAuto.
|
||||
// If issues arise with persistent sign, REVERT to: participantCount == threshold.N()
|
||||
// Related files: session_coordinator_client.go, account_handler.go, mpc_session.go
|
||||
if participantCount != 0 {
|
||||
if sessionType == entities.SessionTypeSign {
|
||||
// Signing session: participant count must be at least t (threshold)
|
||||
// and at most n (total parties from keygen)
|
||||
if participantCount < threshold.T() || participantCount > threshold.N() {
|
||||
return entities.ErrSessionFull
|
||||
}
|
||||
} else {
|
||||
// Keygen session: participant count should equal threshold n
|
||||
// (all parties must participate in key generation)
|
||||
if participantCount != threshold.N() {
|
||||
return entities.ErrSessionFull
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if sessionType == entities.SessionTypeSign && len(messageHash) == 0 {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ import (
|
|||
"encoding/json"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"sync"
|
||||
"syscall/js"
|
||||
|
||||
|
|
@ -19,6 +21,11 @@ import (
|
|||
"github.com/bnb-chain/tss-lib/v2/tss"
|
||||
)
|
||||
|
||||
// Regex to extract round number from tss-lib message type
|
||||
// Message types look like: "binance.tsslib.ecdsa.keygen.KGRound1Message"
|
||||
// or "binance.tsslib.ecdsa.signing.SignRound3Message"
|
||||
var roundRegex = regexp.MustCompile(`Round(\d+)`)
|
||||
|
||||
// Global state for active sessions
|
||||
var (
|
||||
activeSessions = make(map[string]*TSSSession)
|
||||
|
|
@ -160,8 +167,11 @@ func startKeygen(this js.Value, args []js.Value) interface{} {
|
|||
sortedPartyIDs := tss.SortPartyIDs(tssPartyIDs)
|
||||
|
||||
// Create peer context and parameters
|
||||
// IMPORTANT: TSS-lib threshold convention: threshold=t means (t+1) signers required
|
||||
// User says "2-of-3" meaning 2 signers needed, so we pass (thresholdT-1) to TSS-lib
|
||||
peerCtx := tss.NewPeerContext(sortedPartyIDs)
|
||||
params := tss.NewParameters(tss.S256(), peerCtx, selfTSSID, len(sortedPartyIDs), thresholdT)
|
||||
tssThreshold := thresholdT - 1
|
||||
params := tss.NewParameters(tss.S256(), peerCtx, selfTSSID, len(sortedPartyIDs), tssThreshold)
|
||||
|
||||
// Build party index map
|
||||
session.PartyIndexMap = make(map[int]*tss.PartyID)
|
||||
|
|
@ -281,8 +291,11 @@ func startSigning(this js.Value, args []js.Value) interface{} {
|
|||
sortedPartyIDs := tss.SortPartyIDs(tssPartyIDs)
|
||||
|
||||
// Create peer context and parameters
|
||||
// IMPORTANT: TSS-lib threshold convention: threshold=t means (t+1) signers required
|
||||
// This MUST match keygen exactly! Both use (thresholdT-1)
|
||||
peerCtx := tss.NewPeerContext(sortedPartyIDs)
|
||||
params := tss.NewParameters(tss.S256(), peerCtx, selfTSSID, len(sortedPartyIDs), thresholdT)
|
||||
tssThreshold := thresholdT - 1
|
||||
params := tss.NewParameters(tss.S256(), peerCtx, selfTSSID, len(sortedPartyIDs), tssThreshold)
|
||||
|
||||
// Build party index map
|
||||
session.PartyIndexMap = make(map[int]*tss.PartyID)
|
||||
|
|
@ -298,8 +311,12 @@ func startSigning(this js.Value, args []js.Value) interface{} {
|
|||
// Create message hash as big.Int
|
||||
msgHashBig := new(big.Int).SetBytes(messageHash)
|
||||
|
||||
// Create local signing party
|
||||
session.LocalParty = signing.NewLocalParty(msgHashBig, params, saveData, session.OutCh, session.EndChSign)
|
||||
// CRITICAL: Build a subset of the keygen save data for the current signing parties
|
||||
// This is required when signing with a subset of the original keygen participants.
|
||||
subsetSaveData := keygen.BuildLocalSaveDataSubset(saveData, sortedPartyIDs)
|
||||
|
||||
// Create local signing party with the SUBSET save data
|
||||
session.LocalParty = signing.NewLocalParty(msgHashBig, params, subsetSaveData, session.OutCh, session.EndChSign)
|
||||
|
||||
// Store session
|
||||
sessionMutex.Lock()
|
||||
|
|
@ -391,11 +408,24 @@ func stopSession(this js.Value, args []js.Value) interface{} {
|
|||
return createSuccessResult(nil)
|
||||
}
|
||||
|
||||
// extractRoundFromMessageType parses the round number from a tss-lib message type string.
|
||||
// Returns 0 if parsing fails (safe fallback).
|
||||
// Example: "binance.tsslib.ecdsa.keygen.KGRound2Message1" -> 2
|
||||
func extractRoundFromMessageType(msgType string) int {
|
||||
matches := roundRegex.FindStringSubmatch(msgType)
|
||||
if len(matches) >= 2 {
|
||||
if round, err := strconv.Atoi(matches[1]); err == nil {
|
||||
return round
|
||||
}
|
||||
}
|
||||
return 0 // Safe fallback - doesn't affect protocol, just shows 0 in UI
|
||||
}
|
||||
|
||||
// handleOutgoingMessages processes messages from the TSS protocol
|
||||
func (s *TSSSession) handleOutgoingMessages() {
|
||||
totalRounds := 4
|
||||
totalRounds := 4 // GG20 keygen has 4 rounds
|
||||
if !s.IsKeygen {
|
||||
totalRounds = 6 // Signing has 6 rounds
|
||||
totalRounds = 9 // GG20 signing has 9 rounds (matching Electron and Android)
|
||||
}
|
||||
|
||||
for {
|
||||
|
|
@ -430,8 +460,9 @@ func (s *TSSSession) handleOutgoingMessages() {
|
|||
jsMsgJSON, _ := json.Marshal(jsMsg)
|
||||
s.OnMessage.Invoke(string(jsMsgJSON))
|
||||
|
||||
// Send progress update
|
||||
s.OnProgress.Invoke(totalRounds, totalRounds) // Simplified progress
|
||||
// Extract current round from message type and send progress update
|
||||
currentRound := extractRoundFromMessageType(msg.Type())
|
||||
s.OnProgress.Invoke(currentRound, totalRounds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,8 +43,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create app directory with correct ownership
|
||||
RUN mkdir -p /app && chown nestjs:nodejs /app
|
||||
# Create app directory and uploads directory with correct ownership
|
||||
RUN mkdir -p /app /app/uploads && chown -R nestjs:nodejs /app
|
||||
WORKDIR /app
|
||||
|
||||
# Switch to non-root user before installing dependencies
|
||||
|
|
@ -61,6 +61,13 @@ RUN DATABASE_URL="postgresql://user:pass@localhost:5432/db" npx prisma generate
|
|||
# Copy built files
|
||||
COPY --chown=nestjs:nodejs --from=builder /app/dist ./dist
|
||||
|
||||
# Create uploads directory with correct ownership (before volume mount)
|
||||
# This ensures the directory exists and has correct ownership
|
||||
# Note: When a named volume is mounted, if it's empty, Docker will copy the container's directory content to it
|
||||
USER root
|
||||
RUN mkdir -p /app/uploads && chown -R nestjs:nodejs /app/uploads
|
||||
USER nestjs
|
||||
|
||||
# Create startup script that runs migrations before starting the app
|
||||
RUN printf '%s\n' \
|
||||
'#!/bin/sh' \
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue