@wlfi-agent/cli 1.4.17 → 1.4.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/Cargo.lock +5 -0
  2. package/README.md +61 -28
  3. package/crates/vault-cli-admin/src/io_utils.rs +149 -1
  4. package/crates/vault-cli-admin/src/main.rs +639 -16
  5. package/crates/vault-cli-admin/src/shared_config.rs +18 -18
  6. package/crates/vault-cli-admin/src/tui/token_rpc.rs +190 -3
  7. package/crates/vault-cli-admin/src/tui/utils.rs +59 -0
  8. package/crates/vault-cli-admin/src/tui.rs +1205 -120
  9. package/crates/vault-cli-agent/Cargo.toml +1 -0
  10. package/crates/vault-cli-agent/src/io_utils.rs +163 -2
  11. package/crates/vault-cli-agent/src/main.rs +648 -32
  12. package/crates/vault-cli-daemon/Cargo.toml +4 -0
  13. package/crates/vault-cli-daemon/src/main.rs +617 -67
  14. package/crates/vault-cli-daemon/src/relay_sync.rs +776 -4
  15. package/crates/vault-cli-daemon/tests/system_keychain_helper_acl.rs +5 -0
  16. package/crates/vault-daemon/src/daemon_parts/api_impl_and_utils.rs +32 -1
  17. package/crates/vault-daemon/src/persistence.rs +637 -100
  18. package/crates/vault-daemon/src/tests.rs +1013 -3
  19. package/crates/vault-daemon/src/tests_parts/part2.rs +99 -0
  20. package/crates/vault-daemon/src/tests_parts/part4.rs +11 -7
  21. package/crates/vault-domain/src/nonce.rs +4 -0
  22. package/crates/vault-domain/src/tests.rs +616 -0
  23. package/crates/vault-policy/src/engine.rs +55 -32
  24. package/crates/vault-policy/src/tests.rs +195 -0
  25. package/crates/vault-sdk-agent/src/lib.rs +415 -22
  26. package/crates/vault-signer/Cargo.toml +3 -0
  27. package/crates/vault-signer/src/lib.rs +266 -40
  28. package/crates/vault-transport-unix/src/lib.rs +653 -5
  29. package/crates/vault-transport-xpc/src/tests.rs +531 -3
  30. package/crates/vault-transport-xpc/tests/e2e_flow.rs +3 -0
  31. package/dist/cli.cjs +663 -190
  32. package/dist/cli.cjs.map +1 -1
  33. package/package.json +5 -2
  34. package/packages/cache/.turbo/turbo-build.log +20 -20
  35. package/packages/cache/coverage/clover.xml +529 -394
  36. package/packages/cache/coverage/coverage-final.json +2 -2
  37. package/packages/cache/coverage/index.html +21 -21
  38. package/packages/cache/coverage/src/client/index.html +1 -1
  39. package/packages/cache/coverage/src/client/index.ts.html +1 -1
  40. package/packages/cache/coverage/src/errors/index.html +1 -1
  41. package/packages/cache/coverage/src/errors/index.ts.html +12 -12
  42. package/packages/cache/coverage/src/index.html +1 -1
  43. package/packages/cache/coverage/src/index.ts.html +1 -1
  44. package/packages/cache/coverage/src/service/index.html +21 -21
  45. package/packages/cache/coverage/src/service/index.ts.html +769 -313
  46. package/packages/cache/dist/{chunk-QNK6GOTI.js → chunk-KC53LH5Z.js} +35 -2
  47. package/packages/cache/dist/chunk-KC53LH5Z.js.map +1 -0
  48. package/packages/cache/dist/{chunk-QF4XKEIA.cjs → chunk-UVU7VFE3.cjs} +35 -2
  49. package/packages/cache/dist/chunk-UVU7VFE3.cjs.map +1 -0
  50. package/packages/cache/dist/index.cjs +2 -2
  51. package/packages/cache/dist/index.js +1 -1
  52. package/packages/cache/dist/service/index.cjs +2 -2
  53. package/packages/cache/dist/service/index.js +1 -1
  54. package/packages/cache/node_modules/.bin/tsc +2 -2
  55. package/packages/cache/node_modules/.bin/tsserver +2 -2
  56. package/packages/cache/node_modules/.bin/tsup +2 -2
  57. package/packages/cache/node_modules/.bin/tsup-node +2 -2
  58. package/packages/cache/node_modules/.bin/vitest +4 -4
  59. package/packages/cache/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  60. package/packages/cache/src/service/index.test.ts +165 -19
  61. package/packages/cache/src/service/index.ts +38 -1
  62. package/packages/config/.turbo/turbo-build.log +4 -4
  63. package/packages/config/dist/index.cjs +0 -17
  64. package/packages/config/dist/index.cjs.map +1 -1
  65. package/packages/config/src/index.ts +0 -17
  66. package/packages/rpc/.turbo/turbo-build.log +11 -11
  67. package/packages/rpc/dist/index.cjs +0 -17
  68. package/packages/rpc/dist/index.cjs.map +1 -1
  69. package/packages/rpc/src/index.js +1 -0
  70. package/packages/ui/node_modules/.bin/tsc +2 -2
  71. package/packages/ui/node_modules/.bin/tsserver +2 -2
  72. package/packages/ui/node_modules/.bin/tsup +2 -2
  73. package/packages/ui/node_modules/.bin/tsup-node +2 -2
  74. package/scripts/install-cli-launcher.mjs +37 -0
  75. package/scripts/install-rust-binaries.mjs +47 -0
  76. package/scripts/run-tests-isolated.mjs +210 -0
  77. package/src/cli.ts +310 -50
  78. package/src/lib/admin-reset.ts +15 -30
  79. package/src/lib/admin-setup.ts +246 -55
  80. package/src/lib/agent-auth-migrate.ts +5 -1
  81. package/src/lib/asset-broadcast.ts +15 -4
  82. package/src/lib/config-amounts.ts +6 -4
  83. package/src/lib/hidden-tty-prompt.js +1 -0
  84. package/src/lib/hidden-tty-prompt.ts +105 -0
  85. package/src/lib/keychain.ts +1 -0
  86. package/src/lib/local-admin-access.ts +4 -29
  87. package/src/lib/rust.ts +129 -33
  88. package/src/lib/signed-tx.ts +1 -0
  89. package/src/lib/sudo.ts +15 -5
  90. package/src/lib/wallet-profile.ts +3 -0
  91. package/src/lib/wallet-setup.ts +52 -0
  92. package/packages/cache/dist/chunk-QF4XKEIA.cjs.map +0 -1
  93. package/packages/cache/dist/chunk-QNK6GOTI.js.map +0 -1
@@ -4,7 +4,10 @@ use std::path::PathBuf;
4
4
  use std::time::Duration;
5
5
 
6
6
  use anyhow::{anyhow, bail, Context, Result};
7
- use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
7
+ use crossterm::event::{
8
+ self, DisableBracketedPaste, EnableBracketedPaste, Event, KeyCode, KeyEvent, KeyEventKind,
9
+ KeyModifiers,
10
+ };
8
11
  use crossterm::execute;
9
12
  use crossterm::terminal::{
10
13
  disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
@@ -132,14 +135,15 @@ enum PendingDiscardAction {
132
135
  NewNetworkDraft,
133
136
  CycleSavedToken(i8),
134
137
  CycleSavedNetwork(i8),
138
+ Cancel,
135
139
  }
136
140
 
137
- #[derive(Debug, Clone, Copy, PartialEq, Eq)]
141
+ #[derive(Debug, Clone, PartialEq, Eq)]
138
142
  enum PendingDeleteAction {
139
- DeleteDestinationOverride,
140
- DeleteManualApproval,
141
- DeleteToken,
142
- DeleteNetwork,
143
+ DeleteDestinationOverride(usize),
144
+ DeleteManualApproval(usize),
145
+ DeleteToken(String),
146
+ DeleteNetwork(String),
143
147
  }
144
148
 
145
149
  #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -212,6 +216,25 @@ enum Field {
212
216
  Execute,
213
217
  }
214
218
 
219
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
220
+ enum FieldInteraction {
221
+ Edit,
222
+ Select,
223
+ Action,
224
+ ReadOnly,
225
+ }
226
+
227
+ impl FieldInteraction {
228
+ fn badge(self) -> &'static str {
229
+ match self {
230
+ Self::Edit => "[E]",
231
+ Self::Select => "[S]",
232
+ Self::Action => "[A]",
233
+ Self::ReadOnly => "[R]",
234
+ }
235
+ }
236
+ }
237
+
215
238
  #[derive(Debug, Clone, Default, PartialEq, Eq)]
216
239
  struct LimitDraft {
217
240
  per_tx_limit: String,
@@ -906,6 +929,16 @@ impl AppState {
906
929
  self.visible_fields()[self.selected]
907
930
  }
908
931
 
932
+ fn select_field(&mut self, target: Field) {
933
+ if let Some(index) = self
934
+ .visible_fields()
935
+ .iter()
936
+ .position(|field| *field == target)
937
+ {
938
+ self.selected = index;
939
+ }
940
+ }
941
+
909
942
  fn active_draft_dirty(&self) -> bool {
910
943
  match self.view {
911
944
  View::Tokens => self.token_dirty,
@@ -983,7 +1016,7 @@ impl AppState {
983
1016
  }
984
1017
 
985
1018
  fn confirm_delete(&mut self, action: PendingDeleteAction, label: &str) -> bool {
986
- if self.pending_delete_action == Some(action) {
1019
+ if self.pending_delete_action == Some(action.clone()) {
987
1020
  self.clear_pending_delete();
988
1021
  return true;
989
1022
  }
@@ -1080,6 +1113,10 @@ impl AppState {
1080
1113
  }
1081
1114
  }
1082
1115
 
1116
+ fn request_cancel(&mut self) -> bool {
1117
+ self.confirm_discard(PendingDiscardAction::Cancel)
1118
+ }
1119
+
1083
1120
  fn load_token_draft(&mut self, token_key: Option<&str>) {
1084
1121
  self.token_draft = token_key
1085
1122
  .and_then(|token_key| {
@@ -1159,6 +1196,7 @@ impl AppState {
1159
1196
  self.network_dirty = false;
1160
1197
  self.clear_pending_discard();
1161
1198
  self.clear_pending_delete();
1199
+ self.select_field(Field::ChainConfigKey);
1162
1200
  self.set_success_message("new network draft ready");
1163
1201
  }
1164
1202
 
@@ -1167,10 +1205,9 @@ impl AppState {
1167
1205
  self.delete_destination_override();
1168
1206
  return;
1169
1207
  }
1170
- if self.confirm_delete(
1171
- PendingDeleteAction::DeleteDestinationOverride,
1172
- "the selected destination override",
1173
- ) {
1208
+ let target =
1209
+ PendingDeleteAction::DeleteDestinationOverride(self.token_draft.selected_override);
1210
+ if self.confirm_delete(target, "the selected destination override") {
1174
1211
  self.delete_destination_override();
1175
1212
  }
1176
1213
  }
@@ -1180,33 +1217,34 @@ impl AppState {
1180
1217
  self.delete_manual_approval();
1181
1218
  return;
1182
1219
  }
1183
- if self.confirm_delete(
1184
- PendingDeleteAction::DeleteManualApproval,
1185
- "the selected manual approval policy",
1186
- ) {
1220
+ let target =
1221
+ PendingDeleteAction::DeleteManualApproval(self.token_draft.selected_manual_approval);
1222
+ if self.confirm_delete(target, "the selected manual approval policy") {
1187
1223
  self.delete_manual_approval();
1188
1224
  }
1189
1225
  }
1190
1226
 
1191
1227
  fn request_delete_token(&mut self) -> Result<()> {
1192
- let has_candidate = self.token_draft.source_key.is_some()
1193
- || !self.token_draft.key.trim().is_empty();
1228
+ let has_candidate =
1229
+ self.token_draft.source_key.is_some() || !self.token_draft.key.trim().is_empty();
1194
1230
  if !has_candidate {
1195
1231
  return self.delete_token_config();
1196
1232
  }
1197
- if self.confirm_delete(PendingDeleteAction::DeleteToken, "the selected token") {
1233
+ let target = PendingDeleteAction::DeleteToken(self.pending_token_delete_key()?);
1234
+ if self.confirm_delete(target, "the selected token") {
1198
1235
  return self.delete_token_config();
1199
1236
  }
1200
1237
  Ok(())
1201
1238
  }
1202
1239
 
1203
1240
  fn request_delete_network(&mut self) -> Result<()> {
1204
- let has_candidate = self.network_draft.source_key.is_some()
1205
- || !self.network_draft.key.trim().is_empty();
1241
+ let has_candidate =
1242
+ self.network_draft.source_key.is_some() || !self.network_draft.key.trim().is_empty();
1206
1243
  if !has_candidate {
1207
1244
  return self.delete_network_config();
1208
1245
  }
1209
- if self.confirm_delete(PendingDeleteAction::DeleteNetwork, "the selected network") {
1246
+ let target = PendingDeleteAction::DeleteNetwork(self.pending_network_delete_key()?);
1247
+ if self.confirm_delete(target, "the selected network") {
1210
1248
  return self.delete_network_config();
1211
1249
  }
1212
1250
  Ok(())
@@ -1227,6 +1265,7 @@ impl AppState {
1227
1265
  }
1228
1266
  }
1229
1267
  Field::SelectedDestinationOverride => {
1268
+ self.clear_pending_delete();
1230
1269
  cycle_index(
1231
1270
  &mut self.token_draft.selected_override,
1232
1271
  self.token_draft.destination_overrides.len(),
@@ -1234,6 +1273,7 @@ impl AppState {
1234
1273
  );
1235
1274
  }
1236
1275
  Field::SelectedManualApproval => {
1276
+ self.clear_pending_delete();
1237
1277
  cycle_index(
1238
1278
  &mut self.token_draft.selected_manual_approval,
1239
1279
  self.token_draft.manual_approvals.len(),
@@ -1335,9 +1375,8 @@ impl AppState {
1335
1375
  );
1336
1376
  }
1337
1377
 
1338
- fn edit_selected(&mut self, key: KeyEvent) {
1339
- let selected_field = self.selected_field();
1340
- let target = match selected_field {
1378
+ fn selected_text_field_mut(&mut self, selected_field: Field) -> Option<&mut String> {
1379
+ match selected_field {
1341
1380
  Field::TokenKey => Some(&mut self.token_draft.key),
1342
1381
  Field::NetworkAddress => self
1343
1382
  .token_draft
@@ -1427,34 +1466,38 @@ impl AppState {
1427
1466
  Field::ChainConfigName => Some(&mut self.network_draft.name),
1428
1467
  Field::ChainConfigRpcUrl => Some(&mut self.network_draft.rpc_url),
1429
1468
  _ => None,
1430
- };
1431
-
1432
- let Some(target) = target else {
1433
- return;
1434
- };
1469
+ }
1470
+ }
1435
1471
 
1472
+ fn edit_selected(&mut self, key: KeyEvent) {
1473
+ let selected_field = self.selected_field();
1436
1474
  match key.code {
1437
1475
  KeyCode::Backspace => {
1438
- target.pop();
1439
- if field_uses_network_draft(selected_field) {
1440
- self.mark_network_dirty();
1476
+ let changed = if let Some(target) = self.selected_text_field_mut(selected_field) {
1477
+ target.pop();
1478
+ true
1441
1479
  } else {
1442
- self.mark_token_dirty();
1480
+ false
1481
+ };
1482
+ if changed {
1483
+ self.record_selected_text_change(selected_field);
1443
1484
  }
1444
- self.clear_message();
1445
1485
  }
1446
1486
  KeyCode::Char(ch)
1447
1487
  if !key.modifiers.contains(KeyModifiers::CONTROL)
1448
1488
  && !key.modifiers.contains(KeyModifiers::ALT) =>
1449
1489
  {
1450
1490
  if is_allowed_input_char(selected_field, ch) {
1451
- target.push(ch);
1452
- if field_uses_network_draft(selected_field) {
1453
- self.mark_network_dirty();
1454
- } else {
1455
- self.mark_token_dirty();
1491
+ let changed =
1492
+ if let Some(target) = self.selected_text_field_mut(selected_field) {
1493
+ target.push(ch);
1494
+ true
1495
+ } else {
1496
+ false
1497
+ };
1498
+ if changed {
1499
+ self.record_selected_text_change(selected_field);
1456
1500
  }
1457
- self.clear_message();
1458
1501
  } else {
1459
1502
  self.set_error_message(format!(
1460
1503
  "invalid character '{ch}' for the selected field"
@@ -1465,6 +1508,48 @@ impl AppState {
1465
1508
  }
1466
1509
  }
1467
1510
 
1511
+ fn record_selected_text_change(&mut self, selected_field: Field) {
1512
+ if field_uses_network_draft(selected_field) {
1513
+ self.mark_network_dirty();
1514
+ } else {
1515
+ self.mark_token_dirty();
1516
+ }
1517
+ self.clear_message();
1518
+ }
1519
+
1520
+ fn paste_selected(&mut self, pasted: &str) {
1521
+ let selected_field = self.selected_field();
1522
+ let sanitized = pasted
1523
+ .trim()
1524
+ .chars()
1525
+ .filter(|ch| !matches!(ch, '\r' | '\n' | '\t'))
1526
+ .collect::<String>();
1527
+ let sanitized = sanitized.trim();
1528
+ if sanitized.is_empty() {
1529
+ return;
1530
+ }
1531
+
1532
+ if let Some(invalid) = sanitized
1533
+ .chars()
1534
+ .find(|ch| !is_allowed_input_char(selected_field, *ch))
1535
+ {
1536
+ self.set_error_message(format!(
1537
+ "invalid character '{invalid}' for the selected field"
1538
+ ));
1539
+ return;
1540
+ }
1541
+
1542
+ let changed = if let Some(target) = self.selected_text_field_mut(selected_field) {
1543
+ target.push_str(sanitized);
1544
+ true
1545
+ } else {
1546
+ false
1547
+ };
1548
+ if changed {
1549
+ self.record_selected_text_change(selected_field);
1550
+ }
1551
+ }
1552
+
1468
1553
  fn add_destination_override(&mut self) {
1469
1554
  self.token_draft
1470
1555
  .destination_overrides
@@ -1696,6 +1781,28 @@ impl AppState {
1696
1781
  Ok(())
1697
1782
  }
1698
1783
 
1784
+ fn pending_token_delete_key(&self) -> Result<String> {
1785
+ self.token_draft
1786
+ .source_key
1787
+ .clone()
1788
+ .or_else(|| {
1789
+ let trimmed = self.token_draft.key.trim().to_lowercase();
1790
+ (!trimmed.is_empty()).then_some(trimmed)
1791
+ })
1792
+ .context("no saved token is selected")
1793
+ }
1794
+
1795
+ fn pending_network_delete_key(&self) -> Result<String> {
1796
+ self.network_draft
1797
+ .source_key
1798
+ .clone()
1799
+ .or_else(|| {
1800
+ let trimmed = self.network_draft.key.trim().to_lowercase();
1801
+ (!trimmed.is_empty()).then_some(trimmed)
1802
+ })
1803
+ .context("no saved network is selected")
1804
+ }
1805
+
1699
1806
  fn build_params(&self) -> Result<BootstrapParams> {
1700
1807
  build_bootstrap_params_from_shared_config(
1701
1808
  &self.shared_config_draft,
@@ -1808,6 +1915,7 @@ enum LoopAction {
1808
1915
  Continue,
1809
1916
  RefreshTokenMetadata,
1810
1917
  SaveTokenAndApply,
1918
+ SaveNetworkAndApply,
1811
1919
  ApplyAndExit(Box<BootstrapParams>),
1812
1920
  Cancel,
1813
1921
  }
@@ -1819,7 +1927,8 @@ pub(crate) fn run_bootstrap_tui<T>(
1819
1927
  ) -> Result<Option<T>> {
1820
1928
  enable_raw_mode().context("failed to enable raw mode")?;
1821
1929
  let mut stdout = io::stdout();
1822
- execute!(stdout, EnterAlternateScreen).context("failed to enter alternate screen")?;
1930
+ execute!(stdout, EnterAlternateScreen, EnableBracketedPaste)
1931
+ .context("failed to enter alternate screen")?;
1823
1932
  let backend = CrosstermBackend::new(stdout);
1824
1933
  let mut terminal = Terminal::new(backend).context("failed to initialize terminal backend")?;
1825
1934
 
@@ -1839,7 +1948,7 @@ pub(crate) fn run_bootstrap_tui<T>(
1839
1948
 
1840
1949
  fn cleanup_terminal(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
1841
1950
  disable_raw_mode().context("failed to disable raw mode")?;
1842
- execute!(terminal.backend_mut(), LeaveAlternateScreen)
1951
+ execute!(terminal.backend_mut(), DisableBracketedPaste, LeaveAlternateScreen)
1843
1952
  .context("failed to leave alternate screen")?;
1844
1953
  terminal.show_cursor().context("failed to show cursor")?;
1845
1954
  Ok(())
@@ -1893,9 +2002,27 @@ fn run_save_token_and_apply<T, B: Backend>(
1893
2002
  .clone()
1894
2003
  .unwrap_or_else(|| "saved token".to_string());
1895
2004
 
1896
- let params = app.build_params().map_err(|err| {
1897
- anyhow!("{success_message} but failed to apply to wallet: {err}")
1898
- })?;
2005
+ let params = app
2006
+ .build_params()
2007
+ .map_err(|err| anyhow!("{success_message} but failed to apply to wallet: {err}"))?;
2008
+ let output = apply_with_progress(terminal, app, params, on_apply)?;
2009
+ Ok((output, format!("{success_message} and applied to wallet")))
2010
+ }
2011
+
2012
+ fn run_save_network_and_apply<T, B: Backend>(
2013
+ terminal: &mut Terminal<B>,
2014
+ app: &mut AppState,
2015
+ on_apply: &mut impl FnMut(BootstrapParams, &mut dyn FnMut(&str) -> Result<()>) -> Result<T>,
2016
+ ) -> Result<(T, String)> {
2017
+ app.save_network_config()?;
2018
+ let success_message = app
2019
+ .message
2020
+ .clone()
2021
+ .unwrap_or_else(|| "saved network".to_string());
2022
+
2023
+ let params = app
2024
+ .build_params()
2025
+ .map_err(|err| anyhow!("{success_message} but failed to apply to wallet: {err}"))?;
1899
2026
  let output = apply_with_progress(terminal, app, params, on_apply)?;
1900
2027
  Ok((output, format!("{success_message} and applied to wallet")))
1901
2028
  }
@@ -1914,51 +2041,70 @@ fn run_event_loop<T, B: Backend>(
1914
2041
  continue;
1915
2042
  }
1916
2043
 
1917
- let Event::Key(key) = event::read().context("failed to read terminal event")? else {
1918
- continue;
1919
- };
1920
- if key.kind != KeyEventKind::Press {
1921
- continue;
1922
- }
1923
-
1924
- match handle_key_event(&mut app, key)? {
2044
+ match handle_terminal_event(
2045
+ &mut app,
2046
+ event::read().context("failed to read terminal event")?,
2047
+ )? {
1925
2048
  LoopAction::Continue => {}
1926
2049
  LoopAction::RefreshTokenMetadata => {
1927
2050
  if let Err(err) = refresh_metadata_with_progress(terminal, &mut app) {
1928
2051
  app.set_error_message(err.to_string());
1929
2052
  }
1930
2053
  }
1931
- LoopAction::SaveTokenAndApply => match run_save_token_and_apply(
1932
- terminal,
1933
- &mut app,
1934
- &mut on_apply,
1935
- ) {
1936
- Ok((output, success_message)) => {
1937
- last_output = Some(output);
1938
- app.set_success_message(success_message);
2054
+ LoopAction::SaveTokenAndApply => {
2055
+ match run_save_token_and_apply(terminal, &mut app, &mut on_apply) {
2056
+ Ok((output, success_message)) => {
2057
+ last_output = Some(output);
2058
+ app.set_success_message(success_message);
2059
+ }
2060
+ Err(err) => {
2061
+ app.set_error_message(err.to_string());
2062
+ }
1939
2063
  }
1940
- Err(err) => {
1941
- app.set_error_message(err.to_string());
2064
+ }
2065
+ LoopAction::SaveNetworkAndApply => {
2066
+ match run_save_network_and_apply(terminal, &mut app, &mut on_apply) {
2067
+ Ok((output, success_message)) => {
2068
+ last_output = Some(output);
2069
+ app.set_success_message(success_message);
2070
+ }
2071
+ Err(err) => {
2072
+ app.set_error_message(err.to_string());
2073
+ }
1942
2074
  }
1943
- },
1944
- LoopAction::ApplyAndExit(params) => match apply_with_progress(
1945
- terminal,
1946
- &mut app,
1947
- *params,
1948
- &mut on_apply,
1949
- ) {
1950
- Ok(output) => return Ok(Some(output)),
1951
- Err(err) => {
1952
- app.set_error_message(err.to_string());
2075
+ }
2076
+ LoopAction::ApplyAndExit(params) => {
2077
+ match apply_with_progress(terminal, &mut app, *params, &mut on_apply) {
2078
+ Ok(output) => return Ok(Some(output)),
2079
+ Err(err) => {
2080
+ app.set_error_message(err.to_string());
2081
+ }
1953
2082
  }
1954
- },
2083
+ }
1955
2084
  LoopAction::Cancel => return Ok(last_output),
1956
2085
  }
1957
2086
  }
1958
2087
  }
1959
2088
 
2089
+ fn handle_terminal_event(app: &mut AppState, event: Event) -> Result<LoopAction> {
2090
+ match event {
2091
+ Event::Key(key) if key.kind == KeyEventKind::Press => handle_key_event(app, key),
2092
+ Event::Paste(data) => {
2093
+ app.paste_selected(&data);
2094
+ Ok(LoopAction::Continue)
2095
+ }
2096
+ _ => Ok(LoopAction::Continue),
2097
+ }
2098
+ }
2099
+
1960
2100
  fn handle_key_event(app: &mut AppState, key: KeyEvent) -> Result<LoopAction> {
1961
2101
  if key.code == KeyCode::Char('s') && key.modifiers.contains(KeyModifiers::CONTROL) {
2102
+ if app.view == View::Tokens && app.token_dirty {
2103
+ return Ok(LoopAction::SaveTokenAndApply);
2104
+ }
2105
+ if app.view == View::Networks && app.network_dirty {
2106
+ return Ok(LoopAction::SaveNetworkAndApply);
2107
+ }
1962
2108
  match app.build_params() {
1963
2109
  Ok(params) => return Ok(LoopAction::ApplyAndExit(Box::new(params))),
1964
2110
  Err(err) => {
@@ -1994,12 +2140,41 @@ fn handle_key_event(app: &mut AppState, key: KeyEvent) -> Result<LoopAction> {
1994
2140
  return Ok(LoopAction::Continue);
1995
2141
  }
1996
2142
 
2143
+ if field_interaction(app.selected_field()) == FieldInteraction::Edit
2144
+ && !key.modifiers.contains(KeyModifiers::CONTROL)
2145
+ && !key.modifiers.contains(KeyModifiers::ALT)
2146
+ {
2147
+ match key.code {
2148
+ KeyCode::Char(_) | KeyCode::Backspace => {
2149
+ app.edit_selected(key);
2150
+ return Ok(LoopAction::Continue);
2151
+ }
2152
+ _ => {}
2153
+ }
2154
+ }
2155
+
1997
2156
  match key.code {
1998
- KeyCode::Esc => return Ok(LoopAction::Cancel),
2157
+ KeyCode::Esc => {
2158
+ return Ok(if app.request_cancel() {
2159
+ LoopAction::Cancel
2160
+ } else {
2161
+ LoopAction::Continue
2162
+ });
2163
+ }
1999
2164
  KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
2000
- return Ok(LoopAction::Cancel);
2165
+ return Ok(if app.request_cancel() {
2166
+ LoopAction::Cancel
2167
+ } else {
2168
+ LoopAction::Continue
2169
+ });
2170
+ }
2171
+ KeyCode::Char('q') if key.modifiers.is_empty() => {
2172
+ return Ok(if app.request_cancel() {
2173
+ LoopAction::Cancel
2174
+ } else {
2175
+ LoopAction::Continue
2176
+ });
2001
2177
  }
2002
- KeyCode::Char('q') if key.modifiers.is_empty() => return Ok(LoopAction::Cancel),
2003
2178
  KeyCode::Down | KeyCode::Char('j') => {
2004
2179
  app.select_next();
2005
2180
  app.clear_message();
@@ -2210,7 +2385,7 @@ fn draw_ui(frame: &mut ratatui::Frame<'_>, app: &AppState) {
2210
2385
  Style::default()
2211
2386
  };
2212
2387
  ListItem::new(Line::from(vec![
2213
- Span::styled(field_label(*field), style),
2388
+ Span::styled(field_display_label(*field), style),
2214
2389
  Span::styled(": ", style),
2215
2390
  Span::styled(field_value(app, *field), style),
2216
2391
  ]))
@@ -2258,11 +2433,12 @@ fn status_message_style(app: &AppState) -> Style {
2258
2433
 
2259
2434
  fn build_help_lines(view: View) -> Vec<Line<'static>> {
2260
2435
  let mut lines = vec![
2436
+ Line::from("Legend: [E] edit [S] select/toggle [A] Enter [R] read-only"),
2261
2437
  Line::from("Views: Tab/Shift+Tab or ]/["),
2262
- Line::from("Fields: ↑/↓ j/k Home End"),
2263
- Line::from("Toggle/cycle: ←/→ h/l Space"),
2264
- Line::from("Actions: Enter"),
2438
+ Line::from("Move: ↑/↓ j/k Home End"),
2439
+ Line::from("Use: ←/→ h/l Space Enter"),
2265
2440
  Line::from("Drafts: Ctrl+N new, Ctrl+R reload, Ctrl+S bootstrap"),
2441
+ Line::from("Paste: terminal paste appends into editable fields."),
2266
2442
  ];
2267
2443
  match view {
2268
2444
  View::Tokens => {
@@ -2270,10 +2446,10 @@ fn build_help_lines(view: View) -> Vec<Line<'static>> {
2270
2446
  "Tokens: Ctrl+O add override, Ctrl+M add manual approval",
2271
2447
  ));
2272
2448
  lines.push(Line::from(
2273
- "Token name, symbol, and decimals come from RPC metadata refresh.",
2449
+ "RPC fields are [R]; use [A] Fetch Metadata to refresh them.",
2274
2450
  ));
2275
2451
  lines.push(Line::from(
2276
- "Advanced exposes gas, fee, tx count, and calldata limits.",
2452
+ "Advanced [S] exposes gas, fee, tx count, and calldata limits.",
2277
2453
  ));
2278
2454
  }
2279
2455
  View::Networks => {
@@ -2488,6 +2664,66 @@ fn field_label(field: Field) -> &'static str {
2488
2664
  }
2489
2665
  }
2490
2666
 
2667
+ fn field_interaction(field: Field) -> FieldInteraction {
2668
+ match field {
2669
+ Field::SelectedToken
2670
+ | Field::NetworkMembership
2671
+ | Field::EditingNetwork
2672
+ | Field::NetworkIsNative
2673
+ | Field::ShowAdvanced
2674
+ | Field::SelectedDestinationOverride
2675
+ | Field::SelectedManualApproval
2676
+ | Field::SelectedNetwork
2677
+ | Field::ChainConfigUseAsActive => FieldInteraction::Select,
2678
+ Field::TokenKey
2679
+ | Field::NetworkAddress
2680
+ | Field::PerTxLimit
2681
+ | Field::DailyLimit
2682
+ | Field::WeeklyLimit
2683
+ | Field::MaxGasPerChainWei
2684
+ | Field::DailyMaxTxCount
2685
+ | Field::PerTxMaxFeePerGasGwei
2686
+ | Field::PerTxMaxPriorityFeePerGasWei
2687
+ | Field::PerTxMaxCalldataBytes
2688
+ | Field::OverrideRecipientAddress
2689
+ | Field::OverridePerTxLimit
2690
+ | Field::OverrideDailyLimit
2691
+ | Field::OverrideWeeklyLimit
2692
+ | Field::OverrideMaxGasPerChainWei
2693
+ | Field::OverrideDailyMaxTxCount
2694
+ | Field::OverridePerTxMaxFeePerGasGwei
2695
+ | Field::OverridePerTxMaxPriorityFeePerGasWei
2696
+ | Field::OverridePerTxMaxCalldataBytes
2697
+ | Field::ManualApprovalRecipientAddress
2698
+ | Field::ManualApprovalMinAmount
2699
+ | Field::ManualApprovalMaxAmount
2700
+ | Field::ManualApprovalPriority
2701
+ | Field::ChainConfigKey
2702
+ | Field::ChainConfigId
2703
+ | Field::ChainConfigName
2704
+ | Field::ChainConfigRpcUrl => FieldInteraction::Edit,
2705
+ Field::RefreshTokenMetadata
2706
+ | Field::DestinationOverrides
2707
+ | Field::DeleteDestinationOverride
2708
+ | Field::ManualApprovals
2709
+ | Field::DeleteManualApproval
2710
+ | Field::SaveToken
2711
+ | Field::DeleteToken
2712
+ | Field::SaveNetwork
2713
+ | Field::DeleteNetwork
2714
+ | Field::Execute => FieldInteraction::Action,
2715
+ Field::TokenName | Field::TokenSymbol | Field::TokenDecimals => FieldInteraction::ReadOnly,
2716
+ }
2717
+ }
2718
+
2719
+ fn field_display_label(field: Field) -> String {
2720
+ format!(
2721
+ "{} {}",
2722
+ field_interaction(field).badge(),
2723
+ field_label(field)
2724
+ )
2725
+ }
2726
+
2491
2727
  fn field_value(app: &AppState, field: Field) -> String {
2492
2728
  match field {
2493
2729
  Field::SelectedToken => app
@@ -3402,10 +3638,18 @@ fn validate_optional_overlay_limit(
3402
3638
  mod tests {
3403
3639
  use super::{
3404
3640
  apply_with_progress, build_bootstrap_panel_lines, build_token_panel_lines,
3405
- handle_key_event, resolve_all_token_manual_approval_policies, resolve_all_token_policies,
3406
- status_message_style, AppState, ChainProfile, Field, KeyCode, KeyEvent, KeyModifiers,
3407
- LoopAction, MessageLevel, TokenChainProfile, TokenDestinationOverrideProfile,
3408
- TokenManualApprovalProfile, TokenPolicyProfile, TokenProfile, View, WlfiConfig,
3641
+ handle_key_event, handle_terminal_event, resolve_all_token_manual_approval_policies,
3642
+ resolve_all_token_policies, resolve_gwei_or_wei_or_default,
3643
+ resolve_optional_gwei_or_wei, resolve_optional_policy_value,
3644
+ resolve_optional_policy_value_or_default, resolve_policy_amount_or_default,
3645
+ resolve_required_policy_amount, run_save_network_and_apply, run_save_token_and_apply,
3646
+ status_message_style, validate_destination_override_overlay,
3647
+ validate_optional_overlay_limit, validate_overlay_limit, AppState, ChainProfile, Event,
3648
+ Field, KeyCode, KeyEvent, KeyModifiers, LoopAction, ManualApprovalDraft, MessageLevel,
3649
+ NetworkDraft, PendingDeleteAction, PendingDiscardAction, ResolvedLimitFields,
3650
+ TokenChainProfile, TokenDestinationOverrideProfile, TokenDraft,
3651
+ TokenManualApprovalProfile, TokenNetworkDraft, TokenPolicyProfile, TokenProfile, View,
3652
+ WlfiConfig,
3409
3653
  };
3410
3654
  use crate::shared_config::WalletProfile;
3411
3655
  use ratatui::{backend::TestBackend, style::Color, Terminal};
@@ -3454,28 +3698,28 @@ mod tests {
3454
3698
  fn sample_config() -> WlfiConfig {
3455
3699
  let mut config = empty_config();
3456
3700
  config.chains.insert(
3457
- "sepolia".to_string(),
3701
+ "eth".to_string(),
3458
3702
  ChainProfile {
3459
- chain_id: 11155111,
3460
- name: "sepolia".to_string(),
3461
- rpc_url: Some("https://rpc.sepolia.example".to_string()),
3703
+ chain_id: 1,
3704
+ name: "eth".to_string(),
3705
+ rpc_url: Some("https://rpc.ethereum.example".to_string()),
3462
3706
  extra: BTreeMap::new(),
3463
3707
  },
3464
3708
  );
3465
3709
  config.chains.insert(
3466
- "base".to_string(),
3710
+ "bsc".to_string(),
3467
3711
  ChainProfile {
3468
- chain_id: 8453,
3469
- name: "base".to_string(),
3470
- rpc_url: Some("https://mainnet.base.example".to_string()),
3712
+ chain_id: 56,
3713
+ name: "bsc".to_string(),
3714
+ rpc_url: Some("https://rpc.bsc.example".to_string()),
3471
3715
  extra: BTreeMap::new(),
3472
3716
  },
3473
3717
  );
3474
3718
  config.tokens.insert(
3475
- "usdc".to_string(),
3719
+ "usd1".to_string(),
3476
3720
  TokenProfile {
3477
- name: Some("USD Coin".to_string()),
3478
- symbol: "USDC".to_string(),
3721
+ name: Some("USD1".to_string()),
3722
+ symbol: "USD1".to_string(),
3479
3723
  default_policy: Some(sample_policy("10", "100", "500")),
3480
3724
  destination_overrides: vec![TokenDestinationOverrideProfile {
3481
3725
  recipient: "0x1000000000000000000000000000000000000001".to_string(),
@@ -3494,9 +3738,9 @@ mod tests {
3494
3738
  }],
3495
3739
  chains: BTreeMap::from([
3496
3740
  (
3497
- "sepolia".to_string(),
3741
+ "eth".to_string(),
3498
3742
  TokenChainProfile {
3499
- chain_id: 11155111,
3743
+ chain_id: 1,
3500
3744
  is_native: false,
3501
3745
  address: Some("0x1000000000000000000000000000000000000000".to_string()),
3502
3746
  decimals: 6,
@@ -3505,9 +3749,9 @@ mod tests {
3505
3749
  },
3506
3750
  ),
3507
3751
  (
3508
- "base".to_string(),
3752
+ "bsc".to_string(),
3509
3753
  TokenChainProfile {
3510
- chain_id: 8453,
3754
+ chain_id: 56,
3511
3755
  is_native: false,
3512
3756
  address: Some("0x2000000000000000000000000000000000000000".to_string()),
3513
3757
  decimals: 6,
@@ -3522,6 +3766,240 @@ mod tests {
3522
3766
  config
3523
3767
  }
3524
3768
 
3769
+ #[test]
3770
+ fn view_and_message_level_helpers_cover_navigation_and_labels() {
3771
+ assert_eq!(View::Tokens.title(), "Tokens");
3772
+ assert!(View::Tokens.description().contains("source of truth"));
3773
+ assert_eq!(View::Tokens.next(), View::Networks);
3774
+ assert_eq!(View::Networks.next(), View::Bootstrap);
3775
+ assert_eq!(View::Bootstrap.next(), View::Tokens);
3776
+ assert_eq!(View::Tokens.previous(), View::Bootstrap);
3777
+ assert_eq!(View::Networks.previous(), View::Tokens);
3778
+ assert_eq!(View::Bootstrap.previous(), View::Networks);
3779
+
3780
+ assert_eq!(MessageLevel::Info.style(), ratatui::style::Style::default().fg(Color::Cyan));
3781
+ assert_eq!(
3782
+ MessageLevel::Success.style(),
3783
+ ratatui::style::Style::default().fg(Color::Green)
3784
+ );
3785
+ assert_eq!(MessageLevel::Error.style(), ratatui::style::Style::default().fg(Color::Red));
3786
+
3787
+ let mut app = AppState::from_shared_config(&sample_config(), false);
3788
+ assert_eq!(app.active_draft_label(), "token draft");
3789
+ app.token_dirty = true;
3790
+ assert!(app.active_draft_dirty());
3791
+
3792
+ app.view = View::Networks;
3793
+ assert_eq!(app.active_draft_label(), "network draft");
3794
+ app.network_dirty = true;
3795
+ assert!(app.active_draft_dirty());
3796
+
3797
+ app.view = View::Bootstrap;
3798
+ assert_eq!(app.active_draft_label(), "current view");
3799
+ assert!(!app.active_draft_dirty());
3800
+ }
3801
+
3802
+ #[test]
3803
+ fn limit_draft_roundtrip_and_empty_policy_helpers_cover_chain_paths() {
3804
+ let empty = super::LimitDraft::from_policy(None, 6);
3805
+ assert_eq!(empty, super::LimitDraft::empty());
3806
+
3807
+ let draft = super::LimitDraft::from_policy(Some(&sample_policy("12.5", "100", "500.25")), 6);
3808
+ assert_eq!(draft.per_tx_limit, "12.5");
3809
+ assert_eq!(draft.daily_limit, "100");
3810
+ assert_eq!(draft.weekly_limit, "500.25");
3811
+ assert_eq!(draft.max_gas_per_chain_wei, "1000000000000000");
3812
+ assert_eq!(draft.per_tx_max_fee_per_gas_gwei, "25");
3813
+
3814
+ let token_policy = draft.as_token_level_policy(6).expect("token-level policy");
3815
+ assert_eq!(token_policy.per_tx_amount_decimal.as_deref(), Some("12.5"));
3816
+ assert_eq!(token_policy.daily_amount_decimal.as_deref(), Some("100"));
3817
+ assert_eq!(token_policy.weekly_amount_decimal.as_deref(), Some("500.25"));
3818
+ assert_eq!(
3819
+ token_policy.per_tx_max_fee_per_gas_wei.as_deref(),
3820
+ Some("25000000000")
3821
+ );
3822
+ assert!(token_policy.daily_max_tx_count.is_none());
3823
+
3824
+ let chain_policy = draft.as_chain_policy(6).expect("chain policy");
3825
+ assert_eq!(chain_policy.per_tx_limit.as_deref(), Some("12500000"));
3826
+ assert_eq!(chain_policy.daily_limit.as_deref(), Some("100000000"));
3827
+ assert_eq!(chain_policy.weekly_limit.as_deref(), Some("500250000"));
3828
+ }
3829
+
3830
+ #[test]
3831
+ fn field_visibility_and_selection_helpers_cover_view_transitions() {
3832
+ let mut app = AppState::from_shared_config(&sample_config(), false);
3833
+ app.message = Some("stale".to_string());
3834
+ app.pending_discard_action = Some(PendingDiscardAction::NextView);
3835
+ app.pending_delete_action = Some(PendingDeleteAction::DeleteToken("usd1".to_string()));
3836
+ app.selected = usize::MAX;
3837
+ app.normalize_selection();
3838
+ assert_eq!(app.selected, app.visible_fields().len() - 1);
3839
+
3840
+ app.next_view();
3841
+ assert_eq!(app.view, View::Networks);
3842
+ assert_eq!(app.selected, 0);
3843
+ assert!(app.message.is_none());
3844
+ assert!(app.pending_discard_action.is_none());
3845
+ assert!(app.pending_delete_action.is_none());
3846
+ assert!(app.visible_fields().contains(&Field::DeleteNetwork));
3847
+
3848
+ app.previous_view();
3849
+ assert_eq!(app.view, View::Tokens);
3850
+
3851
+ app.view = View::Bootstrap;
3852
+ assert_eq!(app.visible_fields(), vec![Field::Execute]);
3853
+ app.select_next();
3854
+ assert_eq!(app.selected, 0);
3855
+ app.select_prev();
3856
+ assert_eq!(app.selected, 0);
3857
+ assert!(app.request_reload_current_view());
3858
+ app.request_new_current_draft();
3859
+ assert_eq!(app.view, View::Bootstrap);
3860
+ assert!(app.request_cancel());
3861
+ }
3862
+
3863
+ #[test]
3864
+ fn delete_key_and_pending_selection_helpers_cover_errors_and_normalization() {
3865
+ let mut app = AppState::from_shared_config(&empty_config(), false);
3866
+ assert!(app.pending_token_delete_key().is_err());
3867
+
3868
+ app.view = View::Networks;
3869
+ assert!(app.pending_network_delete_key().is_err());
3870
+
3871
+ app.view = View::Tokens;
3872
+ app.token_draft.key = " MixedToken ".to_string();
3873
+ assert_eq!(
3874
+ app.pending_token_delete_key().expect("pending token key"),
3875
+ "mixedtoken"
3876
+ );
3877
+
3878
+ app.view = View::Networks;
3879
+ app.network_draft.key = " ExampleChain ".to_string();
3880
+ assert_eq!(
3881
+ app.pending_network_delete_key().expect("pending network key"),
3882
+ "examplechain"
3883
+ );
3884
+ }
3885
+
3886
+ #[test]
3887
+ fn save_and_apply_helpers_return_success_messages() {
3888
+ let mut token_app = AppState::from_shared_config(&sample_config(), false);
3889
+ let backend = TestBackend::new(120, 40);
3890
+ let mut terminal = Terminal::new(backend).expect("terminal");
3891
+ let (token_output, token_message) = run_save_token_and_apply(
3892
+ &mut terminal,
3893
+ &mut token_app,
3894
+ &mut |params, on_status| {
3895
+ on_status("token apply hook").expect("status");
3896
+ Ok(params.token_policies.len())
3897
+ },
3898
+ )
3899
+ .expect("save token and apply");
3900
+ assert_eq!(token_output, 2);
3901
+ assert!(token_message.contains("saved token"));
3902
+
3903
+ let mut network_app = AppState::from_shared_config(&sample_config(), false);
3904
+ network_app.view = View::Networks;
3905
+ let backend = TestBackend::new(120, 40);
3906
+ let mut terminal = Terminal::new(backend).expect("terminal");
3907
+ let (network_output, network_message) = run_save_network_and_apply(
3908
+ &mut terminal,
3909
+ &mut network_app,
3910
+ &mut |params, on_status| {
3911
+ on_status("network apply hook").expect("status");
3912
+ Ok(params.token_policies.len())
3913
+ },
3914
+ )
3915
+ .expect("save network and apply");
3916
+ assert_eq!(network_output, 2);
3917
+ assert!(network_message.contains("saved network"));
3918
+ }
3919
+
3920
+ #[test]
3921
+ fn policy_value_resolution_and_overlay_helpers_cover_remaining_branches() {
3922
+ assert_eq!(
3923
+ resolve_required_policy_amount("limit", Some("1.5"), None, None, 6)
3924
+ .expect("decimal amount"),
3925
+ 1_500_000
3926
+ );
3927
+ assert_eq!(
3928
+ resolve_required_policy_amount("limit", None, Some("42"), None, 6).expect("raw amount"),
3929
+ 42
3930
+ );
3931
+ assert_eq!(
3932
+ resolve_required_policy_amount("limit", None, None, Some(2.5), 6)
3933
+ .expect("legacy amount"),
3934
+ 2_500_000
3935
+ );
3936
+ assert!(resolve_required_policy_amount("limit", None, None, None, 6).is_err());
3937
+
3938
+ assert_eq!(
3939
+ resolve_policy_amount_or_default(Some("3"), None, None, 9, 6).expect("decimal"),
3940
+ 3_000_000
3941
+ );
3942
+ assert_eq!(
3943
+ resolve_policy_amount_or_default(None, Some("7"), None, 9, 6).expect("raw"),
3944
+ 7
3945
+ );
3946
+ assert_eq!(
3947
+ resolve_policy_amount_or_default(None, None, Some(1.25), 9, 6).expect("legacy"),
3948
+ 1_250_000
3949
+ );
3950
+ assert_eq!(
3951
+ resolve_policy_amount_or_default(None, None, None, 9, 6).expect("default"),
3952
+ 9
3953
+ );
3954
+
3955
+ assert_eq!(
3956
+ resolve_optional_gwei_or_wei(Some("2"), None).expect("gwei"),
3957
+ 2_000_000_000
3958
+ );
3959
+ assert_eq!(
3960
+ resolve_optional_gwei_or_wei(None, Some("5")).expect("wei"),
3961
+ 5
3962
+ );
3963
+ assert_eq!(
3964
+ resolve_gwei_or_wei_or_default(None, None, 11).expect("default"),
3965
+ 11
3966
+ );
3967
+ assert_eq!(resolve_optional_policy_value(None).expect("none"), 0);
3968
+ assert_eq!(
3969
+ resolve_optional_policy_value_or_default(None, 13).expect("default"),
3970
+ 13
3971
+ );
3972
+
3973
+ let defaults = ResolvedLimitFields {
3974
+ per_tx_max_wei: 100,
3975
+ daily_max_wei: 200,
3976
+ weekly_max_wei: 300,
3977
+ max_gas_per_chain_wei: 400,
3978
+ daily_max_tx_count: 5,
3979
+ per_tx_max_fee_per_gas_wei: 6,
3980
+ per_tx_max_priority_fee_per_gas_wei: 7,
3981
+ per_tx_max_calldata_bytes: 8,
3982
+ };
3983
+ let tighter = ResolvedLimitFields {
3984
+ per_tx_max_wei: 90,
3985
+ daily_max_wei: 190,
3986
+ weekly_max_wei: 290,
3987
+ max_gas_per_chain_wei: 300,
3988
+ daily_max_tx_count: 4,
3989
+ per_tx_max_fee_per_gas_wei: 5,
3990
+ per_tx_max_priority_fee_per_gas_wei: 6,
3991
+ per_tx_max_calldata_bytes: 7,
3992
+ };
3993
+ validate_destination_override_overlay("recipient", &defaults, &tighter)
3994
+ .expect("tighter overlay");
3995
+
3996
+ assert!(validate_overlay_limit("recipient", "per-tx max", 100, 101).is_err());
3997
+ assert!(validate_optional_overlay_limit("recipient", "gas max", 0, 1).is_err());
3998
+ assert!(validate_optional_overlay_limit("recipient", "gas max", 10, 0).is_err());
3999
+ assert!(validate_optional_overlay_limit("recipient", "gas max", 10, 11).is_err());
4000
+ validate_optional_overlay_limit("recipient", "gas max", 10, 9).expect("valid optional");
4001
+ }
4002
+
3525
4003
  #[test]
3526
4004
  fn tokens_view_is_default_and_not_numbered() {
3527
4005
  let app = AppState::from_shared_config(&sample_config(), false);
@@ -3537,7 +4015,7 @@ mod tests {
3537
4015
  .map(|line| line.to_string())
3538
4016
  .collect::<Vec<_>>()
3539
4017
  .join("\n");
3540
- assert!(rendered.contains("USD Coin (usdc)"));
4018
+ assert!(rendered.contains("USD1 (usd1)"));
3541
4019
  assert!(rendered.contains("overrides 1"));
3542
4020
  assert!(rendered.contains("manual approvals 1"));
3543
4021
  }
@@ -3554,7 +4032,7 @@ mod tests {
3554
4032
  #[test]
3555
4033
  fn build_params_skips_empty_destination_override_limits() {
3556
4034
  let mut config = sample_config();
3557
- if let Some(token) = config.tokens.get_mut("usdc") {
4035
+ if let Some(token) = config.tokens.get_mut("usd1") {
3558
4036
  token.destination_overrides = vec![TokenDestinationOverrideProfile {
3559
4037
  recipient: "0x1000000000000000000000000000000000000001".to_string(),
3560
4038
  limits: TokenPolicyProfile::default(),
@@ -3646,6 +4124,35 @@ mod tests {
3646
4124
  assert_eq!(super::field_value(&app, Field::PerTxLimit), "10");
3647
4125
  }
3648
4126
 
4127
+ #[test]
4128
+ fn field_display_labels_show_interaction_badges() {
4129
+ assert_eq!(super::field_display_label(Field::TokenKey), "[E] Token Key");
4130
+ assert_eq!(
4131
+ super::field_display_label(Field::SelectedToken),
4132
+ "[S] Selected Token"
4133
+ );
4134
+ assert_eq!(
4135
+ super::field_display_label(Field::RefreshTokenMetadata),
4136
+ "[A] Fetch Metadata"
4137
+ );
4138
+ assert_eq!(
4139
+ super::field_display_label(Field::TokenName),
4140
+ "[R] Token Name (RPC)"
4141
+ );
4142
+ }
4143
+
4144
+ #[test]
4145
+ fn token_help_lines_include_interaction_legend() {
4146
+ let rendered = super::build_help_lines(View::Tokens)
4147
+ .into_iter()
4148
+ .map(|line| line.to_string())
4149
+ .collect::<Vec<_>>()
4150
+ .join("\n");
4151
+ assert!(rendered.contains("Legend: [E] edit [S] select/toggle [A] Enter [R] read-only"));
4152
+ assert!(rendered.contains("Paste: terminal paste appends into editable fields."));
4153
+ assert!(rendered.contains("RPC fields are [R]; use [A] Fetch Metadata to refresh them."));
4154
+ }
4155
+
3649
4156
  #[test]
3650
4157
  fn save_token_defers_apply_to_event_loop_when_metadata_is_complete() {
3651
4158
  let config = sample_config();
@@ -3668,6 +4175,84 @@ mod tests {
3668
4175
  fs::remove_dir_all(config_root).expect("cleanup temp config root");
3669
4176
  }
3670
4177
 
4178
+ #[test]
4179
+ fn ctrl_s_saves_dirty_token_before_bootstrap() {
4180
+ let config = sample_config();
4181
+ let mut app = AppState::from_shared_config(&config, false);
4182
+ app.token_draft.key.push('x');
4183
+ app.mark_token_dirty();
4184
+
4185
+ let action = handle_key_event(
4186
+ &mut app,
4187
+ KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL),
4188
+ )
4189
+ .expect("handle ctrl+s");
4190
+
4191
+ assert!(matches!(action, LoopAction::SaveTokenAndApply));
4192
+ }
4193
+
4194
+ #[test]
4195
+ fn ctrl_s_saves_dirty_network_before_bootstrap() {
4196
+ let config = sample_config();
4197
+ let mut app = AppState::from_shared_config(&config, false);
4198
+ app.view = View::Networks;
4199
+ app.network_draft.name.push('x');
4200
+ app.mark_network_dirty();
4201
+
4202
+ let action = handle_key_event(
4203
+ &mut app,
4204
+ KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL),
4205
+ )
4206
+ .expect("handle ctrl+s");
4207
+
4208
+ assert!(matches!(action, LoopAction::SaveNetworkAndApply));
4209
+ }
4210
+
4211
+ #[test]
4212
+ fn paste_event_appends_trimmed_text_to_editable_fields() {
4213
+ let config = sample_config();
4214
+ let mut app = AppState::from_shared_config(&config, false);
4215
+ app.view = View::Networks;
4216
+ app.network_draft.rpc_url.clear();
4217
+ app.selected = app
4218
+ .visible_fields()
4219
+ .iter()
4220
+ .position(|field| *field == Field::ChainConfigRpcUrl)
4221
+ .expect("chain config rpc field");
4222
+
4223
+ let action = handle_terminal_event(
4224
+ &mut app,
4225
+ Event::Paste(" https://bsc.drpc.org \n".to_string()),
4226
+ )
4227
+ .expect("handle paste");
4228
+
4229
+ assert!(matches!(action, LoopAction::Continue));
4230
+ assert_eq!(app.network_draft.rpc_url, "https://bsc.drpc.org");
4231
+ assert!(app.network_dirty);
4232
+ }
4233
+
4234
+ #[test]
4235
+ fn paste_event_rejects_invalid_chars_for_selected_field() {
4236
+ let config = sample_config();
4237
+ let mut app = AppState::from_shared_config(&config, false);
4238
+ let original_key = app.token_draft.key.clone();
4239
+ app.selected = app
4240
+ .visible_fields()
4241
+ .iter()
4242
+ .position(|field| *field == Field::TokenKey)
4243
+ .expect("token key field");
4244
+
4245
+ let action = handle_terminal_event(&mut app, Event::Paste("bad key".to_string()))
4246
+ .expect("handle invalid paste");
4247
+
4248
+ assert!(matches!(action, LoopAction::Continue));
4249
+ assert_eq!(app.token_draft.key, original_key);
4250
+ assert_eq!(
4251
+ app.message.as_deref(),
4252
+ Some("invalid character ' ' for the selected field")
4253
+ );
4254
+ }
4255
+
3671
4256
  #[test]
3672
4257
  fn refresh_metadata_action_is_deferred_to_event_loop_for_progress_rendering() {
3673
4258
  let config = sample_config();
@@ -3690,13 +4275,14 @@ mod tests {
3690
4275
  let mut app = AppState::from_shared_config(&sample_config(), false);
3691
4276
  let params = app.build_params().expect("params");
3692
4277
 
3693
- let output = apply_with_progress(&mut terminal, &mut app, params, &mut |params, on_status| {
3694
- assert_eq!(params.token_policies.len(), 2);
3695
- on_status("initializing daemon")?;
3696
- on_status("registering spending policies")?;
3697
- Ok("ok".to_string())
3698
- })
3699
- .expect("apply succeeds");
4278
+ let output =
4279
+ apply_with_progress(&mut terminal, &mut app, params, &mut |params, on_status| {
4280
+ assert_eq!(params.token_policies.len(), 2);
4281
+ on_status("initializing daemon")?;
4282
+ on_status("registering spending policies")?;
4283
+ Ok("ok".to_string())
4284
+ })
4285
+ .expect("apply succeeds");
3700
4286
 
3701
4287
  assert_eq!(output, "ok");
3702
4288
  assert_eq!(
@@ -3774,8 +4360,11 @@ mod tests {
3774
4360
  .position(|field| *field == Field::TokenKey)
3775
4361
  .expect("token key field");
3776
4362
 
3777
- let _ = handle_key_event(&mut app, KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE))
3778
- .expect("edit token key");
4363
+ let _ = handle_key_event(
4364
+ &mut app,
4365
+ KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE),
4366
+ )
4367
+ .expect("edit token key");
3779
4368
  assert!(app.token_dirty);
3780
4369
 
3781
4370
  let _ = handle_key_event(&mut app, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE))
@@ -3803,8 +4392,11 @@ mod tests {
3803
4392
  .position(|field| *field == Field::ChainConfigName)
3804
4393
  .expect("chain config name field");
3805
4394
 
3806
- let _ = handle_key_event(&mut app, KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE))
3807
- .expect("edit network name");
4395
+ let _ = handle_key_event(
4396
+ &mut app,
4397
+ KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE),
4398
+ )
4399
+ .expect("edit network name");
3808
4400
  assert!(app.network_dirty);
3809
4401
  assert!(app.network_draft.name.ends_with('x'));
3810
4402
 
@@ -3837,8 +4429,11 @@ mod tests {
3837
4429
  .iter()
3838
4430
  .position(|field| *field == Field::TokenKey)
3839
4431
  .expect("token key field");
3840
- let _ = handle_key_event(&mut app, KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE))
3841
- .expect("edit token key");
4432
+ let _ = handle_key_event(
4433
+ &mut app,
4434
+ KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE),
4435
+ )
4436
+ .expect("edit token key");
3842
4437
 
3843
4438
  app.selected = app
3844
4439
  .visible_fields()
@@ -3848,7 +4443,7 @@ mod tests {
3848
4443
 
3849
4444
  let _ = handle_key_event(&mut app, KeyEvent::new(KeyCode::Right, KeyModifiers::NONE))
3850
4445
  .expect("first cycle");
3851
- assert_eq!(app.token_draft.source_key.as_deref(), Some("usdc"));
4446
+ assert_eq!(app.token_draft.source_key.as_deref(), Some("usd1"));
3852
4447
  assert!(app
3853
4448
  .message
3854
4449
  .as_deref()
@@ -3861,6 +4456,53 @@ mod tests {
3861
4456
  assert!(!app.token_dirty);
3862
4457
  }
3863
4458
 
4459
+ #[test]
4460
+ fn q_requires_confirmation_before_canceling_dirty_token_draft() {
4461
+ let mut app = AppState::from_shared_config(&sample_config(), false);
4462
+ app.token_draft.key.push('x');
4463
+ app.mark_token_dirty();
4464
+
4465
+ let action = handle_key_event(
4466
+ &mut app,
4467
+ KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE),
4468
+ )
4469
+ .expect("first cancel");
4470
+ assert!(matches!(action, LoopAction::Continue));
4471
+ assert!(app
4472
+ .message
4473
+ .as_deref()
4474
+ .expect("discard warning")
4475
+ .contains("unsaved changes in the token draft"));
4476
+
4477
+ let action = handle_key_event(
4478
+ &mut app,
4479
+ KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE),
4480
+ )
4481
+ .expect("second cancel");
4482
+ assert!(matches!(action, LoopAction::Cancel));
4483
+ }
4484
+
4485
+ #[test]
4486
+ fn escape_requires_confirmation_before_canceling_dirty_network_draft() {
4487
+ let mut app = AppState::from_shared_config(&sample_config(), false);
4488
+ app.view = View::Networks;
4489
+ app.network_draft.name.push('x');
4490
+ app.mark_network_dirty();
4491
+
4492
+ let action = handle_key_event(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))
4493
+ .expect("first cancel");
4494
+ assert!(matches!(action, LoopAction::Continue));
4495
+ assert!(app
4496
+ .message
4497
+ .as_deref()
4498
+ .expect("discard warning")
4499
+ .contains("unsaved changes in the network draft"));
4500
+
4501
+ let action = handle_key_event(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))
4502
+ .expect("second cancel");
4503
+ assert!(matches!(action, LoopAction::Cancel));
4504
+ }
4505
+
3864
4506
  #[test]
3865
4507
  fn delete_manual_approval_requires_confirmation() {
3866
4508
  let mut app = AppState::from_shared_config(&sample_config(), false);
@@ -3884,6 +4526,103 @@ mod tests {
3884
4526
  assert!(app.token_draft.manual_approvals.is_empty());
3885
4527
  }
3886
4528
 
4529
+ #[test]
4530
+ fn changing_selected_manual_approval_restarts_delete_confirmation() {
4531
+ let mut config = sample_config();
4532
+ let token = config.tokens.get_mut("usd1").expect("usd1 token");
4533
+ let mut second_policy = token.manual_approval_policies[0].clone();
4534
+ second_policy.priority = 200;
4535
+ second_policy.min_amount_decimal = Some("600".to_string());
4536
+ second_policy.max_amount_decimal = Some("900".to_string());
4537
+ token.manual_approval_policies.push(second_policy);
4538
+
4539
+ let mut app = AppState::from_shared_config(&config, false);
4540
+ let delete_field = app
4541
+ .visible_fields()
4542
+ .iter()
4543
+ .position(|field| *field == Field::DeleteManualApproval)
4544
+ .expect("delete manual approval field");
4545
+ let selected_field = app
4546
+ .visible_fields()
4547
+ .iter()
4548
+ .position(|field| *field == Field::SelectedManualApproval)
4549
+ .expect("selected manual approval field");
4550
+
4551
+ app.selected = delete_field;
4552
+ let _ = handle_key_event(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
4553
+ .expect("first delete");
4554
+ assert_eq!(app.token_draft.manual_approvals.len(), 2);
4555
+
4556
+ app.selected = selected_field;
4557
+ let _ = handle_key_event(&mut app, KeyEvent::new(KeyCode::Right, KeyModifiers::NONE))
4558
+ .expect("cycle manual approval");
4559
+ assert_eq!(app.token_draft.selected_manual_approval, 1);
4560
+
4561
+ app.selected = delete_field;
4562
+ let _ = handle_key_event(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
4563
+ .expect("re-arm delete for second selection");
4564
+ assert_eq!(app.token_draft.manual_approvals.len(), 2);
4565
+ assert!(app
4566
+ .message
4567
+ .as_deref()
4568
+ .expect("renewed confirmation")
4569
+ .contains("repeat the action to confirm deleting the selected manual approval policy"));
4570
+
4571
+ let _ = handle_key_event(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
4572
+ .expect("delete selected manual approval");
4573
+ assert_eq!(app.token_draft.manual_approvals.len(), 1);
4574
+ assert_eq!(app.token_draft.manual_approvals[0].priority, "120");
4575
+ }
4576
+
4577
+ #[test]
4578
+ fn changing_selected_override_restarts_delete_confirmation() {
4579
+ let mut config = sample_config();
4580
+ let token = config.tokens.get_mut("usd1").expect("usd1 token");
4581
+ let mut second_override = token.destination_overrides[0].clone();
4582
+ second_override.recipient = "0x2000000000000000000000000000000000000002".to_string();
4583
+ token.destination_overrides.push(second_override);
4584
+
4585
+ let mut app = AppState::from_shared_config(&config, false);
4586
+ let delete_field = app
4587
+ .visible_fields()
4588
+ .iter()
4589
+ .position(|field| *field == Field::DeleteDestinationOverride)
4590
+ .expect("delete override field");
4591
+ let selected_field = app
4592
+ .visible_fields()
4593
+ .iter()
4594
+ .position(|field| *field == Field::SelectedDestinationOverride)
4595
+ .expect("selected override field");
4596
+
4597
+ app.selected = delete_field;
4598
+ let _ = handle_key_event(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
4599
+ .expect("first delete");
4600
+ assert_eq!(app.token_draft.destination_overrides.len(), 2);
4601
+
4602
+ app.selected = selected_field;
4603
+ let _ = handle_key_event(&mut app, KeyEvent::new(KeyCode::Right, KeyModifiers::NONE))
4604
+ .expect("cycle override");
4605
+ assert_eq!(app.token_draft.selected_override, 1);
4606
+
4607
+ app.selected = delete_field;
4608
+ let _ = handle_key_event(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
4609
+ .expect("re-arm delete for second selection");
4610
+ assert_eq!(app.token_draft.destination_overrides.len(), 2);
4611
+ assert!(app
4612
+ .message
4613
+ .as_deref()
4614
+ .expect("renewed confirmation")
4615
+ .contains("repeat the action to confirm deleting the selected destination override"));
4616
+
4617
+ let _ = handle_key_event(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
4618
+ .expect("delete selected override");
4619
+ assert_eq!(app.token_draft.destination_overrides.len(), 1);
4620
+ assert_eq!(
4621
+ app.token_draft.destination_overrides[0].recipient_address,
4622
+ "0x1000000000000000000000000000000000000001"
4623
+ );
4624
+ }
4625
+
3887
4626
  #[test]
3888
4627
  fn delete_token_requires_confirmation() {
3889
4628
  let mut app = AppState::from_shared_config(&sample_config(), false);
@@ -3899,7 +4638,7 @@ mod tests {
3899
4638
 
3900
4639
  let _ = handle_key_event(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
3901
4640
  .expect("first delete");
3902
- assert!(app.shared_config_draft.tokens.contains_key("usdc"));
4641
+ assert!(app.shared_config_draft.tokens.contains_key("usd1"));
3903
4642
  assert!(app
3904
4643
  .message
3905
4644
  .as_deref()
@@ -3908,8 +4647,354 @@ mod tests {
3908
4647
 
3909
4648
  let _ = handle_key_event(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
3910
4649
  .expect("second delete");
3911
- assert!(!app.shared_config_draft.tokens.contains_key("usdc"));
4650
+ assert!(!app.shared_config_draft.tokens.contains_key("usd1"));
3912
4651
 
3913
4652
  fs::remove_dir_all(config_root).expect("cleanup temp config root");
3914
4653
  }
4654
+
4655
+ #[test]
4656
+ fn token_draft_validation_errors_cover_remaining_paths() {
4657
+ let config = sample_config();
4658
+ let mut draft = TokenDraft::blank(&config);
4659
+
4660
+ assert!(draft.selected_network_mut().is_none());
4661
+ assert!(draft
4662
+ .min_network_decimals()
4663
+ .expect_err("empty draft must fail")
4664
+ .to_string()
4665
+ .contains("select at least one network for the token"));
4666
+ assert!(draft
4667
+ .to_profile(&config)
4668
+ .expect_err("missing key must fail")
4669
+ .to_string()
4670
+ .contains("token key is required"));
4671
+
4672
+ draft.key = "usd1".to_string();
4673
+ assert!(draft
4674
+ .to_profile(&config)
4675
+ .expect_err("missing name must fail")
4676
+ .to_string()
4677
+ .contains("fetch token metadata before saving the token"));
4678
+
4679
+ draft.name = "USD1".to_string();
4680
+ assert!(draft
4681
+ .to_profile(&config)
4682
+ .expect_err("missing symbol must fail")
4683
+ .to_string()
4684
+ .contains("fetch token metadata before saving the token"));
4685
+
4686
+ draft.symbol = "USD1".to_string();
4687
+ assert!(draft
4688
+ .to_profile(&config)
4689
+ .expect_err("missing network must fail")
4690
+ .to_string()
4691
+ .contains("select at least one network for the token"));
4692
+
4693
+ draft.limits = super::LimitDraft::from_policy(Some(&sample_policy("10", "100", "500")), 6);
4694
+ draft.networks.push(TokenNetworkDraft {
4695
+ chain_key: "eth".to_string(),
4696
+ chain_id: "1".to_string(),
4697
+ is_native: false,
4698
+ address: "0x1000000000000000000000000000000000000000".to_string(),
4699
+ decimals: "999".to_string(),
4700
+ });
4701
+ assert!(draft
4702
+ .to_profile(&config)
4703
+ .expect_err("large decimals must fail")
4704
+ .to_string()
4705
+ .contains("decimals must be <= 255"));
4706
+
4707
+ draft.networks[0].decimals = "6".to_string();
4708
+ draft.networks[0].is_native = true;
4709
+ assert!(draft
4710
+ .to_profile(&config)
4711
+ .expect_err("native token with address must fail")
4712
+ .to_string()
4713
+ .contains("must not set an address when native"));
4714
+
4715
+ draft.networks[0].is_native = false;
4716
+ draft.networks[0].chain_key = "missing".to_string();
4717
+ assert!(draft
4718
+ .to_profile(&config)
4719
+ .expect_err("unknown chain must fail")
4720
+ .to_string()
4721
+ .contains("unknown saved network 'missing'"));
4722
+
4723
+ draft.networks[0].chain_key = "eth".to_string();
4724
+ draft.destination_overrides.push(super::DestinationOverrideDraft {
4725
+ recipient_address: "not-an-address".to_string(),
4726
+ limits: draft.limits.clone(),
4727
+ });
4728
+ assert!(draft
4729
+ .to_profile(&config)
4730
+ .expect_err("invalid override recipient must fail")
4731
+ .to_string()
4732
+ .contains("destination override recipient"));
4733
+
4734
+ draft.destination_overrides.clear();
4735
+ draft.manual_approvals.push(ManualApprovalDraft {
4736
+ recipient_address: String::new(),
4737
+ min_amount: "1".to_string(),
4738
+ max_amount: "2".to_string(),
4739
+ priority: (u32::MAX as u64 + 1).to_string(),
4740
+ });
4741
+ assert!(draft
4742
+ .to_profile(&config)
4743
+ .expect_err("large priority must fail")
4744
+ .to_string()
4745
+ .contains("manual approval priority must be <= 4294967295"));
4746
+
4747
+ draft.manual_approvals[0].priority = "10".to_string();
4748
+ draft.manual_approvals[0].recipient_address = "not-an-address".to_string();
4749
+ assert!(draft
4750
+ .to_profile(&config)
4751
+ .expect_err("invalid manual approval recipient must fail")
4752
+ .to_string()
4753
+ .contains("manual approval recipient"));
4754
+ }
4755
+
4756
+ #[test]
4757
+ fn token_draft_membership_and_normalize_cover_remaining_branches() {
4758
+ let empty = empty_config();
4759
+ let mut empty_draft = TokenDraft::blank(&empty);
4760
+ assert!(empty_draft
4761
+ .toggle_network_membership(&empty)
4762
+ .expect_err("empty config must fail")
4763
+ .to_string()
4764
+ .contains("save a network before adding it to a token"));
4765
+
4766
+ let config = sample_config();
4767
+ let mut draft = TokenDraft::blank(&config);
4768
+ draft.available_network_index = 1;
4769
+ draft.toggle_network_membership(&config).expect("add network");
4770
+ assert_eq!(draft.networks.len(), 1);
4771
+ assert_eq!(draft.networks[0].chain_key, "eth");
4772
+
4773
+ draft.destination_overrides.push(super::DestinationOverrideDraft::default());
4774
+ draft.manual_approvals.push(ManualApprovalDraft::default());
4775
+ draft.selected_network = usize::MAX;
4776
+ draft.selected_override = usize::MAX;
4777
+ draft.selected_manual_approval = usize::MAX;
4778
+
4779
+ draft.toggle_network_membership(&config).expect("remove network");
4780
+ assert!(draft.networks.is_empty());
4781
+ assert_eq!(draft.selected_network, 0);
4782
+ assert_eq!(draft.selected_override, 0);
4783
+ assert_eq!(draft.selected_manual_approval, 0);
4784
+ }
4785
+
4786
+ #[test]
4787
+ fn network_draft_to_profile_defaults_name_and_optional_rpc() {
4788
+ let draft = NetworkDraft::blank();
4789
+ assert!(draft
4790
+ .to_profile()
4791
+ .expect_err("blank key must fail")
4792
+ .to_string()
4793
+ .contains("network key is required"));
4794
+
4795
+ let mut draft = NetworkDraft::blank();
4796
+ draft.key = " BSC ".to_string();
4797
+ draft.chain_id = "56".to_string();
4798
+ let (source_key, chain_key, profile, use_as_active) =
4799
+ draft.to_profile().expect("network profile");
4800
+ assert!(source_key.is_none());
4801
+ assert_eq!(chain_key, "bsc");
4802
+ assert_eq!(profile.name, "bsc");
4803
+ assert_eq!(profile.rpc_url, None);
4804
+ assert!(!use_as_active);
4805
+ }
4806
+
4807
+ #[test]
4808
+ fn app_state_network_draft_navigation_helpers_cover_remaining_paths() {
4809
+ let mut app = AppState::from_shared_config(&sample_config(), false);
4810
+ app.view = View::Networks;
4811
+ app.network_draft.name.push('x');
4812
+ app.mark_network_dirty();
4813
+
4814
+ app.request_previous_view();
4815
+ assert_eq!(app.view, View::Networks);
4816
+ assert!(app
4817
+ .message
4818
+ .as_deref()
4819
+ .expect("discard warning")
4820
+ .contains("unsaved changes in the network draft"));
4821
+
4822
+ app.request_previous_view();
4823
+ assert_eq!(app.view, View::Tokens);
4824
+
4825
+ app.view = View::Networks;
4826
+ app.load_network_draft(Some("missing"));
4827
+ assert_eq!(app.network_draft.source_key.as_deref(), Some("bsc"));
4828
+
4829
+ app.request_new_current_draft();
4830
+ assert!(app.network_draft.source_key.is_none());
4831
+ assert_eq!(app.message.as_deref(), Some("new network draft ready"));
4832
+ assert_eq!(app.selected_field(), Field::ChainConfigKey);
4833
+
4834
+ app.cycle_saved_network(1);
4835
+ assert_eq!(app.network_draft.source_key.as_deref(), Some("bsc"));
4836
+ app.cycle_saved_network(-1);
4837
+ assert!(app.network_draft.source_key.is_none());
4838
+ }
4839
+
4840
+ #[test]
4841
+ fn step_selected_and_delete_network_cover_remaining_network_paths() {
4842
+ let config = sample_config();
4843
+ let mut app = AppState::from_shared_config(&config, false);
4844
+
4845
+ app.selected = app
4846
+ .visible_fields()
4847
+ .iter()
4848
+ .position(|field| *field == Field::NetworkMembership)
4849
+ .expect("network membership field");
4850
+ app.step_selected(1);
4851
+ assert_eq!(app.token_draft.available_network_index, 1);
4852
+
4853
+ app.selected = app
4854
+ .visible_fields()
4855
+ .iter()
4856
+ .position(|field| *field == Field::EditingNetwork)
4857
+ .expect("editing network field");
4858
+ app.step_selected(1);
4859
+ assert_eq!(app.token_draft.selected_network, 1);
4860
+
4861
+ app.token_draft.networks[app.token_draft.selected_network].address =
4862
+ "0x3000000000000000000000000000000000000000".to_string();
4863
+ app.selected = app
4864
+ .visible_fields()
4865
+ .iter()
4866
+ .position(|field| *field == Field::NetworkIsNative)
4867
+ .expect("network native field");
4868
+ app.step_selected(1);
4869
+ let selected_network = app
4870
+ .token_draft
4871
+ .selected_network()
4872
+ .expect("selected network");
4873
+ assert!(selected_network.is_native);
4874
+ assert!(selected_network.address.is_empty());
4875
+
4876
+ app.selected = app
4877
+ .visible_fields()
4878
+ .iter()
4879
+ .position(|field| *field == Field::ShowAdvanced)
4880
+ .expect("show advanced field");
4881
+ app.step_selected(1);
4882
+ assert!(app.show_advanced);
4883
+
4884
+ app.view = View::Networks;
4885
+ app.selected = app
4886
+ .visible_fields()
4887
+ .iter()
4888
+ .position(|field| *field == Field::SelectedNetwork)
4889
+ .expect("selected network field");
4890
+ app.step_selected(1);
4891
+ assert_eq!(app.network_draft.source_key.as_deref(), Some("eth"));
4892
+
4893
+ app.selected = app
4894
+ .visible_fields()
4895
+ .iter()
4896
+ .position(|field| *field == Field::ChainConfigUseAsActive)
4897
+ .expect("use as active field");
4898
+ app.step_selected(1);
4899
+ assert!(app.network_draft.use_as_active);
4900
+ assert!(app.network_dirty);
4901
+
4902
+ let mut empty_app = AppState::from_shared_config(&empty_config(), false);
4903
+ empty_app.view = View::Networks;
4904
+ assert!(empty_app
4905
+ .request_delete_network()
4906
+ .expect_err("blank network delete must fail")
4907
+ .to_string()
4908
+ .contains("no saved network is selected"));
4909
+
4910
+ let mut deleting_app = AppState::from_shared_config(&config, false);
4911
+ deleting_app.view = View::Networks;
4912
+ deleting_app
4913
+ .request_delete_network()
4914
+ .expect("first delete only arms confirmation");
4915
+ assert_eq!(
4916
+ deleting_app.pending_delete_action,
4917
+ Some(PendingDeleteAction::DeleteNetwork("bsc".to_string()))
4918
+ );
4919
+ assert!(deleting_app
4920
+ .request_delete_network()
4921
+ .expect_err("network still referenced by token must fail")
4922
+ .to_string()
4923
+ .contains("network 'bsc' is still used by token 'usd1'"));
4924
+ }
4925
+
4926
+ #[test]
4927
+ fn switching_to_new_network_focuses_key_field_for_immediate_typing() {
4928
+ let config = sample_config();
4929
+ let mut app = AppState::from_shared_config(&config, false);
4930
+ app.view = View::Networks;
4931
+ app.selected = app
4932
+ .visible_fields()
4933
+ .iter()
4934
+ .position(|field| *field == Field::SelectedNetwork)
4935
+ .expect("selected network field");
4936
+
4937
+ app.cycle_saved_network(-1);
4938
+ assert!(app.network_draft.source_key.is_none());
4939
+ assert_eq!(app.selected_field(), Field::ChainConfigKey);
4940
+
4941
+ let _ = handle_key_event(
4942
+ &mut app,
4943
+ KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE),
4944
+ )
4945
+ .expect("type into new network key");
4946
+
4947
+ assert_eq!(app.network_draft.key, "s");
4948
+ assert!(app.network_dirty);
4949
+ }
4950
+
4951
+ #[test]
4952
+ fn editing_network_name_accepts_letters_that_overlap_navigation_shortcuts() {
4953
+ let mut app = AppState::from_shared_config(&sample_config(), false);
4954
+ app.view = View::Networks;
4955
+ app.request_new_current_draft();
4956
+ app.selected = app
4957
+ .visible_fields()
4958
+ .iter()
4959
+ .position(|field| *field == Field::ChainConfigName)
4960
+ .expect("network name field");
4961
+
4962
+ let selected = app.selected;
4963
+ for ch in "sepolia".chars() {
4964
+ let _ = handle_key_event(
4965
+ &mut app,
4966
+ KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE),
4967
+ )
4968
+ .expect("type network name");
4969
+ }
4970
+
4971
+ assert_eq!(app.selected, selected);
4972
+ assert_eq!(app.network_draft.name, "sepolia");
4973
+ assert!(app.network_dirty);
4974
+ }
4975
+
4976
+ #[test]
4977
+ fn editing_network_rpc_url_accepts_http_prefix() {
4978
+ let mut app = AppState::from_shared_config(&sample_config(), false);
4979
+ app.view = View::Networks;
4980
+ app.request_new_current_draft();
4981
+ app.selected = app
4982
+ .visible_fields()
4983
+ .iter()
4984
+ .position(|field| *field == Field::ChainConfigRpcUrl)
4985
+ .expect("network rpc url field");
4986
+
4987
+ let selected = app.selected;
4988
+ for ch in "http".chars() {
4989
+ let _ = handle_key_event(
4990
+ &mut app,
4991
+ KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE),
4992
+ )
4993
+ .expect("type rpc prefix");
4994
+ }
4995
+
4996
+ assert_eq!(app.selected, selected);
4997
+ assert_eq!(app.network_draft.rpc_url, "http");
4998
+ assert!(app.network_dirty);
4999
+ }
3915
5000
  }