@wlfi-agent/cli 1.4.12 → 1.4.14

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 (289) hide show
  1. package/Cargo.lock +3968 -0
  2. package/Cargo.toml +50 -0
  3. package/README.md +426 -6
  4. package/crates/vault-cli-admin/Cargo.toml +26 -0
  5. package/crates/vault-cli-admin/src/io_utils.rs +500 -0
  6. package/crates/vault-cli-admin/src/main.rs +3990 -0
  7. package/crates/vault-cli-admin/src/shared_config.rs +624 -0
  8. package/crates/vault-cli-admin/src/tui/amounts.rs +180 -0
  9. package/crates/vault-cli-admin/src/tui/token_rpc.rs +250 -0
  10. package/crates/vault-cli-admin/src/tui/utils.rs +82 -0
  11. package/crates/vault-cli-admin/src/tui.rs +3410 -0
  12. package/crates/vault-cli-agent/Cargo.toml +24 -0
  13. package/crates/vault-cli-agent/src/io_utils.rs +576 -0
  14. package/crates/vault-cli-agent/src/main.rs +833 -0
  15. package/crates/vault-cli-daemon/Cargo.toml +28 -0
  16. package/crates/vault-cli-daemon/src/bin/wlfi-agent-system-keychain.rs +216 -0
  17. package/crates/vault-cli-daemon/src/main.rs +644 -0
  18. package/crates/vault-cli-daemon/src/relay_sync.rs +894 -0
  19. package/crates/vault-cli-daemon/tests/system_keychain_helper_acl.rs +167 -0
  20. package/crates/vault-daemon/Cargo.toml +32 -0
  21. package/crates/vault-daemon/src/daemon_parts/api_impl_and_utils.rs +1041 -0
  22. package/crates/vault-daemon/src/daemon_parts/core_helpers.rs +1256 -0
  23. package/crates/vault-daemon/src/daemon_parts/types_api_rpc.rs +622 -0
  24. package/crates/vault-daemon/src/lib.rs +54 -0
  25. package/crates/vault-daemon/src/persistence.rs +441 -0
  26. package/crates/vault-daemon/src/tests.rs +237 -0
  27. package/crates/vault-daemon/src/tests_parts/part1.rs +1224 -0
  28. package/crates/vault-daemon/src/tests_parts/part2.rs +1021 -0
  29. package/crates/vault-daemon/src/tests_parts/part3.rs +835 -0
  30. package/crates/vault-daemon/src/tests_parts/part4.rs +604 -0
  31. package/crates/vault-domain/Cargo.toml +20 -0
  32. package/crates/vault-domain/src/action.rs +849 -0
  33. package/crates/vault-domain/src/address.rs +51 -0
  34. package/crates/vault-domain/src/approval.rs +90 -0
  35. package/crates/vault-domain/src/constants.rs +4 -0
  36. package/crates/vault-domain/src/error.rs +54 -0
  37. package/crates/vault-domain/src/keys.rs +71 -0
  38. package/crates/vault-domain/src/lib.rs +42 -0
  39. package/crates/vault-domain/src/nonce.rs +102 -0
  40. package/crates/vault-domain/src/policy.rs +172 -0
  41. package/crates/vault-domain/src/request.rs +53 -0
  42. package/crates/vault-domain/src/scope.rs +24 -0
  43. package/crates/vault-domain/src/session.rs +50 -0
  44. package/crates/vault-domain/src/signature.rs +34 -0
  45. package/crates/vault-domain/src/tests.rs +651 -0
  46. package/crates/vault-domain/src/u128_as_decimal_string.rs +44 -0
  47. package/crates/vault-policy/Cargo.toml +17 -0
  48. package/crates/vault-policy/src/engine.rs +301 -0
  49. package/crates/vault-policy/src/error.rs +81 -0
  50. package/crates/vault-policy/src/lib.rs +17 -0
  51. package/crates/vault-policy/src/report.rs +34 -0
  52. package/crates/vault-policy/src/tests.rs +891 -0
  53. package/crates/vault-policy/src/tests_explain.rs +78 -0
  54. package/crates/vault-sdk-agent/Cargo.toml +21 -0
  55. package/crates/vault-sdk-agent/src/lib.rs +711 -0
  56. package/crates/vault-signer/Cargo.toml +25 -0
  57. package/crates/vault-signer/src/lib.rs +731 -0
  58. package/crates/vault-signer/tests/secure_enclave_acl.rs +54 -0
  59. package/crates/vault-transport-unix/Cargo.toml +24 -0
  60. package/crates/vault-transport-unix/src/lib.rs +1640 -0
  61. package/crates/vault-transport-xpc/Cargo.toml +25 -0
  62. package/crates/vault-transport-xpc/src/client_codec_api.rs +635 -0
  63. package/crates/vault-transport-xpc/src/lib.rs +680 -0
  64. package/crates/vault-transport-xpc/src/tests.rs +818 -0
  65. package/crates/vault-transport-xpc/tests/e2e_flow.rs +773 -0
  66. package/dist/cli.cjs +35088 -0
  67. package/dist/cli.cjs.map +1 -0
  68. package/package.json +49 -43
  69. package/packages/cache/.turbo/turbo-build.log +52 -0
  70. package/packages/cache/dist/chunk-2QFWMUXT.cjs +43 -0
  71. package/packages/cache/dist/chunk-2QFWMUXT.cjs.map +1 -0
  72. package/packages/cache/dist/chunk-4U63TZTQ.js +43 -0
  73. package/packages/cache/dist/chunk-4U63TZTQ.js.map +1 -0
  74. package/packages/cache/dist/chunk-ALQ6H7KG.cjs +404 -0
  75. package/packages/cache/dist/chunk-ALQ6H7KG.cjs.map +1 -0
  76. package/packages/cache/dist/chunk-FGJEEF5N.js +404 -0
  77. package/packages/cache/dist/chunk-FGJEEF5N.js.map +1 -0
  78. package/packages/cache/dist/chunk-UYNEHZHB.cjs +45 -0
  79. package/packages/cache/dist/chunk-UYNEHZHB.cjs.map +1 -0
  80. package/packages/cache/dist/chunk-VXVMPG3W.js +45 -0
  81. package/packages/cache/dist/chunk-VXVMPG3W.js.map +1 -0
  82. package/packages/cache/dist/client/index.cjs +11 -0
  83. package/packages/cache/dist/client/index.cjs.map +1 -0
  84. package/packages/cache/dist/client/index.d.cts +15 -0
  85. package/packages/cache/dist/client/index.d.ts +15 -0
  86. package/packages/cache/dist/client/index.js +11 -0
  87. package/packages/cache/dist/client/index.js.map +1 -0
  88. package/packages/cache/dist/errors/index.cjs +11 -0
  89. package/packages/cache/dist/errors/index.cjs.map +1 -0
  90. package/packages/cache/dist/errors/index.d.cts +26 -0
  91. package/packages/cache/dist/errors/index.d.ts +26 -0
  92. package/packages/cache/dist/errors/index.js +11 -0
  93. package/packages/cache/dist/errors/index.js.map +1 -0
  94. package/packages/cache/dist/index.cjs +29 -0
  95. package/packages/cache/dist/index.cjs.map +1 -0
  96. package/packages/cache/dist/index.d.cts +4 -0
  97. package/packages/cache/dist/index.d.ts +4 -0
  98. package/packages/cache/dist/index.js +29 -0
  99. package/packages/cache/dist/index.js.map +1 -0
  100. package/packages/cache/dist/service/index.cjs +15 -0
  101. package/packages/cache/dist/service/index.cjs.map +1 -0
  102. package/packages/cache/dist/service/index.d.cts +184 -0
  103. package/packages/cache/dist/service/index.d.ts +184 -0
  104. package/packages/cache/dist/service/index.js +15 -0
  105. package/packages/cache/dist/service/index.js.map +1 -0
  106. package/packages/cache/node_modules/.bin/jiti +17 -0
  107. package/packages/cache/node_modules/.bin/tsc +17 -0
  108. package/packages/cache/node_modules/.bin/tsserver +17 -0
  109. package/packages/cache/node_modules/.bin/tsup +17 -0
  110. package/packages/cache/node_modules/.bin/tsup-node +17 -0
  111. package/packages/cache/node_modules/.bin/tsx +17 -0
  112. package/packages/cache/node_modules/.bin/vitest +17 -0
  113. package/packages/cache/package.json +48 -0
  114. package/packages/cache/src/client/index.ts +56 -0
  115. package/packages/cache/src/errors/index.ts +53 -0
  116. package/packages/cache/src/index.ts +3 -0
  117. package/packages/cache/src/service/index.test.ts +263 -0
  118. package/packages/cache/src/service/index.ts +678 -0
  119. package/packages/cache/tsconfig.json +13 -0
  120. package/packages/cache/tsup.config.ts +13 -0
  121. package/packages/cache/vitest.config.ts +16 -0
  122. package/packages/config/.turbo/turbo-build.log +18 -0
  123. package/packages/config/dist/index.cjs +1037 -0
  124. package/packages/config/dist/index.cjs.map +1 -0
  125. package/packages/config/dist/index.d.ts +131 -0
  126. package/packages/config/node_modules/.bin/jiti +17 -0
  127. package/packages/config/node_modules/.bin/tsc +17 -0
  128. package/packages/config/node_modules/.bin/tsserver +17 -0
  129. package/packages/config/node_modules/.bin/tsup +17 -0
  130. package/packages/config/node_modules/.bin/tsup-node +17 -0
  131. package/packages/config/node_modules/.bin/tsx +17 -0
  132. package/packages/config/package.json +21 -0
  133. package/packages/config/src/index.js +1 -0
  134. package/packages/config/src/index.ts +1282 -0
  135. package/packages/config/tsconfig.json +4 -0
  136. package/packages/rpc/.turbo/turbo-build.log +32 -0
  137. package/packages/rpc/dist/_esm-BCLXDO2R.cjs +3660 -0
  138. package/packages/rpc/dist/_esm-BCLXDO2R.cjs.map +1 -0
  139. package/packages/rpc/dist/ccip-OWJLAW55.cjs +16 -0
  140. package/packages/rpc/dist/ccip-OWJLAW55.cjs.map +1 -0
  141. package/packages/rpc/dist/chunk-APQIFZ3B.cjs +6247 -0
  142. package/packages/rpc/dist/chunk-APQIFZ3B.cjs.map +1 -0
  143. package/packages/rpc/dist/chunk-CDO2GWRD.cjs +410 -0
  144. package/packages/rpc/dist/chunk-CDO2GWRD.cjs.map +1 -0
  145. package/packages/rpc/dist/chunk-QGTNTFJ7.cjs +2249 -0
  146. package/packages/rpc/dist/chunk-QGTNTFJ7.cjs.map +1 -0
  147. package/packages/rpc/dist/chunk-TZDTAHWR.cjs +44 -0
  148. package/packages/rpc/dist/chunk-TZDTAHWR.cjs.map +1 -0
  149. package/packages/rpc/dist/index.cjs +7342 -0
  150. package/packages/rpc/dist/index.cjs.map +1 -0
  151. package/packages/rpc/dist/index.d.ts +3857 -0
  152. package/packages/rpc/dist/secp256k1-WCNM675D.cjs +18 -0
  153. package/packages/rpc/dist/secp256k1-WCNM675D.cjs.map +1 -0
  154. package/packages/rpc/node_modules/.bin/jiti +17 -0
  155. package/packages/rpc/node_modules/.bin/tsc +17 -0
  156. package/packages/rpc/node_modules/.bin/tsserver +17 -0
  157. package/packages/rpc/node_modules/.bin/tsup +17 -0
  158. package/packages/rpc/node_modules/.bin/tsup-node +17 -0
  159. package/packages/rpc/node_modules/.bin/tsx +17 -0
  160. package/packages/rpc/package.json +25 -0
  161. package/packages/rpc/src/index.ts +206 -0
  162. package/packages/rpc/tsconfig.json +4 -0
  163. package/packages/typescript/base.json +36 -0
  164. package/packages/typescript/nextjs.json +17 -0
  165. package/packages/typescript/package.json +10 -0
  166. package/packages/ui/.turbo/turbo-build.log +44 -0
  167. package/packages/ui/dist/chunk-MOAFBKSA.js +11 -0
  168. package/packages/ui/dist/chunk-MOAFBKSA.js.map +1 -0
  169. package/packages/ui/dist/components/badge.d.ts +12 -0
  170. package/packages/ui/dist/components/badge.js +31 -0
  171. package/packages/ui/dist/components/badge.js.map +1 -0
  172. package/packages/ui/dist/components/button.d.ts +13 -0
  173. package/packages/ui/dist/components/button.js +40 -0
  174. package/packages/ui/dist/components/button.js.map +1 -0
  175. package/packages/ui/dist/components/card.d.ts +10 -0
  176. package/packages/ui/dist/components/card.js +39 -0
  177. package/packages/ui/dist/components/card.js.map +1 -0
  178. package/packages/ui/dist/components/input.d.ts +5 -0
  179. package/packages/ui/dist/components/input.js +28 -0
  180. package/packages/ui/dist/components/input.js.map +1 -0
  181. package/packages/ui/dist/components/label.d.ts +5 -0
  182. package/packages/ui/dist/components/label.js +13 -0
  183. package/packages/ui/dist/components/label.js.map +1 -0
  184. package/packages/ui/dist/components/separator.d.ts +5 -0
  185. package/packages/ui/dist/components/separator.js +13 -0
  186. package/packages/ui/dist/components/separator.js.map +1 -0
  187. package/packages/ui/dist/components/textarea.d.ts +5 -0
  188. package/packages/ui/dist/components/textarea.js +27 -0
  189. package/packages/ui/dist/components/textarea.js.map +1 -0
  190. package/packages/ui/dist/tailwind.d.ts +56 -0
  191. package/packages/ui/dist/tailwind.js +60 -0
  192. package/packages/ui/dist/tailwind.js.map +1 -0
  193. package/packages/ui/dist/utils/cn.d.ts +5 -0
  194. package/packages/ui/dist/utils/cn.js +7 -0
  195. package/packages/ui/dist/utils/cn.js.map +1 -0
  196. package/packages/ui/node_modules/.bin/jiti +17 -0
  197. package/packages/ui/node_modules/.bin/tsc +17 -0
  198. package/packages/ui/node_modules/.bin/tsserver +17 -0
  199. package/packages/ui/node_modules/.bin/tsup +17 -0
  200. package/packages/ui/node_modules/.bin/tsup-node +17 -0
  201. package/packages/ui/node_modules/.bin/tsx +17 -0
  202. package/packages/ui/package.json +69 -0
  203. package/packages/ui/src/components/badge.tsx +27 -0
  204. package/packages/ui/src/components/button.tsx +40 -0
  205. package/packages/ui/src/components/card.tsx +31 -0
  206. package/packages/ui/src/components/input.tsx +21 -0
  207. package/packages/ui/src/components/label.tsx +6 -0
  208. package/packages/ui/src/components/separator.tsx +6 -0
  209. package/packages/ui/src/components/textarea.tsx +20 -0
  210. package/packages/ui/src/globals.css +70 -0
  211. package/packages/ui/src/tailwind.ts +56 -0
  212. package/packages/ui/src/utils/cn.ts +6 -0
  213. package/packages/ui/tsconfig.json +20 -0
  214. package/packages/ui/tsup.config.ts +20 -0
  215. package/pnpm-workspace.yaml +4 -0
  216. package/scripts/install-rust-binaries.mjs +84 -0
  217. package/scripts/launchd/install-user-daemon.sh +358 -0
  218. package/scripts/launchd/run-vault-daemon.sh +5 -0
  219. package/scripts/launchd/run-wlfi-agent-daemon.sh +73 -0
  220. package/scripts/launchd/uninstall-user-daemon.sh +103 -0
  221. package/src/cli.ts +2121 -0
  222. package/src/lib/admin-guard.js +1 -0
  223. package/src/lib/admin-guard.ts +185 -0
  224. package/src/lib/admin-passthrough.ts +33 -0
  225. package/src/lib/admin-reset.ts +751 -0
  226. package/src/lib/admin-setup.ts +1612 -0
  227. package/src/lib/agent-auth-clear.js +1 -0
  228. package/src/lib/agent-auth-clear.ts +58 -0
  229. package/src/lib/agent-auth-forwarding.js +1 -0
  230. package/src/lib/agent-auth-forwarding.ts +149 -0
  231. package/src/lib/agent-auth-migrate.js +1 -0
  232. package/src/lib/agent-auth-migrate.ts +150 -0
  233. package/src/lib/agent-auth-revoke.ts +103 -0
  234. package/src/lib/agent-auth-rotate.ts +107 -0
  235. package/src/lib/agent-auth-token.js +1 -0
  236. package/src/lib/agent-auth-token.ts +25 -0
  237. package/src/lib/agent-auth.ts +89 -0
  238. package/src/lib/asset-broadcast.js +1 -0
  239. package/src/lib/asset-broadcast.ts +285 -0
  240. package/src/lib/bootstrap-artifacts.js +1 -0
  241. package/src/lib/bootstrap-artifacts.ts +205 -0
  242. package/src/lib/bootstrap-credentials.js +1 -0
  243. package/src/lib/bootstrap-credentials.ts +832 -0
  244. package/src/lib/config-amounts.js +1 -0
  245. package/src/lib/config-amounts.ts +189 -0
  246. package/src/lib/config-mutation.ts +27 -0
  247. package/src/lib/fs-trust.js +1 -0
  248. package/src/lib/fs-trust.ts +537 -0
  249. package/src/lib/keychain.js +1 -0
  250. package/src/lib/keychain.ts +225 -0
  251. package/src/lib/local-admin-access.ts +106 -0
  252. package/src/lib/network-selection.js +1 -0
  253. package/src/lib/network-selection.ts +71 -0
  254. package/src/lib/passthrough-security.js +1 -0
  255. package/src/lib/passthrough-security.ts +114 -0
  256. package/src/lib/rpc-guard.js +1 -0
  257. package/src/lib/rpc-guard.ts +7 -0
  258. package/src/lib/rust-spawn-options.js +1 -0
  259. package/src/lib/rust-spawn-options.ts +98 -0
  260. package/src/lib/rust.js +1 -0
  261. package/src/lib/rust.ts +143 -0
  262. package/src/lib/signed-tx.js +1 -0
  263. package/src/lib/signed-tx.ts +116 -0
  264. package/src/lib/status-repair-cli.ts +116 -0
  265. package/src/lib/sudo.js +1 -0
  266. package/src/lib/sudo.ts +172 -0
  267. package/src/lib/vault-password-forwarding.js +1 -0
  268. package/src/lib/vault-password-forwarding.ts +155 -0
  269. package/src/lib/wallet-profile.js +1 -0
  270. package/src/lib/wallet-profile.ts +332 -0
  271. package/src/lib/wallet-repair.js +1 -0
  272. package/src/lib/wallet-repair.ts +304 -0
  273. package/src/lib/wallet-setup.js +1 -0
  274. package/src/lib/wallet-setup.ts +1466 -0
  275. package/src/lib/wallet-status.js +1 -0
  276. package/src/lib/wallet-status.ts +640 -0
  277. package/tsconfig.base.json +17 -0
  278. package/tsconfig.json +10 -0
  279. package/tsup.config.ts +25 -0
  280. package/turbo.json +41 -0
  281. package/LICENSE.md +0 -1
  282. package/dist/wlfa/index.cjs +0 -250
  283. package/dist/wlfa/index.d.cts +0 -1
  284. package/dist/wlfa/index.d.ts +0 -1
  285. package/dist/wlfa/index.js +0 -250
  286. package/dist/wlfc/index.cjs +0 -1894
  287. package/dist/wlfc/index.d.cts +0 -1
  288. package/dist/wlfc/index.d.ts +0 -1
  289. package/dist/wlfc/index.js +0 -1894
@@ -0,0 +1,3410 @@
1
+ use std::collections::{BTreeMap, BTreeSet};
2
+ use std::io;
3
+ use std::path::PathBuf;
4
+ use std::time::Duration;
5
+
6
+ use anyhow::{anyhow, bail, Context, Result};
7
+ use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
8
+ use crossterm::execute;
9
+ use crossterm::terminal::{
10
+ disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
11
+ };
12
+ use ratatui::backend::CrosstermBackend;
13
+ use ratatui::layout::{Constraint, Direction, Layout};
14
+ use ratatui::style::{Color, Modifier, Style};
15
+ use ratatui::text::{Line, Span};
16
+ use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Wrap};
17
+ use ratatui::Terminal;
18
+ use uuid::Uuid;
19
+ use vault_domain::EvmAddress;
20
+
21
+ use crate::shared_config::{
22
+ ChainProfile, LoadedConfig, TokenChainProfile, TokenDestinationOverrideProfile,
23
+ TokenManualApprovalProfile, TokenPolicyProfile, TokenProfile, WlfiConfig,
24
+ };
25
+ use crate::{
26
+ BootstrapParams, TokenDestinationPolicyOverride, TokenManualApprovalPolicyConfig,
27
+ TokenPolicyConfig,
28
+ };
29
+
30
+ mod amounts;
31
+ mod token_rpc;
32
+ mod utils;
33
+
34
+ use amounts::{
35
+ format_gwei_amount, format_token_amount, parse_legacy_amount, parse_optional_gwei_amount,
36
+ parse_required_token_amount,
37
+ };
38
+ use token_rpc::fetch_token_metadata;
39
+ use utils::*;
40
+
41
+ fn fetch_token_metadata_sync(
42
+ chain_key: String,
43
+ rpc_url: String,
44
+ expected_chain_id: u64,
45
+ is_native: bool,
46
+ address: Option<EvmAddress>,
47
+ ) -> Result<token_rpc::FetchedTokenMetadata> {
48
+ std::thread::spawn(move || -> Result<token_rpc::FetchedTokenMetadata> {
49
+ let runtime = tokio::runtime::Builder::new_current_thread()
50
+ .enable_all()
51
+ .build()
52
+ .context("failed to create runtime for token metadata refresh")?;
53
+ runtime.block_on(async move {
54
+ fetch_token_metadata(
55
+ &chain_key,
56
+ &rpc_url,
57
+ expected_chain_id,
58
+ is_native,
59
+ address.as_ref(),
60
+ )
61
+ .await
62
+ })
63
+ })
64
+ .join()
65
+ .map_err(|panic| {
66
+ let payload = if let Some(message) = panic.downcast_ref::<&str>() {
67
+ *message
68
+ } else if let Some(message) = panic.downcast_ref::<String>() {
69
+ message.as_str()
70
+ } else {
71
+ "unknown panic"
72
+ };
73
+ anyhow!("token metadata refresh thread panicked: {payload}")
74
+ })?
75
+ }
76
+
77
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
78
+ enum View {
79
+ Tokens,
80
+ Networks,
81
+ Bootstrap,
82
+ }
83
+
84
+ impl View {
85
+ const ALL: [Self; 3] = [Self::Tokens, Self::Networks, Self::Bootstrap];
86
+
87
+ fn title(self) -> &'static str {
88
+ match self {
89
+ Self::Tokens => "Tokens",
90
+ Self::Networks => "Networks",
91
+ Self::Bootstrap => "Bootstrap",
92
+ }
93
+ }
94
+
95
+ fn description(self) -> &'static str {
96
+ match self {
97
+ Self::Tokens => {
98
+ "Saved tokens are the source of truth. Each token owns its default limits, destination overrides, manual approvals, and network mappings."
99
+ }
100
+ Self::Networks => {
101
+ "Saved networks provide the RPC endpoints and chain ids used for token metadata inference and policy expansion."
102
+ }
103
+ Self::Bootstrap => {
104
+ "Review the saved token inventory and bootstrap all configured token policies in one run."
105
+ }
106
+ }
107
+ }
108
+
109
+ fn next(self) -> Self {
110
+ match self {
111
+ Self::Tokens => Self::Networks,
112
+ Self::Networks => Self::Bootstrap,
113
+ Self::Bootstrap => Self::Tokens,
114
+ }
115
+ }
116
+
117
+ fn previous(self) -> Self {
118
+ match self {
119
+ Self::Tokens => Self::Bootstrap,
120
+ Self::Networks => Self::Tokens,
121
+ Self::Bootstrap => Self::Networks,
122
+ }
123
+ }
124
+ }
125
+
126
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
127
+ enum Field {
128
+ SelectedToken,
129
+ TokenKey,
130
+ TokenName,
131
+ TokenSymbol,
132
+ NetworkMembership,
133
+ EditingNetwork,
134
+ NetworkIsNative,
135
+ NetworkAddress,
136
+ RefreshTokenMetadata,
137
+ TokenDecimals,
138
+ PerTxLimit,
139
+ DailyLimit,
140
+ WeeklyLimit,
141
+ ShowAdvanced,
142
+ MaxGasPerChainWei,
143
+ DailyMaxTxCount,
144
+ PerTxMaxFeePerGasGwei,
145
+ PerTxMaxPriorityFeePerGasWei,
146
+ PerTxMaxCalldataBytes,
147
+ DestinationOverrides,
148
+ SelectedDestinationOverride,
149
+ OverrideRecipientAddress,
150
+ OverridePerTxLimit,
151
+ OverrideDailyLimit,
152
+ OverrideWeeklyLimit,
153
+ OverrideMaxGasPerChainWei,
154
+ OverrideDailyMaxTxCount,
155
+ OverridePerTxMaxFeePerGasGwei,
156
+ OverridePerTxMaxPriorityFeePerGasWei,
157
+ OverridePerTxMaxCalldataBytes,
158
+ DeleteDestinationOverride,
159
+ ManualApprovals,
160
+ SelectedManualApproval,
161
+ ManualApprovalRecipientAddress,
162
+ ManualApprovalMinAmount,
163
+ ManualApprovalMaxAmount,
164
+ ManualApprovalPriority,
165
+ DeleteManualApproval,
166
+ SaveToken,
167
+ DeleteToken,
168
+ SelectedNetwork,
169
+ ChainConfigKey,
170
+ ChainConfigId,
171
+ ChainConfigName,
172
+ ChainConfigRpcUrl,
173
+ ChainConfigUseAsActive,
174
+ SaveNetwork,
175
+ DeleteNetwork,
176
+ Execute,
177
+ }
178
+
179
+ #[derive(Debug, Clone, Default, PartialEq, Eq)]
180
+ struct LimitDraft {
181
+ per_tx_limit: String,
182
+ daily_limit: String,
183
+ weekly_limit: String,
184
+ max_gas_per_chain_wei: String,
185
+ daily_max_tx_count: String,
186
+ per_tx_max_fee_per_gas_gwei: String,
187
+ per_tx_max_priority_fee_per_gas_wei: String,
188
+ per_tx_max_calldata_bytes: String,
189
+ }
190
+
191
+ impl LimitDraft {
192
+ fn empty() -> Self {
193
+ Self {
194
+ per_tx_limit: String::new(),
195
+ daily_limit: String::new(),
196
+ weekly_limit: String::new(),
197
+ max_gas_per_chain_wei: String::new(),
198
+ daily_max_tx_count: String::new(),
199
+ per_tx_max_fee_per_gas_gwei: String::new(),
200
+ per_tx_max_priority_fee_per_gas_wei: String::new(),
201
+ per_tx_max_calldata_bytes: String::new(),
202
+ }
203
+ }
204
+
205
+ fn from_policy(policy: Option<&TokenPolicyProfile>, decimals: u8) -> Self {
206
+ let Some(policy) = policy else {
207
+ return Self::empty();
208
+ };
209
+
210
+ Self {
211
+ per_tx_limit: display_policy_amount(
212
+ policy.per_tx_amount_decimal.as_deref(),
213
+ policy.per_tx_limit.as_deref(),
214
+ policy.per_tx_amount,
215
+ decimals,
216
+ )
217
+ .unwrap_or_default(),
218
+ daily_limit: display_policy_amount(
219
+ policy.daily_amount_decimal.as_deref(),
220
+ policy.daily_limit.as_deref(),
221
+ policy.daily_amount,
222
+ decimals,
223
+ )
224
+ .unwrap_or_default(),
225
+ weekly_limit: display_policy_amount(
226
+ policy.weekly_amount_decimal.as_deref(),
227
+ policy.weekly_limit.as_deref(),
228
+ policy.weekly_amount,
229
+ decimals,
230
+ )
231
+ .unwrap_or_default(),
232
+ max_gas_per_chain_wei: policy.max_gas_per_chain_wei.clone().unwrap_or_default(),
233
+ daily_max_tx_count: policy.daily_max_tx_count.clone().unwrap_or_default(),
234
+ per_tx_max_fee_per_gas_gwei: display_policy_gwei(
235
+ policy.per_tx_max_fee_per_gas_gwei.as_deref(),
236
+ policy.per_tx_max_fee_per_gas_wei.as_deref(),
237
+ )
238
+ .unwrap_or_default(),
239
+ per_tx_max_priority_fee_per_gas_wei: policy
240
+ .per_tx_max_priority_fee_per_gas_wei
241
+ .clone()
242
+ .unwrap_or_default(),
243
+ per_tx_max_calldata_bytes: policy.per_tx_max_calldata_bytes.clone().unwrap_or_default(),
244
+ }
245
+ }
246
+
247
+ fn as_token_level_policy(&self, validation_decimals: u8) -> Result<TokenPolicyProfile> {
248
+ validate_limit_draft(self, validation_decimals)?;
249
+
250
+ let per_tx_max_fee_per_gas_wei =
251
+ parse_optional_gwei_amount("max fee per gas", Some(&self.per_tx_max_fee_per_gas_gwei))?;
252
+ let daily_max_tx_count =
253
+ parse_optional_non_negative_u128("daily max tx count", &self.daily_max_tx_count)?;
254
+ let per_tx_max_priority_fee_per_gas_wei = parse_optional_non_negative_u128(
255
+ "max priority fee per gas",
256
+ &self.per_tx_max_priority_fee_per_gas_wei,
257
+ )?;
258
+ let per_tx_max_calldata_bytes = parse_optional_non_negative_u128(
259
+ "max calldata bytes",
260
+ &self.per_tx_max_calldata_bytes,
261
+ )?;
262
+
263
+ Ok(TokenPolicyProfile {
264
+ per_tx_amount: None,
265
+ daily_amount: None,
266
+ weekly_amount: None,
267
+ per_tx_amount_decimal: Some(self.per_tx_limit.trim().to_string()),
268
+ daily_amount_decimal: Some(self.daily_limit.trim().to_string()),
269
+ weekly_amount_decimal: Some(self.weekly_limit.trim().to_string()),
270
+ per_tx_limit: None,
271
+ daily_limit: None,
272
+ weekly_limit: None,
273
+ max_gas_per_chain_wei: optional_trimmed(&self.max_gas_per_chain_wei),
274
+ daily_max_tx_count: optional_non_zero_string(daily_max_tx_count),
275
+ per_tx_max_fee_per_gas_gwei: (per_tx_max_fee_per_gas_wei > 0)
276
+ .then(|| self.per_tx_max_fee_per_gas_gwei.trim().to_string()),
277
+ per_tx_max_fee_per_gas_wei: optional_non_zero_string(per_tx_max_fee_per_gas_wei),
278
+ per_tx_max_priority_fee_per_gas_wei: optional_non_zero_string(
279
+ per_tx_max_priority_fee_per_gas_wei,
280
+ ),
281
+ per_tx_max_calldata_bytes: optional_non_zero_string(per_tx_max_calldata_bytes),
282
+ extra: BTreeMap::new(),
283
+ })
284
+ }
285
+
286
+ fn as_chain_policy(&self, decimals: u8) -> Result<TokenPolicyProfile> {
287
+ let mut policy = self.as_token_level_policy(decimals)?;
288
+ policy.per_tx_limit = Some(
289
+ parse_required_token_amount("per-tx limit", &self.per_tx_limit, decimals)?.to_string(),
290
+ );
291
+ policy.daily_limit = Some(
292
+ parse_required_token_amount("daily limit", &self.daily_limit, decimals)?.to_string(),
293
+ );
294
+ policy.weekly_limit = Some(
295
+ parse_required_token_amount("weekly limit", &self.weekly_limit, decimals)?.to_string(),
296
+ );
297
+ Ok(policy)
298
+ }
299
+ }
300
+
301
+ #[derive(Debug, Clone, Default, PartialEq, Eq)]
302
+ struct TokenNetworkDraft {
303
+ chain_key: String,
304
+ chain_id: String,
305
+ is_native: bool,
306
+ address: String,
307
+ decimals: String,
308
+ }
309
+
310
+ #[derive(Debug, Clone, Default, PartialEq, Eq)]
311
+ struct DestinationOverrideDraft {
312
+ recipient_address: String,
313
+ limits: LimitDraft,
314
+ }
315
+
316
+ #[derive(Debug, Clone, Default, PartialEq, Eq)]
317
+ struct ManualApprovalDraft {
318
+ recipient_address: String,
319
+ min_amount: String,
320
+ max_amount: String,
321
+ priority: String,
322
+ }
323
+
324
+ #[derive(Debug, Clone, Default)]
325
+ struct TokenDraft {
326
+ source_key: Option<String>,
327
+ key: String,
328
+ name: String,
329
+ symbol: String,
330
+ limits: LimitDraft,
331
+ networks: Vec<TokenNetworkDraft>,
332
+ selected_network: usize,
333
+ available_network_index: usize,
334
+ destination_overrides: Vec<DestinationOverrideDraft>,
335
+ selected_override: usize,
336
+ manual_approvals: Vec<ManualApprovalDraft>,
337
+ selected_manual_approval: usize,
338
+ }
339
+
340
+ impl TokenDraft {
341
+ fn blank(config: &WlfiConfig) -> Self {
342
+ let mut draft = Self {
343
+ limits: LimitDraft::empty(),
344
+ ..Self::default()
345
+ };
346
+ if !config.chains.is_empty() {
347
+ draft.available_network_index = 0;
348
+ }
349
+ draft
350
+ }
351
+
352
+ fn from_profile(token_key: &str, profile: &TokenProfile, config: &WlfiConfig) -> Self {
353
+ let mut networks = profile
354
+ .chains
355
+ .iter()
356
+ .map(|(chain_key, chain_profile)| TokenNetworkDraft {
357
+ chain_key: chain_key.clone(),
358
+ chain_id: chain_profile.chain_id.to_string(),
359
+ is_native: chain_profile.is_native,
360
+ address: chain_profile.address.clone().unwrap_or_default(),
361
+ decimals: chain_profile.decimals.to_string(),
362
+ })
363
+ .collect::<Vec<_>>();
364
+ networks.sort_by(|left, right| left.chain_key.cmp(&right.chain_key));
365
+
366
+ let selected_decimals = networks
367
+ .first()
368
+ .and_then(|network| network.decimals.parse::<u8>().ok())
369
+ .unwrap_or(18);
370
+ let policy = profile
371
+ .default_policy
372
+ .as_ref()
373
+ .or_else(|| first_chain_policy(profile));
374
+ let destination_overrides = profile
375
+ .destination_overrides
376
+ .iter()
377
+ .map(|override_profile| DestinationOverrideDraft {
378
+ recipient_address: override_profile.recipient.clone(),
379
+ limits: LimitDraft::from_policy(Some(&override_profile.limits), selected_decimals),
380
+ })
381
+ .collect::<Vec<_>>();
382
+ let manual_approvals = profile
383
+ .manual_approval_policies
384
+ .iter()
385
+ .map(|manual| ManualApprovalDraft {
386
+ recipient_address: manual.recipient.clone().unwrap_or_default(),
387
+ min_amount: display_manual_amount(
388
+ manual.min_amount_decimal.as_deref(),
389
+ manual.min_amount_wei.as_deref(),
390
+ manual.min_amount,
391
+ selected_decimals,
392
+ )
393
+ .unwrap_or_default(),
394
+ max_amount: display_manual_amount(
395
+ manual.max_amount_decimal.as_deref(),
396
+ manual.max_amount_wei.as_deref(),
397
+ manual.max_amount,
398
+ selected_decimals,
399
+ )
400
+ .unwrap_or_default(),
401
+ priority: if manual.priority == 0 {
402
+ "100".to_string()
403
+ } else {
404
+ manual.priority.to_string()
405
+ },
406
+ })
407
+ .collect::<Vec<_>>();
408
+
409
+ let available_network_index = sorted_chain_keys(config)
410
+ .iter()
411
+ .position(|chain_key| {
412
+ networks
413
+ .first()
414
+ .map(|network| &network.chain_key == chain_key)
415
+ .unwrap_or(false)
416
+ })
417
+ .unwrap_or(0);
418
+
419
+ Self {
420
+ source_key: Some(token_key.to_string()),
421
+ key: token_key.to_string(),
422
+ name: profile.name.clone().unwrap_or_default(),
423
+ symbol: profile.symbol.clone(),
424
+ limits: LimitDraft::from_policy(policy, selected_decimals),
425
+ networks,
426
+ selected_network: 0,
427
+ available_network_index,
428
+ destination_overrides,
429
+ selected_override: 0,
430
+ manual_approvals,
431
+ selected_manual_approval: 0,
432
+ }
433
+ }
434
+
435
+ fn selected_network(&self) -> Option<&TokenNetworkDraft> {
436
+ self.networks.get(self.selected_network)
437
+ }
438
+
439
+ fn selected_network_mut(&mut self) -> Option<&mut TokenNetworkDraft> {
440
+ self.networks.get_mut(self.selected_network)
441
+ }
442
+
443
+ fn min_network_decimals(&self) -> Result<u8> {
444
+ let mut decimals = self
445
+ .networks
446
+ .iter()
447
+ .map(|network| {
448
+ parse_positive_u64("token network decimals", &network.decimals).and_then(|value| {
449
+ if value > u8::MAX as u64 {
450
+ bail!("token network decimals must be <= {}", u8::MAX);
451
+ }
452
+ Ok(value as u8)
453
+ })
454
+ })
455
+ .collect::<Result<Vec<_>>>()?;
456
+ decimals.sort_unstable();
457
+ decimals
458
+ .into_iter()
459
+ .next()
460
+ .context("select at least one network for the token")
461
+ }
462
+
463
+ fn normalize(&mut self) {
464
+ if self.networks.is_empty() {
465
+ self.selected_network = 0;
466
+ } else if self.selected_network >= self.networks.len() {
467
+ self.selected_network = self.networks.len() - 1;
468
+ }
469
+ if self.destination_overrides.is_empty() {
470
+ self.selected_override = 0;
471
+ } else if self.selected_override >= self.destination_overrides.len() {
472
+ self.selected_override = self.destination_overrides.len() - 1;
473
+ }
474
+ if self.manual_approvals.is_empty() {
475
+ self.selected_manual_approval = 0;
476
+ } else if self.selected_manual_approval >= self.manual_approvals.len() {
477
+ self.selected_manual_approval = self.manual_approvals.len() - 1;
478
+ }
479
+ }
480
+
481
+ fn toggle_network_membership(&mut self, config: &WlfiConfig) -> Result<()> {
482
+ let available = sorted_chain_keys(config);
483
+ let Some(chain_key) = available.get(self.available_network_index) else {
484
+ bail!("save a network before adding it to a token");
485
+ };
486
+ if let Some(index) = self
487
+ .networks
488
+ .iter()
489
+ .position(|network| network.chain_key == *chain_key)
490
+ {
491
+ self.networks.remove(index);
492
+ self.normalize();
493
+ return Ok(());
494
+ }
495
+
496
+ let chain_profile = config
497
+ .chains
498
+ .get(chain_key)
499
+ .with_context(|| format!("saved network '{}' no longer exists", chain_key))?;
500
+ self.networks.push(TokenNetworkDraft {
501
+ chain_key: chain_key.clone(),
502
+ chain_id: chain_profile.chain_id.to_string(),
503
+ is_native: false,
504
+ address: String::new(),
505
+ decimals: String::new(),
506
+ });
507
+ self.networks
508
+ .sort_by(|left, right| left.chain_key.cmp(&right.chain_key));
509
+ self.selected_network = self
510
+ .networks
511
+ .iter()
512
+ .position(|network| network.chain_key == *chain_key)
513
+ .unwrap_or(0);
514
+ Ok(())
515
+ }
516
+
517
+ fn to_profile(&self, config: &WlfiConfig) -> Result<(Option<String>, String, TokenProfile)> {
518
+ let token_key = self.key.trim().to_lowercase();
519
+ if token_key.is_empty() {
520
+ bail!("token key is required");
521
+ }
522
+ if self.name.trim().is_empty() {
523
+ bail!("fetch token metadata before saving the token");
524
+ }
525
+ if self.symbol.trim().is_empty() {
526
+ bail!("fetch token metadata before saving the token");
527
+ }
528
+ if self.networks.is_empty() {
529
+ bail!("select at least one network for the token");
530
+ }
531
+
532
+ let validation_decimals = self.min_network_decimals()?;
533
+ let default_policy = Some(self.limits.as_token_level_policy(validation_decimals)?);
534
+
535
+ let mut chains = BTreeMap::new();
536
+ for network in &self.networks {
537
+ let chain_key = network.chain_key.trim().to_lowercase();
538
+ let chain_profile = config
539
+ .chains
540
+ .get(&chain_key)
541
+ .with_context(|| format!("unknown saved network '{}'", chain_key))?;
542
+ let decimals = parse_positive_u64(
543
+ &format!("token '{}:{}' decimals", token_key, chain_key),
544
+ &network.decimals,
545
+ )?;
546
+ if decimals > u8::MAX as u64 {
547
+ bail!(
548
+ "token '{}:{}' decimals must be <= {}",
549
+ token_key,
550
+ chain_key,
551
+ u8::MAX
552
+ );
553
+ }
554
+ let is_native = network.is_native;
555
+ let address = if is_native {
556
+ if !network.address.trim().is_empty() {
557
+ bail!(
558
+ "token '{}:{}' must not set an address when native",
559
+ token_key,
560
+ chain_key
561
+ );
562
+ }
563
+ None
564
+ } else {
565
+ Some(
566
+ parse_address(
567
+ &format!("token '{}:{}'", token_key, chain_key),
568
+ &network.address,
569
+ )?
570
+ .to_string(),
571
+ )
572
+ };
573
+ chains.insert(
574
+ chain_key.clone(),
575
+ TokenChainProfile {
576
+ chain_id: chain_profile.chain_id,
577
+ is_native,
578
+ address,
579
+ decimals: decimals as u8,
580
+ default_policy: Some(self.limits.as_chain_policy(decimals as u8)?),
581
+ extra: BTreeMap::new(),
582
+ },
583
+ );
584
+ }
585
+
586
+ let destination_overrides = self
587
+ .destination_overrides
588
+ .iter()
589
+ .map(|override_draft| {
590
+ Ok(TokenDestinationOverrideProfile {
591
+ recipient: parse_address(
592
+ "destination override recipient",
593
+ &override_draft.recipient_address,
594
+ )?
595
+ .to_string(),
596
+ limits: override_draft
597
+ .limits
598
+ .as_token_level_policy(validation_decimals)?,
599
+ })
600
+ })
601
+ .collect::<Result<Vec<_>>>()?;
602
+
603
+ let manual_approval_policies = self
604
+ .manual_approvals
605
+ .iter()
606
+ .map(|manual_draft| {
607
+ let priority =
608
+ parse_positive_u64("manual approval priority", &manual_draft.priority)?;
609
+ if priority > u32::MAX as u64 {
610
+ bail!("manual approval priority must be <= {}", u32::MAX);
611
+ }
612
+ parse_required_token_amount(
613
+ "manual approval min amount",
614
+ &manual_draft.min_amount,
615
+ validation_decimals,
616
+ )?;
617
+ parse_required_token_amount(
618
+ "manual approval max amount",
619
+ &manual_draft.max_amount,
620
+ validation_decimals,
621
+ )?;
622
+ Ok(TokenManualApprovalProfile {
623
+ priority: priority as u32,
624
+ recipient: if manual_draft.recipient_address.trim().is_empty() {
625
+ None
626
+ } else {
627
+ Some(
628
+ parse_address(
629
+ "manual approval recipient",
630
+ &manual_draft.recipient_address,
631
+ )?
632
+ .to_string(),
633
+ )
634
+ },
635
+ min_amount: None,
636
+ max_amount: None,
637
+ min_amount_decimal: Some(manual_draft.min_amount.trim().to_string()),
638
+ max_amount_decimal: Some(manual_draft.max_amount.trim().to_string()),
639
+ min_amount_wei: None,
640
+ max_amount_wei: None,
641
+ extra: BTreeMap::new(),
642
+ })
643
+ })
644
+ .collect::<Result<Vec<_>>>()?;
645
+
646
+ Ok((
647
+ self.source_key.clone(),
648
+ token_key,
649
+ TokenProfile {
650
+ name: Some(self.name.trim().to_string()),
651
+ symbol: self.symbol.trim().to_string(),
652
+ default_policy,
653
+ destination_overrides,
654
+ manual_approval_policies,
655
+ chains,
656
+ extra: BTreeMap::new(),
657
+ },
658
+ ))
659
+ }
660
+ }
661
+
662
+ #[derive(Debug, Clone, Default)]
663
+ struct NetworkDraft {
664
+ source_key: Option<String>,
665
+ key: String,
666
+ chain_id: String,
667
+ name: String,
668
+ rpc_url: String,
669
+ use_as_active: bool,
670
+ }
671
+
672
+ impl NetworkDraft {
673
+ fn blank() -> Self {
674
+ Self::default()
675
+ }
676
+
677
+ fn from_profile(key: &str, profile: &ChainProfile, config: &WlfiConfig) -> Self {
678
+ Self {
679
+ source_key: Some(key.to_string()),
680
+ key: key.to_string(),
681
+ chain_id: profile.chain_id.to_string(),
682
+ name: profile.name.clone(),
683
+ rpc_url: profile.rpc_url.clone().unwrap_or_default(),
684
+ use_as_active: config.chain_name.as_deref() == Some(key)
685
+ || config.chain_id == Some(profile.chain_id),
686
+ }
687
+ }
688
+
689
+ fn to_profile(&self) -> Result<(Option<String>, String, ChainProfile, bool)> {
690
+ let key = self.key.trim().to_lowercase();
691
+ if key.is_empty() {
692
+ bail!("network key is required");
693
+ }
694
+ let chain_id = parse_positive_u64("network chain id", &self.chain_id)?;
695
+ let name = if self.name.trim().is_empty() {
696
+ key.clone()
697
+ } else {
698
+ self.name.trim().to_string()
699
+ };
700
+ let rpc_url = if self.rpc_url.trim().is_empty() {
701
+ None
702
+ } else {
703
+ Some(self.rpc_url.trim().to_string())
704
+ };
705
+ Ok((
706
+ self.source_key.clone(),
707
+ key,
708
+ ChainProfile {
709
+ chain_id,
710
+ name,
711
+ rpc_url,
712
+ extra: BTreeMap::new(),
713
+ },
714
+ self.use_as_active,
715
+ ))
716
+ }
717
+ }
718
+
719
+ #[derive(Debug)]
720
+ struct AppState {
721
+ view: View,
722
+ selected: usize,
723
+ config_path: PathBuf,
724
+ shared_config_draft: WlfiConfig,
725
+ token_draft: TokenDraft,
726
+ network_draft: NetworkDraft,
727
+ show_advanced: bool,
728
+ print_agent_auth_token: bool,
729
+ message: Option<String>,
730
+ }
731
+
732
+ impl AppState {
733
+ fn from_shared_config(config: &WlfiConfig, print_agent_auth_token: bool) -> Self {
734
+ let token_draft = sorted_token_keys(config)
735
+ .first()
736
+ .and_then(|token_key| {
737
+ config
738
+ .tokens
739
+ .get(token_key)
740
+ .map(|profile| TokenDraft::from_profile(token_key, profile, config))
741
+ })
742
+ .unwrap_or_else(|| TokenDraft::blank(config));
743
+ let network_draft = sorted_chain_keys(config)
744
+ .first()
745
+ .and_then(|chain_key| {
746
+ config
747
+ .chains
748
+ .get(chain_key)
749
+ .map(|profile| NetworkDraft::from_profile(chain_key, profile, config))
750
+ })
751
+ .unwrap_or_else(NetworkDraft::blank);
752
+
753
+ Self {
754
+ view: View::Tokens,
755
+ selected: 0,
756
+ config_path: crate::shared_config::default_config_path()
757
+ .unwrap_or_else(|_| PathBuf::from("config.json")),
758
+ shared_config_draft: config.clone(),
759
+ token_draft,
760
+ network_draft,
761
+ show_advanced: false,
762
+ print_agent_auth_token,
763
+ message: None,
764
+ }
765
+ }
766
+
767
+ fn visible_fields(&self) -> Vec<Field> {
768
+ match self.view {
769
+ View::Tokens => {
770
+ let mut fields = vec![
771
+ Field::SelectedToken,
772
+ Field::TokenKey,
773
+ Field::TokenName,
774
+ Field::TokenSymbol,
775
+ Field::NetworkMembership,
776
+ ];
777
+ if !self.token_draft.networks.is_empty() {
778
+ fields.extend([
779
+ Field::EditingNetwork,
780
+ Field::NetworkIsNative,
781
+ Field::NetworkAddress,
782
+ Field::RefreshTokenMetadata,
783
+ Field::TokenDecimals,
784
+ ]);
785
+ }
786
+ fields.extend([
787
+ Field::PerTxLimit,
788
+ Field::DailyLimit,
789
+ Field::WeeklyLimit,
790
+ Field::ShowAdvanced,
791
+ Field::DestinationOverrides,
792
+ ]);
793
+ if self.show_advanced {
794
+ fields.extend([
795
+ Field::MaxGasPerChainWei,
796
+ Field::DailyMaxTxCount,
797
+ Field::PerTxMaxFeePerGasGwei,
798
+ Field::PerTxMaxPriorityFeePerGasWei,
799
+ Field::PerTxMaxCalldataBytes,
800
+ ]);
801
+ }
802
+ if !self.token_draft.destination_overrides.is_empty() {
803
+ fields.extend([
804
+ Field::SelectedDestinationOverride,
805
+ Field::OverrideRecipientAddress,
806
+ Field::OverridePerTxLimit,
807
+ Field::OverrideDailyLimit,
808
+ Field::OverrideWeeklyLimit,
809
+ Field::DeleteDestinationOverride,
810
+ ]);
811
+ if self.show_advanced {
812
+ fields.extend([
813
+ Field::OverrideMaxGasPerChainWei,
814
+ Field::OverrideDailyMaxTxCount,
815
+ Field::OverridePerTxMaxFeePerGasGwei,
816
+ Field::OverridePerTxMaxPriorityFeePerGasWei,
817
+ Field::OverridePerTxMaxCalldataBytes,
818
+ ]);
819
+ }
820
+ }
821
+ fields.push(Field::ManualApprovals);
822
+ if !self.token_draft.manual_approvals.is_empty() {
823
+ fields.extend([
824
+ Field::SelectedManualApproval,
825
+ Field::ManualApprovalRecipientAddress,
826
+ Field::ManualApprovalMinAmount,
827
+ Field::ManualApprovalMaxAmount,
828
+ Field::ManualApprovalPriority,
829
+ Field::DeleteManualApproval,
830
+ ]);
831
+ }
832
+ fields.extend([Field::SaveToken, Field::DeleteToken]);
833
+ fields
834
+ }
835
+ View::Networks => vec![
836
+ Field::SelectedNetwork,
837
+ Field::ChainConfigKey,
838
+ Field::ChainConfigId,
839
+ Field::ChainConfigName,
840
+ Field::ChainConfigRpcUrl,
841
+ Field::ChainConfigUseAsActive,
842
+ Field::SaveNetwork,
843
+ Field::DeleteNetwork,
844
+ ],
845
+ View::Bootstrap => vec![Field::Execute],
846
+ }
847
+ }
848
+
849
+ fn normalize_selection(&mut self) {
850
+ let len = self.visible_fields().len();
851
+ if len == 0 {
852
+ self.selected = 0;
853
+ } else if self.selected >= len {
854
+ self.selected = len - 1;
855
+ }
856
+ self.token_draft.normalize();
857
+ }
858
+
859
+ fn selected_field(&self) -> Field {
860
+ self.visible_fields()[self.selected]
861
+ }
862
+
863
+ fn next_view(&mut self) {
864
+ self.view = self.view.next();
865
+ self.selected = 0;
866
+ self.message = None;
867
+ self.normalize_selection();
868
+ }
869
+
870
+ fn previous_view(&mut self) {
871
+ self.view = self.view.previous();
872
+ self.selected = 0;
873
+ self.message = None;
874
+ self.normalize_selection();
875
+ }
876
+
877
+ fn select_next(&mut self) {
878
+ let len = self.visible_fields().len();
879
+ if len > 0 {
880
+ self.selected = (self.selected + 1) % len;
881
+ }
882
+ }
883
+
884
+ fn select_prev(&mut self) {
885
+ let len = self.visible_fields().len();
886
+ if len > 0 {
887
+ self.selected = if self.selected == 0 {
888
+ len - 1
889
+ } else {
890
+ self.selected - 1
891
+ };
892
+ }
893
+ }
894
+
895
+ fn reload_current_view(&mut self) {
896
+ match self.view {
897
+ View::Tokens => {
898
+ let source_key = self.token_draft.source_key.clone();
899
+ self.load_token_draft(source_key.as_deref());
900
+ }
901
+ View::Networks => {
902
+ let source_key = self.network_draft.source_key.clone();
903
+ self.load_network_draft(source_key.as_deref());
904
+ }
905
+ View::Bootstrap => {}
906
+ }
907
+ }
908
+
909
+ fn load_token_draft(&mut self, token_key: Option<&str>) {
910
+ self.token_draft = token_key
911
+ .and_then(|token_key| {
912
+ self.shared_config_draft
913
+ .tokens
914
+ .get(token_key)
915
+ .map(|profile| {
916
+ TokenDraft::from_profile(token_key, profile, &self.shared_config_draft)
917
+ })
918
+ })
919
+ .or_else(|| {
920
+ sorted_token_keys(&self.shared_config_draft)
921
+ .first()
922
+ .and_then(|token_key| {
923
+ self.shared_config_draft
924
+ .tokens
925
+ .get(token_key)
926
+ .map(|profile| {
927
+ TokenDraft::from_profile(
928
+ token_key,
929
+ profile,
930
+ &self.shared_config_draft,
931
+ )
932
+ })
933
+ })
934
+ })
935
+ .unwrap_or_else(|| TokenDraft::blank(&self.shared_config_draft));
936
+ self.normalize_selection();
937
+ }
938
+
939
+ fn load_network_draft(&mut self, chain_key: Option<&str>) {
940
+ self.network_draft = chain_key
941
+ .and_then(|chain_key| {
942
+ self.shared_config_draft
943
+ .chains
944
+ .get(chain_key)
945
+ .map(|profile| {
946
+ NetworkDraft::from_profile(chain_key, profile, &self.shared_config_draft)
947
+ })
948
+ })
949
+ .or_else(|| {
950
+ sorted_chain_keys(&self.shared_config_draft)
951
+ .first()
952
+ .and_then(|chain_key| {
953
+ self.shared_config_draft
954
+ .chains
955
+ .get(chain_key)
956
+ .map(|profile| {
957
+ NetworkDraft::from_profile(
958
+ chain_key,
959
+ profile,
960
+ &self.shared_config_draft,
961
+ )
962
+ })
963
+ })
964
+ })
965
+ .unwrap_or_else(NetworkDraft::blank);
966
+ self.normalize_selection();
967
+ }
968
+
969
+ fn new_token_draft(&mut self) {
970
+ self.token_draft = TokenDraft::blank(&self.shared_config_draft);
971
+ self.message = Some("new token draft ready".to_string());
972
+ }
973
+
974
+ fn new_network_draft(&mut self) {
975
+ self.network_draft = NetworkDraft::blank();
976
+ self.message = Some("new network draft ready".to_string());
977
+ }
978
+
979
+ fn step_selected(&mut self, direction: i8) {
980
+ match self.selected_field() {
981
+ Field::SelectedToken => self.cycle_saved_token(direction),
982
+ Field::NetworkMembership => self.cycle_available_network(direction),
983
+ Field::EditingNetwork => self.cycle_selected_network_mapping(direction),
984
+ Field::NetworkIsNative => {
985
+ if let Some(network) = self.token_draft.selected_network_mut() {
986
+ network.is_native = !network.is_native;
987
+ if network.is_native {
988
+ network.address.clear();
989
+ }
990
+ }
991
+ }
992
+ Field::SelectedDestinationOverride => {
993
+ cycle_index(
994
+ &mut self.token_draft.selected_override,
995
+ self.token_draft.destination_overrides.len(),
996
+ direction,
997
+ );
998
+ }
999
+ Field::SelectedManualApproval => {
1000
+ cycle_index(
1001
+ &mut self.token_draft.selected_manual_approval,
1002
+ self.token_draft.manual_approvals.len(),
1003
+ direction,
1004
+ );
1005
+ }
1006
+ Field::SelectedNetwork => self.cycle_saved_network(direction),
1007
+ Field::ShowAdvanced => {
1008
+ self.show_advanced = !self.show_advanced;
1009
+ }
1010
+ Field::ChainConfigUseAsActive => {
1011
+ self.network_draft.use_as_active = !self.network_draft.use_as_active;
1012
+ }
1013
+ _ => {}
1014
+ }
1015
+ }
1016
+
1017
+ fn cycle_saved_token(&mut self, direction: i8) {
1018
+ let saved = sorted_token_keys(&self.shared_config_draft);
1019
+ if saved.is_empty() {
1020
+ self.new_token_draft();
1021
+ return;
1022
+ }
1023
+ let mut entries = saved;
1024
+ entries.push("<new>".to_string());
1025
+ let current = self
1026
+ .token_draft
1027
+ .source_key
1028
+ .clone()
1029
+ .unwrap_or_else(|| "<new>".to_string());
1030
+ let mut index = entries
1031
+ .iter()
1032
+ .position(|entry| *entry == current)
1033
+ .unwrap_or(entries.len() - 1);
1034
+ cycle_index(&mut index, entries.len(), direction);
1035
+ if entries[index] == "<new>" {
1036
+ self.new_token_draft();
1037
+ } else {
1038
+ let key = entries[index].clone();
1039
+ self.load_token_draft(Some(&key));
1040
+ self.message = None;
1041
+ }
1042
+ }
1043
+
1044
+ fn cycle_saved_network(&mut self, direction: i8) {
1045
+ let saved = sorted_chain_keys(&self.shared_config_draft);
1046
+ if saved.is_empty() {
1047
+ self.new_network_draft();
1048
+ return;
1049
+ }
1050
+ let mut entries = saved;
1051
+ entries.push("<new>".to_string());
1052
+ let current = self
1053
+ .network_draft
1054
+ .source_key
1055
+ .clone()
1056
+ .unwrap_or_else(|| "<new>".to_string());
1057
+ let mut index = entries
1058
+ .iter()
1059
+ .position(|entry| *entry == current)
1060
+ .unwrap_or(entries.len() - 1);
1061
+ cycle_index(&mut index, entries.len(), direction);
1062
+ if entries[index] == "<new>" {
1063
+ self.new_network_draft();
1064
+ } else {
1065
+ let key = entries[index].clone();
1066
+ self.load_network_draft(Some(&key));
1067
+ self.message = None;
1068
+ }
1069
+ }
1070
+
1071
+ fn cycle_available_network(&mut self, direction: i8) {
1072
+ let available = sorted_chain_keys(&self.shared_config_draft);
1073
+ cycle_index(
1074
+ &mut self.token_draft.available_network_index,
1075
+ available.len(),
1076
+ direction,
1077
+ );
1078
+ }
1079
+
1080
+ fn cycle_selected_network_mapping(&mut self, direction: i8) {
1081
+ cycle_index(
1082
+ &mut self.token_draft.selected_network,
1083
+ self.token_draft.networks.len(),
1084
+ direction,
1085
+ );
1086
+ }
1087
+
1088
+ fn edit_selected(&mut self, key: KeyEvent) {
1089
+ let selected_field = self.selected_field();
1090
+ let target = match selected_field {
1091
+ Field::TokenKey => Some(&mut self.token_draft.key),
1092
+ Field::NetworkAddress => self
1093
+ .token_draft
1094
+ .selected_network_mut()
1095
+ .map(|network| &mut network.address),
1096
+ Field::PerTxLimit => Some(&mut self.token_draft.limits.per_tx_limit),
1097
+ Field::DailyLimit => Some(&mut self.token_draft.limits.daily_limit),
1098
+ Field::WeeklyLimit => Some(&mut self.token_draft.limits.weekly_limit),
1099
+ Field::MaxGasPerChainWei => Some(&mut self.token_draft.limits.max_gas_per_chain_wei),
1100
+ Field::DailyMaxTxCount => Some(&mut self.token_draft.limits.daily_max_tx_count),
1101
+ Field::PerTxMaxFeePerGasGwei => {
1102
+ Some(&mut self.token_draft.limits.per_tx_max_fee_per_gas_gwei)
1103
+ }
1104
+ Field::PerTxMaxPriorityFeePerGasWei => {
1105
+ Some(&mut self.token_draft.limits.per_tx_max_priority_fee_per_gas_wei)
1106
+ }
1107
+ Field::PerTxMaxCalldataBytes => {
1108
+ Some(&mut self.token_draft.limits.per_tx_max_calldata_bytes)
1109
+ }
1110
+ Field::OverrideRecipientAddress => self
1111
+ .token_draft
1112
+ .destination_overrides
1113
+ .get_mut(self.token_draft.selected_override)
1114
+ .map(|item| &mut item.recipient_address),
1115
+ Field::OverridePerTxLimit => self
1116
+ .token_draft
1117
+ .destination_overrides
1118
+ .get_mut(self.token_draft.selected_override)
1119
+ .map(|item| &mut item.limits.per_tx_limit),
1120
+ Field::OverrideDailyLimit => self
1121
+ .token_draft
1122
+ .destination_overrides
1123
+ .get_mut(self.token_draft.selected_override)
1124
+ .map(|item| &mut item.limits.daily_limit),
1125
+ Field::OverrideWeeklyLimit => self
1126
+ .token_draft
1127
+ .destination_overrides
1128
+ .get_mut(self.token_draft.selected_override)
1129
+ .map(|item| &mut item.limits.weekly_limit),
1130
+ Field::OverrideMaxGasPerChainWei => self
1131
+ .token_draft
1132
+ .destination_overrides
1133
+ .get_mut(self.token_draft.selected_override)
1134
+ .map(|item| &mut item.limits.max_gas_per_chain_wei),
1135
+ Field::OverrideDailyMaxTxCount => self
1136
+ .token_draft
1137
+ .destination_overrides
1138
+ .get_mut(self.token_draft.selected_override)
1139
+ .map(|item| &mut item.limits.daily_max_tx_count),
1140
+ Field::OverridePerTxMaxFeePerGasGwei => self
1141
+ .token_draft
1142
+ .destination_overrides
1143
+ .get_mut(self.token_draft.selected_override)
1144
+ .map(|item| &mut item.limits.per_tx_max_fee_per_gas_gwei),
1145
+ Field::OverridePerTxMaxPriorityFeePerGasWei => self
1146
+ .token_draft
1147
+ .destination_overrides
1148
+ .get_mut(self.token_draft.selected_override)
1149
+ .map(|item| &mut item.limits.per_tx_max_priority_fee_per_gas_wei),
1150
+ Field::OverridePerTxMaxCalldataBytes => self
1151
+ .token_draft
1152
+ .destination_overrides
1153
+ .get_mut(self.token_draft.selected_override)
1154
+ .map(|item| &mut item.limits.per_tx_max_calldata_bytes),
1155
+ Field::ManualApprovalRecipientAddress => self
1156
+ .token_draft
1157
+ .manual_approvals
1158
+ .get_mut(self.token_draft.selected_manual_approval)
1159
+ .map(|item| &mut item.recipient_address),
1160
+ Field::ManualApprovalMinAmount => self
1161
+ .token_draft
1162
+ .manual_approvals
1163
+ .get_mut(self.token_draft.selected_manual_approval)
1164
+ .map(|item| &mut item.min_amount),
1165
+ Field::ManualApprovalMaxAmount => self
1166
+ .token_draft
1167
+ .manual_approvals
1168
+ .get_mut(self.token_draft.selected_manual_approval)
1169
+ .map(|item| &mut item.max_amount),
1170
+ Field::ManualApprovalPriority => self
1171
+ .token_draft
1172
+ .manual_approvals
1173
+ .get_mut(self.token_draft.selected_manual_approval)
1174
+ .map(|item| &mut item.priority),
1175
+ Field::ChainConfigKey => Some(&mut self.network_draft.key),
1176
+ Field::ChainConfigId => Some(&mut self.network_draft.chain_id),
1177
+ Field::ChainConfigName => Some(&mut self.network_draft.name),
1178
+ Field::ChainConfigRpcUrl => Some(&mut self.network_draft.rpc_url),
1179
+ _ => None,
1180
+ };
1181
+
1182
+ let Some(target) = target else {
1183
+ return;
1184
+ };
1185
+
1186
+ match key.code {
1187
+ KeyCode::Backspace => {
1188
+ target.pop();
1189
+ self.message = None;
1190
+ }
1191
+ KeyCode::Char(ch)
1192
+ if !key.modifiers.contains(KeyModifiers::CONTROL)
1193
+ && !key.modifiers.contains(KeyModifiers::ALT) =>
1194
+ {
1195
+ if is_allowed_input_char(selected_field, ch) {
1196
+ target.push(ch);
1197
+ self.message = None;
1198
+ } else {
1199
+ self.message = Some(format!("invalid character '{ch}' for the selected field"));
1200
+ }
1201
+ }
1202
+ _ => {}
1203
+ }
1204
+ }
1205
+
1206
+ fn add_destination_override(&mut self) {
1207
+ self.token_draft
1208
+ .destination_overrides
1209
+ .push(DestinationOverrideDraft {
1210
+ recipient_address: String::new(),
1211
+ limits: self.token_draft.limits.clone(),
1212
+ });
1213
+ self.token_draft.selected_override = self
1214
+ .token_draft
1215
+ .destination_overrides
1216
+ .len()
1217
+ .saturating_sub(1);
1218
+ self.message = Some("destination override added".to_string());
1219
+ }
1220
+
1221
+ fn delete_destination_override(&mut self) {
1222
+ if self.token_draft.destination_overrides.is_empty() {
1223
+ self.message = Some("no destination override is selected".to_string());
1224
+ return;
1225
+ }
1226
+ self.token_draft
1227
+ .destination_overrides
1228
+ .remove(self.token_draft.selected_override);
1229
+ self.token_draft.normalize();
1230
+ self.message = Some("destination override removed".to_string());
1231
+ }
1232
+
1233
+ fn add_manual_approval(&mut self) {
1234
+ self.token_draft.manual_approvals.push(ManualApprovalDraft {
1235
+ recipient_address: String::new(),
1236
+ min_amount: self.token_draft.limits.daily_limit.clone(),
1237
+ max_amount: self.token_draft.limits.weekly_limit.clone(),
1238
+ priority: "100".to_string(),
1239
+ });
1240
+ self.token_draft.selected_manual_approval =
1241
+ self.token_draft.manual_approvals.len().saturating_sub(1);
1242
+ self.message = Some("manual approval policy added".to_string());
1243
+ }
1244
+
1245
+ fn delete_manual_approval(&mut self) {
1246
+ if self.token_draft.manual_approvals.is_empty() {
1247
+ self.message = Some("no manual approval policy is selected".to_string());
1248
+ return;
1249
+ }
1250
+ self.token_draft
1251
+ .manual_approvals
1252
+ .remove(self.token_draft.selected_manual_approval);
1253
+ self.token_draft.normalize();
1254
+ self.message = Some("manual approval policy removed".to_string());
1255
+ }
1256
+
1257
+ fn refresh_token_metadata(&mut self) -> Result<()> {
1258
+ let selected_network = self
1259
+ .token_draft
1260
+ .selected_network()
1261
+ .cloned()
1262
+ .context("select a token network first")?;
1263
+ let chain_profile = self
1264
+ .shared_config_draft
1265
+ .chains
1266
+ .get(&selected_network.chain_key)
1267
+ .with_context(|| format!("unknown saved network '{}'", selected_network.chain_key))?;
1268
+ let rpc_url = chain_profile
1269
+ .rpc_url
1270
+ .as_deref()
1271
+ .or(self.shared_config_draft.rpc_url.as_deref())
1272
+ .context("the selected network needs an rpc url before metadata can be fetched")?;
1273
+ let address = if selected_network.is_native {
1274
+ None
1275
+ } else {
1276
+ Some(parse_address("token address", &selected_network.address)?)
1277
+ };
1278
+ let metadata = fetch_token_metadata_sync(
1279
+ selected_network.chain_key.clone(),
1280
+ rpc_url.to_string(),
1281
+ chain_profile.chain_id,
1282
+ selected_network.is_native,
1283
+ address,
1284
+ )?;
1285
+
1286
+ self.token_draft.name = metadata.name;
1287
+ self.token_draft.symbol = metadata.symbol;
1288
+ if self.token_draft.key.trim().is_empty() {
1289
+ self.token_draft.key = self.token_draft.symbol.to_lowercase();
1290
+ }
1291
+ if let Some(network) = self.token_draft.selected_network_mut() {
1292
+ network.chain_id = metadata.chain_id.to_string();
1293
+ network.decimals = metadata.decimals.to_string();
1294
+ }
1295
+ self.message = Some("token metadata refreshed from rpc".to_string());
1296
+ Ok(())
1297
+ }
1298
+
1299
+ fn selected_token_requires_metadata_refresh(&self) -> bool {
1300
+ let Some(selected_network) = self.token_draft.selected_network() else {
1301
+ return false;
1302
+ };
1303
+ let has_asset_locator =
1304
+ selected_network.is_native || !selected_network.address.trim().is_empty();
1305
+ let missing_metadata = self.token_draft.name.trim().is_empty()
1306
+ || self.token_draft.symbol.trim().is_empty()
1307
+ || selected_network.chain_id.trim().is_empty()
1308
+ || selected_network.decimals.trim().is_empty();
1309
+ has_asset_locator && missing_metadata
1310
+ }
1311
+
1312
+ fn persist_shared_config(&mut self) -> Result<()> {
1313
+ let loaded = LoadedConfig {
1314
+ path: self.config_path.clone(),
1315
+ config: self.shared_config_draft.clone(),
1316
+ };
1317
+ loaded.save()
1318
+ }
1319
+
1320
+ fn save_token_config(&mut self) -> Result<()> {
1321
+ let (source_key, token_key, token_profile) =
1322
+ self.token_draft.to_profile(&self.shared_config_draft)?;
1323
+ let mut candidate = self.shared_config_draft.clone();
1324
+ if let Some(old_key) = source_key.as_ref() {
1325
+ if old_key != &token_key {
1326
+ candidate.tokens.remove(old_key);
1327
+ }
1328
+ }
1329
+ candidate.tokens.insert(token_key.clone(), token_profile);
1330
+
1331
+ let token_policies = resolve_all_token_policies(&candidate)?;
1332
+ let _ = resolve_all_token_destination_overrides(&candidate, &token_policies)?;
1333
+ let _ = resolve_all_token_manual_approval_policies(&candidate, &token_policies)?;
1334
+
1335
+ self.shared_config_draft = candidate;
1336
+ self.persist_shared_config()?;
1337
+ self.load_token_draft(Some(&token_key));
1338
+ self.message = Some(format!("saved token '{}'", token_key));
1339
+ Ok(())
1340
+ }
1341
+
1342
+ fn delete_token_config(&mut self) -> Result<()> {
1343
+ let key = self
1344
+ .token_draft
1345
+ .source_key
1346
+ .clone()
1347
+ .or_else(|| {
1348
+ let trimmed = self.token_draft.key.trim().to_lowercase();
1349
+ (!trimmed.is_empty()).then_some(trimmed)
1350
+ })
1351
+ .context("no saved token is selected")?;
1352
+ if self.shared_config_draft.tokens.remove(&key).is_none() {
1353
+ bail!("no saved token exists for '{}'", key);
1354
+ }
1355
+ self.persist_shared_config()?;
1356
+ self.load_token_draft(None);
1357
+ self.message = Some(format!("deleted token '{}'", key));
1358
+ Ok(())
1359
+ }
1360
+
1361
+ fn save_network_config(&mut self) -> Result<()> {
1362
+ let (source_key, chain_key, profile, use_as_active) = self.network_draft.to_profile()?;
1363
+ let mut candidate = self.shared_config_draft.clone();
1364
+
1365
+ if let Some(old_key) = source_key.as_ref() {
1366
+ if old_key != &chain_key {
1367
+ if candidate.chains.remove(old_key).is_none() {
1368
+ bail!("no saved network exists for '{}'", old_key);
1369
+ }
1370
+ for token_profile in candidate.tokens.values_mut() {
1371
+ if let Some(chain_profile) = token_profile.chains.remove(old_key) {
1372
+ token_profile
1373
+ .chains
1374
+ .insert(chain_key.clone(), chain_profile);
1375
+ }
1376
+ }
1377
+ }
1378
+ }
1379
+
1380
+ candidate.chains.insert(chain_key.clone(), profile.clone());
1381
+ if use_as_active {
1382
+ candidate.chain_id = Some(profile.chain_id);
1383
+ candidate.chain_name = Some(profile.name.clone());
1384
+ candidate.rpc_url = profile.rpc_url.clone();
1385
+ }
1386
+
1387
+ self.shared_config_draft = candidate;
1388
+ self.persist_shared_config()?;
1389
+ self.load_network_draft(Some(&chain_key));
1390
+ let token_source_key = self.token_draft.source_key.clone();
1391
+ self.load_token_draft(token_source_key.as_deref());
1392
+ self.message = Some(format!("saved network '{}'", chain_key));
1393
+ Ok(())
1394
+ }
1395
+
1396
+ fn delete_network_config(&mut self) -> Result<()> {
1397
+ let key = self
1398
+ .network_draft
1399
+ .source_key
1400
+ .clone()
1401
+ .or_else(|| {
1402
+ let trimmed = self.network_draft.key.trim().to_lowercase();
1403
+ (!trimmed.is_empty()).then_some(trimmed)
1404
+ })
1405
+ .context("no saved network is selected")?;
1406
+ if let Some(token_key) =
1407
+ self.shared_config_draft
1408
+ .tokens
1409
+ .iter()
1410
+ .find_map(|(token_key, token_profile)| {
1411
+ token_profile
1412
+ .chains
1413
+ .contains_key(&key)
1414
+ .then_some(token_key.clone())
1415
+ })
1416
+ {
1417
+ bail!(
1418
+ "network '{}' is still used by token '{}'; remove the mapping first",
1419
+ key,
1420
+ token_key
1421
+ );
1422
+ }
1423
+ if self.shared_config_draft.chains.remove(&key).is_none() {
1424
+ bail!("no saved network exists for '{}'", key);
1425
+ }
1426
+ self.persist_shared_config()?;
1427
+ self.load_network_draft(None);
1428
+ self.message = Some(format!("deleted network '{}'", key));
1429
+ Ok(())
1430
+ }
1431
+
1432
+ fn build_params(&self) -> Result<BootstrapParams> {
1433
+ build_bootstrap_params_from_shared_config(
1434
+ &self.shared_config_draft,
1435
+ self.print_agent_auth_token,
1436
+ true,
1437
+ )
1438
+ }
1439
+ }
1440
+
1441
+ pub(crate) fn build_bootstrap_params_from_shared_config(
1442
+ config: &WlfiConfig,
1443
+ print_agent_auth_token: bool,
1444
+ reuse_existing_wallet: bool,
1445
+ ) -> Result<BootstrapParams> {
1446
+ let token_policies = resolve_all_token_policies(config)?;
1447
+ let token_destination_overrides =
1448
+ resolve_all_token_destination_overrides(config, &token_policies)?;
1449
+ let token_manual_approval_policies =
1450
+ resolve_all_token_manual_approval_policies(config, &token_policies)?;
1451
+
1452
+ if token_policies.is_empty() {
1453
+ bail!("save at least one token before running bootstrap");
1454
+ }
1455
+
1456
+ let (existing_vault_key_id, existing_vault_public_key) = if reuse_existing_wallet {
1457
+ let wallet = config.wallet.as_ref();
1458
+ let parsed_vault_key_id = wallet
1459
+ .and_then(|profile| profile.vault_key_id.as_deref())
1460
+ .map(|value| {
1461
+ Uuid::parse_str(value)
1462
+ .with_context(|| format!("wallet.vaultKeyId '{value}' must be a valid UUID"))
1463
+ })
1464
+ .transpose()?;
1465
+ let vault_public_key = wallet.and_then(|profile| {
1466
+ let trimmed = profile.vault_public_key.trim();
1467
+ (!trimmed.is_empty()).then(|| trimmed.to_string())
1468
+ });
1469
+ (parsed_vault_key_id, vault_public_key)
1470
+ } else {
1471
+ (None, None)
1472
+ };
1473
+
1474
+ Ok(BootstrapParams {
1475
+ per_tx_max_wei: 0,
1476
+ daily_max_wei: 0,
1477
+ weekly_max_wei: 0,
1478
+ max_gas_per_chain_wei: 0,
1479
+ daily_max_tx_count: 0,
1480
+ per_tx_max_fee_per_gas_wei: 0,
1481
+ per_tx_max_priority_fee_per_gas_wei: 0,
1482
+ per_tx_max_calldata_bytes: 0,
1483
+ tokens: Vec::new(),
1484
+ allow_native_eth: false,
1485
+ network: None,
1486
+ recipient: None,
1487
+ token_policies,
1488
+ destination_overrides: Vec::new(),
1489
+ token_destination_overrides,
1490
+ token_manual_approval_policies,
1491
+ attach_policy_ids: Vec::new(),
1492
+ print_agent_auth_token,
1493
+ print_vault_private_key: false,
1494
+ existing_vault_key_id,
1495
+ existing_vault_public_key,
1496
+ })
1497
+ }
1498
+
1499
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1500
+ struct ResolvedLimitFields {
1501
+ per_tx_max_wei: u128,
1502
+ daily_max_wei: u128,
1503
+ weekly_max_wei: u128,
1504
+ max_gas_per_chain_wei: u128,
1505
+ daily_max_tx_count: u128,
1506
+ per_tx_max_fee_per_gas_wei: u128,
1507
+ per_tx_max_priority_fee_per_gas_wei: u128,
1508
+ per_tx_max_calldata_bytes: u128,
1509
+ }
1510
+
1511
+ impl ResolvedLimitFields {
1512
+ fn from_token_policy(policy: &TokenPolicyConfig) -> Self {
1513
+ Self {
1514
+ per_tx_max_wei: policy.per_tx_max_wei,
1515
+ daily_max_wei: policy.daily_max_wei,
1516
+ weekly_max_wei: policy.weekly_max_wei,
1517
+ max_gas_per_chain_wei: policy.max_gas_per_chain_wei,
1518
+ daily_max_tx_count: policy.daily_max_tx_count,
1519
+ per_tx_max_fee_per_gas_wei: policy.per_tx_max_fee_per_gas_wei,
1520
+ per_tx_max_priority_fee_per_gas_wei: policy.per_tx_max_priority_fee_per_gas_wei,
1521
+ per_tx_max_calldata_bytes: policy.per_tx_max_calldata_bytes,
1522
+ }
1523
+ }
1524
+
1525
+ fn from_override(override_policy: &TokenDestinationPolicyOverride) -> Self {
1526
+ Self {
1527
+ per_tx_max_wei: override_policy.per_tx_max_wei,
1528
+ daily_max_wei: override_policy.daily_max_wei,
1529
+ weekly_max_wei: override_policy.weekly_max_wei,
1530
+ max_gas_per_chain_wei: override_policy.max_gas_per_chain_wei,
1531
+ daily_max_tx_count: override_policy.daily_max_tx_count,
1532
+ per_tx_max_fee_per_gas_wei: override_policy.per_tx_max_fee_per_gas_wei,
1533
+ per_tx_max_priority_fee_per_gas_wei: override_policy
1534
+ .per_tx_max_priority_fee_per_gas_wei,
1535
+ per_tx_max_calldata_bytes: override_policy.per_tx_max_calldata_bytes,
1536
+ }
1537
+ }
1538
+ }
1539
+
1540
+ enum LoopAction {
1541
+ Continue,
1542
+ ApplyAndContinue {
1543
+ params: Box<BootstrapParams>,
1544
+ success_message: String,
1545
+ },
1546
+ ApplyAndExit(Box<BootstrapParams>),
1547
+ Cancel,
1548
+ }
1549
+
1550
+ pub(crate) fn run_bootstrap_tui<T>(
1551
+ shared_config: &WlfiConfig,
1552
+ print_agent_auth_token: bool,
1553
+ on_apply: impl FnMut(BootstrapParams) -> Result<T>,
1554
+ ) -> Result<Option<T>> {
1555
+ enable_raw_mode().context("failed to enable raw mode")?;
1556
+ let mut stdout = io::stdout();
1557
+ execute!(stdout, EnterAlternateScreen).context("failed to enter alternate screen")?;
1558
+ let backend = CrosstermBackend::new(stdout);
1559
+ let mut terminal = Terminal::new(backend).context("failed to initialize terminal backend")?;
1560
+
1561
+ let run_result = run_event_loop(
1562
+ &mut terminal,
1563
+ AppState::from_shared_config(shared_config, print_agent_auth_token),
1564
+ on_apply,
1565
+ );
1566
+ let cleanup_result = cleanup_terminal(&mut terminal);
1567
+ match (run_result, cleanup_result) {
1568
+ (Ok(output), Ok(())) => Ok(output),
1569
+ (Err(run_err), Ok(())) => Err(run_err),
1570
+ (Ok(_), Err(cleanup_err)) => Err(cleanup_err),
1571
+ (Err(run_err), Err(cleanup_err)) => Err(run_err.context(cleanup_err.to_string())),
1572
+ }
1573
+ }
1574
+
1575
+ fn cleanup_terminal(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
1576
+ disable_raw_mode().context("failed to disable raw mode")?;
1577
+ execute!(terminal.backend_mut(), LeaveAlternateScreen)
1578
+ .context("failed to leave alternate screen")?;
1579
+ terminal.show_cursor().context("failed to show cursor")?;
1580
+ Ok(())
1581
+ }
1582
+
1583
+ fn run_event_loop<T>(
1584
+ terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
1585
+ mut app: AppState,
1586
+ mut on_apply: impl FnMut(BootstrapParams) -> Result<T>,
1587
+ ) -> Result<Option<T>> {
1588
+ let mut last_output = None;
1589
+ loop {
1590
+ app.normalize_selection();
1591
+ terminal
1592
+ .draw(|frame| draw_ui(frame, &app))
1593
+ .context("failed to render tui frame")?;
1594
+
1595
+ if !event::poll(Duration::from_millis(250)).context("failed to poll terminal events")? {
1596
+ continue;
1597
+ }
1598
+
1599
+ let Event::Key(key) = event::read().context("failed to read terminal event")? else {
1600
+ continue;
1601
+ };
1602
+ if key.kind != KeyEventKind::Press {
1603
+ continue;
1604
+ }
1605
+
1606
+ match handle_key_event(&mut app, key)? {
1607
+ LoopAction::Continue => {}
1608
+ LoopAction::ApplyAndContinue {
1609
+ params,
1610
+ success_message,
1611
+ } => match on_apply(*params) {
1612
+ Ok(output) => {
1613
+ last_output = Some(output);
1614
+ app.message = Some(success_message);
1615
+ }
1616
+ Err(err) => {
1617
+ app.message = Some(err.to_string());
1618
+ }
1619
+ },
1620
+ LoopAction::ApplyAndExit(params) => match on_apply(*params) {
1621
+ Ok(output) => return Ok(Some(output)),
1622
+ Err(err) => {
1623
+ app.message = Some(err.to_string());
1624
+ }
1625
+ },
1626
+ LoopAction::Cancel => return Ok(last_output),
1627
+ }
1628
+ }
1629
+ }
1630
+
1631
+ fn handle_key_event(app: &mut AppState, key: KeyEvent) -> Result<LoopAction> {
1632
+ if key.code == KeyCode::Char('s') && key.modifiers.contains(KeyModifiers::CONTROL) {
1633
+ match app.build_params() {
1634
+ Ok(params) => return Ok(LoopAction::ApplyAndExit(Box::new(params))),
1635
+ Err(err) => {
1636
+ app.message = Some(err.to_string());
1637
+ return Ok(LoopAction::Continue);
1638
+ }
1639
+ }
1640
+ }
1641
+
1642
+ if key.code == KeyCode::Char('r') && key.modifiers.contains(KeyModifiers::CONTROL) {
1643
+ app.reload_current_view();
1644
+ app.message = Some("reloaded saved data into the current draft".to_string());
1645
+ return Ok(LoopAction::Continue);
1646
+ }
1647
+
1648
+ if key.code == KeyCode::Char('n') && key.modifiers.contains(KeyModifiers::CONTROL) {
1649
+ match app.view {
1650
+ View::Tokens => app.new_token_draft(),
1651
+ View::Networks => app.new_network_draft(),
1652
+ View::Bootstrap => {}
1653
+ }
1654
+ return Ok(LoopAction::Continue);
1655
+ }
1656
+
1657
+ if key.code == KeyCode::Char('o') && key.modifiers.contains(KeyModifiers::CONTROL) {
1658
+ if app.view == View::Tokens {
1659
+ app.add_destination_override();
1660
+ }
1661
+ return Ok(LoopAction::Continue);
1662
+ }
1663
+
1664
+ if key.code == KeyCode::Char('m') && key.modifiers.contains(KeyModifiers::CONTROL) {
1665
+ if app.view == View::Tokens {
1666
+ app.add_manual_approval();
1667
+ }
1668
+ return Ok(LoopAction::Continue);
1669
+ }
1670
+
1671
+ match key.code {
1672
+ KeyCode::Esc => return Ok(LoopAction::Cancel),
1673
+ KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1674
+ return Ok(LoopAction::Cancel);
1675
+ }
1676
+ KeyCode::Char('q') if key.modifiers.is_empty() => return Ok(LoopAction::Cancel),
1677
+ KeyCode::Down | KeyCode::Char('j') => {
1678
+ app.select_next();
1679
+ app.message = None;
1680
+ return Ok(LoopAction::Continue);
1681
+ }
1682
+ KeyCode::Up | KeyCode::Char('k') => {
1683
+ app.select_prev();
1684
+ app.message = None;
1685
+ return Ok(LoopAction::Continue);
1686
+ }
1687
+ KeyCode::BackTab => {
1688
+ app.previous_view();
1689
+ return Ok(LoopAction::Continue);
1690
+ }
1691
+ KeyCode::Tab => {
1692
+ app.next_view();
1693
+ return Ok(LoopAction::Continue);
1694
+ }
1695
+ KeyCode::Home => {
1696
+ app.selected = 0;
1697
+ return Ok(LoopAction::Continue);
1698
+ }
1699
+ KeyCode::End => {
1700
+ app.selected = app.visible_fields().len().saturating_sub(1);
1701
+ return Ok(LoopAction::Continue);
1702
+ }
1703
+ KeyCode::Left | KeyCode::Char('h') => {
1704
+ app.step_selected(-1);
1705
+ app.message = None;
1706
+ return Ok(LoopAction::Continue);
1707
+ }
1708
+ KeyCode::Right | KeyCode::Char('l') => {
1709
+ app.step_selected(1);
1710
+ app.message = None;
1711
+ return Ok(LoopAction::Continue);
1712
+ }
1713
+ KeyCode::Char(' ') => match app.selected_field() {
1714
+ Field::NetworkMembership => {
1715
+ if let Err(err) = app
1716
+ .token_draft
1717
+ .toggle_network_membership(&app.shared_config_draft)
1718
+ {
1719
+ app.message = Some(err.to_string());
1720
+ } else {
1721
+ app.message = Some("updated token network selection".to_string());
1722
+ }
1723
+ return Ok(LoopAction::Continue);
1724
+ }
1725
+ Field::SelectedToken
1726
+ | Field::EditingNetwork
1727
+ | Field::SelectedDestinationOverride
1728
+ | Field::SelectedManualApproval
1729
+ | Field::SelectedNetwork
1730
+ | Field::NetworkIsNative
1731
+ | Field::ShowAdvanced
1732
+ | Field::ChainConfigUseAsActive => {
1733
+ app.step_selected(1);
1734
+ return Ok(LoopAction::Continue);
1735
+ }
1736
+ _ => {}
1737
+ },
1738
+ KeyCode::Char('[') if key.modifiers.is_empty() => {
1739
+ app.previous_view();
1740
+ return Ok(LoopAction::Continue);
1741
+ }
1742
+ KeyCode::Char(']') if key.modifiers.is_empty() => {
1743
+ app.next_view();
1744
+ return Ok(LoopAction::Continue);
1745
+ }
1746
+ KeyCode::Enter => match app.selected_field() {
1747
+ Field::Execute => match app.build_params() {
1748
+ Ok(params) => return Ok(LoopAction::ApplyAndExit(Box::new(params))),
1749
+ Err(err) => {
1750
+ app.message = Some(err.to_string());
1751
+ return Ok(LoopAction::Continue);
1752
+ }
1753
+ },
1754
+ Field::NetworkMembership => {
1755
+ if let Err(err) = app
1756
+ .token_draft
1757
+ .toggle_network_membership(&app.shared_config_draft)
1758
+ {
1759
+ app.message = Some(err.to_string());
1760
+ } else {
1761
+ app.message = Some("updated token network selection".to_string());
1762
+ }
1763
+ return Ok(LoopAction::Continue);
1764
+ }
1765
+ Field::RefreshTokenMetadata => {
1766
+ if let Err(err) = app.refresh_token_metadata() {
1767
+ app.message = Some(err.to_string());
1768
+ }
1769
+ return Ok(LoopAction::Continue);
1770
+ }
1771
+ Field::DestinationOverrides => {
1772
+ app.add_destination_override();
1773
+ return Ok(LoopAction::Continue);
1774
+ }
1775
+ Field::DeleteDestinationOverride => {
1776
+ app.delete_destination_override();
1777
+ return Ok(LoopAction::Continue);
1778
+ }
1779
+ Field::ManualApprovals => {
1780
+ app.add_manual_approval();
1781
+ return Ok(LoopAction::Continue);
1782
+ }
1783
+ Field::DeleteManualApproval => {
1784
+ app.delete_manual_approval();
1785
+ return Ok(LoopAction::Continue);
1786
+ }
1787
+ Field::SaveToken => {
1788
+ if app.selected_token_requires_metadata_refresh() {
1789
+ if let Err(err) = app.refresh_token_metadata() {
1790
+ app.message = Some(err.to_string());
1791
+ return Ok(LoopAction::Continue);
1792
+ }
1793
+ }
1794
+ if let Err(err) = app.save_token_config() {
1795
+ app.message = Some(err.to_string());
1796
+ return Ok(LoopAction::Continue);
1797
+ }
1798
+ let success_message = app
1799
+ .message
1800
+ .clone()
1801
+ .unwrap_or_else(|| "saved token".to_string());
1802
+ match app.build_params() {
1803
+ Ok(params) => {
1804
+ return Ok(LoopAction::ApplyAndContinue {
1805
+ params: Box::new(params),
1806
+ success_message: format!("{success_message} and applied to wallet"),
1807
+ });
1808
+ }
1809
+ Err(err) => {
1810
+ app.message = Some(format!(
1811
+ "{success_message} but failed to apply to wallet: {err}"
1812
+ ));
1813
+ return Ok(LoopAction::Continue);
1814
+ }
1815
+ }
1816
+ }
1817
+ Field::DeleteToken => {
1818
+ if let Err(err) = app.delete_token_config() {
1819
+ app.message = Some(err.to_string());
1820
+ }
1821
+ return Ok(LoopAction::Continue);
1822
+ }
1823
+ Field::SaveNetwork => {
1824
+ if let Err(err) = app.save_network_config() {
1825
+ app.message = Some(err.to_string());
1826
+ }
1827
+ return Ok(LoopAction::Continue);
1828
+ }
1829
+ Field::DeleteNetwork => {
1830
+ if let Err(err) = app.delete_network_config() {
1831
+ app.message = Some(err.to_string());
1832
+ }
1833
+ return Ok(LoopAction::Continue);
1834
+ }
1835
+ Field::SelectedToken
1836
+ | Field::EditingNetwork
1837
+ | Field::SelectedDestinationOverride
1838
+ | Field::SelectedManualApproval
1839
+ | Field::SelectedNetwork
1840
+ | Field::NetworkIsNative
1841
+ | Field::ShowAdvanced
1842
+ | Field::ChainConfigUseAsActive => {
1843
+ app.step_selected(1);
1844
+ return Ok(LoopAction::Continue);
1845
+ }
1846
+ _ => {}
1847
+ },
1848
+ _ => {}
1849
+ }
1850
+
1851
+ app.edit_selected(key);
1852
+ Ok(LoopAction::Continue)
1853
+ }
1854
+
1855
+ fn draw_ui(frame: &mut ratatui::Frame<'_>, app: &AppState) {
1856
+ let areas = Layout::default()
1857
+ .direction(Direction::Vertical)
1858
+ .constraints([
1859
+ Constraint::Length(4),
1860
+ Constraint::Min(18),
1861
+ Constraint::Length(8),
1862
+ Constraint::Length(3),
1863
+ ])
1864
+ .split(frame.area());
1865
+
1866
+ let header_lines = vec![
1867
+ Line::from(Span::styled(
1868
+ "WLFI Admin Policy Editor",
1869
+ Style::default()
1870
+ .fg(Color::Cyan)
1871
+ .add_modifier(Modifier::BOLD),
1872
+ )),
1873
+ Line::from(app.view.description()),
1874
+ Line::from(
1875
+ View::ALL
1876
+ .iter()
1877
+ .map(|view| {
1878
+ let style = if *view == app.view {
1879
+ Style::default()
1880
+ .fg(Color::Black)
1881
+ .bg(Color::Cyan)
1882
+ .add_modifier(Modifier::BOLD)
1883
+ } else {
1884
+ Style::default().fg(Color::Gray)
1885
+ };
1886
+ Span::styled(format!(" {} ", view.title()), style)
1887
+ })
1888
+ .collect::<Vec<_>>(),
1889
+ ),
1890
+ ];
1891
+ let header =
1892
+ Paragraph::new(header_lines).block(Block::default().borders(Borders::ALL).title("Views"));
1893
+ frame.render_widget(header, areas[0]);
1894
+
1895
+ let body = Layout::default()
1896
+ .direction(Direction::Horizontal)
1897
+ .constraints([Constraint::Percentage(53), Constraint::Percentage(47)])
1898
+ .split(areas[1]);
1899
+
1900
+ let fields = app.visible_fields();
1901
+ let items: Vec<ListItem<'_>> = fields
1902
+ .iter()
1903
+ .enumerate()
1904
+ .map(|(index, field)| {
1905
+ let selected = index == app.selected;
1906
+ let style = if selected {
1907
+ Style::default()
1908
+ .fg(Color::Black)
1909
+ .bg(Color::Cyan)
1910
+ .add_modifier(Modifier::BOLD)
1911
+ } else {
1912
+ Style::default()
1913
+ };
1914
+ ListItem::new(Line::from(vec![
1915
+ Span::styled(field_label(*field), style),
1916
+ Span::styled(": ", style),
1917
+ Span::styled(field_value(app, *field), style),
1918
+ ]))
1919
+ })
1920
+ .collect();
1921
+ let form = List::new(items).block(
1922
+ Block::default()
1923
+ .borders(Borders::ALL)
1924
+ .title(app.view.title()),
1925
+ );
1926
+ frame.render_widget(form, body[0]);
1927
+
1928
+ let panel = Paragraph::new(build_panel_lines(app))
1929
+ .wrap(Wrap { trim: true })
1930
+ .block(
1931
+ Block::default()
1932
+ .borders(Borders::ALL)
1933
+ .title(build_panel_title(app.view)),
1934
+ );
1935
+ frame.render_widget(panel, body[1]);
1936
+
1937
+ let help = Paragraph::new(build_help_lines(app.view))
1938
+ .wrap(Wrap { trim: true })
1939
+ .block(Block::default().borders(Borders::ALL).title("Keys"));
1940
+ frame.render_widget(help, areas[2]);
1941
+
1942
+ let default_message =
1943
+ "Ready. Start with tokens; saved tokens expand across all selected networks at bootstrap.";
1944
+ let message = app.message.as_deref().unwrap_or(default_message);
1945
+ let style = if app.message.is_some() {
1946
+ Style::default().fg(Color::Red)
1947
+ } else {
1948
+ Style::default().fg(Color::Green)
1949
+ };
1950
+ frame.render_widget(
1951
+ Paragraph::new(Line::from(Span::styled(message, style)))
1952
+ .block(Block::default().borders(Borders::ALL).title("Status")),
1953
+ areas[3],
1954
+ );
1955
+ }
1956
+
1957
+ fn build_help_lines(view: View) -> Vec<Line<'static>> {
1958
+ let mut lines = vec![
1959
+ Line::from("Views: Tab/Shift+Tab or ]/["),
1960
+ Line::from("Fields: ↑/↓ j/k Home End"),
1961
+ Line::from("Toggle/cycle: ←/→ h/l Space"),
1962
+ Line::from("Actions: Enter"),
1963
+ Line::from("Drafts: Ctrl+N new, Ctrl+R reload, Ctrl+S bootstrap"),
1964
+ ];
1965
+ match view {
1966
+ View::Tokens => {
1967
+ lines.push(Line::from(
1968
+ "Tokens: Ctrl+O add override, Ctrl+M add manual approval",
1969
+ ));
1970
+ lines.push(Line::from(
1971
+ "Token name, symbol, and decimals come from RPC metadata refresh.",
1972
+ ));
1973
+ lines.push(Line::from(
1974
+ "Advanced exposes gas, fee, tx count, and calldata limits.",
1975
+ ));
1976
+ }
1977
+ View::Networks => {
1978
+ lines.push(Line::from(
1979
+ "Networks: save an rpc url before fetching token metadata.",
1980
+ ));
1981
+ }
1982
+ View::Bootstrap => {
1983
+ lines.push(Line::from(
1984
+ "Bootstrap uses every saved token, override, and manual approval.",
1985
+ ));
1986
+ }
1987
+ }
1988
+ lines
1989
+ }
1990
+
1991
+ fn build_panel_title(view: View) -> &'static str {
1992
+ match view {
1993
+ View::Tokens => "Token Inventory",
1994
+ View::Networks => "Network Inventory",
1995
+ View::Bootstrap => "Bootstrap Summary",
1996
+ }
1997
+ }
1998
+
1999
+ fn build_panel_lines(app: &AppState) -> Vec<Line<'static>> {
2000
+ match app.view {
2001
+ View::Tokens => build_token_panel_lines(app),
2002
+ View::Networks => build_network_panel_lines(app),
2003
+ View::Bootstrap => build_bootstrap_panel_lines(app),
2004
+ }
2005
+ }
2006
+
2007
+ fn build_token_panel_lines(app: &AppState) -> Vec<Line<'static>> {
2008
+ let mut lines = vec![Line::from(Span::styled(
2009
+ "Saved tokens",
2010
+ Style::default().add_modifier(Modifier::BOLD),
2011
+ ))];
2012
+ if app.shared_config_draft.tokens.is_empty() {
2013
+ lines.push(Line::from("No saved tokens yet."));
2014
+ } else {
2015
+ for token_key in sorted_token_keys(&app.shared_config_draft)
2016
+ .into_iter()
2017
+ .take(6)
2018
+ {
2019
+ if let Some(profile) = app.shared_config_draft.tokens.get(&token_key) {
2020
+ lines.push(Line::from(render_saved_token_summary(&token_key, profile)));
2021
+ }
2022
+ }
2023
+ }
2024
+
2025
+ lines.push(Line::from(""));
2026
+ lines.push(Line::from(Span::styled(
2027
+ "Current token draft",
2028
+ Style::default().add_modifier(Modifier::BOLD),
2029
+ )));
2030
+ lines.extend(render_token_draft_lines(
2031
+ &app.token_draft,
2032
+ app.show_advanced,
2033
+ ));
2034
+ lines
2035
+ }
2036
+
2037
+ fn build_network_panel_lines(app: &AppState) -> Vec<Line<'static>> {
2038
+ let mut lines = vec![Line::from(Span::styled(
2039
+ "Saved networks",
2040
+ Style::default().add_modifier(Modifier::BOLD),
2041
+ ))];
2042
+ if app.shared_config_draft.chains.is_empty() {
2043
+ lines.push(Line::from("No saved networks yet."));
2044
+ } else {
2045
+ for chain_key in sorted_chain_keys(&app.shared_config_draft) {
2046
+ let profile = &app.shared_config_draft.chains[&chain_key];
2047
+ let active =
2048
+ if app.shared_config_draft.chain_name.as_deref() == Some(chain_key.as_str()) {
2049
+ " (active)"
2050
+ } else {
2051
+ ""
2052
+ };
2053
+ let rpc = profile
2054
+ .rpc_url
2055
+ .as_deref()
2056
+ .map(|value| format!(" @ {value}"))
2057
+ .unwrap_or_default();
2058
+ lines.push(Line::from(format!(
2059
+ "{} — chain {}{}{}",
2060
+ chain_key, profile.chain_id, rpc, active
2061
+ )));
2062
+ }
2063
+ }
2064
+
2065
+ lines.push(Line::from(""));
2066
+ lines.push(Line::from(Span::styled(
2067
+ "Current network draft",
2068
+ Style::default().add_modifier(Modifier::BOLD),
2069
+ )));
2070
+ lines.push(Line::from(format!(
2071
+ "key={} chain_id={} name={}",
2072
+ if app.network_draft.key.trim().is_empty() {
2073
+ "<new>"
2074
+ } else {
2075
+ app.network_draft.key.trim()
2076
+ },
2077
+ blank_if_empty(&app.network_draft.chain_id),
2078
+ blank_if_empty(&app.network_draft.name)
2079
+ )));
2080
+ lines.push(Line::from(format!(
2081
+ "rpc={}",
2082
+ blank_if_empty(&app.network_draft.rpc_url)
2083
+ )));
2084
+ lines.push(Line::from(format!(
2085
+ "active network: {}",
2086
+ bool_label(app.network_draft.use_as_active)
2087
+ )));
2088
+ lines
2089
+ }
2090
+
2091
+ fn build_bootstrap_panel_lines(app: &AppState) -> Vec<Line<'static>> {
2092
+ let mut lines = vec![Line::from(Span::styled(
2093
+ "Saved token policies",
2094
+ Style::default().add_modifier(Modifier::BOLD),
2095
+ ))];
2096
+ match resolve_all_token_policies(&app.shared_config_draft) {
2097
+ Ok(token_policies) => {
2098
+ lines.push(Line::from(format!(
2099
+ "{} per-token policy bundle(s) are ready.",
2100
+ token_policies.len()
2101
+ )));
2102
+ }
2103
+ Err(err) => lines.push(Line::from(format!("Not ready yet: {}", err))),
2104
+ }
2105
+ match resolve_all_token_policies(&app.shared_config_draft)
2106
+ .and_then(|token_policies| {
2107
+ resolve_all_token_destination_overrides(&app.shared_config_draft, &token_policies)
2108
+ .map(|overrides| (token_policies, overrides))
2109
+ })
2110
+ .and_then(|(token_policies, overrides)| {
2111
+ resolve_all_token_manual_approval_policies(&app.shared_config_draft, &token_policies)
2112
+ .map(|manual| (overrides, manual))
2113
+ }) {
2114
+ Ok((overrides, manual_approvals)) => {
2115
+ lines.push(Line::from(format!(
2116
+ "{} destination override(s); {} manual approval policy/policies.",
2117
+ overrides.len(),
2118
+ manual_approvals.len()
2119
+ )));
2120
+ }
2121
+ Err(err) => lines.push(Line::from(format!("Validation: {}", err))),
2122
+ }
2123
+
2124
+ lines.push(Line::from(""));
2125
+ lines.push(Line::from(Span::styled(
2126
+ "Execution",
2127
+ Style::default().add_modifier(Modifier::BOLD),
2128
+ )));
2129
+ lines.push(Line::from(
2130
+ "Bootstrap applies every saved token across its selected networks.",
2131
+ ));
2132
+ lines
2133
+ }
2134
+
2135
+ fn field_label(field: Field) -> &'static str {
2136
+ match field {
2137
+ Field::SelectedToken => "Selected Token",
2138
+ Field::TokenKey => "Token Key",
2139
+ Field::TokenName => "Token Name (RPC)",
2140
+ Field::TokenSymbol => "Token Symbol (RPC)",
2141
+ Field::NetworkMembership => "Network Multi-Select",
2142
+ Field::EditingNetwork => "Editing Network",
2143
+ Field::NetworkIsNative => "Native Asset",
2144
+ Field::NetworkAddress => "Token Address",
2145
+ Field::RefreshTokenMetadata => "Fetch Metadata",
2146
+ Field::TokenDecimals => "Token Decimals (RPC)",
2147
+ Field::PerTxLimit => "Per-Tx Limit",
2148
+ Field::DailyLimit => "Daily Limit",
2149
+ Field::WeeklyLimit => "Weekly Limit",
2150
+ Field::ShowAdvanced => "Advanced",
2151
+ Field::MaxGasPerChainWei => "Max Gas Spend Per Chain (wei)",
2152
+ Field::DailyMaxTxCount => "Daily Max Tx Count",
2153
+ Field::PerTxMaxFeePerGasGwei => "Max Fee Per Gas (gwei)",
2154
+ Field::PerTxMaxPriorityFeePerGasWei => "Max Priority Fee Per Gas (wei)",
2155
+ Field::PerTxMaxCalldataBytes => "Max Calldata Bytes",
2156
+ Field::DestinationOverrides => "Add Destination Override",
2157
+ Field::SelectedDestinationOverride => "Selected Override",
2158
+ Field::OverrideRecipientAddress => "Override Recipient",
2159
+ Field::OverridePerTxLimit => "Override Per-Tx Limit",
2160
+ Field::OverrideDailyLimit => "Override Daily Limit",
2161
+ Field::OverrideWeeklyLimit => "Override Weekly Limit",
2162
+ Field::OverrideMaxGasPerChainWei => "Override Gas/Chain (wei)",
2163
+ Field::OverrideDailyMaxTxCount => "Override Daily Tx Count",
2164
+ Field::OverridePerTxMaxFeePerGasGwei => "Override Max Fee/Gas (gwei)",
2165
+ Field::OverridePerTxMaxPriorityFeePerGasWei => "Override Priority Fee/Gas (wei)",
2166
+ Field::OverridePerTxMaxCalldataBytes => "Override Calldata Bytes",
2167
+ Field::DeleteDestinationOverride => "Delete Override",
2168
+ Field::ManualApprovals => "Add Manual Approval",
2169
+ Field::SelectedManualApproval => "Selected Manual Approval",
2170
+ Field::ManualApprovalRecipientAddress => "Approval Recipient",
2171
+ Field::ManualApprovalMinAmount => "Approval Min Amount",
2172
+ Field::ManualApprovalMaxAmount => "Approval Max Amount",
2173
+ Field::ManualApprovalPriority => "Approval Priority",
2174
+ Field::DeleteManualApproval => "Delete Manual Approval",
2175
+ Field::SaveToken => "Save Token",
2176
+ Field::DeleteToken => "Delete Token",
2177
+ Field::SelectedNetwork => "Selected Network",
2178
+ Field::ChainConfigKey => "Network Key",
2179
+ Field::ChainConfigId => "Network Chain ID",
2180
+ Field::ChainConfigName => "Network Name",
2181
+ Field::ChainConfigRpcUrl => "Network RPC URL",
2182
+ Field::ChainConfigUseAsActive => "Use As Active Network",
2183
+ Field::SaveNetwork => "Save Network",
2184
+ Field::DeleteNetwork => "Delete Network",
2185
+ Field::Execute => "Bootstrap",
2186
+ }
2187
+ }
2188
+
2189
+ fn field_value(app: &AppState, field: Field) -> String {
2190
+ match field {
2191
+ Field::SelectedToken => app
2192
+ .token_draft
2193
+ .source_key
2194
+ .clone()
2195
+ .unwrap_or_else(|| "<new token>".to_string()),
2196
+ Field::TokenKey => blank_if_empty(&app.token_draft.key),
2197
+ Field::TokenName => blank_if_empty(&app.token_draft.name),
2198
+ Field::TokenSymbol => blank_if_empty(&app.token_draft.symbol),
2199
+ Field::NetworkMembership => {
2200
+ render_network_membership_value(&app.token_draft, &app.shared_config_draft)
2201
+ }
2202
+ Field::EditingNetwork => app
2203
+ .token_draft
2204
+ .selected_network()
2205
+ .map(|network| network.chain_key.clone())
2206
+ .unwrap_or_else(|| "<none>".to_string()),
2207
+ Field::NetworkIsNative => app
2208
+ .token_draft
2209
+ .selected_network()
2210
+ .map(|network| bool_label(network.is_native).to_string())
2211
+ .unwrap_or_else(|| "n/a".to_string()),
2212
+ Field::NetworkAddress => app
2213
+ .token_draft
2214
+ .selected_network()
2215
+ .map(|network| {
2216
+ if network.is_native {
2217
+ "native".to_string()
2218
+ } else {
2219
+ blank_if_empty(&network.address)
2220
+ }
2221
+ })
2222
+ .unwrap_or_else(|| "n/a".to_string()),
2223
+ Field::RefreshTokenMetadata => "press Enter".to_string(),
2224
+ Field::TokenDecimals => app
2225
+ .token_draft
2226
+ .selected_network()
2227
+ .map(|network| blank_if_empty(&network.decimals))
2228
+ .unwrap_or_else(|| "n/a".to_string()),
2229
+ Field::PerTxLimit => blank_if_empty(&app.token_draft.limits.per_tx_limit),
2230
+ Field::DailyLimit => blank_if_empty(&app.token_draft.limits.daily_limit),
2231
+ Field::WeeklyLimit => blank_if_empty(&app.token_draft.limits.weekly_limit),
2232
+ Field::ShowAdvanced => bool_label(app.show_advanced).to_string(),
2233
+ Field::MaxGasPerChainWei => {
2234
+ display_unlimited_if_empty(&app.token_draft.limits.max_gas_per_chain_wei)
2235
+ }
2236
+ Field::DailyMaxTxCount => {
2237
+ display_unlimited_if_empty(&app.token_draft.limits.daily_max_tx_count)
2238
+ }
2239
+ Field::PerTxMaxFeePerGasGwei => {
2240
+ display_unlimited_if_empty(&app.token_draft.limits.per_tx_max_fee_per_gas_gwei)
2241
+ }
2242
+ Field::PerTxMaxPriorityFeePerGasWei => {
2243
+ display_unlimited_if_empty(&app.token_draft.limits.per_tx_max_priority_fee_per_gas_wei)
2244
+ }
2245
+ Field::PerTxMaxCalldataBytes => {
2246
+ display_unlimited_if_empty(&app.token_draft.limits.per_tx_max_calldata_bytes)
2247
+ }
2248
+ Field::DestinationOverrides => format!(
2249
+ "{} saved draft(s) — press Enter",
2250
+ app.token_draft.destination_overrides.len()
2251
+ ),
2252
+ Field::SelectedDestinationOverride => app
2253
+ .token_draft
2254
+ .destination_overrides
2255
+ .get(app.token_draft.selected_override)
2256
+ .map(render_destination_override_label)
2257
+ .unwrap_or_else(|| "<none>".to_string()),
2258
+ Field::OverrideRecipientAddress => app
2259
+ .token_draft
2260
+ .destination_overrides
2261
+ .get(app.token_draft.selected_override)
2262
+ .map(|item| blank_if_empty(&item.recipient_address))
2263
+ .unwrap_or_else(|| "n/a".to_string()),
2264
+ Field::OverridePerTxLimit => app
2265
+ .token_draft
2266
+ .destination_overrides
2267
+ .get(app.token_draft.selected_override)
2268
+ .map(|item| blank_if_empty(&item.limits.per_tx_limit))
2269
+ .unwrap_or_else(|| "n/a".to_string()),
2270
+ Field::OverrideDailyLimit => app
2271
+ .token_draft
2272
+ .destination_overrides
2273
+ .get(app.token_draft.selected_override)
2274
+ .map(|item| blank_if_empty(&item.limits.daily_limit))
2275
+ .unwrap_or_else(|| "n/a".to_string()),
2276
+ Field::OverrideWeeklyLimit => app
2277
+ .token_draft
2278
+ .destination_overrides
2279
+ .get(app.token_draft.selected_override)
2280
+ .map(|item| blank_if_empty(&item.limits.weekly_limit))
2281
+ .unwrap_or_else(|| "n/a".to_string()),
2282
+ Field::OverrideMaxGasPerChainWei => app
2283
+ .token_draft
2284
+ .destination_overrides
2285
+ .get(app.token_draft.selected_override)
2286
+ .map(|item| display_unlimited_if_empty(&item.limits.max_gas_per_chain_wei))
2287
+ .unwrap_or_else(|| "n/a".to_string()),
2288
+ Field::OverrideDailyMaxTxCount => app
2289
+ .token_draft
2290
+ .destination_overrides
2291
+ .get(app.token_draft.selected_override)
2292
+ .map(|item| display_unlimited_if_empty(&item.limits.daily_max_tx_count))
2293
+ .unwrap_or_else(|| "n/a".to_string()),
2294
+ Field::OverridePerTxMaxFeePerGasGwei => app
2295
+ .token_draft
2296
+ .destination_overrides
2297
+ .get(app.token_draft.selected_override)
2298
+ .map(|item| display_unlimited_if_empty(&item.limits.per_tx_max_fee_per_gas_gwei))
2299
+ .unwrap_or_else(|| "n/a".to_string()),
2300
+ Field::OverridePerTxMaxPriorityFeePerGasWei => app
2301
+ .token_draft
2302
+ .destination_overrides
2303
+ .get(app.token_draft.selected_override)
2304
+ .map(|item| {
2305
+ display_unlimited_if_empty(&item.limits.per_tx_max_priority_fee_per_gas_wei)
2306
+ })
2307
+ .unwrap_or_else(|| "n/a".to_string()),
2308
+ Field::OverridePerTxMaxCalldataBytes => app
2309
+ .token_draft
2310
+ .destination_overrides
2311
+ .get(app.token_draft.selected_override)
2312
+ .map(|item| display_unlimited_if_empty(&item.limits.per_tx_max_calldata_bytes))
2313
+ .unwrap_or_else(|| "n/a".to_string()),
2314
+ Field::DeleteDestinationOverride => "press Enter".to_string(),
2315
+ Field::ManualApprovals => format!(
2316
+ "{} saved draft(s) — press Enter",
2317
+ app.token_draft.manual_approvals.len()
2318
+ ),
2319
+ Field::SelectedManualApproval => app
2320
+ .token_draft
2321
+ .manual_approvals
2322
+ .get(app.token_draft.selected_manual_approval)
2323
+ .map(render_manual_approval_label)
2324
+ .unwrap_or_else(|| "<none>".to_string()),
2325
+ Field::ManualApprovalRecipientAddress => app
2326
+ .token_draft
2327
+ .manual_approvals
2328
+ .get(app.token_draft.selected_manual_approval)
2329
+ .map(|item| blank_if_empty(&item.recipient_address))
2330
+ .unwrap_or_else(|| "n/a".to_string()),
2331
+ Field::ManualApprovalMinAmount => app
2332
+ .token_draft
2333
+ .manual_approvals
2334
+ .get(app.token_draft.selected_manual_approval)
2335
+ .map(|item| blank_if_empty(&item.min_amount))
2336
+ .unwrap_or_else(|| "n/a".to_string()),
2337
+ Field::ManualApprovalMaxAmount => app
2338
+ .token_draft
2339
+ .manual_approvals
2340
+ .get(app.token_draft.selected_manual_approval)
2341
+ .map(|item| blank_if_empty(&item.max_amount))
2342
+ .unwrap_or_else(|| "n/a".to_string()),
2343
+ Field::ManualApprovalPriority => app
2344
+ .token_draft
2345
+ .manual_approvals
2346
+ .get(app.token_draft.selected_manual_approval)
2347
+ .map(|item| blank_if_empty(&item.priority))
2348
+ .unwrap_or_else(|| "n/a".to_string()),
2349
+ Field::DeleteManualApproval => "press Enter".to_string(),
2350
+ Field::SaveToken => "press Enter".to_string(),
2351
+ Field::DeleteToken => "press Enter".to_string(),
2352
+ Field::SelectedNetwork => app
2353
+ .network_draft
2354
+ .source_key
2355
+ .clone()
2356
+ .unwrap_or_else(|| "<new network>".to_string()),
2357
+ Field::ChainConfigKey => blank_if_empty(&app.network_draft.key),
2358
+ Field::ChainConfigId => blank_if_empty(&app.network_draft.chain_id),
2359
+ Field::ChainConfigName => blank_if_empty(&app.network_draft.name),
2360
+ Field::ChainConfigRpcUrl => blank_if_empty(&app.network_draft.rpc_url),
2361
+ Field::ChainConfigUseAsActive => bool_label(app.network_draft.use_as_active).to_string(),
2362
+ Field::SaveNetwork => "press Enter".to_string(),
2363
+ Field::DeleteNetwork => "press Enter".to_string(),
2364
+ Field::Execute => "press Enter".to_string(),
2365
+ }
2366
+ }
2367
+
2368
+ fn blank_if_empty(value: &str) -> String {
2369
+ if value.trim().is_empty() {
2370
+ "<blank>".to_string()
2371
+ } else {
2372
+ value.trim().to_string()
2373
+ }
2374
+ }
2375
+
2376
+ fn display_unlimited_if_empty(value: &str) -> String {
2377
+ if value.trim().is_empty() {
2378
+ "unlimited".to_string()
2379
+ } else {
2380
+ value.trim().to_string()
2381
+ }
2382
+ }
2383
+
2384
+ fn render_network_membership_value(token_draft: &TokenDraft, config: &WlfiConfig) -> String {
2385
+ let available = sorted_chain_keys(config);
2386
+ if available.is_empty() {
2387
+ return "no saved networks".to_string();
2388
+ }
2389
+ let focus = available
2390
+ .get(token_draft.available_network_index)
2391
+ .cloned()
2392
+ .unwrap_or_else(|| available[0].clone());
2393
+ let selected = available
2394
+ .iter()
2395
+ .map(|chain_key| {
2396
+ let checked = token_draft
2397
+ .networks
2398
+ .iter()
2399
+ .any(|network| network.chain_key == *chain_key);
2400
+ format!("[{}] {}", if checked { "x" } else { " " }, chain_key)
2401
+ })
2402
+ .collect::<Vec<_>>()
2403
+ .join(" ");
2404
+ format!("focus={} {}", focus, selected)
2405
+ }
2406
+
2407
+ fn render_destination_override_label(override_item: &DestinationOverrideDraft) -> String {
2408
+ if override_item.recipient_address.trim().is_empty() {
2409
+ "<recipient required>".to_string()
2410
+ } else {
2411
+ override_item.recipient_address.trim().to_string()
2412
+ }
2413
+ }
2414
+
2415
+ fn render_manual_approval_label(manual_approval: &ManualApprovalDraft) -> String {
2416
+ let recipient = if manual_approval.recipient_address.trim().is_empty() {
2417
+ "all recipients"
2418
+ } else {
2419
+ manual_approval.recipient_address.trim()
2420
+ };
2421
+ format!(
2422
+ "{} -> {}..{}",
2423
+ recipient,
2424
+ blank_if_empty(&manual_approval.min_amount),
2425
+ blank_if_empty(&manual_approval.max_amount)
2426
+ )
2427
+ }
2428
+
2429
+ fn render_token_draft_lines(token_draft: &TokenDraft, show_advanced: bool) -> Vec<Line<'static>> {
2430
+ let mut lines = vec![Line::from(format!(
2431
+ "{} ({})",
2432
+ if token_draft.name.trim().is_empty() {
2433
+ "<name pending>"
2434
+ } else {
2435
+ token_draft.name.trim()
2436
+ },
2437
+ if token_draft.symbol.trim().is_empty() {
2438
+ token_draft.key.trim()
2439
+ } else {
2440
+ token_draft.symbol.trim()
2441
+ }
2442
+ ))];
2443
+ if token_draft.networks.is_empty() {
2444
+ lines.push(Line::from("No network mappings yet."));
2445
+ } else {
2446
+ lines.push(Line::from("Network mappings:"));
2447
+ for network in token_draft.networks.iter().take(4) {
2448
+ let address = if network.is_native {
2449
+ "native".to_string()
2450
+ } else {
2451
+ blank_if_empty(&network.address)
2452
+ };
2453
+ lines.push(Line::from(format!(
2454
+ "- {} / chain {} / decimals {} / {}",
2455
+ network.chain_key,
2456
+ blank_if_empty(&network.chain_id),
2457
+ blank_if_empty(&network.decimals),
2458
+ address
2459
+ )));
2460
+ }
2461
+ }
2462
+ lines.push(Line::from(format!(
2463
+ "Default limits: per-tx {} / daily {} / weekly {}.",
2464
+ blank_if_empty(&token_draft.limits.per_tx_limit),
2465
+ blank_if_empty(&token_draft.limits.daily_limit),
2466
+ blank_if_empty(&token_draft.limits.weekly_limit),
2467
+ )));
2468
+ if show_advanced {
2469
+ lines.push(Line::from(format!(
2470
+ "Advanced limits: gas {} wei / daily tx {} / max fee {} gwei / priority fee {} wei / calldata {} bytes.",
2471
+ display_unlimited_if_empty(&token_draft.limits.max_gas_per_chain_wei),
2472
+ display_unlimited_if_empty(&token_draft.limits.daily_max_tx_count),
2473
+ display_unlimited_if_empty(&token_draft.limits.per_tx_max_fee_per_gas_gwei),
2474
+ display_unlimited_if_empty(&token_draft.limits.per_tx_max_priority_fee_per_gas_wei),
2475
+ display_unlimited_if_empty(&token_draft.limits.per_tx_max_calldata_bytes),
2476
+ )));
2477
+ }
2478
+ lines.push(Line::from(format!(
2479
+ "{} destination override(s); {} manual approval policy/policies.",
2480
+ token_draft.destination_overrides.len(),
2481
+ token_draft.manual_approvals.len()
2482
+ )));
2483
+ lines
2484
+ }
2485
+
2486
+ fn render_saved_token_summary(token_key: &str, profile: &TokenProfile) -> String {
2487
+ let decimals = profile
2488
+ .chains
2489
+ .values()
2490
+ .next()
2491
+ .map(|chain| chain.decimals)
2492
+ .unwrap_or(18);
2493
+ let limits = LimitDraft::from_policy(
2494
+ profile
2495
+ .default_policy
2496
+ .as_ref()
2497
+ .or_else(|| first_chain_policy(profile)),
2498
+ decimals,
2499
+ );
2500
+ let networks = profile
2501
+ .chains
2502
+ .keys()
2503
+ .cloned()
2504
+ .collect::<Vec<_>>()
2505
+ .join(", ");
2506
+ format!(
2507
+ "{} ({}) — networks [{}] — default per-tx {} / daily {} / weekly {} — overrides {} — manual approvals {}",
2508
+ profile.name.as_deref().unwrap_or(&profile.symbol),
2509
+ token_key,
2510
+ networks,
2511
+ blank_if_empty(&limits.per_tx_limit),
2512
+ blank_if_empty(&limits.daily_limit),
2513
+ blank_if_empty(&limits.weekly_limit),
2514
+ profile.destination_overrides.len(),
2515
+ profile.manual_approval_policies.len(),
2516
+ )
2517
+ }
2518
+
2519
+ fn first_chain_policy(profile: &TokenProfile) -> Option<&TokenPolicyProfile> {
2520
+ profile
2521
+ .chains
2522
+ .values()
2523
+ .find_map(|chain_profile| chain_profile.default_policy.as_ref())
2524
+ }
2525
+
2526
+ fn sorted_token_keys(config: &WlfiConfig) -> Vec<String> {
2527
+ let mut keys = config.tokens.keys().cloned().collect::<Vec<_>>();
2528
+ keys.sort();
2529
+ keys
2530
+ }
2531
+
2532
+ fn sorted_chain_keys(config: &WlfiConfig) -> Vec<String> {
2533
+ let mut keys = config.chains.keys().cloned().collect::<Vec<_>>();
2534
+ keys.sort();
2535
+ keys
2536
+ }
2537
+
2538
+ fn cycle_index(index: &mut usize, len: usize, direction: i8) {
2539
+ if len == 0 {
2540
+ *index = 0;
2541
+ return;
2542
+ }
2543
+ if direction < 0 {
2544
+ *index = if *index == 0 { len - 1 } else { *index - 1 };
2545
+ } else {
2546
+ *index = (*index + 1) % len;
2547
+ }
2548
+ }
2549
+
2550
+ fn validate_limit_draft(limits: &LimitDraft, decimals: u8) -> Result<()> {
2551
+ parse_required_token_amount("per-tx limit", &limits.per_tx_limit, decimals)?;
2552
+ parse_required_token_amount("daily limit", &limits.daily_limit, decimals)?;
2553
+ parse_required_token_amount("weekly limit", &limits.weekly_limit, decimals)?;
2554
+ if !limits.max_gas_per_chain_wei.trim().is_empty() {
2555
+ parse_positive_u128("max gas spend per chain", &limits.max_gas_per_chain_wei)?;
2556
+ }
2557
+ parse_optional_non_negative_u128("daily max tx count", &limits.daily_max_tx_count)?;
2558
+ parse_optional_gwei_amount("max fee per gas", Some(&limits.per_tx_max_fee_per_gas_gwei))?;
2559
+ parse_optional_non_negative_u128(
2560
+ "max priority fee per gas",
2561
+ &limits.per_tx_max_priority_fee_per_gas_wei,
2562
+ )?;
2563
+ parse_optional_non_negative_u128("max calldata bytes", &limits.per_tx_max_calldata_bytes)?;
2564
+ Ok(())
2565
+ }
2566
+
2567
+ fn display_policy_amount(
2568
+ decimal_value: Option<&str>,
2569
+ raw_value: Option<&str>,
2570
+ legacy_value: Option<f64>,
2571
+ decimals: u8,
2572
+ ) -> Result<String> {
2573
+ if let Some(value) = decimal_value {
2574
+ if !value.trim().is_empty() {
2575
+ return Ok(value.trim().to_string());
2576
+ }
2577
+ }
2578
+ if let Some(value) = raw_value {
2579
+ if !value.trim().is_empty() {
2580
+ let raw = parse_positive_u128("policy amount", value)?;
2581
+ return format_token_amount(raw, decimals);
2582
+ }
2583
+ }
2584
+ if let Some(value) = legacy_value {
2585
+ return format_token_amount(
2586
+ parse_legacy_amount("policy amount", value, decimals)?,
2587
+ decimals,
2588
+ );
2589
+ }
2590
+ Ok(String::new())
2591
+ }
2592
+
2593
+ fn display_policy_gwei(gwei_value: Option<&str>, raw_wei_value: Option<&str>) -> Result<String> {
2594
+ if let Some(value) = gwei_value {
2595
+ if !value.trim().is_empty() {
2596
+ return Ok(value.trim().to_string());
2597
+ }
2598
+ }
2599
+ if let Some(value) = raw_wei_value {
2600
+ if !value.trim().is_empty() {
2601
+ return format_gwei_amount(parse_non_negative_u128("policy gwei", value)?);
2602
+ }
2603
+ }
2604
+ Ok(String::new())
2605
+ }
2606
+
2607
+ fn optional_trimmed(value: &str) -> Option<String> {
2608
+ let trimmed = value.trim();
2609
+ (!trimmed.is_empty()).then(|| trimmed.to_string())
2610
+ }
2611
+
2612
+ fn optional_non_zero_string(value: u128) -> Option<String> {
2613
+ (value > 0).then(|| value.to_string())
2614
+ }
2615
+
2616
+ fn parse_optional_non_negative_u128(label: &str, value: &str) -> Result<u128> {
2617
+ if value.trim().is_empty() {
2618
+ Ok(0)
2619
+ } else {
2620
+ parse_non_negative_u128(label, value)
2621
+ }
2622
+ }
2623
+
2624
+ fn display_manual_amount(
2625
+ decimal_value: Option<&str>,
2626
+ raw_value: Option<&str>,
2627
+ legacy_value: Option<f64>,
2628
+ decimals: u8,
2629
+ ) -> Result<String> {
2630
+ if let Some(value) = decimal_value {
2631
+ if !value.trim().is_empty() {
2632
+ return Ok(value.trim().to_string());
2633
+ }
2634
+ }
2635
+ if let Some(value) = raw_value {
2636
+ if !value.trim().is_empty() {
2637
+ return format_token_amount(
2638
+ parse_positive_u128("manual approval amount", value)?,
2639
+ decimals,
2640
+ );
2641
+ }
2642
+ }
2643
+ if let Some(value) = legacy_value {
2644
+ return format_token_amount(
2645
+ parse_legacy_amount("manual approval amount", value, decimals)?,
2646
+ decimals,
2647
+ );
2648
+ }
2649
+ Ok(String::new())
2650
+ }
2651
+
2652
+ fn resolve_all_token_policies(config: &WlfiConfig) -> Result<Vec<TokenPolicyConfig>> {
2653
+ let mut token_policies = Vec::new();
2654
+ for token_key in sorted_token_keys(config) {
2655
+ let token_profile = &config.tokens[&token_key];
2656
+ for (chain_key, chain_profile) in token_profile.chains.iter() {
2657
+ let policy = token_profile
2658
+ .default_policy
2659
+ .as_ref()
2660
+ .or(chain_profile.default_policy.as_ref())
2661
+ .with_context(|| {
2662
+ format!(
2663
+ "token '{}' chain '{}' is missing default limits",
2664
+ token_key, chain_key
2665
+ )
2666
+ })?;
2667
+ token_policies.push(resolve_token_policy_config(
2668
+ &token_key,
2669
+ token_profile,
2670
+ chain_key,
2671
+ chain_profile,
2672
+ policy,
2673
+ )?);
2674
+ }
2675
+ }
2676
+ Ok(token_policies)
2677
+ }
2678
+
2679
+ fn resolve_token_policy_config(
2680
+ token_key: &str,
2681
+ token_profile: &TokenProfile,
2682
+ chain_key: &str,
2683
+ chain_profile: &TokenChainProfile,
2684
+ policy: &TokenPolicyProfile,
2685
+ ) -> Result<TokenPolicyConfig> {
2686
+ let decimals = chain_profile.decimals;
2687
+ let per_tx_max_wei = resolve_required_policy_amount(
2688
+ "per-tx limit",
2689
+ policy.per_tx_amount_decimal.as_deref(),
2690
+ policy.per_tx_limit.as_deref(),
2691
+ policy.per_tx_amount,
2692
+ decimals,
2693
+ )?;
2694
+ let daily_max_wei = resolve_required_policy_amount(
2695
+ "daily limit",
2696
+ policy.daily_amount_decimal.as_deref(),
2697
+ policy.daily_limit.as_deref(),
2698
+ policy.daily_amount,
2699
+ decimals,
2700
+ )?;
2701
+ let weekly_max_wei = resolve_required_policy_amount(
2702
+ "weekly limit",
2703
+ policy.weekly_amount_decimal.as_deref(),
2704
+ policy.weekly_limit.as_deref(),
2705
+ policy.weekly_amount,
2706
+ decimals,
2707
+ )?;
2708
+
2709
+ Ok(TokenPolicyConfig {
2710
+ token_key: token_key.to_string(),
2711
+ symbol: token_profile.symbol.clone(),
2712
+ chain_key: chain_key.to_string(),
2713
+ chain_id: chain_profile.chain_id,
2714
+ is_native: chain_profile.is_native,
2715
+ address: if chain_profile.is_native {
2716
+ None
2717
+ } else {
2718
+ Some(parse_address(
2719
+ &format!("token '{}:{}'", token_key, chain_key),
2720
+ chain_profile.address.as_deref().unwrap_or_default(),
2721
+ )?)
2722
+ },
2723
+ per_tx_max_wei,
2724
+ daily_max_wei,
2725
+ weekly_max_wei,
2726
+ max_gas_per_chain_wei: resolve_optional_policy_value(
2727
+ policy.max_gas_per_chain_wei.as_deref(),
2728
+ )?,
2729
+ daily_max_tx_count: resolve_optional_policy_value(policy.daily_max_tx_count.as_deref())?,
2730
+ per_tx_max_fee_per_gas_wei: resolve_optional_gwei_or_wei(
2731
+ policy.per_tx_max_fee_per_gas_gwei.as_deref(),
2732
+ policy.per_tx_max_fee_per_gas_wei.as_deref(),
2733
+ )?,
2734
+ per_tx_max_priority_fee_per_gas_wei: resolve_optional_policy_value(
2735
+ policy.per_tx_max_priority_fee_per_gas_wei.as_deref(),
2736
+ )?,
2737
+ per_tx_max_calldata_bytes: resolve_optional_policy_value(
2738
+ policy.per_tx_max_calldata_bytes.as_deref(),
2739
+ )?,
2740
+ })
2741
+ }
2742
+
2743
+ fn resolve_all_token_destination_overrides(
2744
+ config: &WlfiConfig,
2745
+ token_policies: &[TokenPolicyConfig],
2746
+ ) -> Result<Vec<TokenDestinationPolicyOverride>> {
2747
+ let mut overrides = Vec::new();
2748
+ let mut seen = BTreeSet::new();
2749
+ for token_key in sorted_token_keys(config) {
2750
+ let token_profile = &config.tokens[&token_key];
2751
+ for override_profile in &token_profile.destination_overrides {
2752
+ let recipient = parse_address(
2753
+ "destination override recipient",
2754
+ &override_profile.recipient,
2755
+ )?;
2756
+ for (chain_key, chain_profile) in &token_profile.chains {
2757
+ if !seen.insert((token_key.clone(), chain_key.clone(), recipient.clone())) {
2758
+ bail!(
2759
+ "duplicate per-token destination override: {}:{} for {}",
2760
+ token_key,
2761
+ chain_key,
2762
+ recipient
2763
+ );
2764
+ }
2765
+ let default_policy = token_policies
2766
+ .iter()
2767
+ .find(|policy| policy.token_key == token_key && policy.chain_key == *chain_key)
2768
+ .with_context(|| {
2769
+ format!(
2770
+ "destination override references unknown token selector '{}:{}'",
2771
+ token_key, chain_key
2772
+ )
2773
+ })?;
2774
+ let default_limits = ResolvedLimitFields::from_token_policy(default_policy);
2775
+ let resolved = TokenDestinationPolicyOverride {
2776
+ token_key: token_key.clone(),
2777
+ chain_key: chain_key.clone(),
2778
+ recipient: recipient.clone(),
2779
+ per_tx_max_wei: resolve_policy_amount_or_default(
2780
+ override_profile.limits.per_tx_amount_decimal.as_deref(),
2781
+ override_profile.limits.per_tx_limit.as_deref(),
2782
+ override_profile.limits.per_tx_amount,
2783
+ default_limits.per_tx_max_wei,
2784
+ chain_profile.decimals,
2785
+ )?,
2786
+ daily_max_wei: resolve_policy_amount_or_default(
2787
+ override_profile.limits.daily_amount_decimal.as_deref(),
2788
+ override_profile.limits.daily_limit.as_deref(),
2789
+ override_profile.limits.daily_amount,
2790
+ default_limits.daily_max_wei,
2791
+ chain_profile.decimals,
2792
+ )?,
2793
+ weekly_max_wei: resolve_policy_amount_or_default(
2794
+ override_profile.limits.weekly_amount_decimal.as_deref(),
2795
+ override_profile.limits.weekly_limit.as_deref(),
2796
+ override_profile.limits.weekly_amount,
2797
+ default_limits.weekly_max_wei,
2798
+ chain_profile.decimals,
2799
+ )?,
2800
+ max_gas_per_chain_wei: resolve_optional_policy_value_or_default(
2801
+ override_profile.limits.max_gas_per_chain_wei.as_deref(),
2802
+ default_limits.max_gas_per_chain_wei,
2803
+ )?,
2804
+ daily_max_tx_count: resolve_optional_policy_value_or_default(
2805
+ override_profile.limits.daily_max_tx_count.as_deref(),
2806
+ default_limits.daily_max_tx_count,
2807
+ )?,
2808
+ per_tx_max_fee_per_gas_wei: resolve_gwei_or_wei_or_default(
2809
+ override_profile
2810
+ .limits
2811
+ .per_tx_max_fee_per_gas_gwei
2812
+ .as_deref(),
2813
+ override_profile
2814
+ .limits
2815
+ .per_tx_max_fee_per_gas_wei
2816
+ .as_deref(),
2817
+ default_limits.per_tx_max_fee_per_gas_wei,
2818
+ )?,
2819
+ per_tx_max_priority_fee_per_gas_wei: resolve_optional_policy_value_or_default(
2820
+ override_profile
2821
+ .limits
2822
+ .per_tx_max_priority_fee_per_gas_wei
2823
+ .as_deref(),
2824
+ default_limits.per_tx_max_priority_fee_per_gas_wei,
2825
+ )?,
2826
+ per_tx_max_calldata_bytes: resolve_optional_policy_value_or_default(
2827
+ override_profile.limits.per_tx_max_calldata_bytes.as_deref(),
2828
+ default_limits.per_tx_max_calldata_bytes,
2829
+ )?,
2830
+ };
2831
+ if ResolvedLimitFields::from_override(&resolved) == default_limits {
2832
+ continue;
2833
+ }
2834
+ validate_destination_override_overlay(
2835
+ &recipient.to_string(),
2836
+ &default_limits,
2837
+ &ResolvedLimitFields::from_override(&resolved),
2838
+ )?;
2839
+ overrides.push(resolved);
2840
+ }
2841
+ }
2842
+ }
2843
+ Ok(overrides)
2844
+ }
2845
+
2846
+ fn resolve_all_token_manual_approval_policies(
2847
+ config: &WlfiConfig,
2848
+ token_policies: &[TokenPolicyConfig],
2849
+ ) -> Result<Vec<TokenManualApprovalPolicyConfig>> {
2850
+ let mut policies = Vec::new();
2851
+ for token_key in sorted_token_keys(config) {
2852
+ let token_profile = &config.tokens[&token_key];
2853
+ for manual_profile in &token_profile.manual_approval_policies {
2854
+ for (chain_key, chain_profile) in &token_profile.chains {
2855
+ let token_policy = token_policies
2856
+ .iter()
2857
+ .find(|policy| policy.token_key == token_key && policy.chain_key == *chain_key)
2858
+ .with_context(|| {
2859
+ format!(
2860
+ "manual approval references unknown token selector '{}:{}'",
2861
+ token_key, chain_key
2862
+ )
2863
+ })?;
2864
+ policies.push(TokenManualApprovalPolicyConfig {
2865
+ token_key: token_key.clone(),
2866
+ symbol: token_profile.symbol.clone(),
2867
+ chain_key: chain_key.clone(),
2868
+ chain_id: chain_profile.chain_id,
2869
+ is_native: chain_policy_is_native(token_policy),
2870
+ address: chain_policy_address(token_policy),
2871
+ priority: if manual_profile.priority == 0 {
2872
+ 100
2873
+ } else {
2874
+ manual_profile.priority
2875
+ },
2876
+ recipient: match manual_profile.recipient.as_deref() {
2877
+ Some(value) if !value.trim().is_empty() => {
2878
+ Some(parse_address("manual approval recipient", value)?)
2879
+ }
2880
+ _ => None,
2881
+ },
2882
+ min_amount_wei: resolve_required_policy_amount(
2883
+ "manual approval min amount",
2884
+ manual_profile.min_amount_decimal.as_deref(),
2885
+ manual_profile.min_amount_wei.as_deref(),
2886
+ manual_profile.min_amount,
2887
+ chain_profile.decimals,
2888
+ )?,
2889
+ max_amount_wei: resolve_required_policy_amount(
2890
+ "manual approval max amount",
2891
+ manual_profile.max_amount_decimal.as_deref(),
2892
+ manual_profile.max_amount_wei.as_deref(),
2893
+ manual_profile.max_amount,
2894
+ chain_profile.decimals,
2895
+ )?,
2896
+ });
2897
+ }
2898
+ }
2899
+ }
2900
+ Ok(policies)
2901
+ }
2902
+
2903
+ fn chain_policy_is_native(policy: &TokenPolicyConfig) -> bool {
2904
+ policy.is_native
2905
+ }
2906
+
2907
+ fn chain_policy_address(policy: &TokenPolicyConfig) -> Option<EvmAddress> {
2908
+ policy.address.clone()
2909
+ }
2910
+
2911
+ fn resolve_required_policy_amount(
2912
+ label: &str,
2913
+ decimal_value: Option<&str>,
2914
+ raw_value: Option<&str>,
2915
+ legacy_value: Option<f64>,
2916
+ decimals: u8,
2917
+ ) -> Result<u128> {
2918
+ if let Some(value) = decimal_value {
2919
+ if !value.trim().is_empty() {
2920
+ return parse_required_token_amount(label, value, decimals);
2921
+ }
2922
+ }
2923
+ if let Some(value) = raw_value {
2924
+ if !value.trim().is_empty() {
2925
+ return parse_positive_u128(label, value);
2926
+ }
2927
+ }
2928
+ if let Some(value) = legacy_value {
2929
+ return parse_legacy_amount(label, value, decimals);
2930
+ }
2931
+ bail!("{label} is required");
2932
+ }
2933
+
2934
+ fn resolve_policy_amount_or_default(
2935
+ decimal_value: Option<&str>,
2936
+ raw_value: Option<&str>,
2937
+ legacy_value: Option<f64>,
2938
+ default_value: u128,
2939
+ decimals: u8,
2940
+ ) -> Result<u128> {
2941
+ if let Some(value) = decimal_value {
2942
+ if !value.trim().is_empty() {
2943
+ return parse_required_token_amount("policy amount", value, decimals);
2944
+ }
2945
+ }
2946
+ if let Some(value) = raw_value {
2947
+ if !value.trim().is_empty() {
2948
+ return parse_positive_u128("policy amount", value);
2949
+ }
2950
+ }
2951
+ if let Some(value) = legacy_value {
2952
+ return parse_legacy_amount("policy amount", value, decimals);
2953
+ }
2954
+ Ok(default_value)
2955
+ }
2956
+
2957
+ fn resolve_optional_gwei_or_wei(
2958
+ gwei_value: Option<&str>,
2959
+ raw_wei_value: Option<&str>,
2960
+ ) -> Result<u128> {
2961
+ if let Some(value) = gwei_value {
2962
+ if !value.trim().is_empty() {
2963
+ return parse_optional_gwei_amount("gwei value", Some(value));
2964
+ }
2965
+ }
2966
+ resolve_optional_policy_value(raw_wei_value)
2967
+ }
2968
+
2969
+ fn resolve_gwei_or_wei_or_default(
2970
+ gwei_value: Option<&str>,
2971
+ raw_wei_value: Option<&str>,
2972
+ default_value: u128,
2973
+ ) -> Result<u128> {
2974
+ if let Some(value) = gwei_value {
2975
+ if !value.trim().is_empty() {
2976
+ return parse_optional_gwei_amount("gwei value", Some(value));
2977
+ }
2978
+ }
2979
+ resolve_optional_policy_value_or_default(raw_wei_value, default_value)
2980
+ }
2981
+
2982
+ fn resolve_optional_policy_value(value: Option<&str>) -> Result<u128> {
2983
+ match value {
2984
+ Some(value) if !value.trim().is_empty() => parse_non_negative_u128("policy value", value),
2985
+ _ => Ok(0),
2986
+ }
2987
+ }
2988
+
2989
+ fn resolve_optional_policy_value_or_default(
2990
+ value: Option<&str>,
2991
+ default_value: u128,
2992
+ ) -> Result<u128> {
2993
+ match value {
2994
+ Some(value) if !value.trim().is_empty() => parse_non_negative_u128("policy value", value),
2995
+ _ => Ok(default_value),
2996
+ }
2997
+ }
2998
+
2999
+ fn validate_destination_override_overlay(
3000
+ recipient: &str,
3001
+ defaults: &ResolvedLimitFields,
3002
+ override_limits: &ResolvedLimitFields,
3003
+ ) -> Result<()> {
3004
+ validate_overlay_limit(
3005
+ recipient,
3006
+ "per-tx max",
3007
+ defaults.per_tx_max_wei,
3008
+ override_limits.per_tx_max_wei,
3009
+ )?;
3010
+ validate_overlay_limit(
3011
+ recipient,
3012
+ "daily max",
3013
+ defaults.daily_max_wei,
3014
+ override_limits.daily_max_wei,
3015
+ )?;
3016
+ validate_overlay_limit(
3017
+ recipient,
3018
+ "weekly max",
3019
+ defaults.weekly_max_wei,
3020
+ override_limits.weekly_max_wei,
3021
+ )?;
3022
+ validate_optional_overlay_limit(
3023
+ recipient,
3024
+ "gas max",
3025
+ defaults.max_gas_per_chain_wei,
3026
+ override_limits.max_gas_per_chain_wei,
3027
+ )?;
3028
+ validate_optional_overlay_limit(
3029
+ recipient,
3030
+ "daily tx count",
3031
+ defaults.daily_max_tx_count,
3032
+ override_limits.daily_max_tx_count,
3033
+ )?;
3034
+ validate_optional_overlay_limit(
3035
+ recipient,
3036
+ "per-tx max fee per gas",
3037
+ defaults.per_tx_max_fee_per_gas_wei,
3038
+ override_limits.per_tx_max_fee_per_gas_wei,
3039
+ )?;
3040
+ validate_optional_overlay_limit(
3041
+ recipient,
3042
+ "per-tx max priority fee per gas",
3043
+ defaults.per_tx_max_priority_fee_per_gas_wei,
3044
+ override_limits.per_tx_max_priority_fee_per_gas_wei,
3045
+ )?;
3046
+ validate_optional_overlay_limit(
3047
+ recipient,
3048
+ "per-tx max calldata bytes",
3049
+ defaults.per_tx_max_calldata_bytes,
3050
+ override_limits.per_tx_max_calldata_bytes,
3051
+ )?;
3052
+ Ok(())
3053
+ }
3054
+
3055
+ fn validate_overlay_limit(
3056
+ recipient: &str,
3057
+ label: &str,
3058
+ defaults: u128,
3059
+ overlay: u128,
3060
+ ) -> Result<()> {
3061
+ if overlay > defaults {
3062
+ bail!(
3063
+ "destination override for {recipient} must not increase {label} above the default value"
3064
+ );
3065
+ }
3066
+ Ok(())
3067
+ }
3068
+
3069
+ fn validate_optional_overlay_limit(
3070
+ recipient: &str,
3071
+ label: &str,
3072
+ defaults: u128,
3073
+ overlay: u128,
3074
+ ) -> Result<()> {
3075
+ match (defaults == 0, overlay == 0) {
3076
+ (true, false) => bail!(
3077
+ "destination override for {recipient} must keep {label} disabled because the default value is disabled"
3078
+ ),
3079
+ (false, true) => bail!(
3080
+ "destination override for {recipient} must keep {label} enabled because the default value is enabled"
3081
+ ),
3082
+ _ if overlay > defaults => bail!(
3083
+ "destination override for {recipient} must not increase {label} above the default value"
3084
+ ),
3085
+ _ => Ok(()),
3086
+ }
3087
+ }
3088
+
3089
+ #[cfg(test)]
3090
+ mod tests {
3091
+ use super::{
3092
+ build_bootstrap_panel_lines, build_token_panel_lines, handle_key_event,
3093
+ resolve_all_token_manual_approval_policies, resolve_all_token_policies, AppState,
3094
+ ChainProfile, Field, KeyCode, KeyEvent, KeyModifiers, LoopAction, TokenChainProfile,
3095
+ TokenDestinationOverrideProfile, TokenManualApprovalProfile, TokenPolicyProfile,
3096
+ TokenProfile, View, WlfiConfig,
3097
+ };
3098
+ use crate::shared_config::WalletProfile;
3099
+ use std::collections::BTreeMap;
3100
+ use std::fs;
3101
+ use uuid::Uuid;
3102
+
3103
+ fn sample_policy(per_tx: &str, daily: &str, weekly: &str) -> TokenPolicyProfile {
3104
+ TokenPolicyProfile {
3105
+ per_tx_amount: None,
3106
+ daily_amount: None,
3107
+ weekly_amount: None,
3108
+ per_tx_amount_decimal: Some(per_tx.to_string()),
3109
+ daily_amount_decimal: Some(daily.to_string()),
3110
+ weekly_amount_decimal: Some(weekly.to_string()),
3111
+ per_tx_limit: Some(per_tx.to_string()),
3112
+ daily_limit: Some(daily.to_string()),
3113
+ weekly_limit: Some(weekly.to_string()),
3114
+ max_gas_per_chain_wei: Some("1000000000000000".to_string()),
3115
+ daily_max_tx_count: Some("0".to_string()),
3116
+ per_tx_max_fee_per_gas_gwei: Some("25".to_string()),
3117
+ per_tx_max_fee_per_gas_wei: Some("25000000000".to_string()),
3118
+ per_tx_max_priority_fee_per_gas_wei: Some("0".to_string()),
3119
+ per_tx_max_calldata_bytes: Some("0".to_string()),
3120
+ extra: BTreeMap::new(),
3121
+ }
3122
+ }
3123
+
3124
+ fn empty_config() -> WlfiConfig {
3125
+ WlfiConfig {
3126
+ rpc_url: None,
3127
+ chain_id: None,
3128
+ chain_name: None,
3129
+ daemon_socket: None,
3130
+ state_file: None,
3131
+ rust_bin_dir: None,
3132
+ agent_key_id: None,
3133
+ agent_auth_token: None,
3134
+ wallet: None,
3135
+ chains: BTreeMap::new(),
3136
+ tokens: BTreeMap::new(),
3137
+ extra: BTreeMap::new(),
3138
+ }
3139
+ }
3140
+
3141
+ fn sample_config() -> WlfiConfig {
3142
+ let mut config = empty_config();
3143
+ config.chains.insert(
3144
+ "sepolia".to_string(),
3145
+ ChainProfile {
3146
+ chain_id: 11155111,
3147
+ name: "sepolia".to_string(),
3148
+ rpc_url: Some("https://rpc.sepolia.example".to_string()),
3149
+ extra: BTreeMap::new(),
3150
+ },
3151
+ );
3152
+ config.chains.insert(
3153
+ "base".to_string(),
3154
+ ChainProfile {
3155
+ chain_id: 8453,
3156
+ name: "base".to_string(),
3157
+ rpc_url: Some("https://mainnet.base.example".to_string()),
3158
+ extra: BTreeMap::new(),
3159
+ },
3160
+ );
3161
+ config.tokens.insert(
3162
+ "usdc".to_string(),
3163
+ TokenProfile {
3164
+ name: Some("USD Coin".to_string()),
3165
+ symbol: "USDC".to_string(),
3166
+ default_policy: Some(sample_policy("10", "100", "500")),
3167
+ destination_overrides: vec![TokenDestinationOverrideProfile {
3168
+ recipient: "0x1000000000000000000000000000000000000001".to_string(),
3169
+ limits: sample_policy("5", "50", "200"),
3170
+ }],
3171
+ manual_approval_policies: vec![TokenManualApprovalProfile {
3172
+ priority: 120,
3173
+ recipient: None,
3174
+ min_amount: None,
3175
+ max_amount: None,
3176
+ min_amount_decimal: Some("250".to_string()),
3177
+ max_amount_decimal: Some("500".to_string()),
3178
+ min_amount_wei: None,
3179
+ max_amount_wei: None,
3180
+ extra: BTreeMap::new(),
3181
+ }],
3182
+ chains: BTreeMap::from([
3183
+ (
3184
+ "sepolia".to_string(),
3185
+ TokenChainProfile {
3186
+ chain_id: 11155111,
3187
+ is_native: false,
3188
+ address: Some("0x1000000000000000000000000000000000000000".to_string()),
3189
+ decimals: 6,
3190
+ default_policy: Some(sample_policy("10", "100", "500")),
3191
+ extra: BTreeMap::new(),
3192
+ },
3193
+ ),
3194
+ (
3195
+ "base".to_string(),
3196
+ TokenChainProfile {
3197
+ chain_id: 8453,
3198
+ is_native: false,
3199
+ address: Some("0x2000000000000000000000000000000000000000".to_string()),
3200
+ decimals: 6,
3201
+ default_policy: Some(sample_policy("10", "100", "500")),
3202
+ extra: BTreeMap::new(),
3203
+ },
3204
+ ),
3205
+ ]),
3206
+ extra: BTreeMap::new(),
3207
+ },
3208
+ );
3209
+ config
3210
+ }
3211
+
3212
+ #[test]
3213
+ fn tokens_view_is_default_and_not_numbered() {
3214
+ let app = AppState::from_shared_config(&sample_config(), false);
3215
+ assert_eq!(app.view, View::Tokens);
3216
+ assert_eq!(app.visible_fields()[0], Field::SelectedToken);
3217
+ }
3218
+
3219
+ #[test]
3220
+ fn token_panel_mentions_overrides_and_manual_approvals() {
3221
+ let app = AppState::from_shared_config(&sample_config(), false);
3222
+ let rendered = build_token_panel_lines(&app)
3223
+ .into_iter()
3224
+ .map(|line| line.to_string())
3225
+ .collect::<Vec<_>>()
3226
+ .join("\n");
3227
+ assert!(rendered.contains("USD Coin (usdc)"));
3228
+ assert!(rendered.contains("overrides 1"));
3229
+ assert!(rendered.contains("manual approvals 1"));
3230
+ }
3231
+
3232
+ #[test]
3233
+ fn build_params_expands_multi_network_token_and_manual_approval() {
3234
+ let app = AppState::from_shared_config(&sample_config(), false);
3235
+ let params = app.build_params().expect("params");
3236
+ assert_eq!(params.token_policies.len(), 2);
3237
+ assert_eq!(params.token_destination_overrides.len(), 2);
3238
+ assert_eq!(params.token_manual_approval_policies.len(), 2);
3239
+ }
3240
+
3241
+ #[test]
3242
+ fn build_params_skips_empty_destination_override_limits() {
3243
+ let mut config = sample_config();
3244
+ if let Some(token) = config.tokens.get_mut("usdc") {
3245
+ token.destination_overrides = vec![TokenDestinationOverrideProfile {
3246
+ recipient: "0x1000000000000000000000000000000000000001".to_string(),
3247
+ limits: TokenPolicyProfile::default(),
3248
+ }];
3249
+ }
3250
+
3251
+ let app = AppState::from_shared_config(&config, false);
3252
+ let params = app.build_params().expect("params");
3253
+ assert_eq!(params.token_policies.len(), 2);
3254
+ assert_eq!(params.token_destination_overrides.len(), 0);
3255
+ }
3256
+
3257
+ #[test]
3258
+ fn build_params_reuses_existing_wallet_metadata_when_available() {
3259
+ let mut config = sample_config();
3260
+ config.wallet = Some(WalletProfile {
3261
+ vault_key_id: Some("11111111-1111-1111-1111-111111111111".to_string()),
3262
+ vault_public_key: "031111111111111111111111111111111111111111111111111111111111111111"
3263
+ .to_string(),
3264
+ address: Some("0x1111111111111111111111111111111111111111".to_string()),
3265
+ agent_key_id: Some("22222222-2222-2222-2222-222222222222".to_string()),
3266
+ policy_attachment: "policy_set".to_string(),
3267
+ attached_policy_ids: Vec::new(),
3268
+ policy_note: None,
3269
+ network_scope: None,
3270
+ asset_scope: None,
3271
+ recipient_scope: None,
3272
+ extra: BTreeMap::new(),
3273
+ });
3274
+
3275
+ let app = AppState::from_shared_config(&config, false);
3276
+ let params = app.build_params().expect("params");
3277
+ assert_eq!(
3278
+ params
3279
+ .existing_vault_key_id
3280
+ .map(|value| value.to_string())
3281
+ .as_deref(),
3282
+ Some("11111111-1111-1111-1111-111111111111")
3283
+ );
3284
+ assert_eq!(
3285
+ params.existing_vault_public_key.as_deref(),
3286
+ Some("031111111111111111111111111111111111111111111111111111111111111111")
3287
+ );
3288
+ }
3289
+
3290
+ #[test]
3291
+ fn resolve_manual_approval_uses_all_token_networks() {
3292
+ let config = sample_config();
3293
+ let token_policies = resolve_all_token_policies(&config).expect("token policies");
3294
+ let manual = resolve_all_token_manual_approval_policies(&config, &token_policies)
3295
+ .expect("manual approvals");
3296
+ assert_eq!(manual.len(), 2);
3297
+ assert_eq!(manual[0].priority, 120);
3298
+ }
3299
+
3300
+ #[test]
3301
+ fn network_membership_toggle_adds_saved_network_to_new_token() {
3302
+ let config = sample_config();
3303
+ let mut app = AppState::from_shared_config(&config, false);
3304
+ app.new_token_draft();
3305
+ app.selected = app
3306
+ .visible_fields()
3307
+ .iter()
3308
+ .position(|field| *field == Field::NetworkMembership)
3309
+ .expect("network membership field");
3310
+
3311
+ let _ = handle_key_event(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
3312
+ .expect("handle");
3313
+
3314
+ assert_eq!(app.token_draft.networks.len(), 1);
3315
+ }
3316
+
3317
+ #[test]
3318
+ fn bootstrap_panel_mentions_manual_approval_counts() {
3319
+ let app = AppState::from_shared_config(&sample_config(), false);
3320
+ let rendered = build_bootstrap_panel_lines(&app)
3321
+ .into_iter()
3322
+ .map(|line| line.to_string())
3323
+ .collect::<Vec<_>>()
3324
+ .join("\n");
3325
+ assert!(rendered.contains("2 destination override(s); 2 manual approval policy/policies."));
3326
+ }
3327
+
3328
+ #[test]
3329
+ fn token_limit_fields_are_decimal_strings_not_raw_wei_labels() {
3330
+ let config = sample_config();
3331
+ let app = AppState::from_shared_config(&config, false);
3332
+ assert_eq!(super::field_label(Field::PerTxLimit), "Per-Tx Limit");
3333
+ assert_eq!(super::field_value(&app, Field::PerTxLimit), "10");
3334
+ }
3335
+
3336
+ #[test]
3337
+ fn save_token_returns_live_apply_action_when_metadata_is_complete() {
3338
+ let config = sample_config();
3339
+ let mut app = AppState::from_shared_config(&config, false);
3340
+ let config_root =
3341
+ std::env::temp_dir().join(format!("wlfi-agent-admin-save-token-{}", Uuid::new_v4()));
3342
+ fs::create_dir_all(&config_root).expect("create temp config root");
3343
+ app.config_path = config_root.join("config.json");
3344
+ app.selected = app
3345
+ .visible_fields()
3346
+ .iter()
3347
+ .position(|field| *field == Field::SaveToken)
3348
+ .expect("save token field");
3349
+
3350
+ let action = handle_key_event(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
3351
+ .expect("handle");
3352
+
3353
+ match action {
3354
+ LoopAction::ApplyAndContinue {
3355
+ params,
3356
+ success_message,
3357
+ } => {
3358
+ assert_eq!(params.token_policies.len(), 2);
3359
+ assert!(success_message.contains("saved token 'usdc'"));
3360
+ assert!(success_message.contains("applied to wallet"));
3361
+ }
3362
+ _ => panic!("expected save token to apply and continue"),
3363
+ }
3364
+
3365
+ fs::remove_dir_all(config_root).expect("cleanup temp config root");
3366
+ }
3367
+
3368
+ #[test]
3369
+ fn advanced_fields_are_hidden_by_default() {
3370
+ let app = AppState::from_shared_config(&sample_config(), false);
3371
+ let fields = app.visible_fields();
3372
+ assert!(fields.contains(&Field::ShowAdvanced));
3373
+ assert!(!fields.contains(&Field::MaxGasPerChainWei));
3374
+ assert!(!fields.contains(&Field::PerTxMaxFeePerGasGwei));
3375
+ assert!(!fields.contains(&Field::OverrideMaxGasPerChainWei));
3376
+ }
3377
+
3378
+ #[test]
3379
+ fn advanced_fields_show_unlimited_when_enabled_without_values() {
3380
+ let mut app = AppState::from_shared_config(&sample_config(), false);
3381
+ app.show_advanced = true;
3382
+ app.token_draft.limits.max_gas_per_chain_wei.clear();
3383
+ app.token_draft.limits.daily_max_tx_count.clear();
3384
+ app.token_draft.limits.per_tx_max_fee_per_gas_gwei.clear();
3385
+ app.token_draft
3386
+ .limits
3387
+ .per_tx_max_priority_fee_per_gas_wei
3388
+ .clear();
3389
+ app.token_draft.limits.per_tx_max_calldata_bytes.clear();
3390
+
3391
+ assert_eq!(
3392
+ super::field_value(&app, Field::MaxGasPerChainWei),
3393
+ "unlimited"
3394
+ );
3395
+ assert_eq!(
3396
+ super::field_value(&app, Field::DailyMaxTxCount),
3397
+ "unlimited"
3398
+ );
3399
+ assert_eq!(
3400
+ super::field_value(&app, Field::PerTxMaxFeePerGasGwei),
3401
+ "unlimited"
3402
+ );
3403
+ }
3404
+
3405
+ #[test]
3406
+ fn native_token_address_field_is_rendered_as_native() {
3407
+ let app = AppState::from_shared_config(&WlfiConfig::default(), false);
3408
+ assert_eq!(super::field_value(&app, Field::NetworkAddress), "native");
3409
+ }
3410
+ }