Compare commits
747 Commits
pre-signat
...
main
| Author | SHA1 | Date |
|---|---|---|
|
|
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 | |
|
|
7696f663a5 | |
|
|
ae936e8a87 | |
|
|
9015888b23 | |
|
|
f849a2a9fd | |
|
|
2a49ab771b | |
|
|
57b84bb9fa | |
|
|
e038f1784f | |
|
|
290b5ea766 | |
|
|
2164664ca0 | |
|
|
fd6f84ce82 | |
|
|
e114723ab0 | |
|
|
1c66b55ea1 | |
|
|
66c3cec9a5 | |
|
|
8c3a299714 | |
|
|
6de545fcb9 | |
|
|
75a2470233 | |
|
|
576679ae30 | |
|
|
c0e292535d | |
|
|
674bc9e5cd | |
|
|
fb1b27e36f | |
|
|
989364969d | |
|
|
1b48c05aa7 | |
|
|
422d7007b1 | |
|
|
c94f3e4d83 | |
|
|
aa9171ce2c | |
|
|
30ec0a1c8e | |
|
|
b0a698250d | |
|
|
072fbbad2c | |
|
|
9b9f6f143e | |
|
|
b48b59d946 | |
|
|
b938722ff6 | |
|
|
e72f96da10 | |
|
|
bd6537a2cb | |
|
|
dfead071ab | |
|
|
820a61793c | |
|
|
a22fc16313 | |
|
|
e222279d77 | |
|
|
48c8c071d5 | |
|
|
9bc48d19a9 | |
|
|
0ca37ee76a | |
|
|
df8a14211e | |
|
|
5f4c7c135f | |
|
|
a5ab2e8350 | |
|
|
af08f0f9c6 | |
|
|
21985abde5 | |
|
|
591dc50eb9 | |
|
|
19e366e0d9 | |
|
|
551f3d27d2 | |
|
|
b1234bc434 | |
|
|
be94a6ab18 | |
|
|
40a257e55c | |
|
|
e78b6e6dcb | |
|
|
4794cafdaa | |
|
|
28da7f6807 | |
|
|
73034c072c | |
|
|
de29fa4800 | |
|
|
81c8db9d50 | |
|
|
a2508ab0fd | |
|
|
761e03ebb0 | |
|
|
f5cbc855f6 | |
|
|
6b2b9e821e | |
|
|
eb283389c4 | |
|
|
59b0e2bb22 | |
|
|
3f25424049 | |
|
|
c97cd208ab | |
|
|
76ef8b0a8c | |
|
|
2a11392ce2 | |
|
|
e268c33fa9 | |
|
|
c457d15829 | |
|
|
a830a88cc3 | |
|
|
7cfaacc833 | |
|
|
47328c67d7 | |
|
|
15cbb2401f | |
|
|
e43500fc3f | |
|
|
7cec5b2b4c | |
|
|
1f476e8e5a | |
|
|
fcaa57605a | |
|
|
88370691d1 | |
|
|
8733e49735 | |
|
|
1908544698 | |
|
|
b3142387f7 | |
|
|
9e21d8c8cd | |
|
|
8a839b5e14 | |
|
|
68237d9905 | |
|
|
7e8113805d | |
|
|
0781c53101 | |
|
|
6c4a40c42d | |
|
|
75a9ffadef | |
|
|
912cc1eb8f | |
|
|
ba3a21d049 | |
|
|
8df2046a4e | |
|
|
cc3644de9d | |
|
|
1af5780b19 | |
|
|
e51450d9f9 | |
|
|
1b5bcf3fda | |
|
|
dc16a616a5 | |
|
|
c328d8b59b | |
|
|
fea01642e7 | |
|
|
9c7dc6f511 | |
|
|
fe593714ae | |
|
|
058849dc2c | |
|
|
a54a01bba0 | |
|
|
b20ec10c75 | |
|
|
f20ed32f5f | |
|
|
1694f37e91 | |
|
|
a8261e110a | |
|
|
3d68d1f6f6 | |
|
|
4e4d9f43f6 | |
|
|
2a929fc082 | |
|
|
339f95f7ed | |
|
|
9153ba3625 | |
|
|
8eecc4c55f | |
|
|
6216a1563a | |
|
|
4de96dac9d | |
|
|
e742a360ec | |
|
|
55bb129477 | |
|
|
c84516b222 | |
|
|
f143be9925 | |
|
|
2b5b80d299 | |
|
|
b20be7213c | |
|
|
aa180c54bc | |
|
|
abc3b358a7 | |
|
|
1cc53bd533 | |
|
|
e9b2917561 | |
|
|
91d3e65289 | |
|
|
0e85c2fd23 | |
|
|
7add51f5a3 | |
|
|
954f170bd4 | |
|
|
d4763ea5bf | |
|
|
dfecdc06e9 | |
|
|
ad5b153fa9 | |
|
|
2e65a92e04 | |
|
|
6c017d2086 | |
|
|
666be6ea60 | |
|
|
86103e4c4d | |
|
|
bdeff3b372 |
|
|
@ -426,7 +426,355 @@
|
|||
"Bash(git commit -m \"$\\(cat <<''EOF''\nfix\\(planting-service\\): 修复跨服务调用使用错误标识符导致的500错误\n\n问题根源:\n- getBalance 调用使用 userId.toString\\(\\) \\(纯数字如 \"14\"\\)\n- wallet-service 按 accountSequence 查找钱包失败后尝试创建新钱包\n- 但 userId 已存在,触发唯一约束冲突导致500错误\n\n修复内容:\n1. planting-application.service.ts:\n - createOrder: getBalance\\(userId.toString\\(\\)\\) → getBalance\\(accountSequence\\)\n - payOrder: getBalance\\(userId.toString\\(\\)\\) → getBalance\\(walletIdentifier\\)\n\n2. payment-compensation.service.ts:\n - 注入 IPlantingOrderRepository 获取订单的 accountSequence\n - handleUnfreeze/handleRetryConfirm 添加 accountSequence 参数\n\n3. wallet-service.client.ts:\n - ensureRegionAccounts 接口添加 provinceTeamAccount/cityTeamAccount 字段\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- 后端 wallet-service: getMyLedger API 返回 allocationType 字段\n- 前端流水明细: 显示权益类型名称(分享权益、省/市区域权益等)\n- 新增权益详情弹窗,点击权益记录可查看详细信息\n- 兑换页面: \"RMB/CNY提现\" 改为 \"提现\"\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(dir /b \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\")",
|
||||
"Bash(git commit -m \"$\\(cat <<''EOF''\nfix\\(reward-service\\): 权益分配memo显示触发用户ID\n\n所有权益类型的memo现在统一显示\"来自用户xxx的认种\"格式:\n- 省团队权益:来自用户xxx的认种\n- 省区域权益:来自用户xxx的认种\n- 市团队权益:来自用户xxx的认种\n- 市区域权益:来自用户xxx的认种\n- 社区权益:来自用户xxx的认种\n\n修改前只显示\"xx权益已激活\",现在与分享权益格式保持一致\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\\): 权益分配memo显示触发用户ID\n\n所有权益类型的memo现在统一显示\"来自用户xxx的认种\"格式:\n- 省团队权益:来自用户xxx的认种\n- 省区域权益:来自用户xxx的认种\n- 市团队权益:来自用户xxx的认种\n- 市区域权益:来自用户xxx的认种\n- 社区权益:来自用户xxx的认种\n\n修改前只显示\"xx权益已激活\",现在与分享权益格式保持一致\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(echo \"请运行以下命令查看 D25122600005 的认种记录:\n\ndocker exec -it rwa-postgres psql -U rwa_user -d rwa_planting -c \"\"\nSELECT order_no, account_sequence, tree_count, status, created_at\nFROM planting_orders\nWHERE account_sequence = ''D25122600005''\nORDER BY created_at DESC;\n\"\"\")",
|
||||
"Bash(git commit -m \"$\\(cat <<''EOF''\nfix\\(wallet-service\\): 修复社区权益根据 targetId 正确分配\n\n问题:社区权益\\(COMMUNITY_RIGHT\\)无论 targetId 是什么,都强制分配到\n总部账户 S0000000001,导致社区授权人无法在流水明细中看到社区权益。\n\n修复:\n- 将 allocateToHeadquartersCommunity 方法重命名为 allocateCommunityRight\n- 根据 targetId 判断分配目标:\n - D 开头(用户账户): 分配到社区授权人账户\n - S 开头或 ''1''(系统账户): 分配到总部社区账户\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- 新增\"权益收入\"筛选项\\(SYSTEM_ALLOCATION\\),用于筛选:\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(chcp 65001)",
|
||||
"Bash(cmd /c \"chcp 65001 && python -c \"\"import openpyxl; import sys; sys.stdout.reconfigure\\(encoding=''utf-8''\\); wb = openpyxl.load_workbook\\(r''c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\\\\榴莲皇后数据.xlsx''\\); print\\(''Sheets:'', wb.sheetnames\\); sheet = wb.active; print\\(''Rows:'', sheet.max_row, ''Cols:'', sheet.max_column\\); [print\\(f''Row {i}:'', row\\) for i, row in enumerate\\(sheet.iter_rows\\(max_row=5, values_only=True\\), 1\\)]\"\"\")",
|
||||
"Bash(node scripts/batch-register.js:*)",
|
||||
"Bash(node batch-register.js:*)",
|
||||
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(mobile-app\\): 隐藏\"我的团队\"功能,需秘密点击解锁\n\n- 默认隐藏\"我的团队\"树形组件\n- 在\"团队种植数\"区域连续点击19次后显示\n- 点击间隔超过1秒自动重置计数器\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''\nrefactor\\(mobile-app\\): 修改\"我的\"页面文案\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''\nrefactor\\(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(npx jest --testPathPattern=\"referral\" --passWithNoTests)",
|
||||
"Bash(npx jest --testPathPattern=\"wallet\" --passWithNoTests)",
|
||||
"Bash(git commit -m \"$\\(cat <<''EOF''\nrefactor\\(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''\nfeat\\(identity-service\\): 增强钱包生成可靠性,确保100%生成成功\n\n核心改进:\n- 基于数据库扫描代替Redis扫描,防止状态丢失后无法重试\n- 指数退避策略\\(1分钟→60分钟\\),无时间限制持续重试\n- 分布式锁保护,防止多实例/并发重复触发\n- getWalletStatus API 检测失败状态并自动触发重试\n\n修改内容:\n- RedisService: 添加 tryLock/unlock 分布式锁方法\n- UserAccountRepository: 添加 findUsersWithIncompleteWallets 查询\n- getWalletStatus: 增强状态检测,失败/超时时自动触发重试\n- WalletRetryTask: 完全重写,基于数据库驱动+指数退避\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 ls:*)",
|
||||
"Bash(tree:*)",
|
||||
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(co-managed-wallet\\): 添加分布式多方共管钱包创建功能\n\n## 功能概述\n实现分布式多方共管钱包创建功能,包括 Admin-Web 扩展和 Service-Party 桌面应用。\n\n## 主要变更\n\n### 1. Admin-Web 扩展 \\(前端\\)\n- 新增 CoManagedWalletSection 组件 \\(frontend/admin-web/src/components/features/co-managed-wallet/\\)\n- 在授权管理页面添加共管钱包入口卡片\n- 实现创建钱包向导: 配置 → 邀请 → 生成 → 完成\n- 包含组件: ThresholdConfig, InviteQRCode, ParticipantList, SessionProgress, WalletResult\n\n### 2. Admin-Service 后端 API\n- 新增共管钱包领域实体和枚举 \\(domain/entities/co-managed-wallet.entity.ts\\)\n- 新增 REST 控制器 \\(api/controllers/co-managed-wallet.controller.ts\\)\n- 新增服务层 \\(application/services/co-managed-wallet.service.ts\\)\n- 新增 Prisma 模型: CoManagedWalletSession, CoManagedWallet\n- 更新 app.module.ts 注册新模块\n\n### 3. Session Coordinator 扩展 \\(Go\\)\n- 新增会话类型: SessionTypeCoManagedKeygen \\(\"co_managed_keygen\"\\)\n- 扩展 MPCSession 实体添加 WalletName 和 InviteCode 字段\n- 更新 PostgreSQL 和 Redis 适配器支持新字段\n- 新增数据库迁移: 008_add_co_managed_wallet_fields\n\n### 4. Service-Party 桌面应用 \\(新项目\\)\n- 位置: backend/mpc-system/services/service-party-app/\n- 技术栈: Electron + React + TypeScript + Vite\n- 包含模块:\n - gRPC 客户端 \\(连接 Message Router\\)\n - TSS 处理器 \\(子进程方式运行 Go TSS 协议\\)\n - 本地加密存储 \\(AES-256-GCM\\)\n- 页面: Home, Join, Create, Session, Settings\n\n## 修改的现有文件 \\(便于回滚\\)\n\n1. backend/mpc-system/services/session-coordinator/domain/entities/mpc_session.go\n - 添加 SessionTypeCoManagedKeygen 常量\n - 添加 IsKeygen\\(\\) 方法\n - 添加 WalletName, InviteCode 字段\n - 更新 ReconstructSession, ToDTO, SessionDTO\n\n2. backend/mpc-system/services/session-coordinator/adapters/output/postgres/session_postgres_repo.go\n - 更新 SQL 查询包含 wallet_name, invite_code\n - 更新 Save, FindByUUID, FindByStatus 等方法\n - 更新 scanSessions, sessionRow\n\n3. backend/mpc-system/services/session-coordinator/adapters/output/redis/session_cache_adapter.go\n - 更新 sessionCacheEntry 结构\n - 更新 sessionToCacheEntry, cacheEntryToSession\n\n4. backend/services/admin-service/prisma/schema.prisma\n - 新增 WalletSessionStatus 枚举\n - 新增 CoManagedWalletSession, CoManagedWallet 模型\n\n5. backend/services/admin-service/src/app.module.ts\n - 导入并注册共管钱包相关组件\n\n6. frontend/admin-web/src/app/\\(dashboard\\)/authorization/page.tsx\n - 导入并添加 CoManagedWalletSection\n\n7. frontend/admin-web/src/infrastructure/api/endpoints.ts\n - 添加 CO_MANAGED_WALLETS API 端点\n\n## 回滚说明\n\n如需回滚此功能:\n1. 回滚数据库迁移: 运行 008_add_co_managed_wallet_fields.down.sql\n2. 删除新增文件夹:\n - backend/mpc-system/services/service-party-app/\n - frontend/admin-web/src/components/features/co-managed-wallet/\n - backend/services/admin-service/src/**/co-managed-wallet*\n3. 恢复修改的文件到前一个版本\n4. 运行 prisma generate 重新生成 Prisma 客户端\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 mod tidy:*)",
|
||||
"Bash(protoc:*)",
|
||||
"Bash(backend/services/admin-service/prisma/schema.prisma )",
|
||||
"Bash(backend/services/admin-service/src/app.module.ts )",
|
||||
"Bash(backend/services/admin-service/src/api/controllers/system-maintenance.controller.ts )",
|
||||
"Bash(backend/services/admin-service/src/api/dto/request/system-maintenance.dto.ts )",
|
||||
"Bash(backend/services/admin-service/src/api/dto/response/system-maintenance.dto.ts )",
|
||||
"Bash(backend/services/admin-service/src/api/interceptors/ )",
|
||||
"Bash(backend/services/admin-service/src/domain/entities/system-maintenance.entity.ts )",
|
||||
"Bash(backend/services/admin-service/src/domain/repositories/system-maintenance.repository.ts )",
|
||||
"Bash(backend/services/admin-service/src/infrastructure/persistence/repositories/system-maintenance.repository.impl.ts )",
|
||||
"Bash(frontend/admin-web/src/components/layout/Sidebar/Sidebar.tsx )",
|
||||
"Bash(frontend/admin-web/src/infrastructure/api/endpoints.ts )",
|
||||
"Bash(frontend/admin-web/src/services/maintenanceService.ts )",
|
||||
"Bash(\"frontend/admin-web/src/app/\\(dashboard\\)/maintenance/\" )",
|
||||
"Bash(frontend/mobile-app/lib/app.dart )",
|
||||
"Bash(frontend/mobile-app/lib/core/providers/ )",
|
||||
"Bash(frontend/mobile-app/lib/core/services/maintenance_service.dart )",
|
||||
"Bash(frontend/mobile-app/lib/features/auth/presentation/pages/splash_page.dart)",
|
||||
"Bash(frontend/mobile-app/lib/features/home/presentation/widgets/bottom_nav_bar.dart )",
|
||||
"Bash(frontend/mobile-app/lib/features/notification/presentation/pages/notification_inbox_page.dart )",
|
||||
"Bash(frontend/mobile-app/lib/features/profile/presentation/pages/profile_page.dart )",
|
||||
"Bash(frontend/mobile-app/lib/features/account/presentation/pages/account_switch_page.dart )",
|
||||
"Bash(frontend/mobile-app/lib/features/auth/presentation/providers/auth_provider.dart)",
|
||||
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(mobile-app,admin\\): 添加系统维护功能和通知徽章功能\n\n系统维护功能:\n- 后端: 添加系统维护配置实体、仓库和控制器\n- 后端: 添加维护模式拦截器,返回503状态码\n- admin-web: 添加系统维护管理页面,支持创建/编辑/开关维护配置\n- mobile-app: 添加维护状态检查服务和阻断弹窗\n- mobile-app: 在启动页、向导页集成维护检查\n- mobile-app: 支持App从后台恢复时自动检查维护状态\n\n通知徽章功能:\n- 添加通知徽章Provider,监听登录状态自动刷新\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\\(co-managed-wallet\\): 修复向后兼容性问题并完善protobuf定义\n\n## 变更概述\n根据用户反馈,将 Session Coordinator 的函数签名改为可选参数模式,\n确保新功能 100% 不影响现有的 keygen/sign 功能。\n\n## 主要变更\n\n### 1. Session Coordinator 向后兼容修复\n- 保留原有 `ReconstructSession` 函数签名不变\n- 新增 `ReconstructSessionOptions` 结构体存放可选参数\n- 新增 `ReconstructSessionWithOptions` 函数支持新字段\n- 原函数内部调用新函数,传入 nil options\n\n### 2. Protobuf 定义更新\n- CreateSessionRequest 新增字段:\n - wallet_name \\(field 10\\): 钱包名称\n - invite_code \\(field 11\\): 邀请码\n- SessionInfo 新增字段:\n - wallet_name \\(field 8\\): 钱包名称\n - invite_code \\(field 9\\): 邀请码\n- session_type 支持 \"co_managed_keygen\"\n\n### 3. TSS Party 子进程修复\n- 修复 tss.NewPartyID 参数类型错误 \\(big.Int\\)\n- 修复 go.mod 依赖问题 \\(ed25519 replace\\)\n- 删除未使用的变量\n\n### 4. 清理错误生成的文件\n- 删除 api/proto/*.pb.go \\(错误位置\\)\n- 保留 api/grpc/coordinator/v1/*.pb.go \\(正确位置\\)\n\n## 修改的文件\n\n| 文件 | 变更类型 | 说明 |\n|------|---------|------|\n| mpc_session.go | 修改 | 添加 ReconstructSessionWithOptions |\n| session_postgres_repo.go | 修改 | 使用新函数传入 options |\n| session_cache_adapter.go | 修改 | 使用新函数传入 options |\n| session_coordinator.proto | 修改 | 添加 wallet_name, invite_code 字段 |\n| session_coordinator.pb.go | 重新生成 | 包含新 protobuf 字段 |\n| tss-party/main.go | 修复 | NewPartyID 参数和未使用变量 |\n| tss-party/go.mod | 修复 | ed25519 依赖替换 |\n\n## 向后兼容性保证\n\n- 所有现有代码调用 ReconstructSession 无需任何修改\n- 数据库使用 COALESCE 处理 NULL 值\n- Protobuf 新字段使用高序号,不影响现有消息解析\n- **影响现有功能的风险: 0%**\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''\nchore\\(admin-service\\): 添加系统维护和共管钱包的数据库迁移\n\n添加缺失的 migration 文件,包含:\n- system_maintenances 表 \\(系统维护公告\\)\n- WalletSessionStatus 枚举\n- co_managed_wallet_sessions 表 \\(共管钱包会话\\)\n- co_managed_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 commit -m \"$\\(cat <<''EOF''\nfix\\(admin-service\\): 修复共管钱包 status 类型不匹配问题\n\n使用 Prisma 生成的类型替代手动定义的接口:\n- PrismaCoManagedWalletSession -> @prisma/client\n- PrismaCoManagedWallet -> @prisma/client\n- status 字段使用 PrismaWalletSessionStatus 枚举类型\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''\ndocs: 添加 Service Party App 技术文档\n\n添加分布式共管钱包桌面应用的详细技术文档,包括:\n\n- 应用概述和使用场景\n- 目录结构说明\n- 技术架构和技术栈\n- TSS 子进程架构设计\n- IPC 消息格式定义\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\\(admin-web\\): 修复系统维护\"立即激活\"按钮不显示的问题\n\n- 修复 getStatusTag 函数逻辑,未激活状态使用 ''inactive'' 样式而不是 ''expired''\n- 添加更细化的状态判断:维护中、已过期、已计划、未激活、待激活\n- 添加 inactive 标签样式(橙色背景)\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\\(migration\\): 使数据库迁移脚本幂等化,支持重复执行\n\n将 008_add_co_managed_wallet_fields.up.sql 改为幂等脚本:\n- 使用 DO $$... IF NOT EXISTS 检查列是否存在再添加\n- 使用 CREATE INDEX IF NOT EXISTS 创建索引\n- 使用 DROP CONSTRAINT IF EXISTS 删除约束\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\\(service-party-app\\): 添加 Windows 一键编译脚本\n\n添加 build-windows.bat 脚本,支持:\n- 检查 Node.js 和 Go 环境\n- 编译 TSS 子进程 \\(tss-party.exe\\)\n- 安装 npm 依赖\n- 编译 Electron 应用\n\n使用方法: 双击运行 build-windows.bat\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_modules/.bin/tsc:*)",
|
||||
"Bash(npm ls:*)",
|
||||
"Bash(npm run build:win:*)",
|
||||
"Bash(npm run clean:*)",
|
||||
"Bash(git cherry-pick:*)",
|
||||
"Bash(git stash:*)",
|
||||
"Bash(docker compose build:*)",
|
||||
"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 \")"
|
||||
],
|
||||
"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 - 授权服务
|
||||
|
|
@ -215,6 +224,10 @@ services:
|
|||
paths:
|
||||
- /api/v1/mobile/notifications
|
||||
strip_path: false
|
||||
- name: admin-mobile-system
|
||||
paths:
|
||||
- /api/v1/mobile/system
|
||||
strip_path: false
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Presence Service - 在线状态服务
|
||||
|
|
@ -246,6 +259,116 @@ services:
|
|||
- /api/v1/balance
|
||||
strip_path: false
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MPC Account Service - MPC 账户服务 (Go - 共管钱包)
|
||||
# ---------------------------------------------------------------------------
|
||||
- name: mpc-account-service
|
||||
url: http://192.168.1.111:4000
|
||||
routes:
|
||||
- name: mpc-co-managed
|
||||
paths:
|
||||
- /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 - 全局插件配置
|
||||
# =============================================================================
|
||||
|
|
@ -255,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
|
||||
|
|
@ -283,8 +408,8 @@ plugins:
|
|||
# 请求限流
|
||||
- name: rate-limiting
|
||||
config:
|
||||
minute: 100
|
||||
hour: 5000
|
||||
minute: 10000
|
||||
hour: 500000
|
||||
policy: local
|
||||
|
||||
# 请求日志
|
||||
|
|
|
|||
|
|
@ -0,0 +1,280 @@
|
|||
#!/bin/bash
|
||||
# =============================================================================
|
||||
# MPC gRPC 代理 - Nginx 配置安装脚本
|
||||
# =============================================================================
|
||||
# 用途: 为 Service Party App 提供 gRPC 连接到 Message Router
|
||||
# 域名: mpc-grpc.szaiai.com
|
||||
#
|
||||
# 前提条件:
|
||||
# 1. Nginx 已安装并运行
|
||||
# 2. Certbot 已安装
|
||||
# 3. DNS 已配置 mpc-grpc.szaiai.com 指向此服务器
|
||||
# 4. Message Router 在后端服务器 (192.168.1.111:50051) 运行
|
||||
#
|
||||
# 此脚本完全独立,不影响现有服务
|
||||
# =============================================================================
|
||||
|
||||
set -e
|
||||
|
||||
DOMAIN="mpc-grpc.szaiai.com"
|
||||
DOMAIN_CONF="${DOMAIN}.conf" # Nginx 配置文件需要 .conf 后缀
|
||||
EMAIL="admin@szaiai.com"
|
||||
BACKEND_HOST="192.168.1.111"
|
||||
BACKEND_PORT="50051"
|
||||
|
||||
# 颜色
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
log_info() { echo -e "${BLUE}[INFO]${NC} $1"; }
|
||||
log_success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; }
|
||||
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
|
||||
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
|
||||
|
||||
# 检查 root 权限
|
||||
check_root() {
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
log_error "请使用 root 权限运行: sudo ./install-mpc-grpc.sh"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 检查前提条件
|
||||
check_prerequisites() {
|
||||
log_info "检查前提条件..."
|
||||
|
||||
# 检查 Nginx
|
||||
if ! command -v nginx &> /dev/null; then
|
||||
log_error "Nginx 未安装,请先安装 Nginx"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 检查 Certbot
|
||||
if ! command -v certbot &> /dev/null; then
|
||||
log_error "Certbot 未安装,请先安装 Certbot"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 检查 Nginx 是否支持 http2 和 grpc
|
||||
if ! nginx -V 2>&1 | grep -q "http_v2_module"; then
|
||||
log_warn "Nginx 可能不支持 HTTP/2,gRPC 需要 HTTP/2 支持"
|
||||
fi
|
||||
|
||||
log_success "前提条件检查通过"
|
||||
}
|
||||
|
||||
# 步骤 1: 创建临时 HTTP 配置用于证书申请
|
||||
configure_http() {
|
||||
log_info "步骤 1/4: 创建临时 HTTP 配置..."
|
||||
|
||||
# 确保 certbot webroot 目录及子目录存在
|
||||
mkdir -p /var/www/certbot/.well-known/acme-challenge
|
||||
chmod -R 755 /var/www/certbot
|
||||
|
||||
# 创建临时 HTTP 配置 (使用 .conf 后缀以便 nginx 加载)
|
||||
cat > /etc/nginx/sites-available/$DOMAIN_CONF << EOF
|
||||
# 临时 HTTP 配置 - 用于 Let's Encrypt 验证
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name $DOMAIN;
|
||||
|
||||
# Let's Encrypt 验证目录
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 200 'MPC gRPC proxy - waiting for SSL certificate';
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
# 启用站点
|
||||
ln -sf /etc/nginx/sites-available/$DOMAIN_CONF /etc/nginx/sites-enabled/$DOMAIN_CONF
|
||||
|
||||
# 测试并重载
|
||||
nginx -t && systemctl reload nginx
|
||||
log_success "临时 HTTP 配置完成"
|
||||
}
|
||||
|
||||
# 步骤 2: 申请 SSL 证书
|
||||
obtain_certificate() {
|
||||
log_info "步骤 2/4: 申请 Let's Encrypt SSL 证书..."
|
||||
|
||||
# 检查证书是否已存在
|
||||
if [ -f "/etc/letsencrypt/live/$DOMAIN/fullchain.pem" ]; then
|
||||
log_warn "证书已存在,跳过申请"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# 申请证书
|
||||
certbot certonly \
|
||||
--webroot \
|
||||
--webroot-path=/var/www/certbot \
|
||||
--email $EMAIL \
|
||||
--agree-tos \
|
||||
--no-eff-email \
|
||||
-d $DOMAIN
|
||||
|
||||
log_success "SSL 证书申请成功"
|
||||
}
|
||||
|
||||
# 步骤 3: 配置 gRPC 代理
|
||||
configure_grpc() {
|
||||
log_info "步骤 3/4: 配置 Nginx gRPC 代理..."
|
||||
|
||||
# 获取脚本所在目录
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# 复制 gRPC 配置
|
||||
cp "$SCRIPT_DIR/mpc-grpc.szaiai.com.conf" /etc/nginx/sites-available/$DOMAIN_CONF
|
||||
|
||||
# 测试并重载
|
||||
nginx -t && systemctl reload nginx
|
||||
log_success "gRPC 代理配置完成"
|
||||
}
|
||||
|
||||
# 步骤 4: 验证配置
|
||||
verify_setup() {
|
||||
log_info "步骤 4/4: 验证配置..."
|
||||
|
||||
# 检查 Nginx 状态
|
||||
if systemctl is-active --quiet nginx; then
|
||||
log_success "Nginx 运行正常"
|
||||
else
|
||||
log_error "Nginx 未运行"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 检查证书
|
||||
if [ -f "/etc/letsencrypt/live/$DOMAIN/fullchain.pem" ]; then
|
||||
log_success "SSL 证书已就绪"
|
||||
else
|
||||
log_error "SSL 证书未找到"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 检查配置语法
|
||||
if nginx -t 2>/dev/null; then
|
||||
log_success "Nginx 配置语法正确"
|
||||
else
|
||||
log_error "Nginx 配置语法错误"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_success "验证完成"
|
||||
}
|
||||
|
||||
# 显示完成信息
|
||||
show_completion() {
|
||||
echo ""
|
||||
echo -e "${GREEN}========================================${NC}"
|
||||
echo -e "${GREEN} MPC gRPC 代理安装完成!${NC}"
|
||||
echo -e "${GREEN}========================================${NC}"
|
||||
echo ""
|
||||
echo -e "gRPC 端点: ${BLUE}mpc-grpc.szaiai.com:443${NC}"
|
||||
echo ""
|
||||
echo "架构:"
|
||||
echo " Service Party App → Nginx (SSL/gRPC) → Message Router"
|
||||
echo " ↓"
|
||||
echo " $DOMAIN:443"
|
||||
echo " ↓"
|
||||
echo " $BACKEND_HOST:$BACKEND_PORT"
|
||||
echo ""
|
||||
echo "Service Party App 连接配置:"
|
||||
echo " gRPC 地址: mpc-grpc.szaiai.com:443"
|
||||
echo " TLS: 启用"
|
||||
echo ""
|
||||
echo "常用命令:"
|
||||
echo " 查看 Nginx 状态: systemctl status nginx"
|
||||
echo " 重载 Nginx: systemctl reload nginx"
|
||||
echo " 查看证书: certbot certificates"
|
||||
echo " 查看日志: tail -f /var/log/nginx/$DOMAIN.access.log"
|
||||
echo ""
|
||||
echo -e "${YELLOW}注意: 确保后端 Message Router ($BACKEND_HOST:$BACKEND_PORT) 正在运行${NC}"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# 显示使用帮助
|
||||
show_help() {
|
||||
echo "用法: $0 [选项]"
|
||||
echo ""
|
||||
echo "选项:"
|
||||
echo " --help, -h 显示帮助信息"
|
||||
echo " --verify 仅验证现有配置"
|
||||
echo " --uninstall 卸载配置"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# 卸载配置
|
||||
uninstall() {
|
||||
log_info "卸载 MPC gRPC 代理配置..."
|
||||
|
||||
# 移除站点配置 (兼容新旧文件名)
|
||||
rm -f /etc/nginx/sites-enabled/$DOMAIN_CONF
|
||||
rm -f /etc/nginx/sites-available/$DOMAIN_CONF
|
||||
rm -f /etc/nginx/sites-enabled/$DOMAIN
|
||||
rm -f /etc/nginx/sites-available/$DOMAIN
|
||||
|
||||
# 重载 Nginx
|
||||
nginx -t && systemctl reload nginx
|
||||
|
||||
log_success "配置已卸载"
|
||||
log_info "注意: SSL 证书未删除,如需删除请运行: certbot delete --cert-name $DOMAIN"
|
||||
}
|
||||
|
||||
# 主函数
|
||||
main() {
|
||||
case "${1:-}" in
|
||||
--help|-h)
|
||||
show_help
|
||||
exit 0
|
||||
;;
|
||||
--verify)
|
||||
check_prerequisites
|
||||
verify_setup
|
||||
exit 0
|
||||
;;
|
||||
--uninstall)
|
||||
check_root
|
||||
uninstall
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
echo ""
|
||||
echo "============================================"
|
||||
echo " MPC gRPC 代理 - Nginx 安装脚本"
|
||||
echo " 域名: $DOMAIN"
|
||||
echo " 后端: $BACKEND_HOST:$BACKEND_PORT"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
|
||||
check_root
|
||||
check_prerequisites
|
||||
|
||||
echo ""
|
||||
log_warn "请确保以下条件已满足:"
|
||||
echo " 1. 域名 $DOMAIN 的 DNS A 记录已指向本服务器 IP"
|
||||
echo " 2. Message Router 已在 $BACKEND_HOST:$BACKEND_PORT 运行"
|
||||
echo ""
|
||||
read -p "是否继续安装? (y/n): " confirm
|
||||
|
||||
if [ "$confirm" != "y" ] && [ "$confirm" != "Y" ]; then
|
||||
log_info "安装已取消"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
configure_http
|
||||
obtain_certificate
|
||||
configure_grpc
|
||||
verify_setup
|
||||
show_completion
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
# =============================================================================
|
||||
# MPC Message Router gRPC 代理配置
|
||||
# =============================================================================
|
||||
# 域名: mpc-grpc.szaiai.com
|
||||
# 用途: 为 Service Party App 提供 gRPC 连接到 Message Router
|
||||
# 后端: Message Router gRPC 服务 (端口 50051)
|
||||
#
|
||||
# 部署步骤:
|
||||
# 1. 放置到: /etc/nginx/sites-available/mpc-grpc.szaiai.com
|
||||
# 2. 启用: ln -s /etc/nginx/sites-available/mpc-grpc.szaiai.com /etc/nginx/sites-enabled/
|
||||
# 3. 申请证书: certbot certonly --nginx -d mpc-grpc.szaiai.com
|
||||
# 4. 重载: nginx -t && systemctl reload nginx
|
||||
#
|
||||
# 注意: 此配置完全独立,不影响现有服务
|
||||
# =============================================================================
|
||||
|
||||
# HTTP 重定向到 HTTPS (gRPC 必须使用 HTTPS)
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name mpc-grpc.szaiai.com;
|
||||
|
||||
# Let's Encrypt 验证目录
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
}
|
||||
|
||||
# 重定向到 HTTPS
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
# HTTPS + gRPC 配置
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
server_name mpc-grpc.szaiai.com;
|
||||
|
||||
# SSL 证书 (Let's Encrypt)
|
||||
# 首次部署前需要先申请证书:
|
||||
# certbot certonly --nginx -d mpc-grpc.szaiai.com
|
||||
ssl_certificate /etc/letsencrypt/live/mpc-grpc.szaiai.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/mpc-grpc.szaiai.com/privkey.pem;
|
||||
|
||||
# SSL 配置优化
|
||||
ssl_session_timeout 1d;
|
||||
ssl_session_cache shared:MPC_SSL:10m;
|
||||
ssl_session_tickets off;
|
||||
|
||||
# 现代加密套件
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
|
||||
ssl_prefer_server_ciphers off;
|
||||
|
||||
# HSTS
|
||||
add_header Strict-Transport-Security "max-age=63072000" always;
|
||||
|
||||
# 日志
|
||||
access_log /var/log/nginx/mpc-grpc.szaiai.com.access.log;
|
||||
error_log /var/log/nginx/mpc-grpc.szaiai.com.error.log;
|
||||
|
||||
# gRPC 代理到 Message Router
|
||||
# 后端服务器: 192.168.1.111 (与其他服务相同)
|
||||
# Message Router gRPC 端口: 50051
|
||||
location / {
|
||||
# gRPC 代理
|
||||
grpc_pass grpc://192.168.1.111:50051;
|
||||
|
||||
# gRPC 超时设置
|
||||
# 会话等待时间较长 (24小时倒计时),需要较长超时
|
||||
grpc_read_timeout 300s;
|
||||
grpc_send_timeout 300s;
|
||||
grpc_connect_timeout 60s;
|
||||
|
||||
# 错误处理
|
||||
error_page 502 = /error502grpc;
|
||||
}
|
||||
|
||||
# gRPC 错误处理
|
||||
location = /error502grpc {
|
||||
internal;
|
||||
default_type application/grpc;
|
||||
add_header grpc-status 14;
|
||||
add_header grpc-message "Message Router unavailable";
|
||||
return 204;
|
||||
}
|
||||
|
||||
# HTTP 健康检查端点 (用于监控)
|
||||
location = /health {
|
||||
access_log off;
|
||||
return 200 '{"status":"ok","service":"mpc-grpc-proxy"}';
|
||||
add_header Content-Type application/json;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
// versions:
|
||||
// protoc-gen-go v1.36.10
|
||||
// protoc v6.33.1
|
||||
// source: api/proto/session_coordinator.proto
|
||||
// source: session_coordinator.proto
|
||||
|
||||
package coordinator
|
||||
|
||||
|
|
@ -24,7 +24,7 @@ const (
|
|||
// CreateSessionRequest creates a new MPC session
|
||||
type CreateSessionRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
SessionType string `protobuf:"bytes,1,opt,name=session_type,json=sessionType,proto3" json:"session_type,omitempty"` // "keygen" or "sign"
|
||||
SessionType string `protobuf:"bytes,1,opt,name=session_type,json=sessionType,proto3" json:"session_type,omitempty"` // "keygen", "sign", or "co_managed_keygen"
|
||||
ThresholdN int32 `protobuf:"varint,2,opt,name=threshold_n,json=thresholdN,proto3" json:"threshold_n,omitempty"` // Total number of parties
|
||||
ThresholdT int32 `protobuf:"varint,3,opt,name=threshold_t,json=thresholdT,proto3" json:"threshold_t,omitempty"` // Minimum required parties
|
||||
Participants []*ParticipantInfo `protobuf:"bytes,4,rep,name=participants,proto3" json:"participants,omitempty"` // Optional: if empty, coordinator selects automatically
|
||||
|
|
@ -35,13 +35,16 @@ type CreateSessionRequest struct {
|
|||
DelegateUserShare *DelegateUserShare `protobuf:"bytes,8,opt,name=delegate_user_share,json=delegateUserShare,proto3" json:"delegate_user_share,omitempty"`
|
||||
// For sign sessions: which keygen session's shares to use
|
||||
KeygenSessionId string `protobuf:"bytes,9,opt,name=keygen_session_id,json=keygenSessionId,proto3" json:"keygen_session_id,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
// For co_managed_keygen sessions: wallet name and invite code
|
||||
WalletName string `protobuf:"bytes,10,opt,name=wallet_name,json=walletName,proto3" json:"wallet_name,omitempty"` // Wallet name (for co_managed_keygen)
|
||||
InviteCode string `protobuf:"bytes,11,opt,name=invite_code,json=inviteCode,proto3" json:"invite_code,omitempty"` // Invite code for participants to join (for co_managed_keygen)
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *CreateSessionRequest) Reset() {
|
||||
*x = CreateSessionRequest{}
|
||||
mi := &file_api_proto_session_coordinator_proto_msgTypes[0]
|
||||
mi := &file_session_coordinator_proto_msgTypes[0]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
|
@ -53,7 +56,7 @@ func (x *CreateSessionRequest) String() string {
|
|||
func (*CreateSessionRequest) ProtoMessage() {}
|
||||
|
||||
func (x *CreateSessionRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_api_proto_session_coordinator_proto_msgTypes[0]
|
||||
mi := &file_session_coordinator_proto_msgTypes[0]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
|
|
@ -66,7 +69,7 @@ func (x *CreateSessionRequest) ProtoReflect() protoreflect.Message {
|
|||
|
||||
// Deprecated: Use CreateSessionRequest.ProtoReflect.Descriptor instead.
|
||||
func (*CreateSessionRequest) Descriptor() ([]byte, []int) {
|
||||
return file_api_proto_session_coordinator_proto_rawDescGZIP(), []int{0}
|
||||
return file_session_coordinator_proto_rawDescGZIP(), []int{0}
|
||||
}
|
||||
|
||||
func (x *CreateSessionRequest) GetSessionType() string {
|
||||
|
|
@ -132,6 +135,20 @@ func (x *CreateSessionRequest) GetKeygenSessionId() string {
|
|||
return ""
|
||||
}
|
||||
|
||||
func (x *CreateSessionRequest) GetWalletName() string {
|
||||
if x != nil {
|
||||
return x.WalletName
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *CreateSessionRequest) GetInviteCode() string {
|
||||
if x != nil {
|
||||
return x.InviteCode
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// DelegateUserShare contains user's share for delegate party to use in signing
|
||||
type DelegateUserShare struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
|
|
@ -144,7 +161,7 @@ type DelegateUserShare struct {
|
|||
|
||||
func (x *DelegateUserShare) Reset() {
|
||||
*x = DelegateUserShare{}
|
||||
mi := &file_api_proto_session_coordinator_proto_msgTypes[1]
|
||||
mi := &file_session_coordinator_proto_msgTypes[1]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
|
@ -156,7 +173,7 @@ func (x *DelegateUserShare) String() string {
|
|||
func (*DelegateUserShare) ProtoMessage() {}
|
||||
|
||||
func (x *DelegateUserShare) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_api_proto_session_coordinator_proto_msgTypes[1]
|
||||
mi := &file_session_coordinator_proto_msgTypes[1]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
|
|
@ -169,7 +186,7 @@ func (x *DelegateUserShare) ProtoReflect() protoreflect.Message {
|
|||
|
||||
// Deprecated: Use DelegateUserShare.ProtoReflect.Descriptor instead.
|
||||
func (*DelegateUserShare) Descriptor() ([]byte, []int) {
|
||||
return file_api_proto_session_coordinator_proto_rawDescGZIP(), []int{1}
|
||||
return file_session_coordinator_proto_rawDescGZIP(), []int{1}
|
||||
}
|
||||
|
||||
func (x *DelegateUserShare) GetDelegatePartyId() string {
|
||||
|
|
@ -205,7 +222,7 @@ type PartyComposition struct {
|
|||
|
||||
func (x *PartyComposition) Reset() {
|
||||
*x = PartyComposition{}
|
||||
mi := &file_api_proto_session_coordinator_proto_msgTypes[2]
|
||||
mi := &file_session_coordinator_proto_msgTypes[2]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
|
@ -217,7 +234,7 @@ func (x *PartyComposition) String() string {
|
|||
func (*PartyComposition) ProtoMessage() {}
|
||||
|
||||
func (x *PartyComposition) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_api_proto_session_coordinator_proto_msgTypes[2]
|
||||
mi := &file_session_coordinator_proto_msgTypes[2]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
|
|
@ -230,7 +247,7 @@ func (x *PartyComposition) ProtoReflect() protoreflect.Message {
|
|||
|
||||
// Deprecated: Use PartyComposition.ProtoReflect.Descriptor instead.
|
||||
func (*PartyComposition) Descriptor() ([]byte, []int) {
|
||||
return file_api_proto_session_coordinator_proto_rawDescGZIP(), []int{2}
|
||||
return file_session_coordinator_proto_rawDescGZIP(), []int{2}
|
||||
}
|
||||
|
||||
func (x *PartyComposition) GetPersistentCount() int32 {
|
||||
|
|
@ -266,7 +283,7 @@ type ParticipantInfo struct {
|
|||
|
||||
func (x *ParticipantInfo) Reset() {
|
||||
*x = ParticipantInfo{}
|
||||
mi := &file_api_proto_session_coordinator_proto_msgTypes[3]
|
||||
mi := &file_session_coordinator_proto_msgTypes[3]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
|
@ -278,7 +295,7 @@ func (x *ParticipantInfo) String() string {
|
|||
func (*ParticipantInfo) ProtoMessage() {}
|
||||
|
||||
func (x *ParticipantInfo) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_api_proto_session_coordinator_proto_msgTypes[3]
|
||||
mi := &file_session_coordinator_proto_msgTypes[3]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
|
|
@ -291,7 +308,7 @@ func (x *ParticipantInfo) ProtoReflect() protoreflect.Message {
|
|||
|
||||
// Deprecated: Use ParticipantInfo.ProtoReflect.Descriptor instead.
|
||||
func (*ParticipantInfo) Descriptor() ([]byte, []int) {
|
||||
return file_api_proto_session_coordinator_proto_rawDescGZIP(), []int{3}
|
||||
return file_session_coordinator_proto_rawDescGZIP(), []int{3}
|
||||
}
|
||||
|
||||
func (x *ParticipantInfo) GetPartyId() string {
|
||||
|
|
@ -328,7 +345,7 @@ type DeviceInfo struct {
|
|||
|
||||
func (x *DeviceInfo) Reset() {
|
||||
*x = DeviceInfo{}
|
||||
mi := &file_api_proto_session_coordinator_proto_msgTypes[4]
|
||||
mi := &file_session_coordinator_proto_msgTypes[4]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
|
@ -340,7 +357,7 @@ func (x *DeviceInfo) String() string {
|
|||
func (*DeviceInfo) ProtoMessage() {}
|
||||
|
||||
func (x *DeviceInfo) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_api_proto_session_coordinator_proto_msgTypes[4]
|
||||
mi := &file_session_coordinator_proto_msgTypes[4]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
|
|
@ -353,7 +370,7 @@ func (x *DeviceInfo) ProtoReflect() protoreflect.Message {
|
|||
|
||||
// Deprecated: Use DeviceInfo.ProtoReflect.Descriptor instead.
|
||||
func (*DeviceInfo) Descriptor() ([]byte, []int) {
|
||||
return file_api_proto_session_coordinator_proto_rawDescGZIP(), []int{4}
|
||||
return file_session_coordinator_proto_rawDescGZIP(), []int{4}
|
||||
}
|
||||
|
||||
func (x *DeviceInfo) GetDeviceType() string {
|
||||
|
|
@ -398,7 +415,7 @@ type CreateSessionResponse struct {
|
|||
|
||||
func (x *CreateSessionResponse) Reset() {
|
||||
*x = CreateSessionResponse{}
|
||||
mi := &file_api_proto_session_coordinator_proto_msgTypes[5]
|
||||
mi := &file_session_coordinator_proto_msgTypes[5]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
|
@ -410,7 +427,7 @@ func (x *CreateSessionResponse) String() string {
|
|||
func (*CreateSessionResponse) ProtoMessage() {}
|
||||
|
||||
func (x *CreateSessionResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_api_proto_session_coordinator_proto_msgTypes[5]
|
||||
mi := &file_session_coordinator_proto_msgTypes[5]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
|
|
@ -423,7 +440,7 @@ func (x *CreateSessionResponse) ProtoReflect() protoreflect.Message {
|
|||
|
||||
// Deprecated: Use CreateSessionResponse.ProtoReflect.Descriptor instead.
|
||||
func (*CreateSessionResponse) Descriptor() ([]byte, []int) {
|
||||
return file_api_proto_session_coordinator_proto_rawDescGZIP(), []int{5}
|
||||
return file_session_coordinator_proto_rawDescGZIP(), []int{5}
|
||||
}
|
||||
|
||||
func (x *CreateSessionResponse) GetSessionId() string {
|
||||
|
|
@ -474,7 +491,7 @@ type JoinSessionRequest struct {
|
|||
|
||||
func (x *JoinSessionRequest) Reset() {
|
||||
*x = JoinSessionRequest{}
|
||||
mi := &file_api_proto_session_coordinator_proto_msgTypes[6]
|
||||
mi := &file_session_coordinator_proto_msgTypes[6]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
|
@ -486,7 +503,7 @@ func (x *JoinSessionRequest) String() string {
|
|||
func (*JoinSessionRequest) ProtoMessage() {}
|
||||
|
||||
func (x *JoinSessionRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_api_proto_session_coordinator_proto_msgTypes[6]
|
||||
mi := &file_session_coordinator_proto_msgTypes[6]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
|
|
@ -499,7 +516,7 @@ func (x *JoinSessionRequest) ProtoReflect() protoreflect.Message {
|
|||
|
||||
// Deprecated: Use JoinSessionRequest.ProtoReflect.Descriptor instead.
|
||||
func (*JoinSessionRequest) Descriptor() ([]byte, []int) {
|
||||
return file_api_proto_session_coordinator_proto_rawDescGZIP(), []int{6}
|
||||
return file_session_coordinator_proto_rawDescGZIP(), []int{6}
|
||||
}
|
||||
|
||||
func (x *JoinSessionRequest) GetSessionId() string {
|
||||
|
|
@ -543,7 +560,7 @@ type JoinSessionResponse struct {
|
|||
|
||||
func (x *JoinSessionResponse) Reset() {
|
||||
*x = JoinSessionResponse{}
|
||||
mi := &file_api_proto_session_coordinator_proto_msgTypes[7]
|
||||
mi := &file_session_coordinator_proto_msgTypes[7]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
|
@ -555,7 +572,7 @@ func (x *JoinSessionResponse) String() string {
|
|||
func (*JoinSessionResponse) ProtoMessage() {}
|
||||
|
||||
func (x *JoinSessionResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_api_proto_session_coordinator_proto_msgTypes[7]
|
||||
mi := &file_session_coordinator_proto_msgTypes[7]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
|
|
@ -568,7 +585,7 @@ func (x *JoinSessionResponse) ProtoReflect() protoreflect.Message {
|
|||
|
||||
// Deprecated: Use JoinSessionResponse.ProtoReflect.Descriptor instead.
|
||||
func (*JoinSessionResponse) Descriptor() ([]byte, []int) {
|
||||
return file_api_proto_session_coordinator_proto_rawDescGZIP(), []int{7}
|
||||
return file_session_coordinator_proto_rawDescGZIP(), []int{7}
|
||||
}
|
||||
|
||||
func (x *JoinSessionResponse) GetSuccess() bool {
|
||||
|
|
@ -610,13 +627,16 @@ type SessionInfo struct {
|
|||
Status string `protobuf:"bytes,6,opt,name=status,proto3" json:"status,omitempty"`
|
||||
// For sign sessions: which keygen session's shares to use
|
||||
KeygenSessionId string `protobuf:"bytes,7,opt,name=keygen_session_id,json=keygenSessionId,proto3" json:"keygen_session_id,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
// For co_managed_keygen sessions
|
||||
WalletName string `protobuf:"bytes,8,opt,name=wallet_name,json=walletName,proto3" json:"wallet_name,omitempty"` // Wallet name (for co_managed_keygen)
|
||||
InviteCode string `protobuf:"bytes,9,opt,name=invite_code,json=inviteCode,proto3" json:"invite_code,omitempty"` // Invite code (for co_managed_keygen)
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *SessionInfo) Reset() {
|
||||
*x = SessionInfo{}
|
||||
mi := &file_api_proto_session_coordinator_proto_msgTypes[8]
|
||||
mi := &file_session_coordinator_proto_msgTypes[8]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
|
@ -628,7 +648,7 @@ func (x *SessionInfo) String() string {
|
|||
func (*SessionInfo) ProtoMessage() {}
|
||||
|
||||
func (x *SessionInfo) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_api_proto_session_coordinator_proto_msgTypes[8]
|
||||
mi := &file_session_coordinator_proto_msgTypes[8]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
|
|
@ -641,7 +661,7 @@ func (x *SessionInfo) ProtoReflect() protoreflect.Message {
|
|||
|
||||
// Deprecated: Use SessionInfo.ProtoReflect.Descriptor instead.
|
||||
func (*SessionInfo) Descriptor() ([]byte, []int) {
|
||||
return file_api_proto_session_coordinator_proto_rawDescGZIP(), []int{8}
|
||||
return file_session_coordinator_proto_rawDescGZIP(), []int{8}
|
||||
}
|
||||
|
||||
func (x *SessionInfo) GetSessionId() string {
|
||||
|
|
@ -693,6 +713,20 @@ func (x *SessionInfo) GetKeygenSessionId() string {
|
|||
return ""
|
||||
}
|
||||
|
||||
func (x *SessionInfo) GetWalletName() string {
|
||||
if x != nil {
|
||||
return x.WalletName
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *SessionInfo) GetInviteCode() string {
|
||||
if x != nil {
|
||||
return x.InviteCode
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// PartyInfo contains party information
|
||||
type PartyInfo struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
|
|
@ -705,7 +739,7 @@ type PartyInfo struct {
|
|||
|
||||
func (x *PartyInfo) Reset() {
|
||||
*x = PartyInfo{}
|
||||
mi := &file_api_proto_session_coordinator_proto_msgTypes[9]
|
||||
mi := &file_session_coordinator_proto_msgTypes[9]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
|
@ -717,7 +751,7 @@ func (x *PartyInfo) String() string {
|
|||
func (*PartyInfo) ProtoMessage() {}
|
||||
|
||||
func (x *PartyInfo) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_api_proto_session_coordinator_proto_msgTypes[9]
|
||||
mi := &file_session_coordinator_proto_msgTypes[9]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
|
|
@ -730,7 +764,7 @@ func (x *PartyInfo) ProtoReflect() protoreflect.Message {
|
|||
|
||||
// Deprecated: Use PartyInfo.ProtoReflect.Descriptor instead.
|
||||
func (*PartyInfo) Descriptor() ([]byte, []int) {
|
||||
return file_api_proto_session_coordinator_proto_rawDescGZIP(), []int{9}
|
||||
return file_session_coordinator_proto_rawDescGZIP(), []int{9}
|
||||
}
|
||||
|
||||
func (x *PartyInfo) GetPartyId() string {
|
||||
|
|
@ -764,7 +798,7 @@ type GetSessionStatusRequest struct {
|
|||
|
||||
func (x *GetSessionStatusRequest) Reset() {
|
||||
*x = GetSessionStatusRequest{}
|
||||
mi := &file_api_proto_session_coordinator_proto_msgTypes[10]
|
||||
mi := &file_session_coordinator_proto_msgTypes[10]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
|
@ -776,7 +810,7 @@ func (x *GetSessionStatusRequest) String() string {
|
|||
func (*GetSessionStatusRequest) ProtoMessage() {}
|
||||
|
||||
func (x *GetSessionStatusRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_api_proto_session_coordinator_proto_msgTypes[10]
|
||||
mi := &file_session_coordinator_proto_msgTypes[10]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
|
|
@ -789,7 +823,7 @@ func (x *GetSessionStatusRequest) ProtoReflect() protoreflect.Message {
|
|||
|
||||
// Deprecated: Use GetSessionStatusRequest.ProtoReflect.Descriptor instead.
|
||||
func (*GetSessionStatusRequest) Descriptor() ([]byte, []int) {
|
||||
return file_api_proto_session_coordinator_proto_rawDescGZIP(), []int{10}
|
||||
return file_session_coordinator_proto_rawDescGZIP(), []int{10}
|
||||
}
|
||||
|
||||
func (x *GetSessionStatusRequest) GetSessionId() string {
|
||||
|
|
@ -814,13 +848,20 @@ type GetSessionStatusResponse struct {
|
|||
// Delegate share info (returned when keygen session completed and delegate party submitted share)
|
||||
// Only populated if session_type="keygen" AND has_delegate=true AND session is completed
|
||||
DelegateShare *DelegateShareInfo `protobuf:"bytes,8,opt,name=delegate_share,json=delegateShare,proto3" json:"delegate_share,omitempty"`
|
||||
// participants contains detailed participant information including party_index
|
||||
// Used by service-party-app for co_managed_keygen sessions
|
||||
Participants []*ParticipantStatus `protobuf:"bytes,9,rep,name=participants,proto3" json:"participants,omitempty"`
|
||||
// threshold_n and threshold_t - actual threshold values from session config
|
||||
// Used for co_managed_keygen sessions where total_parties may differ from threshold_n during joining
|
||||
ThresholdN int32 `protobuf:"varint,10,opt,name=threshold_n,json=thresholdN,proto3" json:"threshold_n,omitempty"` // Total number of parties required (e.g., 3 in 2-of-3)
|
||||
ThresholdT int32 `protobuf:"varint,11,opt,name=threshold_t,json=thresholdT,proto3" json:"threshold_t,omitempty"` // Minimum parties needed to sign (e.g., 2 in 2-of-3)
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *GetSessionStatusResponse) Reset() {
|
||||
*x = GetSessionStatusResponse{}
|
||||
mi := &file_api_proto_session_coordinator_proto_msgTypes[11]
|
||||
mi := &file_session_coordinator_proto_msgTypes[11]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
|
@ -832,7 +873,7 @@ func (x *GetSessionStatusResponse) String() string {
|
|||
func (*GetSessionStatusResponse) ProtoMessage() {}
|
||||
|
||||
func (x *GetSessionStatusResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_api_proto_session_coordinator_proto_msgTypes[11]
|
||||
mi := &file_session_coordinator_proto_msgTypes[11]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
|
|
@ -845,7 +886,7 @@ func (x *GetSessionStatusResponse) ProtoReflect() protoreflect.Message {
|
|||
|
||||
// Deprecated: Use GetSessionStatusResponse.ProtoReflect.Descriptor instead.
|
||||
func (*GetSessionStatusResponse) Descriptor() ([]byte, []int) {
|
||||
return file_api_proto_session_coordinator_proto_rawDescGZIP(), []int{11}
|
||||
return file_session_coordinator_proto_rawDescGZIP(), []int{11}
|
||||
}
|
||||
|
||||
func (x *GetSessionStatusResponse) GetStatus() string {
|
||||
|
|
@ -904,6 +945,88 @@ func (x *GetSessionStatusResponse) GetDelegateShare() *DelegateShareInfo {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (x *GetSessionStatusResponse) GetParticipants() []*ParticipantStatus {
|
||||
if x != nil {
|
||||
return x.Participants
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *GetSessionStatusResponse) GetThresholdN() int32 {
|
||||
if x != nil {
|
||||
return x.ThresholdN
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *GetSessionStatusResponse) GetThresholdT() int32 {
|
||||
if x != nil {
|
||||
return x.ThresholdT
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// ParticipantStatus contains participant status information
|
||||
type ParticipantStatus struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
PartyId string `protobuf:"bytes,1,opt,name=party_id,json=partyId,proto3" json:"party_id,omitempty"`
|
||||
PartyIndex int32 `protobuf:"varint,2,opt,name=party_index,json=partyIndex,proto3" json:"party_index,omitempty"`
|
||||
Status string `protobuf:"bytes,3,opt,name=status,proto3" json:"status,omitempty"` // pending, joined, ready, completed
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *ParticipantStatus) Reset() {
|
||||
*x = ParticipantStatus{}
|
||||
mi := &file_session_coordinator_proto_msgTypes[12]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *ParticipantStatus) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*ParticipantStatus) ProtoMessage() {}
|
||||
|
||||
func (x *ParticipantStatus) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_session_coordinator_proto_msgTypes[12]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use ParticipantStatus.ProtoReflect.Descriptor instead.
|
||||
func (*ParticipantStatus) Descriptor() ([]byte, []int) {
|
||||
return file_session_coordinator_proto_rawDescGZIP(), []int{12}
|
||||
}
|
||||
|
||||
func (x *ParticipantStatus) GetPartyId() string {
|
||||
if x != nil {
|
||||
return x.PartyId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *ParticipantStatus) GetPartyIndex() int32 {
|
||||
if x != nil {
|
||||
return x.PartyIndex
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *ParticipantStatus) GetStatus() string {
|
||||
if x != nil {
|
||||
return x.Status
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// DelegateShareInfo contains the delegate party's share for user
|
||||
type DelegateShareInfo struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
|
|
@ -916,7 +1039,7 @@ type DelegateShareInfo struct {
|
|||
|
||||
func (x *DelegateShareInfo) Reset() {
|
||||
*x = DelegateShareInfo{}
|
||||
mi := &file_api_proto_session_coordinator_proto_msgTypes[12]
|
||||
mi := &file_session_coordinator_proto_msgTypes[13]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
|
@ -928,7 +1051,7 @@ func (x *DelegateShareInfo) String() string {
|
|||
func (*DelegateShareInfo) ProtoMessage() {}
|
||||
|
||||
func (x *DelegateShareInfo) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_api_proto_session_coordinator_proto_msgTypes[12]
|
||||
mi := &file_session_coordinator_proto_msgTypes[13]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
|
|
@ -941,7 +1064,7 @@ func (x *DelegateShareInfo) ProtoReflect() protoreflect.Message {
|
|||
|
||||
// Deprecated: Use DelegateShareInfo.ProtoReflect.Descriptor instead.
|
||||
func (*DelegateShareInfo) Descriptor() ([]byte, []int) {
|
||||
return file_api_proto_session_coordinator_proto_rawDescGZIP(), []int{12}
|
||||
return file_session_coordinator_proto_rawDescGZIP(), []int{13}
|
||||
}
|
||||
|
||||
func (x *DelegateShareInfo) GetEncryptedShare() []byte {
|
||||
|
|
@ -978,7 +1101,7 @@ type ReportCompletionRequest struct {
|
|||
|
||||
func (x *ReportCompletionRequest) Reset() {
|
||||
*x = ReportCompletionRequest{}
|
||||
mi := &file_api_proto_session_coordinator_proto_msgTypes[13]
|
||||
mi := &file_session_coordinator_proto_msgTypes[14]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
|
@ -990,7 +1113,7 @@ func (x *ReportCompletionRequest) String() string {
|
|||
func (*ReportCompletionRequest) ProtoMessage() {}
|
||||
|
||||
func (x *ReportCompletionRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_api_proto_session_coordinator_proto_msgTypes[13]
|
||||
mi := &file_session_coordinator_proto_msgTypes[14]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
|
|
@ -1003,7 +1126,7 @@ func (x *ReportCompletionRequest) ProtoReflect() protoreflect.Message {
|
|||
|
||||
// Deprecated: Use ReportCompletionRequest.ProtoReflect.Descriptor instead.
|
||||
func (*ReportCompletionRequest) Descriptor() ([]byte, []int) {
|
||||
return file_api_proto_session_coordinator_proto_rawDescGZIP(), []int{13}
|
||||
return file_session_coordinator_proto_rawDescGZIP(), []int{14}
|
||||
}
|
||||
|
||||
func (x *ReportCompletionRequest) GetSessionId() string {
|
||||
|
|
@ -1045,7 +1168,7 @@ type ReportCompletionResponse struct {
|
|||
|
||||
func (x *ReportCompletionResponse) Reset() {
|
||||
*x = ReportCompletionResponse{}
|
||||
mi := &file_api_proto_session_coordinator_proto_msgTypes[14]
|
||||
mi := &file_session_coordinator_proto_msgTypes[15]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
|
@ -1057,7 +1180,7 @@ func (x *ReportCompletionResponse) String() string {
|
|||
func (*ReportCompletionResponse) ProtoMessage() {}
|
||||
|
||||
func (x *ReportCompletionResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_api_proto_session_coordinator_proto_msgTypes[14]
|
||||
mi := &file_session_coordinator_proto_msgTypes[15]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
|
|
@ -1070,7 +1193,7 @@ func (x *ReportCompletionResponse) ProtoReflect() protoreflect.Message {
|
|||
|
||||
// Deprecated: Use ReportCompletionResponse.ProtoReflect.Descriptor instead.
|
||||
func (*ReportCompletionResponse) Descriptor() ([]byte, []int) {
|
||||
return file_api_proto_session_coordinator_proto_rawDescGZIP(), []int{14}
|
||||
return file_session_coordinator_proto_rawDescGZIP(), []int{15}
|
||||
}
|
||||
|
||||
func (x *ReportCompletionResponse) GetSuccess() bool {
|
||||
|
|
@ -1097,7 +1220,7 @@ type CloseSessionRequest struct {
|
|||
|
||||
func (x *CloseSessionRequest) Reset() {
|
||||
*x = CloseSessionRequest{}
|
||||
mi := &file_api_proto_session_coordinator_proto_msgTypes[15]
|
||||
mi := &file_session_coordinator_proto_msgTypes[16]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
|
@ -1109,7 +1232,7 @@ func (x *CloseSessionRequest) String() string {
|
|||
func (*CloseSessionRequest) ProtoMessage() {}
|
||||
|
||||
func (x *CloseSessionRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_api_proto_session_coordinator_proto_msgTypes[15]
|
||||
mi := &file_session_coordinator_proto_msgTypes[16]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
|
|
@ -1122,7 +1245,7 @@ func (x *CloseSessionRequest) ProtoReflect() protoreflect.Message {
|
|||
|
||||
// Deprecated: Use CloseSessionRequest.ProtoReflect.Descriptor instead.
|
||||
func (*CloseSessionRequest) Descriptor() ([]byte, []int) {
|
||||
return file_api_proto_session_coordinator_proto_rawDescGZIP(), []int{15}
|
||||
return file_session_coordinator_proto_rawDescGZIP(), []int{16}
|
||||
}
|
||||
|
||||
func (x *CloseSessionRequest) GetSessionId() string {
|
||||
|
|
@ -1142,7 +1265,7 @@ type CloseSessionResponse struct {
|
|||
|
||||
func (x *CloseSessionResponse) Reset() {
|
||||
*x = CloseSessionResponse{}
|
||||
mi := &file_api_proto_session_coordinator_proto_msgTypes[16]
|
||||
mi := &file_session_coordinator_proto_msgTypes[17]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
|
@ -1154,7 +1277,7 @@ func (x *CloseSessionResponse) String() string {
|
|||
func (*CloseSessionResponse) ProtoMessage() {}
|
||||
|
||||
func (x *CloseSessionResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_api_proto_session_coordinator_proto_msgTypes[16]
|
||||
mi := &file_session_coordinator_proto_msgTypes[17]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
|
|
@ -1167,7 +1290,7 @@ func (x *CloseSessionResponse) ProtoReflect() protoreflect.Message {
|
|||
|
||||
// Deprecated: Use CloseSessionResponse.ProtoReflect.Descriptor instead.
|
||||
func (*CloseSessionResponse) Descriptor() ([]byte, []int) {
|
||||
return file_api_proto_session_coordinator_proto_rawDescGZIP(), []int{16}
|
||||
return file_session_coordinator_proto_rawDescGZIP(), []int{17}
|
||||
}
|
||||
|
||||
func (x *CloseSessionResponse) GetSuccess() bool {
|
||||
|
|
@ -1188,7 +1311,7 @@ type MarkPartyReadyRequest struct {
|
|||
|
||||
func (x *MarkPartyReadyRequest) Reset() {
|
||||
*x = MarkPartyReadyRequest{}
|
||||
mi := &file_api_proto_session_coordinator_proto_msgTypes[17]
|
||||
mi := &file_session_coordinator_proto_msgTypes[18]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
|
@ -1200,7 +1323,7 @@ func (x *MarkPartyReadyRequest) String() string {
|
|||
func (*MarkPartyReadyRequest) ProtoMessage() {}
|
||||
|
||||
func (x *MarkPartyReadyRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_api_proto_session_coordinator_proto_msgTypes[17]
|
||||
mi := &file_session_coordinator_proto_msgTypes[18]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
|
|
@ -1213,7 +1336,7 @@ func (x *MarkPartyReadyRequest) ProtoReflect() protoreflect.Message {
|
|||
|
||||
// Deprecated: Use MarkPartyReadyRequest.ProtoReflect.Descriptor instead.
|
||||
func (*MarkPartyReadyRequest) Descriptor() ([]byte, []int) {
|
||||
return file_api_proto_session_coordinator_proto_rawDescGZIP(), []int{17}
|
||||
return file_session_coordinator_proto_rawDescGZIP(), []int{18}
|
||||
}
|
||||
|
||||
func (x *MarkPartyReadyRequest) GetSessionId() string {
|
||||
|
|
@ -1243,7 +1366,7 @@ type MarkPartyReadyResponse struct {
|
|||
|
||||
func (x *MarkPartyReadyResponse) Reset() {
|
||||
*x = MarkPartyReadyResponse{}
|
||||
mi := &file_api_proto_session_coordinator_proto_msgTypes[18]
|
||||
mi := &file_session_coordinator_proto_msgTypes[19]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
|
@ -1255,7 +1378,7 @@ func (x *MarkPartyReadyResponse) String() string {
|
|||
func (*MarkPartyReadyResponse) ProtoMessage() {}
|
||||
|
||||
func (x *MarkPartyReadyResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_api_proto_session_coordinator_proto_msgTypes[18]
|
||||
mi := &file_session_coordinator_proto_msgTypes[19]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
|
|
@ -1268,7 +1391,7 @@ func (x *MarkPartyReadyResponse) ProtoReflect() protoreflect.Message {
|
|||
|
||||
// Deprecated: Use MarkPartyReadyResponse.ProtoReflect.Descriptor instead.
|
||||
func (*MarkPartyReadyResponse) Descriptor() ([]byte, []int) {
|
||||
return file_api_proto_session_coordinator_proto_rawDescGZIP(), []int{18}
|
||||
return file_session_coordinator_proto_rawDescGZIP(), []int{19}
|
||||
}
|
||||
|
||||
func (x *MarkPartyReadyResponse) GetSuccess() bool {
|
||||
|
|
@ -1309,7 +1432,7 @@ type StartSessionRequest struct {
|
|||
|
||||
func (x *StartSessionRequest) Reset() {
|
||||
*x = StartSessionRequest{}
|
||||
mi := &file_api_proto_session_coordinator_proto_msgTypes[19]
|
||||
mi := &file_session_coordinator_proto_msgTypes[20]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
|
@ -1321,7 +1444,7 @@ func (x *StartSessionRequest) String() string {
|
|||
func (*StartSessionRequest) ProtoMessage() {}
|
||||
|
||||
func (x *StartSessionRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_api_proto_session_coordinator_proto_msgTypes[19]
|
||||
mi := &file_session_coordinator_proto_msgTypes[20]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
|
|
@ -1334,7 +1457,7 @@ func (x *StartSessionRequest) ProtoReflect() protoreflect.Message {
|
|||
|
||||
// Deprecated: Use StartSessionRequest.ProtoReflect.Descriptor instead.
|
||||
func (*StartSessionRequest) Descriptor() ([]byte, []int) {
|
||||
return file_api_proto_session_coordinator_proto_rawDescGZIP(), []int{19}
|
||||
return file_session_coordinator_proto_rawDescGZIP(), []int{20}
|
||||
}
|
||||
|
||||
func (x *StartSessionRequest) GetSessionId() string {
|
||||
|
|
@ -1355,7 +1478,7 @@ type StartSessionResponse struct {
|
|||
|
||||
func (x *StartSessionResponse) Reset() {
|
||||
*x = StartSessionResponse{}
|
||||
mi := &file_api_proto_session_coordinator_proto_msgTypes[20]
|
||||
mi := &file_session_coordinator_proto_msgTypes[21]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
|
@ -1367,7 +1490,7 @@ func (x *StartSessionResponse) String() string {
|
|||
func (*StartSessionResponse) ProtoMessage() {}
|
||||
|
||||
func (x *StartSessionResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_api_proto_session_coordinator_proto_msgTypes[20]
|
||||
mi := &file_session_coordinator_proto_msgTypes[21]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
|
|
@ -1380,7 +1503,7 @@ func (x *StartSessionResponse) ProtoReflect() protoreflect.Message {
|
|||
|
||||
// Deprecated: Use StartSessionResponse.ProtoReflect.Descriptor instead.
|
||||
func (*StartSessionResponse) Descriptor() ([]byte, []int) {
|
||||
return file_api_proto_session_coordinator_proto_rawDescGZIP(), []int{20}
|
||||
return file_session_coordinator_proto_rawDescGZIP(), []int{21}
|
||||
}
|
||||
|
||||
func (x *StartSessionResponse) GetSuccess() bool {
|
||||
|
|
@ -1411,7 +1534,7 @@ type SubmitDelegateShareRequest struct {
|
|||
|
||||
func (x *SubmitDelegateShareRequest) Reset() {
|
||||
*x = SubmitDelegateShareRequest{}
|
||||
mi := &file_api_proto_session_coordinator_proto_msgTypes[21]
|
||||
mi := &file_session_coordinator_proto_msgTypes[22]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
|
@ -1423,7 +1546,7 @@ func (x *SubmitDelegateShareRequest) String() string {
|
|||
func (*SubmitDelegateShareRequest) ProtoMessage() {}
|
||||
|
||||
func (x *SubmitDelegateShareRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_api_proto_session_coordinator_proto_msgTypes[21]
|
||||
mi := &file_session_coordinator_proto_msgTypes[22]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
|
|
@ -1436,7 +1559,7 @@ func (x *SubmitDelegateShareRequest) ProtoReflect() protoreflect.Message {
|
|||
|
||||
// Deprecated: Use SubmitDelegateShareRequest.ProtoReflect.Descriptor instead.
|
||||
func (*SubmitDelegateShareRequest) Descriptor() ([]byte, []int) {
|
||||
return file_api_proto_session_coordinator_proto_rawDescGZIP(), []int{21}
|
||||
return file_session_coordinator_proto_rawDescGZIP(), []int{22}
|
||||
}
|
||||
|
||||
func (x *SubmitDelegateShareRequest) GetSessionId() string {
|
||||
|
|
@ -1484,7 +1607,7 @@ type SubmitDelegateShareResponse struct {
|
|||
|
||||
func (x *SubmitDelegateShareResponse) Reset() {
|
||||
*x = SubmitDelegateShareResponse{}
|
||||
mi := &file_api_proto_session_coordinator_proto_msgTypes[22]
|
||||
mi := &file_session_coordinator_proto_msgTypes[23]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
|
@ -1496,7 +1619,7 @@ func (x *SubmitDelegateShareResponse) String() string {
|
|||
func (*SubmitDelegateShareResponse) ProtoMessage() {}
|
||||
|
||||
func (x *SubmitDelegateShareResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_api_proto_session_coordinator_proto_msgTypes[22]
|
||||
mi := &file_session_coordinator_proto_msgTypes[23]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
|
|
@ -1509,7 +1632,7 @@ func (x *SubmitDelegateShareResponse) ProtoReflect() protoreflect.Message {
|
|||
|
||||
// Deprecated: Use SubmitDelegateShareResponse.ProtoReflect.Descriptor instead.
|
||||
func (*SubmitDelegateShareResponse) Descriptor() ([]byte, []int) {
|
||||
return file_api_proto_session_coordinator_proto_rawDescGZIP(), []int{22}
|
||||
return file_session_coordinator_proto_rawDescGZIP(), []int{23}
|
||||
}
|
||||
|
||||
func (x *SubmitDelegateShareResponse) GetSuccess() bool {
|
||||
|
|
@ -1519,11 +1642,11 @@ func (x *SubmitDelegateShareResponse) GetSuccess() bool {
|
|||
return false
|
||||
}
|
||||
|
||||
var File_api_proto_session_coordinator_proto protoreflect.FileDescriptor
|
||||
var File_session_coordinator_proto protoreflect.FileDescriptor
|
||||
|
||||
const file_api_proto_session_coordinator_proto_rawDesc = "" +
|
||||
const file_session_coordinator_proto_rawDesc = "" +
|
||||
"\n" +
|
||||
"#api/proto/session_coordinator.proto\x12\x12mpc.coordinator.v1\"\xeb\x03\n" +
|
||||
"\x19session_coordinator.proto\x12\x12mpc.coordinator.v1\"\xad\x04\n" +
|
||||
"\x14CreateSessionRequest\x12!\n" +
|
||||
"\fsession_type\x18\x01 \x01(\tR\vsessionType\x12\x1f\n" +
|
||||
"\vthreshold_n\x18\x02 \x01(\x05R\n" +
|
||||
|
|
@ -1535,7 +1658,12 @@ const file_api_proto_session_coordinator_proto_rawDesc = "" +
|
|||
"\x12expires_in_seconds\x18\x06 \x01(\x03R\x10expiresInSeconds\x12Q\n" +
|
||||
"\x11party_composition\x18\a \x01(\v2$.mpc.coordinator.v1.PartyCompositionR\x10partyComposition\x12U\n" +
|
||||
"\x13delegate_user_share\x18\b \x01(\v2%.mpc.coordinator.v1.DelegateUserShareR\x11delegateUserShare\x12*\n" +
|
||||
"\x11keygen_session_id\x18\t \x01(\tR\x0fkeygenSessionId\"\x89\x01\n" +
|
||||
"\x11keygen_session_id\x18\t \x01(\tR\x0fkeygenSessionId\x12\x1f\n" +
|
||||
"\vwallet_name\x18\n" +
|
||||
" \x01(\tR\n" +
|
||||
"walletName\x12\x1f\n" +
|
||||
"\vinvite_code\x18\v \x01(\tR\n" +
|
||||
"inviteCode\"\x89\x01\n" +
|
||||
"\x11DelegateUserShare\x12*\n" +
|
||||
"\x11delegate_party_id\x18\x01 \x01(\tR\x0fdelegatePartyId\x12'\n" +
|
||||
"\x0fencrypted_share\x18\x02 \x01(\fR\x0eencryptedShare\x12\x1f\n" +
|
||||
|
|
@ -1584,7 +1712,7 @@ const file_api_proto_session_coordinator_proto_rawDesc = "" +
|
|||
"\fsession_info\x18\x02 \x01(\v2\x1f.mpc.coordinator.v1.SessionInfoR\vsessionInfo\x12B\n" +
|
||||
"\rother_parties\x18\x03 \x03(\v2\x1d.mpc.coordinator.v1.PartyInfoR\fotherParties\x12\x1f\n" +
|
||||
"\vparty_index\x18\x04 \x01(\x05R\n" +
|
||||
"partyIndex\"\xf8\x01\n" +
|
||||
"partyIndex\"\xba\x02\n" +
|
||||
"\vSessionInfo\x12\x1d\n" +
|
||||
"\n" +
|
||||
"session_id\x18\x01 \x01(\tR\tsessionId\x12!\n" +
|
||||
|
|
@ -1595,7 +1723,11 @@ const file_api_proto_session_coordinator_proto_rawDesc = "" +
|
|||
"thresholdT\x12!\n" +
|
||||
"\fmessage_hash\x18\x05 \x01(\fR\vmessageHash\x12\x16\n" +
|
||||
"\x06status\x18\x06 \x01(\tR\x06status\x12*\n" +
|
||||
"\x11keygen_session_id\x18\a \x01(\tR\x0fkeygenSessionId\"\x88\x01\n" +
|
||||
"\x11keygen_session_id\x18\a \x01(\tR\x0fkeygenSessionId\x12\x1f\n" +
|
||||
"\vwallet_name\x18\b \x01(\tR\n" +
|
||||
"walletName\x12\x1f\n" +
|
||||
"\vinvite_code\x18\t \x01(\tR\n" +
|
||||
"inviteCode\"\x88\x01\n" +
|
||||
"\tPartyInfo\x12\x19\n" +
|
||||
"\bparty_id\x18\x01 \x01(\tR\apartyId\x12\x1f\n" +
|
||||
"\vparty_index\x18\x02 \x01(\x05R\n" +
|
||||
|
|
@ -1604,7 +1736,7 @@ const file_api_proto_session_coordinator_proto_rawDesc = "" +
|
|||
"deviceInfo\"8\n" +
|
||||
"\x17GetSessionStatusRequest\x12\x1d\n" +
|
||||
"\n" +
|
||||
"session_id\x18\x01 \x01(\tR\tsessionId\"\xd5\x02\n" +
|
||||
"session_id\x18\x01 \x01(\tR\tsessionId\"\xe2\x03\n" +
|
||||
"\x18GetSessionStatusResponse\x12\x16\n" +
|
||||
"\x06status\x18\x01 \x01(\tR\x06status\x12+\n" +
|
||||
"\x11completed_parties\x18\x02 \x01(\x05R\x10completedParties\x12#\n" +
|
||||
|
|
@ -1614,7 +1746,18 @@ const file_api_proto_session_coordinator_proto_rawDesc = "" +
|
|||
"public_key\x18\x05 \x01(\fR\tpublicKey\x12\x1c\n" +
|
||||
"\tsignature\x18\x06 \x01(\fR\tsignature\x12!\n" +
|
||||
"\fhas_delegate\x18\a \x01(\bR\vhasDelegate\x12L\n" +
|
||||
"\x0edelegate_share\x18\b \x01(\v2%.mpc.coordinator.v1.DelegateShareInfoR\rdelegateShare\"x\n" +
|
||||
"\x0edelegate_share\x18\b \x01(\v2%.mpc.coordinator.v1.DelegateShareInfoR\rdelegateShare\x12I\n" +
|
||||
"\fparticipants\x18\t \x03(\v2%.mpc.coordinator.v1.ParticipantStatusR\fparticipants\x12\x1f\n" +
|
||||
"\vthreshold_n\x18\n" +
|
||||
" \x01(\x05R\n" +
|
||||
"thresholdN\x12\x1f\n" +
|
||||
"\vthreshold_t\x18\v \x01(\x05R\n" +
|
||||
"thresholdT\"g\n" +
|
||||
"\x11ParticipantStatus\x12\x19\n" +
|
||||
"\bparty_id\x18\x01 \x01(\tR\apartyId\x12\x1f\n" +
|
||||
"\vparty_index\x18\x02 \x01(\x05R\n" +
|
||||
"partyIndex\x12\x16\n" +
|
||||
"\x06status\x18\x03 \x01(\tR\x06status\"x\n" +
|
||||
"\x11DelegateShareInfo\x12'\n" +
|
||||
"\x0fencrypted_share\x18\x01 \x01(\fR\x0eencryptedShare\x12\x1f\n" +
|
||||
"\vparty_index\x18\x02 \x01(\x05R\n" +
|
||||
|
|
@ -1673,19 +1816,19 @@ const file_api_proto_session_coordinator_proto_rawDesc = "" +
|
|||
"\x13SubmitDelegateShare\x12..mpc.coordinator.v1.SubmitDelegateShareRequest\x1a/.mpc.coordinator.v1.SubmitDelegateShareResponseBEZCgithub.com/rwadurian/mpc-system/api/grpc/coordinator/v1;coordinatorb\x06proto3"
|
||||
|
||||
var (
|
||||
file_api_proto_session_coordinator_proto_rawDescOnce sync.Once
|
||||
file_api_proto_session_coordinator_proto_rawDescData []byte
|
||||
file_session_coordinator_proto_rawDescOnce sync.Once
|
||||
file_session_coordinator_proto_rawDescData []byte
|
||||
)
|
||||
|
||||
func file_api_proto_session_coordinator_proto_rawDescGZIP() []byte {
|
||||
file_api_proto_session_coordinator_proto_rawDescOnce.Do(func() {
|
||||
file_api_proto_session_coordinator_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_api_proto_session_coordinator_proto_rawDesc), len(file_api_proto_session_coordinator_proto_rawDesc)))
|
||||
func file_session_coordinator_proto_rawDescGZIP() []byte {
|
||||
file_session_coordinator_proto_rawDescOnce.Do(func() {
|
||||
file_session_coordinator_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_session_coordinator_proto_rawDesc), len(file_session_coordinator_proto_rawDesc)))
|
||||
})
|
||||
return file_api_proto_session_coordinator_proto_rawDescData
|
||||
return file_session_coordinator_proto_rawDescData
|
||||
}
|
||||
|
||||
var file_api_proto_session_coordinator_proto_msgTypes = make([]protoimpl.MessageInfo, 24)
|
||||
var file_api_proto_session_coordinator_proto_goTypes = []any{
|
||||
var file_session_coordinator_proto_msgTypes = make([]protoimpl.MessageInfo, 25)
|
||||
var file_session_coordinator_proto_goTypes = []any{
|
||||
(*CreateSessionRequest)(nil), // 0: mpc.coordinator.v1.CreateSessionRequest
|
||||
(*DelegateUserShare)(nil), // 1: mpc.coordinator.v1.DelegateUserShare
|
||||
(*PartyComposition)(nil), // 2: mpc.coordinator.v1.PartyComposition
|
||||
|
|
@ -1698,73 +1841,75 @@ var file_api_proto_session_coordinator_proto_goTypes = []any{
|
|||
(*PartyInfo)(nil), // 9: mpc.coordinator.v1.PartyInfo
|
||||
(*GetSessionStatusRequest)(nil), // 10: mpc.coordinator.v1.GetSessionStatusRequest
|
||||
(*GetSessionStatusResponse)(nil), // 11: mpc.coordinator.v1.GetSessionStatusResponse
|
||||
(*DelegateShareInfo)(nil), // 12: mpc.coordinator.v1.DelegateShareInfo
|
||||
(*ReportCompletionRequest)(nil), // 13: mpc.coordinator.v1.ReportCompletionRequest
|
||||
(*ReportCompletionResponse)(nil), // 14: mpc.coordinator.v1.ReportCompletionResponse
|
||||
(*CloseSessionRequest)(nil), // 15: mpc.coordinator.v1.CloseSessionRequest
|
||||
(*CloseSessionResponse)(nil), // 16: mpc.coordinator.v1.CloseSessionResponse
|
||||
(*MarkPartyReadyRequest)(nil), // 17: mpc.coordinator.v1.MarkPartyReadyRequest
|
||||
(*MarkPartyReadyResponse)(nil), // 18: mpc.coordinator.v1.MarkPartyReadyResponse
|
||||
(*StartSessionRequest)(nil), // 19: mpc.coordinator.v1.StartSessionRequest
|
||||
(*StartSessionResponse)(nil), // 20: mpc.coordinator.v1.StartSessionResponse
|
||||
(*SubmitDelegateShareRequest)(nil), // 21: mpc.coordinator.v1.SubmitDelegateShareRequest
|
||||
(*SubmitDelegateShareResponse)(nil), // 22: mpc.coordinator.v1.SubmitDelegateShareResponse
|
||||
nil, // 23: mpc.coordinator.v1.CreateSessionResponse.JoinTokensEntry
|
||||
(*ParticipantStatus)(nil), // 12: mpc.coordinator.v1.ParticipantStatus
|
||||
(*DelegateShareInfo)(nil), // 13: mpc.coordinator.v1.DelegateShareInfo
|
||||
(*ReportCompletionRequest)(nil), // 14: mpc.coordinator.v1.ReportCompletionRequest
|
||||
(*ReportCompletionResponse)(nil), // 15: mpc.coordinator.v1.ReportCompletionResponse
|
||||
(*CloseSessionRequest)(nil), // 16: mpc.coordinator.v1.CloseSessionRequest
|
||||
(*CloseSessionResponse)(nil), // 17: mpc.coordinator.v1.CloseSessionResponse
|
||||
(*MarkPartyReadyRequest)(nil), // 18: mpc.coordinator.v1.MarkPartyReadyRequest
|
||||
(*MarkPartyReadyResponse)(nil), // 19: mpc.coordinator.v1.MarkPartyReadyResponse
|
||||
(*StartSessionRequest)(nil), // 20: mpc.coordinator.v1.StartSessionRequest
|
||||
(*StartSessionResponse)(nil), // 21: mpc.coordinator.v1.StartSessionResponse
|
||||
(*SubmitDelegateShareRequest)(nil), // 22: mpc.coordinator.v1.SubmitDelegateShareRequest
|
||||
(*SubmitDelegateShareResponse)(nil), // 23: mpc.coordinator.v1.SubmitDelegateShareResponse
|
||||
nil, // 24: mpc.coordinator.v1.CreateSessionResponse.JoinTokensEntry
|
||||
}
|
||||
var file_api_proto_session_coordinator_proto_depIdxs = []int32{
|
||||
var file_session_coordinator_proto_depIdxs = []int32{
|
||||
3, // 0: mpc.coordinator.v1.CreateSessionRequest.participants:type_name -> mpc.coordinator.v1.ParticipantInfo
|
||||
2, // 1: mpc.coordinator.v1.CreateSessionRequest.party_composition:type_name -> mpc.coordinator.v1.PartyComposition
|
||||
1, // 2: mpc.coordinator.v1.CreateSessionRequest.delegate_user_share:type_name -> mpc.coordinator.v1.DelegateUserShare
|
||||
4, // 3: mpc.coordinator.v1.ParticipantInfo.device_info:type_name -> mpc.coordinator.v1.DeviceInfo
|
||||
23, // 4: mpc.coordinator.v1.CreateSessionResponse.join_tokens:type_name -> mpc.coordinator.v1.CreateSessionResponse.JoinTokensEntry
|
||||
24, // 4: mpc.coordinator.v1.CreateSessionResponse.join_tokens:type_name -> mpc.coordinator.v1.CreateSessionResponse.JoinTokensEntry
|
||||
4, // 5: mpc.coordinator.v1.JoinSessionRequest.device_info:type_name -> mpc.coordinator.v1.DeviceInfo
|
||||
8, // 6: mpc.coordinator.v1.JoinSessionResponse.session_info:type_name -> mpc.coordinator.v1.SessionInfo
|
||||
9, // 7: mpc.coordinator.v1.JoinSessionResponse.other_parties:type_name -> mpc.coordinator.v1.PartyInfo
|
||||
4, // 8: mpc.coordinator.v1.PartyInfo.device_info:type_name -> mpc.coordinator.v1.DeviceInfo
|
||||
12, // 9: mpc.coordinator.v1.GetSessionStatusResponse.delegate_share:type_name -> mpc.coordinator.v1.DelegateShareInfo
|
||||
0, // 10: mpc.coordinator.v1.SessionCoordinator.CreateSession:input_type -> mpc.coordinator.v1.CreateSessionRequest
|
||||
6, // 11: mpc.coordinator.v1.SessionCoordinator.JoinSession:input_type -> mpc.coordinator.v1.JoinSessionRequest
|
||||
10, // 12: mpc.coordinator.v1.SessionCoordinator.GetSessionStatus:input_type -> mpc.coordinator.v1.GetSessionStatusRequest
|
||||
17, // 13: mpc.coordinator.v1.SessionCoordinator.MarkPartyReady:input_type -> mpc.coordinator.v1.MarkPartyReadyRequest
|
||||
19, // 14: mpc.coordinator.v1.SessionCoordinator.StartSession:input_type -> mpc.coordinator.v1.StartSessionRequest
|
||||
13, // 15: mpc.coordinator.v1.SessionCoordinator.ReportCompletion:input_type -> mpc.coordinator.v1.ReportCompletionRequest
|
||||
15, // 16: mpc.coordinator.v1.SessionCoordinator.CloseSession:input_type -> mpc.coordinator.v1.CloseSessionRequest
|
||||
21, // 17: mpc.coordinator.v1.SessionCoordinator.SubmitDelegateShare:input_type -> mpc.coordinator.v1.SubmitDelegateShareRequest
|
||||
5, // 18: mpc.coordinator.v1.SessionCoordinator.CreateSession:output_type -> mpc.coordinator.v1.CreateSessionResponse
|
||||
7, // 19: mpc.coordinator.v1.SessionCoordinator.JoinSession:output_type -> mpc.coordinator.v1.JoinSessionResponse
|
||||
11, // 20: mpc.coordinator.v1.SessionCoordinator.GetSessionStatus:output_type -> mpc.coordinator.v1.GetSessionStatusResponse
|
||||
18, // 21: mpc.coordinator.v1.SessionCoordinator.MarkPartyReady:output_type -> mpc.coordinator.v1.MarkPartyReadyResponse
|
||||
20, // 22: mpc.coordinator.v1.SessionCoordinator.StartSession:output_type -> mpc.coordinator.v1.StartSessionResponse
|
||||
14, // 23: mpc.coordinator.v1.SessionCoordinator.ReportCompletion:output_type -> mpc.coordinator.v1.ReportCompletionResponse
|
||||
16, // 24: mpc.coordinator.v1.SessionCoordinator.CloseSession:output_type -> mpc.coordinator.v1.CloseSessionResponse
|
||||
22, // 25: mpc.coordinator.v1.SessionCoordinator.SubmitDelegateShare:output_type -> mpc.coordinator.v1.SubmitDelegateShareResponse
|
||||
18, // [18:26] is the sub-list for method output_type
|
||||
10, // [10:18] is the sub-list for method input_type
|
||||
10, // [10:10] is the sub-list for extension type_name
|
||||
10, // [10:10] is the sub-list for extension extendee
|
||||
0, // [0:10] is the sub-list for field type_name
|
||||
13, // 9: mpc.coordinator.v1.GetSessionStatusResponse.delegate_share:type_name -> mpc.coordinator.v1.DelegateShareInfo
|
||||
12, // 10: mpc.coordinator.v1.GetSessionStatusResponse.participants:type_name -> mpc.coordinator.v1.ParticipantStatus
|
||||
0, // 11: mpc.coordinator.v1.SessionCoordinator.CreateSession:input_type -> mpc.coordinator.v1.CreateSessionRequest
|
||||
6, // 12: mpc.coordinator.v1.SessionCoordinator.JoinSession:input_type -> mpc.coordinator.v1.JoinSessionRequest
|
||||
10, // 13: mpc.coordinator.v1.SessionCoordinator.GetSessionStatus:input_type -> mpc.coordinator.v1.GetSessionStatusRequest
|
||||
18, // 14: mpc.coordinator.v1.SessionCoordinator.MarkPartyReady:input_type -> mpc.coordinator.v1.MarkPartyReadyRequest
|
||||
20, // 15: mpc.coordinator.v1.SessionCoordinator.StartSession:input_type -> mpc.coordinator.v1.StartSessionRequest
|
||||
14, // 16: mpc.coordinator.v1.SessionCoordinator.ReportCompletion:input_type -> mpc.coordinator.v1.ReportCompletionRequest
|
||||
16, // 17: mpc.coordinator.v1.SessionCoordinator.CloseSession:input_type -> mpc.coordinator.v1.CloseSessionRequest
|
||||
22, // 18: mpc.coordinator.v1.SessionCoordinator.SubmitDelegateShare:input_type -> mpc.coordinator.v1.SubmitDelegateShareRequest
|
||||
5, // 19: mpc.coordinator.v1.SessionCoordinator.CreateSession:output_type -> mpc.coordinator.v1.CreateSessionResponse
|
||||
7, // 20: mpc.coordinator.v1.SessionCoordinator.JoinSession:output_type -> mpc.coordinator.v1.JoinSessionResponse
|
||||
11, // 21: mpc.coordinator.v1.SessionCoordinator.GetSessionStatus:output_type -> mpc.coordinator.v1.GetSessionStatusResponse
|
||||
19, // 22: mpc.coordinator.v1.SessionCoordinator.MarkPartyReady:output_type -> mpc.coordinator.v1.MarkPartyReadyResponse
|
||||
21, // 23: mpc.coordinator.v1.SessionCoordinator.StartSession:output_type -> mpc.coordinator.v1.StartSessionResponse
|
||||
15, // 24: mpc.coordinator.v1.SessionCoordinator.ReportCompletion:output_type -> mpc.coordinator.v1.ReportCompletionResponse
|
||||
17, // 25: mpc.coordinator.v1.SessionCoordinator.CloseSession:output_type -> mpc.coordinator.v1.CloseSessionResponse
|
||||
23, // 26: mpc.coordinator.v1.SessionCoordinator.SubmitDelegateShare:output_type -> mpc.coordinator.v1.SubmitDelegateShareResponse
|
||||
19, // [19:27] is the sub-list for method output_type
|
||||
11, // [11:19] is the sub-list for method input_type
|
||||
11, // [11:11] is the sub-list for extension type_name
|
||||
11, // [11:11] is the sub-list for extension extendee
|
||||
0, // [0:11] is the sub-list for field type_name
|
||||
}
|
||||
|
||||
func init() { file_api_proto_session_coordinator_proto_init() }
|
||||
func file_api_proto_session_coordinator_proto_init() {
|
||||
if File_api_proto_session_coordinator_proto != nil {
|
||||
func init() { file_session_coordinator_proto_init() }
|
||||
func file_session_coordinator_proto_init() {
|
||||
if File_session_coordinator_proto != nil {
|
||||
return
|
||||
}
|
||||
type x struct{}
|
||||
out := protoimpl.TypeBuilder{
|
||||
File: protoimpl.DescBuilder{
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_api_proto_session_coordinator_proto_rawDesc), len(file_api_proto_session_coordinator_proto_rawDesc)),
|
||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_session_coordinator_proto_rawDesc), len(file_session_coordinator_proto_rawDesc)),
|
||||
NumEnums: 0,
|
||||
NumMessages: 24,
|
||||
NumMessages: 25,
|
||||
NumExtensions: 0,
|
||||
NumServices: 1,
|
||||
},
|
||||
GoTypes: file_api_proto_session_coordinator_proto_goTypes,
|
||||
DependencyIndexes: file_api_proto_session_coordinator_proto_depIdxs,
|
||||
MessageInfos: file_api_proto_session_coordinator_proto_msgTypes,
|
||||
GoTypes: file_session_coordinator_proto_goTypes,
|
||||
DependencyIndexes: file_session_coordinator_proto_depIdxs,
|
||||
MessageInfos: file_session_coordinator_proto_msgTypes,
|
||||
}.Build()
|
||||
File_api_proto_session_coordinator_proto = out.File
|
||||
file_api_proto_session_coordinator_proto_goTypes = nil
|
||||
file_api_proto_session_coordinator_proto_depIdxs = nil
|
||||
File_session_coordinator_proto = out.File
|
||||
file_session_coordinator_proto_goTypes = nil
|
||||
file_session_coordinator_proto_depIdxs = nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
// versions:
|
||||
// - protoc-gen-go-grpc v1.6.0
|
||||
// - protoc v6.33.1
|
||||
// source: api/proto/session_coordinator.proto
|
||||
// source: session_coordinator.proto
|
||||
|
||||
package coordinator
|
||||
|
||||
|
|
@ -391,5 +391,5 @@ var SessionCoordinator_ServiceDesc = grpc.ServiceDesc{
|
|||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{},
|
||||
Metadata: "api/proto/session_coordinator.proto",
|
||||
Metadata: "session_coordinator.proto",
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,700 +0,0 @@
|
|||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.6.0
|
||||
// - protoc v6.33.1
|
||||
// source: api/proto/message_router.proto
|
||||
|
||||
package router
|
||||
|
||||
import (
|
||||
context "context"
|
||||
grpc "google.golang.org/grpc"
|
||||
codes "google.golang.org/grpc/codes"
|
||||
status "google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// This is a compile-time assertion to ensure that this generated file
|
||||
// is compatible with the grpc package it is being compiled against.
|
||||
// Requires gRPC-Go v1.64.0 or later.
|
||||
const _ = grpc.SupportPackageIsVersion9
|
||||
|
||||
const (
|
||||
MessageRouter_RouteMessage_FullMethodName = "/mpc.router.v1.MessageRouter/RouteMessage"
|
||||
MessageRouter_SubscribeMessages_FullMethodName = "/mpc.router.v1.MessageRouter/SubscribeMessages"
|
||||
MessageRouter_GetPendingMessages_FullMethodName = "/mpc.router.v1.MessageRouter/GetPendingMessages"
|
||||
MessageRouter_AcknowledgeMessage_FullMethodName = "/mpc.router.v1.MessageRouter/AcknowledgeMessage"
|
||||
MessageRouter_GetMessageStatus_FullMethodName = "/mpc.router.v1.MessageRouter/GetMessageStatus"
|
||||
MessageRouter_RegisterParty_FullMethodName = "/mpc.router.v1.MessageRouter/RegisterParty"
|
||||
MessageRouter_Heartbeat_FullMethodName = "/mpc.router.v1.MessageRouter/Heartbeat"
|
||||
MessageRouter_SubscribeSessionEvents_FullMethodName = "/mpc.router.v1.MessageRouter/SubscribeSessionEvents"
|
||||
MessageRouter_PublishSessionEvent_FullMethodName = "/mpc.router.v1.MessageRouter/PublishSessionEvent"
|
||||
MessageRouter_GetRegisteredParties_FullMethodName = "/mpc.router.v1.MessageRouter/GetRegisteredParties"
|
||||
MessageRouter_JoinSession_FullMethodName = "/mpc.router.v1.MessageRouter/JoinSession"
|
||||
MessageRouter_MarkPartyReady_FullMethodName = "/mpc.router.v1.MessageRouter/MarkPartyReady"
|
||||
MessageRouter_ReportCompletion_FullMethodName = "/mpc.router.v1.MessageRouter/ReportCompletion"
|
||||
MessageRouter_GetSessionStatus_FullMethodName = "/mpc.router.v1.MessageRouter/GetSessionStatus"
|
||||
MessageRouter_SubmitDelegateShare_FullMethodName = "/mpc.router.v1.MessageRouter/SubmitDelegateShare"
|
||||
)
|
||||
|
||||
// MessageRouterClient is the client API for MessageRouter service.
|
||||
//
|
||||
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
|
||||
//
|
||||
// MessageRouter service handles MPC message routing
|
||||
// This is the ONLY service that server-parties need to connect to.
|
||||
// All session operations are proxied through Message Router to Session Coordinator.
|
||||
type MessageRouterClient interface {
|
||||
// RouteMessage routes a message from one party to others
|
||||
RouteMessage(ctx context.Context, in *RouteMessageRequest, opts ...grpc.CallOption) (*RouteMessageResponse, error)
|
||||
// SubscribeMessages subscribes to messages for a party (streaming)
|
||||
SubscribeMessages(ctx context.Context, in *SubscribeMessagesRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[MPCMessage], error)
|
||||
// GetPendingMessages retrieves pending messages (polling alternative)
|
||||
GetPendingMessages(ctx context.Context, in *GetPendingMessagesRequest, opts ...grpc.CallOption) (*GetPendingMessagesResponse, error)
|
||||
// AcknowledgeMessage acknowledges receipt of a message
|
||||
// Must be called after processing a message to confirm delivery
|
||||
AcknowledgeMessage(ctx context.Context, in *AcknowledgeMessageRequest, opts ...grpc.CallOption) (*AcknowledgeMessageResponse, error)
|
||||
// GetMessageStatus gets the delivery status of a message
|
||||
GetMessageStatus(ctx context.Context, in *GetMessageStatusRequest, opts ...grpc.CallOption) (*GetMessageStatusResponse, error)
|
||||
// RegisterParty registers a party with the message router (party actively connects)
|
||||
RegisterParty(ctx context.Context, in *RegisterPartyRequest, opts ...grpc.CallOption) (*RegisterPartyResponse, error)
|
||||
// Heartbeat sends a heartbeat to keep the party alive
|
||||
Heartbeat(ctx context.Context, in *HeartbeatRequest, opts ...grpc.CallOption) (*HeartbeatResponse, error)
|
||||
// SubscribeSessionEvents subscribes to session lifecycle events (session start, etc.)
|
||||
SubscribeSessionEvents(ctx context.Context, in *SubscribeSessionEventsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[SessionEvent], error)
|
||||
// PublishSessionEvent publishes a session event (called by Session Coordinator)
|
||||
PublishSessionEvent(ctx context.Context, in *PublishSessionEventRequest, opts ...grpc.CallOption) (*PublishSessionEventResponse, error)
|
||||
// GetRegisteredParties returns all registered parties (for Session Coordinator party discovery)
|
||||
GetRegisteredParties(ctx context.Context, in *GetRegisteredPartiesRequest, opts ...grpc.CallOption) (*GetRegisteredPartiesResponse, error)
|
||||
// JoinSession joins a session (proxied to Session Coordinator)
|
||||
JoinSession(ctx context.Context, in *JoinSessionRequest, opts ...grpc.CallOption) (*JoinSessionResponse, error)
|
||||
// MarkPartyReady marks a party as ready (proxied to Session Coordinator)
|
||||
MarkPartyReady(ctx context.Context, in *MarkPartyReadyRequest, opts ...grpc.CallOption) (*MarkPartyReadyResponse, error)
|
||||
// ReportCompletion reports completion (proxied to Session Coordinator)
|
||||
ReportCompletion(ctx context.Context, in *ReportCompletionRequest, opts ...grpc.CallOption) (*ReportCompletionResponse, error)
|
||||
// GetSessionStatus gets session status (proxied to Session Coordinator)
|
||||
GetSessionStatus(ctx context.Context, in *GetSessionStatusRequest, opts ...grpc.CallOption) (*GetSessionStatusResponse, error)
|
||||
// SubmitDelegateShare submits user's share from delegate party (proxied to Session Coordinator)
|
||||
SubmitDelegateShare(ctx context.Context, in *SubmitDelegateShareRequest, opts ...grpc.CallOption) (*SubmitDelegateShareResponse, error)
|
||||
}
|
||||
|
||||
type messageRouterClient struct {
|
||||
cc grpc.ClientConnInterface
|
||||
}
|
||||
|
||||
func NewMessageRouterClient(cc grpc.ClientConnInterface) MessageRouterClient {
|
||||
return &messageRouterClient{cc}
|
||||
}
|
||||
|
||||
func (c *messageRouterClient) RouteMessage(ctx context.Context, in *RouteMessageRequest, opts ...grpc.CallOption) (*RouteMessageResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(RouteMessageResponse)
|
||||
err := c.cc.Invoke(ctx, MessageRouter_RouteMessage_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *messageRouterClient) SubscribeMessages(ctx context.Context, in *SubscribeMessagesRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[MPCMessage], error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
stream, err := c.cc.NewStream(ctx, &MessageRouter_ServiceDesc.Streams[0], MessageRouter_SubscribeMessages_FullMethodName, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
x := &grpc.GenericClientStream[SubscribeMessagesRequest, MPCMessage]{ClientStream: stream}
|
||||
if err := x.ClientStream.SendMsg(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := x.ClientStream.CloseSend(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return x, nil
|
||||
}
|
||||
|
||||
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
||||
type MessageRouter_SubscribeMessagesClient = grpc.ServerStreamingClient[MPCMessage]
|
||||
|
||||
func (c *messageRouterClient) GetPendingMessages(ctx context.Context, in *GetPendingMessagesRequest, opts ...grpc.CallOption) (*GetPendingMessagesResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(GetPendingMessagesResponse)
|
||||
err := c.cc.Invoke(ctx, MessageRouter_GetPendingMessages_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *messageRouterClient) AcknowledgeMessage(ctx context.Context, in *AcknowledgeMessageRequest, opts ...grpc.CallOption) (*AcknowledgeMessageResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(AcknowledgeMessageResponse)
|
||||
err := c.cc.Invoke(ctx, MessageRouter_AcknowledgeMessage_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *messageRouterClient) GetMessageStatus(ctx context.Context, in *GetMessageStatusRequest, opts ...grpc.CallOption) (*GetMessageStatusResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(GetMessageStatusResponse)
|
||||
err := c.cc.Invoke(ctx, MessageRouter_GetMessageStatus_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *messageRouterClient) RegisterParty(ctx context.Context, in *RegisterPartyRequest, opts ...grpc.CallOption) (*RegisterPartyResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(RegisterPartyResponse)
|
||||
err := c.cc.Invoke(ctx, MessageRouter_RegisterParty_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *messageRouterClient) Heartbeat(ctx context.Context, in *HeartbeatRequest, opts ...grpc.CallOption) (*HeartbeatResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(HeartbeatResponse)
|
||||
err := c.cc.Invoke(ctx, MessageRouter_Heartbeat_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *messageRouterClient) SubscribeSessionEvents(ctx context.Context, in *SubscribeSessionEventsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[SessionEvent], error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
stream, err := c.cc.NewStream(ctx, &MessageRouter_ServiceDesc.Streams[1], MessageRouter_SubscribeSessionEvents_FullMethodName, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
x := &grpc.GenericClientStream[SubscribeSessionEventsRequest, SessionEvent]{ClientStream: stream}
|
||||
if err := x.ClientStream.SendMsg(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := x.ClientStream.CloseSend(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return x, nil
|
||||
}
|
||||
|
||||
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
||||
type MessageRouter_SubscribeSessionEventsClient = grpc.ServerStreamingClient[SessionEvent]
|
||||
|
||||
func (c *messageRouterClient) PublishSessionEvent(ctx context.Context, in *PublishSessionEventRequest, opts ...grpc.CallOption) (*PublishSessionEventResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(PublishSessionEventResponse)
|
||||
err := c.cc.Invoke(ctx, MessageRouter_PublishSessionEvent_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *messageRouterClient) GetRegisteredParties(ctx context.Context, in *GetRegisteredPartiesRequest, opts ...grpc.CallOption) (*GetRegisteredPartiesResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(GetRegisteredPartiesResponse)
|
||||
err := c.cc.Invoke(ctx, MessageRouter_GetRegisteredParties_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *messageRouterClient) JoinSession(ctx context.Context, in *JoinSessionRequest, opts ...grpc.CallOption) (*JoinSessionResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(JoinSessionResponse)
|
||||
err := c.cc.Invoke(ctx, MessageRouter_JoinSession_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *messageRouterClient) MarkPartyReady(ctx context.Context, in *MarkPartyReadyRequest, opts ...grpc.CallOption) (*MarkPartyReadyResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(MarkPartyReadyResponse)
|
||||
err := c.cc.Invoke(ctx, MessageRouter_MarkPartyReady_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *messageRouterClient) ReportCompletion(ctx context.Context, in *ReportCompletionRequest, opts ...grpc.CallOption) (*ReportCompletionResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ReportCompletionResponse)
|
||||
err := c.cc.Invoke(ctx, MessageRouter_ReportCompletion_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *messageRouterClient) GetSessionStatus(ctx context.Context, in *GetSessionStatusRequest, opts ...grpc.CallOption) (*GetSessionStatusResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(GetSessionStatusResponse)
|
||||
err := c.cc.Invoke(ctx, MessageRouter_GetSessionStatus_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *messageRouterClient) SubmitDelegateShare(ctx context.Context, in *SubmitDelegateShareRequest, opts ...grpc.CallOption) (*SubmitDelegateShareResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(SubmitDelegateShareResponse)
|
||||
err := c.cc.Invoke(ctx, MessageRouter_SubmitDelegateShare_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// MessageRouterServer is the server API for MessageRouter service.
|
||||
// All implementations must embed UnimplementedMessageRouterServer
|
||||
// for forward compatibility.
|
||||
//
|
||||
// MessageRouter service handles MPC message routing
|
||||
// This is the ONLY service that server-parties need to connect to.
|
||||
// All session operations are proxied through Message Router to Session Coordinator.
|
||||
type MessageRouterServer interface {
|
||||
// RouteMessage routes a message from one party to others
|
||||
RouteMessage(context.Context, *RouteMessageRequest) (*RouteMessageResponse, error)
|
||||
// SubscribeMessages subscribes to messages for a party (streaming)
|
||||
SubscribeMessages(*SubscribeMessagesRequest, grpc.ServerStreamingServer[MPCMessage]) error
|
||||
// GetPendingMessages retrieves pending messages (polling alternative)
|
||||
GetPendingMessages(context.Context, *GetPendingMessagesRequest) (*GetPendingMessagesResponse, error)
|
||||
// AcknowledgeMessage acknowledges receipt of a message
|
||||
// Must be called after processing a message to confirm delivery
|
||||
AcknowledgeMessage(context.Context, *AcknowledgeMessageRequest) (*AcknowledgeMessageResponse, error)
|
||||
// GetMessageStatus gets the delivery status of a message
|
||||
GetMessageStatus(context.Context, *GetMessageStatusRequest) (*GetMessageStatusResponse, error)
|
||||
// RegisterParty registers a party with the message router (party actively connects)
|
||||
RegisterParty(context.Context, *RegisterPartyRequest) (*RegisterPartyResponse, error)
|
||||
// Heartbeat sends a heartbeat to keep the party alive
|
||||
Heartbeat(context.Context, *HeartbeatRequest) (*HeartbeatResponse, error)
|
||||
// SubscribeSessionEvents subscribes to session lifecycle events (session start, etc.)
|
||||
SubscribeSessionEvents(*SubscribeSessionEventsRequest, grpc.ServerStreamingServer[SessionEvent]) error
|
||||
// PublishSessionEvent publishes a session event (called by Session Coordinator)
|
||||
PublishSessionEvent(context.Context, *PublishSessionEventRequest) (*PublishSessionEventResponse, error)
|
||||
// GetRegisteredParties returns all registered parties (for Session Coordinator party discovery)
|
||||
GetRegisteredParties(context.Context, *GetRegisteredPartiesRequest) (*GetRegisteredPartiesResponse, error)
|
||||
// JoinSession joins a session (proxied to Session Coordinator)
|
||||
JoinSession(context.Context, *JoinSessionRequest) (*JoinSessionResponse, error)
|
||||
// MarkPartyReady marks a party as ready (proxied to Session Coordinator)
|
||||
MarkPartyReady(context.Context, *MarkPartyReadyRequest) (*MarkPartyReadyResponse, error)
|
||||
// ReportCompletion reports completion (proxied to Session Coordinator)
|
||||
ReportCompletion(context.Context, *ReportCompletionRequest) (*ReportCompletionResponse, error)
|
||||
// GetSessionStatus gets session status (proxied to Session Coordinator)
|
||||
GetSessionStatus(context.Context, *GetSessionStatusRequest) (*GetSessionStatusResponse, error)
|
||||
// SubmitDelegateShare submits user's share from delegate party (proxied to Session Coordinator)
|
||||
SubmitDelegateShare(context.Context, *SubmitDelegateShareRequest) (*SubmitDelegateShareResponse, error)
|
||||
mustEmbedUnimplementedMessageRouterServer()
|
||||
}
|
||||
|
||||
// UnimplementedMessageRouterServer must be embedded to have
|
||||
// forward compatible implementations.
|
||||
//
|
||||
// NOTE: this should be embedded by value instead of pointer to avoid a nil
|
||||
// pointer dereference when methods are called.
|
||||
type UnimplementedMessageRouterServer struct{}
|
||||
|
||||
func (UnimplementedMessageRouterServer) RouteMessage(context.Context, *RouteMessageRequest) (*RouteMessageResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method RouteMessage not implemented")
|
||||
}
|
||||
func (UnimplementedMessageRouterServer) SubscribeMessages(*SubscribeMessagesRequest, grpc.ServerStreamingServer[MPCMessage]) error {
|
||||
return status.Error(codes.Unimplemented, "method SubscribeMessages not implemented")
|
||||
}
|
||||
func (UnimplementedMessageRouterServer) GetPendingMessages(context.Context, *GetPendingMessagesRequest) (*GetPendingMessagesResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method GetPendingMessages not implemented")
|
||||
}
|
||||
func (UnimplementedMessageRouterServer) AcknowledgeMessage(context.Context, *AcknowledgeMessageRequest) (*AcknowledgeMessageResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method AcknowledgeMessage not implemented")
|
||||
}
|
||||
func (UnimplementedMessageRouterServer) GetMessageStatus(context.Context, *GetMessageStatusRequest) (*GetMessageStatusResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method GetMessageStatus not implemented")
|
||||
}
|
||||
func (UnimplementedMessageRouterServer) RegisterParty(context.Context, *RegisterPartyRequest) (*RegisterPartyResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method RegisterParty not implemented")
|
||||
}
|
||||
func (UnimplementedMessageRouterServer) Heartbeat(context.Context, *HeartbeatRequest) (*HeartbeatResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method Heartbeat not implemented")
|
||||
}
|
||||
func (UnimplementedMessageRouterServer) SubscribeSessionEvents(*SubscribeSessionEventsRequest, grpc.ServerStreamingServer[SessionEvent]) error {
|
||||
return status.Error(codes.Unimplemented, "method SubscribeSessionEvents not implemented")
|
||||
}
|
||||
func (UnimplementedMessageRouterServer) PublishSessionEvent(context.Context, *PublishSessionEventRequest) (*PublishSessionEventResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method PublishSessionEvent not implemented")
|
||||
}
|
||||
func (UnimplementedMessageRouterServer) GetRegisteredParties(context.Context, *GetRegisteredPartiesRequest) (*GetRegisteredPartiesResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method GetRegisteredParties not implemented")
|
||||
}
|
||||
func (UnimplementedMessageRouterServer) JoinSession(context.Context, *JoinSessionRequest) (*JoinSessionResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method JoinSession not implemented")
|
||||
}
|
||||
func (UnimplementedMessageRouterServer) MarkPartyReady(context.Context, *MarkPartyReadyRequest) (*MarkPartyReadyResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method MarkPartyReady not implemented")
|
||||
}
|
||||
func (UnimplementedMessageRouterServer) ReportCompletion(context.Context, *ReportCompletionRequest) (*ReportCompletionResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method ReportCompletion not implemented")
|
||||
}
|
||||
func (UnimplementedMessageRouterServer) GetSessionStatus(context.Context, *GetSessionStatusRequest) (*GetSessionStatusResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method GetSessionStatus not implemented")
|
||||
}
|
||||
func (UnimplementedMessageRouterServer) SubmitDelegateShare(context.Context, *SubmitDelegateShareRequest) (*SubmitDelegateShareResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method SubmitDelegateShare not implemented")
|
||||
}
|
||||
func (UnimplementedMessageRouterServer) mustEmbedUnimplementedMessageRouterServer() {}
|
||||
func (UnimplementedMessageRouterServer) testEmbeddedByValue() {}
|
||||
|
||||
// UnsafeMessageRouterServer may be embedded to opt out of forward compatibility for this service.
|
||||
// Use of this interface is not recommended, as added methods to MessageRouterServer will
|
||||
// result in compilation errors.
|
||||
type UnsafeMessageRouterServer interface {
|
||||
mustEmbedUnimplementedMessageRouterServer()
|
||||
}
|
||||
|
||||
func RegisterMessageRouterServer(s grpc.ServiceRegistrar, srv MessageRouterServer) {
|
||||
// If the following call panics, it indicates UnimplementedMessageRouterServer was
|
||||
// embedded by pointer and is nil. This will cause panics if an
|
||||
// unimplemented method is ever invoked, so we test this at initialization
|
||||
// time to prevent it from happening at runtime later due to I/O.
|
||||
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
|
||||
t.testEmbeddedByValue()
|
||||
}
|
||||
s.RegisterService(&MessageRouter_ServiceDesc, srv)
|
||||
}
|
||||
|
||||
func _MessageRouter_RouteMessage_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(RouteMessageRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(MessageRouterServer).RouteMessage(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: MessageRouter_RouteMessage_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(MessageRouterServer).RouteMessage(ctx, req.(*RouteMessageRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _MessageRouter_SubscribeMessages_Handler(srv interface{}, stream grpc.ServerStream) error {
|
||||
m := new(SubscribeMessagesRequest)
|
||||
if err := stream.RecvMsg(m); err != nil {
|
||||
return err
|
||||
}
|
||||
return srv.(MessageRouterServer).SubscribeMessages(m, &grpc.GenericServerStream[SubscribeMessagesRequest, MPCMessage]{ServerStream: stream})
|
||||
}
|
||||
|
||||
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
||||
type MessageRouter_SubscribeMessagesServer = grpc.ServerStreamingServer[MPCMessage]
|
||||
|
||||
func _MessageRouter_GetPendingMessages_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(GetPendingMessagesRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(MessageRouterServer).GetPendingMessages(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: MessageRouter_GetPendingMessages_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(MessageRouterServer).GetPendingMessages(ctx, req.(*GetPendingMessagesRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _MessageRouter_AcknowledgeMessage_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(AcknowledgeMessageRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(MessageRouterServer).AcknowledgeMessage(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: MessageRouter_AcknowledgeMessage_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(MessageRouterServer).AcknowledgeMessage(ctx, req.(*AcknowledgeMessageRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _MessageRouter_GetMessageStatus_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(GetMessageStatusRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(MessageRouterServer).GetMessageStatus(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: MessageRouter_GetMessageStatus_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(MessageRouterServer).GetMessageStatus(ctx, req.(*GetMessageStatusRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _MessageRouter_RegisterParty_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(RegisterPartyRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(MessageRouterServer).RegisterParty(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: MessageRouter_RegisterParty_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(MessageRouterServer).RegisterParty(ctx, req.(*RegisterPartyRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _MessageRouter_Heartbeat_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(HeartbeatRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(MessageRouterServer).Heartbeat(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: MessageRouter_Heartbeat_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(MessageRouterServer).Heartbeat(ctx, req.(*HeartbeatRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _MessageRouter_SubscribeSessionEvents_Handler(srv interface{}, stream grpc.ServerStream) error {
|
||||
m := new(SubscribeSessionEventsRequest)
|
||||
if err := stream.RecvMsg(m); err != nil {
|
||||
return err
|
||||
}
|
||||
return srv.(MessageRouterServer).SubscribeSessionEvents(m, &grpc.GenericServerStream[SubscribeSessionEventsRequest, SessionEvent]{ServerStream: stream})
|
||||
}
|
||||
|
||||
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
||||
type MessageRouter_SubscribeSessionEventsServer = grpc.ServerStreamingServer[SessionEvent]
|
||||
|
||||
func _MessageRouter_PublishSessionEvent_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(PublishSessionEventRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(MessageRouterServer).PublishSessionEvent(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: MessageRouter_PublishSessionEvent_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(MessageRouterServer).PublishSessionEvent(ctx, req.(*PublishSessionEventRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _MessageRouter_GetRegisteredParties_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(GetRegisteredPartiesRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(MessageRouterServer).GetRegisteredParties(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: MessageRouter_GetRegisteredParties_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(MessageRouterServer).GetRegisteredParties(ctx, req.(*GetRegisteredPartiesRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _MessageRouter_JoinSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(JoinSessionRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(MessageRouterServer).JoinSession(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: MessageRouter_JoinSession_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(MessageRouterServer).JoinSession(ctx, req.(*JoinSessionRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _MessageRouter_MarkPartyReady_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(MarkPartyReadyRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(MessageRouterServer).MarkPartyReady(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: MessageRouter_MarkPartyReady_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(MessageRouterServer).MarkPartyReady(ctx, req.(*MarkPartyReadyRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _MessageRouter_ReportCompletion_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ReportCompletionRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(MessageRouterServer).ReportCompletion(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: MessageRouter_ReportCompletion_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(MessageRouterServer).ReportCompletion(ctx, req.(*ReportCompletionRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _MessageRouter_GetSessionStatus_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(GetSessionStatusRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(MessageRouterServer).GetSessionStatus(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: MessageRouter_GetSessionStatus_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(MessageRouterServer).GetSessionStatus(ctx, req.(*GetSessionStatusRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _MessageRouter_SubmitDelegateShare_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(SubmitDelegateShareRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(MessageRouterServer).SubmitDelegateShare(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: MessageRouter_SubmitDelegateShare_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(MessageRouterServer).SubmitDelegateShare(ctx, req.(*SubmitDelegateShareRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
// MessageRouter_ServiceDesc is the grpc.ServiceDesc for MessageRouter service.
|
||||
// It's only intended for direct use with grpc.RegisterService,
|
||||
// and not to be introspected or modified (even as a copy)
|
||||
var MessageRouter_ServiceDesc = grpc.ServiceDesc{
|
||||
ServiceName: "mpc.router.v1.MessageRouter",
|
||||
HandlerType: (*MessageRouterServer)(nil),
|
||||
Methods: []grpc.MethodDesc{
|
||||
{
|
||||
MethodName: "RouteMessage",
|
||||
Handler: _MessageRouter_RouteMessage_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "GetPendingMessages",
|
||||
Handler: _MessageRouter_GetPendingMessages_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "AcknowledgeMessage",
|
||||
Handler: _MessageRouter_AcknowledgeMessage_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "GetMessageStatus",
|
||||
Handler: _MessageRouter_GetMessageStatus_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "RegisterParty",
|
||||
Handler: _MessageRouter_RegisterParty_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "Heartbeat",
|
||||
Handler: _MessageRouter_Heartbeat_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "PublishSessionEvent",
|
||||
Handler: _MessageRouter_PublishSessionEvent_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "GetRegisteredParties",
|
||||
Handler: _MessageRouter_GetRegisteredParties_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "JoinSession",
|
||||
Handler: _MessageRouter_JoinSession_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "MarkPartyReady",
|
||||
Handler: _MessageRouter_MarkPartyReady_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ReportCompletion",
|
||||
Handler: _MessageRouter_ReportCompletion_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "GetSessionStatus",
|
||||
Handler: _MessageRouter_GetSessionStatus_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "SubmitDelegateShare",
|
||||
Handler: _MessageRouter_SubmitDelegateShare_Handler,
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{
|
||||
{
|
||||
StreamName: "SubscribeMessages",
|
||||
Handler: _MessageRouter_SubscribeMessages_Handler,
|
||||
ServerStreams: true,
|
||||
},
|
||||
{
|
||||
StreamName: "SubscribeSessionEvents",
|
||||
Handler: _MessageRouter_SubscribeSessionEvents_Handler,
|
||||
ServerStreams: true,
|
||||
},
|
||||
},
|
||||
Metadata: "api/proto/message_router.proto",
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -21,7 +21,7 @@ service SessionCoordinator {
|
|||
|
||||
// CreateSessionRequest creates a new MPC session
|
||||
message CreateSessionRequest {
|
||||
string session_type = 1; // "keygen" or "sign"
|
||||
string session_type = 1; // "keygen", "sign", or "co_managed_keygen"
|
||||
int32 threshold_n = 2; // Total number of parties
|
||||
int32 threshold_t = 3; // Minimum required parties
|
||||
repeated ParticipantInfo participants = 4; // Optional: if empty, coordinator selects automatically
|
||||
|
|
@ -32,6 +32,9 @@ message CreateSessionRequest {
|
|||
DelegateUserShare delegate_user_share = 8;
|
||||
// For sign sessions: which keygen session's shares to use
|
||||
string keygen_session_id = 9;
|
||||
// For co_managed_keygen sessions: wallet name and invite code
|
||||
string wallet_name = 10; // Wallet name (for co_managed_keygen)
|
||||
string invite_code = 11; // Invite code for participants to join (for co_managed_keygen)
|
||||
}
|
||||
|
||||
// DelegateUserShare contains user's share for delegate party to use in signing
|
||||
|
|
@ -98,6 +101,9 @@ message SessionInfo {
|
|||
string status = 6;
|
||||
// For sign sessions: which keygen session's shares to use
|
||||
string keygen_session_id = 7;
|
||||
// For co_managed_keygen sessions
|
||||
string wallet_name = 8; // Wallet name (for co_managed_keygen)
|
||||
string invite_code = 9; // Invite code (for co_managed_keygen)
|
||||
}
|
||||
|
||||
// PartyInfo contains party information
|
||||
|
|
@ -126,6 +132,20 @@ message GetSessionStatusResponse {
|
|||
// Delegate share info (returned when keygen session completed and delegate party submitted share)
|
||||
// Only populated if session_type="keygen" AND has_delegate=true AND session is completed
|
||||
DelegateShareInfo delegate_share = 8;
|
||||
// participants contains detailed participant information including party_index
|
||||
// Used by service-party-app for co_managed_keygen sessions
|
||||
repeated ParticipantStatus participants = 9;
|
||||
// threshold_n and threshold_t - actual threshold values from session config
|
||||
// Used for co_managed_keygen sessions where total_parties may differ from threshold_n during joining
|
||||
int32 threshold_n = 10; // Total number of parties required (e.g., 3 in 2-of-3)
|
||||
int32 threshold_t = 11; // Minimum parties needed to sign (e.g., 2 in 2-of-3)
|
||||
}
|
||||
|
||||
// ParticipantStatus contains participant status information
|
||||
message ParticipantStatus {
|
||||
string party_id = 1;
|
||||
int32 party_index = 2;
|
||||
string status = 3; // pending, joined, ready, completed
|
||||
}
|
||||
|
||||
// DelegateShareInfo contains the delegate party's share for user
|
||||
|
|
|
|||
|
|
@ -1,395 +0,0 @@
|
|||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.6.0
|
||||
// - protoc v6.33.1
|
||||
// source: api/proto/session_coordinator.proto
|
||||
|
||||
package coordinator
|
||||
|
||||
import (
|
||||
context "context"
|
||||
grpc "google.golang.org/grpc"
|
||||
codes "google.golang.org/grpc/codes"
|
||||
status "google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// This is a compile-time assertion to ensure that this generated file
|
||||
// is compatible with the grpc package it is being compiled against.
|
||||
// Requires gRPC-Go v1.64.0 or later.
|
||||
const _ = grpc.SupportPackageIsVersion9
|
||||
|
||||
const (
|
||||
SessionCoordinator_CreateSession_FullMethodName = "/mpc.coordinator.v1.SessionCoordinator/CreateSession"
|
||||
SessionCoordinator_JoinSession_FullMethodName = "/mpc.coordinator.v1.SessionCoordinator/JoinSession"
|
||||
SessionCoordinator_GetSessionStatus_FullMethodName = "/mpc.coordinator.v1.SessionCoordinator/GetSessionStatus"
|
||||
SessionCoordinator_MarkPartyReady_FullMethodName = "/mpc.coordinator.v1.SessionCoordinator/MarkPartyReady"
|
||||
SessionCoordinator_StartSession_FullMethodName = "/mpc.coordinator.v1.SessionCoordinator/StartSession"
|
||||
SessionCoordinator_ReportCompletion_FullMethodName = "/mpc.coordinator.v1.SessionCoordinator/ReportCompletion"
|
||||
SessionCoordinator_CloseSession_FullMethodName = "/mpc.coordinator.v1.SessionCoordinator/CloseSession"
|
||||
SessionCoordinator_SubmitDelegateShare_FullMethodName = "/mpc.coordinator.v1.SessionCoordinator/SubmitDelegateShare"
|
||||
)
|
||||
|
||||
// SessionCoordinatorClient is the client API for SessionCoordinator service.
|
||||
//
|
||||
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
|
||||
//
|
||||
// SessionCoordinator service manages MPC sessions
|
||||
type SessionCoordinatorClient interface {
|
||||
// Session management
|
||||
CreateSession(ctx context.Context, in *CreateSessionRequest, opts ...grpc.CallOption) (*CreateSessionResponse, error)
|
||||
JoinSession(ctx context.Context, in *JoinSessionRequest, opts ...grpc.CallOption) (*JoinSessionResponse, error)
|
||||
GetSessionStatus(ctx context.Context, in *GetSessionStatusRequest, opts ...grpc.CallOption) (*GetSessionStatusResponse, error)
|
||||
MarkPartyReady(ctx context.Context, in *MarkPartyReadyRequest, opts ...grpc.CallOption) (*MarkPartyReadyResponse, error)
|
||||
StartSession(ctx context.Context, in *StartSessionRequest, opts ...grpc.CallOption) (*StartSessionResponse, error)
|
||||
ReportCompletion(ctx context.Context, in *ReportCompletionRequest, opts ...grpc.CallOption) (*ReportCompletionResponse, error)
|
||||
CloseSession(ctx context.Context, in *CloseSessionRequest, opts ...grpc.CallOption) (*CloseSessionResponse, error)
|
||||
// Delegate party share submission (delegate party submits user's share after keygen)
|
||||
SubmitDelegateShare(ctx context.Context, in *SubmitDelegateShareRequest, opts ...grpc.CallOption) (*SubmitDelegateShareResponse, error)
|
||||
}
|
||||
|
||||
type sessionCoordinatorClient struct {
|
||||
cc grpc.ClientConnInterface
|
||||
}
|
||||
|
||||
func NewSessionCoordinatorClient(cc grpc.ClientConnInterface) SessionCoordinatorClient {
|
||||
return &sessionCoordinatorClient{cc}
|
||||
}
|
||||
|
||||
func (c *sessionCoordinatorClient) CreateSession(ctx context.Context, in *CreateSessionRequest, opts ...grpc.CallOption) (*CreateSessionResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(CreateSessionResponse)
|
||||
err := c.cc.Invoke(ctx, SessionCoordinator_CreateSession_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *sessionCoordinatorClient) JoinSession(ctx context.Context, in *JoinSessionRequest, opts ...grpc.CallOption) (*JoinSessionResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(JoinSessionResponse)
|
||||
err := c.cc.Invoke(ctx, SessionCoordinator_JoinSession_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *sessionCoordinatorClient) GetSessionStatus(ctx context.Context, in *GetSessionStatusRequest, opts ...grpc.CallOption) (*GetSessionStatusResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(GetSessionStatusResponse)
|
||||
err := c.cc.Invoke(ctx, SessionCoordinator_GetSessionStatus_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *sessionCoordinatorClient) MarkPartyReady(ctx context.Context, in *MarkPartyReadyRequest, opts ...grpc.CallOption) (*MarkPartyReadyResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(MarkPartyReadyResponse)
|
||||
err := c.cc.Invoke(ctx, SessionCoordinator_MarkPartyReady_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *sessionCoordinatorClient) StartSession(ctx context.Context, in *StartSessionRequest, opts ...grpc.CallOption) (*StartSessionResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(StartSessionResponse)
|
||||
err := c.cc.Invoke(ctx, SessionCoordinator_StartSession_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *sessionCoordinatorClient) ReportCompletion(ctx context.Context, in *ReportCompletionRequest, opts ...grpc.CallOption) (*ReportCompletionResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ReportCompletionResponse)
|
||||
err := c.cc.Invoke(ctx, SessionCoordinator_ReportCompletion_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *sessionCoordinatorClient) CloseSession(ctx context.Context, in *CloseSessionRequest, opts ...grpc.CallOption) (*CloseSessionResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(CloseSessionResponse)
|
||||
err := c.cc.Invoke(ctx, SessionCoordinator_CloseSession_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *sessionCoordinatorClient) SubmitDelegateShare(ctx context.Context, in *SubmitDelegateShareRequest, opts ...grpc.CallOption) (*SubmitDelegateShareResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(SubmitDelegateShareResponse)
|
||||
err := c.cc.Invoke(ctx, SessionCoordinator_SubmitDelegateShare_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SessionCoordinatorServer is the server API for SessionCoordinator service.
|
||||
// All implementations must embed UnimplementedSessionCoordinatorServer
|
||||
// for forward compatibility.
|
||||
//
|
||||
// SessionCoordinator service manages MPC sessions
|
||||
type SessionCoordinatorServer interface {
|
||||
// Session management
|
||||
CreateSession(context.Context, *CreateSessionRequest) (*CreateSessionResponse, error)
|
||||
JoinSession(context.Context, *JoinSessionRequest) (*JoinSessionResponse, error)
|
||||
GetSessionStatus(context.Context, *GetSessionStatusRequest) (*GetSessionStatusResponse, error)
|
||||
MarkPartyReady(context.Context, *MarkPartyReadyRequest) (*MarkPartyReadyResponse, error)
|
||||
StartSession(context.Context, *StartSessionRequest) (*StartSessionResponse, error)
|
||||
ReportCompletion(context.Context, *ReportCompletionRequest) (*ReportCompletionResponse, error)
|
||||
CloseSession(context.Context, *CloseSessionRequest) (*CloseSessionResponse, error)
|
||||
// Delegate party share submission (delegate party submits user's share after keygen)
|
||||
SubmitDelegateShare(context.Context, *SubmitDelegateShareRequest) (*SubmitDelegateShareResponse, error)
|
||||
mustEmbedUnimplementedSessionCoordinatorServer()
|
||||
}
|
||||
|
||||
// UnimplementedSessionCoordinatorServer must be embedded to have
|
||||
// forward compatible implementations.
|
||||
//
|
||||
// NOTE: this should be embedded by value instead of pointer to avoid a nil
|
||||
// pointer dereference when methods are called.
|
||||
type UnimplementedSessionCoordinatorServer struct{}
|
||||
|
||||
func (UnimplementedSessionCoordinatorServer) CreateSession(context.Context, *CreateSessionRequest) (*CreateSessionResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method CreateSession not implemented")
|
||||
}
|
||||
func (UnimplementedSessionCoordinatorServer) JoinSession(context.Context, *JoinSessionRequest) (*JoinSessionResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method JoinSession not implemented")
|
||||
}
|
||||
func (UnimplementedSessionCoordinatorServer) GetSessionStatus(context.Context, *GetSessionStatusRequest) (*GetSessionStatusResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method GetSessionStatus not implemented")
|
||||
}
|
||||
func (UnimplementedSessionCoordinatorServer) MarkPartyReady(context.Context, *MarkPartyReadyRequest) (*MarkPartyReadyResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method MarkPartyReady not implemented")
|
||||
}
|
||||
func (UnimplementedSessionCoordinatorServer) StartSession(context.Context, *StartSessionRequest) (*StartSessionResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method StartSession not implemented")
|
||||
}
|
||||
func (UnimplementedSessionCoordinatorServer) ReportCompletion(context.Context, *ReportCompletionRequest) (*ReportCompletionResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method ReportCompletion not implemented")
|
||||
}
|
||||
func (UnimplementedSessionCoordinatorServer) CloseSession(context.Context, *CloseSessionRequest) (*CloseSessionResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method CloseSession not implemented")
|
||||
}
|
||||
func (UnimplementedSessionCoordinatorServer) SubmitDelegateShare(context.Context, *SubmitDelegateShareRequest) (*SubmitDelegateShareResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method SubmitDelegateShare not implemented")
|
||||
}
|
||||
func (UnimplementedSessionCoordinatorServer) mustEmbedUnimplementedSessionCoordinatorServer() {}
|
||||
func (UnimplementedSessionCoordinatorServer) testEmbeddedByValue() {}
|
||||
|
||||
// UnsafeSessionCoordinatorServer may be embedded to opt out of forward compatibility for this service.
|
||||
// Use of this interface is not recommended, as added methods to SessionCoordinatorServer will
|
||||
// result in compilation errors.
|
||||
type UnsafeSessionCoordinatorServer interface {
|
||||
mustEmbedUnimplementedSessionCoordinatorServer()
|
||||
}
|
||||
|
||||
func RegisterSessionCoordinatorServer(s grpc.ServiceRegistrar, srv SessionCoordinatorServer) {
|
||||
// If the following call panics, it indicates UnimplementedSessionCoordinatorServer was
|
||||
// embedded by pointer and is nil. This will cause panics if an
|
||||
// unimplemented method is ever invoked, so we test this at initialization
|
||||
// time to prevent it from happening at runtime later due to I/O.
|
||||
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
|
||||
t.testEmbeddedByValue()
|
||||
}
|
||||
s.RegisterService(&SessionCoordinator_ServiceDesc, srv)
|
||||
}
|
||||
|
||||
func _SessionCoordinator_CreateSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(CreateSessionRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(SessionCoordinatorServer).CreateSession(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: SessionCoordinator_CreateSession_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(SessionCoordinatorServer).CreateSession(ctx, req.(*CreateSessionRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _SessionCoordinator_JoinSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(JoinSessionRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(SessionCoordinatorServer).JoinSession(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: SessionCoordinator_JoinSession_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(SessionCoordinatorServer).JoinSession(ctx, req.(*JoinSessionRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _SessionCoordinator_GetSessionStatus_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(GetSessionStatusRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(SessionCoordinatorServer).GetSessionStatus(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: SessionCoordinator_GetSessionStatus_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(SessionCoordinatorServer).GetSessionStatus(ctx, req.(*GetSessionStatusRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _SessionCoordinator_MarkPartyReady_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(MarkPartyReadyRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(SessionCoordinatorServer).MarkPartyReady(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: SessionCoordinator_MarkPartyReady_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(SessionCoordinatorServer).MarkPartyReady(ctx, req.(*MarkPartyReadyRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _SessionCoordinator_StartSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(StartSessionRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(SessionCoordinatorServer).StartSession(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: SessionCoordinator_StartSession_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(SessionCoordinatorServer).StartSession(ctx, req.(*StartSessionRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _SessionCoordinator_ReportCompletion_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ReportCompletionRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(SessionCoordinatorServer).ReportCompletion(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: SessionCoordinator_ReportCompletion_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(SessionCoordinatorServer).ReportCompletion(ctx, req.(*ReportCompletionRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _SessionCoordinator_CloseSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(CloseSessionRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(SessionCoordinatorServer).CloseSession(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: SessionCoordinator_CloseSession_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(SessionCoordinatorServer).CloseSession(ctx, req.(*CloseSessionRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _SessionCoordinator_SubmitDelegateShare_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(SubmitDelegateShareRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(SessionCoordinatorServer).SubmitDelegateShare(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: SessionCoordinator_SubmitDelegateShare_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(SessionCoordinatorServer).SubmitDelegateShare(ctx, req.(*SubmitDelegateShareRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
// SessionCoordinator_ServiceDesc is the grpc.ServiceDesc for SessionCoordinator service.
|
||||
// It's only intended for direct use with grpc.RegisterService,
|
||||
// and not to be introspected or modified (even as a copy)
|
||||
var SessionCoordinator_ServiceDesc = grpc.ServiceDesc{
|
||||
ServiceName: "mpc.coordinator.v1.SessionCoordinator",
|
||||
HandlerType: (*SessionCoordinatorServer)(nil),
|
||||
Methods: []grpc.MethodDesc{
|
||||
{
|
||||
MethodName: "CreateSession",
|
||||
Handler: _SessionCoordinator_CreateSession_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "JoinSession",
|
||||
Handler: _SessionCoordinator_JoinSession_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "GetSessionStatus",
|
||||
Handler: _SessionCoordinator_GetSessionStatus_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "MarkPartyReady",
|
||||
Handler: _SessionCoordinator_MarkPartyReady_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "StartSession",
|
||||
Handler: _SessionCoordinator_StartSession_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ReportCompletion",
|
||||
Handler: _SessionCoordinator_ReportCompletion_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "CloseSession",
|
||||
Handler: _SessionCoordinator_CloseSession_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "SubmitDelegateShare",
|
||||
Handler: _SessionCoordinator_SubmitDelegateShare_Handler,
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{},
|
||||
Metadata: "api/proto/session_coordinator.proto",
|
||||
}
|
||||
|
|
@ -86,8 +86,8 @@ load_environment() {
|
|||
|
||||
# Service lists
|
||||
CORE_SERVICES="postgres"
|
||||
DEV_MPC_SERVICES="session-coordinator message-router server-party-1 server-party-2 server-party-3 server-party-api account-service"
|
||||
PROD_CENTRAL_SERVICES="postgres message-router session-coordinator account-service server-party-api"
|
||||
DEV_MPC_SERVICES="session-coordinator message-router server-party-1 server-party-2 server-party-3 server-party-api server-party-co-managed-1 server-party-co-managed-2 server-party-co-managed-3 account-service"
|
||||
PROD_CENTRAL_SERVICES="postgres message-router session-coordinator account-service server-party-api server-party-co-managed-1 server-party-co-managed-2 server-party-co-managed-3"
|
||||
|
||||
# ============================================
|
||||
# Development Mode Commands (docker-compose.yml)
|
||||
|
|
@ -212,6 +212,55 @@ dev_commands() {
|
|||
echo ""
|
||||
;;
|
||||
|
||||
start-svc)
|
||||
if [ -z "$2" ]; then
|
||||
log_error "Please specify a service name"
|
||||
return 1
|
||||
fi
|
||||
log_info "Starting $2..."
|
||||
docker compose up -d "$2"
|
||||
log_success "$2 started"
|
||||
;;
|
||||
|
||||
stop-svc)
|
||||
if [ -z "$2" ]; then
|
||||
log_error "Please specify a service name"
|
||||
return 1
|
||||
fi
|
||||
log_info "Stopping $2..."
|
||||
docker compose stop "$2"
|
||||
log_success "$2 stopped"
|
||||
;;
|
||||
|
||||
restart-svc)
|
||||
if [ -z "$2" ]; then
|
||||
log_error "Please specify a service name"
|
||||
return 1
|
||||
fi
|
||||
log_info "Restarting $2..."
|
||||
docker compose stop "$2"
|
||||
docker compose up -d "$2"
|
||||
log_success "$2 restarted"
|
||||
;;
|
||||
|
||||
rebuild-svc)
|
||||
if [ -z "$2" ]; then
|
||||
log_error "Please specify a service name"
|
||||
return 1
|
||||
fi
|
||||
local svc="$2"
|
||||
local no_cache="$3"
|
||||
log_info "Rebuilding $svc..."
|
||||
if [ "$no_cache" = "--no-cache" ]; then
|
||||
log_info "Building without cache..."
|
||||
docker compose build --no-cache "$svc"
|
||||
else
|
||||
docker compose build "$svc"
|
||||
fi
|
||||
docker compose up -d "$svc"
|
||||
log_success "$svc rebuilt and restarted"
|
||||
;;
|
||||
|
||||
*)
|
||||
return 1
|
||||
;;
|
||||
|
|
@ -314,6 +363,55 @@ prod_commands() {
|
|||
fi
|
||||
;;
|
||||
|
||||
start-svc)
|
||||
if [ -z "$2" ]; then
|
||||
log_error "Please specify a service name"
|
||||
return 1
|
||||
fi
|
||||
log_info "Starting $2..."
|
||||
docker compose -f docker-compose.prod.yml up -d "$2"
|
||||
log_success "$2 started"
|
||||
;;
|
||||
|
||||
stop-svc)
|
||||
if [ -z "$2" ]; then
|
||||
log_error "Please specify a service name"
|
||||
return 1
|
||||
fi
|
||||
log_info "Stopping $2..."
|
||||
docker compose -f docker-compose.prod.yml stop "$2"
|
||||
log_success "$2 stopped"
|
||||
;;
|
||||
|
||||
restart-svc)
|
||||
if [ -z "$2" ]; then
|
||||
log_error "Please specify a service name"
|
||||
return 1
|
||||
fi
|
||||
log_info "Restarting $2..."
|
||||
docker compose -f docker-compose.prod.yml stop "$2"
|
||||
docker compose -f docker-compose.prod.yml up -d "$2"
|
||||
log_success "$2 restarted"
|
||||
;;
|
||||
|
||||
rebuild-svc)
|
||||
if [ -z "$2" ]; then
|
||||
log_error "Please specify a service name"
|
||||
return 1
|
||||
fi
|
||||
local svc="$2"
|
||||
local no_cache="$3"
|
||||
log_info "Rebuilding $svc..."
|
||||
if [ "$no_cache" = "--no-cache" ]; then
|
||||
log_info "Building without cache..."
|
||||
docker compose -f docker-compose.prod.yml build --no-cache "$svc"
|
||||
else
|
||||
docker compose -f docker-compose.prod.yml build "$svc"
|
||||
fi
|
||||
docker compose -f docker-compose.prod.yml up -d "$svc"
|
||||
log_success "$svc rebuilt and restarted"
|
||||
;;
|
||||
|
||||
*)
|
||||
return 1
|
||||
;;
|
||||
|
|
@ -435,39 +533,67 @@ show_help() {
|
|||
echo ""
|
||||
echo "Development Commands (default mode):"
|
||||
echo " $0 build Build all Docker images"
|
||||
echo " $0 build-no-cache Build all images without cache"
|
||||
echo " $0 up|start Start all services"
|
||||
echo " $0 down|stop Stop all services"
|
||||
echo " $0 restart Restart all services"
|
||||
echo " $0 logs [service] Follow logs"
|
||||
echo " $0 logs-tail [svc] Show last 100 lines of logs"
|
||||
echo " $0 status|ps Show services status"
|
||||
echo " $0 health Check all services health"
|
||||
echo " $0 clean Remove containers and volumes"
|
||||
echo " $0 shell [service] Open shell in container"
|
||||
echo " $0 test-api Test Account Service API"
|
||||
echo ""
|
||||
echo "Single Service Commands (Development):"
|
||||
echo " $0 start-svc <name> Start a specific service"
|
||||
echo " $0 stop-svc <name> Stop a specific service"
|
||||
echo " $0 restart-svc <name> Restart a specific service"
|
||||
echo " $0 rebuild-svc <name> [--no-cache] Rebuild and restart a service"
|
||||
echo ""
|
||||
echo "Production Central Commands:"
|
||||
echo " $0 prod build Build central services"
|
||||
echo " $0 prod up Start central services"
|
||||
echo " $0 prod down Stop central services"
|
||||
echo " $0 prod logs Follow central logs"
|
||||
echo " $0 prod restart Restart central services"
|
||||
echo " $0 prod logs [svc] Follow central logs"
|
||||
echo " $0 prod status Show central status"
|
||||
echo " $0 prod health Check central health"
|
||||
echo " $0 prod clean Remove central containers and volumes"
|
||||
echo " $0 prod start-svc <name> Start a specific service"
|
||||
echo " $0 prod stop-svc <name> Stop a specific service"
|
||||
echo " $0 prod restart-svc <name> Restart a specific service"
|
||||
echo " $0 prod rebuild-svc <name> [--no-cache] Rebuild and restart"
|
||||
echo ""
|
||||
echo "Production Party Commands (run on each party machine):"
|
||||
echo " $0 party build Build party service"
|
||||
echo " $0 party up Start party (connects to central)"
|
||||
echo " $0 party down Stop party"
|
||||
echo " $0 party restart Restart party"
|
||||
echo " $0 party logs Follow party logs"
|
||||
echo " $0 party status Show party status"
|
||||
echo " $0 party health Check party health and connectivity"
|
||||
echo " $0 party clean Remove party containers and volumes"
|
||||
echo ""
|
||||
echo "Environment Files:"
|
||||
echo " .env Development configuration"
|
||||
echo " .env.prod Production Central configuration"
|
||||
echo " .env.party Production Party configuration"
|
||||
echo ""
|
||||
echo "Services (Development):"
|
||||
echo " postgres, session-coordinator, message-router, account-service,"
|
||||
echo " server-party-api, server-party-1, server-party-2, server-party-3,"
|
||||
echo " server-party-co-managed-1, server-party-co-managed-2, server-party-co-managed-3"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " # Development (all on one machine)"
|
||||
echo " $0 up"
|
||||
echo " $0 rebuild-svc account-service --no-cache"
|
||||
echo " $0 restart-svc session-coordinator"
|
||||
echo ""
|
||||
echo " # Production Central (on central server)"
|
||||
echo " $0 prod up"
|
||||
echo " $0 prod rebuild-svc account-service"
|
||||
echo ""
|
||||
echo " # Production Party (on each party machine)"
|
||||
echo " PARTY_ID=server-party-1 $0 party up"
|
||||
|
|
|
|||
|
|
@ -91,7 +91,8 @@ services:
|
|||
dockerfile: services/message-router/Dockerfile
|
||||
container_name: mpc-message-router
|
||||
ports:
|
||||
- "8082:8080" # WebSocket for external connections
|
||||
- "50051:50051" # gRPC for party connections
|
||||
- "8082:8080" # HTTP for health checks
|
||||
environment:
|
||||
TZ: Asia/Shanghai
|
||||
MPC_SERVER_GRPC_PORT: 50051
|
||||
|
|
@ -278,6 +279,123 @@ services:
|
|||
- mpc-network
|
||||
restart: unless-stopped
|
||||
|
||||
# ============================================
|
||||
# Co-Managed Server Party Services - TSS 参与方 (专用于 co_managed_keygen)
|
||||
# 与普通 server-party 隔离,使用两阶段事件处理
|
||||
# 行为与 service-party-app 100% 兼容
|
||||
# ============================================
|
||||
|
||||
# Co-Managed Server Party 1
|
||||
server-party-co-managed-1:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: services/server-party-co-managed/Dockerfile
|
||||
container_name: mpc-server-party-co-managed-1
|
||||
environment:
|
||||
TZ: Asia/Shanghai
|
||||
MPC_SERVER_HTTP_PORT: 8080
|
||||
MPC_SERVER_ENVIRONMENT: ${ENVIRONMENT:-development}
|
||||
MPC_LOGGER_LEVEL: ${LOG_LEVEL:-debug}
|
||||
MPC_DATABASE_HOST: postgres
|
||||
MPC_DATABASE_PORT: 5432
|
||||
MPC_DATABASE_USER: ${POSTGRES_USER:-mpc_user}
|
||||
MPC_DATABASE_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set}
|
||||
MPC_DATABASE_DBNAME: mpc_system
|
||||
MPC_DATABASE_SSLMODE: disable
|
||||
MESSAGE_ROUTER_ADDR: message-router:50051
|
||||
MPC_CRYPTO_MASTER_KEY: ${CRYPTO_MASTER_KEY}
|
||||
PARTY_ID: co-managed-party-1
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
session-coordinator:
|
||||
condition: service_healthy
|
||||
message-router:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-sf", "http://localhost:8080/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
networks:
|
||||
- mpc-network
|
||||
restart: unless-stopped
|
||||
|
||||
# Co-Managed Server Party 2
|
||||
server-party-co-managed-2:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: services/server-party-co-managed/Dockerfile
|
||||
container_name: mpc-server-party-co-managed-2
|
||||
environment:
|
||||
TZ: Asia/Shanghai
|
||||
MPC_SERVER_HTTP_PORT: 8080
|
||||
MPC_SERVER_ENVIRONMENT: ${ENVIRONMENT:-development}
|
||||
MPC_LOGGER_LEVEL: ${LOG_LEVEL:-debug}
|
||||
MPC_DATABASE_HOST: postgres
|
||||
MPC_DATABASE_PORT: 5432
|
||||
MPC_DATABASE_USER: ${POSTGRES_USER:-mpc_user}
|
||||
MPC_DATABASE_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set}
|
||||
MPC_DATABASE_DBNAME: mpc_system
|
||||
MPC_DATABASE_SSLMODE: disable
|
||||
MESSAGE_ROUTER_ADDR: message-router:50051
|
||||
MPC_CRYPTO_MASTER_KEY: ${CRYPTO_MASTER_KEY}
|
||||
PARTY_ID: co-managed-party-2
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
session-coordinator:
|
||||
condition: service_healthy
|
||||
message-router:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-sf", "http://localhost:8080/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
networks:
|
||||
- mpc-network
|
||||
restart: unless-stopped
|
||||
|
||||
# Co-Managed Server Party 3
|
||||
server-party-co-managed-3:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: services/server-party-co-managed/Dockerfile
|
||||
container_name: mpc-server-party-co-managed-3
|
||||
environment:
|
||||
TZ: Asia/Shanghai
|
||||
MPC_SERVER_HTTP_PORT: 8080
|
||||
MPC_SERVER_ENVIRONMENT: ${ENVIRONMENT:-development}
|
||||
MPC_LOGGER_LEVEL: ${LOG_LEVEL:-debug}
|
||||
MPC_DATABASE_HOST: postgres
|
||||
MPC_DATABASE_PORT: 5432
|
||||
MPC_DATABASE_USER: ${POSTGRES_USER:-mpc_user}
|
||||
MPC_DATABASE_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set}
|
||||
MPC_DATABASE_DBNAME: mpc_system
|
||||
MPC_DATABASE_SSLMODE: disable
|
||||
MESSAGE_ROUTER_ADDR: message-router:50051
|
||||
MPC_CRYPTO_MASTER_KEY: ${CRYPTO_MASTER_KEY}
|
||||
PARTY_ID: co-managed-party-3
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
session-coordinator:
|
||||
condition: service_healthy
|
||||
message-router:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-sf", "http://localhost:8080/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
networks:
|
||||
- mpc-network
|
||||
restart: unless-stopped
|
||||
|
||||
# ============================================
|
||||
# Account Service - External API Entry Point
|
||||
# Main HTTP API for backend mpc-service integration
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
-- Migration: 008_add_co_managed_wallet_fields (rollback)
|
||||
-- Description: Remove wallet_name and invite_code fields and revert session_type constraint
|
||||
|
||||
-- Drop the index for invite_code
|
||||
DROP INDEX IF EXISTS idx_mpc_sessions_invite_code;
|
||||
|
||||
-- Remove the columns
|
||||
ALTER TABLE mpc_sessions
|
||||
DROP COLUMN IF EXISTS wallet_name,
|
||||
DROP COLUMN IF EXISTS invite_code;
|
||||
|
||||
-- Drop the updated session_type constraint
|
||||
ALTER TABLE mpc_sessions DROP CONSTRAINT IF EXISTS chk_session_type;
|
||||
|
||||
-- Restore the original session_type constraint
|
||||
ALTER TABLE mpc_sessions
|
||||
ADD CONSTRAINT chk_session_type CHECK (session_type IN ('keygen', 'sign'));
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
-- Migration: 008_add_co_managed_wallet_fields
|
||||
-- Description: Add wallet_name and invite_code fields for co-managed wallet sessions
|
||||
-- and extend session_type to support 'co_managed_keygen'
|
||||
-- This migration is idempotent - safe to run multiple times
|
||||
|
||||
-- Add new columns for co-managed wallet sessions (idempotent)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'mpc_sessions' AND column_name = 'wallet_name') THEN
|
||||
ALTER TABLE mpc_sessions ADD COLUMN wallet_name VARCHAR(255);
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'mpc_sessions' AND column_name = 'invite_code') THEN
|
||||
ALTER TABLE mpc_sessions ADD COLUMN invite_code VARCHAR(50);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Create index for invite_code lookups (idempotent)
|
||||
CREATE INDEX IF NOT EXISTS idx_mpc_sessions_invite_code ON mpc_sessions(invite_code) WHERE invite_code IS NOT NULL;
|
||||
|
||||
-- Drop the existing session_type constraint
|
||||
ALTER TABLE mpc_sessions DROP CONSTRAINT IF EXISTS chk_session_type;
|
||||
|
||||
-- Add updated session_type constraint including 'co_managed_keygen'
|
||||
ALTER TABLE mpc_sessions
|
||||
ADD CONSTRAINT chk_session_type CHECK (session_type IN ('keygen', 'sign', 'co_managed_keygen'));
|
||||
|
||||
-- Add comment for the new columns (safe to run multiple times)
|
||||
COMMENT ON COLUMN mpc_sessions.wallet_name IS 'Wallet name for co-managed wallet sessions';
|
||||
COMMENT ON COLUMN mpc_sessions.invite_code IS 'Invite code for co-managed wallet sessions - used for participants to join';
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,864 @@
|
|||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
grpcclient "github.com/rwadurian/mpc-system/services/account/adapters/output/grpc"
|
||||
"github.com/rwadurian/mpc-system/pkg/jwt"
|
||||
"github.com/rwadurian/mpc-system/pkg/logger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// CoManagedHTTPHandler handles HTTP requests for co-managed wallets
|
||||
// This is a completely independent handler that does not affect existing functionality
|
||||
type CoManagedHTTPHandler struct {
|
||||
sessionCoordinatorClient *grpcclient.SessionCoordinatorClient
|
||||
db *sql.DB // Database connection for invite_code lookups
|
||||
jwtService *jwt.JWTService // JWT service for generating join tokens
|
||||
}
|
||||
|
||||
// NewCoManagedHTTPHandler creates a new CoManagedHTTPHandler
|
||||
func NewCoManagedHTTPHandler(
|
||||
sessionCoordinatorClient *grpcclient.SessionCoordinatorClient,
|
||||
) *CoManagedHTTPHandler {
|
||||
return &CoManagedHTTPHandler{
|
||||
sessionCoordinatorClient: sessionCoordinatorClient,
|
||||
db: nil,
|
||||
}
|
||||
}
|
||||
|
||||
// NewCoManagedHTTPHandlerWithDB creates a new CoManagedHTTPHandler with database support
|
||||
func NewCoManagedHTTPHandlerWithDB(
|
||||
sessionCoordinatorClient *grpcclient.SessionCoordinatorClient,
|
||||
db *sql.DB,
|
||||
jwtService *jwt.JWTService,
|
||||
) *CoManagedHTTPHandler {
|
||||
return &CoManagedHTTPHandler{
|
||||
sessionCoordinatorClient: sessionCoordinatorClient,
|
||||
db: db,
|
||||
jwtService: jwtService,
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterRoutes registers HTTP routes for co-managed wallets
|
||||
func (h *CoManagedHTTPHandler) RegisterRoutes(router *gin.RouterGroup) {
|
||||
coManaged := router.Group("/co-managed")
|
||||
{
|
||||
// Keygen session routes
|
||||
coManaged.POST("/sessions", h.CreateSession)
|
||||
coManaged.POST("/sessions/:sessionId/join", h.JoinSession)
|
||||
coManaged.GET("/sessions/:sessionId", h.GetSessionStatus)
|
||||
coManaged.GET("/sessions/by-invite-code/:inviteCode", h.GetSessionByInviteCode)
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// generateInviteCode generates a random invite code in format XXXX-XXXX-XXXX
|
||||
func generateInviteCode() string {
|
||||
bytes := make([]byte, 6)
|
||||
rand.Read(bytes)
|
||||
code := fmt.Sprintf("%X", bytes)
|
||||
return fmt.Sprintf("%s-%s-%s", code[0:4], code[4:8], code[8:12])
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Create Co-Managed Wallet Session
|
||||
// ============================================
|
||||
|
||||
// CreateCoManagedSessionRequest represents the request for creating a co-managed wallet session
|
||||
type CreateCoManagedSessionRequest struct {
|
||||
WalletName string `json:"wallet_name" binding:"required"` // Wallet name
|
||||
ThresholdT int `json:"threshold_t" binding:"required,min=1"` // Signing threshold (actual signers needed = t+1)
|
||||
ThresholdN int `json:"threshold_n" binding:"required,min=2"` // Total parties
|
||||
InitiatorPartyID string `json:"initiator_party_id" binding:"required"` // Initiator's party ID
|
||||
InitiatorName string `json:"initiator_name"` // Initiator's display name
|
||||
PersistentCount int `json:"persistent_count"` // Number of server parties (default 2)
|
||||
}
|
||||
|
||||
// CreateSession handles creating a new co-managed wallet session
|
||||
func (h *CoManagedHTTPHandler) CreateSession(c *gin.Context) {
|
||||
var req CreateCoManagedSessionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate threshold
|
||||
if req.ThresholdT >= req.ThresholdN {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "threshold_t must be less than threshold_n"})
|
||||
return
|
||||
}
|
||||
|
||||
// Default persistent count is 2 (platform backup parties)
|
||||
persistentCount := req.PersistentCount
|
||||
if persistentCount <= 0 {
|
||||
persistentCount = 2
|
||||
}
|
||||
|
||||
// Calculate external party count
|
||||
externalCount := req.ThresholdN - persistentCount
|
||||
if externalCount < 1 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "threshold_n must be greater than persistent_count to allow external participants",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate invite code
|
||||
inviteCode := generateInviteCode()
|
||||
|
||||
// Call session coordinator via gRPC
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
logger.Info("Creating co-managed keygen session",
|
||||
zap.String("wallet_name", req.WalletName),
|
||||
zap.Int("threshold_n", req.ThresholdN),
|
||||
zap.Int("threshold_t", req.ThresholdT),
|
||||
zap.Int("persistent_count", persistentCount),
|
||||
zap.Int("external_count", externalCount),
|
||||
zap.String("initiator_party_id", req.InitiatorPartyID))
|
||||
|
||||
resp, err := h.sessionCoordinatorClient.CreateCoManagedKeygenSession(
|
||||
ctx,
|
||||
req.WalletName,
|
||||
inviteCode,
|
||||
int32(req.ThresholdN),
|
||||
int32(req.ThresholdT),
|
||||
int32(persistentCount),
|
||||
int32(externalCount),
|
||||
3600, // 1 hour expiry for session creation phase
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
logger.Error("Failed to create co-managed keygen session", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Get wildcard join token for external participants
|
||||
wildcardToken := ""
|
||||
if token, ok := resp.JoinTokens["*"]; ok {
|
||||
wildcardToken = token
|
||||
}
|
||||
|
||||
logger.Info("Co-managed keygen session created successfully",
|
||||
zap.String("session_id", resp.SessionID),
|
||||
zap.String("invite_code", inviteCode),
|
||||
zap.Int("num_server_parties", len(resp.SelectedServerParties)))
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"session_id": resp.SessionID,
|
||||
"wallet_name": req.WalletName,
|
||||
"invite_code": inviteCode,
|
||||
"join_token": wildcardToken, // Token for external participants (backward compatible)
|
||||
"join_tokens": resp.JoinTokens, // Full join tokens map for service-party-app
|
||||
"threshold_n": req.ThresholdN,
|
||||
"threshold_t": req.ThresholdT,
|
||||
"selected_server_parties": resp.SelectedServerParties,
|
||||
"status": "waiting_for_participants",
|
||||
"current_participants": len(resp.SelectedServerParties), // Server parties auto-joined
|
||||
"required_participants": req.ThresholdN,
|
||||
"expires_at": resp.ExpiresAt,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Join Co-Managed Wallet Session
|
||||
// ============================================
|
||||
|
||||
// JoinSessionRequest represents the request for joining a session
|
||||
type JoinSessionRequest struct {
|
||||
PartyID string `json:"party_id" binding:"required"` // Participant's party ID
|
||||
JoinToken string `json:"join_token" binding:"required"` // Join token (from invite)
|
||||
ParticipantName string `json:"participant_name"` // Display name
|
||||
DeviceType string `json:"device_type"` // Device type (pc, android, ios)
|
||||
DeviceID string `json:"device_id"` // Device ID
|
||||
}
|
||||
|
||||
// JoinSession handles joining an existing co-managed session
|
||||
func (h *CoManagedHTTPHandler) JoinSession(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
|
||||
}
|
||||
|
||||
var req JoinSessionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Default device type
|
||||
deviceType := req.DeviceType
|
||||
if deviceType == "" {
|
||||
deviceType = "pc"
|
||||
}
|
||||
|
||||
deviceID := req.DeviceID
|
||||
if deviceID == "" {
|
||||
deviceID = req.PartyID // Use party ID as device ID if not provided
|
||||
}
|
||||
|
||||
// Call session coordinator via gRPC
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
logger.Info("Joining co-managed session",
|
||||
zap.String("session_id", sessionID),
|
||||
zap.String("party_id", req.PartyID),
|
||||
zap.String("participant_name", req.ParticipantName))
|
||||
|
||||
resp, err := h.sessionCoordinatorClient.JoinSession(
|
||||
ctx,
|
||||
sessionID,
|
||||
req.PartyID,
|
||||
req.JoinToken,
|
||||
deviceType,
|
||||
deviceID,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
logger.Error("Failed to join session", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if !resp.Success {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to join session"})
|
||||
return
|
||||
}
|
||||
|
||||
// Build other parties list
|
||||
otherParties := make([]gin.H, 0, len(resp.OtherParties))
|
||||
for _, p := range resp.OtherParties {
|
||||
otherParties = append(otherParties, gin.H{
|
||||
"party_id": p.PartyID,
|
||||
"party_index": p.PartyIndex,
|
||||
})
|
||||
}
|
||||
|
||||
result := gin.H{
|
||||
"success": true,
|
||||
"party_index": resp.PartyIndex,
|
||||
"other_parties": otherParties,
|
||||
}
|
||||
|
||||
if resp.SessionInfo != nil {
|
||||
result["session_info"] = gin.H{
|
||||
"session_id": resp.SessionInfo.SessionID,
|
||||
"session_type": resp.SessionInfo.SessionType,
|
||||
"threshold_n": resp.SessionInfo.ThresholdN,
|
||||
"threshold_t": resp.SessionInfo.ThresholdT,
|
||||
"status": resp.SessionInfo.Status,
|
||||
"wallet_name": resp.SessionInfo.WalletName,
|
||||
}
|
||||
}
|
||||
|
||||
logger.Info("Joined co-managed session successfully",
|
||||
zap.String("session_id", sessionID),
|
||||
zap.String("party_id", req.PartyID),
|
||||
zap.Int32("party_index", resp.PartyIndex))
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Get Session Status
|
||||
// ============================================
|
||||
|
||||
// GetSessionStatus handles getting the status of a co-managed session
|
||||
func (h *CoManagedHTTPHandler) GetSessionStatus(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 session status", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
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 public key if keygen completed
|
||||
if resp.SessionType == "co_managed_keygen" && len(resp.PublicKey) > 0 {
|
||||
result["public_key"] = hex.EncodeToString(resp.PublicKey)
|
||||
}
|
||||
|
||||
// Include participants with party_index (for service-party-app to build correct participant list)
|
||||
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)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Get Session By Invite Code
|
||||
// ============================================
|
||||
|
||||
// GetSessionByInviteCode handles looking up a session by its invite code
|
||||
// This allows Service-Party-App to find the session_id from an invite_code
|
||||
func (h *CoManagedHTTPHandler) GetSessionByInviteCode(c *gin.Context) {
|
||||
inviteCode := c.Param("inviteCode")
|
||||
if inviteCode == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invite_code is required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if database connection is available
|
||||
if h.db == nil {
|
||||
logger.Error("Database connection not available for invite_code lookup")
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "service configuration error"})
|
||||
return
|
||||
}
|
||||
|
||||
// Query database for session by invite_code
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var sessionID string
|
||||
var walletName string
|
||||
var thresholdN, thresholdT int
|
||||
var status string
|
||||
var expiresAt time.Time
|
||||
|
||||
err := h.db.QueryRowContext(ctx, `
|
||||
SELECT id, COALESCE(wallet_name, ''), threshold_n, threshold_t, status, expires_at
|
||||
FROM mpc_sessions
|
||||
WHERE invite_code = $1 AND session_type = 'co_managed_keygen'
|
||||
`, inviteCode).Scan(&sessionID, &walletName, &thresholdN, &thresholdT, &status, &expiresAt)
|
||||
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
logger.Info("Session not found for invite_code",
|
||||
zap.String("invite_code", inviteCode))
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "session not found or expired"})
|
||||
return
|
||||
}
|
||||
logger.Error("Failed to query session by invite_code",
|
||||
zap.String("invite_code", inviteCode),
|
||||
zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to lookup session"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if session is expired
|
||||
if time.Now().After(expiresAt) {
|
||||
logger.Info("Session expired for invite_code",
|
||||
zap.String("invite_code", inviteCode),
|
||||
zap.String("session_id", sessionID))
|
||||
c.JSON(http.StatusGone, gin.H{"error": "session has expired"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get session status from session coordinator
|
||||
statusResp, err := h.sessionCoordinatorClient.GetSessionStatus(ctx, sessionID)
|
||||
if err != nil {
|
||||
logger.Error("Failed to get session status from coordinator",
|
||||
zap.String("session_id", sessionID),
|
||||
zap.Error(err))
|
||||
// Return basic info without join token
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"session_id": sessionID,
|
||||
"wallet_name": walletName,
|
||||
"threshold_n": thresholdN,
|
||||
"threshold_t": thresholdT,
|
||||
"status": status,
|
||||
"expires_at": expiresAt.UnixMilli(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate a wildcard join token for this session
|
||||
// This allows any participant to join using this token
|
||||
var joinToken string
|
||||
if h.jwtService != nil {
|
||||
sessionUUID, err := uuid.Parse(sessionID)
|
||||
if err == nil {
|
||||
// Token valid until session expires
|
||||
tokenExpiry := time.Until(expiresAt)
|
||||
if tokenExpiry > 0 {
|
||||
joinToken, err = h.jwtService.GenerateJoinToken(sessionUUID, "*", tokenExpiry)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to generate join token",
|
||||
zap.String("session_id", sessionID),
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.Info("Found session for invite_code",
|
||||
zap.String("invite_code", inviteCode),
|
||||
zap.String("session_id", sessionID),
|
||||
zap.String("wallet_name", walletName),
|
||||
zap.Bool("has_join_token", joinToken != ""))
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"session_id": sessionID,
|
||||
"wallet_name": walletName,
|
||||
"threshold_n": thresholdN,
|
||||
"threshold_t": thresholdT,
|
||||
"status": statusResp.Status,
|
||||
"completed_parties": statusResp.CompletedParties,
|
||||
"total_parties": statusResp.TotalParties,
|
||||
"expires_at": expiresAt.UnixMilli(),
|
||||
"join_token": joinToken,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Co-Managed Sign Session (NEW - Independent)
|
||||
// ============================================
|
||||
|
||||
// SignPartyInfo contains party information for signing
|
||||
type SignPartyInfo struct {
|
||||
PartyID string `json:"party_id" binding:"required"`
|
||||
PartyIndex int32 `json:"party_index" binding:"required"`
|
||||
}
|
||||
|
||||
// CreateSignSessionRequest represents the request for creating a co-managed sign session
|
||||
type CreateSignSessionRequest struct {
|
||||
KeygenSessionID string `json:"keygen_session_id" binding:"required"` // The keygen session that created the wallet
|
||||
WalletName string `json:"wallet_name"` // Wallet name (for display)
|
||||
MessageHash string `json:"message_hash" binding:"required"` // Hex-encoded message hash to sign
|
||||
Parties []SignPartyInfo `json:"parties" binding:"required,min=1"` // Parties to participate in signing (t+1)
|
||||
ThresholdT int `json:"threshold_t" binding:"required,min=1"` // Signing threshold
|
||||
InitiatorName string `json:"initiator_name"` // Initiator's display name
|
||||
}
|
||||
|
||||
// CreateSignSession handles creating a new co-managed sign session
|
||||
// This is a completely new endpoint that does not affect existing sign functionality
|
||||
func (h *CoManagedHTTPHandler) CreateSignSession(c *gin.Context) {
|
||||
var req CreateSignSessionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate keygen_session_id format
|
||||
if _, err := uuid.Parse(req.KeygenSessionID); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid keygen_session_id format"})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate message_hash (should be hex encoded)
|
||||
messageHash, err := hex.DecodeString(req.MessageHash)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "message_hash must be hex encoded"})
|
||||
return
|
||||
}
|
||||
|
||||
// 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 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()
|
||||
|
||||
// Convert parties to gRPC format
|
||||
parties := make([]grpcclient.SigningPartyInfo, len(req.Parties))
|
||||
for i, p := range req.Parties {
|
||||
parties[i] = grpcclient.SigningPartyInfo{
|
||||
PartyID: p.PartyID,
|
||||
PartyIndex: p.PartyIndex,
|
||||
}
|
||||
}
|
||||
|
||||
logger.Info("Creating co-managed sign session",
|
||||
zap.String("keygen_session_id", req.KeygenSessionID),
|
||||
zap.String("wallet_name", req.WalletName),
|
||||
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
|
||||
nil, // No delegate share for co-managed wallets
|
||||
req.KeygenSessionID,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
logger.Error("Failed to create co-managed sign session", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Store invite_code mapping in database (for lookup)
|
||||
if h.db != nil {
|
||||
_, dbErr := h.db.ExecContext(ctx, `
|
||||
UPDATE mpc_sessions
|
||||
SET invite_code = $1, wallet_name = $2
|
||||
WHERE id = $3
|
||||
`, inviteCode, req.WalletName, resp.SessionID)
|
||||
if dbErr != nil {
|
||||
logger.Warn("Failed to store invite_code for sign session",
|
||||
zap.String("session_id", resp.SessionID),
|
||||
zap.Error(dbErr))
|
||||
// Don't fail the request, just log the warning
|
||||
}
|
||||
}
|
||||
|
||||
// Get wildcard token for backward compatibility (join_token field)
|
||||
wildcardToken := ""
|
||||
if token, ok := resp.JoinTokens["*"]; ok {
|
||||
wildcardToken = token
|
||||
}
|
||||
|
||||
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_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, // 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,
|
||||
})
|
||||
}
|
||||
|
||||
// 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) {
|
||||
inviteCode := c.Param("inviteCode")
|
||||
if inviteCode == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invite_code is required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if database connection is available
|
||||
if h.db == nil {
|
||||
logger.Error("Database connection not available for sign invite_code lookup")
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "service configuration error"})
|
||||
return
|
||||
}
|
||||
|
||||
// Query database for sign session by invite_code
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var sessionID string
|
||||
var walletName string
|
||||
var keygenSessionID string
|
||||
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, ''),
|
||||
status, expires_at, COALESCE(message_hash, '')
|
||||
FROM mpc_sessions
|
||||
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 {
|
||||
logger.Info("Sign session not found for invite_code",
|
||||
zap.String("invite_code", inviteCode))
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "sign session not found or expired"})
|
||||
return
|
||||
}
|
||||
logger.Error("Failed to query sign session by invite_code",
|
||||
zap.String("invite_code", inviteCode),
|
||||
zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to lookup sign session"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if session is expired
|
||||
if time.Now().After(expiresAt) {
|
||||
logger.Info("Sign session expired for invite_code",
|
||||
zap.String("invite_code", inviteCode),
|
||||
zap.String("session_id", sessionID))
|
||||
c.JSON(http.StatusGone, gin.H{"error": "sign session has expired"})
|
||||
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 {
|
||||
logger.Error("Failed to get sign session status from coordinator",
|
||||
zap.String("session_id", sessionID),
|
||||
zap.Error(err))
|
||||
// Return basic info without detailed status
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"session_id": sessionID,
|
||||
"keygen_session_id": keygenSessionID,
|
||||
"wallet_name": walletName,
|
||||
"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("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,
|
||||
"message_hash": hex.EncodeToString(messageHash),
|
||||
"threshold_n": keygenThresholdN,
|
||||
"threshold_t": keygenThresholdT,
|
||||
"status": statusResp.Status,
|
||||
"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)
|
||||
|
|
@ -225,6 +239,8 @@ func (c *SessionCoordinatorClient) GetSessionStatus(
|
|||
Status: resp.Status,
|
||||
CompletedParties: resp.CompletedParties,
|
||||
TotalParties: resp.TotalParties,
|
||||
ThresholdT: resp.ThresholdT,
|
||||
ThresholdN: resp.ThresholdN,
|
||||
SessionType: resp.SessionType,
|
||||
PublicKey: resp.PublicKey,
|
||||
Signature: resp.Signature,
|
||||
|
|
@ -240,6 +256,18 @@ func (c *SessionCoordinatorClient) GetSessionStatus(
|
|||
}
|
||||
}
|
||||
|
||||
// Include participants if present (for co_managed_keygen sessions)
|
||||
if len(resp.Participants) > 0 {
|
||||
result.Participants = make([]ParticipantStatusInfo, len(resp.Participants))
|
||||
for i, p := range resp.Participants {
|
||||
result.Participants[i] = ParticipantStatusInfo{
|
||||
PartyID: p.PartyId,
|
||||
PartyIndex: p.PartyIndex,
|
||||
Status: p.Status,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
|
|
@ -281,6 +309,8 @@ type SessionStatusResponse struct {
|
|||
Status string
|
||||
CompletedParties int32
|
||||
TotalParties int32
|
||||
ThresholdT int32 // Minimum parties needed to sign (e.g., 2 in 2-of-3)
|
||||
ThresholdN int32 // Total number of parties required (e.g., 3 in 2-of-3)
|
||||
SessionType string // "keygen" or "sign"
|
||||
PublicKey []byte
|
||||
Signature []byte
|
||||
|
|
@ -290,6 +320,16 @@ type SessionStatusResponse struct {
|
|||
// DelegateShare is non-nil when session_type="keygen" AND has_delegate=true AND session is completed
|
||||
// nil with has_delegate=true means share was already retrieved (one-time retrieval)
|
||||
DelegateShare *DelegateShareInfo
|
||||
// Participants contains detailed participant information including party_index
|
||||
// Used by service-party-app for co_managed_keygen sessions
|
||||
Participants []ParticipantStatusInfo
|
||||
}
|
||||
|
||||
// ParticipantStatusInfo contains participant status information
|
||||
type ParticipantStatusInfo struct {
|
||||
PartyID string
|
||||
PartyIndex int32
|
||||
Status string
|
||||
}
|
||||
|
||||
// DelegateShareInfo contains the delegate party's share for user
|
||||
|
|
@ -298,3 +338,167 @@ type DelegateShareInfo struct {
|
|||
PartyIndex int32
|
||||
PartyID string
|
||||
}
|
||||
|
||||
// CreateCoManagedSessionResponse contains the created co-managed session info
|
||||
type CreateCoManagedSessionResponse struct {
|
||||
SessionID string
|
||||
InviteCode string
|
||||
WalletName string
|
||||
SelectedServerParties []string // Auto-selected server parties
|
||||
JoinTokens map[string]string // Includes wildcard token for external parties
|
||||
ExpiresAt int64
|
||||
ThresholdN int32
|
||||
ThresholdT int32
|
||||
}
|
||||
|
||||
// CreateCoManagedKeygenSession creates a new co-managed keygen session
|
||||
// This session waits for external participants to join via invite code
|
||||
func (c *SessionCoordinatorClient) CreateCoManagedKeygenSession(
|
||||
ctx context.Context,
|
||||
walletName string,
|
||||
inviteCode string,
|
||||
thresholdN int32,
|
||||
thresholdT int32,
|
||||
persistentCount int32, // Number of server parties to auto-select
|
||||
externalCount int32, // Number of external parties (Service Party Apps)
|
||||
expiresInSeconds int64,
|
||||
) (*CreateCoManagedSessionResponse, error) {
|
||||
req := &coordinatorpb.CreateSessionRequest{
|
||||
SessionType: "co_managed_keygen",
|
||||
ThresholdN: thresholdN,
|
||||
ThresholdT: thresholdT,
|
||||
Participants: nil, // External participants will join later
|
||||
ExpiresInSeconds: expiresInSeconds,
|
||||
PartyComposition: &coordinatorpb.PartyComposition{
|
||||
PersistentCount: persistentCount,
|
||||
DelegateCount: 0,
|
||||
TemporaryCount: externalCount, // External parties treated as temporary
|
||||
},
|
||||
WalletName: walletName,
|
||||
InviteCode: inviteCode,
|
||||
}
|
||||
|
||||
logger.Info("Sending CreateCoManagedKeygenSession gRPC request",
|
||||
zap.String("session_type", "co_managed_keygen"),
|
||||
zap.String("wallet_name", walletName),
|
||||
zap.Int32("threshold_n", thresholdN),
|
||||
zap.Int32("threshold_t", thresholdT),
|
||||
zap.Int32("persistent_count", persistentCount),
|
||||
zap.Int32("external_count", externalCount))
|
||||
|
||||
resp, err := c.client.CreateSession(ctx, req)
|
||||
if err != nil {
|
||||
logger.Error("CreateCoManagedKeygenSession gRPC call failed", zap.Error(err))
|
||||
return nil, fmt.Errorf("failed to create co-managed keygen session: %w", err)
|
||||
}
|
||||
|
||||
// Extract selected server parties
|
||||
var serverParties []string
|
||||
for partyID := range resp.JoinTokens {
|
||||
if partyID != "*" { // Exclude wildcard token
|
||||
serverParties = append(serverParties, partyID)
|
||||
}
|
||||
}
|
||||
|
||||
logger.Info("CreateCoManagedKeygenSession gRPC call succeeded",
|
||||
zap.String("session_id", resp.SessionId),
|
||||
zap.Int("num_server_parties", len(serverParties)))
|
||||
|
||||
return &CreateCoManagedSessionResponse{
|
||||
SessionID: resp.SessionId,
|
||||
InviteCode: inviteCode,
|
||||
WalletName: walletName,
|
||||
SelectedServerParties: serverParties,
|
||||
JoinTokens: resp.JoinTokens,
|
||||
ExpiresAt: resp.ExpiresAt,
|
||||
ThresholdN: thresholdN,
|
||||
ThresholdT: thresholdT,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// JoinSessionResponse contains join session result
|
||||
type JoinSessionResponse struct {
|
||||
Success bool
|
||||
PartyIndex int32
|
||||
SessionInfo *SessionInfoResponse
|
||||
OtherParties []PartyInfoResponse
|
||||
}
|
||||
|
||||
// SessionInfoResponse contains session info from join response
|
||||
type SessionInfoResponse struct {
|
||||
SessionID string
|
||||
SessionType string
|
||||
ThresholdN int32
|
||||
ThresholdT int32
|
||||
Status string
|
||||
WalletName string
|
||||
InviteCode string
|
||||
KeygenSessionID string
|
||||
}
|
||||
|
||||
// PartyInfoResponse contains party info
|
||||
type PartyInfoResponse struct {
|
||||
PartyID string
|
||||
PartyIndex int32
|
||||
}
|
||||
|
||||
// JoinSession joins an existing session
|
||||
func (c *SessionCoordinatorClient) JoinSession(
|
||||
ctx context.Context,
|
||||
sessionID string,
|
||||
partyID string,
|
||||
joinToken string,
|
||||
deviceType string,
|
||||
deviceID string,
|
||||
) (*JoinSessionResponse, error) {
|
||||
req := &coordinatorpb.JoinSessionRequest{
|
||||
SessionId: sessionID,
|
||||
PartyId: partyID,
|
||||
JoinToken: joinToken,
|
||||
DeviceInfo: &coordinatorpb.DeviceInfo{
|
||||
DeviceType: deviceType,
|
||||
DeviceId: deviceID,
|
||||
},
|
||||
}
|
||||
|
||||
logger.Info("Sending JoinSession gRPC request",
|
||||
zap.String("session_id", sessionID),
|
||||
zap.String("party_id", partyID))
|
||||
|
||||
resp, err := c.client.JoinSession(ctx, req)
|
||||
if err != nil {
|
||||
logger.Error("JoinSession gRPC call failed", zap.Error(err))
|
||||
return nil, fmt.Errorf("failed to join session: %w", err)
|
||||
}
|
||||
|
||||
result := &JoinSessionResponse{
|
||||
Success: resp.Success,
|
||||
PartyIndex: resp.PartyIndex,
|
||||
}
|
||||
|
||||
if resp.SessionInfo != nil {
|
||||
result.SessionInfo = &SessionInfoResponse{
|
||||
SessionID: resp.SessionInfo.SessionId,
|
||||
SessionType: resp.SessionInfo.SessionType,
|
||||
ThresholdN: resp.SessionInfo.ThresholdN,
|
||||
ThresholdT: resp.SessionInfo.ThresholdT,
|
||||
Status: resp.SessionInfo.Status,
|
||||
WalletName: resp.SessionInfo.WalletName,
|
||||
InviteCode: resp.SessionInfo.InviteCode,
|
||||
KeygenSessionID: resp.SessionInfo.KeygenSessionId,
|
||||
}
|
||||
}
|
||||
|
||||
for _, p := range resp.OtherParties {
|
||||
result.OtherParties = append(result.OtherParties, PartyInfoResponse{
|
||||
PartyID: p.PartyId,
|
||||
PartyIndex: p.PartyIndex,
|
||||
})
|
||||
}
|
||||
|
||||
logger.Info("JoinSession gRPC call succeeded",
|
||||
zap.Bool("success", resp.Success),
|
||||
zap.Int32("party_index", resp.PartyIndex))
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -137,6 +137,7 @@ func main() {
|
|||
getRecoveryStatusUC,
|
||||
cancelRecoveryUC,
|
||||
sessionCoordinatorClient,
|
||||
db,
|
||||
); err != nil {
|
||||
errChan <- fmt.Errorf("HTTP server error: %w", err)
|
||||
}
|
||||
|
|
@ -239,6 +240,7 @@ func startHTTPServer(
|
|||
getRecoveryStatusUC *use_cases.GetRecoveryStatusUseCase,
|
||||
cancelRecoveryUC *use_cases.CancelRecoveryUseCase,
|
||||
sessionCoordinatorClient *grpcadapter.SessionCoordinatorClient,
|
||||
db *sql.DB,
|
||||
) error {
|
||||
// Set Gin mode
|
||||
if cfg.Server.Environment == "production" {
|
||||
|
|
@ -300,14 +302,19 @@ func startHTTPServer(
|
|||
})
|
||||
})
|
||||
|
||||
// Create co-managed wallet handler (independent from existing functionality)
|
||||
// Uses database connection for invite_code lookups and JWT service for generating join tokens
|
||||
coManagedHandler := httphandler.NewCoManagedHTTPHandlerWithDB(sessionCoordinatorClient, db, jwtService)
|
||||
|
||||
// Configure authentication middleware
|
||||
// Skip paths that don't require authentication
|
||||
authConfig := middleware.AuthConfig{
|
||||
JWTService: jwtService,
|
||||
SkipPaths: []string{
|
||||
"/health",
|
||||
"/api/v1/auth/*", // Auth endpoints (login, refresh, challenge)
|
||||
"/api/v1/auth/*", // Auth endpoints (login, refresh, challenge)
|
||||
"/api/v1/accounts/from-keygen", // Internal API from coordinator
|
||||
"/api/v1/co-managed/*", // Co-managed wallet API (public for Service Party App)
|
||||
},
|
||||
AllowAnonymous: false,
|
||||
}
|
||||
|
|
@ -317,6 +324,9 @@ func startHTTPServer(
|
|||
api.Use(middleware.BearerAuth(authConfig))
|
||||
httpHandler.RegisterRoutes(api)
|
||||
|
||||
// Register co-managed wallet routes (public API)
|
||||
coManagedHandler.RegisterRoutes(api)
|
||||
|
||||
logger.Info("Starting HTTP server",
|
||||
zap.Int("port", cfg.Server.HTTPPort),
|
||||
zap.String("environment", cfg.Server.Environment),
|
||||
|
|
|
|||
|
|
@ -87,12 +87,49 @@ func (s *MessageRouterServer) RouteMessage(
|
|||
}
|
||||
|
||||
// SubscribeMessages subscribes to messages for a party (streaming)
|
||||
// On subscription, it first sends any pending messages from the database
|
||||
// to ensure no messages are lost during reconnection
|
||||
func (s *MessageRouterServer) SubscribeMessages(
|
||||
req *pb.SubscribeMessagesRequest,
|
||||
stream pb.MessageRouter_SubscribeMessagesServer,
|
||||
) error {
|
||||
ctx := stream.Context()
|
||||
|
||||
logger.Info("Party subscribing to messages",
|
||||
zap.String("session_id", req.SessionId),
|
||||
zap.String("party_id", req.PartyId))
|
||||
|
||||
// First, send any pending messages from the database (message recovery on reconnect)
|
||||
if s.getPendingMessagesUC != nil && req.SessionId != "" {
|
||||
input := use_cases.GetPendingMessagesInput{
|
||||
SessionID: req.SessionId,
|
||||
PartyID: req.PartyId,
|
||||
AfterTimestamp: 0, // Get all pending messages
|
||||
}
|
||||
|
||||
pendingMessages, err := s.getPendingMessagesUC.Execute(ctx, input)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to get pending messages on subscribe",
|
||||
zap.String("session_id", req.SessionId),
|
||||
zap.String("party_id", req.PartyId),
|
||||
zap.Error(err))
|
||||
} else if len(pendingMessages) > 0 {
|
||||
logger.Info("Sending pending messages on subscribe",
|
||||
zap.String("session_id", req.SessionId),
|
||||
zap.String("party_id", req.PartyId),
|
||||
zap.Int("count", len(pendingMessages)))
|
||||
|
||||
for _, msg := range pendingMessages {
|
||||
if err := sendMessage(stream, msg); err != nil {
|
||||
logger.Error("Failed to send pending message",
|
||||
zap.String("message_id", msg.ID),
|
||||
zap.Error(err))
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to party messages
|
||||
partyCh, err := s.messageBroker.SubscribeToPartyMessages(ctx, req.PartyId)
|
||||
if err != nil {
|
||||
|
|
@ -109,6 +146,9 @@ func (s *MessageRouterServer) SubscribeMessages(
|
|||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
logger.Info("Party unsubscribed from messages",
|
||||
zap.String("session_id", req.SessionId),
|
||||
zap.String("party_id", req.PartyId))
|
||||
return nil
|
||||
case msg, ok := <-partyCh:
|
||||
if !ok {
|
||||
|
|
@ -310,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 {
|
||||
|
|
@ -516,6 +558,7 @@ func (s *MessageRouterServer) JoinSession(
|
|||
ThresholdT: coordResp.SessionInfo.ThresholdT,
|
||||
MessageHash: coordResp.SessionInfo.MessageHash,
|
||||
KeygenSessionId: coordResp.SessionInfo.KeygenSessionId,
|
||||
Status: coordResp.SessionInfo.Status, // 修复: 添加缺失的 Status 字段
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -639,11 +682,24 @@ func (s *MessageRouterServer) GetSessionStatus(
|
|||
return nil, err
|
||||
}
|
||||
|
||||
// Convert participants from coordinator response
|
||||
var participants []*pb.PartyInfo
|
||||
if len(coordResp.Participants) > 0 {
|
||||
participants = make([]*pb.PartyInfo, len(coordResp.Participants))
|
||||
for i, p := range coordResp.Participants {
|
||||
participants[i] = &pb.PartyInfo{
|
||||
PartyId: p.PartyId,
|
||||
PartyIndex: p.PartyIndex,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &pb.GetSessionStatusResponse{
|
||||
SessionId: req.SessionId,
|
||||
Status: coordResp.Status,
|
||||
ThresholdN: coordResp.TotalParties, // Use TotalParties as N
|
||||
ThresholdT: coordResp.CompletedParties, // Return completed count in ThresholdT for info
|
||||
SessionId: req.SessionId,
|
||||
Status: coordResp.Status,
|
||||
ThresholdN: coordResp.ThresholdN, // Actual threshold N from session config
|
||||
ThresholdT: coordResp.ThresholdT, // Actual threshold T from session config
|
||||
Participants: participants, // Include participants for co_managed_keygen
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -116,6 +116,7 @@ func (a *MessageBrokerAdapter) PublishToSession(
|
|||
}
|
||||
|
||||
// SubscribeToPartyMessages subscribes to messages for a specific party
|
||||
// If the party already has an active subscription, the old channel is closed first
|
||||
func (a *MessageBrokerAdapter) SubscribeToPartyMessages(
|
||||
ctx context.Context,
|
||||
partyID string,
|
||||
|
|
@ -123,11 +124,15 @@ func (a *MessageBrokerAdapter) SubscribeToPartyMessages(
|
|||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
// Create channel if not exists
|
||||
if _, exists := a.partyChannels[partyID]; !exists {
|
||||
a.partyChannels[partyID] = make(chan *entities.MessageDTO, 100)
|
||||
// Close existing channel if party is re-subscribing (e.g., after reconnect)
|
||||
if oldCh, exists := a.partyChannels[partyID]; exists {
|
||||
close(oldCh)
|
||||
logger.Info("closed existing party channel for re-subscription",
|
||||
zap.String("party_id", partyID))
|
||||
}
|
||||
|
||||
// Create new channel
|
||||
a.partyChannels[partyID] = make(chan *entities.MessageDTO, 100)
|
||||
ch := a.partyChannels[partyID]
|
||||
|
||||
// Return a read-only channel
|
||||
|
|
@ -155,6 +160,7 @@ func (a *MessageBrokerAdapter) SubscribeToPartyMessages(
|
|||
}
|
||||
|
||||
// SubscribeToSessionMessages subscribes to all messages in a session
|
||||
// If the party already has an active subscription for this session, the old channel is closed first
|
||||
func (a *MessageBrokerAdapter) SubscribeToSessionMessages(
|
||||
ctx context.Context,
|
||||
sessionID string,
|
||||
|
|
@ -171,14 +177,18 @@ func (a *MessageBrokerAdapter) SubscribeToSessionMessages(
|
|||
zap.String("key", key),
|
||||
zap.Int("current_channel_count", len(a.sessionChannels)))
|
||||
|
||||
// Create channel if not exists
|
||||
if _, exists := a.sessionChannels[key]; !exists {
|
||||
a.sessionChannels[key] = make(chan *entities.MessageDTO, 100)
|
||||
logger.Info("Created new session channel",
|
||||
// Close existing channel if party is re-subscribing (e.g., after reconnect)
|
||||
if oldCh, exists := a.sessionChannels[key]; exists {
|
||||
close(oldCh)
|
||||
logger.Info("closed existing session channel for re-subscription",
|
||||
zap.String("key", key))
|
||||
}
|
||||
|
||||
// Create new channel
|
||||
a.sessionChannels[key] = make(chan *entities.MessageDTO, 100)
|
||||
ch := a.sessionChannels[key]
|
||||
logger.Info("Created new session channel",
|
||||
zap.String("key", key))
|
||||
|
||||
// Return a read-only channel
|
||||
out := make(chan *entities.MessageDTO, 100)
|
||||
|
|
|
|||
|
|
@ -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,25 +23,51 @@ func NewSessionEventBroadcaster() *SessionEventBroadcaster {
|
|||
}
|
||||
|
||||
// Subscribe subscribes a party to session events
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -62,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,38 @@
|
|||
# Build stage
|
||||
FROM golang:1.24-alpine AS builder
|
||||
|
||||
RUN apk add --no-cache git ca-certificates
|
||||
|
||||
# Set Go proxy (can be overridden with --build-arg GOPROXY=...)
|
||||
ARG GOPROXY=https://proxy.golang.org,direct
|
||||
ENV GOPROXY=${GOPROXY}
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
|
||||
-ldflags="-w -s" \
|
||||
-o /bin/server-party-co-managed \
|
||||
./services/server-party-co-managed/cmd/server
|
||||
|
||||
# Final stage
|
||||
FROM alpine:3.18
|
||||
|
||||
RUN apk --no-cache add ca-certificates curl
|
||||
RUN adduser -D -s /bin/sh mpc
|
||||
|
||||
COPY --from=builder /bin/server-party-co-managed /bin/server-party-co-managed
|
||||
|
||||
USER mpc
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD curl -sf http://localhost:8080/health || exit 1
|
||||
|
||||
ENTRYPOINT ["/bin/server-party-co-managed"]
|
||||
|
|
@ -0,0 +1,464 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
router "github.com/rwadurian/mpc-system/api/grpc/router/v1"
|
||||
"github.com/rwadurian/mpc-system/pkg/config"
|
||||
"github.com/rwadurian/mpc-system/pkg/crypto"
|
||||
"github.com/rwadurian/mpc-system/pkg/logger"
|
||||
grpcclient "github.com/rwadurian/mpc-system/services/server-party/adapters/output/grpc"
|
||||
"github.com/rwadurian/mpc-system/services/server-party/adapters/output/postgres"
|
||||
"github.com/rwadurian/mpc-system/services/server-party/application/use_cases"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// PendingSession stores session info between session_created and session_started events
|
||||
type PendingSession struct {
|
||||
SessionID uuid.UUID
|
||||
JoinToken string
|
||||
MessageHash []byte
|
||||
ThresholdN int
|
||||
ThresholdT int
|
||||
SelectedParties []string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// PendingSessionCache stores pending sessions waiting for session_started
|
||||
type PendingSessionCache struct {
|
||||
mu sync.RWMutex
|
||||
sessions map[string]*PendingSession // sessionID -> PendingSession
|
||||
}
|
||||
|
||||
// Global pending session cache
|
||||
var pendingSessionCache = &PendingSessionCache{
|
||||
sessions: make(map[string]*PendingSession),
|
||||
}
|
||||
|
||||
// Store stores a pending session
|
||||
func (c *PendingSessionCache) Store(sessionID string, session *PendingSession) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.sessions[sessionID] = session
|
||||
logger.Info("Pending session stored",
|
||||
zap.String("session_id", sessionID))
|
||||
}
|
||||
|
||||
// Get retrieves and deletes a pending session
|
||||
func (c *PendingSessionCache) Get(sessionID string) (*PendingSession, bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
session, exists := c.sessions[sessionID]
|
||||
if exists {
|
||||
delete(c.sessions, sessionID)
|
||||
logger.Info("Pending session retrieved and deleted",
|
||||
zap.String("session_id", sessionID))
|
||||
}
|
||||
return session, exists
|
||||
}
|
||||
|
||||
// Delete removes a pending session without returning it
|
||||
func (c *PendingSessionCache) Delete(sessionID string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
delete(c.sessions, sessionID)
|
||||
}
|
||||
|
||||
func main() {
|
||||
// Parse flags
|
||||
configPath := flag.String("config", "", "Path to config file")
|
||||
flag.Parse()
|
||||
|
||||
// Load configuration
|
||||
cfg, err := config.Load(*configPath)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to load config: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Initialize logger
|
||||
if err := logger.Init(&logger.Config{
|
||||
Level: cfg.Logger.Level,
|
||||
Encoding: cfg.Logger.Encoding,
|
||||
}); err != nil {
|
||||
fmt.Printf("Failed to initialize logger: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer logger.Sync()
|
||||
|
||||
logger.Info("Starting Server Party Co-Managed Service",
|
||||
zap.String("environment", cfg.Server.Environment),
|
||||
zap.Int("http_port", cfg.Server.HTTPPort))
|
||||
|
||||
// Initialize database connection
|
||||
db, err := initDatabase(cfg.Database)
|
||||
if err != nil {
|
||||
logger.Fatal("Failed to connect to database", zap.Error(err))
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Initialize crypto service with master key from environment
|
||||
masterKeyHex := os.Getenv("MPC_CRYPTO_MASTER_KEY")
|
||||
if masterKeyHex == "" {
|
||||
masterKeyHex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" // 64 hex chars = 32 bytes
|
||||
}
|
||||
masterKey, err := hex.DecodeString(masterKeyHex)
|
||||
if err != nil {
|
||||
logger.Fatal("Invalid master key format", zap.Error(err))
|
||||
}
|
||||
cryptoService, err := crypto.NewCryptoService(masterKey)
|
||||
if err != nil {
|
||||
logger.Fatal("Failed to create crypto service", zap.Error(err))
|
||||
}
|
||||
|
||||
// Get Message Router address from environment
|
||||
routerAddr := os.Getenv("MESSAGE_ROUTER_ADDR")
|
||||
if routerAddr == "" {
|
||||
routerAddr = "localhost:9092"
|
||||
}
|
||||
|
||||
// Initialize Message Router client
|
||||
messageRouter, err := grpcclient.NewMessageRouterClient(routerAddr)
|
||||
if err != nil {
|
||||
logger.Fatal("Failed to connect to message router", zap.Error(err))
|
||||
}
|
||||
defer messageRouter.Close()
|
||||
|
||||
// Initialize repositories
|
||||
keyShareRepo := postgres.NewKeySharePostgresRepo(db)
|
||||
|
||||
// Initialize use cases
|
||||
participateKeygenUC := use_cases.NewParticipateKeygenUseCase(
|
||||
keyShareRepo,
|
||||
messageRouter,
|
||||
messageRouter,
|
||||
cryptoService,
|
||||
)
|
||||
|
||||
// Create shutdown context
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
// Get party ID from environment
|
||||
partyID := os.Getenv("PARTY_ID")
|
||||
if partyID == "" {
|
||||
partyID, _ = os.Hostname()
|
||||
if partyID == "" {
|
||||
partyID = "co-managed-party-" + uuid.New().String()[:8]
|
||||
}
|
||||
}
|
||||
|
||||
// Party role is co_managed_persistent - different from normal persistent
|
||||
// This ensures co_managed_keygen sessions only select these parties
|
||||
partyRole := "co_managed_persistent"
|
||||
|
||||
// Register this party with Message Router
|
||||
logger.Info("Registering co-managed party with Message Router",
|
||||
zap.String("party_id", partyID),
|
||||
zap.String("role", partyRole))
|
||||
|
||||
if err := messageRouter.RegisterPartyWithNotification(ctx, partyID, partyRole, "1.0.0", nil); err != nil {
|
||||
logger.Fatal("Failed to register party", zap.Error(err))
|
||||
}
|
||||
|
||||
// Start heartbeat
|
||||
heartbeatCancel := messageRouter.StartHeartbeat(ctx, partyID, 30*time.Second, func(pendingCount int32) {
|
||||
if pendingCount > 0 {
|
||||
logger.Info("Pending messages detected via heartbeat",
|
||||
zap.String("party_id", partyID),
|
||||
zap.Int32("pending_count", pendingCount))
|
||||
}
|
||||
})
|
||||
defer heartbeatCancel()
|
||||
logger.Info("Heartbeat started", zap.String("party_id", partyID), zap.Duration("interval", 30*time.Second))
|
||||
|
||||
// Subscribe to session events with two-phase handling for co_managed_keygen
|
||||
logger.Info("Subscribing to session events (co_managed_keygen only)", zap.String("party_id", partyID))
|
||||
|
||||
eventHandler := createCoManagedSessionEventHandler(
|
||||
ctx,
|
||||
partyID,
|
||||
messageRouter,
|
||||
participateKeygenUC,
|
||||
)
|
||||
|
||||
if err := messageRouter.SubscribeSessionEvents(ctx, partyID, eventHandler); err != nil {
|
||||
logger.Fatal("Failed to subscribe to session events", zap.Error(err))
|
||||
}
|
||||
|
||||
logger.Info("Co-managed party initialized successfully",
|
||||
zap.String("party_id", partyID),
|
||||
zap.String("role", partyRole))
|
||||
|
||||
// Start HTTP server
|
||||
errChan := make(chan error, 1)
|
||||
go func() {
|
||||
if err := startHTTPServer(cfg); err != nil {
|
||||
errChan <- fmt.Errorf("HTTP server error: %w", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for shutdown signal
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
select {
|
||||
case sig := <-sigChan:
|
||||
logger.Info("Received shutdown signal", zap.String("signal", sig.String()))
|
||||
case err := <-errChan:
|
||||
logger.Error("Server error", zap.Error(err))
|
||||
}
|
||||
|
||||
// Graceful shutdown
|
||||
logger.Info("Shutting down...")
|
||||
cancel()
|
||||
|
||||
time.Sleep(5 * time.Second)
|
||||
logger.Info("Shutdown complete")
|
||||
}
|
||||
|
||||
func initDatabase(cfg config.DatabaseConfig) (*sql.DB, error) {
|
||||
const maxRetries = 10
|
||||
const retryDelay = 2 * time.Second
|
||||
|
||||
var db *sql.DB
|
||||
var err error
|
||||
|
||||
for i := 0; i < maxRetries; i++ {
|
||||
db, err = sql.Open("postgres", cfg.DSN())
|
||||
if err != nil {
|
||||
logger.Warn("Failed to open database connection, retrying...",
|
||||
zap.Int("attempt", i+1),
|
||||
zap.Int("max_retries", maxRetries),
|
||||
zap.Error(err))
|
||||
time.Sleep(retryDelay * time.Duration(i+1))
|
||||
continue
|
||||
}
|
||||
|
||||
db.SetMaxOpenConns(cfg.MaxOpenConns)
|
||||
db.SetMaxIdleConns(cfg.MaxIdleConns)
|
||||
db.SetConnMaxLifetime(cfg.ConnMaxLife)
|
||||
|
||||
if err = db.Ping(); err != nil {
|
||||
logger.Warn("Failed to ping database, retrying...",
|
||||
zap.Int("attempt", i+1),
|
||||
zap.Int("max_retries", maxRetries),
|
||||
zap.Error(err))
|
||||
db.Close()
|
||||
time.Sleep(retryDelay * time.Duration(i+1))
|
||||
continue
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
var result int
|
||||
err = db.QueryRowContext(ctx, "SELECT 1").Scan(&result)
|
||||
cancel()
|
||||
if err != nil {
|
||||
logger.Warn("Database ping succeeded but query failed, retrying...",
|
||||
zap.Int("attempt", i+1),
|
||||
zap.Int("max_retries", maxRetries),
|
||||
zap.Error(err))
|
||||
db.Close()
|
||||
time.Sleep(retryDelay * time.Duration(i+1))
|
||||
continue
|
||||
}
|
||||
|
||||
logger.Info("Connected to PostgreSQL and verified connectivity",
|
||||
zap.Int("attempt", i+1))
|
||||
return db, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("failed to connect to database after %d retries: %w", maxRetries, err)
|
||||
}
|
||||
|
||||
func startHTTPServer(cfg *config.Config) error {
|
||||
if cfg.Server.Environment == "production" {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
|
||||
r := gin.New()
|
||||
r.Use(gin.Recovery())
|
||||
r.Use(gin.Logger())
|
||||
|
||||
// Health check
|
||||
r.GET("/health", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "healthy",
|
||||
"service": "server-party-co-managed",
|
||||
})
|
||||
})
|
||||
|
||||
logger.Info("Starting HTTP server", zap.Int("port", cfg.Server.HTTPPort))
|
||||
return r.Run(fmt.Sprintf(":%d", cfg.Server.HTTPPort))
|
||||
}
|
||||
|
||||
// createCoManagedSessionEventHandler creates a handler specifically for co_managed_keygen sessions
|
||||
// Two-phase event handling:
|
||||
// Phase 1 (session_created): JoinSession immediately + store session info
|
||||
// Phase 2 (session_started): Execute TSS protocol (same timing as user clients receiving all_joined)
|
||||
func createCoManagedSessionEventHandler(
|
||||
ctx context.Context,
|
||||
partyID string,
|
||||
messageRouter *grpcclient.MessageRouterClient,
|
||||
participateKeygenUC *use_cases.ParticipateKeygenUseCase,
|
||||
) func(*router.SessionEvent) {
|
||||
return func(event *router.SessionEvent) {
|
||||
// Check if this party is selected for the session
|
||||
isSelected := false
|
||||
for _, selectedParty := range event.SelectedParties {
|
||||
if selectedParty == partyID {
|
||||
isSelected = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !isSelected {
|
||||
logger.Debug("Party not selected for this session",
|
||||
zap.String("session_id", event.SessionId),
|
||||
zap.String("party_id", partyID))
|
||||
return
|
||||
}
|
||||
|
||||
logger.Info("Received session event",
|
||||
zap.String("session_id", event.SessionId),
|
||||
zap.String("party_id", partyID),
|
||||
zap.String("event_type", event.EventType))
|
||||
|
||||
// Parse session ID
|
||||
sessionID, err := uuid.Parse(event.SessionId)
|
||||
if err != nil {
|
||||
logger.Error("Invalid session ID", zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
// Handle different event types
|
||||
switch event.EventType {
|
||||
case "session_created":
|
||||
// Only handle keygen sessions (no message_hash)
|
||||
if len(event.MessageHash) > 0 {
|
||||
logger.Debug("Ignoring sign session (co-managed only handles keygen)",
|
||||
zap.String("session_id", event.SessionId))
|
||||
return
|
||||
}
|
||||
|
||||
// Phase 1: Get join token
|
||||
joinToken, exists := event.JoinTokens[partyID]
|
||||
if !exists {
|
||||
logger.Error("No join token found for party in session_created",
|
||||
zap.String("session_id", event.SessionId),
|
||||
zap.String("party_id", partyID))
|
||||
return
|
||||
}
|
||||
|
||||
// Immediately call JoinSession (this is required to trigger session_started)
|
||||
joinCtx, joinCancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
_, err := messageRouter.JoinSession(joinCtx, sessionID, partyID, joinToken)
|
||||
joinCancel()
|
||||
if err != nil {
|
||||
logger.Error("Failed to join session",
|
||||
zap.String("session_id", event.SessionId),
|
||||
zap.String("party_id", partyID),
|
||||
zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
logger.Info("Successfully joined session, waiting for session_started",
|
||||
zap.String("session_id", event.SessionId),
|
||||
zap.String("party_id", partyID))
|
||||
|
||||
// Store pending session for later use when session_started arrives
|
||||
pendingSessionCache.Store(event.SessionId, &PendingSession{
|
||||
SessionID: sessionID,
|
||||
JoinToken: joinToken,
|
||||
MessageHash: event.MessageHash,
|
||||
ThresholdN: int(event.ThresholdN),
|
||||
ThresholdT: int(event.ThresholdT),
|
||||
SelectedParties: event.SelectedParties,
|
||||
CreatedAt: time.Now(),
|
||||
})
|
||||
|
||||
case "session_started":
|
||||
// Phase 2: All participants have joined, now execute TSS protocol
|
||||
pendingSession, exists := pendingSessionCache.Get(event.SessionId)
|
||||
if !exists {
|
||||
logger.Warn("No pending session found for session_started event",
|
||||
zap.String("session_id", event.SessionId),
|
||||
zap.String("party_id", partyID))
|
||||
return
|
||||
}
|
||||
|
||||
logger.Info("Session started event received, beginning TSS keygen protocol",
|
||||
zap.String("session_id", event.SessionId),
|
||||
zap.String("party_id", partyID))
|
||||
|
||||
// Execute TSS keygen protocol in goroutine
|
||||
// Timeout starts NOW (when session_started is received), not at session_created
|
||||
go func() {
|
||||
// 10 minute timeout for TSS protocol execution
|
||||
participateCtx, cancel := context.WithTimeout(ctx, 10*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
logger.Info("Auto-participating in co_managed_keygen session",
|
||||
zap.String("session_id", event.SessionId),
|
||||
zap.String("party_id", partyID))
|
||||
|
||||
// Build SessionInfo from session_started event (NOT from pendingSession cache)
|
||||
// session_started event contains ALL participants who have joined,
|
||||
// including external parties that joined dynamically after session_created
|
||||
// Note: We already called JoinSession in session_created phase,
|
||||
// so we use ExecuteWithSessionInfo to skip the duplicate JoinSession call
|
||||
participants := make([]use_cases.ParticipantInfo, len(event.SelectedParties))
|
||||
for i, p := range event.SelectedParties {
|
||||
participants[i] = use_cases.ParticipantInfo{
|
||||
PartyID: p,
|
||||
PartyIndex: i,
|
||||
}
|
||||
}
|
||||
|
||||
sessionInfo := &use_cases.SessionInfo{
|
||||
SessionID: pendingSession.SessionID,
|
||||
SessionType: "co_managed_keygen",
|
||||
ThresholdN: int(event.ThresholdN),
|
||||
ThresholdT: int(event.ThresholdT),
|
||||
MessageHash: pendingSession.MessageHash,
|
||||
Participants: participants,
|
||||
}
|
||||
|
||||
result, err := participateKeygenUC.ExecuteWithSessionInfo(
|
||||
participateCtx,
|
||||
pendingSession.SessionID,
|
||||
partyID,
|
||||
sessionInfo,
|
||||
)
|
||||
if err != nil {
|
||||
logger.Error("Co-managed keygen participation failed",
|
||||
zap.Error(err),
|
||||
zap.String("session_id", event.SessionId))
|
||||
} else {
|
||||
logger.Info("Co-managed keygen participation completed",
|
||||
zap.String("session_id", event.SessionId),
|
||||
zap.String("public_key", hex.EncodeToString(result.PublicKey)))
|
||||
}
|
||||
}()
|
||||
|
||||
default:
|
||||
logger.Debug("Ignoring unhandled event type",
|
||||
zap.String("session_id", event.SessionId),
|
||||
zap.String("event_type", event.EventType))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -385,7 +385,7 @@ func (c *MessageRouterClient) UpdateNotificationChannels(
|
|||
return c.RegisterPartyWithNotification(ctx, partyID, partyRole, version, notification)
|
||||
}
|
||||
|
||||
// SubscribeSessionEvents subscribes to session lifecycle events
|
||||
// SubscribeSessionEvents subscribes to session lifecycle events with auto-reconnect
|
||||
func (c *MessageRouterClient) SubscribeSessionEvents(
|
||||
ctx context.Context,
|
||||
partyID string,
|
||||
|
|
@ -396,7 +396,7 @@ func (c *MessageRouterClient) SubscribeSessionEvents(
|
|||
EventTypes: []string{}, // Subscribe to all event types
|
||||
}
|
||||
|
||||
// Create a streaming connection
|
||||
// Create initial streaming connection
|
||||
stream, err := c.createSessionEventStream(ctx, req)
|
||||
if err != nil {
|
||||
logger.Error("Failed to subscribe to session events",
|
||||
|
|
@ -408,8 +408,12 @@ func (c *MessageRouterClient) SubscribeSessionEvents(
|
|||
logger.Info("Subscribed to session events",
|
||||
zap.String("party_id", partyID))
|
||||
|
||||
// Start goroutine to receive events
|
||||
// Start goroutine to receive events with auto-reconnect
|
||||
go func() {
|
||||
currentStream := stream
|
||||
reconnectBackoff := time.Second // Start with 1 second backoff
|
||||
maxBackoff := 30 * time.Second
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
|
|
@ -418,27 +422,60 @@ func (c *MessageRouterClient) SubscribeSessionEvents(
|
|||
return
|
||||
default:
|
||||
event := &router.SessionEvent{}
|
||||
err := stream.RecvMsg(event)
|
||||
err := currentStream.RecvMsg(event)
|
||||
if err == io.EOF {
|
||||
logger.Info("Session event stream ended",
|
||||
logger.Warn("Session event stream ended, reconnecting...",
|
||||
zap.String("party_id", partyID))
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
logger.Error("Error receiving session event",
|
||||
} else if err != nil {
|
||||
logger.Warn("Error receiving session event, reconnecting...",
|
||||
zap.Error(err),
|
||||
zap.String("party_id", partyID))
|
||||
return
|
||||
} else {
|
||||
// Successfully received event, reset backoff
|
||||
reconnectBackoff = time.Second
|
||||
|
||||
logger.Info("Received session event",
|
||||
zap.String("event_type", event.EventType),
|
||||
zap.String("session_id", event.SessionId),
|
||||
zap.String("party_id", partyID))
|
||||
|
||||
// Call event handler
|
||||
if eventHandler != nil {
|
||||
eventHandler(event)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
logger.Info("Received session event",
|
||||
zap.String("event_type", event.EventType),
|
||||
zap.String("session_id", event.SessionId),
|
||||
zap.String("party_id", partyID))
|
||||
// Reconnect with exponential backoff
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(reconnectBackoff):
|
||||
logger.Info("Attempting to reconnect session event stream",
|
||||
zap.String("party_id", partyID),
|
||||
zap.Duration("backoff", reconnectBackoff))
|
||||
|
||||
// Call event handler
|
||||
if eventHandler != nil {
|
||||
eventHandler(event)
|
||||
newStream, err := c.createSessionEventStream(ctx, req)
|
||||
if err != nil {
|
||||
logger.Error("Failed to reconnect session event stream",
|
||||
zap.Error(err),
|
||||
zap.String("party_id", partyID))
|
||||
// Increase backoff for next attempt
|
||||
reconnectBackoff = reconnectBackoff * 2
|
||||
if reconnectBackoff > maxBackoff {
|
||||
reconnectBackoff = maxBackoff
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
logger.Info("Successfully reconnected to session events",
|
||||
zap.String("party_id", partyID))
|
||||
currentStream = newStream
|
||||
reconnectBackoff = time.Second // Reset backoff on success
|
||||
break
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -814,3 +851,45 @@ func (c *MessageRouterClient) SubmitDelegateShare(
|
|||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// GetSessionStatusFull gets the full session status including participants via Message Router
|
||||
// This is used for co_managed_keygen sessions to wait for all parties to join
|
||||
// Includes automatic retry with exponential backoff for transient failures
|
||||
func (c *MessageRouterClient) GetSessionStatusFull(
|
||||
ctx context.Context,
|
||||
sessionID uuid.UUID,
|
||||
) (*use_cases.SessionStatusInfo, error) {
|
||||
req := &router.GetSessionStatusRequest{
|
||||
SessionId: sessionID.String(),
|
||||
}
|
||||
|
||||
return retry.Do(ctx, c.retryCfg, "GetSessionStatusFull", func() (*use_cases.SessionStatusInfo, error) {
|
||||
resp := &router.GetSessionStatusResponse{}
|
||||
err := c.getConn().Invoke(ctx, "/mpc.router.v1.MessageRouter/GetSessionStatus", req, resp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Convert participants from response
|
||||
participants := make([]use_cases.ParticipantInfo, len(resp.Participants))
|
||||
for i, p := range resp.Participants {
|
||||
participants[i] = use_cases.ParticipantInfo{
|
||||
PartyID: p.PartyId,
|
||||
PartyIndex: int(p.PartyIndex),
|
||||
}
|
||||
}
|
||||
|
||||
logger.Debug("GetSessionStatusFull response",
|
||||
zap.String("session_id", sessionID.String()),
|
||||
zap.String("status", resp.Status),
|
||||
zap.Int32("threshold_n", resp.ThresholdN),
|
||||
zap.Int("participants_count", len(participants)))
|
||||
|
||||
return &use_cases.SessionStatusInfo{
|
||||
Status: resp.Status,
|
||||
ThresholdN: int(resp.ThresholdN),
|
||||
ThresholdT: int(resp.ThresholdT),
|
||||
Participants: participants,
|
||||
}, nil
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,12 +41,22 @@ type ParticipateKeygenOutput struct {
|
|||
type SessionCoordinatorClient interface {
|
||||
JoinSession(ctx context.Context, sessionID uuid.UUID, partyID, joinToken string) (*SessionInfo, error)
|
||||
ReportCompletion(ctx context.Context, sessionID uuid.UUID, partyID string, publicKey []byte) error
|
||||
GetSessionStatusFull(ctx context.Context, sessionID uuid.UUID) (*SessionStatusInfo, error)
|
||||
}
|
||||
|
||||
// SessionStatusInfo contains full session status information
|
||||
type SessionStatusInfo struct {
|
||||
Status string
|
||||
ThresholdN int
|
||||
ThresholdT int
|
||||
Participants []ParticipantInfo
|
||||
}
|
||||
|
||||
// MessageRouterClient defines the interface for message router communication
|
||||
type MessageRouterClient interface {
|
||||
RouteMessage(ctx context.Context, sessionID uuid.UUID, fromParty string, toParties []string, roundNumber int, payload []byte) error
|
||||
SubscribeMessages(ctx context.Context, sessionID uuid.UUID, partyID string) (<-chan *MPCMessage, error)
|
||||
Heartbeat(ctx context.Context, partyID string) (int32, error)
|
||||
}
|
||||
|
||||
// SessionInfo contains session information from coordinator
|
||||
|
|
@ -110,16 +120,61 @@ func (uc *ParticipateKeygenUseCase) Execute(
|
|||
return nil, err
|
||||
}
|
||||
|
||||
if sessionInfo.SessionType != "keygen" {
|
||||
// Accept both "keygen" and "co_managed_keygen" session types
|
||||
if sessionInfo.SessionType != "keygen" && sessionInfo.SessionType != "co_managed_keygen" {
|
||||
return nil, ErrInvalidSession
|
||||
}
|
||||
|
||||
// 2. Find self in participants and build party index map
|
||||
// For co_managed_keygen: wait for all N participants to join before proceeding
|
||||
// This is necessary because server parties join immediately but external party joins later
|
||||
if sessionInfo.SessionType == "co_managed_keygen" {
|
||||
sessionInfo, err = uc.waitForAllParticipants(ctx, input.SessionID, sessionInfo, input.PartyID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Delegate to the common execution logic
|
||||
return uc.executeWithSessionInfo(ctx, input.SessionID, input.PartyID, sessionInfo)
|
||||
}
|
||||
|
||||
// ExecuteWithSessionInfo participates in a keygen session with pre-obtained SessionInfo
|
||||
// This is used by server-party-co-managed which has already called JoinSession in session_created phase
|
||||
// and receives session_started event when all participants have joined
|
||||
func (uc *ParticipateKeygenUseCase) ExecuteWithSessionInfo(
|
||||
ctx context.Context,
|
||||
sessionID uuid.UUID,
|
||||
partyID string,
|
||||
sessionInfo *SessionInfo,
|
||||
) (*ParticipateKeygenOutput, error) {
|
||||
// Validate session type
|
||||
if sessionInfo.SessionType != "keygen" && sessionInfo.SessionType != "co_managed_keygen" {
|
||||
return nil, ErrInvalidSession
|
||||
}
|
||||
|
||||
logger.Info("ExecuteWithSessionInfo: starting keygen with pre-obtained session info",
|
||||
zap.String("session_id", sessionID.String()),
|
||||
zap.String("party_id", partyID),
|
||||
zap.String("session_type", sessionInfo.SessionType),
|
||||
zap.Int("participants", len(sessionInfo.Participants)))
|
||||
|
||||
// Delegate to the common execution logic
|
||||
return uc.executeWithSessionInfo(ctx, sessionID, partyID, sessionInfo)
|
||||
}
|
||||
|
||||
// executeWithSessionInfo is the common execution logic shared by Execute and ExecuteWithSessionInfo
|
||||
func (uc *ParticipateKeygenUseCase) executeWithSessionInfo(
|
||||
ctx context.Context,
|
||||
sessionID uuid.UUID,
|
||||
partyID string,
|
||||
sessionInfo *SessionInfo,
|
||||
) (*ParticipateKeygenOutput, error) {
|
||||
// 1. Find self in participants and build party index map
|
||||
var selfIndex int
|
||||
partyIndexMap := make(map[string]int)
|
||||
for _, p := range sessionInfo.Participants {
|
||||
partyIndexMap[p.PartyID] = p.PartyIndex
|
||||
if p.PartyID == input.PartyID {
|
||||
if p.PartyID == partyID {
|
||||
selfIndex = p.PartyIndex
|
||||
}
|
||||
logger.Debug("Added participant to index map",
|
||||
|
|
@ -127,13 +182,13 @@ func (uc *ParticipateKeygenUseCase) Execute(
|
|||
zap.Int("party_index", p.PartyIndex))
|
||||
}
|
||||
logger.Info("Built party index map",
|
||||
zap.String("session_id", input.SessionID.String()),
|
||||
zap.String("self_party_id", input.PartyID),
|
||||
zap.String("session_id", sessionID.String()),
|
||||
zap.String("self_party_id", partyID),
|
||||
zap.Int("self_index", selfIndex),
|
||||
zap.Int("total_participants", len(sessionInfo.Participants)))
|
||||
|
||||
// 3. Subscribe to messages
|
||||
msgChan, err := uc.messageRouter.SubscribeMessages(ctx, input.SessionID, input.PartyID)
|
||||
msgChan, err := uc.messageRouter.SubscribeMessages(ctx, sessionID, partyID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -141,8 +196,8 @@ func (uc *ParticipateKeygenUseCase) Execute(
|
|||
// 4. Run TSS Keygen protocol
|
||||
saveData, publicKey, err := uc.runKeygenProtocol(
|
||||
ctx,
|
||||
input.SessionID,
|
||||
input.PartyID,
|
||||
sessionID,
|
||||
partyID,
|
||||
selfIndex,
|
||||
sessionInfo.Participants,
|
||||
sessionInfo.ThresholdN,
|
||||
|
|
@ -155,15 +210,15 @@ func (uc *ParticipateKeygenUseCase) Execute(
|
|||
}
|
||||
|
||||
// 5. Encrypt the share
|
||||
encryptedShare, err := uc.cryptoService.EncryptShare(saveData, input.PartyID)
|
||||
encryptedShare, err := uc.cryptoService.EncryptShare(saveData, partyID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
keyShare := entities.NewPartyKeyShare(
|
||||
input.PartyID,
|
||||
partyID,
|
||||
selfIndex,
|
||||
input.SessionID,
|
||||
sessionID,
|
||||
sessionInfo.ThresholdN,
|
||||
sessionInfo.ThresholdT,
|
||||
encryptedShare,
|
||||
|
|
@ -181,21 +236,21 @@ func (uc *ParticipateKeygenUseCase) Execute(
|
|||
return nil, ErrShareSaveFailed
|
||||
}
|
||||
logger.Info("Share saved to database (persistent party)",
|
||||
zap.String("party_id", input.PartyID),
|
||||
zap.String("session_id", input.SessionID.String()))
|
||||
zap.String("party_id", partyID),
|
||||
zap.String("session_id", sessionID.String()))
|
||||
|
||||
case "delegate":
|
||||
// Delegate Party: do NOT save to database, return to user
|
||||
shareForUser = encryptedShare
|
||||
logger.Info("Share NOT saved, will be returned to user (delegate party)",
|
||||
zap.String("party_id", input.PartyID),
|
||||
zap.String("session_id", input.SessionID.String()),
|
||||
zap.String("party_id", partyID),
|
||||
zap.String("session_id", sessionID.String()),
|
||||
zap.Int("share_size", len(shareForUser)))
|
||||
|
||||
case "temporary":
|
||||
// Temporary Party: optionally save to temp storage (not implemented yet)
|
||||
logger.Info("Temporary party - share not saved",
|
||||
zap.String("party_id", input.PartyID))
|
||||
zap.String("party_id", partyID))
|
||||
|
||||
default:
|
||||
// Default to persistent for safety
|
||||
|
|
@ -203,12 +258,12 @@ func (uc *ParticipateKeygenUseCase) Execute(
|
|||
return nil, ErrShareSaveFailed
|
||||
}
|
||||
logger.Warn("Unknown party role, defaulting to persistent",
|
||||
zap.String("party_id", input.PartyID),
|
||||
zap.String("party_id", partyID),
|
||||
zap.String("role", partyRole))
|
||||
}
|
||||
|
||||
// 7. Report completion to coordinator
|
||||
if err := uc.sessionClient.ReportCompletion(ctx, input.SessionID, input.PartyID, publicKey); err != nil {
|
||||
if err := uc.sessionClient.ReportCompletion(ctx, sessionID, partyID, publicKey); err != nil {
|
||||
logger.Error("failed to report completion", zap.Error(err))
|
||||
// Don't fail - share is handled
|
||||
}
|
||||
|
|
@ -368,3 +423,89 @@ func (uc *ParticipateKeygenUseCase) getPartyRole() string {
|
|||
}
|
||||
return role
|
||||
}
|
||||
|
||||
// waitForAllParticipants waits for all N participants to join the session
|
||||
// This is only used for co_managed_keygen sessions where server parties join first
|
||||
// and need to wait for the external party to join via invite code
|
||||
func (uc *ParticipateKeygenUseCase) waitForAllParticipants(
|
||||
ctx context.Context,
|
||||
sessionID uuid.UUID,
|
||||
initialSessionInfo *SessionInfo,
|
||||
partyID string,
|
||||
) (*SessionInfo, error) {
|
||||
logger.Info("Waiting for all participants to join co_managed_keygen session",
|
||||
zap.String("session_id", sessionID.String()),
|
||||
zap.Int("expected_n", initialSessionInfo.ThresholdN),
|
||||
zap.Int("current_participants", len(initialSessionInfo.Participants)))
|
||||
|
||||
// If already have all participants, return immediately
|
||||
if len(initialSessionInfo.Participants) >= initialSessionInfo.ThresholdN {
|
||||
logger.Info("All participants already joined",
|
||||
zap.String("session_id", sessionID.String()))
|
||||
return initialSessionInfo, nil
|
||||
}
|
||||
|
||||
// Poll for session status until all participants join or timeout
|
||||
pollInterval := 2 * time.Second
|
||||
maxWaitTime := 5 * time.Minute
|
||||
deadline := time.Now().Add(maxWaitTime)
|
||||
|
||||
for time.Now().Before(deadline) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(pollInterval):
|
||||
// Send heartbeat to keep the party alive during wait
|
||||
// This prevents the session-coordinator from timing out this party
|
||||
_, heartbeatErr := uc.messageRouter.Heartbeat(ctx, partyID)
|
||||
if heartbeatErr != nil {
|
||||
logger.Warn("Failed to send heartbeat during wait",
|
||||
zap.String("session_id", sessionID.String()),
|
||||
zap.String("party_id", partyID),
|
||||
zap.Error(heartbeatErr))
|
||||
// Continue anyway - heartbeat failure is not fatal
|
||||
}
|
||||
|
||||
// Get full session status including participants
|
||||
statusInfo, err := uc.sessionClient.GetSessionStatusFull(ctx, sessionID)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to get session status, will retry",
|
||||
zap.String("session_id", sessionID.String()),
|
||||
zap.Error(err))
|
||||
continue
|
||||
}
|
||||
|
||||
logger.Debug("Polled session status",
|
||||
zap.String("session_id", sessionID.String()),
|
||||
zap.String("status", statusInfo.Status),
|
||||
zap.Int("participants", len(statusInfo.Participants)),
|
||||
zap.Int("expected_n", initialSessionInfo.ThresholdN))
|
||||
|
||||
// Check if session is in_progress (all parties joined and ready)
|
||||
if statusInfo.Status == "in_progress" && len(statusInfo.Participants) >= initialSessionInfo.ThresholdN {
|
||||
logger.Info("All participants joined, session is in_progress",
|
||||
zap.String("session_id", sessionID.String()),
|
||||
zap.Int("participants", len(statusInfo.Participants)))
|
||||
|
||||
// Update session info with full participants list
|
||||
initialSessionInfo.Participants = statusInfo.Participants
|
||||
return initialSessionInfo, nil
|
||||
}
|
||||
|
||||
// Also accept if we have all N participants even if status hasn't changed
|
||||
if len(statusInfo.Participants) >= initialSessionInfo.ThresholdN {
|
||||
logger.Info("All participants joined",
|
||||
zap.String("session_id", sessionID.String()),
|
||||
zap.Int("participants", len(statusInfo.Participants)))
|
||||
|
||||
initialSessionInfo.Participants = statusInfo.Participants
|
||||
return initialSessionInfo, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.Error("Timeout waiting for all participants",
|
||||
zap.String("session_id", sessionID.String()),
|
||||
zap.Int("expected_n", initialSessionInfo.ThresholdN))
|
||||
return nil, ErrKeygenTimeout
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
dist-electron/
|
||||
release/
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
|
||||
# SQLite database files
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Electron build cache
|
||||
.electron/
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
@echo off
|
||||
chcp 65001 >nul
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
echo ============================================
|
||||
echo Service Party App - Windows Build Script
|
||||
echo ============================================
|
||||
echo.
|
||||
|
||||
cd /d "%~dp0"
|
||||
|
||||
:: Parse command line arguments
|
||||
set "DO_CLEAN=0"
|
||||
set "DO_CLEAN_ALL=0"
|
||||
|
||||
if "%1"=="clean" set "DO_CLEAN=1"
|
||||
if "%1"=="--clean" set "DO_CLEAN=1"
|
||||
if "%1"=="-c" set "DO_CLEAN=1"
|
||||
if "%1"=="cleanall" set "DO_CLEAN_ALL=1"
|
||||
if "%1"=="--clean-all" set "DO_CLEAN_ALL=1"
|
||||
|
||||
:: Clean all (includes node_modules)
|
||||
if "%DO_CLEAN_ALL%"=="1" (
|
||||
echo [CLEAN] Performing full clean...
|
||||
if exist "dist" rmdir /s /q "dist"
|
||||
if exist "dist-electron" rmdir /s /q "dist-electron"
|
||||
if exist "release" rmdir /s /q "release"
|
||||
if exist "node_modules" rmdir /s /q "node_modules"
|
||||
if exist "package-lock.json" del /q "package-lock.json"
|
||||
if exist "node_modules\.cache" rmdir /s /q "node_modules\.cache"
|
||||
echo [OK] Full clean completed
|
||||
echo.
|
||||
)
|
||||
|
||||
:: Clean build artifacts only
|
||||
if "%DO_CLEAN%"=="1" (
|
||||
echo [CLEAN] Cleaning build artifacts...
|
||||
if exist "dist" rmdir /s /q "dist"
|
||||
if exist "dist-electron" rmdir /s /q "dist-electron"
|
||||
if exist "release" rmdir /s /q "release"
|
||||
if exist "node_modules\.cache" rmdir /s /q "node_modules\.cache"
|
||||
echo [OK] Clean completed
|
||||
echo.
|
||||
)
|
||||
|
||||
:: Check Node.js
|
||||
where node >nul 2>nul
|
||||
if %errorlevel% neq 0 (
|
||||
echo [ERROR] Node.js not found. Please install Node.js 18+
|
||||
echo Download: https://nodejs.org/
|
||||
goto :error
|
||||
)
|
||||
|
||||
:: Check Go
|
||||
where go >nul 2>nul
|
||||
if %errorlevel% neq 0 (
|
||||
echo [ERROR] Go not found. Please install Go 1.21+
|
||||
echo Download: https://go.dev/dl/
|
||||
goto :error
|
||||
)
|
||||
|
||||
:: Show versions
|
||||
echo [INFO] Node.js version:
|
||||
node --version
|
||||
echo [INFO] Go version:
|
||||
go version
|
||||
echo.
|
||||
|
||||
:: Step 1: Build TSS subprocess
|
||||
echo ============================================
|
||||
echo [1/4] Building TSS subprocess (Go)...
|
||||
echo ============================================
|
||||
|
||||
cd tss-party
|
||||
if not exist "..\bin\win32-x64" mkdir "..\bin\win32-x64"
|
||||
|
||||
echo Building tss-party.exe...
|
||||
go build -ldflags="-s -w" -o ..\bin\win32-x64\tss-party.exe .
|
||||
if %errorlevel% neq 0 (
|
||||
echo [ERROR] TSS subprocess build failed
|
||||
goto :error
|
||||
)
|
||||
echo [OK] tss-party.exe created
|
||||
cd ..
|
||||
echo.
|
||||
|
||||
:: Step 2: Install dependencies
|
||||
echo ============================================
|
||||
echo [2/4] Installing npm dependencies...
|
||||
echo ============================================
|
||||
|
||||
if exist "node_modules" (
|
||||
if "%DO_CLEAN_ALL%"=="0" (
|
||||
echo Dependencies exist, skipping install
|
||||
) else (
|
||||
call npm install
|
||||
if %errorlevel% neq 0 (
|
||||
echo [ERROR] npm install failed
|
||||
goto :error
|
||||
)
|
||||
)
|
||||
) else (
|
||||
call npm install
|
||||
if %errorlevel% neq 0 (
|
||||
echo [ERROR] npm install failed
|
||||
goto :error
|
||||
)
|
||||
)
|
||||
echo [OK] Dependencies installed
|
||||
echo.
|
||||
|
||||
:: Step 3: Check resources
|
||||
echo ============================================
|
||||
echo [3/4] Checking resources...
|
||||
echo ============================================
|
||||
|
||||
if not exist "build" mkdir "build"
|
||||
if not exist "build\icon.ico" echo [WARN] build\icon.ico not found, using default icon
|
||||
echo.
|
||||
|
||||
:: Step 4: Build Electron app
|
||||
echo ============================================
|
||||
echo [4/4] Building Windows application...
|
||||
echo ============================================
|
||||
|
||||
echo Building frontend code...
|
||||
call npm run build:win
|
||||
if %errorlevel% neq 0 (
|
||||
echo [ERROR] Application build failed
|
||||
goto :error
|
||||
)
|
||||
|
||||
echo.
|
||||
echo ============================================
|
||||
echo Build Complete!
|
||||
echo ============================================
|
||||
echo.
|
||||
echo Output directory: %cd%\release\
|
||||
echo.
|
||||
|
||||
:: List generated files
|
||||
if exist "release" (
|
||||
echo Generated files:
|
||||
dir /b release\*.exe 2>nul
|
||||
)
|
||||
|
||||
echo.
|
||||
echo ============================================
|
||||
echo Usage Tips
|
||||
echo ============================================
|
||||
echo.
|
||||
echo build-windows.bat Normal build
|
||||
echo build-windows.bat clean Clean and rebuild
|
||||
echo build-windows.bat cleanall Full clean (delete node_modules) and rebuild
|
||||
echo.
|
||||
echo Press any key to exit...
|
||||
pause >nul
|
||||
exit /b 0
|
||||
|
||||
:error
|
||||
echo.
|
||||
echo Build failed. Please check error messages.
|
||||
echo.
|
||||
echo If you encounter module/type errors, try:
|
||||
echo build-windows.bat cleanall
|
||||
echo.
|
||||
pause
|
||||
exit /b 1
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
{
|
||||
"$schema": "https://raw.githubusercontent.com/electron-userland/electron-builder/master/packages/app-builder-lib/scheme.json",
|
||||
"appId": "com.rwadurian.service-party",
|
||||
"productName": "Service Party",
|
||||
"copyright": "Copyright © 2024 RWADurian",
|
||||
"directories": {
|
||||
"output": "release",
|
||||
"buildResources": "build"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*",
|
||||
"dist-electron/**/*"
|
||||
],
|
||||
"afterPack": "./scripts/afterPack.js",
|
||||
"extraMetadata": {
|
||||
"main": "dist-electron/main.js"
|
||||
},
|
||||
"win": {
|
||||
"target": [
|
||||
{
|
||||
"target": "nsis",
|
||||
"arch": ["x64"]
|
||||
},
|
||||
{
|
||||
"target": "portable",
|
||||
"arch": ["x64"]
|
||||
}
|
||||
],
|
||||
"artifactName": "${productName}-${version}-${platform}-${arch}.${ext}"
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"perMachine": true,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"deleteAppDataOnUninstall": false,
|
||||
"createDesktopShortcut": true,
|
||||
"createStartMenuShortcut": true,
|
||||
"shortcutName": "Service Party"
|
||||
},
|
||||
"mac": {
|
||||
"target": [
|
||||
{
|
||||
"target": "dmg",
|
||||
"arch": ["x64", "arm64"]
|
||||
},
|
||||
{
|
||||
"target": "zip",
|
||||
"arch": ["x64", "arm64"]
|
||||
}
|
||||
],
|
||||
"icon": "build/icon.icns",
|
||||
"category": "public.app-category.utilities",
|
||||
"artifactName": "${productName}-${version}-${platform}-${arch}.${ext}"
|
||||
},
|
||||
"dmg": {
|
||||
"contents": [
|
||||
{
|
||||
"x": 130,
|
||||
"y": 220
|
||||
},
|
||||
{
|
||||
"x": 410,
|
||||
"y": 220,
|
||||
"type": "link",
|
||||
"path": "/Applications"
|
||||
}
|
||||
]
|
||||
},
|
||||
"linux": {
|
||||
"target": [
|
||||
{
|
||||
"target": "AppImage",
|
||||
"arch": ["x64"]
|
||||
},
|
||||
{
|
||||
"target": "deb",
|
||||
"arch": ["x64"]
|
||||
}
|
||||
],
|
||||
"icon": "build/icons",
|
||||
"category": "Utility",
|
||||
"artifactName": "${productName}-${version}-${platform}-${arch}.${ext}"
|
||||
},
|
||||
"publish": null
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,379 @@
|
|||
/**
|
||||
* Account Service HTTP Client
|
||||
*
|
||||
* 用于 Service-Party-App 调用 Account 服务的 HTTP API
|
||||
* 主要用于创建/查询 keygen 和 sign 会话
|
||||
*/
|
||||
|
||||
// =============================================================================
|
||||
// 类型定义
|
||||
// =============================================================================
|
||||
|
||||
// Keygen 会话相关
|
||||
export interface CreateKeygenSessionRequest {
|
||||
wallet_name: string;
|
||||
threshold_t: number;
|
||||
threshold_n: number;
|
||||
initiator_party_id: string;
|
||||
initiator_name?: string;
|
||||
persistent_count: number;
|
||||
external_count: number;
|
||||
expires_in_seconds?: number;
|
||||
}
|
||||
|
||||
export interface CreateKeygenSessionResponse {
|
||||
session_id: string;
|
||||
invite_code: string;
|
||||
wallet_name: string;
|
||||
threshold_n: number;
|
||||
threshold_t: number;
|
||||
selected_server_parties: string[];
|
||||
join_tokens: Record<string, string>;
|
||||
join_token?: string; // Wildcard token for backward compatibility
|
||||
expires_at: number;
|
||||
}
|
||||
|
||||
export interface JoinSessionRequest {
|
||||
party_id: string;
|
||||
join_token: string;
|
||||
device_type?: string;
|
||||
device_id?: string;
|
||||
}
|
||||
|
||||
export interface PartyInfo {
|
||||
party_id: string;
|
||||
party_index: number;
|
||||
}
|
||||
|
||||
export interface SessionInfo {
|
||||
session_id: string;
|
||||
session_type: string;
|
||||
threshold_n: number;
|
||||
threshold_t: number;
|
||||
status: string;
|
||||
wallet_name: string;
|
||||
invite_code: string;
|
||||
keygen_session_id?: string;
|
||||
}
|
||||
|
||||
export interface JoinSessionResponse {
|
||||
success: boolean;
|
||||
party_index: number;
|
||||
session_info: SessionInfo;
|
||||
other_parties: PartyInfo[];
|
||||
}
|
||||
|
||||
// Participant status information with party_index
|
||||
export interface ParticipantStatusInfo {
|
||||
party_id: string;
|
||||
party_index: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface GetSessionStatusResponse {
|
||||
session_id: string;
|
||||
status: string;
|
||||
threshold_t: number; // Minimum parties needed to sign (e.g., 2 in 2-of-3)
|
||||
threshold_n: number; // Total number of parties required (e.g., 3 in 2-of-3)
|
||||
completed_parties: number;
|
||||
total_parties: number;
|
||||
session_type: string;
|
||||
public_key?: string;
|
||||
signature?: string;
|
||||
has_delegate: boolean;
|
||||
// participants contains detailed participant information including party_index
|
||||
// Used for co_managed_keygen sessions to build correct participant list
|
||||
participants?: ParticipantStatusInfo[];
|
||||
}
|
||||
|
||||
export interface GetSessionByInviteCodeResponse {
|
||||
session_id: string;
|
||||
wallet_name: string;
|
||||
threshold_n: number;
|
||||
threshold_t: number;
|
||||
status: string;
|
||||
invite_code: string;
|
||||
expires_at: number;
|
||||
joined_parties: number;
|
||||
completed_parties?: number;
|
||||
total_parties?: number;
|
||||
join_token?: string;
|
||||
}
|
||||
|
||||
// Sign 会话相关
|
||||
export interface SignPartyInfo {
|
||||
party_id: string;
|
||||
party_index: number;
|
||||
}
|
||||
|
||||
export interface CreateSignSessionRequest {
|
||||
keygen_session_id: string;
|
||||
wallet_name: string;
|
||||
message_hash: string;
|
||||
parties: SignPartyInfo[];
|
||||
threshold_t: number;
|
||||
initiator_name?: string;
|
||||
}
|
||||
|
||||
export interface CreateSignSessionResponse {
|
||||
session_id: string;
|
||||
invite_code: string;
|
||||
keygen_session_id: string;
|
||||
wallet_name: string;
|
||||
threshold_t: number;
|
||||
selected_parties: string[];
|
||||
expires_at: number;
|
||||
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 {
|
||||
session_id: string;
|
||||
keygen_session_id: string;
|
||||
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;
|
||||
}
|
||||
|
||||
// 错误响应
|
||||
export interface ErrorResponse {
|
||||
error: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// HTTP 客户端类
|
||||
// =============================================================================
|
||||
|
||||
export class AccountClient {
|
||||
private baseUrl: string;
|
||||
private timeout: number;
|
||||
|
||||
/**
|
||||
* 构造函数
|
||||
* @param baseUrl Account 服务的基础 URL (例如: https://api.szaiai.com 或 http://localhost:8080)
|
||||
* @param timeout 请求超时时间 (毫秒)
|
||||
*/
|
||||
constructor(baseUrl: string, timeout: number = 30000) {
|
||||
// 移除末尾的斜杠
|
||||
this.baseUrl = baseUrl.replace(/\/$/, '');
|
||||
this.timeout = timeout;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新基础 URL
|
||||
*/
|
||||
setBaseUrl(baseUrl: string): void {
|
||||
this.baseUrl = baseUrl.replace(/\/$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前基础 URL
|
||||
*/
|
||||
getBaseUrl(): string {
|
||||
return this.baseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送 HTTP 请求
|
||||
*/
|
||||
private async request<T>(
|
||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
|
||||
path: string,
|
||||
body?: unknown
|
||||
): Promise<T> {
|
||||
const url = `${this.baseUrl}${path}`;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
||||
|
||||
try {
|
||||
const options: RequestInit = {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
signal: controller.signal,
|
||||
};
|
||||
|
||||
if (body) {
|
||||
options.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
console.log(`[AccountClient] ${method} ${url}`, body ? JSON.stringify(body) : '');
|
||||
|
||||
const response = await fetch(url, options);
|
||||
|
||||
const text = await response.text();
|
||||
let data: T | ErrorResponse;
|
||||
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch {
|
||||
throw new Error(`Invalid JSON response: ${text}`);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = data as ErrorResponse;
|
||||
throw new Error(errorData.message || errorData.error || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
console.log(`[AccountClient] Response:`, data);
|
||||
return data as T;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
throw new Error(`Request timeout after ${this.timeout}ms`);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Keygen 会话 API
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* 创建 Keygen 会话
|
||||
*/
|
||||
async createKeygenSession(
|
||||
params: CreateKeygenSessionRequest
|
||||
): Promise<CreateKeygenSessionResponse> {
|
||||
return this.request<CreateKeygenSessionResponse>(
|
||||
'POST',
|
||||
'/api/v1/co-managed/sessions',
|
||||
params
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 加入会话
|
||||
*/
|
||||
async joinSession(
|
||||
sessionId: string,
|
||||
params: JoinSessionRequest
|
||||
): Promise<JoinSessionResponse> {
|
||||
return this.request<JoinSessionResponse>(
|
||||
'POST',
|
||||
`/api/v1/co-managed/sessions/${sessionId}/join`,
|
||||
params
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话状态
|
||||
*/
|
||||
async getSessionStatus(sessionId: string): Promise<GetSessionStatusResponse> {
|
||||
return this.request<GetSessionStatusResponse>(
|
||||
'GET',
|
||||
`/api/v1/co-managed/sessions/${sessionId}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过邀请码查询 Keygen 会话
|
||||
*/
|
||||
async getSessionByInviteCode(inviteCode: string): Promise<GetSessionByInviteCodeResponse> {
|
||||
return this.request<GetSessionByInviteCodeResponse>(
|
||||
'GET',
|
||||
`/api/v1/co-managed/sessions/by-invite-code/${inviteCode}`
|
||||
);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Sign 会话 API
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* 创建 Sign 会话
|
||||
*/
|
||||
async createSignSession(
|
||||
params: CreateSignSessionRequest
|
||||
): Promise<CreateSignSessionResponse> {
|
||||
return this.request<CreateSignSessionResponse>(
|
||||
'POST',
|
||||
'/api/v1/co-managed/sign',
|
||||
params
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过邀请码查询 Sign 会话
|
||||
*/
|
||||
async getSignSessionByInviteCode(inviteCode: string): Promise<GetSignSessionByInviteCodeResponse> {
|
||||
return this.request<GetSignSessionByInviteCodeResponse>(
|
||||
'GET',
|
||||
`/api/v1/co-managed/sign/by-invite-code/${inviteCode}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Sign 会话状态
|
||||
*/
|
||||
async getSignSessionStatus(sessionId: string): Promise<GetSignSessionStatusResponse> {
|
||||
return this.request<GetSignSessionStatusResponse>(
|
||||
'GET',
|
||||
`/api/v1/co-managed/sign/${sessionId}`
|
||||
);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 健康检查
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* 健康检查
|
||||
*/
|
||||
async healthCheck(): Promise<{ status: string; service: string }> {
|
||||
return this.request<{ status: string; service: string }>(
|
||||
'GET',
|
||||
'/health'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试连接
|
||||
*/
|
||||
async testConnection(): Promise<boolean> {
|
||||
try {
|
||||
const result = await this.healthCheck();
|
||||
return result.status === 'healthy';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 默认实例
|
||||
// =============================================================================
|
||||
|
||||
// 默认使用生产环境地址
|
||||
const DEFAULT_ACCOUNT_SERVICE_URL = 'https://rwaapi.szaiai.com';
|
||||
|
||||
// 创建默认客户端实例
|
||||
export const accountClient = new AccountClient(DEFAULT_ACCOUNT_SERVICE_URL);
|
||||
|
|
@ -0,0 +1,333 @@
|
|||
import * as crypto from 'crypto';
|
||||
import { bech32 } from 'bech32';
|
||||
|
||||
// =============================================================================
|
||||
// 链配置
|
||||
// =============================================================================
|
||||
|
||||
export interface ChainConfig {
|
||||
name: string;
|
||||
prefix: string;
|
||||
coinType: number;
|
||||
curve: 'secp256k1' | 'ed25519';
|
||||
derivationPath: string;
|
||||
}
|
||||
|
||||
export const CHAIN_CONFIGS: Record<string, ChainConfig> = {
|
||||
kava: {
|
||||
name: 'Kava',
|
||||
prefix: 'kava',
|
||||
coinType: 459,
|
||||
curve: 'secp256k1',
|
||||
derivationPath: "m/44'/459'/0'/0/0",
|
||||
},
|
||||
cosmos: {
|
||||
name: 'Cosmos Hub',
|
||||
prefix: 'cosmos',
|
||||
coinType: 118,
|
||||
curve: 'secp256k1',
|
||||
derivationPath: "m/44'/118'/0'/0/0",
|
||||
},
|
||||
osmosis: {
|
||||
name: 'Osmosis',
|
||||
prefix: 'osmo',
|
||||
coinType: 118,
|
||||
curve: 'secp256k1',
|
||||
derivationPath: "m/44'/118'/0'/0/0",
|
||||
},
|
||||
ethereum: {
|
||||
name: 'Ethereum',
|
||||
prefix: '0x',
|
||||
coinType: 60,
|
||||
curve: 'secp256k1',
|
||||
derivationPath: "m/44'/60'/0'/0/0",
|
||||
},
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// 地址派生工具
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* 从公钥派生 Bech32 地址 (Cosmos 系列)
|
||||
*
|
||||
* 流程:
|
||||
* 1. 公钥 → SHA256 → RIPEMD160 → 20字节地址
|
||||
* 2. 20字节地址 → Bech32 编码
|
||||
*/
|
||||
export function deriveCosmosAddress(publicKeyHex: string, prefix: string): string {
|
||||
// 移除可能的 0x 前缀
|
||||
const cleanHex = publicKeyHex.startsWith('0x') ? publicKeyHex.slice(2) : publicKeyHex;
|
||||
const publicKeyBytes = Buffer.from(cleanHex, 'hex');
|
||||
|
||||
// 对于 secp256k1,需要压缩公钥 (33 bytes)
|
||||
// 如果是未压缩公钥 (65 bytes),需要先压缩
|
||||
let compressedKey: Buffer = publicKeyBytes;
|
||||
if (publicKeyBytes.length === 65) {
|
||||
compressedKey = compressSecp256k1PublicKey(publicKeyBytes);
|
||||
} else if (publicKeyBytes.length === 64) {
|
||||
// 没有前缀的未压缩公钥
|
||||
const uncompressed = Buffer.concat([Buffer.from([0x04]), publicKeyBytes]);
|
||||
compressedKey = compressSecp256k1PublicKey(uncompressed);
|
||||
}
|
||||
|
||||
// SHA256 → RIPEMD160
|
||||
const sha256Hash = crypto.createHash('sha256').update(compressedKey).digest();
|
||||
const ripemd160Hash = crypto.createHash('ripemd160').update(sha256Hash).digest();
|
||||
|
||||
// Bech32 编码
|
||||
const words = bech32.toWords(ripemd160Hash);
|
||||
const address = bech32.encode(prefix, words);
|
||||
|
||||
return address;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从公钥派生以太坊地址
|
||||
*
|
||||
* 流程:
|
||||
* 1. 未压缩公钥 (去掉 04 前缀) → Keccak256 → 取后 20 字节
|
||||
*/
|
||||
export function deriveEthereumAddress(publicKeyHex: string): string {
|
||||
const cleanHex = publicKeyHex.startsWith('0x') ? publicKeyHex.slice(2) : publicKeyHex;
|
||||
const publicKeyBytes = Buffer.from(cleanHex, 'hex');
|
||||
|
||||
// 需要未压缩公钥的 x, y 坐标 (64 bytes)
|
||||
let uncompressedKey: Buffer;
|
||||
if (publicKeyBytes.length === 33) {
|
||||
// 压缩公钥,需要解压
|
||||
uncompressedKey = decompressSecp256k1PublicKey(publicKeyBytes);
|
||||
} else if (publicKeyBytes.length === 65) {
|
||||
// 未压缩公钥,去掉 04 前缀
|
||||
uncompressedKey = publicKeyBytes.slice(1) as Buffer;
|
||||
} else if (publicKeyBytes.length === 64) {
|
||||
uncompressedKey = publicKeyBytes;
|
||||
} else {
|
||||
throw new Error(`Invalid public key length: ${publicKeyBytes.length}`);
|
||||
}
|
||||
|
||||
// Keccak256 (使用 keccak256 而不是 sha3-256)
|
||||
const { keccak_256 } = require('@noble/hashes/sha3');
|
||||
const hash = keccak_256(uncompressedKey);
|
||||
|
||||
// 取后 20 字节
|
||||
const addressBytes = hash.slice(-20);
|
||||
const address = '0x' + Buffer.from(addressBytes).toString('hex');
|
||||
|
||||
return checksumAddress(address);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Ed25519 公钥派生地址 (用于某些链)
|
||||
*/
|
||||
export function deriveEd25519Address(publicKeyHex: string, prefix: string): string {
|
||||
const cleanHex = publicKeyHex.startsWith('0x') ? publicKeyHex.slice(2) : publicKeyHex;
|
||||
const publicKeyBytes = Buffer.from(cleanHex, 'hex');
|
||||
|
||||
// SHA256 → RIPEMD160
|
||||
const sha256Hash = crypto.createHash('sha256').update(publicKeyBytes).digest();
|
||||
const ripemd160Hash = crypto.createHash('ripemd160').update(sha256Hash).digest();
|
||||
|
||||
// Bech32 编码
|
||||
const words = bech32.toWords(ripemd160Hash);
|
||||
const address = bech32.encode(prefix, words);
|
||||
|
||||
return address;
|
||||
}
|
||||
|
||||
/**
|
||||
* 压缩 secp256k1 公钥
|
||||
*/
|
||||
function compressSecp256k1PublicKey(uncompressed: Buffer): Buffer {
|
||||
if (uncompressed.length !== 65 || uncompressed[0] !== 0x04) {
|
||||
throw new Error('Invalid uncompressed public key');
|
||||
}
|
||||
|
||||
const x = uncompressed.slice(1, 33);
|
||||
const y = uncompressed.slice(33, 65);
|
||||
|
||||
// 判断 y 是奇数还是偶数
|
||||
const prefix = y[31] % 2 === 0 ? 0x02 : 0x03;
|
||||
|
||||
return Buffer.concat([Buffer.from([prefix]), x]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解压缩 secp256k1 公钥
|
||||
* 使用椭圆曲线数学: y² = x³ + 7 (mod p)
|
||||
*/
|
||||
function decompressSecp256k1PublicKey(compressed: Buffer): Buffer {
|
||||
if (compressed.length !== 33) {
|
||||
throw new Error('Invalid compressed public key');
|
||||
}
|
||||
|
||||
// secp256k1 曲线参数
|
||||
const p = BigInt('0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F');
|
||||
|
||||
const prefix = compressed[0];
|
||||
const x = BigInt('0x' + compressed.slice(1).toString('hex'));
|
||||
|
||||
// 计算 y² = x³ + 7 (mod p)
|
||||
const xCubed = modPow(x, 3n, p);
|
||||
const ySquared = (xCubed + 7n) % p;
|
||||
|
||||
// 计算平方根 (p ≡ 3 mod 4, 所以 y = ySquared^((p+1)/4) mod p)
|
||||
let y = modPow(ySquared, (p + 1n) / 4n, p);
|
||||
|
||||
// 根据前缀选择正确的 y 值
|
||||
const isYOdd = y % 2n === 1n;
|
||||
const shouldBeOdd = prefix === 0x03;
|
||||
|
||||
if (isYOdd !== shouldBeOdd) {
|
||||
y = p - y;
|
||||
}
|
||||
|
||||
// 转换为 Buffer (64 bytes: x || y)
|
||||
const xBuffer = Buffer.from(x.toString(16).padStart(64, '0'), 'hex');
|
||||
const yBuffer = Buffer.from(y.toString(16).padStart(64, '0'), 'hex');
|
||||
|
||||
return Buffer.concat([xBuffer, yBuffer]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 模幂运算
|
||||
*/
|
||||
function modPow(base: bigint, exponent: bigint, modulus: bigint): bigint {
|
||||
let result = 1n;
|
||||
base = base % modulus;
|
||||
|
||||
while (exponent > 0n) {
|
||||
if (exponent % 2n === 1n) {
|
||||
result = (result * base) % modulus;
|
||||
}
|
||||
exponent = exponent / 2n;
|
||||
base = (base * base) % modulus;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* EIP-55 校验和地址
|
||||
*/
|
||||
function checksumAddress(address: string): string {
|
||||
const { keccak_256 } = require('@noble/hashes/sha3');
|
||||
const addr = address.toLowerCase().replace('0x', '');
|
||||
const hash = Buffer.from(keccak_256(Buffer.from(addr, 'utf8'))).toString('hex');
|
||||
|
||||
let result = '0x';
|
||||
for (let i = 0; i < addr.length; i++) {
|
||||
if (parseInt(hash[i], 16) >= 8) {
|
||||
result += addr[i].toUpperCase();
|
||||
} else {
|
||||
result += addr[i];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 地址派生服务
|
||||
// =============================================================================
|
||||
|
||||
export interface DerivedAddress {
|
||||
chain: string;
|
||||
chainName: string;
|
||||
prefix: string;
|
||||
address: string;
|
||||
derivationPath: string;
|
||||
publicKeyHex: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 地址派生服务
|
||||
*
|
||||
* 注意:TSS keygen 生成的是聚合公钥,不是派生公钥。
|
||||
* 对于需要不同链的地址,我们直接使用聚合公钥派生地址,
|
||||
* 而不是像 HD 钱包那样从种子派生。
|
||||
*
|
||||
* 这意味着:
|
||||
* - 所有链使用相同的公钥
|
||||
* - 不同链的地址只是编码方式不同
|
||||
* - 这是 TSS 钱包的标准做法
|
||||
*/
|
||||
export class AddressDerivationService {
|
||||
/**
|
||||
* 从 TSS 聚合公钥派生指定链的地址
|
||||
*/
|
||||
deriveAddress(publicKeyHex: string, chain: string): DerivedAddress {
|
||||
const config = CHAIN_CONFIGS[chain];
|
||||
if (!config) {
|
||||
throw new Error(`Unsupported chain: ${chain}`);
|
||||
}
|
||||
|
||||
let address: string;
|
||||
|
||||
if (chain === 'ethereum') {
|
||||
address = deriveEthereumAddress(publicKeyHex);
|
||||
} else if (config.curve === 'ed25519') {
|
||||
address = deriveEd25519Address(publicKeyHex, config.prefix);
|
||||
} else {
|
||||
// Cosmos 系列 (kava, cosmos, osmosis 等)
|
||||
address = deriveCosmosAddress(publicKeyHex, config.prefix);
|
||||
}
|
||||
|
||||
return {
|
||||
chain,
|
||||
chainName: config.name,
|
||||
prefix: config.prefix,
|
||||
address,
|
||||
derivationPath: config.derivationPath,
|
||||
publicKeyHex,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 派生所有支持的链地址
|
||||
*/
|
||||
deriveAllAddresses(publicKeyHex: string): DerivedAddress[] {
|
||||
const addresses: DerivedAddress[] = [];
|
||||
|
||||
for (const chain of Object.keys(CHAIN_CONFIGS)) {
|
||||
try {
|
||||
const derived = this.deriveAddress(publicKeyHex, chain);
|
||||
addresses.push(derived);
|
||||
} catch (err) {
|
||||
console.error(`Failed to derive ${chain} address:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
return addresses;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证地址格式
|
||||
*/
|
||||
validateAddress(address: string, chain: string): boolean {
|
||||
const config = CHAIN_CONFIGS[chain];
|
||||
if (!config) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (chain === 'ethereum') {
|
||||
return /^0x[a-fA-F0-9]{40}$/.test(address);
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = bech32.decode(address);
|
||||
return decoded.prefix === config.prefix;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取支持的链列表
|
||||
*/
|
||||
getSupportedChains(): ChainConfig[] {
|
||||
return Object.values(CHAIN_CONFIGS);
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
export const addressDerivationService = new AddressDerivationService();
|
||||
|
|
@ -0,0 +1,778 @@
|
|||
import * as crypto from 'crypto';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import { app } from 'electron';
|
||||
import initSqlJs from 'sql.js';
|
||||
import type { Database as SqlJsDatabase, SqlJsStatic } from 'sql.js';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
// =============================================================================
|
||||
// sql.js WASM 文件路径
|
||||
// =============================================================================
|
||||
function getSqlJsWasmPath(): string {
|
||||
// 在开发环境中,WASM 文件在 node_modules 中
|
||||
// 在生产环境中,WASM 文件被复制到 extraResources 目录
|
||||
const isDev = !app.isPackaged;
|
||||
|
||||
if (isDev) {
|
||||
// 开发环境: 使用 node_modules 中的文件
|
||||
return path.join(__dirname, '../../node_modules/sql.js/dist/sql-wasm.wasm');
|
||||
} else {
|
||||
// 生产环境: 使用 extraResources 中的文件
|
||||
return path.join(process.resourcesPath, 'sql-wasm.wasm');
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 数据库路径
|
||||
// =============================================================================
|
||||
function getDatabasePath(): string {
|
||||
const userDataPath = app.getPath('userData');
|
||||
return path.join(userDataPath, 'service-party.db');
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 加密配置
|
||||
// =============================================================================
|
||||
const ALGORITHM = 'aes-256-gcm';
|
||||
const KEY_LENGTH = 32;
|
||||
const IV_LENGTH = 16;
|
||||
const SALT_LENGTH = 32;
|
||||
const TAG_LENGTH = 16;
|
||||
const ITERATIONS = 100000;
|
||||
|
||||
// =============================================================================
|
||||
// 数据类型定义
|
||||
// =============================================================================
|
||||
|
||||
export interface ShareRecord {
|
||||
id: string;
|
||||
session_id: string;
|
||||
wallet_name: string;
|
||||
party_id: string;
|
||||
party_index: number;
|
||||
threshold_t: number;
|
||||
threshold_n: number;
|
||||
public_key_hex: string;
|
||||
encrypted_share: string;
|
||||
created_at: string;
|
||||
last_used_at: string | null;
|
||||
participants_json: string; // JSON 存储参与者列表
|
||||
}
|
||||
|
||||
export interface DerivedAddressRecord {
|
||||
id: string;
|
||||
share_id: string;
|
||||
chain: string;
|
||||
derivation_path: string;
|
||||
address: string;
|
||||
public_key_hex: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface SigningHistoryRecord {
|
||||
id: string;
|
||||
share_id: string;
|
||||
session_id: string;
|
||||
message_hash: string;
|
||||
signature: string | null;
|
||||
status: 'pending' | 'in_progress' | 'completed' | 'failed';
|
||||
error_message: string | null;
|
||||
created_at: string;
|
||||
completed_at: string | null;
|
||||
}
|
||||
|
||||
export interface SettingsRecord {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 数据库管理类 (使用 sql.js - 纯 JavaScript SQLite)
|
||||
// =============================================================================
|
||||
|
||||
export class DatabaseManager {
|
||||
private db: SqlJsDatabase | null = null;
|
||||
private SQL: SqlJsStatic | null = null;
|
||||
private dbPath: string;
|
||||
private initPromise: Promise<void>;
|
||||
|
||||
constructor() {
|
||||
this.dbPath = getDatabasePath();
|
||||
this.initPromise = this.initialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化数据库
|
||||
*/
|
||||
private async initialize(): Promise<void> {
|
||||
// 获取 WASM 文件路径
|
||||
const wasmPath = getSqlJsWasmPath();
|
||||
console.log('[Database] App packaged:', app.isPackaged);
|
||||
console.log('[Database] Resources path:', process.resourcesPath);
|
||||
console.log('[Database] WASM path:', wasmPath);
|
||||
console.log('[Database] WASM exists:', fs.existsSync(wasmPath));
|
||||
|
||||
// 初始化 sql.js (加载 WASM)
|
||||
// 使用 wasmBinary 直接加载 WASM 文件,这在打包环境中更可靠
|
||||
let config: { wasmBinary?: ArrayBuffer; locateFile?: (file: string) => string } = {};
|
||||
|
||||
if (fs.existsSync(wasmPath)) {
|
||||
// 直接读取 WASM 文件作为 ArrayBuffer - 这种方式更可靠
|
||||
const wasmBuffer = fs.readFileSync(wasmPath);
|
||||
config.wasmBinary = wasmBuffer.buffer.slice(
|
||||
wasmBuffer.byteOffset,
|
||||
wasmBuffer.byteOffset + wasmBuffer.byteLength
|
||||
);
|
||||
console.log('[Database] WASM loaded as binary, size:', wasmBuffer.length);
|
||||
} else {
|
||||
console.warn('[Database] WASM file not found, sql.js will try to load from default location');
|
||||
// 作为备用方案,使用 locateFile
|
||||
config.locateFile = (file: string) => {
|
||||
console.log('[Database] locateFile called for:', file);
|
||||
return file;
|
||||
};
|
||||
}
|
||||
|
||||
this.SQL = await initSqlJs(config);
|
||||
|
||||
// 如果数据库文件存在,加载它
|
||||
if (fs.existsSync(this.dbPath)) {
|
||||
const buffer = fs.readFileSync(this.dbPath);
|
||||
this.db = new this.SQL.Database(buffer);
|
||||
} else {
|
||||
this.db = new this.SQL.Database();
|
||||
}
|
||||
|
||||
// 创建表结构
|
||||
this.createTables();
|
||||
this.saveToFile();
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保数据库已初始化
|
||||
*/
|
||||
private async ensureReady(): Promise<void> {
|
||||
await this.initPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待数据库初始化完成(公开方法)
|
||||
*/
|
||||
async waitForReady(): Promise<void> {
|
||||
await this.initPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建表结构
|
||||
*/
|
||||
private createTables(): void {
|
||||
if (!this.db) return;
|
||||
|
||||
this.db.run(`
|
||||
CREATE TABLE IF NOT EXISTS shares (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL,
|
||||
wallet_name TEXT NOT NULL,
|
||||
party_id TEXT NOT NULL,
|
||||
party_index INTEGER NOT NULL,
|
||||
threshold_t INTEGER NOT NULL,
|
||||
threshold_n INTEGER NOT NULL,
|
||||
public_key_hex TEXT NOT NULL,
|
||||
encrypted_share TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
last_used_at TEXT,
|
||||
participants_json TEXT NOT NULL DEFAULT '[]'
|
||||
)
|
||||
`);
|
||||
|
||||
this.db.run(`
|
||||
CREATE TABLE IF NOT EXISTS derived_addresses (
|
||||
id TEXT PRIMARY KEY,
|
||||
share_id TEXT NOT NULL,
|
||||
chain TEXT NOT NULL,
|
||||
derivation_path TEXT NOT NULL,
|
||||
address TEXT NOT NULL,
|
||||
public_key_hex TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE(share_id, chain, derivation_path)
|
||||
)
|
||||
`);
|
||||
|
||||
this.db.run(`
|
||||
CREATE TABLE IF NOT EXISTS signing_history (
|
||||
id TEXT PRIMARY KEY,
|
||||
share_id TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
message_hash TEXT NOT NULL,
|
||||
signature TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
error_message TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
completed_at TEXT
|
||||
)
|
||||
`);
|
||||
|
||||
this.db.run(`
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
// 已处理消息表 - 用于消息去重,防止重连后重复处理消息
|
||||
this.db.run(`
|
||||
CREATE TABLE IF NOT EXISTS processed_messages (
|
||||
message_id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL,
|
||||
processed_at TEXT NOT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
// 创建索引
|
||||
this.db.run(`CREATE INDEX IF NOT EXISTS idx_shares_session ON shares(session_id)`);
|
||||
this.db.run(`CREATE INDEX IF NOT EXISTS idx_addresses_share ON derived_addresses(share_id)`);
|
||||
this.db.run(`CREATE INDEX IF NOT EXISTS idx_addresses_chain ON derived_addresses(chain)`);
|
||||
this.db.run(`CREATE INDEX IF NOT EXISTS idx_history_share ON signing_history(share_id)`);
|
||||
this.db.run(`CREATE INDEX IF NOT EXISTS idx_history_status ON signing_history(status)`);
|
||||
this.db.run(`CREATE INDEX IF NOT EXISTS idx_processed_messages_session ON processed_messages(session_id)`);
|
||||
|
||||
// 插入默认设置
|
||||
this.db.run(`INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)`, ['message_router_url', 'mpc-grpc.szaiai.com:443']);
|
||||
this.db.run(`INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)`, ['auto_backup', 'false']);
|
||||
this.db.run(`INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)`, ['backup_path', '']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存数据库到文件
|
||||
*/
|
||||
private saveToFile(): void {
|
||||
if (!this.db) return;
|
||||
const data = this.db.export();
|
||||
const buffer = Buffer.from(data);
|
||||
fs.writeFileSync(this.dbPath, buffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从密码派生密钥
|
||||
*/
|
||||
private deriveKey(password: string, salt: Buffer): Buffer {
|
||||
return crypto.pbkdf2Sync(password, salt, ITERATIONS, KEY_LENGTH, 'sha256');
|
||||
}
|
||||
|
||||
/**
|
||||
* 加密数据
|
||||
*/
|
||||
private encrypt(data: string, password: string): string {
|
||||
const salt = crypto.randomBytes(SALT_LENGTH);
|
||||
const key = this.deriveKey(password, salt);
|
||||
const iv = crypto.randomBytes(IV_LENGTH);
|
||||
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
||||
let encrypted = cipher.update(data, 'utf8', 'hex');
|
||||
encrypted += cipher.final('hex');
|
||||
const tag = cipher.getAuthTag();
|
||||
|
||||
// 格式: salt(hex) + iv(hex) + tag(hex) + encrypted(hex)
|
||||
return salt.toString('hex') + iv.toString('hex') + tag.toString('hex') + encrypted;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解密数据
|
||||
*/
|
||||
private decrypt(encryptedData: string, password: string): string {
|
||||
const salt = Buffer.from(encryptedData.slice(0, SALT_LENGTH * 2), 'hex');
|
||||
const iv = Buffer.from(encryptedData.slice(SALT_LENGTH * 2, SALT_LENGTH * 2 + IV_LENGTH * 2), 'hex');
|
||||
const tag = Buffer.from(encryptedData.slice(SALT_LENGTH * 2 + IV_LENGTH * 2, SALT_LENGTH * 2 + IV_LENGTH * 2 + TAG_LENGTH * 2), 'hex');
|
||||
const encrypted = encryptedData.slice(SALT_LENGTH * 2 + IV_LENGTH * 2 + TAG_LENGTH * 2);
|
||||
|
||||
const key = this.deriveKey(password, salt);
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
|
||||
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
|
||||
decrypted += decipher.final('utf8');
|
||||
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将查询结果转换为对象数组
|
||||
*/
|
||||
private queryToObjects<T>(sql: string, params: unknown[] = []): T[] {
|
||||
if (!this.db) return [];
|
||||
|
||||
const results = this.db.exec(sql, params);
|
||||
if (results.length === 0) return [];
|
||||
|
||||
const columns = results[0].columns;
|
||||
return results[0].values.map((row: (number | string | Uint8Array | null)[]) => {
|
||||
const obj: Record<string, unknown> = {};
|
||||
columns.forEach((col: string, i: number) => {
|
||||
obj[col] = row[i];
|
||||
});
|
||||
return obj as T;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询单个对象
|
||||
*/
|
||||
private queryOne<T>(sql: string, params: unknown[] = []): T | undefined {
|
||||
const results = this.queryToObjects<T>(sql, params);
|
||||
return results[0];
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Share 操作
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* 保存 share
|
||||
*/
|
||||
saveShare(params: {
|
||||
sessionId: string;
|
||||
walletName: string;
|
||||
partyId: string;
|
||||
partyIndex: number;
|
||||
thresholdT: number;
|
||||
thresholdN: number;
|
||||
publicKeyHex: string;
|
||||
rawShare: string;
|
||||
participants: Array<{ partyId: string; name: string }>;
|
||||
}, password: string): ShareRecord {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
const id = uuidv4();
|
||||
const encryptedShare = this.encrypt(params.rawShare, password);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
this.db.run(`
|
||||
INSERT INTO shares (
|
||||
id, session_id, wallet_name, party_id, party_index,
|
||||
threshold_t, threshold_n, public_key_hex, encrypted_share,
|
||||
created_at, participants_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, [
|
||||
id,
|
||||
params.sessionId,
|
||||
params.walletName,
|
||||
params.partyId,
|
||||
params.partyIndex,
|
||||
params.thresholdT,
|
||||
params.thresholdN,
|
||||
params.publicKeyHex,
|
||||
encryptedShare,
|
||||
now,
|
||||
JSON.stringify(params.participants)
|
||||
]);
|
||||
|
||||
this.saveToFile();
|
||||
|
||||
return {
|
||||
id,
|
||||
session_id: params.sessionId,
|
||||
wallet_name: params.walletName,
|
||||
party_id: params.partyId,
|
||||
party_index: params.partyIndex,
|
||||
threshold_t: params.thresholdT,
|
||||
threshold_n: params.thresholdN,
|
||||
public_key_hex: params.publicKeyHex,
|
||||
encrypted_share: encryptedShare,
|
||||
created_at: now,
|
||||
last_used_at: null,
|
||||
participants_json: JSON.stringify(params.participants),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有 share (不含加密数据)
|
||||
*/
|
||||
listShares(): Omit<ShareRecord, 'encrypted_share'>[] {
|
||||
return this.queryToObjects<Omit<ShareRecord, 'encrypted_share'>>(`
|
||||
SELECT id, session_id, wallet_name, party_id, party_index,
|
||||
threshold_t, threshold_n, public_key_hex, created_at,
|
||||
last_used_at, participants_json
|
||||
FROM shares
|
||||
ORDER BY created_at DESC
|
||||
`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个 share (解密)
|
||||
*/
|
||||
getShare(id: string, password: string): ShareRecord & { raw_share: string } {
|
||||
const share = this.queryOne<ShareRecord>(`SELECT * FROM shares WHERE id = ?`, [id]);
|
||||
|
||||
if (!share) {
|
||||
throw new Error('Share not found');
|
||||
}
|
||||
|
||||
const rawShare = this.decrypt(share.encrypted_share, password);
|
||||
|
||||
return {
|
||||
...share,
|
||||
raw_share: rawShare,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 share 最后使用时间
|
||||
*/
|
||||
updateShareLastUsed(id: string): void {
|
||||
if (!this.db) return;
|
||||
this.db.run(`UPDATE shares SET last_used_at = ? WHERE id = ?`, [new Date().toISOString(), id]);
|
||||
this.saveToFile();
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除 share (级联删除派生地址和签名历史)
|
||||
*/
|
||||
deleteShare(id: string): void {
|
||||
if (!this.db) return;
|
||||
// 手动级联删除
|
||||
this.db.run(`DELETE FROM derived_addresses WHERE share_id = ?`, [id]);
|
||||
this.db.run(`DELETE FROM signing_history WHERE share_id = ?`, [id]);
|
||||
this.db.run(`DELETE FROM shares WHERE id = ?`, [id]);
|
||||
this.saveToFile();
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 派生地址操作
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* 保存派生地址
|
||||
*/
|
||||
saveDerivedAddress(params: {
|
||||
shareId: string;
|
||||
chain: string;
|
||||
derivationPath: string;
|
||||
address: string;
|
||||
publicKeyHex: string;
|
||||
}): DerivedAddressRecord {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
const id = uuidv4();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
this.db.run(`
|
||||
INSERT OR REPLACE INTO derived_addresses (
|
||||
id, share_id, chain, derivation_path, address, public_key_hex, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`, [
|
||||
id,
|
||||
params.shareId,
|
||||
params.chain,
|
||||
params.derivationPath,
|
||||
params.address,
|
||||
params.publicKeyHex,
|
||||
now
|
||||
]);
|
||||
|
||||
this.saveToFile();
|
||||
|
||||
return {
|
||||
id,
|
||||
share_id: params.shareId,
|
||||
chain: params.chain,
|
||||
derivation_path: params.derivationPath,
|
||||
address: params.address,
|
||||
public_key_hex: params.publicKeyHex,
|
||||
created_at: now,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 share 的所有派生地址
|
||||
*/
|
||||
getAddressesByShare(shareId: string): DerivedAddressRecord[] {
|
||||
return this.queryToObjects<DerivedAddressRecord>(`
|
||||
SELECT * FROM derived_addresses
|
||||
WHERE share_id = ?
|
||||
ORDER BY chain, derivation_path
|
||||
`, [shareId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据链获取地址
|
||||
*/
|
||||
getAddressByChain(shareId: string, chain: string): DerivedAddressRecord | undefined {
|
||||
return this.queryOne<DerivedAddressRecord>(`
|
||||
SELECT * FROM derived_addresses
|
||||
WHERE share_id = ? AND chain = ?
|
||||
LIMIT 1
|
||||
`, [shareId, chain]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有指定链的地址
|
||||
*/
|
||||
getAllAddressesByChain(chain: string): DerivedAddressRecord[] {
|
||||
return this.queryToObjects<DerivedAddressRecord>(`
|
||||
SELECT * FROM derived_addresses
|
||||
WHERE chain = ?
|
||||
ORDER BY created_at DESC
|
||||
`, [chain]);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 签名历史操作
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* 创建签名历史记录
|
||||
*/
|
||||
createSigningHistory(params: {
|
||||
shareId: string;
|
||||
sessionId: string;
|
||||
messageHash: string;
|
||||
}): SigningHistoryRecord {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
const id = uuidv4();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
this.db.run(`
|
||||
INSERT INTO signing_history (
|
||||
id, share_id, session_id, message_hash, status, created_at
|
||||
) VALUES (?, ?, ?, ?, 'pending', ?)
|
||||
`, [id, params.shareId, params.sessionId, params.messageHash, now]);
|
||||
|
||||
this.saveToFile();
|
||||
|
||||
return {
|
||||
id,
|
||||
share_id: params.shareId,
|
||||
session_id: params.sessionId,
|
||||
message_hash: params.messageHash,
|
||||
signature: null,
|
||||
status: 'pending',
|
||||
error_message: null,
|
||||
created_at: now,
|
||||
completed_at: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新签名历史状态
|
||||
*/
|
||||
updateSigningHistory(id: string, params: {
|
||||
status: SigningHistoryRecord['status'];
|
||||
signature?: string;
|
||||
errorMessage?: string;
|
||||
}): void {
|
||||
if (!this.db) return;
|
||||
|
||||
const completedAt = params.status === 'completed' || params.status === 'failed'
|
||||
? new Date().toISOString()
|
||||
: null;
|
||||
|
||||
this.db.run(`
|
||||
UPDATE signing_history
|
||||
SET status = ?, signature = ?, error_message = ?, completed_at = ?
|
||||
WHERE id = ?
|
||||
`, [
|
||||
params.status,
|
||||
params.signature || null,
|
||||
params.errorMessage || null,
|
||||
completedAt,
|
||||
id
|
||||
]);
|
||||
|
||||
this.saveToFile();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 share 的签名历史
|
||||
*/
|
||||
getSigningHistoryByShare(shareId: string): SigningHistoryRecord[] {
|
||||
return this.queryToObjects<SigningHistoryRecord>(`
|
||||
SELECT * FROM signing_history
|
||||
WHERE share_id = ?
|
||||
ORDER BY created_at DESC
|
||||
`, [shareId]);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 设置操作
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* 获取设置
|
||||
*/
|
||||
getSetting(key: string): string | undefined {
|
||||
const row = this.queryOne<{ value: string }>(`SELECT value FROM settings WHERE key = ?`, [key]);
|
||||
return row?.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存设置
|
||||
*/
|
||||
setSetting(key: string, value: string): void {
|
||||
if (!this.db) return;
|
||||
this.db.run(`INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)`, [key, value]);
|
||||
this.saveToFile();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有设置
|
||||
*/
|
||||
getAllSettings(): Record<string, string> {
|
||||
const rows = this.queryToObjects<SettingsRecord>(`SELECT key, value FROM settings`);
|
||||
const settings: Record<string, string> = {};
|
||||
for (const row of rows) {
|
||||
settings[row.key] = row.value;
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 消息去重操作
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* 检查消息是否已处理
|
||||
*/
|
||||
isMessageProcessed(messageId: string): boolean {
|
||||
const row = this.queryOne<{ message_id: string }>(
|
||||
`SELECT message_id FROM processed_messages WHERE message_id = ?`,
|
||||
[messageId]
|
||||
);
|
||||
return !!row;
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记消息为已处理
|
||||
*/
|
||||
markMessageProcessed(messageId: string, sessionId: string): void {
|
||||
if (!this.db) return;
|
||||
const now = new Date().toISOString();
|
||||
this.db.run(
|
||||
`INSERT OR IGNORE INTO processed_messages (message_id, session_id, processed_at) VALUES (?, ?, ?)`,
|
||||
[messageId, sessionId, now]
|
||||
);
|
||||
this.saveToFile();
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理指定会话的已处理消息记录
|
||||
* 当会话完成后调用,释放空间
|
||||
*/
|
||||
clearProcessedMessages(sessionId: string): void {
|
||||
if (!this.db) return;
|
||||
this.db.run(`DELETE FROM processed_messages WHERE session_id = ?`, [sessionId]);
|
||||
this.saveToFile();
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理过期的已处理消息记录(超过24小时)
|
||||
* 可在应用启动时调用
|
||||
*/
|
||||
cleanupOldProcessedMessages(): void {
|
||||
if (!this.db) return;
|
||||
const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
|
||||
this.db.run(`DELETE FROM processed_messages WHERE processed_at < ?`, [cutoff]);
|
||||
this.saveToFile();
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 导入导出
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* 导出 share (加密备份)
|
||||
*/
|
||||
exportShare(id: string, password: string): Buffer {
|
||||
const share = this.getShare(id, password);
|
||||
const addresses = this.getAddressesByShare(id);
|
||||
|
||||
const exportData = {
|
||||
version: '1.0.0',
|
||||
exportedAt: new Date().toISOString(),
|
||||
share: {
|
||||
session_id: share.session_id,
|
||||
wallet_name: share.wallet_name,
|
||||
party_id: share.party_id,
|
||||
party_index: share.party_index,
|
||||
threshold_t: share.threshold_t,
|
||||
threshold_n: share.threshold_n,
|
||||
public_key_hex: share.public_key_hex,
|
||||
raw_share: share.raw_share,
|
||||
participants: JSON.parse(share.participants_json),
|
||||
},
|
||||
addresses: addresses.map(addr => ({
|
||||
chain: addr.chain,
|
||||
derivation_path: addr.derivation_path,
|
||||
address: addr.address,
|
||||
public_key_hex: addr.public_key_hex,
|
||||
})),
|
||||
};
|
||||
|
||||
const encrypted = this.encrypt(JSON.stringify(exportData), password);
|
||||
return Buffer.from(encrypted, 'utf8');
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入 share
|
||||
*/
|
||||
importShare(data: Buffer, password: string): ShareRecord {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
const encrypted = data.toString('utf8');
|
||||
const decrypted = this.decrypt(encrypted, password);
|
||||
const exportData = JSON.parse(decrypted);
|
||||
|
||||
if (!exportData.version || !exportData.share) {
|
||||
throw new Error('Invalid export file format');
|
||||
}
|
||||
|
||||
// 检查是否已存在
|
||||
const existing = this.queryOne<{ id: string }>(`
|
||||
SELECT id FROM shares WHERE session_id = ? AND party_id = ?
|
||||
`, [exportData.share.session_id, exportData.share.party_id]);
|
||||
|
||||
if (existing) {
|
||||
throw new Error('Share already exists');
|
||||
}
|
||||
|
||||
// 保存 share
|
||||
const share = this.saveShare({
|
||||
sessionId: exportData.share.session_id,
|
||||
walletName: exportData.share.wallet_name,
|
||||
partyId: exportData.share.party_id,
|
||||
partyIndex: exportData.share.party_index,
|
||||
thresholdT: exportData.share.threshold_t,
|
||||
thresholdN: exportData.share.threshold_n,
|
||||
publicKeyHex: exportData.share.public_key_hex,
|
||||
rawShare: exportData.share.raw_share,
|
||||
participants: exportData.share.participants,
|
||||
}, password);
|
||||
|
||||
// 恢复派生地址
|
||||
if (exportData.addresses) {
|
||||
for (const addr of exportData.addresses) {
|
||||
this.saveDerivedAddress({
|
||||
shareId: share.id,
|
||||
chain: addr.chain,
|
||||
derivationPath: addr.derivation_path,
|
||||
address: addr.address,
|
||||
publicKeyHex: addr.public_key_hex,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return share;
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭数据库连接
|
||||
*/
|
||||
close(): void {
|
||||
if (this.db) {
|
||||
this.saveToFile();
|
||||
this.db.close();
|
||||
this.db = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,736 @@
|
|||
import * as grpc from '@grpc/grpc-js';
|
||||
import * as protoLoader from '@grpc/proto-loader';
|
||||
import * as path from 'path';
|
||||
import { EventEmitter } from 'events';
|
||||
import { app } from 'electron';
|
||||
|
||||
// 定义 proto 包结构类型
|
||||
interface ProtoPackage {
|
||||
mpc?: {
|
||||
router?: {
|
||||
v1?: {
|
||||
MessageRouter?: grpc.ServiceClientConstructor;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// 延迟加载的 Proto 定义
|
||||
let packageDefinition: protoLoader.PackageDefinition | null = null;
|
||||
|
||||
// Proto 文件路径 - 在打包后需要从 app.asar.unpacked 或 resources 目录加载
|
||||
function getProtoPath(): string {
|
||||
// 开发环境
|
||||
if (!app.isPackaged) {
|
||||
return path.join(__dirname, '../../proto/message_router.proto');
|
||||
}
|
||||
// 生产环境 - proto 文件需要解包
|
||||
return path.join(process.resourcesPath, 'proto/message_router.proto');
|
||||
}
|
||||
|
||||
// 延迟加载 Proto 定义
|
||||
function loadProtoDefinition(): protoLoader.PackageDefinition {
|
||||
if (!packageDefinition) {
|
||||
const protoPath = getProtoPath();
|
||||
console.log('Loading proto from:', protoPath);
|
||||
packageDefinition = protoLoader.loadSync(protoPath, {
|
||||
keepCase: true,
|
||||
longs: String,
|
||||
enums: String,
|
||||
defaults: true,
|
||||
oneofs: true,
|
||||
});
|
||||
}
|
||||
return packageDefinition;
|
||||
}
|
||||
|
||||
// Note: field names must match proto definitions with keepCase: true
|
||||
// Proto uses snake_case: session_id, session_type, threshold_n, threshold_t
|
||||
interface SessionInfo {
|
||||
session_id: string;
|
||||
session_type: string;
|
||||
threshold_n: number;
|
||||
threshold_t: number;
|
||||
message_hash?: Buffer;
|
||||
keygen_session_id?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
interface PartyInfo {
|
||||
party_id: string;
|
||||
party_index: number;
|
||||
}
|
||||
|
||||
interface JoinSessionResponse {
|
||||
success: boolean;
|
||||
session_info?: SessionInfo;
|
||||
party_index: number;
|
||||
other_parties: PartyInfo[];
|
||||
}
|
||||
|
||||
interface MPCMessage {
|
||||
message_id: string;
|
||||
session_id: string;
|
||||
from_party: string;
|
||||
is_broadcast: boolean;
|
||||
round_number: number;
|
||||
payload: Buffer;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface SessionEvent {
|
||||
event_id: string;
|
||||
event_type: string;
|
||||
session_id: string;
|
||||
threshold_n: number;
|
||||
threshold_t: number;
|
||||
selected_parties: string[];
|
||||
join_tokens: Record<string, string>;
|
||||
message_hash?: Buffer;
|
||||
}
|
||||
|
||||
// Raw proto response (snake_case)
|
||||
interface RegisteredPartyProto {
|
||||
party_id: string;
|
||||
role: string;
|
||||
online: boolean;
|
||||
registered_at: string;
|
||||
last_seen_at: string;
|
||||
}
|
||||
|
||||
interface GetRegisteredPartiesResponse {
|
||||
parties: RegisteredPartyProto[];
|
||||
}
|
||||
|
||||
// Converted response (camelCase) - used by callers
|
||||
interface RegisteredParty {
|
||||
partyId: string;
|
||||
role: string;
|
||||
online: boolean;
|
||||
registeredAt: string;
|
||||
lastSeenAt: string;
|
||||
}
|
||||
|
||||
// 重连配置
|
||||
interface ReconnectConfig {
|
||||
maxRetries: number;
|
||||
initialDelayMs: number;
|
||||
maxDelayMs: number;
|
||||
backoffMultiplier: number;
|
||||
}
|
||||
|
||||
const DEFAULT_RECONNECT_CONFIG: ReconnectConfig = {
|
||||
maxRetries: 10,
|
||||
initialDelayMs: 1000,
|
||||
maxDelayMs: 30000,
|
||||
backoffMultiplier: 2,
|
||||
};
|
||||
|
||||
/**
|
||||
* gRPC 客户端 - 连接到 Message Router
|
||||
*
|
||||
* 连接地址格式:
|
||||
* - 开发环境: localhost:50051 (不加密)
|
||||
* - 生产环境: mpc-grpc.szaiai.com:443 (TLS 加密)
|
||||
*
|
||||
* 特性:
|
||||
* - 自动重连机制(指数退避)
|
||||
* - 事件流断开后自动重新订阅
|
||||
* - 心跳失败后自动重连
|
||||
*/
|
||||
export class GrpcClient extends EventEmitter {
|
||||
private client: grpc.Client | null = null;
|
||||
private connected = false;
|
||||
private partyId: string | null = null;
|
||||
private partyRole: string | null = null;
|
||||
private heartbeatInterval: NodeJS.Timeout | null = null;
|
||||
private messageStream: grpc.ClientReadableStream<MPCMessage> | null = null;
|
||||
private eventStream: grpc.ClientReadableStream<SessionEvent> | null = null;
|
||||
|
||||
// 重连相关
|
||||
private reconnectConfig: ReconnectConfig;
|
||||
private currentAddress: string | null = null;
|
||||
private currentUseTLS: boolean | undefined;
|
||||
private isReconnecting = false;
|
||||
private reconnectAttempts = 0;
|
||||
private reconnectTimeout: NodeJS.Timeout | null = null;
|
||||
private shouldReconnect = true;
|
||||
|
||||
// 消息流状态(用于重连后恢复)
|
||||
private activeMessageSubscription: { sessionId: string; partyId: string } | null = null;
|
||||
private eventStreamSubscribed = false;
|
||||
|
||||
// 心跳失败计数
|
||||
private heartbeatFailCount = 0;
|
||||
private readonly MAX_HEARTBEAT_FAILS = 3;
|
||||
|
||||
constructor(reconnectConfig?: Partial<ReconnectConfig>) {
|
||||
super();
|
||||
this.reconnectConfig = { ...DEFAULT_RECONNECT_CONFIG, ...reconnectConfig };
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接到 Message Router
|
||||
* @param address 完整地址,格式: host:port (例如 mpc-grpc.szaiai.com:443 或 localhost:50051)
|
||||
* @param useTLS 是否使用 TLS 加密 (默认: 自动检测,端口 443 使用 TLS)
|
||||
*/
|
||||
async connect(address: string, useTLS?: boolean): Promise<void> {
|
||||
// 保存连接参数用于重连
|
||||
this.currentAddress = address;
|
||||
this.currentUseTLS = useTLS;
|
||||
this.shouldReconnect = true;
|
||||
|
||||
return this.doConnect(address, useTLS);
|
||||
}
|
||||
|
||||
private async doConnect(address: string, useTLS?: boolean): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const definition = loadProtoDefinition();
|
||||
const proto = grpc.loadPackageDefinition(definition) as ProtoPackage;
|
||||
const MessageRouter = proto.mpc?.router?.v1?.MessageRouter;
|
||||
|
||||
if (!MessageRouter) {
|
||||
reject(new Error('Failed to load MessageRouter service definition'));
|
||||
return;
|
||||
}
|
||||
|
||||
// 解析地址,如果没有端口则默认使用 443
|
||||
let targetAddress = address;
|
||||
if (!address.includes(':')) {
|
||||
targetAddress = `${address}:443`;
|
||||
}
|
||||
|
||||
// 自动检测是否使用 TLS: 端口 443 或显式指定
|
||||
const port = parseInt(targetAddress.split(':')[1], 10);
|
||||
const shouldUseTLS = useTLS !== undefined ? useTLS : (port === 443);
|
||||
|
||||
// 创建凭证
|
||||
const credentials = shouldUseTLS
|
||||
? grpc.credentials.createSsl() // TLS 加密 (生产环境)
|
||||
: grpc.credentials.createInsecure(); // 不加密 (开发环境)
|
||||
|
||||
console.log(`[gRPC] Connecting to Message Router: ${targetAddress} (TLS: ${shouldUseTLS})`);
|
||||
|
||||
this.client = new MessageRouter(
|
||||
targetAddress,
|
||||
credentials
|
||||
) as grpc.Client;
|
||||
|
||||
// 等待连接就绪
|
||||
const deadline = new Date();
|
||||
deadline.setSeconds(deadline.getSeconds() + 10);
|
||||
|
||||
(this.client as grpc.Client & { waitForReady: (deadline: Date, callback: (err?: Error) => void) => void })
|
||||
.waitForReady(deadline, (err?: Error) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
this.connected = true;
|
||||
this.reconnectAttempts = 0; // 重置重连计数
|
||||
this.heartbeatFailCount = 0;
|
||||
console.log('[gRPC] Connected successfully');
|
||||
this.emit('connected');
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 断开连接(不会自动重连)
|
||||
*/
|
||||
disconnect(): void {
|
||||
this.shouldReconnect = false;
|
||||
this.cleanupConnection();
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理连接资源
|
||||
*/
|
||||
private cleanupConnection(): void {
|
||||
if (this.reconnectTimeout) {
|
||||
clearTimeout(this.reconnectTimeout);
|
||||
this.reconnectTimeout = null;
|
||||
}
|
||||
|
||||
if (this.heartbeatInterval) {
|
||||
clearInterval(this.heartbeatInterval);
|
||||
this.heartbeatInterval = null;
|
||||
}
|
||||
|
||||
if (this.messageStream) {
|
||||
try {
|
||||
this.messageStream.cancel();
|
||||
} catch (e) {
|
||||
// 忽略取消错误
|
||||
}
|
||||
this.messageStream = null;
|
||||
}
|
||||
|
||||
if (this.eventStream) {
|
||||
try {
|
||||
this.eventStream.cancel();
|
||||
} catch (e) {
|
||||
// 忽略取消错误
|
||||
}
|
||||
this.eventStream = null;
|
||||
}
|
||||
|
||||
if (this.client) {
|
||||
try {
|
||||
(this.client as grpc.Client & { close: () => void }).close();
|
||||
} catch (e) {
|
||||
// 忽略关闭错误
|
||||
}
|
||||
this.client = null;
|
||||
}
|
||||
|
||||
this.connected = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发重连
|
||||
*/
|
||||
private async triggerReconnect(reason: string): Promise<void> {
|
||||
if (!this.shouldReconnect || this.isReconnecting || !this.currentAddress) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[gRPC] Triggering reconnect: ${reason}`);
|
||||
this.isReconnecting = true;
|
||||
this.connected = false;
|
||||
this.emit('disconnected', reason);
|
||||
|
||||
// 清理现有连接
|
||||
this.cleanupConnection();
|
||||
|
||||
// 计算延迟时间(指数退避)
|
||||
const delay = Math.min(
|
||||
this.reconnectConfig.initialDelayMs * Math.pow(this.reconnectConfig.backoffMultiplier, this.reconnectAttempts),
|
||||
this.reconnectConfig.maxDelayMs
|
||||
);
|
||||
|
||||
console.log(`[gRPC] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts + 1}/${this.reconnectConfig.maxRetries})`);
|
||||
|
||||
this.reconnectTimeout = setTimeout(async () => {
|
||||
this.reconnectAttempts++;
|
||||
|
||||
if (this.reconnectAttempts > this.reconnectConfig.maxRetries) {
|
||||
console.error('[gRPC] Max reconnect attempts reached');
|
||||
this.isReconnecting = false;
|
||||
this.emit('reconnectFailed', 'Max retries exceeded');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.doConnect(this.currentAddress!, this.currentUseTLS);
|
||||
|
||||
// 重新注册
|
||||
if (this.partyId && this.partyRole) {
|
||||
console.log(`[gRPC] Re-registering as party: ${this.partyId}`);
|
||||
await this.registerParty(this.partyId, this.partyRole);
|
||||
}
|
||||
|
||||
// 重新订阅事件流
|
||||
if (this.eventStreamSubscribed && this.partyId) {
|
||||
console.log('[gRPC] Re-subscribing to session events');
|
||||
this.subscribeSessionEvents(this.partyId);
|
||||
}
|
||||
|
||||
// 重新订阅消息流
|
||||
if (this.activeMessageSubscription) {
|
||||
console.log(`[gRPC] Re-subscribing to messages for session: ${this.activeMessageSubscription.sessionId}`);
|
||||
this.subscribeMessages(this.activeMessageSubscription.sessionId, this.activeMessageSubscription.partyId);
|
||||
}
|
||||
|
||||
this.isReconnecting = false;
|
||||
this.emit('reconnected');
|
||||
} catch (err) {
|
||||
console.error(`[gRPC] Reconnect attempt ${this.reconnectAttempts} failed:`, (err as Error).message);
|
||||
this.isReconnecting = false;
|
||||
// 继续尝试重连
|
||||
this.triggerReconnect('Previous reconnect attempt failed');
|
||||
}
|
||||
}, delay);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否已连接
|
||||
*/
|
||||
isConnected(): boolean {
|
||||
return this.connected;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前 Party ID
|
||||
*/
|
||||
getPartyId(): string | null {
|
||||
return this.partyId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册为参与方
|
||||
*/
|
||||
async registerParty(partyId: string, role: string): Promise<void> {
|
||||
if (!this.client) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
(this.client as grpc.Client & { registerParty: (req: unknown, callback: (err: Error | null, res: { success: boolean }) => void) => void })
|
||||
.registerParty(
|
||||
{
|
||||
party_id: partyId,
|
||||
party_role: role,
|
||||
version: '1.0.0',
|
||||
},
|
||||
(err: Error | null, response: { success: boolean }) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else if (!response.success) {
|
||||
reject(new Error('Registration failed'));
|
||||
} else {
|
||||
this.partyId = partyId;
|
||||
this.partyRole = role;
|
||||
this.startHeartbeat();
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始心跳(带重连逻辑)
|
||||
*/
|
||||
private startHeartbeat(): void {
|
||||
if (this.heartbeatInterval) {
|
||||
clearInterval(this.heartbeatInterval);
|
||||
}
|
||||
|
||||
this.heartbeatFailCount = 0;
|
||||
|
||||
this.heartbeatInterval = setInterval(() => {
|
||||
if (this.client && this.partyId) {
|
||||
(this.client as grpc.Client & { heartbeat: (req: unknown, callback: (err: Error | null) => void) => void })
|
||||
.heartbeat(
|
||||
{ party_id: this.partyId },
|
||||
(err: Error | null) => {
|
||||
if (err) {
|
||||
this.heartbeatFailCount++;
|
||||
console.error(`[gRPC] Heartbeat failed (${this.heartbeatFailCount}/${this.MAX_HEARTBEAT_FAILS}):`, err.message);
|
||||
this.emit('connectionError', err);
|
||||
|
||||
// 连续失败多次后触发重连
|
||||
if (this.heartbeatFailCount >= this.MAX_HEARTBEAT_FAILS) {
|
||||
this.triggerReconnect('Heartbeat failed');
|
||||
}
|
||||
} else {
|
||||
// 心跳成功,重置失败计数
|
||||
this.heartbeatFailCount = 0;
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
}, 30000); // 每 30 秒一次
|
||||
}
|
||||
|
||||
/**
|
||||
* 加入会话
|
||||
*/
|
||||
async joinSession(sessionId: string, partyId: string, joinToken: string): Promise<JoinSessionResponse> {
|
||||
if (!this.client) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
(this.client as grpc.Client & { joinSession: (req: unknown, callback: (err: Error | null, res: JoinSessionResponse) => void) => void })
|
||||
.joinSession(
|
||||
{
|
||||
session_id: sessionId,
|
||||
party_id: partyId,
|
||||
join_token: joinToken,
|
||||
},
|
||||
(err: Error | null, response: JoinSessionResponse) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(response);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅会话事件(带自动重连)
|
||||
*/
|
||||
subscribeSessionEvents(partyId: string): void {
|
||||
if (!this.client) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
// 标记已订阅(用于重连后恢复)
|
||||
this.eventStreamSubscribed = true;
|
||||
|
||||
// 取消现有流 - 先移除事件监听器再取消,防止旧流的 end 事件触发重连
|
||||
if (this.eventStream) {
|
||||
const oldStream = this.eventStream;
|
||||
oldStream.removeAllListeners();
|
||||
try {
|
||||
oldStream.cancel();
|
||||
} catch (e) {
|
||||
// 忽略
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
this.eventStream.on('error', (err: Error) => {
|
||||
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');
|
||||
}
|
||||
});
|
||||
|
||||
this.eventStream.on('end', () => {
|
||||
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');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消订阅会话事件
|
||||
*/
|
||||
unsubscribeSessionEvents(): void {
|
||||
this.eventStreamSubscribed = false;
|
||||
if (this.eventStream) {
|
||||
try {
|
||||
this.eventStream.cancel();
|
||||
} catch (e) {
|
||||
// 忽略
|
||||
}
|
||||
this.eventStream = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅 MPC 消息(带自动重连)
|
||||
*/
|
||||
subscribeMessages(sessionId: string, partyId: string): void {
|
||||
if (!this.client) {
|
||||
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 {
|
||||
oldStream.cancel();
|
||||
} catch (e) {
|
||||
console.log('[gRPC] Ignored error while canceling old message stream:', (e as Error).message);
|
||||
}
|
||||
this.messageStream = null;
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
this.messageStream.on('error', (err: Error) => {
|
||||
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');
|
||||
}
|
||||
});
|
||||
|
||||
this.messageStream.on('end', () => {
|
||||
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');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消订阅 MPC 消息
|
||||
*/
|
||||
unsubscribeMessages(): void {
|
||||
this.activeMessageSubscription = null;
|
||||
if (this.messageStream) {
|
||||
try {
|
||||
this.messageStream.cancel();
|
||||
} catch (e) {
|
||||
// 忽略
|
||||
}
|
||||
this.messageStream = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送 MPC 消息
|
||||
*/
|
||||
async routeMessage(
|
||||
sessionId: string,
|
||||
fromParty: string,
|
||||
toParties: string[],
|
||||
roundNumber: number,
|
||||
payload: Buffer
|
||||
): Promise<string> {
|
||||
if (!this.client) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
(this.client as grpc.Client & { routeMessage: (req: unknown, callback: (err: Error | null, res: { message_id: string }) => void) => void })
|
||||
.routeMessage(
|
||||
{
|
||||
session_id: sessionId,
|
||||
from_party: fromParty,
|
||||
to_parties: toParties,
|
||||
round_number: roundNumber,
|
||||
payload: payload,
|
||||
},
|
||||
(err: Error | null, response: { message_id: string }) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(response.message_id);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 报告完成
|
||||
*/
|
||||
async reportCompletion(sessionId: string, partyId: string, publicKey: Buffer): Promise<boolean> {
|
||||
if (!this.client) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
(this.client as grpc.Client & { reportCompletion: (req: unknown, callback: (err: Error | null, res: { all_completed: boolean }) => void) => void })
|
||||
.reportCompletion(
|
||||
{
|
||||
session_id: sessionId,
|
||||
party_id: partyId,
|
||||
public_key: publicKey,
|
||||
},
|
||||
(err: Error | null, response: { all_completed: boolean }) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(response.all_completed);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取已注册的参与方列表
|
||||
* @param roleFilter 可选,按角色过滤 (persistent/delegate/temporary)
|
||||
* @param onlyOnline 可选,只返回在线的参与方
|
||||
*/
|
||||
async getRegisteredParties(roleFilter?: string, onlyOnline?: boolean): Promise<RegisteredParty[]> {
|
||||
if (!this.client) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
(this.client as grpc.Client & { getRegisteredParties: (req: unknown, callback: (err: Error | null, res: GetRegisteredPartiesResponse) => void) => void })
|
||||
.getRegisteredParties(
|
||||
{
|
||||
role_filter: roleFilter || '',
|
||||
only_online: onlyOnline || false,
|
||||
},
|
||||
(err: Error | null, response: GetRegisteredPartiesResponse) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
// 转换字段名从 snake_case 到 camelCase
|
||||
const parties = (response.parties || []).map((p: { party_id?: string; partyId?: string; role?: string; party_role?: string; online?: boolean; registered_at?: string; registeredAt?: string; last_seen_at?: string; lastSeenAt?: string }) => ({
|
||||
partyId: p.party_id || p.partyId || '',
|
||||
role: p.role || p.party_role || '',
|
||||
online: p.online || false,
|
||||
registeredAt: p.registered_at || p.registeredAt || '',
|
||||
lastSeenAt: p.last_seen_at || p.lastSeenAt || '',
|
||||
}));
|
||||
resolve(parties);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,561 @@
|
|||
/**
|
||||
* Kava 区块链客户端
|
||||
*
|
||||
* 功能:
|
||||
* 1. 查询账户余额
|
||||
* 2. 查询账户信息 (用于获取 sequence 和 account_number)
|
||||
* 3. 构建交易
|
||||
* 4. 广播交易
|
||||
*
|
||||
* 使用 Kava 官方 LCD REST API:
|
||||
* - 主网: https://api.kava.io
|
||||
* - 备用: https://api.kava-rpc.com
|
||||
*/
|
||||
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
// =============================================================================
|
||||
// 配置
|
||||
// =============================================================================
|
||||
|
||||
export interface KavaClientConfig {
|
||||
lcdEndpoint: string; // LCD REST API 端点
|
||||
chainId: string; // 链 ID (kava_2222-10 for mainnet)
|
||||
gasPrice: string; // Gas 价格 (如 "0.025ukava")
|
||||
defaultGasLimit: number; // 默认 Gas 限制
|
||||
}
|
||||
|
||||
export const KAVA_MAINNET_CONFIG: KavaClientConfig = {
|
||||
lcdEndpoint: 'https://api.kava.io',
|
||||
chainId: 'kava_2222-10',
|
||||
gasPrice: '0.025ukava',
|
||||
defaultGasLimit: 200000,
|
||||
};
|
||||
|
||||
export const KAVA_TESTNET_CONFIG: KavaClientConfig = {
|
||||
lcdEndpoint: 'https://api.testnet.kava.io',
|
||||
chainId: 'kava_2221-16000',
|
||||
gasPrice: '0.025ukava',
|
||||
defaultGasLimit: 200000,
|
||||
};
|
||||
|
||||
// 备用端点列表
|
||||
export const KAVA_LCD_ENDPOINTS = [
|
||||
'https://api.kava.io',
|
||||
'https://api.kava-rpc.com',
|
||||
'https://api.kava.chainstacklabs.com',
|
||||
];
|
||||
|
||||
// =============================================================================
|
||||
// 类型定义
|
||||
// =============================================================================
|
||||
|
||||
export interface Coin {
|
||||
denom: string;
|
||||
amount: string;
|
||||
}
|
||||
|
||||
export interface AccountInfo {
|
||||
address: string;
|
||||
accountNumber: string;
|
||||
sequence: string;
|
||||
pubKey?: {
|
||||
type: string;
|
||||
value: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface BalanceResponse {
|
||||
balances: Coin[];
|
||||
pagination: {
|
||||
next_key: string | null;
|
||||
total: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AccountResponse {
|
||||
account: {
|
||||
'@type': string;
|
||||
address: string;
|
||||
pub_key?: {
|
||||
'@type': string;
|
||||
key: string;
|
||||
};
|
||||
account_number: string;
|
||||
sequence: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TxResponse {
|
||||
height: string;
|
||||
txhash: string;
|
||||
codespace: string;
|
||||
code: number;
|
||||
data: string;
|
||||
raw_log: string;
|
||||
logs: unknown[];
|
||||
info: string;
|
||||
gas_wanted: string;
|
||||
gas_used: string;
|
||||
tx: unknown;
|
||||
timestamp: string;
|
||||
events: unknown[];
|
||||
}
|
||||
|
||||
export interface BroadcastTxResponse {
|
||||
tx_response: TxResponse;
|
||||
}
|
||||
|
||||
export interface SimulateTxResponse {
|
||||
gas_info: {
|
||||
gas_wanted: string;
|
||||
gas_used: string;
|
||||
};
|
||||
result: {
|
||||
data: string;
|
||||
log: string;
|
||||
events: unknown[];
|
||||
};
|
||||
}
|
||||
|
||||
// 交易消息类型
|
||||
export interface MsgSend {
|
||||
'@type': '/cosmos.bank.v1beta1.MsgSend';
|
||||
from_address: string;
|
||||
to_address: string;
|
||||
amount: Coin[];
|
||||
}
|
||||
|
||||
export interface Fee {
|
||||
amount: Coin[];
|
||||
gas_limit: string;
|
||||
payer?: string;
|
||||
granter?: string;
|
||||
}
|
||||
|
||||
export interface SignerInfo {
|
||||
public_key: {
|
||||
'@type': string;
|
||||
key: string;
|
||||
};
|
||||
mode_info: {
|
||||
single: {
|
||||
mode: string;
|
||||
};
|
||||
};
|
||||
sequence: string;
|
||||
}
|
||||
|
||||
export interface AuthInfo {
|
||||
signer_infos: SignerInfo[];
|
||||
fee: Fee;
|
||||
}
|
||||
|
||||
export interface TxBody {
|
||||
messages: MsgSend[];
|
||||
memo: string;
|
||||
timeout_height: string;
|
||||
extension_options: unknown[];
|
||||
non_critical_extension_options: unknown[];
|
||||
}
|
||||
|
||||
export interface TxRaw {
|
||||
body_bytes: string;
|
||||
auth_info_bytes: string;
|
||||
signatures: string[];
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Kava 客户端类
|
||||
// =============================================================================
|
||||
|
||||
export class KavaClient {
|
||||
private config: KavaClientConfig;
|
||||
private currentEndpointIndex = 0;
|
||||
|
||||
constructor(config: KavaClientConfig = KAVA_MAINNET_CONFIG) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前 LCD 端点
|
||||
*/
|
||||
private getLcdEndpoint(): string {
|
||||
return this.config.lcdEndpoint;
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换到备用端点
|
||||
*/
|
||||
private switchToBackupEndpoint(): void {
|
||||
this.currentEndpointIndex = (this.currentEndpointIndex + 1) % KAVA_LCD_ENDPOINTS.length;
|
||||
this.config.lcdEndpoint = KAVA_LCD_ENDPOINTS[this.currentEndpointIndex];
|
||||
console.log(`Switched to backup endpoint: ${this.config.lcdEndpoint}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送 HTTP 请求
|
||||
*/
|
||||
private async request<T>(
|
||||
path: string,
|
||||
method: 'GET' | 'POST' = 'GET',
|
||||
body?: unknown
|
||||
): Promise<T> {
|
||||
const url = `${this.getLcdEndpoint()}${path}`;
|
||||
|
||||
const options: RequestInit = {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
};
|
||||
|
||||
if (body) {
|
||||
options.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, options);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`HTTP ${response.status}: ${errorText}`);
|
||||
}
|
||||
|
||||
return await response.json() as T;
|
||||
} catch (error) {
|
||||
// 如果请求失败,尝试切换端点
|
||||
console.error(`Request failed for ${url}:`, error);
|
||||
this.switchToBackupEndpoint();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 查询功能
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* 查询账户余额
|
||||
*
|
||||
* @param address - Kava 地址 (bech32 格式,以 "kava" 开头)
|
||||
* @returns 余额列表
|
||||
*/
|
||||
async getBalances(address: string): Promise<Coin[]> {
|
||||
const response = await this.request<BalanceResponse>(
|
||||
`/cosmos/bank/v1beta1/balances/${address}`
|
||||
);
|
||||
return response.balances;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询指定代币余额
|
||||
*
|
||||
* @param address - Kava 地址
|
||||
* @param denom - 代币单位 (如 "ukava")
|
||||
* @returns 余额
|
||||
*/
|
||||
async getBalance(address: string, denom: string = 'ukava'): Promise<Coin> {
|
||||
const response = await this.request<{ balance: Coin }>(
|
||||
`/cosmos/bank/v1beta1/balances/${address}/by_denom?denom=${denom}`
|
||||
);
|
||||
return response.balance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询账户信息 (用于构建交易)
|
||||
*
|
||||
* @param address - Kava 地址
|
||||
* @returns 账户信息 (包含 account_number 和 sequence)
|
||||
*/
|
||||
async getAccountInfo(address: string): Promise<AccountInfo> {
|
||||
const response = await this.request<AccountResponse>(
|
||||
`/cosmos/auth/v1beta1/accounts/${address}`
|
||||
);
|
||||
|
||||
const account = response.account;
|
||||
return {
|
||||
address: account.address,
|
||||
accountNumber: account.account_number,
|
||||
sequence: account.sequence,
|
||||
pubKey: account.pub_key ? {
|
||||
type: account.pub_key['@type'],
|
||||
value: account.pub_key.key,
|
||||
} : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询交易详情
|
||||
*
|
||||
* @param txHash - 交易哈希
|
||||
* @returns 交易详情
|
||||
*/
|
||||
async getTx(txHash: string): Promise<TxResponse> {
|
||||
const response = await this.request<{ tx_response: TxResponse }>(
|
||||
`/cosmos/tx/v1beta1/txs/${txHash}`
|
||||
);
|
||||
return response.tx_response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询最新区块高度
|
||||
*/
|
||||
async getLatestBlockHeight(): Promise<number> {
|
||||
const response = await this.request<{ block: { header: { height: string } } }>(
|
||||
`/cosmos/base/tendermint/v1beta1/blocks/latest`
|
||||
);
|
||||
return parseInt(response.block.header.height, 10);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 交易构建
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* 构建转账交易消息
|
||||
*
|
||||
* @param fromAddress - 发送方地址
|
||||
* @param toAddress - 接收方地址
|
||||
* @param amount - 金额
|
||||
* @param denom - 代币单位
|
||||
* @returns MsgSend 消息
|
||||
*/
|
||||
buildMsgSend(
|
||||
fromAddress: string,
|
||||
toAddress: string,
|
||||
amount: string,
|
||||
denom: string = 'ukava'
|
||||
): MsgSend {
|
||||
return {
|
||||
'@type': '/cosmos.bank.v1beta1.MsgSend',
|
||||
from_address: fromAddress,
|
||||
to_address: toAddress,
|
||||
amount: [{ denom, amount }],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建交易体
|
||||
*
|
||||
* @param messages - 交易消息列表
|
||||
* @param memo - 备注
|
||||
* @returns TxBody
|
||||
*/
|
||||
buildTxBody(messages: MsgSend[], memo: string = ''): TxBody {
|
||||
return {
|
||||
messages,
|
||||
memo,
|
||||
timeout_height: '0',
|
||||
extension_options: [],
|
||||
non_critical_extension_options: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 AuthInfo
|
||||
*
|
||||
* @param publicKeyBase64 - 压缩公钥 (Base64)
|
||||
* @param sequence - 账户序列号
|
||||
* @param gasLimit - Gas 限制
|
||||
* @param feeAmount - 手续费金额
|
||||
* @returns AuthInfo
|
||||
*/
|
||||
buildAuthInfo(
|
||||
publicKeyBase64: string,
|
||||
sequence: string,
|
||||
gasLimit: number = this.config.defaultGasLimit,
|
||||
feeAmount?: Coin[]
|
||||
): AuthInfo {
|
||||
// 计算手续费 (如果未提供)
|
||||
if (!feeAmount) {
|
||||
const gasPrice = parseFloat(this.config.gasPrice.replace('ukava', ''));
|
||||
const fee = Math.ceil(gasLimit * gasPrice);
|
||||
feeAmount = [{ denom: 'ukava', amount: fee.toString() }];
|
||||
}
|
||||
|
||||
return {
|
||||
signer_infos: [{
|
||||
public_key: {
|
||||
'@type': '/cosmos.crypto.secp256k1.PubKey',
|
||||
key: publicKeyBase64,
|
||||
},
|
||||
mode_info: {
|
||||
single: {
|
||||
mode: 'SIGN_MODE_DIRECT',
|
||||
},
|
||||
},
|
||||
sequence,
|
||||
}],
|
||||
fee: {
|
||||
amount: feeAmount,
|
||||
gas_limit: gasLimit.toString(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建待签名的交易数据
|
||||
*
|
||||
* @param txBody - 交易体
|
||||
* @param authInfo - 认证信息
|
||||
* @param accountNumber - 账户编号
|
||||
* @returns 签名数据 (用于 TSS 签名)
|
||||
*/
|
||||
buildSignDoc(
|
||||
txBody: TxBody,
|
||||
authInfo: AuthInfo,
|
||||
accountNumber: string
|
||||
): {
|
||||
bodyBytes: Buffer;
|
||||
authInfoBytes: Buffer;
|
||||
chainId: string;
|
||||
accountNumber: string;
|
||||
signBytes: Buffer;
|
||||
} {
|
||||
// 注意:这里需要使用 protobuf 编码
|
||||
// 简化版本:使用 JSON 编码后进行 SHA256 哈希
|
||||
// 生产环境应使用 @cosmjs/proto-signing
|
||||
|
||||
const bodyBytes = Buffer.from(JSON.stringify(txBody));
|
||||
const authInfoBytes = Buffer.from(JSON.stringify(authInfo));
|
||||
|
||||
// 构建 SignDoc
|
||||
const signDoc = {
|
||||
body_bytes: bodyBytes.toString('base64'),
|
||||
auth_info_bytes: authInfoBytes.toString('base64'),
|
||||
chain_id: this.config.chainId,
|
||||
account_number: accountNumber,
|
||||
};
|
||||
|
||||
// 计算签名哈希 (SHA256)
|
||||
const signBytes = crypto.createHash('sha256')
|
||||
.update(JSON.stringify(signDoc))
|
||||
.digest();
|
||||
|
||||
return {
|
||||
bodyBytes,
|
||||
authInfoBytes,
|
||||
chainId: this.config.chainId,
|
||||
accountNumber,
|
||||
signBytes,
|
||||
};
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 交易广播
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* 模拟交易 (估算 Gas)
|
||||
*
|
||||
* @param txBytes - 交易字节 (Base64)
|
||||
* @returns 模拟结果
|
||||
*/
|
||||
async simulateTx(txBytes: string): Promise<SimulateTxResponse> {
|
||||
return this.request<SimulateTxResponse>(
|
||||
'/cosmos/tx/v1beta1/simulate',
|
||||
'POST',
|
||||
{ tx_bytes: txBytes }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 广播交易
|
||||
*
|
||||
* @param txBytes - 已签名的交易字节 (Base64)
|
||||
* @param mode - 广播模式 (BROADCAST_MODE_SYNC | BROADCAST_MODE_ASYNC | BROADCAST_MODE_BLOCK)
|
||||
* @returns 广播结果
|
||||
*/
|
||||
async broadcastTx(
|
||||
txBytes: string,
|
||||
mode: 'BROADCAST_MODE_SYNC' | 'BROADCAST_MODE_ASYNC' | 'BROADCAST_MODE_BLOCK' = 'BROADCAST_MODE_SYNC'
|
||||
): Promise<BroadcastTxResponse> {
|
||||
return this.request<BroadcastTxResponse>(
|
||||
'/cosmos/tx/v1beta1/txs',
|
||||
'POST',
|
||||
{
|
||||
tx_bytes: txBytes,
|
||||
mode,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建并编码完整的已签名交易
|
||||
*
|
||||
* @param bodyBytes - 交易体字节
|
||||
* @param authInfoBytes - 认证信息字节
|
||||
* @param signature - 签名 (Buffer)
|
||||
* @returns Base64 编码的交易字节
|
||||
*/
|
||||
encodeSignedTx(
|
||||
bodyBytes: Buffer,
|
||||
authInfoBytes: Buffer,
|
||||
signature: Buffer
|
||||
): string {
|
||||
// 简化版本:使用 JSON 编码
|
||||
// 生产环境应使用 protobuf 编码
|
||||
const txRaw = {
|
||||
body_bytes: bodyBytes.toString('base64'),
|
||||
auth_info_bytes: authInfoBytes.toString('base64'),
|
||||
signatures: [signature.toString('base64')],
|
||||
};
|
||||
|
||||
return Buffer.from(JSON.stringify(txRaw)).toString('base64');
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 便捷方法
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* 格式化 KAVA 金额 (ukava -> KAVA)
|
||||
*
|
||||
* @param amount - 金额 (ukava)
|
||||
* @returns 格式化的金额字符串
|
||||
*/
|
||||
formatKava(amount: string): string {
|
||||
const ukava = BigInt(amount);
|
||||
const kava = Number(ukava) / 1_000_000;
|
||||
return kava.toFixed(6);
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换 KAVA 到 ukava
|
||||
*
|
||||
* @param kava - KAVA 金额
|
||||
* @returns ukava 金额字符串
|
||||
*/
|
||||
toUkava(kava: number | string): string {
|
||||
const ukava = Math.floor(Number(kava) * 1_000_000);
|
||||
return ukava.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查地址格式
|
||||
*
|
||||
* @param address - 地址
|
||||
* @returns 是否有效
|
||||
*/
|
||||
isValidAddress(address: string): boolean {
|
||||
return address.startsWith('kava') && address.length === 43;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取配置
|
||||
*/
|
||||
getConfig(): KavaClientConfig {
|
||||
return { ...this.config };
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新配置
|
||||
*/
|
||||
updateConfig(config: Partial<KavaClientConfig>): void {
|
||||
this.config = { ...this.config, ...config };
|
||||
}
|
||||
}
|
||||
|
||||
// 导出默认客户端实例
|
||||
export const kavaClient = new KavaClient();
|
||||
|
|
@ -0,0 +1,537 @@
|
|||
/**
|
||||
* Kava 交易服务
|
||||
*
|
||||
* 使用 Kava LCD REST API 构建和广播交易
|
||||
* 支持 TSS 多方签名
|
||||
*
|
||||
* API 文档参考:
|
||||
* - https://docs.kava.io/docs/using-kava-endpoints/endpoints/
|
||||
* - https://docs.cosmos.network/main/learn/advanced/grpc_rest
|
||||
*/
|
||||
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
// =============================================================================
|
||||
// 配置
|
||||
// =============================================================================
|
||||
|
||||
export interface KavaTxConfig {
|
||||
lcdEndpoint: string;
|
||||
rpcEndpoint: string;
|
||||
chainId: string;
|
||||
prefix: string;
|
||||
denom: string;
|
||||
gasPrice: number; // ukava per gas unit
|
||||
}
|
||||
|
||||
export const KAVA_MAINNET_TX_CONFIG: KavaTxConfig = {
|
||||
lcdEndpoint: 'https://api.kava.io',
|
||||
rpcEndpoint: 'https://rpc.kava.io',
|
||||
chainId: 'kava_2222-10',
|
||||
prefix: 'kava',
|
||||
denom: 'ukava',
|
||||
gasPrice: 0.025,
|
||||
};
|
||||
|
||||
export const KAVA_TESTNET_TX_CONFIG: KavaTxConfig = {
|
||||
lcdEndpoint: 'https://api.testnet.kava.io',
|
||||
rpcEndpoint: 'https://rpc.testnet.kava.io',
|
||||
chainId: 'kava_2221-16000',
|
||||
prefix: 'kava',
|
||||
denom: 'ukava',
|
||||
gasPrice: 0.025,
|
||||
};
|
||||
|
||||
// 备用端点
|
||||
const BACKUP_ENDPOINTS = [
|
||||
'https://api.kava.io',
|
||||
'https://api.kava-rpc.com',
|
||||
'https://api.kava.chainstacklabs.com',
|
||||
];
|
||||
|
||||
// =============================================================================
|
||||
// 类型定义
|
||||
// =============================================================================
|
||||
|
||||
export interface Coin {
|
||||
denom: string;
|
||||
amount: string;
|
||||
}
|
||||
|
||||
export interface AccountBalance {
|
||||
denom: string;
|
||||
amount: string;
|
||||
formatted: string; // 人类可读格式 (KAVA)
|
||||
}
|
||||
|
||||
export interface AccountInfo {
|
||||
address: string;
|
||||
accountNumber: number;
|
||||
sequence: number;
|
||||
balances: AccountBalance[];
|
||||
}
|
||||
|
||||
export interface UnsignedTxData {
|
||||
// 用于 TSS 签名的数据
|
||||
signBytes: Uint8Array; // 待签名的哈希
|
||||
signBytesHex: string; // 十六进制格式
|
||||
|
||||
// 交易元数据
|
||||
txBodyBytes: Uint8Array;
|
||||
authInfoBytes: Uint8Array;
|
||||
accountNumber: number;
|
||||
sequence: number;
|
||||
chainId: string;
|
||||
|
||||
// 可读信息
|
||||
from: string;
|
||||
to: string;
|
||||
amount: string;
|
||||
denom: string;
|
||||
memo: string;
|
||||
fee: string;
|
||||
gasLimit: number;
|
||||
}
|
||||
|
||||
export interface SignedTxData {
|
||||
txBytes: Uint8Array; // 完整的已签名交易
|
||||
txBytesBase64: string; // Base64 格式
|
||||
txHash: string; // 交易哈希
|
||||
}
|
||||
|
||||
export interface TxBroadcastResult {
|
||||
success: boolean;
|
||||
txHash?: string;
|
||||
code?: number;
|
||||
rawLog?: string;
|
||||
gasUsed?: string;
|
||||
gasWanted?: string;
|
||||
height?: string;
|
||||
}
|
||||
|
||||
export interface TxStatus {
|
||||
found: boolean;
|
||||
status: 'pending' | 'success' | 'failed';
|
||||
code?: number;
|
||||
rawLog?: string;
|
||||
height?: string;
|
||||
gasUsed?: string;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Kava 交易服务类
|
||||
// =============================================================================
|
||||
|
||||
export class KavaTxService {
|
||||
private config: KavaTxConfig;
|
||||
private backupEndpointIndex = 0;
|
||||
|
||||
constructor(config: KavaTxConfig = KAVA_MAINNET_TX_CONFIG) {
|
||||
this.config = { ...config };
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 配置管理
|
||||
// ===========================================================================
|
||||
|
||||
getConfig(): KavaTxConfig {
|
||||
return { ...this.config };
|
||||
}
|
||||
|
||||
updateConfig(config: Partial<KavaTxConfig>): void {
|
||||
this.config = { ...this.config, ...config };
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换到测试网
|
||||
*/
|
||||
switchToTestnet(): void {
|
||||
this.config = { ...KAVA_TESTNET_TX_CONFIG };
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换到主网
|
||||
*/
|
||||
switchToMainnet(): void {
|
||||
this.config = { ...KAVA_MAINNET_TX_CONFIG };
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否是测试网
|
||||
*/
|
||||
isTestnet(): boolean {
|
||||
return this.config.chainId === KAVA_TESTNET_TX_CONFIG.chainId;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// HTTP 请求
|
||||
// ===========================================================================
|
||||
|
||||
private async request<T>(
|
||||
path: string,
|
||||
method: 'GET' | 'POST' = 'GET',
|
||||
body?: unknown,
|
||||
timeout: number = 10000
|
||||
): Promise<T> {
|
||||
const url = `${this.config.lcdEndpoint}${path}`;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||
|
||||
try {
|
||||
const options: RequestInit = {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
signal: controller.signal,
|
||||
};
|
||||
|
||||
if (body) {
|
||||
options.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
const response = await fetch(url, options);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`HTTP ${response.status}: ${errorText}`);
|
||||
}
|
||||
|
||||
return await response.json() as T;
|
||||
} catch (error) {
|
||||
// 如果是主网且请求失败,尝试备用端点
|
||||
if (!this.isTestnet()) {
|
||||
this.backupEndpointIndex = (this.backupEndpointIndex + 1) % BACKUP_ENDPOINTS.length;
|
||||
console.log(`Switched to backup endpoint: ${BACKUP_ENDPOINTS[this.backupEndpointIndex]}`);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 查询功能
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* 健康检查 - 查询最新区块
|
||||
*/
|
||||
async healthCheck(): Promise<{ ok: boolean; latency?: number; blockHeight?: number; error?: string }> {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const response = await this.request<{ block: { header: { height: string } } }>(
|
||||
'/cosmos/base/tendermint/v1beta1/blocks/latest',
|
||||
'GET',
|
||||
undefined,
|
||||
5000
|
||||
);
|
||||
const latency = Date.now() - start;
|
||||
return {
|
||||
ok: true,
|
||||
latency,
|
||||
blockHeight: parseInt(response.block.header.height, 10),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: (error as Error).message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询 KAVA 余额
|
||||
*/
|
||||
async getKavaBalance(address: string): Promise<string> {
|
||||
try {
|
||||
const response = await this.request<{ balance: Coin }>(
|
||||
`/cosmos/bank/v1beta1/balances/${address}/by_denom?denom=ukava`
|
||||
);
|
||||
return response.balance?.amount || '0';
|
||||
} catch {
|
||||
return '0';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询所有余额
|
||||
*/
|
||||
async getAllBalances(address: string): Promise<AccountBalance[]> {
|
||||
const response = await this.request<{ balances: Coin[] }>(
|
||||
`/cosmos/bank/v1beta1/balances/${address}`
|
||||
);
|
||||
|
||||
return (response.balances || []).map(coin => ({
|
||||
denom: coin.denom,
|
||||
amount: coin.amount,
|
||||
formatted: this.formatAmount(coin.amount, coin.denom),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询账户信息
|
||||
*/
|
||||
async getAccountInfo(address: string): Promise<AccountInfo | null> {
|
||||
try {
|
||||
const [accountResp, balances] = await Promise.all([
|
||||
this.request<{
|
||||
account: {
|
||||
'@type': string;
|
||||
address: string;
|
||||
account_number: string;
|
||||
sequence: string;
|
||||
};
|
||||
}>(`/cosmos/auth/v1beta1/accounts/${address}`),
|
||||
this.getAllBalances(address),
|
||||
]);
|
||||
|
||||
return {
|
||||
address: accountResp.account.address,
|
||||
accountNumber: parseInt(accountResp.account.account_number, 10),
|
||||
sequence: parseInt(accountResp.account.sequence, 10),
|
||||
balances,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询交易状态
|
||||
*/
|
||||
async getTxStatus(txHash: string): Promise<TxStatus> {
|
||||
try {
|
||||
const response = await this.request<{
|
||||
tx_response: {
|
||||
code: number;
|
||||
raw_log: string;
|
||||
height: string;
|
||||
gas_used: string;
|
||||
timestamp: string;
|
||||
};
|
||||
}>(`/cosmos/tx/v1beta1/txs/${txHash}`);
|
||||
|
||||
return {
|
||||
found: true,
|
||||
status: response.tx_response.code === 0 ? 'success' : 'failed',
|
||||
code: response.tx_response.code,
|
||||
rawLog: response.tx_response.raw_log,
|
||||
height: response.tx_response.height,
|
||||
gasUsed: response.tx_response.gas_used,
|
||||
timestamp: response.tx_response.timestamp,
|
||||
};
|
||||
} catch {
|
||||
return { found: false, status: 'pending' };
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 交易构建 (使用 Amino JSON)
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* 构建转账交易 (返回待签名数据)
|
||||
*/
|
||||
async buildSendTx(
|
||||
fromAddress: string,
|
||||
toAddress: string,
|
||||
amount: string,
|
||||
publicKeyHex: string,
|
||||
memo: string = ''
|
||||
): Promise<UnsignedTxData> {
|
||||
// 获取账户信息
|
||||
const accountInfo = await this.getAccountInfo(fromAddress);
|
||||
if (!accountInfo) {
|
||||
throw new Error('Account not found or has no transactions');
|
||||
}
|
||||
|
||||
const gasLimit = 100000;
|
||||
const feeAmount = Math.ceil(gasLimit * this.config.gasPrice);
|
||||
|
||||
// 构建 Amino JSON 签名文档
|
||||
const signDoc = {
|
||||
chain_id: this.config.chainId,
|
||||
account_number: accountInfo.accountNumber.toString(),
|
||||
sequence: accountInfo.sequence.toString(),
|
||||
fee: {
|
||||
amount: [{ denom: 'ukava', amount: feeAmount.toString() }],
|
||||
gas: gasLimit.toString(),
|
||||
},
|
||||
msgs: [{
|
||||
type: 'cosmos-sdk/MsgSend',
|
||||
value: {
|
||||
from_address: fromAddress,
|
||||
to_address: toAddress,
|
||||
amount: [{ denom: 'ukava', amount }],
|
||||
},
|
||||
}],
|
||||
memo,
|
||||
};
|
||||
|
||||
// 计算签名哈希 (SHA256)
|
||||
const signDocJson = JSON.stringify(sortObject(signDoc));
|
||||
const signBytes = crypto.createHash('sha256').update(signDocJson).digest();
|
||||
|
||||
// 构建交易体和认证信息 (用于广播)
|
||||
const txBody = {
|
||||
messages: [{
|
||||
'@type': '/cosmos.bank.v1beta1.MsgSend',
|
||||
from_address: fromAddress,
|
||||
to_address: toAddress,
|
||||
amount: [{ denom: 'ukava', amount }],
|
||||
}],
|
||||
memo,
|
||||
timeout_height: '0',
|
||||
extension_options: [],
|
||||
non_critical_extension_options: [],
|
||||
};
|
||||
|
||||
const authInfo = {
|
||||
signer_infos: [{
|
||||
public_key: {
|
||||
'@type': '/cosmos.crypto.secp256k1.PubKey',
|
||||
key: Buffer.from(publicKeyHex, 'hex').toString('base64'),
|
||||
},
|
||||
mode_info: { single: { mode: 'SIGN_MODE_LEGACY_AMINO_JSON' } },
|
||||
sequence: accountInfo.sequence.toString(),
|
||||
}],
|
||||
fee: {
|
||||
amount: [{ denom: 'ukava', amount: feeAmount.toString() }],
|
||||
gas_limit: gasLimit.toString(),
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
signBytes: new Uint8Array(signBytes),
|
||||
signBytesHex: signBytes.toString('hex'),
|
||||
txBodyBytes: new Uint8Array(Buffer.from(JSON.stringify(txBody))),
|
||||
authInfoBytes: new Uint8Array(Buffer.from(JSON.stringify(authInfo))),
|
||||
accountNumber: accountInfo.accountNumber,
|
||||
sequence: accountInfo.sequence,
|
||||
chainId: this.config.chainId,
|
||||
from: fromAddress,
|
||||
to: toAddress,
|
||||
amount,
|
||||
denom: 'ukava',
|
||||
memo,
|
||||
fee: feeAmount.toString(),
|
||||
gasLimit,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 完成交易 (添加签名)
|
||||
*/
|
||||
async completeTx(unsignedTx: UnsignedTxData, signatureHex: string): Promise<SignedTxData> {
|
||||
// 解析保存的交易数据
|
||||
const txBody = JSON.parse(Buffer.from(unsignedTx.txBodyBytes).toString());
|
||||
const authInfo = JSON.parse(Buffer.from(unsignedTx.authInfoBytes).toString());
|
||||
|
||||
// 构建完整的已签名交易
|
||||
const signedTx = {
|
||||
body: txBody,
|
||||
auth_info: authInfo,
|
||||
signatures: [Buffer.from(signatureHex, 'hex').toString('base64')],
|
||||
};
|
||||
|
||||
const txBytes = Buffer.from(JSON.stringify(signedTx));
|
||||
const txHash = crypto.createHash('sha256').update(txBytes).digest('hex').toUpperCase();
|
||||
|
||||
return {
|
||||
txBytes: new Uint8Array(txBytes),
|
||||
txBytesBase64: txBytes.toString('base64'),
|
||||
txHash,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 广播交易
|
||||
*/
|
||||
async broadcastTx(signedTx: SignedTxData): Promise<TxBroadcastResult> {
|
||||
try {
|
||||
const response = await this.request<{
|
||||
tx_response: {
|
||||
code: number;
|
||||
txhash: string;
|
||||
raw_log: string;
|
||||
gas_used: string;
|
||||
gas_wanted: string;
|
||||
height: string;
|
||||
};
|
||||
}>('/cosmos/tx/v1beta1/txs', 'POST', {
|
||||
tx_bytes: signedTx.txBytesBase64,
|
||||
mode: 'BROADCAST_MODE_SYNC',
|
||||
});
|
||||
|
||||
return {
|
||||
success: response.tx_response.code === 0,
|
||||
txHash: response.tx_response.txhash,
|
||||
code: response.tx_response.code,
|
||||
rawLog: response.tx_response.raw_log,
|
||||
gasUsed: response.tx_response.gas_used,
|
||||
gasWanted: response.tx_response.gas_wanted,
|
||||
height: response.tx_response.height,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
rawLog: (error as Error).message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 工具方法
|
||||
// ===========================================================================
|
||||
|
||||
formatAmount(amount: string, denom: string): string {
|
||||
if (denom === 'ukava') {
|
||||
const kava = Number(amount) / 1_000_000;
|
||||
return `${kava.toFixed(6)} KAVA`;
|
||||
}
|
||||
return `${amount} ${denom}`;
|
||||
}
|
||||
|
||||
toUkava(kava: number | string): string {
|
||||
return Math.floor(Number(kava) * 1_000_000).toString();
|
||||
}
|
||||
|
||||
fromUkava(ukava: string): number {
|
||||
return Number(ukava) / 1_000_000;
|
||||
}
|
||||
|
||||
/**
|
||||
* 断开连接 (清理资源)
|
||||
*/
|
||||
disconnect(): void {
|
||||
// 目前使用 REST API,无需特殊清理
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 辅助函数
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* 递归排序对象键 (Amino JSON 签名需要)
|
||||
*/
|
||||
function sortObject(obj: unknown): unknown {
|
||||
if (obj === null || typeof obj !== 'object') {
|
||||
return obj;
|
||||
}
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map(sortObject);
|
||||
}
|
||||
|
||||
const sortedObj: Record<string, unknown> = {};
|
||||
const keys = Object.keys(obj as Record<string, unknown>).sort();
|
||||
for (const key of keys) {
|
||||
sortedObj[key] = sortObject((obj as Record<string, unknown>)[key]);
|
||||
}
|
||||
return sortedObj;
|
||||
}
|
||||
|
||||
// 导出默认实例
|
||||
export const kavaTxService = new KavaTxService();
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue