@wlfi-agent/cli 1.4.13 → 1.4.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (289) hide show
  1. package/Cargo.lock +3968 -0
  2. package/Cargo.toml +50 -0
  3. package/README.md +426 -6
  4. package/crates/vault-cli-admin/Cargo.toml +26 -0
  5. package/crates/vault-cli-admin/src/io_utils.rs +500 -0
  6. package/crates/vault-cli-admin/src/main.rs +3990 -0
  7. package/crates/vault-cli-admin/src/shared_config.rs +624 -0
  8. package/crates/vault-cli-admin/src/tui/amounts.rs +180 -0
  9. package/crates/vault-cli-admin/src/tui/token_rpc.rs +250 -0
  10. package/crates/vault-cli-admin/src/tui/utils.rs +82 -0
  11. package/crates/vault-cli-admin/src/tui.rs +3410 -0
  12. package/crates/vault-cli-agent/Cargo.toml +24 -0
  13. package/crates/vault-cli-agent/src/io_utils.rs +576 -0
  14. package/crates/vault-cli-agent/src/main.rs +833 -0
  15. package/crates/vault-cli-daemon/Cargo.toml +28 -0
  16. package/crates/vault-cli-daemon/src/bin/wlfi-agent-system-keychain.rs +216 -0
  17. package/crates/vault-cli-daemon/src/main.rs +644 -0
  18. package/crates/vault-cli-daemon/src/relay_sync.rs +894 -0
  19. package/crates/vault-cli-daemon/tests/system_keychain_helper_acl.rs +167 -0
  20. package/crates/vault-daemon/Cargo.toml +32 -0
  21. package/crates/vault-daemon/src/daemon_parts/api_impl_and_utils.rs +1041 -0
  22. package/crates/vault-daemon/src/daemon_parts/core_helpers.rs +1256 -0
  23. package/crates/vault-daemon/src/daemon_parts/types_api_rpc.rs +622 -0
  24. package/crates/vault-daemon/src/lib.rs +54 -0
  25. package/crates/vault-daemon/src/persistence.rs +441 -0
  26. package/crates/vault-daemon/src/tests.rs +237 -0
  27. package/crates/vault-daemon/src/tests_parts/part1.rs +1224 -0
  28. package/crates/vault-daemon/src/tests_parts/part2.rs +1021 -0
  29. package/crates/vault-daemon/src/tests_parts/part3.rs +835 -0
  30. package/crates/vault-daemon/src/tests_parts/part4.rs +604 -0
  31. package/crates/vault-domain/Cargo.toml +20 -0
  32. package/crates/vault-domain/src/action.rs +849 -0
  33. package/crates/vault-domain/src/address.rs +51 -0
  34. package/crates/vault-domain/src/approval.rs +90 -0
  35. package/crates/vault-domain/src/constants.rs +4 -0
  36. package/crates/vault-domain/src/error.rs +54 -0
  37. package/crates/vault-domain/src/keys.rs +71 -0
  38. package/crates/vault-domain/src/lib.rs +42 -0
  39. package/crates/vault-domain/src/nonce.rs +102 -0
  40. package/crates/vault-domain/src/policy.rs +172 -0
  41. package/crates/vault-domain/src/request.rs +53 -0
  42. package/crates/vault-domain/src/scope.rs +24 -0
  43. package/crates/vault-domain/src/session.rs +50 -0
  44. package/crates/vault-domain/src/signature.rs +34 -0
  45. package/crates/vault-domain/src/tests.rs +651 -0
  46. package/crates/vault-domain/src/u128_as_decimal_string.rs +44 -0
  47. package/crates/vault-policy/Cargo.toml +17 -0
  48. package/crates/vault-policy/src/engine.rs +301 -0
  49. package/crates/vault-policy/src/error.rs +81 -0
  50. package/crates/vault-policy/src/lib.rs +17 -0
  51. package/crates/vault-policy/src/report.rs +34 -0
  52. package/crates/vault-policy/src/tests.rs +891 -0
  53. package/crates/vault-policy/src/tests_explain.rs +78 -0
  54. package/crates/vault-sdk-agent/Cargo.toml +21 -0
  55. package/crates/vault-sdk-agent/src/lib.rs +711 -0
  56. package/crates/vault-signer/Cargo.toml +25 -0
  57. package/crates/vault-signer/src/lib.rs +731 -0
  58. package/crates/vault-signer/tests/secure_enclave_acl.rs +54 -0
  59. package/crates/vault-transport-unix/Cargo.toml +24 -0
  60. package/crates/vault-transport-unix/src/lib.rs +1640 -0
  61. package/crates/vault-transport-xpc/Cargo.toml +25 -0
  62. package/crates/vault-transport-xpc/src/client_codec_api.rs +635 -0
  63. package/crates/vault-transport-xpc/src/lib.rs +680 -0
  64. package/crates/vault-transport-xpc/src/tests.rs +818 -0
  65. package/crates/vault-transport-xpc/tests/e2e_flow.rs +773 -0
  66. package/dist/cli.cjs +35088 -0
  67. package/dist/cli.cjs.map +1 -0
  68. package/package.json +49 -43
  69. package/packages/cache/.turbo/turbo-build.log +52 -0
  70. package/packages/cache/dist/chunk-2QFWMUXT.cjs +43 -0
  71. package/packages/cache/dist/chunk-2QFWMUXT.cjs.map +1 -0
  72. package/packages/cache/dist/chunk-4U63TZTQ.js +43 -0
  73. package/packages/cache/dist/chunk-4U63TZTQ.js.map +1 -0
  74. package/packages/cache/dist/chunk-ALQ6H7KG.cjs +404 -0
  75. package/packages/cache/dist/chunk-ALQ6H7KG.cjs.map +1 -0
  76. package/packages/cache/dist/chunk-FGJEEF5N.js +404 -0
  77. package/packages/cache/dist/chunk-FGJEEF5N.js.map +1 -0
  78. package/packages/cache/dist/chunk-UYNEHZHB.cjs +45 -0
  79. package/packages/cache/dist/chunk-UYNEHZHB.cjs.map +1 -0
  80. package/packages/cache/dist/chunk-VXVMPG3W.js +45 -0
  81. package/packages/cache/dist/chunk-VXVMPG3W.js.map +1 -0
  82. package/packages/cache/dist/client/index.cjs +11 -0
  83. package/packages/cache/dist/client/index.cjs.map +1 -0
  84. package/packages/cache/dist/client/index.d.cts +15 -0
  85. package/packages/cache/dist/client/index.d.ts +15 -0
  86. package/packages/cache/dist/client/index.js +11 -0
  87. package/packages/cache/dist/client/index.js.map +1 -0
  88. package/packages/cache/dist/errors/index.cjs +11 -0
  89. package/packages/cache/dist/errors/index.cjs.map +1 -0
  90. package/packages/cache/dist/errors/index.d.cts +26 -0
  91. package/packages/cache/dist/errors/index.d.ts +26 -0
  92. package/packages/cache/dist/errors/index.js +11 -0
  93. package/packages/cache/dist/errors/index.js.map +1 -0
  94. package/packages/cache/dist/index.cjs +29 -0
  95. package/packages/cache/dist/index.cjs.map +1 -0
  96. package/packages/cache/dist/index.d.cts +4 -0
  97. package/packages/cache/dist/index.d.ts +4 -0
  98. package/packages/cache/dist/index.js +29 -0
  99. package/packages/cache/dist/index.js.map +1 -0
  100. package/packages/cache/dist/service/index.cjs +15 -0
  101. package/packages/cache/dist/service/index.cjs.map +1 -0
  102. package/packages/cache/dist/service/index.d.cts +184 -0
  103. package/packages/cache/dist/service/index.d.ts +184 -0
  104. package/packages/cache/dist/service/index.js +15 -0
  105. package/packages/cache/dist/service/index.js.map +1 -0
  106. package/packages/cache/node_modules/.bin/jiti +17 -0
  107. package/packages/cache/node_modules/.bin/tsc +17 -0
  108. package/packages/cache/node_modules/.bin/tsserver +17 -0
  109. package/packages/cache/node_modules/.bin/tsup +17 -0
  110. package/packages/cache/node_modules/.bin/tsup-node +17 -0
  111. package/packages/cache/node_modules/.bin/tsx +17 -0
  112. package/packages/cache/node_modules/.bin/vitest +17 -0
  113. package/packages/cache/package.json +48 -0
  114. package/packages/cache/src/client/index.ts +56 -0
  115. package/packages/cache/src/errors/index.ts +53 -0
  116. package/packages/cache/src/index.ts +3 -0
  117. package/packages/cache/src/service/index.test.ts +263 -0
  118. package/packages/cache/src/service/index.ts +678 -0
  119. package/packages/cache/tsconfig.json +13 -0
  120. package/packages/cache/tsup.config.ts +13 -0
  121. package/packages/cache/vitest.config.ts +16 -0
  122. package/packages/config/.turbo/turbo-build.log +18 -0
  123. package/packages/config/dist/index.cjs +1037 -0
  124. package/packages/config/dist/index.cjs.map +1 -0
  125. package/packages/config/dist/index.d.ts +131 -0
  126. package/packages/config/node_modules/.bin/jiti +17 -0
  127. package/packages/config/node_modules/.bin/tsc +17 -0
  128. package/packages/config/node_modules/.bin/tsserver +17 -0
  129. package/packages/config/node_modules/.bin/tsup +17 -0
  130. package/packages/config/node_modules/.bin/tsup-node +17 -0
  131. package/packages/config/node_modules/.bin/tsx +17 -0
  132. package/packages/config/package.json +21 -0
  133. package/packages/config/src/index.js +1 -0
  134. package/packages/config/src/index.ts +1282 -0
  135. package/packages/config/tsconfig.json +4 -0
  136. package/packages/rpc/.turbo/turbo-build.log +32 -0
  137. package/packages/rpc/dist/_esm-BCLXDO2R.cjs +3660 -0
  138. package/packages/rpc/dist/_esm-BCLXDO2R.cjs.map +1 -0
  139. package/packages/rpc/dist/ccip-OWJLAW55.cjs +16 -0
  140. package/packages/rpc/dist/ccip-OWJLAW55.cjs.map +1 -0
  141. package/packages/rpc/dist/chunk-APQIFZ3B.cjs +6247 -0
  142. package/packages/rpc/dist/chunk-APQIFZ3B.cjs.map +1 -0
  143. package/packages/rpc/dist/chunk-CDO2GWRD.cjs +410 -0
  144. package/packages/rpc/dist/chunk-CDO2GWRD.cjs.map +1 -0
  145. package/packages/rpc/dist/chunk-QGTNTFJ7.cjs +2249 -0
  146. package/packages/rpc/dist/chunk-QGTNTFJ7.cjs.map +1 -0
  147. package/packages/rpc/dist/chunk-TZDTAHWR.cjs +44 -0
  148. package/packages/rpc/dist/chunk-TZDTAHWR.cjs.map +1 -0
  149. package/packages/rpc/dist/index.cjs +7342 -0
  150. package/packages/rpc/dist/index.cjs.map +1 -0
  151. package/packages/rpc/dist/index.d.ts +3857 -0
  152. package/packages/rpc/dist/secp256k1-WCNM675D.cjs +18 -0
  153. package/packages/rpc/dist/secp256k1-WCNM675D.cjs.map +1 -0
  154. package/packages/rpc/node_modules/.bin/jiti +17 -0
  155. package/packages/rpc/node_modules/.bin/tsc +17 -0
  156. package/packages/rpc/node_modules/.bin/tsserver +17 -0
  157. package/packages/rpc/node_modules/.bin/tsup +17 -0
  158. package/packages/rpc/node_modules/.bin/tsup-node +17 -0
  159. package/packages/rpc/node_modules/.bin/tsx +17 -0
  160. package/packages/rpc/package.json +25 -0
  161. package/packages/rpc/src/index.ts +206 -0
  162. package/packages/rpc/tsconfig.json +4 -0
  163. package/packages/typescript/base.json +36 -0
  164. package/packages/typescript/nextjs.json +17 -0
  165. package/packages/typescript/package.json +10 -0
  166. package/packages/ui/.turbo/turbo-build.log +44 -0
  167. package/packages/ui/dist/chunk-MOAFBKSA.js +11 -0
  168. package/packages/ui/dist/chunk-MOAFBKSA.js.map +1 -0
  169. package/packages/ui/dist/components/badge.d.ts +12 -0
  170. package/packages/ui/dist/components/badge.js +31 -0
  171. package/packages/ui/dist/components/badge.js.map +1 -0
  172. package/packages/ui/dist/components/button.d.ts +13 -0
  173. package/packages/ui/dist/components/button.js +40 -0
  174. package/packages/ui/dist/components/button.js.map +1 -0
  175. package/packages/ui/dist/components/card.d.ts +10 -0
  176. package/packages/ui/dist/components/card.js +39 -0
  177. package/packages/ui/dist/components/card.js.map +1 -0
  178. package/packages/ui/dist/components/input.d.ts +5 -0
  179. package/packages/ui/dist/components/input.js +28 -0
  180. package/packages/ui/dist/components/input.js.map +1 -0
  181. package/packages/ui/dist/components/label.d.ts +5 -0
  182. package/packages/ui/dist/components/label.js +13 -0
  183. package/packages/ui/dist/components/label.js.map +1 -0
  184. package/packages/ui/dist/components/separator.d.ts +5 -0
  185. package/packages/ui/dist/components/separator.js +13 -0
  186. package/packages/ui/dist/components/separator.js.map +1 -0
  187. package/packages/ui/dist/components/textarea.d.ts +5 -0
  188. package/packages/ui/dist/components/textarea.js +27 -0
  189. package/packages/ui/dist/components/textarea.js.map +1 -0
  190. package/packages/ui/dist/tailwind.d.ts +56 -0
  191. package/packages/ui/dist/tailwind.js +60 -0
  192. package/packages/ui/dist/tailwind.js.map +1 -0
  193. package/packages/ui/dist/utils/cn.d.ts +5 -0
  194. package/packages/ui/dist/utils/cn.js +7 -0
  195. package/packages/ui/dist/utils/cn.js.map +1 -0
  196. package/packages/ui/node_modules/.bin/jiti +17 -0
  197. package/packages/ui/node_modules/.bin/tsc +17 -0
  198. package/packages/ui/node_modules/.bin/tsserver +17 -0
  199. package/packages/ui/node_modules/.bin/tsup +17 -0
  200. package/packages/ui/node_modules/.bin/tsup-node +17 -0
  201. package/packages/ui/node_modules/.bin/tsx +17 -0
  202. package/packages/ui/package.json +69 -0
  203. package/packages/ui/src/components/badge.tsx +27 -0
  204. package/packages/ui/src/components/button.tsx +40 -0
  205. package/packages/ui/src/components/card.tsx +31 -0
  206. package/packages/ui/src/components/input.tsx +21 -0
  207. package/packages/ui/src/components/label.tsx +6 -0
  208. package/packages/ui/src/components/separator.tsx +6 -0
  209. package/packages/ui/src/components/textarea.tsx +20 -0
  210. package/packages/ui/src/globals.css +70 -0
  211. package/packages/ui/src/tailwind.ts +56 -0
  212. package/packages/ui/src/utils/cn.ts +6 -0
  213. package/packages/ui/tsconfig.json +20 -0
  214. package/packages/ui/tsup.config.ts +20 -0
  215. package/pnpm-workspace.yaml +4 -0
  216. package/scripts/install-rust-binaries.mjs +84 -0
  217. package/scripts/launchd/install-user-daemon.sh +358 -0
  218. package/scripts/launchd/run-vault-daemon.sh +5 -0
  219. package/scripts/launchd/run-wlfi-agent-daemon.sh +73 -0
  220. package/scripts/launchd/uninstall-user-daemon.sh +103 -0
  221. package/src/cli.ts +2121 -0
  222. package/src/lib/admin-guard.js +1 -0
  223. package/src/lib/admin-guard.ts +185 -0
  224. package/src/lib/admin-passthrough.ts +33 -0
  225. package/src/lib/admin-reset.ts +751 -0
  226. package/src/lib/admin-setup.ts +1612 -0
  227. package/src/lib/agent-auth-clear.js +1 -0
  228. package/src/lib/agent-auth-clear.ts +58 -0
  229. package/src/lib/agent-auth-forwarding.js +1 -0
  230. package/src/lib/agent-auth-forwarding.ts +149 -0
  231. package/src/lib/agent-auth-migrate.js +1 -0
  232. package/src/lib/agent-auth-migrate.ts +150 -0
  233. package/src/lib/agent-auth-revoke.ts +103 -0
  234. package/src/lib/agent-auth-rotate.ts +107 -0
  235. package/src/lib/agent-auth-token.js +1 -0
  236. package/src/lib/agent-auth-token.ts +25 -0
  237. package/src/lib/agent-auth.ts +89 -0
  238. package/src/lib/asset-broadcast.js +1 -0
  239. package/src/lib/asset-broadcast.ts +285 -0
  240. package/src/lib/bootstrap-artifacts.js +1 -0
  241. package/src/lib/bootstrap-artifacts.ts +205 -0
  242. package/src/lib/bootstrap-credentials.js +1 -0
  243. package/src/lib/bootstrap-credentials.ts +832 -0
  244. package/src/lib/config-amounts.js +1 -0
  245. package/src/lib/config-amounts.ts +189 -0
  246. package/src/lib/config-mutation.ts +27 -0
  247. package/src/lib/fs-trust.js +1 -0
  248. package/src/lib/fs-trust.ts +537 -0
  249. package/src/lib/keychain.js +1 -0
  250. package/src/lib/keychain.ts +225 -0
  251. package/src/lib/local-admin-access.ts +106 -0
  252. package/src/lib/network-selection.js +1 -0
  253. package/src/lib/network-selection.ts +71 -0
  254. package/src/lib/passthrough-security.js +1 -0
  255. package/src/lib/passthrough-security.ts +114 -0
  256. package/src/lib/rpc-guard.js +1 -0
  257. package/src/lib/rpc-guard.ts +7 -0
  258. package/src/lib/rust-spawn-options.js +1 -0
  259. package/src/lib/rust-spawn-options.ts +98 -0
  260. package/src/lib/rust.js +1 -0
  261. package/src/lib/rust.ts +143 -0
  262. package/src/lib/signed-tx.js +1 -0
  263. package/src/lib/signed-tx.ts +116 -0
  264. package/src/lib/status-repair-cli.ts +116 -0
  265. package/src/lib/sudo.js +1 -0
  266. package/src/lib/sudo.ts +172 -0
  267. package/src/lib/vault-password-forwarding.js +1 -0
  268. package/src/lib/vault-password-forwarding.ts +155 -0
  269. package/src/lib/wallet-profile.js +1 -0
  270. package/src/lib/wallet-profile.ts +332 -0
  271. package/src/lib/wallet-repair.js +1 -0
  272. package/src/lib/wallet-repair.ts +304 -0
  273. package/src/lib/wallet-setup.js +1 -0
  274. package/src/lib/wallet-setup.ts +1466 -0
  275. package/src/lib/wallet-status.js +1 -0
  276. package/src/lib/wallet-status.ts +640 -0
  277. package/tsconfig.base.json +17 -0
  278. package/tsconfig.json +10 -0
  279. package/tsup.config.ts +25 -0
  280. package/turbo.json +41 -0
  281. package/LICENSE.md +0 -1
  282. package/dist/wlfa/index.cjs +0 -250
  283. package/dist/wlfa/index.d.cts +0 -1
  284. package/dist/wlfa/index.d.ts +0 -1
  285. package/dist/wlfa/index.js +0 -250
  286. package/dist/wlfc/index.cjs +0 -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,894 @@
1
+ use std::collections::BTreeMap;
2
+ use std::path::Path;
3
+ use std::sync::Arc;
4
+
5
+ use ::time::format_description::well_known::Rfc3339;
6
+ use ::time::OffsetDateTime;
7
+ use anyhow::{Context, Result};
8
+ use reqwest::header::CONTENT_TYPE;
9
+ use reqwest::Client;
10
+ use serde::{Deserialize, Serialize};
11
+ use tokio::task::JoinHandle;
12
+ use tokio::time::{self, MissedTickBehavior};
13
+ use uuid::Uuid;
14
+ use vault_daemon::{InMemoryDaemon, RelayRegistrationSnapshot};
15
+ use vault_domain::{
16
+ manual_approval_capability_hash, manual_approval_capability_token, AgentAction, AssetId,
17
+ EntityScope, ManualApprovalDecision, ManualApprovalStatus, PolicyType, SpendingPolicy,
18
+ };
19
+ use vault_signer::VaultSignerBackend;
20
+
21
+ const DEFAULT_RELAY_POLL_LIMIT: u32 = 25;
22
+ const DEFAULT_RELAY_LEASE_SECONDS: u32 = 30;
23
+ const ZERO_ADDRESS: &str = "0x0000000000000000000000000000000000000000";
24
+ const RELAY_DAEMON_TOKEN_ENV: &str = "WLFI_RELAY_DAEMON_TOKEN";
25
+ const RELAY_DAEMON_TOKEN_FILE_ENV: &str = "WLFI_RELAY_DAEMON_TOKEN_FILE";
26
+
27
+ #[derive(Debug, Serialize)]
28
+ #[serde(rename_all = "camelCase")]
29
+ struct RelayDaemonProfilePayload {
30
+ daemon_id: String,
31
+ daemon_public_key: String,
32
+ ethereum_address: String,
33
+ #[serde(skip_serializing_if = "Option::is_none")]
34
+ label: Option<String>,
35
+ last_seen_at: String,
36
+ registered_at: String,
37
+ #[serde(skip_serializing_if = "Option::is_none")]
38
+ relay_url: Option<String>,
39
+ signer_backend: String,
40
+ status: &'static str,
41
+ updated_at: String,
42
+ version: String,
43
+ }
44
+
45
+ #[derive(Debug, Serialize)]
46
+ #[serde(rename_all = "camelCase")]
47
+ struct RelayPolicyPayload {
48
+ action: String,
49
+ #[serde(skip_serializing_if = "Option::is_none")]
50
+ amount_max_wei: Option<String>,
51
+ #[serde(skip_serializing_if = "Option::is_none")]
52
+ amount_min_wei: Option<String>,
53
+ #[serde(skip_serializing_if = "Option::is_none")]
54
+ chain_id: Option<u64>,
55
+ destination: String,
56
+ #[serde(skip_serializing_if = "Option::is_none")]
57
+ metadata: Option<BTreeMap<String, String>>,
58
+ policy_id: String,
59
+ requires_manual_approval: bool,
60
+ scope: &'static str,
61
+ #[serde(skip_serializing_if = "Option::is_none")]
62
+ token_address: Option<String>,
63
+ updated_at: String,
64
+ }
65
+
66
+ #[derive(Debug, Serialize)]
67
+ #[serde(rename_all = "camelCase")]
68
+ struct RelayAgentKeyPayload {
69
+ agent_key_id: String,
70
+ created_at: String,
71
+ #[serde(skip_serializing_if = "Option::is_none")]
72
+ label: Option<String>,
73
+ #[serde(skip_serializing_if = "Option::is_none")]
74
+ metadata: Option<BTreeMap<String, String>>,
75
+ status: &'static str,
76
+ updated_at: String,
77
+ }
78
+
79
+ #[derive(Debug, Serialize)]
80
+ #[serde(rename_all = "camelCase")]
81
+ struct RelayApprovalRequestPayload {
82
+ agent_key_id: String,
83
+ amount_wei: String,
84
+ approval_request_id: String,
85
+ chain_id: u64,
86
+ destination: String,
87
+ #[serde(skip_serializing_if = "Option::is_none")]
88
+ metadata: Option<BTreeMap<String, String>>,
89
+ #[serde(skip_serializing_if = "Option::is_none")]
90
+ network: Option<String>,
91
+ #[serde(skip_serializing_if = "Option::is_none")]
92
+ reason: Option<String>,
93
+ requested_at: String,
94
+ status: &'static str,
95
+ #[serde(skip_serializing_if = "Option::is_none")]
96
+ token_address: Option<String>,
97
+ transaction_type: String,
98
+ updated_at: String,
99
+ }
100
+
101
+ #[derive(Debug, Serialize)]
102
+ #[serde(rename_all = "camelCase")]
103
+ struct RelayRegisterRequest {
104
+ daemon: RelayDaemonProfilePayload,
105
+ policies: Vec<RelayPolicyPayload>,
106
+ agent_keys: Vec<RelayAgentKeyPayload>,
107
+ approval_requests: Vec<RelayApprovalRequestPayload>,
108
+ }
109
+
110
+ #[derive(Debug, Serialize)]
111
+ #[serde(rename_all = "camelCase")]
112
+ struct RelayPollRequest<'a> {
113
+ daemon_id: &'a str,
114
+ lease_seconds: u32,
115
+ limit: u32,
116
+ }
117
+
118
+ #[derive(Debug, Deserialize)]
119
+ #[serde(rename_all = "camelCase")]
120
+ struct RelayEncryptedPayload {
121
+ algorithm: String,
122
+ ciphertext_base64: String,
123
+ encapsulated_key_base64: String,
124
+ nonce_base64: String,
125
+ }
126
+
127
+ #[derive(Debug, Deserialize)]
128
+ #[serde(rename_all = "camelCase")]
129
+ struct RelayEncryptedUpdateRecord {
130
+ claim_token: Option<String>,
131
+ daemon_id: String,
132
+ payload: RelayEncryptedPayload,
133
+ target_approval_request_id: Option<String>,
134
+ r#type: String,
135
+ update_id: String,
136
+ }
137
+
138
+ #[derive(Debug, Deserialize)]
139
+ #[serde(rename_all = "camelCase")]
140
+ struct RelayPollResponse {
141
+ items: Vec<RelayEncryptedUpdateRecord>,
142
+ }
143
+
144
+ #[derive(Debug, Serialize)]
145
+ #[serde(rename_all = "camelCase")]
146
+ struct RelayFeedbackRequest<'a> {
147
+ claim_token: &'a str,
148
+ daemon_id: &'a str,
149
+ #[serde(skip_serializing_if = "Option::is_none")]
150
+ details: Option<BTreeMap<String, String>>,
151
+ #[serde(skip_serializing_if = "Option::is_none")]
152
+ message: Option<String>,
153
+ status: &'a str,
154
+ update_id: &'a str,
155
+ }
156
+
157
+ #[derive(Debug, Deserialize)]
158
+ #[serde(rename_all = "camelCase")]
159
+ struct RelayManualApprovalUpdatePayload {
160
+ approval_id: String,
161
+ daemon_id: String,
162
+ decision: RelayDecision,
163
+ #[serde(default)]
164
+ note: Option<String>,
165
+ vault_password: String,
166
+ }
167
+
168
+ #[derive(Debug, Deserialize)]
169
+ #[serde(rename_all = "snake_case")]
170
+ enum RelayDecision {
171
+ Approve,
172
+ Reject,
173
+ }
174
+
175
+ struct ProcessedFeedback {
176
+ details: Option<BTreeMap<String, String>>,
177
+ message: Option<String>,
178
+ status: &'static str,
179
+ }
180
+
181
+ pub fn spawn_relay_sync_task<B>(
182
+ daemon: Arc<InMemoryDaemon<B>>,
183
+ signer_backend: &'static str,
184
+ ) -> JoinHandle<()>
185
+ where
186
+ B: VaultSignerBackend + Send + Sync + 'static,
187
+ {
188
+ tokio::spawn(async move {
189
+ if let Err(error) = run_relay_sync_loop(daemon, signer_backend).await {
190
+ eprintln!("==> relay sync terminated: {error:#}");
191
+ }
192
+ })
193
+ }
194
+
195
+ async fn run_relay_sync_loop<B>(
196
+ daemon: Arc<InMemoryDaemon<B>>,
197
+ signer_backend: &'static str,
198
+ ) -> Result<()>
199
+ where
200
+ B: VaultSignerBackend + Send + Sync + 'static,
201
+ {
202
+ let client = Client::builder()
203
+ .user_agent(format!("wlfi-agent-daemon/{}", env!("CARGO_PKG_VERSION")))
204
+ .build()
205
+ .context("failed to initialize relay sync HTTP client")?;
206
+ let registered_at = format_time(OffsetDateTime::now_utc())?;
207
+ let mut interval = time::interval(std::time::Duration::from_secs(1));
208
+ interval.set_missed_tick_behavior(MissedTickBehavior::Skip);
209
+
210
+ loop {
211
+ interval.tick().await;
212
+ if let Err(error) =
213
+ sync_once(&client, daemon.as_ref(), signer_backend, &registered_at).await
214
+ {
215
+ eprintln!("==> relay sync warning: {error:#}");
216
+ }
217
+ }
218
+ }
219
+
220
+ async fn sync_once<B>(
221
+ client: &Client,
222
+ daemon: &InMemoryDaemon<B>,
223
+ signer_backend: &'static str,
224
+ registered_at: &str,
225
+ ) -> Result<()>
226
+ where
227
+ B: VaultSignerBackend + Send + Sync + 'static,
228
+ {
229
+ let snapshot = daemon
230
+ .relay_registration_snapshot()
231
+ .context("failed to snapshot daemon relay registration state")?;
232
+ let Some(relay_url) = snapshot.relay_config.relay_url.clone() else {
233
+ return Ok(());
234
+ };
235
+ let Some(ethereum_address) = snapshot.ethereum_address.clone() else {
236
+ return Ok(());
237
+ };
238
+
239
+ register_snapshot(
240
+ client,
241
+ &relay_url,
242
+ signer_backend,
243
+ registered_at,
244
+ &snapshot,
245
+ &ethereum_address,
246
+ )
247
+ .await
248
+ .context("failed to register daemon snapshot with relay")?;
249
+
250
+ let poll_response = poll_updates(client, &relay_url, &snapshot.relay_config.daemon_id_hex)
251
+ .await
252
+ .context("failed to poll relay for encrypted updates")?;
253
+
254
+ for update in poll_response.items {
255
+ let feedback = process_update(daemon, &snapshot.relay_config.daemon_id_hex, &update).await;
256
+ let claim_token = update
257
+ .claim_token
258
+ .as_deref()
259
+ .context("relay returned update without claim token")?;
260
+ submit_feedback(
261
+ client,
262
+ &relay_url,
263
+ claim_token,
264
+ &snapshot.relay_config.daemon_id_hex,
265
+ &update.update_id,
266
+ feedback,
267
+ )
268
+ .await
269
+ .with_context(|| {
270
+ format!(
271
+ "failed to submit feedback for relay update {}",
272
+ update.update_id
273
+ )
274
+ })?;
275
+ }
276
+
277
+ Ok(())
278
+ }
279
+
280
+ async fn register_snapshot(
281
+ client: &Client,
282
+ relay_url: &str,
283
+ signer_backend: &'static str,
284
+ registered_at: &str,
285
+ snapshot: &RelayRegistrationSnapshot,
286
+ ethereum_address: &str,
287
+ ) -> Result<()> {
288
+ let now = format_time(OffsetDateTime::now_utc())?;
289
+ let request = RelayRegisterRequest {
290
+ daemon: RelayDaemonProfilePayload {
291
+ daemon_id: snapshot.relay_config.daemon_id_hex.clone(),
292
+ daemon_public_key: snapshot.relay_config.daemon_public_key_hex.clone(),
293
+ ethereum_address: ethereum_address.to_string(),
294
+ label: std::env::var("HOSTNAME")
295
+ .ok()
296
+ .filter(|value| !value.trim().is_empty()),
297
+ last_seen_at: now.clone(),
298
+ registered_at: registered_at.to_string(),
299
+ relay_url: snapshot.relay_config.relay_url.clone(),
300
+ signer_backend: signer_backend.to_string(),
301
+ status: "active",
302
+ updated_at: now.clone(),
303
+ version: env!("CARGO_PKG_VERSION").to_string(),
304
+ },
305
+ policies: flatten_policy_records(&snapshot.policies, &now),
306
+ agent_keys: snapshot
307
+ .agent_keys
308
+ .iter()
309
+ .map(|agent_key| RelayAgentKeyPayload {
310
+ agent_key_id: agent_key.id.to_string(),
311
+ created_at: format_time(agent_key.created_at).unwrap_or_else(|_| now.clone()),
312
+ label: None,
313
+ metadata: None,
314
+ status: "active",
315
+ updated_at: now.clone(),
316
+ })
317
+ .collect(),
318
+ approval_requests: snapshot
319
+ .manual_approval_requests
320
+ .iter()
321
+ .map(|request| RelayApprovalRequestPayload {
322
+ agent_key_id: request.agent_key_id.to_string(),
323
+ amount_wei: request.amount_wei.to_string(),
324
+ approval_request_id: request.id.to_string(),
325
+ chain_id: request.chain_id,
326
+ destination: request.recipient.to_string(),
327
+ metadata: approval_metadata(request, &snapshot.relay_private_key_hex),
328
+ network: Some(request.chain_id.to_string()),
329
+ reason: request.rejection_reason.clone(),
330
+ requested_at: format_time(request.created_at).unwrap_or_else(|_| now.clone()),
331
+ status: map_approval_status(request.status),
332
+ token_address: asset_token_address(&request.asset),
333
+ transaction_type: action_name(&request.action).to_string(),
334
+ updated_at: format_time(request.updated_at).unwrap_or_else(|_| now.clone()),
335
+ })
336
+ .collect(),
337
+ };
338
+
339
+ apply_daemon_auth(
340
+ client
341
+ .post(format!(
342
+ "{}/v1/daemon/register",
343
+ relay_url.trim_end_matches('/')
344
+ ))
345
+ .header(CONTENT_TYPE, "application/json")
346
+ .json(&request),
347
+ )
348
+ .send()
349
+ .await
350
+ .context("relay register request failed")?
351
+ .error_for_status()
352
+ .context("relay register request was rejected")?;
353
+
354
+ Ok(())
355
+ }
356
+
357
+ async fn poll_updates(
358
+ client: &Client,
359
+ relay_url: &str,
360
+ daemon_id: &str,
361
+ ) -> Result<RelayPollResponse> {
362
+ let response = apply_daemon_auth(
363
+ client
364
+ .post(format!(
365
+ "{}/v1/daemon/poll-updates",
366
+ relay_url.trim_end_matches('/')
367
+ ))
368
+ .header(CONTENT_TYPE, "application/json")
369
+ .json(&RelayPollRequest {
370
+ daemon_id,
371
+ lease_seconds: DEFAULT_RELAY_LEASE_SECONDS,
372
+ limit: DEFAULT_RELAY_POLL_LIMIT,
373
+ }),
374
+ )
375
+ .send()
376
+ .await
377
+ .context("relay poll request failed")?
378
+ .error_for_status()
379
+ .context("relay poll request was rejected")?;
380
+
381
+ response
382
+ .json::<RelayPollResponse>()
383
+ .await
384
+ .context("failed to deserialize relay poll response")
385
+ }
386
+
387
+ async fn submit_feedback(
388
+ client: &Client,
389
+ relay_url: &str,
390
+ claim_token: &str,
391
+ daemon_id: &str,
392
+ update_id: &str,
393
+ feedback: ProcessedFeedback,
394
+ ) -> Result<()> {
395
+ apply_daemon_auth(
396
+ client
397
+ .post(format!(
398
+ "{}/v1/daemon/submit-feedback",
399
+ relay_url.trim_end_matches('/')
400
+ ))
401
+ .header(CONTENT_TYPE, "application/json")
402
+ .json(&RelayFeedbackRequest {
403
+ claim_token,
404
+ daemon_id,
405
+ details: feedback.details,
406
+ message: feedback.message,
407
+ status: feedback.status,
408
+ update_id,
409
+ }),
410
+ )
411
+ .send()
412
+ .await
413
+ .context("relay feedback request failed")?
414
+ .error_for_status()
415
+ .context("relay feedback request was rejected")?;
416
+
417
+ Ok(())
418
+ }
419
+
420
+ async fn process_update<B>(
421
+ daemon: &InMemoryDaemon<B>,
422
+ expected_daemon_id: &str,
423
+ update: &RelayEncryptedUpdateRecord,
424
+ ) -> ProcessedFeedback
425
+ where
426
+ B: VaultSignerBackend + Send + Sync + 'static,
427
+ {
428
+ if update.daemon_id != expected_daemon_id {
429
+ return ProcessedFeedback {
430
+ details: None,
431
+ message: Some(format!(
432
+ "relay update daemon_id '{}' does not match '{}'",
433
+ update.daemon_id, expected_daemon_id
434
+ )),
435
+ status: "rejected",
436
+ };
437
+ }
438
+
439
+ if update.r#type != "manual_approval_decision" {
440
+ return ProcessedFeedback {
441
+ details: None,
442
+ message: Some(format!("unsupported relay update type '{}'", update.r#type)),
443
+ status: "failed",
444
+ };
445
+ }
446
+
447
+ let plaintext = match daemon.decrypt_relay_envelope(
448
+ &update.payload.algorithm,
449
+ &update.payload.encapsulated_key_base64,
450
+ &update.payload.nonce_base64,
451
+ &update.payload.ciphertext_base64,
452
+ ) {
453
+ Ok(value) => value,
454
+ Err(error) => {
455
+ return ProcessedFeedback {
456
+ details: None,
457
+ message: Some(error.to_string()),
458
+ status: "failed",
459
+ };
460
+ }
461
+ };
462
+
463
+ let payload = match serde_json::from_slice::<RelayManualApprovalUpdatePayload>(&plaintext) {
464
+ Ok(value) => value,
465
+ Err(error) => {
466
+ return ProcessedFeedback {
467
+ details: None,
468
+ message: Some(format!("invalid relay update payload: {error}")),
469
+ status: "failed",
470
+ };
471
+ }
472
+ };
473
+
474
+ if payload.daemon_id != expected_daemon_id {
475
+ return ProcessedFeedback {
476
+ details: None,
477
+ message: Some(format!(
478
+ "payload daemon_id '{}' does not match '{}'",
479
+ payload.daemon_id, expected_daemon_id
480
+ )),
481
+ status: "rejected",
482
+ };
483
+ }
484
+
485
+ if let Some(target_approval_request_id) = &update.target_approval_request_id {
486
+ if target_approval_request_id != &payload.approval_id {
487
+ return ProcessedFeedback {
488
+ details: None,
489
+ message: Some(format!(
490
+ "payload approval_id '{}' does not match relay target '{}'",
491
+ payload.approval_id, target_approval_request_id
492
+ )),
493
+ status: "rejected",
494
+ };
495
+ }
496
+ }
497
+
498
+ let approval_request_id = match Uuid::parse_str(&payload.approval_id) {
499
+ Ok(value) => value,
500
+ Err(error) => {
501
+ return ProcessedFeedback {
502
+ details: None,
503
+ message: Some(format!("approval_id is not a UUID: {error}")),
504
+ status: "failed",
505
+ };
506
+ }
507
+ };
508
+
509
+ let decision = match payload.decision {
510
+ RelayDecision::Approve => ManualApprovalDecision::Approve,
511
+ RelayDecision::Reject => ManualApprovalDecision::Reject,
512
+ };
513
+ let rejection_reason = if matches!(decision, ManualApprovalDecision::Reject) {
514
+ payload
515
+ .note
516
+ .clone()
517
+ .filter(|value| !value.trim().is_empty())
518
+ } else {
519
+ None
520
+ };
521
+
522
+ match daemon
523
+ .apply_relay_manual_approval_decision(
524
+ &payload.vault_password,
525
+ approval_request_id,
526
+ decision,
527
+ rejection_reason.clone(),
528
+ )
529
+ .await
530
+ {
531
+ Ok(request) => {
532
+ let mut details = BTreeMap::new();
533
+ details.insert("approvalRequestId".to_string(), request.id.to_string());
534
+ details.insert(
535
+ "manualApprovalStatus".to_string(),
536
+ map_approval_status(request.status).to_string(),
537
+ );
538
+ if let Some(note) = payload.note.filter(|value| !value.trim().is_empty()) {
539
+ details.insert("note".to_string(), note);
540
+ }
541
+ ProcessedFeedback {
542
+ details: Some(details),
543
+ message: Some(format!(
544
+ "manual approval {} applied to {}",
545
+ match decision {
546
+ ManualApprovalDecision::Approve => "approve",
547
+ ManualApprovalDecision::Reject => "reject",
548
+ },
549
+ request.id
550
+ )),
551
+ status: "applied",
552
+ }
553
+ }
554
+ Err(error) => ProcessedFeedback {
555
+ details: None,
556
+ message: Some(error.to_string()),
557
+ status: if matches!(decision, ManualApprovalDecision::Reject) {
558
+ "rejected"
559
+ } else {
560
+ "failed"
561
+ },
562
+ },
563
+ }
564
+ }
565
+
566
+ fn apply_daemon_auth(request: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
567
+ let token = resolve_relay_daemon_token();
568
+ match token {
569
+ Some(token) => request.header("x-relay-daemon-token", token),
570
+ None => request,
571
+ }
572
+ }
573
+
574
+ fn resolve_relay_daemon_token() -> Option<String> {
575
+ resolve_relay_daemon_token_with(
576
+ |name| std::env::var(name).ok(),
577
+ |path| std::fs::read_to_string(path),
578
+ )
579
+ }
580
+
581
+ fn resolve_relay_daemon_token_with<Env, ReadFile>(
582
+ read_env: Env,
583
+ read_file: ReadFile,
584
+ ) -> Option<String>
585
+ where
586
+ Env: Fn(&str) -> Option<String>,
587
+ ReadFile: Fn(&Path) -> std::io::Result<String>,
588
+ {
589
+ if let Some(token) = read_env(RELAY_DAEMON_TOKEN_ENV).and_then(|value| normalize_secret(&value))
590
+ {
591
+ return Some(token);
592
+ }
593
+
594
+ let token_path = read_env(RELAY_DAEMON_TOKEN_FILE_ENV)?;
595
+ let trimmed_path = token_path.trim();
596
+ if trimmed_path.is_empty() {
597
+ return None;
598
+ }
599
+
600
+ read_file(Path::new(trimmed_path))
601
+ .ok()
602
+ .and_then(|value| normalize_secret(&value))
603
+ }
604
+
605
+ fn normalize_secret(value: &str) -> Option<String> {
606
+ let trimmed = value.trim();
607
+ (!trimmed.is_empty()).then(|| trimmed.to_string())
608
+ }
609
+
610
+ fn flatten_policy_records(
611
+ policies: &[SpendingPolicy],
612
+ updated_at: &str,
613
+ ) -> Vec<RelayPolicyPayload> {
614
+ let mut records = Vec::new();
615
+
616
+ for policy in policies.iter().filter(|policy| policy.enabled) {
617
+ let recipients = match &policy.recipients {
618
+ EntityScope::All => vec![(ZERO_ADDRESS.to_string(), "default")],
619
+ EntityScope::Set(values) => values
620
+ .iter()
621
+ .cloned()
622
+ .map(|value| (value.to_string(), "override"))
623
+ .collect::<Vec<_>>(),
624
+ };
625
+ let assets = match &policy.assets {
626
+ EntityScope::All => vec![None],
627
+ EntityScope::Set(values) => values.iter().cloned().map(Some).collect::<Vec<_>>(),
628
+ };
629
+ let networks = match &policy.networks {
630
+ EntityScope::All => vec![None],
631
+ EntityScope::Set(values) => values.iter().copied().map(Some).collect::<Vec<_>>(),
632
+ };
633
+
634
+ for (destination, scope) in &recipients {
635
+ for asset in &assets {
636
+ for chain_id in &networks {
637
+ records.push(RelayPolicyPayload {
638
+ action: policy_name(policy).to_string(),
639
+ amount_max_wei: Some(policy.max_amount_wei.to_string()),
640
+ amount_min_wei: policy.min_amount_wei.map(|value| value.to_string()),
641
+ chain_id: *chain_id,
642
+ destination: destination.clone(),
643
+ metadata: Some(policy_metadata(policy, asset.as_ref(), *chain_id, scope)),
644
+ policy_id: policy.id.to_string(),
645
+ requires_manual_approval: matches!(
646
+ policy.policy_type,
647
+ PolicyType::ManualApproval
648
+ ),
649
+ scope,
650
+ token_address: asset.as_ref().and_then(asset_token_address),
651
+ updated_at: updated_at.to_string(),
652
+ });
653
+ }
654
+ }
655
+ }
656
+ }
657
+
658
+ records
659
+ }
660
+
661
+ fn policy_metadata(
662
+ policy: &SpendingPolicy,
663
+ asset: Option<&AssetId>,
664
+ chain_id: Option<u64>,
665
+ scope: &str,
666
+ ) -> BTreeMap<String, String> {
667
+ let mut metadata = BTreeMap::new();
668
+ metadata.insert("policyType".to_string(), policy_name(policy).to_string());
669
+ metadata.insert("scope".to_string(), scope.to_string());
670
+ metadata.insert(
671
+ "recipientScope".to_string(),
672
+ match policy.recipients {
673
+ EntityScope::All => "all".to_string(),
674
+ EntityScope::Set(_) => "set".to_string(),
675
+ },
676
+ );
677
+ metadata.insert(
678
+ "assetScope".to_string(),
679
+ match policy.assets {
680
+ EntityScope::All => "all".to_string(),
681
+ EntityScope::Set(_) => "set".to_string(),
682
+ },
683
+ );
684
+ metadata.insert(
685
+ "networkScope".to_string(),
686
+ match policy.networks {
687
+ EntityScope::All => "all".to_string(),
688
+ EntityScope::Set(_) => "set".to_string(),
689
+ },
690
+ );
691
+ if let Some(asset) = asset {
692
+ metadata.insert("asset".to_string(), asset.to_string());
693
+ }
694
+ if let Some(chain_id) = chain_id {
695
+ metadata.insert("chainId".to_string(), chain_id.to_string());
696
+ }
697
+ metadata
698
+ }
699
+
700
+ fn approval_metadata(
701
+ request: &vault_domain::ManualApprovalRequest,
702
+ relay_private_key_hex: &str,
703
+ ) -> Option<BTreeMap<String, String>> {
704
+ let mut metadata = BTreeMap::new();
705
+ metadata.insert(
706
+ "triggeredPolicyIds".to_string(),
707
+ request
708
+ .triggered_by_policy_ids
709
+ .iter()
710
+ .map(Uuid::to_string)
711
+ .collect::<Vec<_>>()
712
+ .join(","),
713
+ );
714
+ metadata.insert("asset".to_string(), request.asset.to_string());
715
+ if matches!(request.status, ManualApprovalStatus::Pending) {
716
+ if let Ok(token) = manual_approval_capability_token(relay_private_key_hex, request.id) {
717
+ metadata.insert("approvalCapabilityToken".to_string(), token.clone());
718
+ if let Ok(hash) = manual_approval_capability_hash(&token) {
719
+ metadata.insert("approvalCapabilityHash".to_string(), hash);
720
+ }
721
+ }
722
+ }
723
+ Some(metadata)
724
+ }
725
+
726
+ fn policy_name(policy: &SpendingPolicy) -> &'static str {
727
+ match policy.policy_type {
728
+ PolicyType::DailyMaxSpending => "daily_max_spending",
729
+ PolicyType::DailyMaxTxCount => "daily_max_tx_count",
730
+ PolicyType::WeeklyMaxSpending => "weekly_max_spending",
731
+ PolicyType::PerTxMaxSpending => "per_tx_max_spending",
732
+ PolicyType::PerTxMaxFeePerGas => "per_tx_max_fee_per_gas",
733
+ PolicyType::PerTxMaxPriorityFeePerGas => "per_tx_max_priority_fee_per_gas",
734
+ PolicyType::PerTxMaxCalldataBytes => "per_tx_max_calldata_bytes",
735
+ PolicyType::PerChainMaxGasSpend => "per_chain_max_gas_spend",
736
+ PolicyType::ManualApproval => "manual_approval",
737
+ }
738
+ }
739
+
740
+ fn action_name(action: &AgentAction) -> &'static str {
741
+ match action {
742
+ AgentAction::Approve { .. } => "approve",
743
+ AgentAction::Transfer { .. } => "transfer",
744
+ AgentAction::TransferNative { .. } => "transfer_native",
745
+ AgentAction::Permit2Permit { .. } => "permit2_permit",
746
+ AgentAction::Eip3009TransferWithAuthorization { .. } => {
747
+ "eip3009_transfer_with_authorization"
748
+ }
749
+ AgentAction::Eip3009ReceiveWithAuthorization { .. } => "eip3009_receive_with_authorization",
750
+ AgentAction::BroadcastTx { .. } => "broadcast_tx",
751
+ }
752
+ }
753
+
754
+ fn asset_token_address(asset: &AssetId) -> Option<String> {
755
+ match asset {
756
+ AssetId::NativeEth => None,
757
+ AssetId::Erc20(token) => Some(token.to_string()),
758
+ }
759
+ }
760
+
761
+ fn map_approval_status(status: ManualApprovalStatus) -> &'static str {
762
+ match status {
763
+ ManualApprovalStatus::Pending => "pending",
764
+ ManualApprovalStatus::Approved => "approved",
765
+ ManualApprovalStatus::Rejected => "rejected",
766
+ ManualApprovalStatus::Completed => "completed",
767
+ }
768
+ }
769
+
770
+ fn format_time(value: OffsetDateTime) -> Result<String> {
771
+ value
772
+ .format(&Rfc3339)
773
+ .context("failed to format timestamp as RFC3339")
774
+ }
775
+
776
+ #[cfg(test)]
777
+ mod tests {
778
+ use super::{approval_metadata, resolve_relay_daemon_token_with};
779
+ use std::collections::BTreeMap;
780
+ use std::fs;
781
+ use std::path::Path;
782
+ use std::time::{SystemTime, UNIX_EPOCH};
783
+ use time::OffsetDateTime;
784
+ use uuid::Uuid;
785
+ use vault_domain::{AgentAction, AssetId, ManualApprovalRequest, ManualApprovalStatus};
786
+
787
+ #[test]
788
+ fn approval_metadata_includes_admin_reissue_token_and_public_hash_for_pending_requests() {
789
+ let request = ManualApprovalRequest {
790
+ id: Uuid::parse_str("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa").expect("uuid"),
791
+ agent_key_id: Uuid::nil(),
792
+ vault_key_id: Uuid::nil(),
793
+ request_payload_hash_hex: "11".repeat(32),
794
+ action: AgentAction::TransferNative {
795
+ chain_id: 56,
796
+ to: "0x2222222222222222222222222222222222222222"
797
+ .parse()
798
+ .expect("address"),
799
+ amount_wei: 1,
800
+ },
801
+ chain_id: 56,
802
+ asset: AssetId::NativeEth,
803
+ recipient: "0x2222222222222222222222222222222222222222"
804
+ .parse()
805
+ .expect("address"),
806
+ amount_wei: 1,
807
+ created_at: OffsetDateTime::UNIX_EPOCH,
808
+ updated_at: OffsetDateTime::UNIX_EPOCH,
809
+ status: ManualApprovalStatus::Pending,
810
+ triggered_by_policy_ids: vec![Uuid::nil()],
811
+ completed_at: None,
812
+ rejection_reason: None,
813
+ };
814
+
815
+ let metadata = approval_metadata(&request, &"11".repeat(32)).expect("metadata");
816
+ let token = metadata
817
+ .get("approvalCapabilityToken")
818
+ .expect("capability token");
819
+ let hash = metadata
820
+ .get("approvalCapabilityHash")
821
+ .expect("capability hash");
822
+
823
+ assert_eq!(token.len(), 64);
824
+ assert_eq!(hash.len(), 64);
825
+ }
826
+
827
+ fn temp_path(prefix: &str) -> std::path::PathBuf {
828
+ std::env::temp_dir().join(format!(
829
+ "{prefix}-{}-{}",
830
+ std::process::id(),
831
+ SystemTime::now()
832
+ .duration_since(UNIX_EPOCH)
833
+ .expect("system time before unix epoch")
834
+ .as_nanos()
835
+ ))
836
+ }
837
+
838
+ #[test]
839
+ fn resolve_relay_daemon_token_prefers_explicit_env_value() {
840
+ let env = BTreeMap::from([
841
+ (
842
+ "WLFI_RELAY_DAEMON_TOKEN".to_string(),
843
+ " env-token ".to_string(),
844
+ ),
845
+ (
846
+ "WLFI_RELAY_DAEMON_TOKEN_FILE".to_string(),
847
+ "/should/not/be/read".to_string(),
848
+ ),
849
+ ]);
850
+
851
+ let token = resolve_relay_daemon_token_with(
852
+ |name| env.get(name).cloned(),
853
+ |_path| -> std::io::Result<String> { panic!("file token should not be read") },
854
+ );
855
+
856
+ assert_eq!(token.as_deref(), Some("env-token"));
857
+ }
858
+
859
+ #[test]
860
+ fn resolve_relay_daemon_token_reads_root_only_file_path_when_env_token_is_absent() {
861
+ let token_path = temp_path("wlfi-relay-daemon-token");
862
+ fs::write(&token_path, " file-token \n").expect("write relay token file");
863
+ let env = BTreeMap::from([(
864
+ "WLFI_RELAY_DAEMON_TOKEN_FILE".to_string(),
865
+ token_path.display().to_string(),
866
+ )]);
867
+
868
+ let token = resolve_relay_daemon_token_with(
869
+ |name| env.get(name).cloned(),
870
+ |path| fs::read_to_string(path),
871
+ );
872
+
873
+ assert_eq!(token.as_deref(), Some("file-token"));
874
+ let _ = fs::remove_file(token_path);
875
+ }
876
+
877
+ #[test]
878
+ fn resolve_relay_daemon_token_ignores_blank_or_unreadable_sources() {
879
+ let env = BTreeMap::from([
880
+ ("WLFI_RELAY_DAEMON_TOKEN".to_string(), " ".to_string()),
881
+ (
882
+ "WLFI_RELAY_DAEMON_TOKEN_FILE".to_string(),
883
+ "/missing/token-file".to_string(),
884
+ ),
885
+ ]);
886
+
887
+ let token = resolve_relay_daemon_token_with(
888
+ |name| env.get(name).cloned(),
889
+ |_path: &Path| Err(std::io::Error::from(std::io::ErrorKind::NotFound)),
890
+ );
891
+
892
+ assert_eq!(token, None);
893
+ }
894
+ }