mirror of
https://github.com/logos-blockchain/lssa.git
synced 2026-05-01 23:43:06 +00:00
Merge a3983f5a89b580fe7873b9fec98713ef5eb828b1 into 8cf73c00a9470a878f6e8d4cd2f9625d7fd02369
This commit is contained in:
commit
ca20b0e8a4
1
Cargo.lock
generated
1
Cargo.lock
generated
@ -9026,6 +9026,7 @@ dependencies = [
|
|||||||
"anyhow",
|
"anyhow",
|
||||||
"async-stream",
|
"async-stream",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
|
"bip39",
|
||||||
"borsh",
|
"borsh",
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"clap",
|
"clap",
|
||||||
|
|||||||
545
integration_tests/configs/debug/wallet/wallet_config.json
Normal file
545
integration_tests/configs/debug/wallet/wallet_config.json
Normal file
@ -0,0 +1,545 @@
|
|||||||
|
{
|
||||||
|
"sequencer_addr": "http://127.0.0.1:3040",
|
||||||
|
"seq_poll_timeout_millis": 12000,
|
||||||
|
"seq_tx_poll_max_blocks": 5,
|
||||||
|
"seq_poll_max_retries": 5,
|
||||||
|
"seq_block_poll_max_amount": 100,
|
||||||
|
"initial_accounts": [
|
||||||
|
{
|
||||||
|
"Public": {
|
||||||
|
"account_id": "BLgCRDXYdQPMMWVHYRFGQZbgeHx9frkipa8GtpG2Syqy",
|
||||||
|
"pub_sign_key": [
|
||||||
|
16,
|
||||||
|
162,
|
||||||
|
106,
|
||||||
|
154,
|
||||||
|
236,
|
||||||
|
125,
|
||||||
|
52,
|
||||||
|
184,
|
||||||
|
35,
|
||||||
|
100,
|
||||||
|
238,
|
||||||
|
174,
|
||||||
|
69,
|
||||||
|
197,
|
||||||
|
41,
|
||||||
|
77,
|
||||||
|
187,
|
||||||
|
10,
|
||||||
|
118,
|
||||||
|
75,
|
||||||
|
0,
|
||||||
|
11,
|
||||||
|
148,
|
||||||
|
238,
|
||||||
|
185,
|
||||||
|
181,
|
||||||
|
133,
|
||||||
|
17,
|
||||||
|
220,
|
||||||
|
72,
|
||||||
|
124,
|
||||||
|
77
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Public": {
|
||||||
|
"account_id": "Gj1mJy5W7J5pfmLRujmQaLfLMWidNxQ6uwnhb666ZwHw",
|
||||||
|
"pub_sign_key": [
|
||||||
|
113,
|
||||||
|
121,
|
||||||
|
64,
|
||||||
|
177,
|
||||||
|
204,
|
||||||
|
85,
|
||||||
|
229,
|
||||||
|
214,
|
||||||
|
178,
|
||||||
|
6,
|
||||||
|
109,
|
||||||
|
191,
|
||||||
|
29,
|
||||||
|
154,
|
||||||
|
63,
|
||||||
|
38,
|
||||||
|
242,
|
||||||
|
18,
|
||||||
|
244,
|
||||||
|
219,
|
||||||
|
8,
|
||||||
|
208,
|
||||||
|
35,
|
||||||
|
136,
|
||||||
|
23,
|
||||||
|
127,
|
||||||
|
207,
|
||||||
|
237,
|
||||||
|
216,
|
||||||
|
169,
|
||||||
|
190,
|
||||||
|
27
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Private": {
|
||||||
|
"account_id": "3oCG8gqdKLMegw4rRfyaMQvuPHpcASt7xwttsmnZLSkw",
|
||||||
|
"account": {
|
||||||
|
"program_owner": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"balance": 10000,
|
||||||
|
"data": [],
|
||||||
|
"nonce": 0
|
||||||
|
},
|
||||||
|
"key_chain": {
|
||||||
|
"secret_spending_key": [
|
||||||
|
251,
|
||||||
|
82,
|
||||||
|
235,
|
||||||
|
1,
|
||||||
|
146,
|
||||||
|
96,
|
||||||
|
30,
|
||||||
|
81,
|
||||||
|
162,
|
||||||
|
234,
|
||||||
|
33,
|
||||||
|
15,
|
||||||
|
123,
|
||||||
|
129,
|
||||||
|
116,
|
||||||
|
0,
|
||||||
|
84,
|
||||||
|
136,
|
||||||
|
176,
|
||||||
|
70,
|
||||||
|
190,
|
||||||
|
224,
|
||||||
|
161,
|
||||||
|
54,
|
||||||
|
134,
|
||||||
|
142,
|
||||||
|
154,
|
||||||
|
1,
|
||||||
|
18,
|
||||||
|
251,
|
||||||
|
242,
|
||||||
|
189
|
||||||
|
],
|
||||||
|
"private_key_holder": {
|
||||||
|
"nullifier_secret_key": [
|
||||||
|
29,
|
||||||
|
250,
|
||||||
|
10,
|
||||||
|
187,
|
||||||
|
35,
|
||||||
|
123,
|
||||||
|
180,
|
||||||
|
250,
|
||||||
|
246,
|
||||||
|
97,
|
||||||
|
216,
|
||||||
|
153,
|
||||||
|
44,
|
||||||
|
156,
|
||||||
|
16,
|
||||||
|
93,
|
||||||
|
241,
|
||||||
|
26,
|
||||||
|
174,
|
||||||
|
219,
|
||||||
|
72,
|
||||||
|
84,
|
||||||
|
34,
|
||||||
|
247,
|
||||||
|
112,
|
||||||
|
101,
|
||||||
|
217,
|
||||||
|
243,
|
||||||
|
189,
|
||||||
|
173,
|
||||||
|
75,
|
||||||
|
20
|
||||||
|
],
|
||||||
|
"incoming_viewing_secret_key": [
|
||||||
|
251,
|
||||||
|
201,
|
||||||
|
22,
|
||||||
|
154,
|
||||||
|
100,
|
||||||
|
165,
|
||||||
|
218,
|
||||||
|
108,
|
||||||
|
163,
|
||||||
|
190,
|
||||||
|
135,
|
||||||
|
91,
|
||||||
|
145,
|
||||||
|
84,
|
||||||
|
69,
|
||||||
|
241,
|
||||||
|
46,
|
||||||
|
117,
|
||||||
|
217,
|
||||||
|
110,
|
||||||
|
197,
|
||||||
|
248,
|
||||||
|
91,
|
||||||
|
193,
|
||||||
|
14,
|
||||||
|
104,
|
||||||
|
88,
|
||||||
|
103,
|
||||||
|
67,
|
||||||
|
153,
|
||||||
|
182,
|
||||||
|
158
|
||||||
|
],
|
||||||
|
"outgoing_viewing_secret_key": [
|
||||||
|
25,
|
||||||
|
67,
|
||||||
|
121,
|
||||||
|
76,
|
||||||
|
175,
|
||||||
|
100,
|
||||||
|
30,
|
||||||
|
198,
|
||||||
|
105,
|
||||||
|
123,
|
||||||
|
49,
|
||||||
|
169,
|
||||||
|
75,
|
||||||
|
178,
|
||||||
|
75,
|
||||||
|
210,
|
||||||
|
100,
|
||||||
|
143,
|
||||||
|
210,
|
||||||
|
243,
|
||||||
|
228,
|
||||||
|
243,
|
||||||
|
21,
|
||||||
|
18,
|
||||||
|
36,
|
||||||
|
84,
|
||||||
|
164,
|
||||||
|
186,
|
||||||
|
139,
|
||||||
|
113,
|
||||||
|
214,
|
||||||
|
12
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullifer_public_key": [
|
||||||
|
63,
|
||||||
|
202,
|
||||||
|
178,
|
||||||
|
231,
|
||||||
|
183,
|
||||||
|
82,
|
||||||
|
237,
|
||||||
|
212,
|
||||||
|
216,
|
||||||
|
221,
|
||||||
|
215,
|
||||||
|
255,
|
||||||
|
153,
|
||||||
|
101,
|
||||||
|
177,
|
||||||
|
161,
|
||||||
|
254,
|
||||||
|
210,
|
||||||
|
128,
|
||||||
|
122,
|
||||||
|
54,
|
||||||
|
190,
|
||||||
|
230,
|
||||||
|
151,
|
||||||
|
183,
|
||||||
|
64,
|
||||||
|
225,
|
||||||
|
229,
|
||||||
|
113,
|
||||||
|
1,
|
||||||
|
228,
|
||||||
|
97
|
||||||
|
],
|
||||||
|
"incoming_viewing_public_key": [
|
||||||
|
3,
|
||||||
|
235,
|
||||||
|
139,
|
||||||
|
131,
|
||||||
|
237,
|
||||||
|
177,
|
||||||
|
122,
|
||||||
|
189,
|
||||||
|
6,
|
||||||
|
177,
|
||||||
|
167,
|
||||||
|
178,
|
||||||
|
202,
|
||||||
|
117,
|
||||||
|
246,
|
||||||
|
58,
|
||||||
|
28,
|
||||||
|
65,
|
||||||
|
132,
|
||||||
|
79,
|
||||||
|
220,
|
||||||
|
139,
|
||||||
|
119,
|
||||||
|
243,
|
||||||
|
187,
|
||||||
|
160,
|
||||||
|
212,
|
||||||
|
121,
|
||||||
|
61,
|
||||||
|
247,
|
||||||
|
116,
|
||||||
|
72,
|
||||||
|
205
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Private": {
|
||||||
|
"account_id": "AKTcXgJ1xoynta1Ec7y6Jso1z1JQtHqd7aPQ1h9er6xX",
|
||||||
|
"account": {
|
||||||
|
"program_owner": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"balance": 20000,
|
||||||
|
"data": [],
|
||||||
|
"nonce": 0
|
||||||
|
},
|
||||||
|
"key_chain": {
|
||||||
|
"secret_spending_key": [
|
||||||
|
238,
|
||||||
|
171,
|
||||||
|
241,
|
||||||
|
69,
|
||||||
|
111,
|
||||||
|
217,
|
||||||
|
85,
|
||||||
|
64,
|
||||||
|
19,
|
||||||
|
82,
|
||||||
|
18,
|
||||||
|
189,
|
||||||
|
32,
|
||||||
|
91,
|
||||||
|
78,
|
||||||
|
175,
|
||||||
|
107,
|
||||||
|
7,
|
||||||
|
109,
|
||||||
|
60,
|
||||||
|
52,
|
||||||
|
44,
|
||||||
|
243,
|
||||||
|
230,
|
||||||
|
72,
|
||||||
|
244,
|
||||||
|
192,
|
||||||
|
92,
|
||||||
|
137,
|
||||||
|
33,
|
||||||
|
118,
|
||||||
|
254
|
||||||
|
],
|
||||||
|
"private_key_holder": {
|
||||||
|
"nullifier_secret_key": [
|
||||||
|
25,
|
||||||
|
211,
|
||||||
|
215,
|
||||||
|
119,
|
||||||
|
57,
|
||||||
|
223,
|
||||||
|
247,
|
||||||
|
37,
|
||||||
|
245,
|
||||||
|
144,
|
||||||
|
122,
|
||||||
|
29,
|
||||||
|
118,
|
||||||
|
245,
|
||||||
|
83,
|
||||||
|
228,
|
||||||
|
23,
|
||||||
|
9,
|
||||||
|
101,
|
||||||
|
120,
|
||||||
|
88,
|
||||||
|
33,
|
||||||
|
238,
|
||||||
|
207,
|
||||||
|
128,
|
||||||
|
61,
|
||||||
|
110,
|
||||||
|
2,
|
||||||
|
89,
|
||||||
|
62,
|
||||||
|
164,
|
||||||
|
13
|
||||||
|
],
|
||||||
|
"incoming_viewing_secret_key": [
|
||||||
|
193,
|
||||||
|
181,
|
||||||
|
14,
|
||||||
|
196,
|
||||||
|
142,
|
||||||
|
84,
|
||||||
|
15,
|
||||||
|
65,
|
||||||
|
128,
|
||||||
|
101,
|
||||||
|
70,
|
||||||
|
196,
|
||||||
|
241,
|
||||||
|
47,
|
||||||
|
130,
|
||||||
|
221,
|
||||||
|
23,
|
||||||
|
146,
|
||||||
|
161,
|
||||||
|
237,
|
||||||
|
221,
|
||||||
|
40,
|
||||||
|
19,
|
||||||
|
126,
|
||||||
|
59,
|
||||||
|
15,
|
||||||
|
169,
|
||||||
|
236,
|
||||||
|
25,
|
||||||
|
105,
|
||||||
|
104,
|
||||||
|
231
|
||||||
|
],
|
||||||
|
"outgoing_viewing_secret_key": [
|
||||||
|
20,
|
||||||
|
170,
|
||||||
|
220,
|
||||||
|
108,
|
||||||
|
41,
|
||||||
|
23,
|
||||||
|
155,
|
||||||
|
217,
|
||||||
|
247,
|
||||||
|
190,
|
||||||
|
175,
|
||||||
|
168,
|
||||||
|
247,
|
||||||
|
34,
|
||||||
|
105,
|
||||||
|
134,
|
||||||
|
114,
|
||||||
|
74,
|
||||||
|
104,
|
||||||
|
91,
|
||||||
|
211,
|
||||||
|
62,
|
||||||
|
126,
|
||||||
|
13,
|
||||||
|
130,
|
||||||
|
100,
|
||||||
|
241,
|
||||||
|
214,
|
||||||
|
250,
|
||||||
|
236,
|
||||||
|
38,
|
||||||
|
150
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullifer_public_key": [
|
||||||
|
192,
|
||||||
|
251,
|
||||||
|
166,
|
||||||
|
243,
|
||||||
|
167,
|
||||||
|
236,
|
||||||
|
84,
|
||||||
|
249,
|
||||||
|
35,
|
||||||
|
136,
|
||||||
|
130,
|
||||||
|
172,
|
||||||
|
219,
|
||||||
|
225,
|
||||||
|
161,
|
||||||
|
139,
|
||||||
|
229,
|
||||||
|
89,
|
||||||
|
243,
|
||||||
|
125,
|
||||||
|
194,
|
||||||
|
213,
|
||||||
|
209,
|
||||||
|
30,
|
||||||
|
23,
|
||||||
|
174,
|
||||||
|
100,
|
||||||
|
244,
|
||||||
|
124,
|
||||||
|
74,
|
||||||
|
140,
|
||||||
|
47
|
||||||
|
],
|
||||||
|
"incoming_viewing_public_key": [
|
||||||
|
2,
|
||||||
|
181,
|
||||||
|
98,
|
||||||
|
93,
|
||||||
|
216,
|
||||||
|
241,
|
||||||
|
241,
|
||||||
|
110,
|
||||||
|
58,
|
||||||
|
198,
|
||||||
|
119,
|
||||||
|
174,
|
||||||
|
250,
|
||||||
|
184,
|
||||||
|
1,
|
||||||
|
204,
|
||||||
|
200,
|
||||||
|
173,
|
||||||
|
44,
|
||||||
|
238,
|
||||||
|
37,
|
||||||
|
247,
|
||||||
|
170,
|
||||||
|
156,
|
||||||
|
100,
|
||||||
|
254,
|
||||||
|
116,
|
||||||
|
242,
|
||||||
|
28,
|
||||||
|
183,
|
||||||
|
187,
|
||||||
|
77,
|
||||||
|
255
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -218,7 +218,7 @@ impl TestContext {
|
|||||||
let config_overrides = WalletConfigOverrides::default();
|
let config_overrides = WalletConfigOverrides::default();
|
||||||
|
|
||||||
let wallet_password = "test_pass".to_owned();
|
let wallet_password = "test_pass".to_owned();
|
||||||
let wallet = WalletCore::new_init_storage(
|
let (wallet, _mnemonic) = WalletCore::new_init_storage(
|
||||||
config_path,
|
config_path,
|
||||||
storage_path,
|
storage_path,
|
||||||
Some(config_overrides),
|
Some(config_overrides),
|
||||||
|
|||||||
@ -205,13 +205,14 @@ fn new_wallet_rust_with_default_config(password: &str) -> WalletCore {
|
|||||||
let config_path = tempdir.path().join("wallet_config.json");
|
let config_path = tempdir.path().join("wallet_config.json");
|
||||||
let storage_path = tempdir.path().join("storage.json");
|
let storage_path = tempdir.path().join("storage.json");
|
||||||
|
|
||||||
WalletCore::new_init_storage(
|
let (core, _mnemonic) = WalletCore::new_init_storage(
|
||||||
config_path.to_path_buf(),
|
config_path.to_path_buf(),
|
||||||
storage_path.to_path_buf(),
|
storage_path.to_path_buf(),
|
||||||
None,
|
None,
|
||||||
password.to_string(),
|
password.to_string(),
|
||||||
)
|
)
|
||||||
.unwrap()
|
.unwrap();
|
||||||
|
core
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load_existing_ffi_wallet(home: &Path) -> *mut WalletHandle {
|
fn load_existing_ffi_wallet(home: &Path) -> *mut WalletHandle {
|
||||||
|
|||||||
@ -40,10 +40,10 @@ impl KeyChain {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn new_mnemonic(passphrase: String) -> Self {
|
pub fn new_mnemonic(passphrase: &str) -> (Self, bip39::Mnemonic) {
|
||||||
// Currently dropping SeedHolder at the end of initialization.
|
// Currently dropping SeedHolder at the end of initialization.
|
||||||
// Not entirely sure if we need it in the future.
|
// Not entirely sure if we need it in the future.
|
||||||
let seed_holder = SeedHolder::new_mnemonic(passphrase);
|
let (seed_holder, mnemonic) = SeedHolder::new_mnemonic(passphrase);
|
||||||
let secret_spending_key = seed_holder.produce_top_secret_key_holder();
|
let secret_spending_key = seed_holder.produce_top_secret_key_holder();
|
||||||
|
|
||||||
let private_key_holder = secret_spending_key.produce_private_key_holder(None);
|
let private_key_holder = secret_spending_key.produce_private_key_holder(None);
|
||||||
@ -51,12 +51,13 @@ impl KeyChain {
|
|||||||
let nullifer_public_key = private_key_holder.generate_nullifier_public_key();
|
let nullifer_public_key = private_key_holder.generate_nullifier_public_key();
|
||||||
let viewing_public_key = private_key_holder.generate_viewing_public_key();
|
let viewing_public_key = private_key_holder.generate_viewing_public_key();
|
||||||
|
|
||||||
Self {
|
(Self {
|
||||||
secret_spending_key,
|
secret_spending_key,
|
||||||
private_key_holder,
|
private_key_holder,
|
||||||
nullifer_public_key,
|
nullifer_public_key,
|
||||||
viewing_public_key,
|
viewing_public_key,
|
||||||
}
|
},
|
||||||
|
mnemonic)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn calculate_shared_secret_receiver(
|
pub fn calculate_shared_secret_receiver(
|
||||||
|
|||||||
@ -8,8 +8,6 @@ use rand::{RngCore, rngs::OsRng};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sha2::{Digest, digest::FixedOutput};
|
use sha2::{Digest, digest::FixedOutput};
|
||||||
|
|
||||||
const NSSA_ENTROPY_BYTES: [u8; 32] = [0; 32];
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
/// Seed holder. Non-clonable to ensure that different holders use different seeds.
|
/// Seed holder. Non-clonable to ensure that different holders use different seeds.
|
||||||
/// Produces `TopSecretKeyHolder` objects.
|
/// Produces `TopSecretKeyHolder` objects.
|
||||||
@ -46,9 +44,23 @@ impl SeedHolder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn new_mnemonic(passphrase: String) -> Self {
|
pub fn new_mnemonic(passphrase: &str) -> (Self, Mnemonic) {
|
||||||
let mnemonic = Mnemonic::from_entropy(&NSSA_ENTROPY_BYTES)
|
let mut entropy_bytes: [u8; 32] = [0; 32];
|
||||||
.expect("Enthropy must be a multiple of 32 bytes");
|
OsRng.fill_bytes(&mut entropy_bytes);
|
||||||
|
|
||||||
|
let mnemonic =
|
||||||
|
Mnemonic::from_entropy(&entropy_bytes).expect("Entropy must be a multiple of 32 bytes");
|
||||||
|
let seed_wide = mnemonic.to_seed(passphrase);
|
||||||
|
|
||||||
|
(
|
||||||
|
Self {
|
||||||
|
seed: seed_wide.to_vec(),
|
||||||
|
},
|
||||||
|
mnemonic,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_mnemonic(mnemonic: &Mnemonic, passphrase: &str) -> Self {
|
||||||
let seed_wide = mnemonic.to_seed(passphrase);
|
let seed_wide = mnemonic.to_seed(passphrase);
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
@ -163,12 +175,63 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn two_seeds_generated_same_from_same_mnemonic() {
|
fn two_seeds_recovered_same_from_same_mnemonic() {
|
||||||
let mnemonic = "test_pass";
|
let passphrase = "test_pass";
|
||||||
|
|
||||||
let seed_holder1 = SeedHolder::new_mnemonic(mnemonic.to_string());
|
// Generate a mnemonic with random entropy
|
||||||
let seed_holder2 = SeedHolder::new_mnemonic(mnemonic.to_string());
|
let (original_seed_holder, mnemonic) = SeedHolder::new_mnemonic(passphrase);
|
||||||
|
|
||||||
assert_eq!(seed_holder1.seed, seed_holder2.seed);
|
// Recover from the same mnemonic
|
||||||
|
let recovered_seed_holder = SeedHolder::from_mnemonic(&mnemonic, passphrase);
|
||||||
|
|
||||||
|
assert_eq!(original_seed_holder.seed, recovered_seed_holder.seed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_mnemonic_generates_different_seeds_each_time() {
|
||||||
|
let (seed_holder1, mnemonic1) = SeedHolder::new_mnemonic("");
|
||||||
|
let (seed_holder2, mnemonic2) = SeedHolder::new_mnemonic("");
|
||||||
|
|
||||||
|
// Different entropy should produce different mnemonics and seeds
|
||||||
|
assert_ne!(mnemonic1.to_string(), mnemonic2.to_string());
|
||||||
|
assert_ne!(seed_holder1.seed, seed_holder2.seed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_mnemonic_generates_24_word_phrase() {
|
||||||
|
let (_seed_holder, mnemonic) = SeedHolder::new_mnemonic("");
|
||||||
|
|
||||||
|
// 256 bits of entropy produces a 24-word mnemonic
|
||||||
|
let word_count = mnemonic.to_string().split_whitespace().count();
|
||||||
|
assert_eq!(word_count, 24);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_mnemonic_produces_valid_seed_length() {
|
||||||
|
let (seed_holder, _mnemonic) = SeedHolder::new_mnemonic("");
|
||||||
|
|
||||||
|
assert_eq!(seed_holder.seed.len(), 64);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn different_passphrases_produce_different_seeds() {
|
||||||
|
let (_seed_holder, mnemonic) = SeedHolder::new_mnemonic("");
|
||||||
|
|
||||||
|
let seed_with_pass_a = SeedHolder::from_mnemonic(&mnemonic, "password_a");
|
||||||
|
let seed_with_pass_b = SeedHolder::from_mnemonic(&mnemonic, "password_b");
|
||||||
|
|
||||||
|
// Same mnemonic but different passphrases should produce different seeds
|
||||||
|
assert_ne!(seed_with_pass_a.seed, seed_with_pass_b.seed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_passphrase_is_deterministic() {
|
||||||
|
let (_seed_holder, mnemonic) = SeedHolder::new_mnemonic("");
|
||||||
|
|
||||||
|
let seed1 = SeedHolder::from_mnemonic(&mnemonic, "");
|
||||||
|
let seed2 = SeedHolder::from_mnemonic(&mnemonic, "");
|
||||||
|
|
||||||
|
// Same mnemonic and passphrase should always produce the same seed
|
||||||
|
assert_eq!(seed1.seed, seed2.seed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -187,11 +187,12 @@ impl NSSAUserData {
|
|||||||
|
|
||||||
impl Default for NSSAUserData {
|
impl Default for NSSAUserData {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
|
let (seed_holder, _mnemonic) = SeedHolder::new_mnemonic("");
|
||||||
Self::new_with_accounts(
|
Self::new_with_accounts(
|
||||||
BTreeMap::new(),
|
BTreeMap::new(),
|
||||||
BTreeMap::new(),
|
BTreeMap::new(),
|
||||||
KeyTreePublic::new(&SeedHolder::new_mnemonic("default".to_string())),
|
KeyTreePublic::new(&seed_holder),
|
||||||
KeyTreePrivate::new(&SeedHolder::new_mnemonic("default".to_string())),
|
KeyTreePrivate::new(&seed_holder),
|
||||||
)
|
)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -115,7 +115,7 @@ pub unsafe extern "C" fn wallet_ffi_create_new(
|
|||||||
};
|
};
|
||||||
|
|
||||||
match WalletCore::new_init_storage(config_path, storage_path, None, password) {
|
match WalletCore::new_init_storage(config_path, storage_path, None, password) {
|
||||||
Ok(core) => {
|
Ok((core, _mnemonic)) => {
|
||||||
let wrapper = Box::new(WalletWrapper {
|
let wrapper = Box::new(WalletWrapper {
|
||||||
core: Mutex::new(core),
|
core: Mutex::new(core),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -344,6 +344,30 @@ enum WalletFfiError wallet_ffi_get_account_public(struct WalletHandle *handle,
|
|||||||
const struct FfiBytes32 *account_id,
|
const struct FfiBytes32 *account_id,
|
||||||
struct FfiAccount *out_account);
|
struct FfiAccount *out_account);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get full private account data from the local storage.
|
||||||
|
*
|
||||||
|
* # Parameters
|
||||||
|
* - `handle`: Valid wallet handle
|
||||||
|
* - `account_id`: The account ID (32 bytes)
|
||||||
|
* - `out_account`: Output pointer for account data
|
||||||
|
*
|
||||||
|
* # Returns
|
||||||
|
* - `Success` on successful query
|
||||||
|
* - Error code on failure
|
||||||
|
*
|
||||||
|
* # Memory
|
||||||
|
* The account data must be freed with `wallet_ffi_free_account_data()`.
|
||||||
|
*
|
||||||
|
* # Safety
|
||||||
|
* - `handle` must be a valid wallet handle from `wallet_ffi_create_new` or `wallet_ffi_open`
|
||||||
|
* - `account_id` must be a valid pointer to a `FfiBytes32` struct
|
||||||
|
* - `out_account` must be a valid pointer to a `FfiAccount` struct
|
||||||
|
*/
|
||||||
|
enum WalletFfiError wallet_ffi_get_account_private(struct WalletHandle *handle,
|
||||||
|
const struct FfiBytes32 *account_id,
|
||||||
|
struct FfiAccount *out_account);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Free account data returned by `wallet_ffi_get_account_public`.
|
* Free account data returned by `wallet_ffi_get_account_public`.
|
||||||
*
|
*
|
||||||
@ -546,6 +570,108 @@ enum WalletFfiError wallet_ffi_transfer_public(struct WalletHandle *handle,
|
|||||||
const uint8_t (*amount)[16],
|
const uint8_t (*amount)[16],
|
||||||
struct FfiTransferResult *out_result);
|
struct FfiTransferResult *out_result);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a shielded token transfer.
|
||||||
|
*
|
||||||
|
* Transfers tokens from a public account to a private account.
|
||||||
|
*
|
||||||
|
* # Parameters
|
||||||
|
* - `handle`: Valid wallet handle
|
||||||
|
* - `from`: Source account ID (must be owned by this wallet)
|
||||||
|
* - `to_keys`: Destination account keys
|
||||||
|
* - `amount`: Amount to transfer as little-endian [u8; 16]
|
||||||
|
* - `out_result`: Output pointer for transfer result
|
||||||
|
*
|
||||||
|
* # Returns
|
||||||
|
* - `Success` if the transfer was submitted successfully
|
||||||
|
* - `InsufficientFunds` if the source account doesn't have enough balance
|
||||||
|
* - `KeyNotFound` if the source account's signing key is not in this wallet
|
||||||
|
* - Error code on other failures
|
||||||
|
*
|
||||||
|
* # Memory
|
||||||
|
* The result must be freed with `wallet_ffi_free_transfer_result()`.
|
||||||
|
*
|
||||||
|
* # Safety
|
||||||
|
* - `handle` must be a valid wallet handle from `wallet_ffi_create_new` or `wallet_ffi_open`
|
||||||
|
* - `from` must be a valid pointer to a `FfiBytes32` struct
|
||||||
|
* - `to_keys` must be a valid pointer to a `FfiPrivateAccountKeys` struct
|
||||||
|
* - `amount` must be a valid pointer to a `[u8; 16]` array
|
||||||
|
* - `out_result` must be a valid pointer to a `FfiTransferResult` struct
|
||||||
|
*/
|
||||||
|
enum WalletFfiError wallet_ffi_transfer_shielded(struct WalletHandle *handle,
|
||||||
|
const struct FfiBytes32 *from,
|
||||||
|
const struct FfiPrivateAccountKeys *to_keys,
|
||||||
|
const uint8_t (*amount)[16],
|
||||||
|
struct FfiTransferResult *out_result);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a deshielded token transfer.
|
||||||
|
*
|
||||||
|
* Transfers tokens from a private account to a public account.
|
||||||
|
*
|
||||||
|
* # Parameters
|
||||||
|
* - `handle`: Valid wallet handle
|
||||||
|
* - `from`: Source account ID (must be owned by this wallet)
|
||||||
|
* - `to`: Destination account ID
|
||||||
|
* - `amount`: Amount to transfer as little-endian [u8; 16]
|
||||||
|
* - `out_result`: Output pointer for transfer result
|
||||||
|
*
|
||||||
|
* # Returns
|
||||||
|
* - `Success` if the transfer was submitted successfully
|
||||||
|
* - `InsufficientFunds` if the source account doesn't have enough balance
|
||||||
|
* - `KeyNotFound` if the source account's signing key is not in this wallet
|
||||||
|
* - Error code on other failures
|
||||||
|
*
|
||||||
|
* # Memory
|
||||||
|
* The result must be freed with `wallet_ffi_free_transfer_result()`.
|
||||||
|
*
|
||||||
|
* # Safety
|
||||||
|
* - `handle` must be a valid wallet handle from `wallet_ffi_create_new` or `wallet_ffi_open`
|
||||||
|
* - `from` must be a valid pointer to a `FfiBytes32` struct
|
||||||
|
* - `to` must be a valid pointer to a `FfiBytes32` struct
|
||||||
|
* - `amount` must be a valid pointer to a `[u8; 16]` array
|
||||||
|
* - `out_result` must be a valid pointer to a `FfiTransferResult` struct
|
||||||
|
*/
|
||||||
|
enum WalletFfiError wallet_ffi_transfer_deshielded(struct WalletHandle *handle,
|
||||||
|
const struct FfiBytes32 *from,
|
||||||
|
const struct FfiBytes32 *to,
|
||||||
|
const uint8_t (*amount)[16],
|
||||||
|
struct FfiTransferResult *out_result);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a private token transfer.
|
||||||
|
*
|
||||||
|
* Transfers tokens from a private account to another private account.
|
||||||
|
*
|
||||||
|
* # Parameters
|
||||||
|
* - `handle`: Valid wallet handle
|
||||||
|
* - `from`: Source account ID (must be owned by this wallet)
|
||||||
|
* - `to_keys`: Destination account keys
|
||||||
|
* - `amount`: Amount to transfer as little-endian [u8; 16]
|
||||||
|
* - `out_result`: Output pointer for transfer result
|
||||||
|
*
|
||||||
|
* # Returns
|
||||||
|
* - `Success` if the transfer was submitted successfully
|
||||||
|
* - `InsufficientFunds` if the source account doesn't have enough balance
|
||||||
|
* - `KeyNotFound` if the source account's signing key is not in this wallet
|
||||||
|
* - Error code on other failures
|
||||||
|
*
|
||||||
|
* # Memory
|
||||||
|
* The result must be freed with `wallet_ffi_free_transfer_result()`.
|
||||||
|
*
|
||||||
|
* # Safety
|
||||||
|
* - `handle` must be a valid wallet handle from `wallet_ffi_create_new` or `wallet_ffi_open`
|
||||||
|
* - `from` must be a valid pointer to a `FfiBytes32` struct
|
||||||
|
* - `to_keys` must be a valid pointer to a `FfiPrivateAccountKeys` struct
|
||||||
|
* - `amount` must be a valid pointer to a `[u8; 16]` array
|
||||||
|
* - `out_result` must be a valid pointer to a `FfiTransferResult` struct
|
||||||
|
*/
|
||||||
|
enum WalletFfiError wallet_ffi_transfer_private(struct WalletHandle *handle,
|
||||||
|
const struct FfiBytes32 *from,
|
||||||
|
const struct FfiPrivateAccountKeys *to_keys,
|
||||||
|
const uint8_t (*amount)[16],
|
||||||
|
struct FfiTransferResult *out_result);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register a public account on the network.
|
* Register a public account on the network.
|
||||||
*
|
*
|
||||||
@ -573,6 +699,33 @@ enum WalletFfiError wallet_ffi_register_public_account(struct WalletHandle *hand
|
|||||||
const struct FfiBytes32 *account_id,
|
const struct FfiBytes32 *account_id,
|
||||||
struct FfiTransferResult *out_result);
|
struct FfiTransferResult *out_result);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a private account on the network.
|
||||||
|
*
|
||||||
|
* This initializes a private account. The account must be
|
||||||
|
* owned by this wallet.
|
||||||
|
*
|
||||||
|
* # Parameters
|
||||||
|
* - `handle`: Valid wallet handle
|
||||||
|
* - `account_id`: Account ID to register
|
||||||
|
* - `out_result`: Output pointer for registration result
|
||||||
|
*
|
||||||
|
* # Returns
|
||||||
|
* - `Success` if the registration was submitted successfully
|
||||||
|
* - Error code on failure
|
||||||
|
*
|
||||||
|
* # Memory
|
||||||
|
* The result must be freed with `wallet_ffi_free_transfer_result()`.
|
||||||
|
*
|
||||||
|
* # Safety
|
||||||
|
* - `handle` must be a valid wallet handle from `wallet_ffi_create_new` or `wallet_ffi_open`
|
||||||
|
* - `account_id` must be a valid pointer to a `FfiBytes32` struct
|
||||||
|
* - `out_result` must be a valid pointer to a `FfiTransferResult` struct
|
||||||
|
*/
|
||||||
|
enum WalletFfiError wallet_ffi_register_private_account(struct WalletHandle *handle,
|
||||||
|
const struct FfiBytes32 *account_id,
|
||||||
|
struct FfiTransferResult *out_result);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Free a transfer result returned by `wallet_ffi_transfer_public` or
|
* Free a transfer result returned by `wallet_ffi_transfer_public` or
|
||||||
* `wallet_ffi_register_public_account`.
|
* `wallet_ffi_register_public_account`.
|
||||||
|
|||||||
@ -11,6 +11,7 @@ common.workspace = true
|
|||||||
key_protocol.workspace = true
|
key_protocol.workspace = true
|
||||||
token_core.workspace = true
|
token_core.workspace = true
|
||||||
amm_core.workspace = true
|
amm_core.workspace = true
|
||||||
|
bip39.workspace = true
|
||||||
|
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
use std::collections::{BTreeMap, HashMap, btree_map::Entry};
|
use std::collections::{BTreeMap, HashMap, btree_map::Entry};
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
use bip39::Mnemonic;
|
||||||
use key_protocol::{
|
use key_protocol::{
|
||||||
key_management::{
|
key_management::{
|
||||||
key_tree::{KeyTreePrivate, KeyTreePublic, chain_index::ChainIndex},
|
key_tree::{KeyTreePrivate, KeyTreePublic, chain_index::ChainIndex},
|
||||||
@ -91,7 +92,7 @@ impl WalletChainStore {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn new_storage(config: WalletConfig, password: String) -> Result<Self> {
|
pub fn new_storage(config: WalletConfig, password: String) -> Result<(Self, Mnemonic)> {
|
||||||
let mut public_init_acc_map = BTreeMap::new();
|
let mut public_init_acc_map = BTreeMap::new();
|
||||||
let mut private_init_acc_map = BTreeMap::new();
|
let mut private_init_acc_map = BTreeMap::new();
|
||||||
|
|
||||||
@ -111,13 +112,43 @@ impl WalletChainStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let public_tree = KeyTreePublic::new(&SeedHolder::new_mnemonic(password.clone()));
|
// TODO: Use password for storage encryption
|
||||||
let private_tree = KeyTreePrivate::new(&SeedHolder::new_mnemonic(password));
|
let _ = password;
|
||||||
|
let (seed_holder, mnemonic) = SeedHolder::new_mnemonic("");
|
||||||
|
let public_tree = KeyTreePublic::new(&seed_holder);
|
||||||
|
let private_tree = KeyTreePrivate::new(&seed_holder);
|
||||||
|
|
||||||
|
Ok((
|
||||||
|
Self {
|
||||||
|
user_data: NSSAUserData::new_with_accounts(
|
||||||
|
public_init_acc_map,
|
||||||
|
private_init_acc_map,
|
||||||
|
public_tree,
|
||||||
|
private_tree,
|
||||||
|
)?,
|
||||||
|
wallet_config: config,
|
||||||
|
labels: HashMap::new(),
|
||||||
|
},
|
||||||
|
mnemonic,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restore storage from an existing mnemonic phrase.
|
||||||
|
pub fn restore_storage(
|
||||||
|
config: WalletConfig,
|
||||||
|
mnemonic: &Mnemonic,
|
||||||
|
password: &str,
|
||||||
|
) -> Result<Self> {
|
||||||
|
// TODO: Use password for storage encryption
|
||||||
|
let _ = password;
|
||||||
|
let seed_holder = SeedHolder::from_mnemonic(mnemonic, "");
|
||||||
|
let public_tree = KeyTreePublic::new(&seed_holder);
|
||||||
|
let private_tree = KeyTreePrivate::new(&seed_holder);
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
user_data: NSSAUserData::new_with_accounts(
|
user_data: NSSAUserData::new_with_accounts(
|
||||||
public_init_acc_map,
|
BTreeMap::new(),
|
||||||
private_init_acc_map,
|
BTreeMap::new(),
|
||||||
public_tree,
|
public_tree,
|
||||||
private_tree,
|
private_tree,
|
||||||
)?,
|
)?,
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
use std::{io::Write, path::PathBuf};
|
use std::{io::Write, path::PathBuf, str::FromStr};
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
|
use bip39::Mnemonic;
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use common::HashType;
|
use common::HashType;
|
||||||
use nssa::{ProgramDeploymentTransaction, program::Program};
|
use nssa::{ProgramDeploymentTransaction, program::Program};
|
||||||
@ -150,8 +151,9 @@ pub async fn execute_subcommand(
|
|||||||
config_subcommand.handle_subcommand(wallet_core).await?
|
config_subcommand.handle_subcommand(wallet_core).await?
|
||||||
}
|
}
|
||||||
Command::RestoreKeys { depth } => {
|
Command::RestoreKeys { depth } => {
|
||||||
|
let mnemonic = read_mnemonic_from_stdin()?;
|
||||||
let password = read_password_from_stdin()?;
|
let password = read_password_from_stdin()?;
|
||||||
wallet_core.reset_storage(password)?;
|
wallet_core.restore_storage(&mnemonic, &password)?;
|
||||||
execute_keys_restoration(wallet_core, depth).await?;
|
execute_keys_restoration(wallet_core, depth).await?;
|
||||||
|
|
||||||
SubcommandReturnValue::Empty
|
SubcommandReturnValue::Empty
|
||||||
@ -202,6 +204,16 @@ pub fn read_password_from_stdin() -> Result<String> {
|
|||||||
Ok(password.trim().to_string())
|
Ok(password.trim().to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn read_mnemonic_from_stdin() -> Result<Mnemonic> {
|
||||||
|
let mut phrase = String::new();
|
||||||
|
|
||||||
|
print!("Input recovery phrase: ");
|
||||||
|
std::io::stdout().flush()?;
|
||||||
|
std::io::stdin().read_line(&mut phrase)?;
|
||||||
|
|
||||||
|
Mnemonic::from_str(phrase.trim()).context("Invalid mnemonic phrase")
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn execute_keys_restoration(wallet_core: &mut WalletCore, depth: u32) -> Result<()> {
|
pub async fn execute_keys_restoration(wallet_core: &mut WalletCore, depth: u32) -> Result<()> {
|
||||||
wallet_core
|
wallet_core
|
||||||
.storage
|
.storage
|
||||||
|
|||||||
@ -2,6 +2,7 @@ use std::{path::PathBuf, sync::Arc};
|
|||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
|
use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
|
||||||
|
use bip39::Mnemonic;
|
||||||
use chain_storage::WalletChainStore;
|
use chain_storage::WalletChainStore;
|
||||||
use common::{
|
use common::{
|
||||||
HashType, error::ExecutionFailureKind, rpc_primitives::requests::SendTxResponse,
|
HashType, error::ExecutionFailureKind, rpc_primitives::requests::SendTxResponse,
|
||||||
@ -87,14 +88,23 @@ impl WalletCore {
|
|||||||
storage_path: PathBuf,
|
storage_path: PathBuf,
|
||||||
config_overrides: Option<WalletConfigOverrides>,
|
config_overrides: Option<WalletConfigOverrides>,
|
||||||
password: String,
|
password: String,
|
||||||
) -> Result<Self> {
|
) -> Result<(Self, Mnemonic)> {
|
||||||
Self::new(
|
let mut mnemonic_out = None;
|
||||||
|
let wallet = Self::new(
|
||||||
config_path,
|
config_path,
|
||||||
storage_path,
|
storage_path,
|
||||||
config_overrides,
|
config_overrides,
|
||||||
|config| WalletChainStore::new_storage(config, password),
|
|config| {
|
||||||
|
let (storage, mnemonic) = WalletChainStore::new_storage(config, password)?;
|
||||||
|
mnemonic_out = Some(mnemonic);
|
||||||
|
Ok(storage)
|
||||||
|
},
|
||||||
0,
|
0,
|
||||||
)
|
)?;
|
||||||
|
Ok((
|
||||||
|
wallet,
|
||||||
|
mnemonic_out.expect("mnemonic should be set after new_storage"),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn new(
|
fn new(
|
||||||
@ -139,9 +149,13 @@ impl WalletCore {
|
|||||||
&self.storage
|
&self.storage
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reset storage
|
/// Restore storage from an existing mnemonic phrase.
|
||||||
pub fn reset_storage(&mut self, password: String) -> Result<()> {
|
pub fn restore_storage(&mut self, mnemonic: &Mnemonic, password: &str) -> Result<()> {
|
||||||
self.storage = WalletChainStore::new_storage(self.storage.wallet_config.clone(), password)?;
|
self.storage = WalletChainStore::restore_storage(
|
||||||
|
self.storage.wallet_config.clone(),
|
||||||
|
mnemonic,
|
||||||
|
password,
|
||||||
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -39,13 +39,21 @@ async fn main() -> Result<()> {
|
|||||||
println!("Persistent storage not found, need to execute setup");
|
println!("Persistent storage not found, need to execute setup");
|
||||||
|
|
||||||
let password = read_password_from_stdin()?;
|
let password = read_password_from_stdin()?;
|
||||||
let wallet = WalletCore::new_init_storage(
|
let (wallet, mnemonic) = WalletCore::new_init_storage(
|
||||||
config_path,
|
config_path,
|
||||||
storage_path,
|
storage_path,
|
||||||
Some(config_overrides),
|
Some(config_overrides),
|
||||||
password,
|
password,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
|
println!();
|
||||||
|
println!("IMPORTANT: Write down your recovery phrase and store it securely.");
|
||||||
|
println!("This is the only way to recover your wallet if you lose access.");
|
||||||
|
println!();
|
||||||
|
println!("Recovery phrase:");
|
||||||
|
println!(" {}", mnemonic);
|
||||||
|
println!();
|
||||||
|
|
||||||
wallet.store_persistent_data().await?;
|
wallet.store_persistent_data().await?;
|
||||||
wallet
|
wallet
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user