@wlfi-agent/cli 1.4.13 → 1.4.15

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 +45 -41
  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 -1839
  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 -1839
@@ -0,0 +1,3990 @@
1
+ use std::collections::BTreeSet;
2
+ use std::path::PathBuf;
3
+ use std::sync::Arc;
4
+
5
+ use anyhow::{bail, Context, Result};
6
+ use clap::{Args, Parser, Subcommand, ValueEnum};
7
+ use serde::Serialize;
8
+ use time::format_description::well_known::Rfc3339;
9
+ use uuid::Uuid;
10
+ use vault_daemon::KeyManagerDaemonApi;
11
+ use vault_domain::{
12
+ AdminSession, AssetId, EntityScope, EvmAddress, ManualApprovalDecision, ManualApprovalRequest,
13
+ PolicyAttachment, PolicyType, RelayConfig, SpendingPolicy, DEFAULT_MAX_GAS_SPEND_PER_CHAIN_WEI,
14
+ };
15
+ use vault_signer::KeyCreateRequest;
16
+ use vault_transport_unix::{assert_root_owned_daemon_socket_path, UnixDaemonClient};
17
+ use zeroize::Zeroize;
18
+
19
+ mod io_utils;
20
+ mod shared_config;
21
+ mod tui;
22
+
23
+ use io_utils::*;
24
+
25
+ #[derive(Debug, Parser)]
26
+ #[command(name = "wlfi-agent-admin")]
27
+ #[command(about = "Admin CLI for configuring vault policies and agent keys")]
28
+ struct Cli {
29
+ #[arg(
30
+ long,
31
+ default_value_t = false,
32
+ help = "Read vault password from stdin (trailing newlines are trimmed)"
33
+ )]
34
+ vault_password_stdin: bool,
35
+ #[arg(
36
+ long,
37
+ default_value_t = false,
38
+ help = "Do not prompt for password; require --vault-password-stdin"
39
+ )]
40
+ non_interactive: bool,
41
+ #[arg(
42
+ long,
43
+ short = 'f',
44
+ value_enum,
45
+ value_name = "text|json",
46
+ help = "Output format"
47
+ )]
48
+ format: Option<OutputFormat>,
49
+ #[arg(
50
+ long,
51
+ short = 'j',
52
+ default_value_t = false,
53
+ help = "Shortcut for --format json"
54
+ )]
55
+ json: bool,
56
+ #[arg(
57
+ long,
58
+ short = 'q',
59
+ default_value_t = false,
60
+ help = "Suppress non-essential status messages in text mode"
61
+ )]
62
+ quiet: bool,
63
+ #[arg(
64
+ long,
65
+ short = 'o',
66
+ value_name = "PATH",
67
+ help = "Write final command output to PATH (use '-' for stdout)"
68
+ )]
69
+ output: Option<PathBuf>,
70
+ #[arg(
71
+ long,
72
+ default_value_t = false,
73
+ requires = "output",
74
+ help = "Allow replacing an existing output file"
75
+ )]
76
+ overwrite: bool,
77
+ #[arg(
78
+ long,
79
+ env = "WLFI_DAEMON_SOCKET",
80
+ value_name = "PATH",
81
+ help = "Always-on daemon unix socket path (default: $WLFI_HOME/daemon.sock or ~/.wlfi_agent/daemon.sock)"
82
+ )]
83
+ daemon_socket: Option<PathBuf>,
84
+ #[command(subcommand)]
85
+ command: Commands,
86
+ }
87
+
88
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
89
+ enum OutputFormat {
90
+ Text,
91
+ Json,
92
+ }
93
+
94
+ #[derive(Debug)]
95
+ enum OutputTarget {
96
+ Stdout,
97
+ File { path: PathBuf, overwrite: bool },
98
+ }
99
+
100
+ #[derive(Debug, Args)]
101
+ struct BootstrapCommandArgs {
102
+ #[arg(
103
+ long,
104
+ default_value_t = false,
105
+ help = "Load per-token bootstrap policies from the saved shared config instead of legacy global defaults"
106
+ )]
107
+ from_shared_config: bool,
108
+ #[arg(
109
+ long,
110
+ default_value_t = 1_000_000_000_000_000_000u128,
111
+ value_parser = parse_positive_u128
112
+ )]
113
+ per_tx_max_wei: u128,
114
+ #[arg(
115
+ long,
116
+ default_value_t = 5_000_000_000_000_000_000u128,
117
+ value_parser = parse_positive_u128
118
+ )]
119
+ daily_max_wei: u128,
120
+ #[arg(
121
+ long,
122
+ default_value_t = 20_000_000_000_000_000_000u128,
123
+ value_parser = parse_positive_u128
124
+ )]
125
+ weekly_max_wei: u128,
126
+ #[arg(
127
+ long,
128
+ default_value_t = DEFAULT_MAX_GAS_SPEND_PER_CHAIN_WEI,
129
+ value_parser = parse_positive_u128
130
+ )]
131
+ max_gas_per_chain_wei: u128,
132
+ #[arg(long, default_value_t = 0u128, value_parser = parse_non_negative_u128)]
133
+ daily_max_tx_count: u128,
134
+ #[arg(long, default_value_t = 0u128, value_parser = parse_non_negative_u128)]
135
+ per_tx_max_fee_per_gas_wei: u128,
136
+ #[arg(long, default_value_t = 0u128, value_parser = parse_non_negative_u128)]
137
+ per_tx_max_priority_fee_per_gas_wei: u128,
138
+ #[arg(long, default_value_t = 0u128, value_parser = parse_non_negative_u128)]
139
+ per_tx_max_calldata_bytes: u128,
140
+ #[arg(long)]
141
+ token: Vec<EvmAddress>,
142
+ #[arg(long, default_value_t = false)]
143
+ allow_native_eth: bool,
144
+ #[arg(long, value_parser = parse_positive_u64)]
145
+ network: Option<u64>,
146
+ #[arg(long)]
147
+ recipient: Option<EvmAddress>,
148
+ #[arg(
149
+ long,
150
+ value_name = "UUID",
151
+ help = "Attach new agent key to an explicit existing policy id (repeatable)"
152
+ )]
153
+ attach_policy_id: Vec<Uuid>,
154
+ #[arg(
155
+ long,
156
+ default_value_t = false,
157
+ help = "Deprecated compatibility flag; bootstrap-created policies are always attached"
158
+ )]
159
+ attach_bootstrap_policies: bool,
160
+ #[arg(long, default_value_t = false)]
161
+ print_agent_auth_token: bool,
162
+ #[arg(
163
+ long,
164
+ default_value_t = false,
165
+ help = "Include the vault private key in bootstrap output when generating a new software wallet (high risk)"
166
+ )]
167
+ print_vault_private_key: bool,
168
+ }
169
+
170
+ #[derive(Debug, Args)]
171
+ struct RotateAgentAuthTokenCommandArgs {
172
+ #[arg(long, value_name = "UUID", help = "Agent key id to rotate")]
173
+ agent_key_id: Uuid,
174
+ #[arg(long, default_value_t = false)]
175
+ print_agent_auth_token: bool,
176
+ }
177
+
178
+ #[derive(Debug, Args)]
179
+ struct RevokeAgentKeyCommandArgs {
180
+ #[arg(long, value_name = "UUID", help = "Agent key id to revoke")]
181
+ agent_key_id: Uuid,
182
+ }
183
+
184
+ #[derive(Debug, Args)]
185
+ struct AddManualApprovalPolicyCommandArgs {
186
+ #[arg(long, default_value_t = 100u32)]
187
+ priority: u32,
188
+ #[arg(long, value_parser = parse_positive_u128)]
189
+ min_amount_wei: u128,
190
+ #[arg(long, value_parser = parse_positive_u128)]
191
+ max_amount_wei: u128,
192
+ #[arg(long)]
193
+ token: Vec<EvmAddress>,
194
+ #[arg(long, default_value_t = false)]
195
+ allow_native_eth: bool,
196
+ #[arg(long, value_parser = parse_positive_u64)]
197
+ network: Option<u64>,
198
+ #[arg(long)]
199
+ recipient: Option<EvmAddress>,
200
+ }
201
+
202
+ #[derive(Debug, Args)]
203
+ struct ApproveManualApprovalRequestCommandArgs {
204
+ #[arg(long, value_name = "UUID")]
205
+ approval_request_id: Uuid,
206
+ }
207
+
208
+ #[derive(Debug, Args)]
209
+ struct RejectManualApprovalRequestCommandArgs {
210
+ #[arg(long, value_name = "UUID")]
211
+ approval_request_id: Uuid,
212
+ #[arg(long)]
213
+ rejection_reason: Option<String>,
214
+ }
215
+
216
+ #[derive(Debug, Args)]
217
+ struct SetRelayConfigCommandArgs {
218
+ #[arg(long)]
219
+ relay_url: Option<String>,
220
+ #[arg(long)]
221
+ frontend_url: Option<String>,
222
+ #[arg(
223
+ long,
224
+ default_value_t = false,
225
+ conflicts_with_all = ["relay_url", "frontend_url"]
226
+ )]
227
+ clear: bool,
228
+ }
229
+
230
+ #[derive(Debug, Args)]
231
+ struct SetupCommandArgs {
232
+ #[arg(
233
+ value_name = "ARGS",
234
+ num_args = 0..,
235
+ trailing_var_arg = true,
236
+ allow_hyphen_values = true,
237
+ help = "Additional setup arguments handled by the TypeScript wrapper"
238
+ )]
239
+ forwarded_args: Vec<String>,
240
+ }
241
+
242
+ #[derive(Debug, Args)]
243
+ struct TuiCommandArgs {
244
+ #[arg(
245
+ long,
246
+ default_value_t = false,
247
+ help = "Include the new agent auth token in bootstrap output so the wrapper can import it"
248
+ )]
249
+ print_agent_auth_token: bool,
250
+ }
251
+
252
+ #[derive(Debug, Subcommand)]
253
+ enum Commands {
254
+ #[command(about = "Create spending policies and issue a vault key + agent key")]
255
+ Bootstrap(Box<BootstrapCommandArgs>),
256
+ #[command(about = "Rotate the bearer token for an existing agent key")]
257
+ RotateAgentAuthToken(RotateAgentAuthTokenCommandArgs),
258
+ #[command(about = "Revoke an existing agent key and invalidate its bearer token")]
259
+ RevokeAgentKey(RevokeAgentKeyCommandArgs),
260
+ #[command(
261
+ about = "Create a manual-approval policy for matching destination/token/amount requests"
262
+ )]
263
+ AddManualApprovalPolicy(AddManualApprovalPolicyCommandArgs),
264
+ #[command(about = "List manual approval requests")]
265
+ ListManualApprovalRequests,
266
+ #[command(about = "Approve a pending manual approval request")]
267
+ ApproveManualApprovalRequest(ApproveManualApprovalRequestCommandArgs),
268
+ #[command(about = "Reject a pending manual approval request")]
269
+ RejectManualApprovalRequest(RejectManualApprovalRequestCommandArgs),
270
+ #[command(about = "Configure the relay URL used for approval UX")]
271
+ SetRelayConfig(SetRelayConfigCommandArgs),
272
+ #[command(about = "Read the relay configuration used for approval UX")]
273
+ GetRelayConfig,
274
+ #[command(about = "Launch interactive terminal UI for bootstrap configuration")]
275
+ Tui(TuiCommandArgs),
276
+ #[command(
277
+ about = "Install daemon autostart and bootstrap wallet access via the TypeScript wrapper"
278
+ )]
279
+ Setup(SetupCommandArgs),
280
+ }
281
+
282
+ #[derive(Debug, Serialize)]
283
+ struct BootstrapOutput {
284
+ state_file: String,
285
+ lease_id: String,
286
+ lease_expires_at: String,
287
+ #[serde(skip_serializing_if = "Option::is_none")]
288
+ per_tx_policy_id: Option<String>,
289
+ #[serde(skip_serializing_if = "Option::is_none")]
290
+ daily_policy_id: Option<String>,
291
+ #[serde(skip_serializing_if = "Option::is_none")]
292
+ weekly_policy_id: Option<String>,
293
+ #[serde(skip_serializing_if = "Option::is_none")]
294
+ gas_policy_id: Option<String>,
295
+ #[serde(skip_serializing_if = "Option::is_none")]
296
+ per_tx_max_wei: Option<String>,
297
+ #[serde(skip_serializing_if = "Option::is_none")]
298
+ daily_max_wei: Option<String>,
299
+ #[serde(skip_serializing_if = "Option::is_none")]
300
+ weekly_max_wei: Option<String>,
301
+ #[serde(skip_serializing_if = "Option::is_none")]
302
+ max_gas_per_chain_wei: Option<String>,
303
+ #[serde(skip_serializing_if = "Option::is_none")]
304
+ daily_max_tx_count: Option<String>,
305
+ #[serde(skip_serializing_if = "Option::is_none")]
306
+ daily_tx_count_policy_id: Option<String>,
307
+ #[serde(skip_serializing_if = "Option::is_none")]
308
+ per_tx_max_fee_per_gas_wei: Option<String>,
309
+ #[serde(skip_serializing_if = "Option::is_none")]
310
+ per_tx_max_fee_per_gas_policy_id: Option<String>,
311
+ #[serde(skip_serializing_if = "Option::is_none")]
312
+ per_tx_max_priority_fee_per_gas_wei: Option<String>,
313
+ #[serde(skip_serializing_if = "Option::is_none")]
314
+ per_tx_max_priority_fee_per_gas_policy_id: Option<String>,
315
+ #[serde(skip_serializing_if = "Option::is_none")]
316
+ per_tx_max_calldata_bytes: Option<String>,
317
+ #[serde(skip_serializing_if = "Option::is_none")]
318
+ per_tx_max_calldata_bytes_policy_id: Option<String>,
319
+ vault_key_id: String,
320
+ vault_public_key: String,
321
+ #[serde(skip_serializing_if = "Option::is_none")]
322
+ vault_private_key: Option<String>,
323
+ agent_key_id: String,
324
+ agent_auth_token: String,
325
+ agent_auth_token_redacted: bool,
326
+ #[serde(skip_serializing_if = "Option::is_none")]
327
+ network_scope: Option<String>,
328
+ #[serde(skip_serializing_if = "Option::is_none")]
329
+ asset_scope: Option<String>,
330
+ #[serde(skip_serializing_if = "Option::is_none")]
331
+ recipient_scope: Option<String>,
332
+ #[serde(skip_serializing_if = "Vec::is_empty")]
333
+ token_policies: Vec<TokenPolicyOutput>,
334
+ destination_override_count: usize,
335
+ #[serde(skip_serializing_if = "Vec::is_empty")]
336
+ destination_overrides: Vec<DestinationOverrideOutput>,
337
+ #[serde(skip_serializing_if = "Vec::is_empty")]
338
+ token_destination_overrides: Vec<TokenDestinationOverrideOutput>,
339
+ #[serde(skip_serializing_if = "Vec::is_empty")]
340
+ token_manual_approval_policies: Vec<TokenManualApprovalPolicyOutput>,
341
+ policy_attachment: String,
342
+ #[serde(skip_serializing_if = "Vec::is_empty")]
343
+ attached_policy_ids: Vec<String>,
344
+ policy_note: String,
345
+ }
346
+
347
+ #[derive(Debug, Serialize)]
348
+ struct DestinationOverrideOutput {
349
+ recipient: String,
350
+ per_tx_policy_id: String,
351
+ daily_policy_id: String,
352
+ weekly_policy_id: String,
353
+ #[serde(skip_serializing_if = "Option::is_none")]
354
+ gas_policy_id: Option<String>,
355
+ per_tx_max_wei: String,
356
+ daily_max_wei: String,
357
+ weekly_max_wei: String,
358
+ #[serde(skip_serializing_if = "Option::is_none")]
359
+ max_gas_per_chain_wei: Option<String>,
360
+ #[serde(skip_serializing_if = "Option::is_none")]
361
+ daily_max_tx_count: Option<String>,
362
+ #[serde(skip_serializing_if = "Option::is_none")]
363
+ daily_tx_count_policy_id: Option<String>,
364
+ #[serde(skip_serializing_if = "Option::is_none")]
365
+ per_tx_max_fee_per_gas_wei: Option<String>,
366
+ #[serde(skip_serializing_if = "Option::is_none")]
367
+ per_tx_max_fee_per_gas_policy_id: Option<String>,
368
+ #[serde(skip_serializing_if = "Option::is_none")]
369
+ per_tx_max_priority_fee_per_gas_wei: Option<String>,
370
+ #[serde(skip_serializing_if = "Option::is_none")]
371
+ per_tx_max_priority_fee_per_gas_policy_id: Option<String>,
372
+ #[serde(skip_serializing_if = "Option::is_none")]
373
+ per_tx_max_calldata_bytes: Option<String>,
374
+ #[serde(skip_serializing_if = "Option::is_none")]
375
+ per_tx_max_calldata_bytes_policy_id: Option<String>,
376
+ }
377
+
378
+ #[derive(Debug, Serialize)]
379
+ struct TokenPolicyOutput {
380
+ token_key: String,
381
+ symbol: String,
382
+ chain_key: String,
383
+ chain_id: u64,
384
+ asset_scope: String,
385
+ recipient_scope: String,
386
+ per_tx_policy_id: String,
387
+ daily_policy_id: String,
388
+ weekly_policy_id: String,
389
+ #[serde(skip_serializing_if = "Option::is_none")]
390
+ gas_policy_id: Option<String>,
391
+ per_tx_max_wei: String,
392
+ daily_max_wei: String,
393
+ weekly_max_wei: String,
394
+ #[serde(skip_serializing_if = "Option::is_none")]
395
+ max_gas_per_chain_wei: Option<String>,
396
+ #[serde(skip_serializing_if = "Option::is_none")]
397
+ daily_max_tx_count: Option<String>,
398
+ #[serde(skip_serializing_if = "Option::is_none")]
399
+ daily_tx_count_policy_id: Option<String>,
400
+ #[serde(skip_serializing_if = "Option::is_none")]
401
+ per_tx_max_fee_per_gas_wei: Option<String>,
402
+ #[serde(skip_serializing_if = "Option::is_none")]
403
+ per_tx_max_fee_per_gas_policy_id: Option<String>,
404
+ #[serde(skip_serializing_if = "Option::is_none")]
405
+ per_tx_max_priority_fee_per_gas_wei: Option<String>,
406
+ #[serde(skip_serializing_if = "Option::is_none")]
407
+ per_tx_max_priority_fee_per_gas_policy_id: Option<String>,
408
+ #[serde(skip_serializing_if = "Option::is_none")]
409
+ per_tx_max_calldata_bytes: Option<String>,
410
+ #[serde(skip_serializing_if = "Option::is_none")]
411
+ per_tx_max_calldata_bytes_policy_id: Option<String>,
412
+ }
413
+
414
+ #[derive(Debug, Serialize)]
415
+ struct TokenDestinationOverrideOutput {
416
+ token_key: String,
417
+ symbol: String,
418
+ chain_key: String,
419
+ chain_id: u64,
420
+ recipient: String,
421
+ asset_scope: String,
422
+ per_tx_policy_id: String,
423
+ daily_policy_id: String,
424
+ weekly_policy_id: String,
425
+ #[serde(skip_serializing_if = "Option::is_none")]
426
+ gas_policy_id: Option<String>,
427
+ per_tx_max_wei: String,
428
+ daily_max_wei: String,
429
+ weekly_max_wei: String,
430
+ #[serde(skip_serializing_if = "Option::is_none")]
431
+ max_gas_per_chain_wei: Option<String>,
432
+ #[serde(skip_serializing_if = "Option::is_none")]
433
+ daily_max_tx_count: Option<String>,
434
+ #[serde(skip_serializing_if = "Option::is_none")]
435
+ daily_tx_count_policy_id: Option<String>,
436
+ #[serde(skip_serializing_if = "Option::is_none")]
437
+ per_tx_max_fee_per_gas_wei: Option<String>,
438
+ #[serde(skip_serializing_if = "Option::is_none")]
439
+ per_tx_max_fee_per_gas_policy_id: Option<String>,
440
+ #[serde(skip_serializing_if = "Option::is_none")]
441
+ per_tx_max_priority_fee_per_gas_wei: Option<String>,
442
+ #[serde(skip_serializing_if = "Option::is_none")]
443
+ per_tx_max_priority_fee_per_gas_policy_id: Option<String>,
444
+ #[serde(skip_serializing_if = "Option::is_none")]
445
+ per_tx_max_calldata_bytes: Option<String>,
446
+ #[serde(skip_serializing_if = "Option::is_none")]
447
+ per_tx_max_calldata_bytes_policy_id: Option<String>,
448
+ }
449
+
450
+ #[derive(Debug, Serialize)]
451
+ struct TokenManualApprovalPolicyOutput {
452
+ token_key: String,
453
+ symbol: String,
454
+ chain_key: String,
455
+ chain_id: u64,
456
+ priority: u32,
457
+ min_amount_wei: String,
458
+ max_amount_wei: String,
459
+ asset_scope: String,
460
+ recipient_scope: String,
461
+ policy_id: String,
462
+ }
463
+
464
+ #[derive(Debug, Serialize)]
465
+ struct RotateAgentAuthTokenOutput {
466
+ agent_key_id: String,
467
+ agent_auth_token: String,
468
+ agent_auth_token_redacted: bool,
469
+ }
470
+
471
+ #[derive(Debug, Serialize)]
472
+ struct RevokeAgentKeyOutput {
473
+ agent_key_id: String,
474
+ revoked: bool,
475
+ }
476
+
477
+ #[derive(Debug, Serialize)]
478
+ struct ManualApprovalPolicyOutput {
479
+ policy_id: String,
480
+ priority: u32,
481
+ min_amount_wei: String,
482
+ max_amount_wei: String,
483
+ network_scope: String,
484
+ asset_scope: String,
485
+ recipient_scope: String,
486
+ }
487
+
488
+ #[derive(Debug, Clone)]
489
+ pub(crate) struct DestinationPolicyOverride {
490
+ recipient: EvmAddress,
491
+ per_tx_max_wei: u128,
492
+ daily_max_wei: u128,
493
+ weekly_max_wei: u128,
494
+ max_gas_per_chain_wei: u128,
495
+ daily_max_tx_count: u128,
496
+ per_tx_max_fee_per_gas_wei: u128,
497
+ per_tx_max_priority_fee_per_gas_wei: u128,
498
+ per_tx_max_calldata_bytes: u128,
499
+ }
500
+
501
+ #[derive(Debug, Clone)]
502
+ pub(crate) struct TokenPolicyConfig {
503
+ token_key: String,
504
+ symbol: String,
505
+ chain_key: String,
506
+ chain_id: u64,
507
+ is_native: bool,
508
+ address: Option<EvmAddress>,
509
+ per_tx_max_wei: u128,
510
+ daily_max_wei: u128,
511
+ weekly_max_wei: u128,
512
+ max_gas_per_chain_wei: u128,
513
+ daily_max_tx_count: u128,
514
+ per_tx_max_fee_per_gas_wei: u128,
515
+ per_tx_max_priority_fee_per_gas_wei: u128,
516
+ per_tx_max_calldata_bytes: u128,
517
+ }
518
+
519
+ #[derive(Debug, Clone)]
520
+ pub(crate) struct TokenDestinationPolicyOverride {
521
+ token_key: String,
522
+ chain_key: String,
523
+ recipient: EvmAddress,
524
+ per_tx_max_wei: u128,
525
+ daily_max_wei: u128,
526
+ weekly_max_wei: u128,
527
+ max_gas_per_chain_wei: u128,
528
+ daily_max_tx_count: u128,
529
+ per_tx_max_fee_per_gas_wei: u128,
530
+ per_tx_max_priority_fee_per_gas_wei: u128,
531
+ per_tx_max_calldata_bytes: u128,
532
+ }
533
+
534
+ #[derive(Debug, Clone)]
535
+ pub(crate) struct TokenManualApprovalPolicyConfig {
536
+ token_key: String,
537
+ symbol: String,
538
+ chain_key: String,
539
+ chain_id: u64,
540
+ is_native: bool,
541
+ address: Option<EvmAddress>,
542
+ priority: u32,
543
+ recipient: Option<EvmAddress>,
544
+ min_amount_wei: u128,
545
+ max_amount_wei: u128,
546
+ }
547
+
548
+ #[derive(Debug, Clone)]
549
+ pub(crate) struct BootstrapParams {
550
+ per_tx_max_wei: u128,
551
+ daily_max_wei: u128,
552
+ weekly_max_wei: u128,
553
+ max_gas_per_chain_wei: u128,
554
+ daily_max_tx_count: u128,
555
+ per_tx_max_fee_per_gas_wei: u128,
556
+ per_tx_max_priority_fee_per_gas_wei: u128,
557
+ per_tx_max_calldata_bytes: u128,
558
+ tokens: Vec<EvmAddress>,
559
+ allow_native_eth: bool,
560
+ network: Option<u64>,
561
+ recipient: Option<EvmAddress>,
562
+ token_policies: Vec<TokenPolicyConfig>,
563
+ destination_overrides: Vec<DestinationPolicyOverride>,
564
+ token_destination_overrides: Vec<TokenDestinationPolicyOverride>,
565
+ token_manual_approval_policies: Vec<TokenManualApprovalPolicyConfig>,
566
+ attach_policy_ids: Vec<Uuid>,
567
+ print_agent_auth_token: bool,
568
+ print_vault_private_key: bool,
569
+ existing_vault_key_id: Option<Uuid>,
570
+ existing_vault_public_key: Option<String>,
571
+ }
572
+
573
+ #[derive(Debug, Clone)]
574
+ struct RotateAgentAuthTokenParams {
575
+ agent_key_id: Uuid,
576
+ print_agent_auth_token: bool,
577
+ }
578
+
579
+ #[derive(Debug, Clone)]
580
+ struct RevokeAgentKeyParams {
581
+ agent_key_id: Uuid,
582
+ }
583
+
584
+ #[derive(Debug, Clone)]
585
+ struct AddManualApprovalPolicyParams {
586
+ priority: u32,
587
+ min_amount_wei: u128,
588
+ max_amount_wei: u128,
589
+ tokens: Vec<EvmAddress>,
590
+ allow_native_eth: bool,
591
+ network: Option<u64>,
592
+ recipient: Option<EvmAddress>,
593
+ }
594
+
595
+ #[derive(Debug, Clone)]
596
+ struct DecideManualApprovalRequestParams {
597
+ approval_request_id: Uuid,
598
+ decision: ManualApprovalDecision,
599
+ rejection_reason: Option<String>,
600
+ }
601
+
602
+ #[derive(Debug, Clone)]
603
+ struct SetRelayConfigParams {
604
+ relay_url: Option<String>,
605
+ frontend_url: Option<String>,
606
+ clear: bool,
607
+ }
608
+
609
+ #[tokio::main]
610
+ async fn main() -> Result<()> {
611
+ let cli = Cli::parse();
612
+ if let Commands::Setup(args) = &cli.command {
613
+ let forwarded = if args.forwarded_args.is_empty() {
614
+ String::new()
615
+ } else {
616
+ format!(" {}", args.forwarded_args.join(" "))
617
+ };
618
+ bail!(
619
+ "`setup` is implemented by the TypeScript wrapper, not the raw Rust binary. In local development run `node dist/cli.cjs admin setup{forwarded}` after `npm run build`, or `pnpm exec wlfi-agent admin setup{forwarded}`."
620
+ );
621
+ }
622
+ let output_format = resolve_output_format(cli.format, cli.json)?;
623
+ let output_target = resolve_output_target(cli.output, cli.overwrite)?;
624
+ let daemon_socket = resolve_daemon_socket_path(cli.daemon_socket)?;
625
+ let mut vault_password = resolve_vault_password(cli.vault_password_stdin, cli.non_interactive)?;
626
+
627
+ let socket_client = Arc::new(UnixDaemonClient::new_with_expected_server_euid(
628
+ daemon_socket.clone(),
629
+ std::time::Duration::from_secs(10),
630
+ 0,
631
+ ));
632
+ let daemon_api = socket_client as Arc<dyn KeyManagerDaemonApi>;
633
+ let state_file_display = format!("daemon_socket:{}", daemon_socket.display());
634
+
635
+ let result = match cli.command {
636
+ Commands::Bootstrap(args) => {
637
+ let BootstrapCommandArgs {
638
+ from_shared_config,
639
+ per_tx_max_wei,
640
+ daily_max_wei,
641
+ weekly_max_wei,
642
+ max_gas_per_chain_wei,
643
+ daily_max_tx_count,
644
+ per_tx_max_fee_per_gas_wei,
645
+ per_tx_max_priority_fee_per_gas_wei,
646
+ per_tx_max_calldata_bytes,
647
+ token,
648
+ allow_native_eth,
649
+ network,
650
+ recipient,
651
+ attach_policy_id,
652
+ attach_bootstrap_policies: _attach_bootstrap_policies,
653
+ print_agent_auth_token,
654
+ print_vault_private_key,
655
+ } = *args;
656
+ let params = if from_shared_config {
657
+ let shared_config = shared_config::LoadedConfig::load_default()?;
658
+ let mut params = tui::build_bootstrap_params_from_shared_config(
659
+ &shared_config.config,
660
+ print_agent_auth_token,
661
+ false,
662
+ )?;
663
+ params.attach_policy_ids = attach_policy_id;
664
+ params
665
+ } else {
666
+ BootstrapParams {
667
+ per_tx_max_wei,
668
+ daily_max_wei,
669
+ weekly_max_wei,
670
+ max_gas_per_chain_wei,
671
+ daily_max_tx_count,
672
+ per_tx_max_fee_per_gas_wei,
673
+ per_tx_max_priority_fee_per_gas_wei,
674
+ per_tx_max_calldata_bytes,
675
+ tokens: token,
676
+ allow_native_eth,
677
+ network,
678
+ recipient,
679
+ token_policies: Vec::new(),
680
+ destination_overrides: Vec::new(),
681
+ token_destination_overrides: Vec::new(),
682
+ token_manual_approval_policies: Vec::new(),
683
+ attach_policy_ids: attach_policy_id,
684
+ print_agent_auth_token,
685
+ print_vault_private_key,
686
+ existing_vault_key_id: None,
687
+ existing_vault_public_key: None,
688
+ }
689
+ };
690
+ let output = execute_bootstrap(
691
+ daemon_api.clone(),
692
+ &vault_password,
693
+ &state_file_display,
694
+ params,
695
+ |message| print_status(message, output_format, cli.quiet),
696
+ )
697
+ .await?;
698
+ print_status("bootstrap complete", output_format, cli.quiet);
699
+ print_bootstrap_output(&output, output_format, &output_target)
700
+ }
701
+ Commands::RotateAgentAuthToken(args) => {
702
+ let params = RotateAgentAuthTokenParams {
703
+ agent_key_id: args.agent_key_id,
704
+ print_agent_auth_token: args.print_agent_auth_token,
705
+ };
706
+ let output = execute_rotate_agent_auth_token(
707
+ daemon_api.clone(),
708
+ &vault_password,
709
+ params,
710
+ |message| print_status(message, output_format, cli.quiet),
711
+ )
712
+ .await?;
713
+ print_status("agent auth token rotated", output_format, cli.quiet);
714
+ print_rotate_agent_auth_token_output(&output, output_format, &output_target)
715
+ }
716
+ Commands::RevokeAgentKey(args) => {
717
+ let params = RevokeAgentKeyParams {
718
+ agent_key_id: args.agent_key_id,
719
+ };
720
+ let output =
721
+ execute_revoke_agent_key(daemon_api.clone(), &vault_password, params, |message| {
722
+ print_status(message, output_format, cli.quiet)
723
+ })
724
+ .await?;
725
+ print_status("agent key revoked", output_format, cli.quiet);
726
+ print_revoke_agent_key_output(&output, output_format, &output_target)
727
+ }
728
+ Commands::AddManualApprovalPolicy(args) => {
729
+ let params = AddManualApprovalPolicyParams {
730
+ priority: args.priority,
731
+ min_amount_wei: args.min_amount_wei,
732
+ max_amount_wei: args.max_amount_wei,
733
+ tokens: args.token,
734
+ allow_native_eth: args.allow_native_eth,
735
+ network: args.network,
736
+ recipient: args.recipient,
737
+ };
738
+ let output = execute_add_manual_approval_policy(
739
+ daemon_api.clone(),
740
+ &vault_password,
741
+ params,
742
+ |message| print_status(message, output_format, cli.quiet),
743
+ )
744
+ .await?;
745
+ print_status("manual approval policy created", output_format, cli.quiet);
746
+ print_manual_approval_policy_output(&output, output_format, &output_target)
747
+ }
748
+ Commands::ListManualApprovalRequests => {
749
+ let output = execute_list_manual_approval_requests(
750
+ daemon_api.clone(),
751
+ &vault_password,
752
+ |message| print_status(message, output_format, cli.quiet),
753
+ )
754
+ .await?;
755
+ print_manual_approval_requests_output(&output, output_format, &output_target)
756
+ }
757
+ Commands::ApproveManualApprovalRequest(args) => {
758
+ let output = execute_decide_manual_approval_request(
759
+ daemon_api.clone(),
760
+ &vault_password,
761
+ DecideManualApprovalRequestParams {
762
+ approval_request_id: args.approval_request_id,
763
+ decision: ManualApprovalDecision::Approve,
764
+ rejection_reason: None,
765
+ },
766
+ |message| print_status(message, output_format, cli.quiet),
767
+ )
768
+ .await?;
769
+ print_status("manual approval request updated", output_format, cli.quiet);
770
+ print_manual_approval_request_output(&output, output_format, &output_target)
771
+ }
772
+ Commands::RejectManualApprovalRequest(args) => {
773
+ let output = execute_decide_manual_approval_request(
774
+ daemon_api.clone(),
775
+ &vault_password,
776
+ DecideManualApprovalRequestParams {
777
+ approval_request_id: args.approval_request_id,
778
+ decision: ManualApprovalDecision::Reject,
779
+ rejection_reason: args.rejection_reason,
780
+ },
781
+ |message| print_status(message, output_format, cli.quiet),
782
+ )
783
+ .await?;
784
+ print_status("manual approval request updated", output_format, cli.quiet);
785
+ print_manual_approval_request_output(&output, output_format, &output_target)
786
+ }
787
+ Commands::SetRelayConfig(args) => {
788
+ let relay_url = if args.clear { None } else { args.relay_url };
789
+ let frontend_url = if args.clear { None } else { args.frontend_url };
790
+ let output = execute_set_relay_config(
791
+ daemon_api.clone(),
792
+ &vault_password,
793
+ SetRelayConfigParams {
794
+ relay_url,
795
+ frontend_url,
796
+ clear: args.clear,
797
+ },
798
+ |message| print_status(message, output_format, cli.quiet),
799
+ )
800
+ .await?;
801
+ print_status("relay configuration updated", output_format, cli.quiet);
802
+ print_relay_config_output(&output, output_format, &output_target)
803
+ }
804
+ Commands::GetRelayConfig => {
805
+ let output = execute_get_relay_config(daemon_api.clone(), &vault_password, |message| {
806
+ print_status(message, output_format, cli.quiet)
807
+ })
808
+ .await?;
809
+ print_relay_config_output(&output, output_format, &output_target)
810
+ }
811
+ Commands::Tui(args) => {
812
+ let shared_config = shared_config::LoadedConfig::load_default()?;
813
+ if let Some(output) = tui::run_bootstrap_tui(
814
+ &shared_config.config,
815
+ args.print_agent_auth_token,
816
+ |params| {
817
+ tokio::task::block_in_place(|| {
818
+ tokio::runtime::Handle::current().block_on(execute_bootstrap(
819
+ daemon_api.clone(),
820
+ &vault_password,
821
+ &state_file_display,
822
+ params,
823
+ |_| {},
824
+ ))
825
+ })
826
+ },
827
+ )? {
828
+ print_status("bootstrap complete", output_format, cli.quiet);
829
+ print_bootstrap_output(&output, output_format, &output_target)
830
+ } else {
831
+ print_status("tui canceled", output_format, cli.quiet);
832
+ Ok(())
833
+ }
834
+ }
835
+ Commands::Setup(_) => unreachable!("setup is handled before daemon initialization"),
836
+ };
837
+
838
+ vault_password.zeroize();
839
+ result
840
+ }
841
+
842
+ async fn execute_bootstrap(
843
+ daemon: Arc<dyn KeyManagerDaemonApi>,
844
+ vault_password: &str,
845
+ state_file_display: &str,
846
+ params: BootstrapParams,
847
+ mut on_status: impl FnMut(&str),
848
+ ) -> Result<BootstrapOutput> {
849
+ if !params.token_policies.is_empty() {
850
+ return execute_per_token_bootstrap(
851
+ daemon,
852
+ vault_password,
853
+ state_file_display,
854
+ params,
855
+ on_status,
856
+ )
857
+ .await;
858
+ }
859
+
860
+ validate_policy_limits(
861
+ params.per_tx_max_wei,
862
+ params.daily_max_wei,
863
+ params.weekly_max_wei,
864
+ )?;
865
+ validate_destination_policy_overrides(&params)?;
866
+ on_status("initializing daemon");
867
+
868
+ on_status("issuing admin lease");
869
+ let lease = daemon.issue_lease(vault_password).await?;
870
+ let mut session = AdminSession {
871
+ vault_password: vault_password.to_string(),
872
+ lease: lease.clone(),
873
+ };
874
+ let result = async {
875
+ let asset_scope = build_asset_scope(&params.tokens, params.allow_native_eth);
876
+ let network_scope = build_network_scope(params.network);
877
+ let recipient_scope: EntityScope<EvmAddress> = params
878
+ .recipient
879
+ .clone()
880
+ .map(single_scope)
881
+ .unwrap_or(EntityScope::All);
882
+ let default_limits = PolicyLimitConfig::from_params(&params);
883
+ let default_bundle = build_policy_bundle(
884
+ 0,
885
+ &default_limits,
886
+ recipient_scope.clone(),
887
+ asset_scope.clone(),
888
+ network_scope.clone(),
889
+ )?;
890
+
891
+ let mut destination_override_bundles = Vec::with_capacity(params.destination_overrides.len());
892
+ for (index, destination_override) in params.destination_overrides.iter().enumerate() {
893
+ let priority_base = 100 + (index as u32 * 10);
894
+ let bundle = build_policy_bundle(
895
+ priority_base,
896
+ &PolicyLimitConfig::from_destination_override(destination_override),
897
+ single_scope(destination_override.recipient.clone()),
898
+ asset_scope.clone(),
899
+ network_scope.clone(),
900
+ )?;
901
+ destination_override_bundles.push((destination_override, bundle));
902
+ }
903
+
904
+ validate_existing_policy_attachments(daemon.as_ref(), &session, &params.attach_policy_ids)
905
+ .await?;
906
+
907
+ on_status("registering spending policies");
908
+ register_policy_bundle(daemon.as_ref(), &session, &default_bundle).await?;
909
+ for (_, bundle) in &destination_override_bundles {
910
+ register_policy_bundle(daemon.as_ref(), &session, bundle).await?;
911
+ }
912
+
913
+ let mut created_policy_ids = default_bundle.policy_ids();
914
+ for (_, bundle) in &destination_override_bundles {
915
+ created_policy_ids.extend(bundle.policy_ids());
916
+ }
917
+
918
+ let (policy_attachment, policy_attachment_label, attached_policy_ids, mut policy_note) =
919
+ resolve_bootstrap_policy_attachment(created_policy_ids, &params.attach_policy_ids)?;
920
+
921
+ if !params.destination_overrides.is_empty() {
922
+ policy_note.push_str(&format!(
923
+ "; {} destination override(s) apply as stricter overlays and do not replace the default limits",
924
+ params.destination_overrides.len()
925
+ ));
926
+ }
927
+
928
+ let (vault_key_id, vault_public_key, vault_private_key, key_status_message) =
929
+ match (
930
+ params.existing_vault_key_id,
931
+ params.existing_vault_public_key.as_ref(),
932
+ ) {
933
+ (Some(vault_key_id), Some(vault_public_key)) => {
934
+ policy_note.push_str("; reused the existing wallet address");
935
+ (vault_key_id, vault_public_key.clone(), None, "creating agent key")
936
+ }
937
+ (Some(_), None) => {
938
+ bail!("existing wallet metadata is incomplete: wallet.vaultPublicKey is required");
939
+ }
940
+ (None, Some(_)) => {
941
+ bail!("existing wallet metadata is incomplete: wallet.vaultKeyId is required");
942
+ }
943
+ (None, None) => {
944
+ let vault_key = daemon
945
+ .create_vault_key(&session, KeyCreateRequest::Generate)
946
+ .await?;
947
+ let vault_private_key = if params.print_vault_private_key {
948
+ daemon.export_vault_private_key(&session, vault_key.id).await?
949
+ } else {
950
+ None
951
+ };
952
+ (
953
+ vault_key.id,
954
+ vault_key.public_key_hex,
955
+ vault_private_key,
956
+ "creating vault and agent keys",
957
+ )
958
+ }
959
+ };
960
+ on_status(key_status_message);
961
+ let mut agent_credentials = daemon
962
+ .create_agent_key(&session, vault_key_id, policy_attachment)
963
+ .await?;
964
+ let agent_auth_token = if params.print_agent_auth_token {
965
+ std::mem::take(&mut agent_credentials.auth_token)
966
+ } else {
967
+ agent_credentials.auth_token.zeroize();
968
+ "<redacted>".to_string()
969
+ };
970
+
971
+ let destination_override_outputs = destination_override_bundles
972
+ .iter()
973
+ .map(|(destination_override, bundle)| DestinationOverrideOutput {
974
+ recipient: destination_override.recipient.to_string(),
975
+ per_tx_policy_id: bundle.per_tx.id.to_string(),
976
+ daily_policy_id: bundle.daily.id.to_string(),
977
+ weekly_policy_id: bundle.weekly.id.to_string(),
978
+ gas_policy_id: bundle.gas.as_ref().map(|policy| policy.id.to_string()),
979
+ per_tx_max_wei: destination_override.per_tx_max_wei.to_string(),
980
+ daily_max_wei: destination_override.daily_max_wei.to_string(),
981
+ weekly_max_wei: destination_override.weekly_max_wei.to_string(),
982
+ max_gas_per_chain_wei: (destination_override.max_gas_per_chain_wei > 0)
983
+ .then(|| destination_override.max_gas_per_chain_wei.to_string()),
984
+ daily_max_tx_count: (destination_override.daily_max_tx_count > 0)
985
+ .then(|| destination_override.daily_max_tx_count.to_string()),
986
+ daily_tx_count_policy_id: bundle
987
+ .daily_tx_count
988
+ .as_ref()
989
+ .map(|policy| policy.id.to_string()),
990
+ per_tx_max_fee_per_gas_wei: (destination_override.per_tx_max_fee_per_gas_wei > 0)
991
+ .then(|| destination_override.per_tx_max_fee_per_gas_wei.to_string()),
992
+ per_tx_max_fee_per_gas_policy_id: bundle
993
+ .per_tx_max_fee
994
+ .as_ref()
995
+ .map(|policy| policy.id.to_string()),
996
+ per_tx_max_priority_fee_per_gas_wei: (destination_override
997
+ .per_tx_max_priority_fee_per_gas_wei
998
+ > 0)
999
+ .then(|| {
1000
+ destination_override
1001
+ .per_tx_max_priority_fee_per_gas_wei
1002
+ .to_string()
1003
+ }),
1004
+ per_tx_max_priority_fee_per_gas_policy_id: bundle
1005
+ .per_tx_max_priority_fee
1006
+ .as_ref()
1007
+ .map(|policy| policy.id.to_string()),
1008
+ per_tx_max_calldata_bytes: (destination_override.per_tx_max_calldata_bytes > 0)
1009
+ .then(|| destination_override.per_tx_max_calldata_bytes.to_string()),
1010
+ per_tx_max_calldata_bytes_policy_id: bundle
1011
+ .per_tx_max_calldata_bytes
1012
+ .as_ref()
1013
+ .map(|policy| policy.id.to_string()),
1014
+ })
1015
+ .collect::<Vec<_>>();
1016
+
1017
+ Ok(BootstrapOutput {
1018
+ state_file: state_file_display.to_string(),
1019
+ lease_id: lease.lease_id.to_string(),
1020
+ lease_expires_at: lease
1021
+ .expires_at
1022
+ .format(&Rfc3339)
1023
+ .context("failed to format lease expiry as RFC3339")?,
1024
+ per_tx_policy_id: Some(default_bundle.per_tx.id.to_string()),
1025
+ daily_policy_id: Some(default_bundle.daily.id.to_string()),
1026
+ weekly_policy_id: Some(default_bundle.weekly.id.to_string()),
1027
+ gas_policy_id: default_bundle.gas.as_ref().map(|policy| policy.id.to_string()),
1028
+ per_tx_max_wei: Some(params.per_tx_max_wei.to_string()),
1029
+ daily_max_wei: Some(params.daily_max_wei.to_string()),
1030
+ weekly_max_wei: Some(params.weekly_max_wei.to_string()),
1031
+ max_gas_per_chain_wei: (params.max_gas_per_chain_wei > 0)
1032
+ .then(|| params.max_gas_per_chain_wei.to_string()),
1033
+ daily_max_tx_count: (params.daily_max_tx_count > 0)
1034
+ .then(|| params.daily_max_tx_count.to_string()),
1035
+ daily_tx_count_policy_id: default_bundle
1036
+ .daily_tx_count
1037
+ .as_ref()
1038
+ .map(|policy| policy.id.to_string()),
1039
+ per_tx_max_fee_per_gas_wei: (params.per_tx_max_fee_per_gas_wei > 0)
1040
+ .then(|| params.per_tx_max_fee_per_gas_wei.to_string()),
1041
+ per_tx_max_fee_per_gas_policy_id: default_bundle
1042
+ .per_tx_max_fee
1043
+ .as_ref()
1044
+ .map(|policy| policy.id.to_string()),
1045
+ per_tx_max_priority_fee_per_gas_wei: (params.per_tx_max_priority_fee_per_gas_wei > 0)
1046
+ .then(|| params.per_tx_max_priority_fee_per_gas_wei.to_string()),
1047
+ per_tx_max_priority_fee_per_gas_policy_id: default_bundle
1048
+ .per_tx_max_priority_fee
1049
+ .as_ref()
1050
+ .map(|policy| policy.id.to_string()),
1051
+ per_tx_max_calldata_bytes: (params.per_tx_max_calldata_bytes > 0)
1052
+ .then(|| params.per_tx_max_calldata_bytes.to_string()),
1053
+ per_tx_max_calldata_bytes_policy_id: default_bundle
1054
+ .per_tx_max_calldata_bytes
1055
+ .as_ref()
1056
+ .map(|policy| policy.id.to_string()),
1057
+ vault_key_id: vault_key_id.to_string(),
1058
+ vault_public_key,
1059
+ vault_private_key,
1060
+ agent_key_id: agent_credentials.agent_key.id.to_string(),
1061
+ agent_auth_token,
1062
+ agent_auth_token_redacted: !params.print_agent_auth_token,
1063
+ network_scope: Some(describe_network_scope(&network_scope)),
1064
+ asset_scope: Some(describe_asset_scope(&asset_scope)),
1065
+ recipient_scope: Some(describe_recipient_scope(&recipient_scope)),
1066
+ token_policies: Vec::new(),
1067
+ destination_override_count: destination_override_outputs.len(),
1068
+ destination_overrides: destination_override_outputs,
1069
+ token_destination_overrides: Vec::new(),
1070
+ token_manual_approval_policies: Vec::new(),
1071
+ policy_attachment: policy_attachment_label,
1072
+ attached_policy_ids,
1073
+ policy_note,
1074
+ })
1075
+ }
1076
+ .await;
1077
+
1078
+ session.vault_password.zeroize();
1079
+ result
1080
+ }
1081
+
1082
+ async fn execute_per_token_bootstrap(
1083
+ daemon: Arc<dyn KeyManagerDaemonApi>,
1084
+ vault_password: &str,
1085
+ state_file_display: &str,
1086
+ params: BootstrapParams,
1087
+ mut on_status: impl FnMut(&str),
1088
+ ) -> Result<BootstrapOutput> {
1089
+ validate_per_token_bootstrap_params(&params)?;
1090
+ on_status("initializing daemon");
1091
+
1092
+ on_status("issuing admin lease");
1093
+ let lease = daemon.issue_lease(vault_password).await?;
1094
+ let mut session = AdminSession {
1095
+ vault_password: vault_password.to_string(),
1096
+ lease: lease.clone(),
1097
+ };
1098
+
1099
+ let result = async {
1100
+ let mut token_policy_bundles = Vec::with_capacity(params.token_policies.len());
1101
+ for (index, token_policy) in params.token_policies.iter().enumerate() {
1102
+ let asset_scope = build_asset_scope_for_token_policy(token_policy)?;
1103
+ let network_scope = single_scope(token_policy.chain_id);
1104
+ let bundle = build_policy_bundle(
1105
+ index as u32 * 100,
1106
+ &PolicyLimitConfig::from_token_policy(token_policy),
1107
+ EntityScope::All,
1108
+ asset_scope.clone(),
1109
+ network_scope.clone(),
1110
+ )?;
1111
+ token_policy_bundles.push((token_policy, asset_scope, network_scope, bundle));
1112
+ }
1113
+
1114
+ let mut token_destination_override_bundles =
1115
+ Vec::with_capacity(params.token_destination_overrides.len());
1116
+ for (index, destination_override) in params.token_destination_overrides.iter().enumerate() {
1117
+ let token_policy = params
1118
+ .token_policies
1119
+ .iter()
1120
+ .find(|policy| {
1121
+ policy.token_key == destination_override.token_key
1122
+ && policy.chain_key == destination_override.chain_key
1123
+ })
1124
+ .with_context(|| {
1125
+ format!(
1126
+ "unknown token selector for destination override: {}:{}",
1127
+ destination_override.token_key, destination_override.chain_key
1128
+ )
1129
+ })?;
1130
+ let asset_scope = build_asset_scope_for_token_policy(token_policy)?;
1131
+ let network_scope = single_scope(token_policy.chain_id);
1132
+ let bundle = build_policy_bundle(
1133
+ 10_000 + index as u32 * 100,
1134
+ &PolicyLimitConfig::from_token_destination_override(destination_override),
1135
+ single_scope(destination_override.recipient.clone()),
1136
+ asset_scope.clone(),
1137
+ network_scope.clone(),
1138
+ )?;
1139
+ token_destination_override_bundles.push((
1140
+ destination_override,
1141
+ token_policy,
1142
+ asset_scope,
1143
+ network_scope,
1144
+ bundle,
1145
+ ));
1146
+ }
1147
+
1148
+ let mut token_manual_approval_policies =
1149
+ Vec::with_capacity(params.token_manual_approval_policies.len());
1150
+ for manual_approval in &params.token_manual_approval_policies {
1151
+ let asset_scope = build_asset_scope_for_token_manual_approval(manual_approval)?;
1152
+ let network_scope = single_scope(manual_approval.chain_id);
1153
+ let recipient_scope = manual_approval
1154
+ .recipient
1155
+ .clone()
1156
+ .map_or(EntityScope::All, single_scope);
1157
+ let policy = SpendingPolicy::new_manual_approval(
1158
+ manual_approval.priority,
1159
+ manual_approval.min_amount_wei,
1160
+ manual_approval.max_amount_wei,
1161
+ recipient_scope.clone(),
1162
+ asset_scope.clone(),
1163
+ network_scope.clone(),
1164
+ )?;
1165
+ token_manual_approval_policies.push((
1166
+ manual_approval,
1167
+ asset_scope,
1168
+ recipient_scope,
1169
+ network_scope,
1170
+ policy,
1171
+ ));
1172
+ }
1173
+
1174
+ validate_existing_policy_attachments(daemon.as_ref(), &session, &params.attach_policy_ids)
1175
+ .await?;
1176
+
1177
+ on_status("registering spending policies");
1178
+ for (_, _, _, bundle) in &token_policy_bundles {
1179
+ register_policy_bundle(daemon.as_ref(), &session, bundle).await?;
1180
+ }
1181
+ for (_, _, _, _, bundle) in &token_destination_override_bundles {
1182
+ register_policy_bundle(daemon.as_ref(), &session, bundle).await?;
1183
+ }
1184
+ for (_, _, _, _, policy) in &token_manual_approval_policies {
1185
+ daemon.add_policy(&session, policy.clone()).await?;
1186
+ }
1187
+
1188
+ let mut created_policy_ids = BTreeSet::new();
1189
+ for (_, _, _, bundle) in &token_policy_bundles {
1190
+ created_policy_ids.extend(bundle.policy_ids());
1191
+ }
1192
+ for (_, _, _, _, bundle) in &token_destination_override_bundles {
1193
+ created_policy_ids.extend(bundle.policy_ids());
1194
+ }
1195
+ for (_, _, _, _, policy) in &token_manual_approval_policies {
1196
+ created_policy_ids.insert(policy.id);
1197
+ }
1198
+
1199
+ let (policy_attachment, policy_attachment_label, attached_policy_ids, mut policy_note) =
1200
+ resolve_bootstrap_policy_attachment(created_policy_ids, &params.attach_policy_ids)?;
1201
+ policy_note.push_str(&format!(
1202
+ "; {} per-token policy bundle(s) created",
1203
+ token_policy_bundles.len()
1204
+ ));
1205
+ if !token_destination_override_bundles.is_empty() {
1206
+ policy_note.push_str(&format!(
1207
+ "; {} per-token destination override(s) apply as stricter overlays",
1208
+ token_destination_override_bundles.len()
1209
+ ));
1210
+ }
1211
+ if !token_manual_approval_policies.is_empty() {
1212
+ policy_note.push_str(&format!(
1213
+ "; {} token manual approval policy/policies created",
1214
+ token_manual_approval_policies.len()
1215
+ ));
1216
+ }
1217
+
1218
+ let (vault_key_id, vault_public_key, vault_private_key, key_status_message) = match (
1219
+ params.existing_vault_key_id,
1220
+ params.existing_vault_public_key.as_ref(),
1221
+ ) {
1222
+ (Some(vault_key_id), Some(vault_public_key)) => {
1223
+ policy_note.push_str("; reused the existing wallet address");
1224
+ (
1225
+ vault_key_id,
1226
+ vault_public_key.clone(),
1227
+ None,
1228
+ "creating agent key",
1229
+ )
1230
+ }
1231
+ (Some(_), None) => {
1232
+ bail!("existing wallet metadata is incomplete: wallet.vaultPublicKey is required");
1233
+ }
1234
+ (None, Some(_)) => {
1235
+ bail!("existing wallet metadata is incomplete: wallet.vaultKeyId is required");
1236
+ }
1237
+ (None, None) => {
1238
+ let vault_key = daemon
1239
+ .create_vault_key(&session, KeyCreateRequest::Generate)
1240
+ .await?;
1241
+ let vault_private_key = if params.print_vault_private_key {
1242
+ daemon
1243
+ .export_vault_private_key(&session, vault_key.id)
1244
+ .await?
1245
+ } else {
1246
+ None
1247
+ };
1248
+ (
1249
+ vault_key.id,
1250
+ vault_key.public_key_hex,
1251
+ vault_private_key,
1252
+ "creating vault and agent keys",
1253
+ )
1254
+ }
1255
+ };
1256
+ on_status(key_status_message);
1257
+ let mut agent_credentials = daemon
1258
+ .create_agent_key(&session, vault_key_id, policy_attachment)
1259
+ .await?;
1260
+ let agent_auth_token = if params.print_agent_auth_token {
1261
+ std::mem::take(&mut agent_credentials.auth_token)
1262
+ } else {
1263
+ agent_credentials.auth_token.zeroize();
1264
+ "<redacted>".to_string()
1265
+ };
1266
+
1267
+ let token_policy_outputs = token_policy_bundles
1268
+ .iter()
1269
+ .map(|(token_policy, asset_scope, _, bundle)| TokenPolicyOutput {
1270
+ token_key: token_policy.token_key.clone(),
1271
+ symbol: token_policy.symbol.clone(),
1272
+ chain_key: token_policy.chain_key.clone(),
1273
+ chain_id: token_policy.chain_id,
1274
+ asset_scope: describe_asset_scope(asset_scope),
1275
+ recipient_scope: "all recipients".to_string(),
1276
+ per_tx_policy_id: bundle.per_tx.id.to_string(),
1277
+ daily_policy_id: bundle.daily.id.to_string(),
1278
+ weekly_policy_id: bundle.weekly.id.to_string(),
1279
+ gas_policy_id: bundle.gas.as_ref().map(|policy| policy.id.to_string()),
1280
+ per_tx_max_wei: token_policy.per_tx_max_wei.to_string(),
1281
+ daily_max_wei: token_policy.daily_max_wei.to_string(),
1282
+ weekly_max_wei: token_policy.weekly_max_wei.to_string(),
1283
+ max_gas_per_chain_wei: (token_policy.max_gas_per_chain_wei > 0)
1284
+ .then(|| token_policy.max_gas_per_chain_wei.to_string()),
1285
+ daily_max_tx_count: (token_policy.daily_max_tx_count > 0)
1286
+ .then(|| token_policy.daily_max_tx_count.to_string()),
1287
+ daily_tx_count_policy_id: bundle
1288
+ .daily_tx_count
1289
+ .as_ref()
1290
+ .map(|policy| policy.id.to_string()),
1291
+ per_tx_max_fee_per_gas_wei: (token_policy.per_tx_max_fee_per_gas_wei > 0)
1292
+ .then(|| token_policy.per_tx_max_fee_per_gas_wei.to_string()),
1293
+ per_tx_max_fee_per_gas_policy_id: bundle
1294
+ .per_tx_max_fee
1295
+ .as_ref()
1296
+ .map(|policy| policy.id.to_string()),
1297
+ per_tx_max_priority_fee_per_gas_wei: (token_policy
1298
+ .per_tx_max_priority_fee_per_gas_wei
1299
+ > 0)
1300
+ .then(|| token_policy.per_tx_max_priority_fee_per_gas_wei.to_string()),
1301
+ per_tx_max_priority_fee_per_gas_policy_id: bundle
1302
+ .per_tx_max_priority_fee
1303
+ .as_ref()
1304
+ .map(|policy| policy.id.to_string()),
1305
+ per_tx_max_calldata_bytes: (token_policy.per_tx_max_calldata_bytes > 0)
1306
+ .then(|| token_policy.per_tx_max_calldata_bytes.to_string()),
1307
+ per_tx_max_calldata_bytes_policy_id: bundle
1308
+ .per_tx_max_calldata_bytes
1309
+ .as_ref()
1310
+ .map(|policy| policy.id.to_string()),
1311
+ })
1312
+ .collect::<Vec<_>>();
1313
+
1314
+ let token_destination_override_outputs = token_destination_override_bundles
1315
+ .iter()
1316
+ .map(
1317
+ |(destination_override, token_policy, asset_scope, _, bundle)| {
1318
+ TokenDestinationOverrideOutput {
1319
+ token_key: destination_override.token_key.clone(),
1320
+ symbol: token_policy.symbol.clone(),
1321
+ chain_key: destination_override.chain_key.clone(),
1322
+ chain_id: token_policy.chain_id,
1323
+ recipient: destination_override.recipient.to_string(),
1324
+ asset_scope: describe_asset_scope(asset_scope),
1325
+ per_tx_policy_id: bundle.per_tx.id.to_string(),
1326
+ daily_policy_id: bundle.daily.id.to_string(),
1327
+ weekly_policy_id: bundle.weekly.id.to_string(),
1328
+ gas_policy_id: bundle.gas.as_ref().map(|policy| policy.id.to_string()),
1329
+ per_tx_max_wei: destination_override.per_tx_max_wei.to_string(),
1330
+ daily_max_wei: destination_override.daily_max_wei.to_string(),
1331
+ weekly_max_wei: destination_override.weekly_max_wei.to_string(),
1332
+ max_gas_per_chain_wei: (destination_override.max_gas_per_chain_wei > 0)
1333
+ .then(|| destination_override.max_gas_per_chain_wei.to_string()),
1334
+ daily_max_tx_count: (destination_override.daily_max_tx_count > 0)
1335
+ .then(|| destination_override.daily_max_tx_count.to_string()),
1336
+ daily_tx_count_policy_id: bundle
1337
+ .daily_tx_count
1338
+ .as_ref()
1339
+ .map(|policy| policy.id.to_string()),
1340
+ per_tx_max_fee_per_gas_wei: (destination_override
1341
+ .per_tx_max_fee_per_gas_wei
1342
+ > 0)
1343
+ .then(|| destination_override.per_tx_max_fee_per_gas_wei.to_string()),
1344
+ per_tx_max_fee_per_gas_policy_id: bundle
1345
+ .per_tx_max_fee
1346
+ .as_ref()
1347
+ .map(|policy| policy.id.to_string()),
1348
+ per_tx_max_priority_fee_per_gas_wei: (destination_override
1349
+ .per_tx_max_priority_fee_per_gas_wei
1350
+ > 0)
1351
+ .then(|| {
1352
+ destination_override
1353
+ .per_tx_max_priority_fee_per_gas_wei
1354
+ .to_string()
1355
+ }),
1356
+ per_tx_max_priority_fee_per_gas_policy_id: bundle
1357
+ .per_tx_max_priority_fee
1358
+ .as_ref()
1359
+ .map(|policy| policy.id.to_string()),
1360
+ per_tx_max_calldata_bytes: (destination_override.per_tx_max_calldata_bytes
1361
+ > 0)
1362
+ .then(|| destination_override.per_tx_max_calldata_bytes.to_string()),
1363
+ per_tx_max_calldata_bytes_policy_id: bundle
1364
+ .per_tx_max_calldata_bytes
1365
+ .as_ref()
1366
+ .map(|policy| policy.id.to_string()),
1367
+ }
1368
+ },
1369
+ )
1370
+ .collect::<Vec<_>>();
1371
+
1372
+ let token_manual_approval_outputs = token_manual_approval_policies
1373
+ .iter()
1374
+ .map(
1375
+ |(manual_approval, asset_scope, recipient_scope, _, policy)| {
1376
+ TokenManualApprovalPolicyOutput {
1377
+ token_key: manual_approval.token_key.clone(),
1378
+ symbol: manual_approval.symbol.clone(),
1379
+ chain_key: manual_approval.chain_key.clone(),
1380
+ chain_id: manual_approval.chain_id,
1381
+ priority: manual_approval.priority,
1382
+ min_amount_wei: manual_approval.min_amount_wei.to_string(),
1383
+ max_amount_wei: manual_approval.max_amount_wei.to_string(),
1384
+ asset_scope: describe_asset_scope(asset_scope),
1385
+ recipient_scope: describe_recipient_scope(recipient_scope),
1386
+ policy_id: policy.id.to_string(),
1387
+ }
1388
+ },
1389
+ )
1390
+ .collect::<Vec<_>>();
1391
+
1392
+ Ok(BootstrapOutput {
1393
+ state_file: state_file_display.to_string(),
1394
+ lease_id: lease.lease_id.to_string(),
1395
+ lease_expires_at: lease
1396
+ .expires_at
1397
+ .format(&Rfc3339)
1398
+ .context("failed to format lease expiry as RFC3339")?,
1399
+ per_tx_policy_id: None,
1400
+ daily_policy_id: None,
1401
+ weekly_policy_id: None,
1402
+ gas_policy_id: None,
1403
+ per_tx_max_wei: None,
1404
+ daily_max_wei: None,
1405
+ weekly_max_wei: None,
1406
+ max_gas_per_chain_wei: None,
1407
+ daily_max_tx_count: None,
1408
+ daily_tx_count_policy_id: None,
1409
+ per_tx_max_fee_per_gas_wei: None,
1410
+ per_tx_max_fee_per_gas_policy_id: None,
1411
+ per_tx_max_priority_fee_per_gas_wei: None,
1412
+ per_tx_max_priority_fee_per_gas_policy_id: None,
1413
+ per_tx_max_calldata_bytes: None,
1414
+ per_tx_max_calldata_bytes_policy_id: None,
1415
+ vault_key_id: vault_key_id.to_string(),
1416
+ vault_public_key,
1417
+ vault_private_key,
1418
+ agent_key_id: agent_credentials.agent_key.id.to_string(),
1419
+ agent_auth_token,
1420
+ agent_auth_token_redacted: !params.print_agent_auth_token,
1421
+ network_scope: None,
1422
+ asset_scope: None,
1423
+ recipient_scope: None,
1424
+ token_policies: token_policy_outputs,
1425
+ destination_override_count: token_destination_override_outputs.len(),
1426
+ destination_overrides: Vec::new(),
1427
+ token_destination_overrides: token_destination_override_outputs,
1428
+ token_manual_approval_policies: token_manual_approval_outputs,
1429
+ policy_attachment: policy_attachment_label,
1430
+ attached_policy_ids,
1431
+ policy_note,
1432
+ })
1433
+ }
1434
+ .await;
1435
+
1436
+ session.vault_password.zeroize();
1437
+ result
1438
+ }
1439
+
1440
+ #[derive(Debug, Clone, Copy)]
1441
+ struct PolicyLimitConfig {
1442
+ per_tx_max_wei: u128,
1443
+ daily_max_wei: u128,
1444
+ weekly_max_wei: u128,
1445
+ max_gas_per_chain_wei: u128,
1446
+ daily_max_tx_count: u128,
1447
+ per_tx_max_fee_per_gas_wei: u128,
1448
+ per_tx_max_priority_fee_per_gas_wei: u128,
1449
+ per_tx_max_calldata_bytes: u128,
1450
+ }
1451
+
1452
+ impl PolicyLimitConfig {
1453
+ fn from_params(params: &BootstrapParams) -> Self {
1454
+ Self {
1455
+ per_tx_max_wei: params.per_tx_max_wei,
1456
+ daily_max_wei: params.daily_max_wei,
1457
+ weekly_max_wei: params.weekly_max_wei,
1458
+ max_gas_per_chain_wei: params.max_gas_per_chain_wei,
1459
+ daily_max_tx_count: params.daily_max_tx_count,
1460
+ per_tx_max_fee_per_gas_wei: params.per_tx_max_fee_per_gas_wei,
1461
+ per_tx_max_priority_fee_per_gas_wei: params.per_tx_max_priority_fee_per_gas_wei,
1462
+ per_tx_max_calldata_bytes: params.per_tx_max_calldata_bytes,
1463
+ }
1464
+ }
1465
+
1466
+ fn from_token_policy(token_policy: &TokenPolicyConfig) -> Self {
1467
+ Self {
1468
+ per_tx_max_wei: token_policy.per_tx_max_wei,
1469
+ daily_max_wei: token_policy.daily_max_wei,
1470
+ weekly_max_wei: token_policy.weekly_max_wei,
1471
+ max_gas_per_chain_wei: token_policy.max_gas_per_chain_wei,
1472
+ daily_max_tx_count: token_policy.daily_max_tx_count,
1473
+ per_tx_max_fee_per_gas_wei: token_policy.per_tx_max_fee_per_gas_wei,
1474
+ per_tx_max_priority_fee_per_gas_wei: token_policy.per_tx_max_priority_fee_per_gas_wei,
1475
+ per_tx_max_calldata_bytes: token_policy.per_tx_max_calldata_bytes,
1476
+ }
1477
+ }
1478
+
1479
+ fn from_destination_override(destination_override: &DestinationPolicyOverride) -> Self {
1480
+ Self {
1481
+ per_tx_max_wei: destination_override.per_tx_max_wei,
1482
+ daily_max_wei: destination_override.daily_max_wei,
1483
+ weekly_max_wei: destination_override.weekly_max_wei,
1484
+ max_gas_per_chain_wei: destination_override.max_gas_per_chain_wei,
1485
+ daily_max_tx_count: destination_override.daily_max_tx_count,
1486
+ per_tx_max_fee_per_gas_wei: destination_override.per_tx_max_fee_per_gas_wei,
1487
+ per_tx_max_priority_fee_per_gas_wei: destination_override
1488
+ .per_tx_max_priority_fee_per_gas_wei,
1489
+ per_tx_max_calldata_bytes: destination_override.per_tx_max_calldata_bytes,
1490
+ }
1491
+ }
1492
+
1493
+ fn from_token_destination_override(
1494
+ destination_override: &TokenDestinationPolicyOverride,
1495
+ ) -> Self {
1496
+ Self {
1497
+ per_tx_max_wei: destination_override.per_tx_max_wei,
1498
+ daily_max_wei: destination_override.daily_max_wei,
1499
+ weekly_max_wei: destination_override.weekly_max_wei,
1500
+ max_gas_per_chain_wei: destination_override.max_gas_per_chain_wei,
1501
+ daily_max_tx_count: destination_override.daily_max_tx_count,
1502
+ per_tx_max_fee_per_gas_wei: destination_override.per_tx_max_fee_per_gas_wei,
1503
+ per_tx_max_priority_fee_per_gas_wei: destination_override
1504
+ .per_tx_max_priority_fee_per_gas_wei,
1505
+ per_tx_max_calldata_bytes: destination_override.per_tx_max_calldata_bytes,
1506
+ }
1507
+ }
1508
+ }
1509
+
1510
+ #[derive(Debug)]
1511
+ struct PolicyBundle {
1512
+ per_tx: SpendingPolicy,
1513
+ daily: SpendingPolicy,
1514
+ weekly: SpendingPolicy,
1515
+ gas: Option<SpendingPolicy>,
1516
+ daily_tx_count: Option<SpendingPolicy>,
1517
+ per_tx_max_fee: Option<SpendingPolicy>,
1518
+ per_tx_max_priority_fee: Option<SpendingPolicy>,
1519
+ per_tx_max_calldata_bytes: Option<SpendingPolicy>,
1520
+ }
1521
+
1522
+ impl PolicyBundle {
1523
+ fn policies(&self) -> Vec<&SpendingPolicy> {
1524
+ let mut policies = vec![&self.per_tx, &self.daily, &self.weekly];
1525
+ if let Some(policy) = &self.gas {
1526
+ policies.push(policy);
1527
+ }
1528
+ if let Some(policy) = &self.daily_tx_count {
1529
+ policies.push(policy);
1530
+ }
1531
+ if let Some(policy) = &self.per_tx_max_fee {
1532
+ policies.push(policy);
1533
+ }
1534
+ if let Some(policy) = &self.per_tx_max_priority_fee {
1535
+ policies.push(policy);
1536
+ }
1537
+ if let Some(policy) = &self.per_tx_max_calldata_bytes {
1538
+ policies.push(policy);
1539
+ }
1540
+ policies
1541
+ }
1542
+
1543
+ fn policy_ids(&self) -> BTreeSet<Uuid> {
1544
+ self.policies()
1545
+ .into_iter()
1546
+ .map(|policy| policy.id)
1547
+ .collect()
1548
+ }
1549
+ }
1550
+
1551
+ fn build_policy_bundle(
1552
+ priority_base: u32,
1553
+ limits: &PolicyLimitConfig,
1554
+ recipient_scope: EntityScope<EvmAddress>,
1555
+ asset_scope: EntityScope<AssetId>,
1556
+ network_scope: EntityScope<u64>,
1557
+ ) -> Result<PolicyBundle> {
1558
+ Ok(PolicyBundle {
1559
+ per_tx: SpendingPolicy::new(
1560
+ priority_base,
1561
+ PolicyType::PerTxMaxSpending,
1562
+ limits.per_tx_max_wei,
1563
+ recipient_scope.clone(),
1564
+ asset_scope.clone(),
1565
+ network_scope.clone(),
1566
+ )
1567
+ .context("invalid policy configuration")?,
1568
+ daily: SpendingPolicy::new(
1569
+ priority_base + 1,
1570
+ PolicyType::DailyMaxSpending,
1571
+ limits.daily_max_wei,
1572
+ recipient_scope.clone(),
1573
+ asset_scope.clone(),
1574
+ network_scope.clone(),
1575
+ )
1576
+ .context("invalid policy configuration")?,
1577
+ weekly: SpendingPolicy::new(
1578
+ priority_base + 2,
1579
+ PolicyType::WeeklyMaxSpending,
1580
+ limits.weekly_max_wei,
1581
+ recipient_scope.clone(),
1582
+ asset_scope.clone(),
1583
+ network_scope.clone(),
1584
+ )
1585
+ .context("invalid policy configuration")?,
1586
+ gas: (limits.max_gas_per_chain_wei > 0)
1587
+ .then(|| {
1588
+ SpendingPolicy::new(
1589
+ priority_base + 3,
1590
+ PolicyType::PerChainMaxGasSpend,
1591
+ limits.max_gas_per_chain_wei,
1592
+ recipient_scope.clone(),
1593
+ EntityScope::All,
1594
+ network_scope.clone(),
1595
+ )
1596
+ })
1597
+ .transpose()
1598
+ .context("invalid policy configuration")?,
1599
+ daily_tx_count: (limits.daily_max_tx_count > 0)
1600
+ .then(|| {
1601
+ SpendingPolicy::new(
1602
+ priority_base + 4,
1603
+ PolicyType::DailyMaxTxCount,
1604
+ limits.daily_max_tx_count,
1605
+ recipient_scope.clone(),
1606
+ asset_scope.clone(),
1607
+ network_scope.clone(),
1608
+ )
1609
+ })
1610
+ .transpose()
1611
+ .context("invalid policy configuration")?,
1612
+ per_tx_max_fee: (limits.per_tx_max_fee_per_gas_wei > 0)
1613
+ .then(|| {
1614
+ SpendingPolicy::new(
1615
+ priority_base + 5,
1616
+ PolicyType::PerTxMaxFeePerGas,
1617
+ limits.per_tx_max_fee_per_gas_wei,
1618
+ recipient_scope.clone(),
1619
+ asset_scope.clone(),
1620
+ network_scope.clone(),
1621
+ )
1622
+ })
1623
+ .transpose()
1624
+ .context("invalid policy configuration")?,
1625
+ per_tx_max_priority_fee: (limits.per_tx_max_priority_fee_per_gas_wei > 0)
1626
+ .then(|| {
1627
+ SpendingPolicy::new(
1628
+ priority_base + 6,
1629
+ PolicyType::PerTxMaxPriorityFeePerGas,
1630
+ limits.per_tx_max_priority_fee_per_gas_wei,
1631
+ recipient_scope.clone(),
1632
+ asset_scope.clone(),
1633
+ network_scope.clone(),
1634
+ )
1635
+ })
1636
+ .transpose()
1637
+ .context("invalid policy configuration")?,
1638
+ per_tx_max_calldata_bytes: (limits.per_tx_max_calldata_bytes > 0)
1639
+ .then(|| {
1640
+ SpendingPolicy::new(
1641
+ priority_base + 7,
1642
+ PolicyType::PerTxMaxCalldataBytes,
1643
+ limits.per_tx_max_calldata_bytes,
1644
+ recipient_scope,
1645
+ asset_scope,
1646
+ network_scope,
1647
+ )
1648
+ })
1649
+ .transpose()
1650
+ .context("invalid policy configuration")?,
1651
+ })
1652
+ }
1653
+
1654
+ async fn register_policy_bundle(
1655
+ daemon: &dyn KeyManagerDaemonApi,
1656
+ session: &AdminSession,
1657
+ bundle: &PolicyBundle,
1658
+ ) -> Result<()> {
1659
+ for policy in bundle.policies() {
1660
+ daemon.add_policy(session, policy.clone()).await?;
1661
+ }
1662
+ Ok(())
1663
+ }
1664
+
1665
+ fn validate_destination_policy_overrides(params: &BootstrapParams) -> Result<()> {
1666
+ if params.recipient.is_some() && !params.destination_overrides.is_empty() {
1667
+ bail!(
1668
+ "destination overrides require the default recipient scope to cover every destination"
1669
+ );
1670
+ }
1671
+
1672
+ let defaults = PolicyLimitConfig::from_params(params);
1673
+ let mut seen_recipients = BTreeSet::new();
1674
+ for destination_override in &params.destination_overrides {
1675
+ validate_policy_limits(
1676
+ destination_override.per_tx_max_wei,
1677
+ destination_override.daily_max_wei,
1678
+ destination_override.weekly_max_wei,
1679
+ )?;
1680
+ if !seen_recipients.insert(destination_override.recipient.clone()) {
1681
+ bail!(
1682
+ "duplicate destination override recipient: {}",
1683
+ destination_override.recipient
1684
+ );
1685
+ }
1686
+ let recipient = destination_override.recipient.to_string();
1687
+ validate_required_overlay_limit(
1688
+ &recipient,
1689
+ "per-tx max",
1690
+ defaults.per_tx_max_wei,
1691
+ destination_override.per_tx_max_wei,
1692
+ )?;
1693
+ validate_required_overlay_limit(
1694
+ &recipient,
1695
+ "daily max",
1696
+ defaults.daily_max_wei,
1697
+ destination_override.daily_max_wei,
1698
+ )?;
1699
+ validate_required_overlay_limit(
1700
+ &recipient,
1701
+ "weekly max",
1702
+ defaults.weekly_max_wei,
1703
+ destination_override.weekly_max_wei,
1704
+ )?;
1705
+ validate_optional_overlay_limit(
1706
+ &recipient,
1707
+ "max gas per chain",
1708
+ defaults.max_gas_per_chain_wei,
1709
+ destination_override.max_gas_per_chain_wei,
1710
+ )?;
1711
+ validate_optional_overlay_limit(
1712
+ &recipient,
1713
+ "daily max tx count",
1714
+ defaults.daily_max_tx_count,
1715
+ destination_override.daily_max_tx_count,
1716
+ )?;
1717
+ validate_optional_overlay_limit(
1718
+ &recipient,
1719
+ "per-tx max fee per gas",
1720
+ defaults.per_tx_max_fee_per_gas_wei,
1721
+ destination_override.per_tx_max_fee_per_gas_wei,
1722
+ )?;
1723
+ validate_optional_overlay_limit(
1724
+ &recipient,
1725
+ "per-tx max priority fee per gas",
1726
+ defaults.per_tx_max_priority_fee_per_gas_wei,
1727
+ destination_override.per_tx_max_priority_fee_per_gas_wei,
1728
+ )?;
1729
+ validate_optional_overlay_limit(
1730
+ &recipient,
1731
+ "per-tx max calldata bytes",
1732
+ defaults.per_tx_max_calldata_bytes,
1733
+ destination_override.per_tx_max_calldata_bytes,
1734
+ )?;
1735
+ }
1736
+ Ok(())
1737
+ }
1738
+
1739
+ fn validate_per_token_bootstrap_params(params: &BootstrapParams) -> Result<()> {
1740
+ if params.token_policies.is_empty() {
1741
+ bail!("at least one per-token policy is required");
1742
+ }
1743
+ if !params.destination_overrides.is_empty() {
1744
+ bail!("legacy destination overrides cannot be mixed with per-token policies");
1745
+ }
1746
+ if params.recipient.is_some() {
1747
+ bail!("global recipient scope is not supported with per-token policies");
1748
+ }
1749
+
1750
+ let mut seen_selectors = BTreeSet::new();
1751
+ for token_policy in &params.token_policies {
1752
+ if token_policy.token_key.trim().is_empty() || token_policy.chain_key.trim().is_empty() {
1753
+ bail!("token policy selectors must include token and chain keys");
1754
+ }
1755
+ if !seen_selectors.insert((
1756
+ token_policy.token_key.clone(),
1757
+ token_policy.chain_key.clone(),
1758
+ )) {
1759
+ bail!(
1760
+ "duplicate token policy selector: {}:{}",
1761
+ token_policy.token_key,
1762
+ token_policy.chain_key
1763
+ );
1764
+ }
1765
+ if token_policy.chain_id == 0 {
1766
+ bail!(
1767
+ "token policy '{}' chain '{}' must have a non-zero chain id",
1768
+ token_policy.token_key,
1769
+ token_policy.chain_key
1770
+ );
1771
+ }
1772
+ if token_policy.is_native {
1773
+ if token_policy.address.is_some() {
1774
+ bail!(
1775
+ "token policy '{}:{}' must not set an address when native",
1776
+ token_policy.token_key,
1777
+ token_policy.chain_key
1778
+ );
1779
+ }
1780
+ } else if token_policy.address.is_none() {
1781
+ bail!(
1782
+ "token policy '{}:{}' requires an ERC-20 address",
1783
+ token_policy.token_key,
1784
+ token_policy.chain_key
1785
+ );
1786
+ }
1787
+
1788
+ validate_policy_limits(
1789
+ token_policy.per_tx_max_wei,
1790
+ token_policy.daily_max_wei,
1791
+ token_policy.weekly_max_wei,
1792
+ )?;
1793
+ }
1794
+
1795
+ let mut seen_overrides = BTreeSet::new();
1796
+ for destination_override in &params.token_destination_overrides {
1797
+ let Some(token_policy) = params.token_policies.iter().find(|policy| {
1798
+ policy.token_key == destination_override.token_key
1799
+ && policy.chain_key == destination_override.chain_key
1800
+ }) else {
1801
+ bail!(
1802
+ "destination override references unknown token selector '{}:{}'",
1803
+ destination_override.token_key,
1804
+ destination_override.chain_key
1805
+ );
1806
+ };
1807
+
1808
+ let recipient = destination_override.recipient.to_string();
1809
+ if !seen_overrides.insert((
1810
+ destination_override.token_key.clone(),
1811
+ destination_override.chain_key.clone(),
1812
+ recipient.clone(),
1813
+ )) {
1814
+ bail!(
1815
+ "duplicate per-token destination override: {}:{} for {}",
1816
+ destination_override.token_key,
1817
+ destination_override.chain_key,
1818
+ recipient
1819
+ );
1820
+ }
1821
+
1822
+ validate_policy_limits(
1823
+ destination_override.per_tx_max_wei,
1824
+ destination_override.daily_max_wei,
1825
+ destination_override.weekly_max_wei,
1826
+ )?;
1827
+ validate_token_destination_override_overlay(
1828
+ &recipient,
1829
+ &PolicyLimitConfig::from_token_policy(token_policy),
1830
+ &PolicyLimitConfig::from_token_destination_override(destination_override),
1831
+ )?;
1832
+ }
1833
+
1834
+ for manual_approval in &params.token_manual_approval_policies {
1835
+ let Some(token_policy) = params.token_policies.iter().find(|policy| {
1836
+ policy.token_key == manual_approval.token_key
1837
+ && policy.chain_key == manual_approval.chain_key
1838
+ }) else {
1839
+ bail!(
1840
+ "manual approval policy references unknown token selector '{}:{}'",
1841
+ manual_approval.token_key,
1842
+ manual_approval.chain_key
1843
+ );
1844
+ };
1845
+ if token_policy.chain_id != manual_approval.chain_id {
1846
+ bail!(
1847
+ "manual approval policy '{}:{}' must match chain id {}",
1848
+ manual_approval.token_key,
1849
+ manual_approval.chain_key,
1850
+ token_policy.chain_id
1851
+ );
1852
+ }
1853
+ if manual_approval.min_amount_wei == 0 || manual_approval.max_amount_wei == 0 {
1854
+ bail!(
1855
+ "manual approval policy '{}:{}' requires non-zero min/max amounts",
1856
+ manual_approval.token_key,
1857
+ manual_approval.chain_key
1858
+ );
1859
+ }
1860
+ if manual_approval.min_amount_wei > manual_approval.max_amount_wei {
1861
+ bail!(
1862
+ "manual approval policy '{}:{}' min amount must be less than or equal to max amount",
1863
+ manual_approval.token_key,
1864
+ manual_approval.chain_key
1865
+ );
1866
+ }
1867
+ if manual_approval.is_native != token_policy.is_native
1868
+ || manual_approval.address != token_policy.address
1869
+ {
1870
+ bail!(
1871
+ "manual approval policy '{}:{}' must match the saved token asset scope",
1872
+ manual_approval.token_key,
1873
+ manual_approval.chain_key
1874
+ );
1875
+ }
1876
+ }
1877
+
1878
+ Ok(())
1879
+ }
1880
+
1881
+ fn build_asset_scope_for_token_policy(
1882
+ token_policy: &TokenPolicyConfig,
1883
+ ) -> Result<EntityScope<AssetId>> {
1884
+ build_asset_scope_for_token_selector(
1885
+ &token_policy.token_key,
1886
+ &token_policy.chain_key,
1887
+ token_policy.is_native,
1888
+ token_policy.address.as_ref(),
1889
+ )
1890
+ }
1891
+
1892
+ fn build_asset_scope_for_token_manual_approval(
1893
+ manual_approval: &TokenManualApprovalPolicyConfig,
1894
+ ) -> Result<EntityScope<AssetId>> {
1895
+ build_asset_scope_for_token_selector(
1896
+ &manual_approval.token_key,
1897
+ &manual_approval.chain_key,
1898
+ manual_approval.is_native,
1899
+ manual_approval.address.as_ref(),
1900
+ )
1901
+ }
1902
+
1903
+ fn build_asset_scope_for_token_selector(
1904
+ token_key: &str,
1905
+ chain_key: &str,
1906
+ is_native: bool,
1907
+ address: Option<&EvmAddress>,
1908
+ ) -> Result<EntityScope<AssetId>> {
1909
+ if is_native {
1910
+ if address.is_some() {
1911
+ bail!(
1912
+ "token policy '{}:{}' must not include an address for native asset scope",
1913
+ token_key,
1914
+ chain_key
1915
+ );
1916
+ }
1917
+ Ok(single_scope(AssetId::NativeEth))
1918
+ } else {
1919
+ let address = address.cloned().with_context(|| {
1920
+ format!(
1921
+ "token policy '{}:{}' requires an ERC-20 address",
1922
+ token_key, chain_key
1923
+ )
1924
+ })?;
1925
+ Ok(single_scope(AssetId::Erc20(address)))
1926
+ }
1927
+ }
1928
+
1929
+ fn validate_token_destination_override_overlay(
1930
+ recipient: &str,
1931
+ defaults: &PolicyLimitConfig,
1932
+ override_limits: &PolicyLimitConfig,
1933
+ ) -> Result<()> {
1934
+ validate_required_overlay_limit(
1935
+ recipient,
1936
+ "per-tx max",
1937
+ defaults.per_tx_max_wei,
1938
+ override_limits.per_tx_max_wei,
1939
+ )?;
1940
+ validate_required_overlay_limit(
1941
+ recipient,
1942
+ "daily max",
1943
+ defaults.daily_max_wei,
1944
+ override_limits.daily_max_wei,
1945
+ )?;
1946
+ validate_required_overlay_limit(
1947
+ recipient,
1948
+ "weekly max",
1949
+ defaults.weekly_max_wei,
1950
+ override_limits.weekly_max_wei,
1951
+ )?;
1952
+ validate_optional_overlay_limit(
1953
+ recipient,
1954
+ "max gas per chain",
1955
+ defaults.max_gas_per_chain_wei,
1956
+ override_limits.max_gas_per_chain_wei,
1957
+ )?;
1958
+ validate_optional_overlay_limit(
1959
+ recipient,
1960
+ "daily max tx count",
1961
+ defaults.daily_max_tx_count,
1962
+ override_limits.daily_max_tx_count,
1963
+ )?;
1964
+ validate_optional_overlay_limit(
1965
+ recipient,
1966
+ "per-tx max fee per gas",
1967
+ defaults.per_tx_max_fee_per_gas_wei,
1968
+ override_limits.per_tx_max_fee_per_gas_wei,
1969
+ )?;
1970
+ validate_optional_overlay_limit(
1971
+ recipient,
1972
+ "per-tx max priority fee per gas",
1973
+ defaults.per_tx_max_priority_fee_per_gas_wei,
1974
+ override_limits.per_tx_max_priority_fee_per_gas_wei,
1975
+ )?;
1976
+ validate_optional_overlay_limit(
1977
+ recipient,
1978
+ "per-tx max calldata bytes",
1979
+ defaults.per_tx_max_calldata_bytes,
1980
+ override_limits.per_tx_max_calldata_bytes,
1981
+ )?;
1982
+ Ok(())
1983
+ }
1984
+
1985
+ fn validate_required_overlay_limit(
1986
+ recipient: &str,
1987
+ label: &str,
1988
+ default_value: u128,
1989
+ override_value: u128,
1990
+ ) -> Result<()> {
1991
+ if override_value > default_value {
1992
+ bail!(
1993
+ "destination override for {recipient} must not increase {label} above the default value"
1994
+ );
1995
+ }
1996
+ Ok(())
1997
+ }
1998
+
1999
+ fn validate_optional_overlay_limit(
2000
+ recipient: &str,
2001
+ label: &str,
2002
+ default_value: u128,
2003
+ override_value: u128,
2004
+ ) -> Result<()> {
2005
+ if default_value == 0 {
2006
+ return Ok(());
2007
+ }
2008
+ if override_value == 0 {
2009
+ bail!(
2010
+ "destination override for {recipient} must keep {label} enabled because the default value is enabled"
2011
+ );
2012
+ }
2013
+ if override_value > default_value {
2014
+ bail!(
2015
+ "destination override for {recipient} must not increase {label} above the default value"
2016
+ );
2017
+ }
2018
+ Ok(())
2019
+ }
2020
+
2021
+ async fn execute_rotate_agent_auth_token(
2022
+ daemon: Arc<dyn KeyManagerDaemonApi>,
2023
+ vault_password: &str,
2024
+ params: RotateAgentAuthTokenParams,
2025
+ mut on_status: impl FnMut(&str),
2026
+ ) -> Result<RotateAgentAuthTokenOutput> {
2027
+ on_status("issuing admin lease");
2028
+ let lease = daemon.issue_lease(vault_password).await?;
2029
+ let mut session = AdminSession {
2030
+ vault_password: vault_password.to_string(),
2031
+ lease,
2032
+ };
2033
+
2034
+ let result = async {
2035
+ on_status("rotating agent auth token");
2036
+ let mut agent_auth_token = daemon
2037
+ .rotate_agent_auth_token(&session, params.agent_key_id)
2038
+ .await?;
2039
+
2040
+ if params.print_agent_auth_token {
2041
+ Ok(RotateAgentAuthTokenOutput {
2042
+ agent_key_id: params.agent_key_id.to_string(),
2043
+ agent_auth_token,
2044
+ agent_auth_token_redacted: false,
2045
+ })
2046
+ } else {
2047
+ agent_auth_token.zeroize();
2048
+ Ok(RotateAgentAuthTokenOutput {
2049
+ agent_key_id: params.agent_key_id.to_string(),
2050
+ agent_auth_token: "<redacted>".to_string(),
2051
+ agent_auth_token_redacted: true,
2052
+ })
2053
+ }
2054
+ }
2055
+ .await;
2056
+
2057
+ session.vault_password.zeroize();
2058
+ result
2059
+ }
2060
+
2061
+ async fn execute_revoke_agent_key(
2062
+ daemon: Arc<dyn KeyManagerDaemonApi>,
2063
+ vault_password: &str,
2064
+ params: RevokeAgentKeyParams,
2065
+ mut on_status: impl FnMut(&str),
2066
+ ) -> Result<RevokeAgentKeyOutput> {
2067
+ on_status("issuing admin lease");
2068
+ let lease = daemon.issue_lease(vault_password).await?;
2069
+ let mut session = AdminSession {
2070
+ vault_password: vault_password.to_string(),
2071
+ lease,
2072
+ };
2073
+
2074
+ let result = async {
2075
+ on_status("revoking agent key");
2076
+ daemon
2077
+ .revoke_agent_key(&session, params.agent_key_id)
2078
+ .await?;
2079
+ Ok(RevokeAgentKeyOutput {
2080
+ agent_key_id: params.agent_key_id.to_string(),
2081
+ revoked: true,
2082
+ })
2083
+ }
2084
+ .await;
2085
+
2086
+ session.vault_password.zeroize();
2087
+ result
2088
+ }
2089
+
2090
+ async fn execute_add_manual_approval_policy(
2091
+ daemon: Arc<dyn KeyManagerDaemonApi>,
2092
+ vault_password: &str,
2093
+ params: AddManualApprovalPolicyParams,
2094
+ mut on_status: impl FnMut(&str),
2095
+ ) -> Result<ManualApprovalPolicyOutput> {
2096
+ if params.min_amount_wei > params.max_amount_wei {
2097
+ bail!("--min-amount-wei must be less than or equal to --max-amount-wei");
2098
+ }
2099
+
2100
+ on_status("issuing admin lease");
2101
+ let lease = daemon.issue_lease(vault_password).await?;
2102
+ let mut session = AdminSession {
2103
+ vault_password: vault_password.to_string(),
2104
+ lease,
2105
+ };
2106
+
2107
+ let recipients = params
2108
+ .recipient
2109
+ .clone()
2110
+ .map_or(EntityScope::All, single_scope);
2111
+ let assets = build_asset_scope(&params.tokens, params.allow_native_eth);
2112
+ let networks = build_network_scope(params.network);
2113
+ let policy = SpendingPolicy::new_manual_approval(
2114
+ params.priority,
2115
+ params.min_amount_wei,
2116
+ params.max_amount_wei,
2117
+ recipients.clone(),
2118
+ assets.clone(),
2119
+ networks.clone(),
2120
+ )?;
2121
+ let policy_id = policy.id;
2122
+
2123
+ let result = async {
2124
+ on_status("creating manual approval policy");
2125
+ daemon.add_policy(&session, policy).await?;
2126
+ Ok(ManualApprovalPolicyOutput {
2127
+ policy_id: policy_id.to_string(),
2128
+ priority: params.priority,
2129
+ min_amount_wei: params.min_amount_wei.to_string(),
2130
+ max_amount_wei: params.max_amount_wei.to_string(),
2131
+ network_scope: describe_network_scope(&networks),
2132
+ asset_scope: describe_asset_scope(&assets),
2133
+ recipient_scope: describe_recipient_scope(&recipients),
2134
+ })
2135
+ }
2136
+ .await;
2137
+
2138
+ session.vault_password.zeroize();
2139
+ result
2140
+ }
2141
+
2142
+ async fn execute_list_manual_approval_requests(
2143
+ daemon: Arc<dyn KeyManagerDaemonApi>,
2144
+ vault_password: &str,
2145
+ mut on_status: impl FnMut(&str),
2146
+ ) -> Result<Vec<ManualApprovalRequest>> {
2147
+ on_status("issuing admin lease");
2148
+ let lease = daemon.issue_lease(vault_password).await?;
2149
+ let mut session = AdminSession {
2150
+ vault_password: vault_password.to_string(),
2151
+ lease,
2152
+ };
2153
+
2154
+ let result = async {
2155
+ on_status("listing manual approval requests");
2156
+ Ok(daemon.list_manual_approval_requests(&session).await?)
2157
+ }
2158
+ .await;
2159
+
2160
+ session.vault_password.zeroize();
2161
+ result
2162
+ }
2163
+
2164
+ async fn execute_decide_manual_approval_request(
2165
+ daemon: Arc<dyn KeyManagerDaemonApi>,
2166
+ vault_password: &str,
2167
+ params: DecideManualApprovalRequestParams,
2168
+ mut on_status: impl FnMut(&str),
2169
+ ) -> Result<ManualApprovalRequest> {
2170
+ on_status("issuing admin lease");
2171
+ let lease = daemon.issue_lease(vault_password).await?;
2172
+ let mut session = AdminSession {
2173
+ vault_password: vault_password.to_string(),
2174
+ lease,
2175
+ };
2176
+
2177
+ let result = async {
2178
+ on_status("updating manual approval request");
2179
+ Ok(daemon
2180
+ .decide_manual_approval_request(
2181
+ &session,
2182
+ params.approval_request_id,
2183
+ params.decision,
2184
+ params.rejection_reason,
2185
+ )
2186
+ .await?)
2187
+ }
2188
+ .await;
2189
+
2190
+ session.vault_password.zeroize();
2191
+ result
2192
+ }
2193
+
2194
+ async fn execute_set_relay_config(
2195
+ daemon: Arc<dyn KeyManagerDaemonApi>,
2196
+ vault_password: &str,
2197
+ params: SetRelayConfigParams,
2198
+ mut on_status: impl FnMut(&str),
2199
+ ) -> Result<RelayConfig> {
2200
+ on_status("issuing admin lease");
2201
+ let lease = daemon.issue_lease(vault_password).await?;
2202
+ let mut session = AdminSession {
2203
+ vault_password: vault_password.to_string(),
2204
+ lease,
2205
+ };
2206
+
2207
+ let result = async {
2208
+ let (relay_url, frontend_url) = if params.clear {
2209
+ (None, None)
2210
+ } else {
2211
+ on_status("reading existing relay configuration");
2212
+ let existing = daemon.get_relay_config(&session).await?;
2213
+ (
2214
+ params.relay_url.or(existing.relay_url),
2215
+ params.frontend_url.or(existing.frontend_url),
2216
+ )
2217
+ };
2218
+ on_status("updating relay configuration");
2219
+ Ok(daemon
2220
+ .set_relay_config(&session, relay_url, frontend_url)
2221
+ .await?)
2222
+ }
2223
+ .await;
2224
+
2225
+ session.vault_password.zeroize();
2226
+ result
2227
+ }
2228
+
2229
+ async fn execute_get_relay_config(
2230
+ daemon: Arc<dyn KeyManagerDaemonApi>,
2231
+ vault_password: &str,
2232
+ mut on_status: impl FnMut(&str),
2233
+ ) -> Result<RelayConfig> {
2234
+ on_status("issuing admin lease");
2235
+ let lease = daemon.issue_lease(vault_password).await?;
2236
+ let mut session = AdminSession {
2237
+ vault_password: vault_password.to_string(),
2238
+ lease,
2239
+ };
2240
+
2241
+ let result = async {
2242
+ on_status("reading relay configuration");
2243
+ Ok(daemon.get_relay_config(&session).await?)
2244
+ }
2245
+ .await;
2246
+
2247
+ session.vault_password.zeroize();
2248
+ result
2249
+ }
2250
+
2251
+ async fn validate_existing_policy_attachments(
2252
+ daemon: &dyn KeyManagerDaemonApi,
2253
+ session: &AdminSession,
2254
+ attach_policy_ids: &[Uuid],
2255
+ ) -> Result<()> {
2256
+ if attach_policy_ids.is_empty() {
2257
+ return Ok(());
2258
+ }
2259
+
2260
+ let existing_policy_ids = daemon
2261
+ .list_policies(session)
2262
+ .await?
2263
+ .into_iter()
2264
+ .map(|policy| policy.id)
2265
+ .collect::<BTreeSet<_>>();
2266
+ let missing = attach_policy_ids
2267
+ .iter()
2268
+ .copied()
2269
+ .filter(|policy_id| !existing_policy_ids.contains(policy_id))
2270
+ .collect::<Vec<_>>();
2271
+
2272
+ if missing.is_empty() {
2273
+ return Ok(());
2274
+ }
2275
+
2276
+ let joined = missing
2277
+ .into_iter()
2278
+ .map(|policy_id| policy_id.to_string())
2279
+ .collect::<Vec<_>>()
2280
+ .join(", ");
2281
+ bail!("unknown --attach-policy-id value(s): {joined}");
2282
+ }
2283
+
2284
+ fn resolve_bootstrap_policy_attachment(
2285
+ created_policy_ids: impl IntoIterator<Item = Uuid>,
2286
+ attach_policy_ids: &[Uuid],
2287
+ ) -> Result<(PolicyAttachment, String, Vec<String>, String)> {
2288
+ let created_policy_ids = created_policy_ids.into_iter().collect::<BTreeSet<_>>();
2289
+ let explicit_policy_ids = attach_policy_ids.iter().copied().collect::<BTreeSet<_>>();
2290
+ let mut policy_set_ids = created_policy_ids.clone();
2291
+ policy_set_ids.extend(explicit_policy_ids.iter().copied());
2292
+
2293
+ if policy_set_ids.is_empty() {
2294
+ bail!("bootstrap must create or attach at least one policy");
2295
+ }
2296
+
2297
+ let attached_policy_ids = policy_set_ids
2298
+ .iter()
2299
+ .map(ToString::to_string)
2300
+ .collect::<Vec<_>>();
2301
+
2302
+ let policy_note = match (created_policy_ids.len(), explicit_policy_ids.len()) {
2303
+ (created_count, 0) => format!(
2304
+ "agent key is attached to {created_count} bootstrap-created policy id(s)"
2305
+ ),
2306
+ (0, explicit_count) => {
2307
+ format!("agent key is attached to {explicit_count} explicit policy id(s)")
2308
+ }
2309
+ (created_count, explicit_count) => format!(
2310
+ "agent key is attached to {created_count} bootstrap-created policy id(s) and {explicit_count} explicit policy id(s)"
2311
+ ),
2312
+ };
2313
+
2314
+ Ok((
2315
+ PolicyAttachment::policy_set(policy_set_ids)
2316
+ .context("invalid policy attachment configuration")?,
2317
+ "policy_set".to_string(),
2318
+ attached_policy_ids,
2319
+ policy_note,
2320
+ ))
2321
+ }
2322
+
2323
+ fn validate_policy_limits(per_tx: u128, daily: u128, weekly: u128) -> Result<()> {
2324
+ if daily < per_tx {
2325
+ bail!(
2326
+ "--daily-max-wei ({daily}) must be greater than or equal to --per-tx-max-wei ({per_tx})"
2327
+ );
2328
+ }
2329
+ if weekly < daily {
2330
+ bail!(
2331
+ "--weekly-max-wei ({weekly}) must be greater than or equal to --daily-max-wei ({daily})"
2332
+ );
2333
+ }
2334
+ Ok(())
2335
+ }
2336
+
2337
+ fn parse_positive_u128(input: &str) -> Result<u128, String> {
2338
+ let parsed = input
2339
+ .parse::<u128>()
2340
+ .map_err(|_| "must be a valid unsigned integer".to_string())?;
2341
+ if parsed == 0 {
2342
+ return Err("must be greater than zero".to_string());
2343
+ }
2344
+ Ok(parsed)
2345
+ }
2346
+
2347
+ fn resolve_daemon_socket_path(cli_value: Option<PathBuf>) -> Result<PathBuf> {
2348
+ let path = match cli_value {
2349
+ Some(path) => path,
2350
+ None => wlfi_home_dir()?.join("daemon.sock"),
2351
+ };
2352
+
2353
+ assert_root_owned_daemon_socket_path(&path).map_err(anyhow::Error::msg)
2354
+ }
2355
+
2356
+ fn wlfi_home_dir() -> Result<PathBuf> {
2357
+ if let Some(path) = std::env::var_os("WLFI_HOME") {
2358
+ let candidate = PathBuf::from(path);
2359
+ if candidate.as_os_str().is_empty() {
2360
+ bail!("WLFI_HOME must not be empty");
2361
+ }
2362
+ return Ok(candidate);
2363
+ }
2364
+
2365
+ let Some(home) = std::env::var_os("HOME") else {
2366
+ bail!("HOME is not set; use WLFI_HOME to choose config directory");
2367
+ };
2368
+ Ok(PathBuf::from(home).join(".wlfi_agent"))
2369
+ }
2370
+
2371
+ fn parse_non_negative_u128(input: &str) -> Result<u128, String> {
2372
+ input
2373
+ .parse::<u128>()
2374
+ .map_err(|_| "must be a valid unsigned integer".to_string())
2375
+ }
2376
+
2377
+ fn parse_positive_u64(input: &str) -> Result<u64, String> {
2378
+ let parsed = input
2379
+ .parse::<u64>()
2380
+ .map_err(|_| "must be a valid unsigned integer".to_string())?;
2381
+ if parsed == 0 {
2382
+ return Err("must be greater than zero".to_string());
2383
+ }
2384
+ Ok(parsed)
2385
+ }
2386
+
2387
+ fn display_optional_output_value(value: &Option<String>) -> &str {
2388
+ value.as_deref().unwrap_or("unlimited")
2389
+ }
2390
+
2391
+ fn print_bootstrap_output(
2392
+ output: &BootstrapOutput,
2393
+ format: OutputFormat,
2394
+ target: &OutputTarget,
2395
+ ) -> Result<()> {
2396
+ let rendered = match format {
2397
+ OutputFormat::Json => {
2398
+ serde_json::to_string_pretty(output).context("failed to serialize output")?
2399
+ }
2400
+ OutputFormat::Text => {
2401
+ let mut lines = vec![
2402
+ format!("State File: {}", output.state_file),
2403
+ "Lease".to_string(),
2404
+ format!(" ID: {}", output.lease_id),
2405
+ format!(" Expires At: {}", output.lease_expires_at),
2406
+ format!(" Agent Policy Attachment: {}", output.policy_attachment),
2407
+ "Keys".to_string(),
2408
+ format!(" Vault Key ID: {}", output.vault_key_id),
2409
+ format!(" Vault Public Key: {}", output.vault_public_key),
2410
+ output
2411
+ .vault_private_key
2412
+ .as_ref()
2413
+ .map(|value| format!(" Vault Private Key: {value}"))
2414
+ .unwrap_or_default(),
2415
+ format!(" Agent Key ID: {}", output.agent_key_id),
2416
+ format!(" Agent Auth Token: {}", output.agent_auth_token),
2417
+ ];
2418
+ if output.token_policies.is_empty() {
2419
+ lines.push("Policies".to_string());
2420
+ if let Some(value) = &output.per_tx_policy_id {
2421
+ lines.push(format!(" Per-Tx: {value}"));
2422
+ }
2423
+ if let Some(value) = &output.daily_policy_id {
2424
+ lines.push(format!(" Daily: {value}"));
2425
+ }
2426
+ if let Some(value) = &output.weekly_policy_id {
2427
+ lines.push(format!(" Weekly: {value}"));
2428
+ }
2429
+ if let Some(value) = &output.gas_policy_id {
2430
+ lines.push(format!(" Per-Chain Max Gas: {value}"));
2431
+ }
2432
+ if let Some(value) = &output.per_tx_max_wei {
2433
+ lines.push(format!(" Per-Tx Limit (wei): {value}"));
2434
+ }
2435
+ if let Some(value) = &output.daily_max_wei {
2436
+ lines.push(format!(" Daily Limit (wei): {value}"));
2437
+ }
2438
+ if let Some(value) = &output.weekly_max_wei {
2439
+ lines.push(format!(" Weekly Limit (wei): {value}"));
2440
+ }
2441
+ if let Some(value) = &output.max_gas_per_chain_wei {
2442
+ lines.push(format!(" Per-Chain Max Gas Limit (wei): {value}"));
2443
+ }
2444
+ if let Some(value) = &output.daily_max_tx_count {
2445
+ lines.push(format!(" Daily Tx Count Limit: {value}"));
2446
+ }
2447
+ if let Some(id) = &output.daily_tx_count_policy_id {
2448
+ lines.push(format!(" Daily Tx Count: {id}"));
2449
+ }
2450
+ if let Some(value) = &output.per_tx_max_fee_per_gas_wei {
2451
+ lines.push(format!(" Per-Tx Max Fee Per Gas Limit (wei): {value}"));
2452
+ }
2453
+ if let Some(id) = &output.per_tx_max_fee_per_gas_policy_id {
2454
+ lines.push(format!(" Per-Tx Max Fee Per Gas: {id}"));
2455
+ }
2456
+ if let Some(value) = &output.per_tx_max_priority_fee_per_gas_wei {
2457
+ lines.push(format!(
2458
+ " Per-Tx Max Priority Fee Per Gas Limit (wei): {value}"
2459
+ ));
2460
+ }
2461
+ if let Some(id) = &output.per_tx_max_priority_fee_per_gas_policy_id {
2462
+ lines.push(format!(" Per-Tx Max Priority Fee Per Gas: {id}"));
2463
+ }
2464
+ if let Some(value) = &output.per_tx_max_calldata_bytes {
2465
+ lines.push(format!(" Per-Tx Max Calldata Bytes Limit: {value}"));
2466
+ }
2467
+ if let Some(id) = &output.per_tx_max_calldata_bytes_policy_id {
2468
+ lines.push(format!(" Per-Tx Max Calldata Bytes: {id}"));
2469
+ }
2470
+ if let Some(value) = &output.network_scope {
2471
+ lines.push(format!(" Network Scope: {value}"));
2472
+ }
2473
+ if let Some(value) = &output.asset_scope {
2474
+ lines.push(format!(" Asset Scope: {value}"));
2475
+ }
2476
+ if let Some(value) = &output.recipient_scope {
2477
+ lines.push(format!(" Recipient Scope: {value}"));
2478
+ }
2479
+ } else {
2480
+ lines.push("Per-Token Policies".to_string());
2481
+ for token_policy in &output.token_policies {
2482
+ lines.push(format!(
2483
+ " {}:{} ({})",
2484
+ token_policy.token_key, token_policy.chain_key, token_policy.symbol
2485
+ ));
2486
+ lines.push(format!(" Chain ID: {}", token_policy.chain_id));
2487
+ lines.push(format!(" Asset Scope: {}", token_policy.asset_scope));
2488
+ lines.push(format!(
2489
+ " Limits: per-tx={} daily={} weekly={} gas={}",
2490
+ token_policy.per_tx_max_wei,
2491
+ token_policy.daily_max_wei,
2492
+ token_policy.weekly_max_wei,
2493
+ display_optional_output_value(&token_policy.max_gas_per_chain_wei)
2494
+ ));
2495
+ if let Some(value) = &token_policy.daily_max_tx_count {
2496
+ lines.push(format!(" Daily Tx Count Limit: {value}"));
2497
+ }
2498
+ if let Some(value) = &token_policy.per_tx_max_fee_per_gas_wei {
2499
+ lines.push(format!(" Per-Tx Max Fee/Gas Limit (wei): {value}"));
2500
+ }
2501
+ if let Some(value) = &token_policy.per_tx_max_priority_fee_per_gas_wei {
2502
+ lines.push(format!(
2503
+ " Per-Tx Max Priority Fee/Gas Limit (wei): {value}"
2504
+ ));
2505
+ }
2506
+ if let Some(value) = &token_policy.per_tx_max_calldata_bytes {
2507
+ lines.push(format!(" Per-Tx Max Calldata Bytes Limit: {value}"));
2508
+ }
2509
+ lines.push(format!(
2510
+ " Policy IDs: per_tx={} daily={} weekly={} gas={}",
2511
+ token_policy.per_tx_policy_id,
2512
+ token_policy.daily_policy_id,
2513
+ token_policy.weekly_policy_id,
2514
+ display_optional_output_value(&token_policy.gas_policy_id)
2515
+ ));
2516
+ }
2517
+ }
2518
+ lines.push(format!(
2519
+ " Destination Override Count: {}",
2520
+ output.destination_override_count
2521
+ ));
2522
+ if output.agent_auth_token == "<redacted>" {
2523
+ lines.push(
2524
+ " Note: pass --print-agent-auth-token to intentionally print secret credentials"
2525
+ .to_string(),
2526
+ );
2527
+ } else {
2528
+ lines.push(
2529
+ " Warning: keep the agent auth token and any exported private key carefully."
2530
+ .to_string(),
2531
+ );
2532
+ }
2533
+ if !output.destination_overrides.is_empty() {
2534
+ lines.push("Destination Overrides".to_string());
2535
+ for destination_override in &output.destination_overrides {
2536
+ lines.push(format!(" Recipient: {}", destination_override.recipient));
2537
+ lines.push(format!(
2538
+ " Limits (wei): per-tx={} daily={} weekly={} gas={}",
2539
+ destination_override.per_tx_max_wei,
2540
+ destination_override.daily_max_wei,
2541
+ destination_override.weekly_max_wei,
2542
+ display_optional_output_value(&destination_override.max_gas_per_chain_wei)
2543
+ ));
2544
+ if let Some(value) = &destination_override.daily_max_tx_count {
2545
+ lines.push(format!(" Daily Tx Count Limit: {value}"));
2546
+ }
2547
+ if let Some(value) = &destination_override.per_tx_max_fee_per_gas_wei {
2548
+ lines.push(format!(" Per-Tx Max Fee/Gas Limit (wei): {value}"));
2549
+ }
2550
+ if let Some(value) = &destination_override.per_tx_max_priority_fee_per_gas_wei {
2551
+ lines.push(format!(
2552
+ " Per-Tx Max Priority Fee/Gas Limit (wei): {value}"
2553
+ ));
2554
+ }
2555
+ if let Some(value) = &destination_override.per_tx_max_calldata_bytes {
2556
+ lines.push(format!(" Per-Tx Max Calldata Bytes Limit: {value}"));
2557
+ }
2558
+ lines.push(format!(
2559
+ " Policy IDs: per_tx={} daily={} weekly={} gas={}",
2560
+ destination_override.per_tx_policy_id,
2561
+ destination_override.daily_policy_id,
2562
+ destination_override.weekly_policy_id,
2563
+ display_optional_output_value(&destination_override.gas_policy_id)
2564
+ ));
2565
+ }
2566
+ }
2567
+ if !output.token_destination_overrides.is_empty() {
2568
+ lines.push("Per-Token Destination Overrides".to_string());
2569
+ for destination_override in &output.token_destination_overrides {
2570
+ lines.push(format!(
2571
+ " {}:{} ({}) -> {}",
2572
+ destination_override.token_key,
2573
+ destination_override.chain_key,
2574
+ destination_override.symbol,
2575
+ destination_override.recipient
2576
+ ));
2577
+ lines.push(format!(
2578
+ " Limits: per-tx={} daily={} weekly={} gas={}",
2579
+ destination_override.per_tx_max_wei,
2580
+ destination_override.daily_max_wei,
2581
+ destination_override.weekly_max_wei,
2582
+ display_optional_output_value(&destination_override.max_gas_per_chain_wei)
2583
+ ));
2584
+ if let Some(value) = &destination_override.daily_max_tx_count {
2585
+ lines.push(format!(" Daily Tx Count Limit: {value}"));
2586
+ }
2587
+ if let Some(value) = &destination_override.per_tx_max_fee_per_gas_wei {
2588
+ lines.push(format!(" Per-Tx Max Fee/Gas Limit (wei): {value}"));
2589
+ }
2590
+ if let Some(value) = &destination_override.per_tx_max_priority_fee_per_gas_wei {
2591
+ lines.push(format!(
2592
+ " Per-Tx Max Priority Fee/Gas Limit (wei): {value}"
2593
+ ));
2594
+ }
2595
+ if let Some(value) = &destination_override.per_tx_max_calldata_bytes {
2596
+ lines.push(format!(" Per-Tx Max Calldata Bytes Limit: {value}"));
2597
+ }
2598
+ lines.push(format!(
2599
+ " Policy IDs: per_tx={} daily={} weekly={} gas={}",
2600
+ destination_override.per_tx_policy_id,
2601
+ destination_override.daily_policy_id,
2602
+ destination_override.weekly_policy_id,
2603
+ display_optional_output_value(&destination_override.gas_policy_id)
2604
+ ));
2605
+ }
2606
+ }
2607
+ if !output.token_manual_approval_policies.is_empty() {
2608
+ lines.push("Per-Token Manual Approval Policies".to_string());
2609
+ for manual_approval in &output.token_manual_approval_policies {
2610
+ lines.push(format!(
2611
+ " {}:{} ({})",
2612
+ manual_approval.token_key,
2613
+ manual_approval.chain_key,
2614
+ manual_approval.symbol
2615
+ ));
2616
+ lines.push(format!(" Chain ID: {}", manual_approval.chain_id));
2617
+ lines.push(format!(" Priority: {}", manual_approval.priority));
2618
+ lines.push(format!(
2619
+ " Amount Range (wei): {} -> {}",
2620
+ manual_approval.min_amount_wei, manual_approval.max_amount_wei
2621
+ ));
2622
+ lines.push(format!(" Asset Scope: {}", manual_approval.asset_scope));
2623
+ lines.push(format!(
2624
+ " Recipient Scope: {}",
2625
+ manual_approval.recipient_scope
2626
+ ));
2627
+ lines.push(format!(" Policy ID: {}", manual_approval.policy_id));
2628
+ }
2629
+ }
2630
+ if !output.attached_policy_ids.is_empty() {
2631
+ lines.push("Attached Policy IDs".to_string());
2632
+ for policy_id in &output.attached_policy_ids {
2633
+ lines.push(format!(" {policy_id}"));
2634
+ }
2635
+ }
2636
+ lines.push("Policy Note".to_string());
2637
+ lines.push(format!(" {}", output.policy_note));
2638
+ lines
2639
+ .into_iter()
2640
+ .filter(|line| !line.is_empty())
2641
+ .collect::<Vec<_>>()
2642
+ .join("\n")
2643
+ }
2644
+ };
2645
+ emit_output(&rendered, target)
2646
+ }
2647
+
2648
+ fn print_rotate_agent_auth_token_output(
2649
+ output: &RotateAgentAuthTokenOutput,
2650
+ format: OutputFormat,
2651
+ target: &OutputTarget,
2652
+ ) -> Result<()> {
2653
+ let rendered = match format {
2654
+ OutputFormat::Json => {
2655
+ serde_json::to_string_pretty(output).context("failed to serialize output")?
2656
+ }
2657
+ OutputFormat::Text => {
2658
+ let mut lines = vec![
2659
+ format!("Agent Key ID: {}", output.agent_key_id),
2660
+ format!("Agent Auth Token: {}", output.agent_auth_token),
2661
+ ];
2662
+ if output.agent_auth_token_redacted {
2663
+ lines.push(
2664
+ "Note: pass --print-agent-auth-token to intentionally print secret credentials"
2665
+ .to_string(),
2666
+ );
2667
+ }
2668
+ lines.join("\n")
2669
+ }
2670
+ };
2671
+ emit_output(&rendered, target)
2672
+ }
2673
+
2674
+ fn print_revoke_agent_key_output(
2675
+ output: &RevokeAgentKeyOutput,
2676
+ format: OutputFormat,
2677
+ target: &OutputTarget,
2678
+ ) -> Result<()> {
2679
+ let rendered = match format {
2680
+ OutputFormat::Json => {
2681
+ serde_json::to_string_pretty(output).context("failed to serialize output")?
2682
+ }
2683
+ OutputFormat::Text => format!(
2684
+ "Agent Key ID: {}\nRevoked: {}",
2685
+ output.agent_key_id, output.revoked
2686
+ ),
2687
+ };
2688
+ emit_output(&rendered, target)
2689
+ }
2690
+
2691
+ fn print_manual_approval_policy_output(
2692
+ output: &ManualApprovalPolicyOutput,
2693
+ format: OutputFormat,
2694
+ target: &OutputTarget,
2695
+ ) -> Result<()> {
2696
+ let rendered = match format {
2697
+ OutputFormat::Json => {
2698
+ serde_json::to_string_pretty(output).context("failed to serialize output")?
2699
+ }
2700
+ OutputFormat::Text => vec![
2701
+ format!("Policy ID: {}", output.policy_id),
2702
+ format!("Priority: {}", output.priority),
2703
+ format!(
2704
+ "Amount Range (wei): {}..={}",
2705
+ output.min_amount_wei, output.max_amount_wei
2706
+ ),
2707
+ format!("Networks: {}", output.network_scope),
2708
+ format!("Assets: {}", output.asset_scope),
2709
+ format!("Recipients: {}", output.recipient_scope),
2710
+ ]
2711
+ .join(
2712
+ "
2713
+ ",
2714
+ ),
2715
+ };
2716
+ emit_output(&rendered, target)
2717
+ }
2718
+
2719
+ fn print_manual_approval_requests_output(
2720
+ output: &[ManualApprovalRequest],
2721
+ format: OutputFormat,
2722
+ target: &OutputTarget,
2723
+ ) -> Result<()> {
2724
+ let rendered = match format {
2725
+ OutputFormat::Json => {
2726
+ serde_json::to_string_pretty(output).context("failed to serialize output")?
2727
+ }
2728
+ OutputFormat::Text => {
2729
+ if output.is_empty() {
2730
+ "No manual approval requests".to_string()
2731
+ } else {
2732
+ output
2733
+ .iter()
2734
+ .map(render_manual_approval_request_text)
2735
+ .collect::<Vec<_>>()
2736
+ .join(
2737
+ "
2738
+
2739
+ ",
2740
+ )
2741
+ }
2742
+ }
2743
+ };
2744
+ emit_output(&rendered, target)
2745
+ }
2746
+
2747
+ fn print_manual_approval_request_output(
2748
+ output: &ManualApprovalRequest,
2749
+ format: OutputFormat,
2750
+ target: &OutputTarget,
2751
+ ) -> Result<()> {
2752
+ let rendered = match format {
2753
+ OutputFormat::Json => {
2754
+ serde_json::to_string_pretty(output).context("failed to serialize output")?
2755
+ }
2756
+ OutputFormat::Text => render_manual_approval_request_text(output),
2757
+ };
2758
+ emit_output(&rendered, target)
2759
+ }
2760
+
2761
+ fn render_manual_approval_request_text(output: &ManualApprovalRequest) -> String {
2762
+ let created_at = output
2763
+ .created_at
2764
+ .format(&Rfc3339)
2765
+ .unwrap_or_else(|_| output.created_at.to_string());
2766
+ let updated_at = output
2767
+ .updated_at
2768
+ .format(&Rfc3339)
2769
+ .unwrap_or_else(|_| output.updated_at.to_string());
2770
+ let completed_at = output
2771
+ .completed_at
2772
+ .and_then(|value| value.format(&Rfc3339).ok());
2773
+ let mut lines = vec![
2774
+ format!("Request ID: {}", output.id),
2775
+ format!("Status: {:?}", output.status),
2776
+ format!("Agent Key ID: {}", output.agent_key_id),
2777
+ format!("Vault Key ID: {}", output.vault_key_id),
2778
+ format!("Chain ID: {}", output.chain_id),
2779
+ format!("Asset: {}", output.asset),
2780
+ format!("Recipient: {}", output.recipient),
2781
+ format!("Amount (wei): {}", output.amount_wei),
2782
+ format!("Payload Hash: {}", output.request_payload_hash_hex),
2783
+ format!("Created At: {created_at}"),
2784
+ format!("Updated At: {updated_at}"),
2785
+ ];
2786
+ if let Some(completed_at) = completed_at {
2787
+ lines.push(format!("Completed At: {completed_at}"));
2788
+ }
2789
+ if let Some(reason) = &output.rejection_reason {
2790
+ lines.push(format!("Rejection Reason: {reason}"));
2791
+ }
2792
+ if !output.triggered_by_policy_ids.is_empty() {
2793
+ lines.push(format!(
2794
+ "Triggered By Policies: {}",
2795
+ output
2796
+ .triggered_by_policy_ids
2797
+ .iter()
2798
+ .map(ToString::to_string)
2799
+ .collect::<Vec<_>>()
2800
+ .join(",")
2801
+ ));
2802
+ }
2803
+ lines.join(
2804
+ "
2805
+ ",
2806
+ )
2807
+ }
2808
+
2809
+ fn print_relay_config_output(
2810
+ output: &RelayConfig,
2811
+ format: OutputFormat,
2812
+ target: &OutputTarget,
2813
+ ) -> Result<()> {
2814
+ let rendered = match format {
2815
+ OutputFormat::Json => {
2816
+ serde_json::to_string_pretty(output).context("failed to serialize output")?
2817
+ }
2818
+ OutputFormat::Text => vec![
2819
+ format!(
2820
+ "Relay URL: {}",
2821
+ output.relay_url.as_deref().unwrap_or("<unset>")
2822
+ ),
2823
+ format!(
2824
+ "Frontend URL: {}",
2825
+ output.frontend_url.as_deref().unwrap_or("<unset>")
2826
+ ),
2827
+ format!("Daemon ID: {}", output.daemon_id_hex),
2828
+ format!("Daemon Public Key: {}", output.daemon_public_key_hex),
2829
+ ]
2830
+ .join(
2831
+ "
2832
+ ",
2833
+ ),
2834
+ };
2835
+ emit_output(&rendered, target)
2836
+ }
2837
+
2838
+ fn build_asset_scope(tokens: &[EvmAddress], allow_native_eth: bool) -> EntityScope<AssetId> {
2839
+ if tokens.is_empty() && !allow_native_eth {
2840
+ return EntityScope::All;
2841
+ }
2842
+
2843
+ let mut set = BTreeSet::new();
2844
+ if allow_native_eth {
2845
+ set.insert(AssetId::NativeEth);
2846
+ }
2847
+ for token in tokens {
2848
+ set.insert(AssetId::Erc20(token.clone()));
2849
+ }
2850
+ EntityScope::Set(set)
2851
+ }
2852
+
2853
+ fn build_network_scope(network: Option<u64>) -> EntityScope<u64> {
2854
+ match network {
2855
+ Some(chain_id) => single_scope(chain_id),
2856
+ None => EntityScope::All,
2857
+ }
2858
+ }
2859
+
2860
+ fn describe_network_scope(scope: &EntityScope<u64>) -> String {
2861
+ match scope {
2862
+ EntityScope::All => "all networks".to_string(),
2863
+ EntityScope::Set(values) => values
2864
+ .iter()
2865
+ .map(ToString::to_string)
2866
+ .collect::<Vec<_>>()
2867
+ .join(","),
2868
+ }
2869
+ }
2870
+
2871
+ fn describe_asset_scope(scope: &EntityScope<AssetId>) -> String {
2872
+ match scope {
2873
+ EntityScope::All => "all assets".to_string(),
2874
+ EntityScope::Set(values) => values
2875
+ .iter()
2876
+ .map(ToString::to_string)
2877
+ .collect::<Vec<_>>()
2878
+ .join(","),
2879
+ }
2880
+ }
2881
+
2882
+ fn describe_recipient_scope(scope: &EntityScope<EvmAddress>) -> String {
2883
+ match scope {
2884
+ EntityScope::All => "all recipients".to_string(),
2885
+ EntityScope::Set(values) => values
2886
+ .iter()
2887
+ .map(ToString::to_string)
2888
+ .collect::<Vec<_>>()
2889
+ .join(","),
2890
+ }
2891
+ }
2892
+
2893
+ fn single_scope<T: Ord>(value: T) -> EntityScope<T> {
2894
+ let mut set = BTreeSet::new();
2895
+ set.insert(value);
2896
+ EntityScope::Set(set)
2897
+ }
2898
+
2899
+ #[cfg(test)]
2900
+ mod tests {
2901
+ use super::{
2902
+ build_asset_scope, build_network_scope, describe_recipient_scope, ensure_output_parent,
2903
+ execute_bootstrap, execute_revoke_agent_key, execute_rotate_agent_auth_token,
2904
+ resolve_daemon_socket_path, resolve_output_format, resolve_output_target,
2905
+ should_print_status, validate_existing_policy_attachments, validate_password,
2906
+ validate_policy_limits, write_output_file, BootstrapParams, Cli, Commands,
2907
+ DestinationPolicyOverride, OutputFormat, OutputTarget, RevokeAgentKeyParams,
2908
+ RotateAgentAuthTokenParams, TokenDestinationPolicyOverride, TokenPolicyConfig,
2909
+ };
2910
+ use crate::{shared_config::WlfiConfig, tui};
2911
+ use clap::Parser;
2912
+ use serde_json::to_vec;
2913
+ use std::sync::Arc;
2914
+ use std::time::{SystemTime, UNIX_EPOCH};
2915
+ use uuid::Uuid;
2916
+ use vault_daemon::{DaemonError, InMemoryDaemon, KeyManagerDaemonApi};
2917
+ use vault_domain::{AdminSession, AgentAction, AssetId, EntityScope, EvmAddress, SignRequest};
2918
+ use vault_signer::{KeyCreateRequest, SoftwareSignerBackend};
2919
+ use zeroize::Zeroize;
2920
+
2921
+ #[test]
2922
+ fn resolve_output_format_defaults_to_text() {
2923
+ let format = resolve_output_format(None, false).expect("format");
2924
+ assert!(matches!(format, OutputFormat::Text));
2925
+ }
2926
+
2927
+ #[test]
2928
+ fn resolve_output_format_json_shortcut() {
2929
+ let format = resolve_output_format(None, true).expect("format");
2930
+ assert!(matches!(format, OutputFormat::Json));
2931
+ }
2932
+
2933
+ #[test]
2934
+ fn resolve_output_format_rejects_text_conflict_with_json_shortcut() {
2935
+ let err = resolve_output_format(Some(OutputFormat::Text), true).expect_err("must fail");
2936
+ assert!(err.to_string().contains("--format text"));
2937
+ }
2938
+
2939
+ #[test]
2940
+ fn resolve_output_format_allows_json_redundancy() {
2941
+ let format = resolve_output_format(Some(OutputFormat::Json), true).expect("format");
2942
+ assert!(matches!(format, OutputFormat::Json));
2943
+ }
2944
+
2945
+ #[test]
2946
+ fn validate_password_rejects_whitespace_only() {
2947
+ let err = validate_password(" ".to_string(), "argument").expect_err("must fail");
2948
+ assert!(err.to_string().contains("must not be empty or whitespace"));
2949
+ }
2950
+
2951
+ #[test]
2952
+ fn cli_rejects_inline_vault_password_argument() {
2953
+ let err = Cli::try_parse_from([
2954
+ "wlfi-agent-admin",
2955
+ "--vault-password",
2956
+ "vault-secret",
2957
+ "bootstrap",
2958
+ ])
2959
+ .expect_err("must reject");
2960
+ assert!(err.to_string().contains("--vault-password"));
2961
+ }
2962
+
2963
+ #[test]
2964
+ fn validate_policy_limits_enforce_ordering() {
2965
+ let err = validate_policy_limits(10, 9, 20).expect_err("must fail");
2966
+ assert!(err.to_string().contains("--daily-max-wei"));
2967
+
2968
+ let err = validate_policy_limits(10, 10, 9).expect_err("must fail");
2969
+ assert!(err.to_string().contains("--weekly-max-wei"));
2970
+ }
2971
+
2972
+ #[test]
2973
+ fn build_asset_scope_supports_native_and_erc20_sets() {
2974
+ let token: EvmAddress = "0x1000000000000000000000000000000000000000"
2975
+ .parse()
2976
+ .expect("token");
2977
+
2978
+ let all_scope = build_asset_scope(&[], false);
2979
+ assert!(matches!(all_scope, EntityScope::All));
2980
+
2981
+ let native_only = build_asset_scope(&[], true);
2982
+ assert!(matches!(
2983
+ native_only,
2984
+ EntityScope::Set(values) if values.contains(&AssetId::NativeEth) && values.len() == 1
2985
+ ));
2986
+
2987
+ let mixed = build_asset_scope(std::slice::from_ref(&token), true);
2988
+ assert!(matches!(
2989
+ mixed,
2990
+ EntityScope::Set(values)
2991
+ if values.contains(&AssetId::NativeEth)
2992
+ && values.contains(&AssetId::Erc20(token))
2993
+ ));
2994
+ }
2995
+
2996
+ #[test]
2997
+ fn build_network_scope_supports_all_or_specific_chain() {
2998
+ assert!(matches!(build_network_scope(None), EntityScope::All));
2999
+ assert!(matches!(
3000
+ build_network_scope(Some(1)),
3001
+ EntityScope::Set(values) if values.contains(&1)
3002
+ ));
3003
+ }
3004
+
3005
+ #[test]
3006
+ fn describe_recipient_scope_supports_all_or_specific_recipient() {
3007
+ assert_eq!(
3008
+ describe_recipient_scope(&EntityScope::All),
3009
+ "all recipients"
3010
+ );
3011
+
3012
+ let recipient: EvmAddress = "0x1000000000000000000000000000000000000001"
3013
+ .parse()
3014
+ .expect("recipient");
3015
+ let mut recipients = std::collections::BTreeSet::new();
3016
+ recipients.insert(recipient.clone());
3017
+
3018
+ assert_eq!(
3019
+ describe_recipient_scope(&EntityScope::Set(recipients)),
3020
+ recipient.to_string()
3021
+ );
3022
+ }
3023
+
3024
+ #[tokio::test]
3025
+ async fn validate_existing_policy_attachments_rejects_unknown_policy_ids() {
3026
+ let daemon = test_daemon();
3027
+ let lease = daemon.issue_lease("vault-password").await.expect("lease");
3028
+ let mut session = vault_domain::AdminSession {
3029
+ vault_password: "vault-password".to_string(),
3030
+ lease,
3031
+ };
3032
+
3033
+ let missing = Uuid::parse_str("00000000-0000-0000-0000-000000000111").expect("uuid");
3034
+ let err = validate_existing_policy_attachments(daemon.as_ref(), &session, &[missing])
3035
+ .await
3036
+ .expect_err("must reject unknown policy id");
3037
+
3038
+ session.vault_password.zeroize();
3039
+ assert!(err
3040
+ .to_string()
3041
+ .contains("unknown --attach-policy-id value(s):"));
3042
+ assert!(err.to_string().contains(&missing.to_string()));
3043
+ }
3044
+
3045
+ #[test]
3046
+ fn status_printing_only_in_text_when_not_quiet() {
3047
+ assert!(should_print_status(OutputFormat::Text, false));
3048
+ assert!(!should_print_status(OutputFormat::Text, true));
3049
+ assert!(!should_print_status(OutputFormat::Json, false));
3050
+ }
3051
+
3052
+ #[test]
3053
+ fn resolve_output_target_maps_dash_to_stdout() {
3054
+ let target = resolve_output_target(Some("-".into()), false).expect("target");
3055
+ assert!(matches!(target, OutputTarget::Stdout));
3056
+ }
3057
+
3058
+ #[test]
3059
+ fn resolve_output_target_rejects_overwrite_with_stdout() {
3060
+ let err = resolve_output_target(Some("-".into()), true).expect_err("must fail");
3061
+ assert!(err.to_string().contains("--overwrite cannot be used"));
3062
+ }
3063
+
3064
+ #[test]
3065
+ fn output_file_write_respects_overwrite_flag() {
3066
+ let unique = SystemTime::now()
3067
+ .duration_since(UNIX_EPOCH)
3068
+ .expect("time")
3069
+ .as_nanos();
3070
+ let path = std::env::temp_dir().join(format!(
3071
+ "wlfi-admin-cli-output-{}-{}.txt",
3072
+ std::process::id(),
3073
+ unique
3074
+ ));
3075
+ std::fs::write(&path, "existing\n").expect("seed");
3076
+
3077
+ let err = write_output_file(&path, "next", false).expect_err("must fail");
3078
+ assert!(err.to_string().contains("already exists"));
3079
+
3080
+ write_output_file(&path, "next", true).expect("overwrite");
3081
+ let updated = std::fs::read_to_string(&path).expect("read");
3082
+ assert_eq!(updated, "next\n");
3083
+
3084
+ std::fs::remove_file(&path).expect("cleanup");
3085
+ }
3086
+
3087
+ #[cfg(unix)]
3088
+ #[test]
3089
+ fn output_file_write_rejects_symlink_target() {
3090
+ use std::os::unix::fs::symlink;
3091
+
3092
+ let unique = SystemTime::now()
3093
+ .duration_since(UNIX_EPOCH)
3094
+ .expect("time")
3095
+ .as_nanos();
3096
+ let target = std::env::temp_dir().join(format!(
3097
+ "wlfi-admin-cli-symlink-target-{}-{}.txt",
3098
+ std::process::id(),
3099
+ unique
3100
+ ));
3101
+ let link = std::env::temp_dir().join(format!(
3102
+ "wlfi-admin-cli-symlink-link-{}-{}.txt",
3103
+ std::process::id(),
3104
+ unique
3105
+ ));
3106
+ std::fs::write(&target, "seed\n").expect("seed");
3107
+ symlink(&target, &link).expect("symlink");
3108
+
3109
+ let err = ensure_output_parent(&link).expect_err("must fail");
3110
+ assert!(err.to_string().contains("must not be a symlink"));
3111
+
3112
+ std::fs::remove_file(&link).expect("cleanup link");
3113
+ std::fs::remove_file(&target).expect("cleanup target");
3114
+ }
3115
+
3116
+ #[test]
3117
+ fn canonical_bootstrap_command_is_accepted() {
3118
+ let cli = Cli::try_parse_from([
3119
+ "wlfi-agent-admin",
3120
+ "--daemon-socket",
3121
+ "/tmp/wlfi.sock",
3122
+ "bootstrap",
3123
+ ])
3124
+ .expect("parse");
3125
+ assert!(matches!(cli.command, Commands::Bootstrap(_)));
3126
+ }
3127
+
3128
+ #[test]
3129
+ fn bootstrap_supports_explicit_agent_policy_attachment_flags() {
3130
+ let cli = Cli::try_parse_from([
3131
+ "wlfi-agent-admin",
3132
+ "--daemon-socket",
3133
+ "/tmp/wlfi.sock",
3134
+ "bootstrap",
3135
+ "--from-shared-config",
3136
+ "--attach-bootstrap-policies",
3137
+ "--attach-policy-id",
3138
+ "00000000-0000-0000-0000-000000000001",
3139
+ "--attach-policy-id",
3140
+ "00000000-0000-0000-0000-000000000002",
3141
+ ])
3142
+ .expect("parse");
3143
+
3144
+ let Commands::Bootstrap(args) = cli.command else {
3145
+ panic!("expected bootstrap command");
3146
+ };
3147
+ assert!(args.from_shared_config);
3148
+ assert!(args.attach_bootstrap_policies);
3149
+ assert_eq!(args.attach_policy_id.len(), 2);
3150
+ }
3151
+
3152
+ #[test]
3153
+ fn bootstrap_command_accepts_explicit_vault_private_key_export_flag() {
3154
+ let cli = Cli::try_parse_from([
3155
+ "wlfi-agent-admin",
3156
+ "--daemon-socket",
3157
+ "/tmp/wlfi.sock",
3158
+ "bootstrap",
3159
+ "--print-vault-private-key",
3160
+ ])
3161
+ .expect("parse");
3162
+
3163
+ let Commands::Bootstrap(args) = cli.command else {
3164
+ panic!("expected bootstrap command");
3165
+ };
3166
+ assert!(args.print_vault_private_key);
3167
+ }
3168
+
3169
+ #[test]
3170
+ fn shared_config_bootstrap_params_expand_seeded_token_policies() {
3171
+ let params =
3172
+ tui::build_bootstrap_params_from_shared_config(&WlfiConfig::default(), false, false)
3173
+ .expect("params");
3174
+ assert_eq!(params.token_policies.len(), 3);
3175
+ assert!(params.tokens.is_empty());
3176
+ assert!(params.token_destination_overrides.is_empty());
3177
+ }
3178
+
3179
+ #[test]
3180
+ fn tui_command_is_accepted() {
3181
+ let cli = Cli::try_parse_from([
3182
+ "wlfi-agent-admin",
3183
+ "--daemon-socket",
3184
+ "/tmp/wlfi.sock",
3185
+ "tui",
3186
+ ])
3187
+ .expect("parse");
3188
+ let Commands::Tui(args) = cli.command else {
3189
+ panic!("expected tui command");
3190
+ };
3191
+ assert!(!args.print_agent_auth_token);
3192
+ }
3193
+
3194
+ #[test]
3195
+ fn tui_command_accepts_print_agent_auth_token_flag() {
3196
+ let cli = Cli::try_parse_from([
3197
+ "wlfi-agent-admin",
3198
+ "--daemon-socket",
3199
+ "/tmp/wlfi.sock",
3200
+ "tui",
3201
+ "--print-agent-auth-token",
3202
+ ])
3203
+ .expect("parse");
3204
+ let Commands::Tui(args) = cli.command else {
3205
+ panic!("expected tui command");
3206
+ };
3207
+ assert!(args.print_agent_auth_token);
3208
+ }
3209
+
3210
+ #[test]
3211
+ fn setup_command_is_accepted() {
3212
+ let cli = Cli::try_parse_from(["wlfi-agent-admin", "setup", "--network", "11155111"])
3213
+ .expect("parse");
3214
+ let Commands::Setup(args) = cli.command else {
3215
+ panic!("expected setup command");
3216
+ };
3217
+ assert_eq!(args.forwarded_args, vec!["--network", "11155111"]);
3218
+ }
3219
+
3220
+ #[test]
3221
+ fn rotate_agent_auth_token_command_is_accepted() {
3222
+ let cli = Cli::try_parse_from([
3223
+ "wlfi-agent-admin",
3224
+ "--daemon-socket",
3225
+ "/tmp/wlfi.sock",
3226
+ "rotate-agent-auth-token",
3227
+ "--agent-key-id",
3228
+ "00000000-0000-0000-0000-000000000001",
3229
+ ])
3230
+ .expect("parse");
3231
+
3232
+ let Commands::RotateAgentAuthToken(args) = cli.command else {
3233
+ panic!("expected rotate-agent-auth-token command");
3234
+ };
3235
+ assert_eq!(
3236
+ args.agent_key_id,
3237
+ Uuid::parse_str("00000000-0000-0000-0000-000000000001").expect("uuid")
3238
+ );
3239
+ assert!(!args.print_agent_auth_token);
3240
+ }
3241
+
3242
+ #[test]
3243
+ fn revoke_agent_key_command_is_accepted() {
3244
+ let cli = Cli::try_parse_from([
3245
+ "wlfi-agent-admin",
3246
+ "--daemon-socket",
3247
+ "/tmp/wlfi.sock",
3248
+ "revoke-agent-key",
3249
+ "--agent-key-id",
3250
+ "00000000-0000-0000-0000-000000000001",
3251
+ ])
3252
+ .expect("parse");
3253
+
3254
+ let Commands::RevokeAgentKey(args) = cli.command else {
3255
+ panic!("expected revoke-agent-key command");
3256
+ };
3257
+ assert_eq!(
3258
+ args.agent_key_id,
3259
+ Uuid::parse_str("00000000-0000-0000-0000-000000000001").expect("uuid")
3260
+ );
3261
+ }
3262
+
3263
+ fn test_bootstrap_params(print_agent_auth_token: bool) -> BootstrapParams {
3264
+ BootstrapParams {
3265
+ per_tx_max_wei: 1_000_000_000_000_000_000,
3266
+ daily_max_wei: 5_000_000_000_000_000_000,
3267
+ weekly_max_wei: 20_000_000_000_000_000_000,
3268
+ max_gas_per_chain_wei: 1_000_000_000_000_000,
3269
+ daily_max_tx_count: 0,
3270
+ per_tx_max_fee_per_gas_wei: 0,
3271
+ per_tx_max_priority_fee_per_gas_wei: 0,
3272
+ per_tx_max_calldata_bytes: 0,
3273
+ tokens: Vec::new(),
3274
+ allow_native_eth: true,
3275
+ network: Some(1),
3276
+ recipient: None,
3277
+ token_policies: Vec::new(),
3278
+ destination_overrides: Vec::new(),
3279
+ token_destination_overrides: Vec::new(),
3280
+ token_manual_approval_policies: Vec::new(),
3281
+ attach_policy_ids: Vec::new(),
3282
+ print_agent_auth_token,
3283
+ print_vault_private_key: false,
3284
+ existing_vault_key_id: None,
3285
+ existing_vault_public_key: None,
3286
+ }
3287
+ }
3288
+
3289
+ fn test_per_token_bootstrap_params(print_agent_auth_token: bool) -> BootstrapParams {
3290
+ BootstrapParams {
3291
+ per_tx_max_wei: 0,
3292
+ daily_max_wei: 0,
3293
+ weekly_max_wei: 0,
3294
+ max_gas_per_chain_wei: 0,
3295
+ daily_max_tx_count: 0,
3296
+ per_tx_max_fee_per_gas_wei: 0,
3297
+ per_tx_max_priority_fee_per_gas_wei: 0,
3298
+ per_tx_max_calldata_bytes: 0,
3299
+ tokens: Vec::new(),
3300
+ allow_native_eth: false,
3301
+ network: None,
3302
+ recipient: None,
3303
+ token_policies: vec![
3304
+ TokenPolicyConfig {
3305
+ token_key: "eth".to_string(),
3306
+ symbol: "ETH".to_string(),
3307
+ chain_key: "ethereum".to_string(),
3308
+ chain_id: 1,
3309
+ is_native: true,
3310
+ address: None,
3311
+ per_tx_max_wei: 100,
3312
+ daily_max_wei: 500,
3313
+ weekly_max_wei: 1_000,
3314
+ max_gas_per_chain_wei: 1_000_000,
3315
+ daily_max_tx_count: 0,
3316
+ per_tx_max_fee_per_gas_wei: 0,
3317
+ per_tx_max_priority_fee_per_gas_wei: 0,
3318
+ per_tx_max_calldata_bytes: 0,
3319
+ },
3320
+ TokenPolicyConfig {
3321
+ token_key: "usdc".to_string(),
3322
+ symbol: "USDC".to_string(),
3323
+ chain_key: "ethereum".to_string(),
3324
+ chain_id: 1,
3325
+ is_native: false,
3326
+ address: Some(
3327
+ "0x1000000000000000000000000000000000000000"
3328
+ .parse()
3329
+ .expect("usdc address"),
3330
+ ),
3331
+ per_tx_max_wei: 250,
3332
+ daily_max_wei: 1_000,
3333
+ weekly_max_wei: 2_000,
3334
+ max_gas_per_chain_wei: 1_000_000,
3335
+ daily_max_tx_count: 0,
3336
+ per_tx_max_fee_per_gas_wei: 0,
3337
+ per_tx_max_priority_fee_per_gas_wei: 0,
3338
+ per_tx_max_calldata_bytes: 0,
3339
+ },
3340
+ ],
3341
+ destination_overrides: Vec::new(),
3342
+ token_destination_overrides: Vec::new(),
3343
+ token_manual_approval_policies: Vec::new(),
3344
+ attach_policy_ids: Vec::new(),
3345
+ print_agent_auth_token,
3346
+ print_vault_private_key: false,
3347
+ existing_vault_key_id: None,
3348
+ existing_vault_public_key: None,
3349
+ }
3350
+ }
3351
+
3352
+ fn test_daemon() -> Arc<dyn KeyManagerDaemonApi> {
3353
+ Arc::new(
3354
+ InMemoryDaemon::new(
3355
+ "vault-password",
3356
+ SoftwareSignerBackend::default(),
3357
+ Default::default(),
3358
+ )
3359
+ .expect("daemon"),
3360
+ )
3361
+ }
3362
+
3363
+ fn build_sign_request(
3364
+ agent_key_id: &str,
3365
+ agent_auth_token: &str,
3366
+ action: AgentAction,
3367
+ ) -> SignRequest {
3368
+ let now = time::OffsetDateTime::now_utc();
3369
+ SignRequest {
3370
+ request_id: Uuid::new_v4(),
3371
+ agent_key_id: Uuid::parse_str(agent_key_id).expect("agent key uuid"),
3372
+ agent_auth_token: agent_auth_token.to_string(),
3373
+ payload: to_vec(&action).expect("action payload"),
3374
+ action,
3375
+ requested_at: now,
3376
+ expires_at: now + time::Duration::minutes(2),
3377
+ }
3378
+ }
3379
+
3380
+ async fn create_existing_vault_key(daemon: Arc<dyn KeyManagerDaemonApi>) -> (Uuid, String) {
3381
+ let lease = daemon
3382
+ .issue_lease("vault-password")
3383
+ .await
3384
+ .expect("issue lease");
3385
+ let session = AdminSession {
3386
+ vault_password: "vault-password".to_string(),
3387
+ lease,
3388
+ };
3389
+ let vault_key = daemon
3390
+ .create_vault_key(&session, KeyCreateRequest::Generate)
3391
+ .await
3392
+ .expect("create vault key");
3393
+ (vault_key.id, vault_key.public_key_hex)
3394
+ }
3395
+
3396
+ #[tokio::test]
3397
+ async fn execute_bootstrap_creates_destination_override_policy_sets() {
3398
+ let daemon = test_daemon();
3399
+ let mut params = test_bootstrap_params(true);
3400
+ params
3401
+ .destination_overrides
3402
+ .push(DestinationPolicyOverride {
3403
+ recipient: "0x1000000000000000000000000000000000000001"
3404
+ .parse()
3405
+ .expect("recipient"),
3406
+ per_tx_max_wei: 100,
3407
+ daily_max_wei: 200,
3408
+ weekly_max_wei: 300,
3409
+ max_gas_per_chain_wei: 400,
3410
+ daily_max_tx_count: 2,
3411
+ per_tx_max_fee_per_gas_wei: 3,
3412
+ per_tx_max_priority_fee_per_gas_wei: 4,
3413
+ per_tx_max_calldata_bytes: 5,
3414
+ });
3415
+
3416
+ let output = execute_bootstrap(
3417
+ daemon,
3418
+ "vault-password",
3419
+ "daemon_socket:/tmp/wlfi.sock",
3420
+ params,
3421
+ |_| {},
3422
+ )
3423
+ .await
3424
+ .expect("bootstrap");
3425
+
3426
+ assert_eq!(output.destination_override_count, 1);
3427
+ assert_eq!(output.destination_overrides.len(), 1);
3428
+ let override_output = &output.destination_overrides[0];
3429
+ assert_eq!(
3430
+ override_output.recipient,
3431
+ "0x1000000000000000000000000000000000000001"
3432
+ );
3433
+ assert_eq!(override_output.per_tx_max_wei, "100");
3434
+ assert_eq!(override_output.daily_max_tx_count.as_deref(), Some("2"));
3435
+ assert!(output.policy_note.contains("stricter overlays"));
3436
+ }
3437
+
3438
+ #[tokio::test]
3439
+ async fn execute_bootstrap_supports_per_token_policy_bundles() {
3440
+ let daemon = Arc::new(
3441
+ InMemoryDaemon::new(
3442
+ "vault-password",
3443
+ SoftwareSignerBackend::default(),
3444
+ Default::default(),
3445
+ )
3446
+ .expect("daemon"),
3447
+ );
3448
+ let daemon_api: Arc<dyn KeyManagerDaemonApi> = daemon.clone();
3449
+
3450
+ let output = execute_bootstrap(
3451
+ daemon_api,
3452
+ "vault-password",
3453
+ "daemon_socket:/tmp/wlfi.sock",
3454
+ test_per_token_bootstrap_params(true),
3455
+ |_| {},
3456
+ )
3457
+ .await
3458
+ .expect("bootstrap");
3459
+
3460
+ assert_eq!(output.token_policies.len(), 2);
3461
+ assert!(output.per_tx_policy_id.is_none());
3462
+ assert_eq!(output.policy_attachment, "policy_set");
3463
+ assert!(!output.attached_policy_ids.is_empty());
3464
+ assert!(output
3465
+ .attached_policy_ids
3466
+ .contains(&output.token_policies[0].per_tx_policy_id));
3467
+
3468
+ let native_err = daemon
3469
+ .sign_for_agent(build_sign_request(
3470
+ &output.agent_key_id,
3471
+ &output.agent_auth_token,
3472
+ AgentAction::TransferNative {
3473
+ chain_id: 1,
3474
+ to: "0x2000000000000000000000000000000000000001"
3475
+ .parse()
3476
+ .expect("recipient"),
3477
+ amount_wei: 150,
3478
+ },
3479
+ ))
3480
+ .await
3481
+ .expect_err("native transfer must exceed ETH per-tx policy");
3482
+ assert!(matches!(native_err, DaemonError::Policy(_)));
3483
+
3484
+ let erc20_signature = daemon
3485
+ .sign_for_agent(build_sign_request(
3486
+ &output.agent_key_id,
3487
+ &output.agent_auth_token,
3488
+ AgentAction::Transfer {
3489
+ chain_id: 1,
3490
+ token: "0x1000000000000000000000000000000000000000"
3491
+ .parse()
3492
+ .expect("token"),
3493
+ to: "0x2000000000000000000000000000000000000002"
3494
+ .parse()
3495
+ .expect("recipient"),
3496
+ amount_wei: 200,
3497
+ },
3498
+ ))
3499
+ .await
3500
+ .expect("erc20 transfer should use the USDC token policy");
3501
+ assert!(!erc20_signature.bytes.is_empty());
3502
+ }
3503
+
3504
+ #[tokio::test]
3505
+ async fn execute_bootstrap_reuses_existing_wallet_when_requested() {
3506
+ let daemon = test_daemon();
3507
+ let (vault_key_id, vault_public_key) = create_existing_vault_key(daemon.clone()).await;
3508
+ let mut params = test_per_token_bootstrap_params(true);
3509
+ params.existing_vault_key_id = Some(vault_key_id);
3510
+ params.existing_vault_public_key = Some(vault_public_key.clone());
3511
+
3512
+ let output = execute_bootstrap(
3513
+ daemon,
3514
+ "vault-password",
3515
+ "daemon_socket:/tmp/wlfi.sock",
3516
+ params,
3517
+ |_| {},
3518
+ )
3519
+ .await
3520
+ .expect("bootstrap");
3521
+
3522
+ assert_eq!(output.vault_key_id, vault_key_id.to_string());
3523
+ assert_eq!(output.vault_public_key, vault_public_key);
3524
+ assert!(output
3525
+ .policy_note
3526
+ .contains("reused the existing wallet address"));
3527
+ }
3528
+
3529
+ #[tokio::test]
3530
+ async fn execute_bootstrap_allows_per_token_unlimited_gas_defaults() {
3531
+ let daemon = Arc::new(
3532
+ InMemoryDaemon::new(
3533
+ "vault-password",
3534
+ SoftwareSignerBackend::default(),
3535
+ Default::default(),
3536
+ )
3537
+ .expect("daemon"),
3538
+ );
3539
+ let daemon_api: Arc<dyn KeyManagerDaemonApi> = daemon.clone();
3540
+ let mut params = test_per_token_bootstrap_params(true);
3541
+ for token_policy in &mut params.token_policies {
3542
+ token_policy.max_gas_per_chain_wei = 0;
3543
+ }
3544
+
3545
+ let output = execute_bootstrap(
3546
+ daemon_api,
3547
+ "vault-password",
3548
+ "daemon_socket:/tmp/wlfi.sock",
3549
+ params,
3550
+ |_| {},
3551
+ )
3552
+ .await
3553
+ .expect("bootstrap");
3554
+
3555
+ assert!(output
3556
+ .token_policies
3557
+ .iter()
3558
+ .all(|token_policy| token_policy.gas_policy_id.is_none()));
3559
+ assert!(output
3560
+ .token_policies
3561
+ .iter()
3562
+ .all(|token_policy| token_policy.max_gas_per_chain_wei.is_none()));
3563
+ }
3564
+
3565
+ #[tokio::test]
3566
+ async fn execute_bootstrap_applies_per_token_destination_overrides() {
3567
+ let daemon = Arc::new(
3568
+ InMemoryDaemon::new(
3569
+ "vault-password",
3570
+ SoftwareSignerBackend::default(),
3571
+ Default::default(),
3572
+ )
3573
+ .expect("daemon"),
3574
+ );
3575
+ let daemon_api: Arc<dyn KeyManagerDaemonApi> = daemon.clone();
3576
+ let mut params = test_per_token_bootstrap_params(true);
3577
+ params
3578
+ .token_destination_overrides
3579
+ .push(TokenDestinationPolicyOverride {
3580
+ token_key: "eth".to_string(),
3581
+ chain_key: "ethereum".to_string(),
3582
+ recipient: "0x3000000000000000000000000000000000000003"
3583
+ .parse()
3584
+ .expect("recipient"),
3585
+ per_tx_max_wei: 50,
3586
+ daily_max_wei: 250,
3587
+ weekly_max_wei: 500,
3588
+ max_gas_per_chain_wei: 1_000_000,
3589
+ daily_max_tx_count: 0,
3590
+ per_tx_max_fee_per_gas_wei: 0,
3591
+ per_tx_max_priority_fee_per_gas_wei: 0,
3592
+ per_tx_max_calldata_bytes: 0,
3593
+ });
3594
+
3595
+ let output = execute_bootstrap(
3596
+ daemon_api,
3597
+ "vault-password",
3598
+ "daemon_socket:/tmp/wlfi.sock",
3599
+ params,
3600
+ |_| {},
3601
+ )
3602
+ .await
3603
+ .expect("bootstrap");
3604
+
3605
+ assert_eq!(output.token_destination_overrides.len(), 1);
3606
+
3607
+ let strict_err = daemon
3608
+ .sign_for_agent(build_sign_request(
3609
+ &output.agent_key_id,
3610
+ &output.agent_auth_token,
3611
+ AgentAction::TransferNative {
3612
+ chain_id: 1,
3613
+ to: "0x3000000000000000000000000000000000000003"
3614
+ .parse()
3615
+ .expect("recipient"),
3616
+ amount_wei: 75,
3617
+ },
3618
+ ))
3619
+ .await
3620
+ .expect_err("override recipient should be constrained by stricter ETH limit");
3621
+ assert!(matches!(strict_err, DaemonError::Policy(_)));
3622
+
3623
+ let allowed_elsewhere = daemon
3624
+ .sign_for_agent(build_sign_request(
3625
+ &output.agent_key_id,
3626
+ &output.agent_auth_token,
3627
+ AgentAction::TransferNative {
3628
+ chain_id: 1,
3629
+ to: "0x3000000000000000000000000000000000000004"
3630
+ .parse()
3631
+ .expect("recipient"),
3632
+ amount_wei: 75,
3633
+ },
3634
+ ))
3635
+ .await
3636
+ .expect("non-overridden recipient should keep the base ETH limit");
3637
+ assert!(!allowed_elsewhere.bytes.is_empty());
3638
+ }
3639
+
3640
+ #[tokio::test]
3641
+ async fn execute_bootstrap_destination_override_applies_stricter_limit() {
3642
+ let daemon = Arc::new(
3643
+ InMemoryDaemon::new(
3644
+ "vault-password",
3645
+ SoftwareSignerBackend::default(),
3646
+ Default::default(),
3647
+ )
3648
+ .expect("daemon"),
3649
+ );
3650
+ let daemon_api: Arc<dyn KeyManagerDaemonApi> = daemon.clone();
3651
+ let mut params = test_bootstrap_params(true);
3652
+ params.per_tx_max_wei = 1_000;
3653
+ params.daily_max_wei = 5_000;
3654
+ params.weekly_max_wei = 20_000;
3655
+ params
3656
+ .destination_overrides
3657
+ .push(DestinationPolicyOverride {
3658
+ recipient: "0x2000000000000000000000000000000000000002"
3659
+ .parse()
3660
+ .expect("recipient"),
3661
+ per_tx_max_wei: 100,
3662
+ daily_max_wei: 500,
3663
+ weekly_max_wei: 1_000,
3664
+ max_gas_per_chain_wei: params.max_gas_per_chain_wei,
3665
+ daily_max_tx_count: 0,
3666
+ per_tx_max_fee_per_gas_wei: 0,
3667
+ per_tx_max_priority_fee_per_gas_wei: 0,
3668
+ per_tx_max_calldata_bytes: 0,
3669
+ });
3670
+
3671
+ let output = execute_bootstrap(
3672
+ daemon_api,
3673
+ "vault-password",
3674
+ "daemon_socket:/tmp/wlfi.sock",
3675
+ params,
3676
+ |_| {},
3677
+ )
3678
+ .await
3679
+ .expect("bootstrap");
3680
+
3681
+ let strict_action = AgentAction::TransferNative {
3682
+ chain_id: 1,
3683
+ to: "0x2000000000000000000000000000000000000002"
3684
+ .parse()
3685
+ .expect("recipient"),
3686
+ amount_wei: 150,
3687
+ };
3688
+ let err = daemon
3689
+ .sign_for_agent(build_sign_request(
3690
+ &output.agent_key_id,
3691
+ &output.agent_auth_token,
3692
+ strict_action,
3693
+ ))
3694
+ .await
3695
+ .expect_err("override recipient should be constrained by stricter limit");
3696
+ assert!(matches!(err, DaemonError::Policy(_)));
3697
+
3698
+ let allowed_elsewhere = AgentAction::TransferNative {
3699
+ chain_id: 1,
3700
+ to: "0x2000000000000000000000000000000000000003"
3701
+ .parse()
3702
+ .expect("recipient"),
3703
+ amount_wei: 150,
3704
+ };
3705
+ let signature = daemon
3706
+ .sign_for_agent(build_sign_request(
3707
+ &output.agent_key_id,
3708
+ &output.agent_auth_token,
3709
+ allowed_elsewhere,
3710
+ ))
3711
+ .await
3712
+ .expect("non-overridden recipient should use default limit");
3713
+ assert!(!signature.bytes.is_empty());
3714
+ }
3715
+
3716
+ #[tokio::test]
3717
+ async fn execute_bootstrap_rejects_destination_override_that_relaxes_default() {
3718
+ let daemon = test_daemon();
3719
+ let mut params = test_bootstrap_params(true);
3720
+ params
3721
+ .destination_overrides
3722
+ .push(DestinationPolicyOverride {
3723
+ recipient: "0x1000000000000000000000000000000000000001"
3724
+ .parse()
3725
+ .expect("recipient"),
3726
+ per_tx_max_wei: params.per_tx_max_wei + 1,
3727
+ daily_max_wei: params.daily_max_wei,
3728
+ weekly_max_wei: params.weekly_max_wei,
3729
+ max_gas_per_chain_wei: params.max_gas_per_chain_wei,
3730
+ daily_max_tx_count: params.daily_max_tx_count,
3731
+ per_tx_max_fee_per_gas_wei: params.per_tx_max_fee_per_gas_wei,
3732
+ per_tx_max_priority_fee_per_gas_wei: params.per_tx_max_priority_fee_per_gas_wei,
3733
+ per_tx_max_calldata_bytes: params.per_tx_max_calldata_bytes,
3734
+ });
3735
+
3736
+ let err = execute_bootstrap(
3737
+ daemon,
3738
+ "vault-password",
3739
+ "daemon_socket:/tmp/wlfi.sock",
3740
+ params,
3741
+ |_| {},
3742
+ )
3743
+ .await
3744
+ .expect_err("override must be rejected");
3745
+ assert!(err
3746
+ .to_string()
3747
+ .contains("must not increase per-tx max above the default value"));
3748
+ }
3749
+
3750
+ #[tokio::test]
3751
+ async fn execute_bootstrap_emits_rfc3339_lease_expiry() {
3752
+ let daemon = test_daemon();
3753
+ let output = execute_bootstrap(
3754
+ daemon,
3755
+ "vault-password",
3756
+ "daemon_socket:/tmp/wlfi.sock",
3757
+ test_bootstrap_params(true),
3758
+ |_| {},
3759
+ )
3760
+ .await
3761
+ .expect("bootstrap");
3762
+
3763
+ assert!(time::OffsetDateTime::parse(
3764
+ &output.lease_expires_at,
3765
+ &time::format_description::well_known::Rfc3339
3766
+ )
3767
+ .is_ok());
3768
+ assert!(output.lease_expires_at.contains('T'));
3769
+ }
3770
+
3771
+ #[tokio::test]
3772
+ async fn execute_bootstrap_does_not_export_private_key_when_printing_agent_token() {
3773
+ let daemon = test_daemon();
3774
+ let output = execute_bootstrap(
3775
+ daemon,
3776
+ "vault-password",
3777
+ "daemon_socket:/tmp/wlfi.sock",
3778
+ test_bootstrap_params(true),
3779
+ |_| {},
3780
+ )
3781
+ .await
3782
+ .expect("bootstrap");
3783
+
3784
+ assert!(output.vault_private_key.is_none());
3785
+ }
3786
+
3787
+ #[tokio::test]
3788
+ async fn execute_bootstrap_exports_software_private_key_only_when_requested() {
3789
+ let daemon = test_daemon();
3790
+ let mut params = test_bootstrap_params(true);
3791
+ params.print_vault_private_key = true;
3792
+ let output = execute_bootstrap(
3793
+ daemon,
3794
+ "vault-password",
3795
+ "daemon_socket:/tmp/wlfi.sock",
3796
+ params,
3797
+ |_| {},
3798
+ )
3799
+ .await
3800
+ .expect("bootstrap");
3801
+
3802
+ let private_key = output
3803
+ .vault_private_key
3804
+ .as_ref()
3805
+ .expect("software backend should export private key when requested");
3806
+ assert_eq!(private_key.len(), 64);
3807
+ assert!(private_key.chars().all(|ch| ch.is_ascii_hexdigit()));
3808
+ }
3809
+
3810
+ #[tokio::test]
3811
+ async fn execute_rotate_agent_auth_token_redacts_by_default() {
3812
+ let daemon = test_daemon();
3813
+ let bootstrap = execute_bootstrap(
3814
+ daemon.clone(),
3815
+ "vault-password",
3816
+ "daemon_socket:/tmp/wlfi.sock",
3817
+ test_bootstrap_params(true),
3818
+ |_| {},
3819
+ )
3820
+ .await
3821
+ .expect("bootstrap");
3822
+
3823
+ let output = execute_rotate_agent_auth_token(
3824
+ daemon,
3825
+ "vault-password",
3826
+ RotateAgentAuthTokenParams {
3827
+ agent_key_id: Uuid::parse_str(&bootstrap.agent_key_id).expect("agent key uuid"),
3828
+ print_agent_auth_token: false,
3829
+ },
3830
+ |_| {},
3831
+ )
3832
+ .await
3833
+ .expect("rotate output");
3834
+
3835
+ assert_eq!(output.agent_key_id, bootstrap.agent_key_id);
3836
+ assert_eq!(output.agent_auth_token, "<redacted>");
3837
+ assert!(output.agent_auth_token_redacted);
3838
+ }
3839
+
3840
+ #[tokio::test]
3841
+ async fn execute_rotate_agent_auth_token_can_print_new_secret() {
3842
+ let daemon = test_daemon();
3843
+ let bootstrap = execute_bootstrap(
3844
+ daemon.clone(),
3845
+ "vault-password",
3846
+ "daemon_socket:/tmp/wlfi.sock",
3847
+ test_bootstrap_params(true),
3848
+ |_| {},
3849
+ )
3850
+ .await
3851
+ .expect("bootstrap");
3852
+
3853
+ let output = execute_rotate_agent_auth_token(
3854
+ daemon,
3855
+ "vault-password",
3856
+ RotateAgentAuthTokenParams {
3857
+ agent_key_id: Uuid::parse_str(&bootstrap.agent_key_id).expect("agent key uuid"),
3858
+ print_agent_auth_token: true,
3859
+ },
3860
+ |_| {},
3861
+ )
3862
+ .await
3863
+ .expect("rotate output");
3864
+
3865
+ assert_eq!(output.agent_key_id, bootstrap.agent_key_id);
3866
+ assert!(!output.agent_auth_token_redacted);
3867
+ assert_ne!(output.agent_auth_token, bootstrap.agent_auth_token);
3868
+ assert!(!output.agent_auth_token.is_empty());
3869
+ }
3870
+
3871
+ #[tokio::test]
3872
+ async fn execute_revoke_agent_key_revokes_existing_agent_credentials() {
3873
+ let daemon = test_daemon();
3874
+ let bootstrap = execute_bootstrap(
3875
+ daemon.clone(),
3876
+ "vault-password",
3877
+ "daemon_socket:/tmp/wlfi.sock",
3878
+ test_bootstrap_params(true),
3879
+ |_| {},
3880
+ )
3881
+ .await
3882
+ .expect("bootstrap");
3883
+
3884
+ let output = execute_revoke_agent_key(
3885
+ daemon,
3886
+ "vault-password",
3887
+ RevokeAgentKeyParams {
3888
+ agent_key_id: Uuid::parse_str(&bootstrap.agent_key_id).expect("agent key uuid"),
3889
+ },
3890
+ |_| {},
3891
+ )
3892
+ .await
3893
+ .expect("revoke output");
3894
+
3895
+ assert_eq!(output.agent_key_id, bootstrap.agent_key_id);
3896
+ assert!(output.revoked);
3897
+ }
3898
+
3899
+ #[tokio::test]
3900
+ async fn execute_bootstrap_rejects_unknown_attachment_before_mutating_policies() {
3901
+ let daemon = test_daemon();
3902
+ let mut params = test_bootstrap_params(true);
3903
+ params.attach_policy_ids =
3904
+ vec![Uuid::parse_str("00000000-0000-0000-0000-000000000222").expect("uuid")];
3905
+
3906
+ let err = execute_bootstrap(
3907
+ daemon.clone(),
3908
+ "vault-password",
3909
+ "daemon_socket:/tmp/wlfi.sock",
3910
+ params,
3911
+ |_| {},
3912
+ )
3913
+ .await
3914
+ .expect_err("bootstrap must fail for unknown attachment id");
3915
+
3916
+ assert!(err
3917
+ .to_string()
3918
+ .contains("unknown --attach-policy-id value(s):"));
3919
+
3920
+ let lease = daemon.issue_lease("vault-password").await.expect("lease");
3921
+ let mut session = vault_domain::AdminSession {
3922
+ vault_password: "vault-password".to_string(),
3923
+ lease,
3924
+ };
3925
+ let policies = daemon.list_policies(&session).await.expect("list policies");
3926
+ session.vault_password.zeroize();
3927
+
3928
+ assert!(
3929
+ policies.is_empty(),
3930
+ "bootstrap failure must not register policies"
3931
+ );
3932
+ }
3933
+
3934
+ #[tokio::test]
3935
+ async fn execute_bootstrap_attaches_created_policies_by_default() {
3936
+ let output = execute_bootstrap(
3937
+ test_daemon(),
3938
+ "vault-password",
3939
+ "daemon_socket:/tmp/wlfi.sock",
3940
+ test_bootstrap_params(true),
3941
+ |_| {},
3942
+ )
3943
+ .await
3944
+ .expect("bootstrap");
3945
+
3946
+ assert_eq!(output.policy_attachment, "policy_set");
3947
+ assert_eq!(output.attached_policy_ids.len(), 4);
3948
+ assert!(output
3949
+ .attached_policy_ids
3950
+ .contains(output.per_tx_policy_id.as_ref().expect("per-tx policy id")));
3951
+ assert!(output
3952
+ .attached_policy_ids
3953
+ .contains(output.daily_policy_id.as_ref().expect("daily policy id")));
3954
+ assert!(output
3955
+ .attached_policy_ids
3956
+ .contains(output.weekly_policy_id.as_ref().expect("weekly policy id")));
3957
+ assert!(output
3958
+ .attached_policy_ids
3959
+ .contains(output.gas_policy_id.as_ref().expect("gas policy id")));
3960
+ assert!(output
3961
+ .policy_note
3962
+ .contains("bootstrap-created policy id(s)"));
3963
+ }
3964
+
3965
+ #[test]
3966
+ #[cfg(unix)]
3967
+ fn resolve_daemon_socket_path_rejects_non_root_owned_socket() {
3968
+ use std::os::unix::fs::PermissionsExt;
3969
+ use std::os::unix::net::UnixListener;
3970
+
3971
+ let unique = SystemTime::now()
3972
+ .duration_since(UNIX_EPOCH)
3973
+ .expect("time")
3974
+ .as_nanos();
3975
+ let root = std::env::temp_dir().join(format!("wa-{unique:x}"));
3976
+ std::fs::create_dir_all(&root).expect("create root directory");
3977
+ std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700))
3978
+ .expect("secure root directory permissions");
3979
+
3980
+ let socket_path = root.join("daemon.sock");
3981
+ let listener = UnixListener::bind(&socket_path).expect("bind socket");
3982
+
3983
+ let err = resolve_daemon_socket_path(Some(socket_path.clone())).expect_err("must reject");
3984
+ assert!(err.to_string().contains("must be owned by root"));
3985
+
3986
+ drop(listener);
3987
+ std::fs::remove_file(&socket_path).expect("cleanup socket");
3988
+ std::fs::remove_dir_all(&root).expect("cleanup root directory");
3989
+ }
3990
+ }