ethagent 1.1.2 → 2.0.0

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 (268) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +126 -30
  3. package/package.json +7 -2
  4. package/src/app/FirstRun.tsx +190 -146
  5. package/src/app/FirstRunTimeline.tsx +47 -0
  6. package/src/app/input/AppInputProvider.tsx +1 -1
  7. package/src/app/keybindings/KeybindingProvider.tsx +1 -1
  8. package/src/chat/ChatBottomPane.tsx +0 -1
  9. package/src/chat/ChatInput.tsx +6 -6
  10. package/src/chat/ChatScreen.tsx +35 -15
  11. package/src/chat/ContextLimitView.tsx +4 -4
  12. package/src/chat/ContinuityEditReviewView.tsx +10 -22
  13. package/src/chat/CopyPicker.tsx +0 -1
  14. package/src/chat/MessageList.tsx +62 -45
  15. package/src/chat/PermissionPrompt.tsx +13 -9
  16. package/src/chat/PlanApprovalView.tsx +3 -3
  17. package/src/chat/ResumeView.tsx +1 -4
  18. package/src/chat/RewindView.tsx +2 -2
  19. package/src/chat/chatInputState.ts +1 -1
  20. package/src/chat/chatScreenUtils.ts +22 -11
  21. package/src/chat/chatSessionState.ts +2 -2
  22. package/src/chat/chatTurnOrchestrator.ts +16 -81
  23. package/src/chat/commands.ts +1 -1
  24. package/src/chat/textCursor.ts +1 -1
  25. package/src/chat/transcriptViewport.ts +2 -7
  26. package/src/cli/ResetConfirmView.tsx +1 -1
  27. package/src/cli/main.tsx +9 -3
  28. package/src/cli/preview.tsx +0 -5
  29. package/src/cli/updateNotice.ts +4 -2
  30. package/src/identity/continuity/editor.ts +7 -107
  31. package/src/identity/continuity/envelope.ts +1048 -40
  32. package/src/identity/continuity/history.ts +4 -4
  33. package/src/identity/continuity/localBackup.ts +249 -0
  34. package/src/identity/continuity/privateEdit/apply.ts +170 -0
  35. package/src/identity/continuity/privateEdit/diff.ts +82 -0
  36. package/src/identity/continuity/privateEdit/files.ts +23 -0
  37. package/src/identity/continuity/privateEdit/types.ts +28 -0
  38. package/src/identity/continuity/privateEdit.ts +10 -298
  39. package/src/identity/continuity/publicSkills.ts +8 -9
  40. package/src/identity/continuity/snapshots.ts +17 -6
  41. package/src/identity/continuity/storage/defaults.ts +111 -0
  42. package/src/identity/continuity/storage/files.ts +72 -0
  43. package/src/identity/continuity/storage/markdown.ts +81 -0
  44. package/src/identity/continuity/storage/paths.ts +24 -0
  45. package/src/identity/continuity/storage/scaffold.ts +124 -0
  46. package/src/identity/continuity/storage/status.ts +86 -0
  47. package/src/identity/continuity/storage/types.ts +27 -0
  48. package/src/identity/continuity/storage.ts +32 -507
  49. package/src/identity/continuity/zipWriter.ts +95 -0
  50. package/src/identity/crypto/backupEnvelope.ts +14 -247
  51. package/src/identity/crypto/eth.ts +7 -7
  52. package/src/identity/ens/agentRecords.ts +96 -0
  53. package/src/identity/ens/ensAutomation/contracts.ts +38 -0
  54. package/src/identity/ens/ensAutomation/delete.ts +80 -0
  55. package/src/identity/ens/ensAutomation/names.ts +14 -0
  56. package/src/identity/ens/ensAutomation/operators.ts +29 -0
  57. package/src/identity/ens/ensAutomation/read.ts +114 -0
  58. package/src/identity/ens/ensAutomation/root.ts +63 -0
  59. package/src/identity/ens/ensAutomation/setup.ts +284 -0
  60. package/src/identity/ens/ensAutomation/transactions.ts +107 -0
  61. package/src/identity/ens/ensAutomation/types.ts +126 -0
  62. package/src/identity/ens/ensAutomation.ts +29 -0
  63. package/src/identity/ens/ensLookup/client.ts +43 -0
  64. package/src/identity/ens/ensLookup/constants.ts +26 -0
  65. package/src/identity/ens/ensLookup/discovery.ts +70 -0
  66. package/src/identity/ens/ensLookup/names.ts +34 -0
  67. package/src/identity/ens/ensLookup/records.ts +45 -0
  68. package/src/identity/ens/ensLookup/resolve.ts +75 -0
  69. package/src/identity/ens/ensLookup/tokenReference.ts +17 -0
  70. package/src/identity/ens/ensLookup/types.ts +38 -0
  71. package/src/identity/ens/ensLookup/validation.ts +72 -0
  72. package/src/identity/ens/ensLookup.ts +19 -0
  73. package/src/identity/ens/ensRegistration.ts +199 -0
  74. package/src/identity/ens/resolverDelegation.ts +48 -0
  75. package/src/identity/hub/IdentityHub.tsx +13 -817
  76. package/src/identity/hub/OperationalRoutes.tsx +370 -0
  77. package/src/identity/hub/Routes.tsx +361 -0
  78. package/src/identity/hub/advancedEnsValidation.ts +45 -0
  79. package/src/identity/hub/{screens → components}/DetailsScreen.tsx +14 -8
  80. package/src/identity/hub/{screens → components}/ErrorScreen.tsx +15 -5
  81. package/src/identity/hub/components/FlowTimeline.tsx +27 -0
  82. package/src/identity/hub/components/IdentitySummary.tsx +190 -0
  83. package/src/identity/hub/components/MenuScreen.tsx +237 -0
  84. package/src/identity/hub/{screens → components}/NetworkScreen.tsx +3 -3
  85. package/src/identity/hub/{screens/RebackupStorageScreen.tsx → components/PinataJwtInput.tsx} +21 -18
  86. package/src/identity/hub/components/UnlinkedIdentityScreen.tsx +76 -0
  87. package/src/identity/hub/{screens → components}/WalletApprovalScreen.tsx +9 -8
  88. package/src/identity/hub/components/menuFlagsFromReconciliation.ts +68 -0
  89. package/src/identity/hub/effects/create.ts +310 -0
  90. package/src/identity/hub/effects/ens/flows.ts +218 -0
  91. package/src/identity/hub/effects/ens/index.ts +11 -0
  92. package/src/identity/hub/effects/ens/transactions.ts +239 -0
  93. package/src/identity/hub/effects/index.ts +74 -0
  94. package/src/identity/hub/effects/profile/profileState.ts +173 -0
  95. package/src/identity/hub/effects/publicProfile/index.ts +5 -0
  96. package/src/identity/hub/effects/publicProfile/runPublicProfileSave.ts +646 -0
  97. package/src/identity/hub/effects/rebackup/index.ts +7 -0
  98. package/src/identity/hub/effects/rebackup/operatorVault.ts +378 -0
  99. package/src/identity/hub/effects/rebackup/runRebackup.ts +451 -0
  100. package/src/identity/hub/effects/receipts.ts +46 -0
  101. package/src/identity/hub/effects/restore/apply.ts +112 -0
  102. package/src/identity/hub/effects/restore/auth.ts +159 -0
  103. package/src/identity/hub/effects/restore/discover.ts +86 -0
  104. package/src/identity/hub/effects/restore/envelopes.ts +21 -0
  105. package/src/identity/hub/effects/restore/fetch.ts +25 -0
  106. package/src/identity/hub/effects/restore/index.ts +22 -0
  107. package/src/identity/hub/effects/restore/recovery.ts +135 -0
  108. package/src/identity/hub/effects/restore/resolve.ts +102 -0
  109. package/src/identity/hub/effects/restore/restoreEffects.ts +22 -0
  110. package/src/identity/hub/effects/restore/shared.ts +91 -0
  111. package/src/identity/hub/effects/restoreAdmin.ts +93 -0
  112. package/src/identity/hub/effects/shared/profilePrep.ts +139 -0
  113. package/src/identity/hub/effects/shared/snapshot.ts +336 -0
  114. package/src/identity/hub/effects/shared/sync.ts +190 -0
  115. package/src/identity/hub/effects/token-transfer/index.ts +6 -0
  116. package/src/identity/hub/effects/token-transfer/progress.ts +59 -0
  117. package/src/identity/hub/effects/token-transfer/runTokenTransfer.ts +299 -0
  118. package/src/identity/hub/effects/types.ts +53 -0
  119. package/src/identity/hub/effects/vault/preflight.ts +50 -0
  120. package/src/identity/hub/flows/continuity/ContinuityDashboardScreen.tsx +170 -0
  121. package/src/identity/hub/flows/continuity/RebackupStorageScreen.tsx +28 -0
  122. package/src/identity/hub/{screens → flows/continuity}/RecoveryConfirmScreen.tsx +28 -19
  123. package/src/identity/hub/flows/continuity/SavePromptScreen.tsx +49 -0
  124. package/src/identity/hub/{screens → flows/create}/CreateFlow.tsx +61 -62
  125. package/src/identity/hub/flows/custody/CustodyEditFlow.tsx +347 -0
  126. package/src/identity/hub/flows/custody/custodyEffects.ts +321 -0
  127. package/src/identity/hub/flows/custody/custodyFlowActions.ts +236 -0
  128. package/src/identity/hub/flows/custody/custodyFlowEffects.ts +163 -0
  129. package/src/identity/hub/flows/custody/custodyFlowHelpers.ts +25 -0
  130. package/src/identity/hub/flows/custody/custodyFlowRoutes.tsx +239 -0
  131. package/src/identity/hub/flows/custody/custodyFlowTypes.ts +45 -0
  132. package/src/identity/hub/flows/custody/useCustodyFlow.tsx +25 -0
  133. package/src/identity/hub/flows/ens/EnsEditAdvancedScreens.tsx +336 -0
  134. package/src/identity/hub/flows/ens/EnsEditFlow.tsx +397 -0
  135. package/src/identity/hub/flows/ens/EnsEditMaintenanceScreens.tsx +332 -0
  136. package/src/identity/hub/flows/ens/EnsEditReviewScreens.tsx +471 -0
  137. package/src/identity/hub/flows/ens/EnsEditRunners.tsx +198 -0
  138. package/src/identity/hub/flows/ens/EnsEditShared.tsx +162 -0
  139. package/src/identity/hub/flows/ens/EnsEditSimpleScreens.tsx +518 -0
  140. package/src/identity/hub/flows/ens/IdentityHubEnsFlow.tsx +299 -0
  141. package/src/identity/hub/flows/ens/OperatorWalletsScreen.tsx +398 -0
  142. package/src/identity/hub/flows/ens/ensEditCopy.ts +117 -0
  143. package/src/identity/hub/flows/ens/ensEditTypes.ts +91 -0
  144. package/src/identity/hub/flows/profile/EditProfileFlow.tsx +271 -0
  145. package/src/identity/hub/flows/restore/RestoreFlow.tsx +324 -0
  146. package/src/identity/hub/flows/restore/useRestoreFlowEffects.ts +77 -0
  147. package/src/identity/hub/{screens → flows/settings}/StorageCredentialScreen.tsx +23 -44
  148. package/src/identity/hub/flows/token-transfer/IdentityHubTokenTransferFlow.tsx +162 -0
  149. package/src/identity/hub/flows/token-transfer/TokenTransferScreens.tsx +256 -0
  150. package/src/identity/hub/identityHubReducer.ts +164 -99
  151. package/src/identity/hub/model/continuity.ts +94 -0
  152. package/src/identity/hub/model/copy.ts +35 -0
  153. package/src/identity/hub/model/custody.ts +54 -0
  154. package/src/identity/hub/model/ens.ts +49 -0
  155. package/src/identity/hub/model/errors.ts +140 -0
  156. package/src/identity/hub/model/format.ts +15 -0
  157. package/src/identity/hub/model/identity.ts +94 -0
  158. package/src/identity/hub/model/network.ts +32 -0
  159. package/src/identity/hub/model/transfer.ts +57 -0
  160. package/src/identity/hub/operatorWallets.ts +131 -0
  161. package/src/identity/hub/reconciliation/agentReconciliation/hook.ts +46 -0
  162. package/src/identity/hub/reconciliation/agentReconciliation/ownership.ts +129 -0
  163. package/src/identity/hub/reconciliation/agentReconciliation/run.ts +302 -0
  164. package/src/identity/hub/reconciliation/agentReconciliation/types.ts +17 -0
  165. package/src/identity/hub/reconciliation/index.ts +21 -0
  166. package/src/identity/hub/reconciliation/useAgentReconciliation.ts +10 -0
  167. package/src/identity/hub/reconciliation/walletSetup.ts +220 -0
  168. package/src/identity/hub/txGuard.ts +51 -0
  169. package/src/identity/hub/types.ts +17 -0
  170. package/src/identity/hub/useIdentityHubContinuity.ts +136 -0
  171. package/src/identity/hub/useIdentityHubController.ts +396 -0
  172. package/src/identity/hub/useIdentityHubSideEffects.ts +309 -0
  173. package/src/identity/hub/utils.ts +79 -0
  174. package/src/identity/identityCompat.ts +34 -0
  175. package/src/identity/profile/agentIcon.ts +61 -0
  176. package/src/identity/profile/imagePicker.ts +12 -12
  177. package/src/identity/registry/erc8004/abi.ts +14 -0
  178. package/src/identity/registry/erc8004/chains.ts +150 -0
  179. package/src/identity/registry/erc8004/client.ts +11 -0
  180. package/src/identity/registry/erc8004/discovery.ts +511 -0
  181. package/src/identity/registry/erc8004/metadata.ts +335 -0
  182. package/src/identity/registry/erc8004/ownership.ts +121 -0
  183. package/src/identity/registry/erc8004/preflight.ts +123 -0
  184. package/src/identity/registry/erc8004/transactions.ts +77 -0
  185. package/src/identity/registry/erc8004/types.ts +88 -0
  186. package/src/identity/registry/erc8004/uri.ts +59 -0
  187. package/src/identity/registry/erc8004/utils.ts +58 -0
  188. package/src/identity/registry/erc8004.ts +53 -1106
  189. package/src/identity/registry/fieldParsers.ts +28 -0
  190. package/src/identity/registry/operatorVault/bytecode.ts +98 -0
  191. package/src/identity/registry/operatorVault/constants.ts +38 -0
  192. package/src/identity/registry/operatorVault/read.ts +246 -0
  193. package/src/identity/registry/operatorVault/transactions.ts +81 -0
  194. package/src/identity/registry/operatorVault.ts +44 -0
  195. package/src/identity/storage/ipfs.ts +26 -24
  196. package/src/identity/wallet/browserWallet/gas.ts +41 -0
  197. package/src/identity/wallet/browserWallet/html.ts +106 -0
  198. package/src/identity/wallet/browserWallet/http.ts +28 -0
  199. package/src/identity/wallet/browserWallet/requestServer.ts +106 -0
  200. package/src/identity/wallet/browserWallet/requests.ts +191 -0
  201. package/src/identity/wallet/browserWallet/session.ts +325 -0
  202. package/src/identity/wallet/browserWallet/types.ts +192 -0
  203. package/src/identity/wallet/browserWallet/validation.ts +74 -0
  204. package/src/identity/wallet/browserWallet.ts +30 -393
  205. package/src/identity/wallet/page/constants.ts +5 -0
  206. package/src/identity/wallet/page/controller.ts +251 -0
  207. package/src/identity/wallet/page/copy.ts +340 -0
  208. package/src/identity/wallet/page/grainient.ts +278 -0
  209. package/src/identity/wallet/page/html.ts +28 -0
  210. package/src/identity/wallet/page/markup.ts +50 -0
  211. package/src/identity/wallet/page/state.ts +9 -0
  212. package/src/identity/wallet/page/styles/base.ts +259 -0
  213. package/src/identity/wallet/page/styles/components.ts +262 -0
  214. package/src/identity/wallet/page/styles/index.ts +5 -0
  215. package/src/identity/wallet/page/styles/responsive.ts +247 -0
  216. package/src/identity/wallet/page/types.ts +47 -0
  217. package/src/identity/wallet/page/view.ts +535 -0
  218. package/src/identity/wallet/page/walletProvider.ts +70 -0
  219. package/src/identity/wallet/page.tsx +38 -0
  220. package/src/identity/wallet/walletPurposeCompat.ts +27 -0
  221. package/src/mcp/manager.ts +0 -1
  222. package/src/models/ModelPicker.tsx +36 -30
  223. package/src/models/catalog.ts +5 -2
  224. package/src/models/huggingface.ts +9 -9
  225. package/src/models/llamacpp.ts +13 -13
  226. package/src/models/modelDisplay.ts +75 -0
  227. package/src/models/modelPickerOptions.ts +16 -3
  228. package/src/models/modelRecommendation.ts +0 -1
  229. package/src/providers/errors.ts +16 -0
  230. package/src/providers/gemini.ts +252 -39
  231. package/src/providers/registry.ts +2 -2
  232. package/src/providers/retry.ts +1 -1
  233. package/src/runtime/sessionMode.ts +1 -1
  234. package/src/runtime/systemPrompt.ts +2 -0
  235. package/src/runtime/toolExecution.ts +18 -22
  236. package/src/runtime/toolIntent.ts +0 -20
  237. package/src/runtime/turn.ts +0 -92
  238. package/src/storage/atomicWrite.ts +4 -1
  239. package/src/storage/config.ts +181 -5
  240. package/src/storage/identity.ts +9 -3
  241. package/src/storage/secrets.ts +2 -2
  242. package/src/tools/bashSafety.ts +8 -0
  243. package/src/tools/changeDirectoryTool.ts +1 -1
  244. package/src/tools/deleteFileTool.ts +4 -4
  245. package/src/tools/editTool.ts +4 -4
  246. package/src/tools/editUtils.ts +5 -5
  247. package/src/tools/privateContinuityEditTool.ts +4 -5
  248. package/src/tools/privateContinuityReadTool.ts +1 -2
  249. package/src/tools/registry.ts +30 -0
  250. package/src/tools/writeFileTool.ts +5 -5
  251. package/src/ui/BrandSplash.tsx +20 -85
  252. package/src/ui/ProgressBar.tsx +3 -5
  253. package/src/ui/Select.tsx +20 -8
  254. package/src/ui/Spinner.tsx +38 -3
  255. package/src/ui/Surface.tsx +2 -2
  256. package/src/ui/TextInput.tsx +63 -20
  257. package/src/ui/theme.ts +7 -34
  258. package/src/utils/openExternal.ts +21 -0
  259. package/src/utils/withRetry.ts +47 -3
  260. package/src/identity/hub/identityHubEffects.ts +0 -937
  261. package/src/identity/hub/identityHubModel.ts +0 -371
  262. package/src/identity/hub/screens/ContinuityDashboardScreen.tsx +0 -156
  263. package/src/identity/hub/screens/EditProfileFlow.tsx +0 -146
  264. package/src/identity/hub/screens/IdentitySummary.tsx +0 -106
  265. package/src/identity/hub/screens/MenuScreen.tsx +0 -117
  266. package/src/identity/hub/screens/RestoreFlow.tsx +0 -206
  267. package/src/identity/wallet/wallet-page/wallet.html +0 -1202
  268. /package/src/identity/hub/{screens → components}/BusyScreen.tsx +0 -0
@@ -1,6 +1,7 @@
1
1
  import crypto from 'node:crypto'
2
2
  import { ml_kem1024 } from '@noble/post-quantum/ml-kem.js'
3
3
  import { recoverAddressFromSignature, toChecksumAddress } from '../crypto/eth.js'
4
+ import type { TransferSnapshotMetadata } from '../../storage/config.js'
4
5
 
5
6
  export const CONTINUITY_SNAPSHOT_ENVELOPE_VERSION = 'ethagent-continuity-snapshot-v1'
6
7
 
@@ -9,7 +10,7 @@ export type ContinuityFiles = {
9
10
  'MEMORY.md': string
10
11
  }
11
12
 
12
- export type ContinuityTranscriptSummary = {
13
+ type ContinuityTranscriptSummary = {
13
14
  sessionId?: string
14
15
  createdAt?: string
15
16
  summary: string
@@ -25,7 +26,7 @@ export type ContinuityAgentSnapshot = {
25
26
  description?: string
26
27
  }
27
28
 
28
- export type ContinuitySnapshotPayload = {
29
+ type ContinuitySnapshotPayload = {
29
30
  version: 1
30
31
  ownerAddress: string
31
32
  createdAt: string
@@ -36,7 +37,41 @@ export type ContinuitySnapshotPayload = {
36
37
  state: Record<string, unknown>
37
38
  }
38
39
 
39
- export type ContinuitySnapshotEnvelope = {
40
+ type ContinuitySnapshotToken = {
41
+ chainId: number
42
+ identityRegistryAddress: string
43
+ agentId: string
44
+ }
45
+
46
+ type TransferContinuitySnapshotSlot = {
47
+ address: string
48
+ challenge: string
49
+ salt: string
50
+ nonce: string
51
+ encryptedKey: string
52
+ tag: string
53
+ }
54
+
55
+ export type WalletContinuityRestoreAccessKey = {
56
+ address: string
57
+ challenge: string
58
+ salt: string
59
+ kemPublicKey: string
60
+ createdAt?: string
61
+ }
62
+
63
+ type WalletContinuitySnapshotSlot = {
64
+ address: string
65
+ challenge: string
66
+ salt: string
67
+ kemPublicKey: string
68
+ kemCiphertext: string
69
+ nonce: string
70
+ encryptedKey: string
71
+ tag: string
72
+ }
73
+
74
+ type SignatureContinuitySnapshotEnvelope = {
40
75
  version: 1
41
76
  envelopeVersion: typeof CONTINUITY_SNAPSHOT_ENVELOPE_VERSION
42
77
  ownerAddress: string
@@ -47,6 +82,7 @@ export type ContinuitySnapshotEnvelope = {
47
82
  aead: 'AES-256-GCM'
48
83
  kdf: 'HKDF-SHA256'
49
84
  signature: 'EIP-191'
85
+ decryptsWith?: 'owner-signature'
50
86
  }
51
87
  salt: string
52
88
  kemPublicKey: string
@@ -56,7 +92,59 @@ export type ContinuitySnapshotEnvelope = {
56
92
  tag: string
57
93
  }
58
94
 
59
- export type CreateContinuitySnapshotEnvelopeArgs = {
95
+ type WalletContinuitySnapshotEnvelope = {
96
+ version: 1
97
+ envelopeVersion: typeof CONTINUITY_SNAPSHOT_ENVELOPE_VERSION
98
+ ownerAddress: string
99
+ createdAt: string
100
+ token: ContinuitySnapshotToken
101
+ accessEpoch: number
102
+ accessManifestHash: string
103
+ crypto: {
104
+ kem: 'ML-KEM-1024'
105
+ aead: 'AES-256-GCM'
106
+ kdf: 'HKDF-SHA256'
107
+ signature: 'EIP-191'
108
+ decryptsWith: 'wallet-signature-slots'
109
+ }
110
+ payloadNonce: string
111
+ payloadCiphertext: string
112
+ payloadTag: string
113
+ payloadHash: string
114
+ slots: WalletContinuitySnapshotSlot[]
115
+ }
116
+
117
+ export type TransferContinuitySnapshotEnvelope = {
118
+ version: 1
119
+ envelopeVersion: typeof CONTINUITY_SNAPSHOT_ENVELOPE_VERSION
120
+ ownerAddress: string
121
+ createdAt: string
122
+ challenge: string
123
+ token: ContinuitySnapshotToken
124
+ targetAddress: string
125
+ targetHandle?: string
126
+ crypto: {
127
+ aead: 'AES-256-GCM'
128
+ kdf: 'HKDF-SHA256'
129
+ signature: 'EIP-191'
130
+ decryptsWith: 'transfer-signature-slot'
131
+ }
132
+ payloadNonce: string
133
+ payloadCiphertext: string
134
+ payloadTag: string
135
+ payloadHash: string
136
+ slots: {
137
+ owner: TransferContinuitySnapshotSlot
138
+ target: TransferContinuitySnapshotSlot
139
+ }
140
+ }
141
+
142
+ export type ContinuitySnapshotEnvelope =
143
+ | SignatureContinuitySnapshotEnvelope
144
+ | WalletContinuitySnapshotEnvelope
145
+ | TransferContinuitySnapshotEnvelope
146
+
147
+ type CreateContinuitySnapshotEnvelopeArgs = {
60
148
  ownerAddress: string
61
149
  walletSignature: string
62
150
  payload: Omit<ContinuitySnapshotPayload, 'version' | 'ownerAddress' | 'createdAt'> & {
@@ -64,9 +152,34 @@ export type CreateContinuitySnapshotEnvelopeArgs = {
64
152
  }
65
153
  }
66
154
 
67
- export type RestoreContinuitySnapshotEnvelopeArgs = {
155
+ type CreateTransferContinuitySnapshotEnvelopeArgs = {
156
+ ownerAddress: string
157
+ ownerWalletSignature: string
158
+ targetAddress: string
159
+ targetWalletSignature: string
160
+ targetHandle?: string
161
+ token: ContinuitySnapshotToken
162
+ payload: Omit<ContinuitySnapshotPayload, 'version' | 'ownerAddress' | 'createdAt'> & {
163
+ createdAt?: string
164
+ }
165
+ }
166
+
167
+ type CreateWalletContinuitySnapshotEnvelopeArgs = {
168
+ ownerAddress: string
169
+ token: ContinuitySnapshotToken
170
+ signerAddress: string
171
+ signerWalletSignature: string
172
+ accessKeys: WalletContinuityRestoreAccessKey[]
173
+ accessEpoch?: number
174
+ payload: Omit<ContinuitySnapshotPayload, 'version' | 'ownerAddress' | 'createdAt'> & {
175
+ createdAt?: string
176
+ }
177
+ }
178
+
179
+ type RestoreContinuitySnapshotEnvelopeArgs = {
68
180
  envelope: ContinuitySnapshotEnvelope
69
181
  walletSignature: string
182
+ currentOwnerAddress?: string
70
183
  }
71
184
 
72
185
  export class ContinuitySnapshotOwnerMismatchError extends Error {
@@ -74,16 +187,34 @@ export class ContinuitySnapshotOwnerMismatchError extends Error {
74
187
  readonly snapshotOwner: string,
75
188
  readonly currentOwner: string,
76
189
  ) {
77
- super('continuity snapshot is encrypted for another wallet')
190
+ super('Continuity snapshot is encrypted for another wallet')
78
191
  this.name = 'ContinuitySnapshotOwnerMismatchError'
79
192
  }
80
193
  }
81
194
 
82
- export const CONTINUITY_SNAPSHOT_CHALLENGE_MESSAGES = [
83
- 'ethagent: save or restore identity files',
195
+ export class ContinuityTransferSnapshotTargetMismatchError extends Error {
196
+ constructor(
197
+ readonly snapshotOwner: string,
198
+ readonly targetOwner: string,
199
+ readonly currentOwner: string,
200
+ ) {
201
+ super('Transfer snapshot receiver does not match the current token owner')
202
+ this.name = 'ContinuityTransferSnapshotTargetMismatchError'
203
+ }
204
+ }
205
+
206
+ export class ContinuitySnapshotRestoreSlotMissingError extends Error {
207
+ constructor(readonly walletAddress: string) {
208
+ super('Restore slot missing')
209
+ this.name = 'ContinuitySnapshotRestoreSlotMissingError'
210
+ }
211
+ }
212
+
213
+ const CONTINUITY_SNAPSHOT_CHALLENGE_MESSAGES = [
214
+ 'Save or Restore Identity Files',
84
215
  'Action: encrypt or decrypt local identity files',
85
216
  'Private: SOUL.md, MEMORY.md',
86
- 'Public: skills.json, public profile',
217
+ 'Public: public skills and profile',
87
218
  'Safety: no transaction, spending, or approvals',
88
219
  'Version: 1',
89
220
  ] as const
@@ -97,22 +228,151 @@ export function createContinuitySnapshotChallenge(ownerAddress: string): string
97
228
  ].join('\n')
98
229
  }
99
230
 
100
- export function createContinuitySnapshotEnvelope(args: CreateContinuitySnapshotEnvelopeArgs): ContinuitySnapshotEnvelope {
231
+ export const TRANSFER_SNAPSHOT_CHALLENGE_HEADER_LEGACY = 'Prepare Transfer Restore Snapshot'
232
+ export const TRANSFER_SNAPSHOT_CHALLENGE_HEADER_SENDER = 'Prepare Transfer Snapshot · Sender Restore Slot'
233
+ export const TRANSFER_SNAPSHOT_CHALLENGE_HEADER_RECEIVER = 'Prepare Transfer Snapshot · Receiver Restore Slot'
234
+
235
+ export function createTransferContinuitySnapshotChallenge(args: {
236
+ token: ContinuitySnapshotToken
237
+ ownerAddress: string
238
+ targetAddress: string
239
+ role?: 'sender' | 'receiver'
240
+ }): string {
241
+ const token = normalizeContinuitySnapshotToken(args.token)
242
+ const ownerAddress = toChecksumAddress(args.ownerAddress)
243
+ const targetAddress = toChecksumAddress(args.targetAddress)
244
+ const header = args.role === 'sender'
245
+ ? TRANSFER_SNAPSHOT_CHALLENGE_HEADER_SENDER
246
+ : args.role === 'receiver'
247
+ ? TRANSFER_SNAPSHOT_CHALLENGE_HEADER_RECEIVER
248
+ : TRANSFER_SNAPSHOT_CHALLENGE_HEADER_LEGACY
249
+ return [
250
+ header,
251
+ `ERC-8004 Chain ID: ${token.chainId}`,
252
+ `ERC-8004 Registry: ${token.identityRegistryAddress}`,
253
+ `ERC-8004 Token ID: ${token.agentId}`,
254
+ `Sender Owner: ${ownerAddress}`,
255
+ `Receiver Owner: ${targetAddress}`,
256
+ 'Action: encrypt or decrypt local identity files for this token transfer',
257
+ 'Private: SOUL.md, MEMORY.md',
258
+ 'Public: public skills and profile',
259
+ 'Safety: no transaction, spending, or approvals',
260
+ 'Version: 1',
261
+ ].join('\n')
262
+ }
263
+
264
+ export type WalletChallengePurpose =
265
+ | 'create-agent'
266
+ | 'update-snapshot'
267
+ | 'update-ens-snapshot'
268
+ | 'clear-ens-snapshot'
269
+ | 'update-profile-snapshot'
270
+ | 'update-operators-snapshot'
271
+ | 'refetch-snapshot'
272
+ | 'operator-proof'
273
+ | 'restore-owner'
274
+ | 'restore-operator'
275
+ | 'transfer-prepare-sender'
276
+ | 'transfer-prepare-receiver'
277
+
278
+ const WALLET_CHALLENGE_V2_COPY: Record<WalletChallengePurpose, { title: string; action: string }> = {
279
+ 'create-agent': { title: 'Create Agent Snapshot Key', action: 'Action: encrypt the new agent snapshot for owner restore' },
280
+ 'update-snapshot': { title: 'Save Snapshot Encryption Key', action: 'Action: encrypt the updated agent snapshot' },
281
+ 'update-ens-snapshot': { title: 'Update ENS in Agent Snapshot', action: 'Action: encrypt the snapshot with the new ENS name. No onchain ENS records change.' },
282
+ 'clear-ens-snapshot': { title: 'Clear ENS from Agent Snapshot', action: 'Action: encrypt the snapshot with no ENS name. No onchain ENS records change.' },
283
+ 'update-profile-snapshot': { title: 'Update Public Profile Snapshot Key', action: 'Action: encrypt the snapshot with the updated profile' },
284
+ 'update-operators-snapshot': { title: 'Update Operator Wallets Snapshot Key', action: 'Action: encrypt the snapshot with the updated operator list' },
285
+ 'refetch-snapshot': { title: 'Refetch Latest Snapshot', action: 'Action: decrypt the latest published snapshot' },
286
+ 'operator-proof': { title: 'Authorize Operator Wallet Restore Access', action: 'Action: prove this operator wallet can decrypt future snapshots' },
287
+ 'restore-owner': { title: 'Restore Agent with Owner Wallet', action: 'Action: decrypt the snapshot for the owner wallet' },
288
+ 'restore-operator': { title: 'Restore Agent with Operator Wallet', action: 'Action: decrypt the snapshot for the authorized operator wallet' },
289
+ 'transfer-prepare-sender': { title: 'Prepare Token Transfer (Sender)', action: 'Action: encrypt the transfer snapshot for the receiver' },
290
+ 'transfer-prepare-receiver': { title: 'Receive Token Transfer (Receiver)', action: 'Action: prepare receiver decryption for the transfer snapshot' },
291
+ }
292
+
293
+ export function createWalletRestoreAccessChallenge(args: {
294
+ token: ContinuitySnapshotToken
295
+ ownerAddress: string
296
+ walletAddress: string
297
+ accessEpoch?: number
298
+ purpose?: WalletChallengePurpose
299
+ }): string {
300
+ const token = normalizeContinuitySnapshotToken(args.token)
301
+ const ownerAddress = toChecksumAddress(args.ownerAddress)
302
+ const walletAddress = toChecksumAddress(args.walletAddress)
303
+ if (args.purpose) {
304
+ const copy = WALLET_CHALLENGE_V2_COPY[args.purpose]
305
+ return [
306
+ copy.title,
307
+ `ERC-8004 Chain ID: ${token.chainId}`,
308
+ `ERC-8004 Registry: ${token.identityRegistryAddress}`,
309
+ `ERC-8004 Token ID: ${token.agentId}`,
310
+ `Owner: ${ownerAddress}`,
311
+ `Wallet: ${walletAddress}`,
312
+ `Access Epoch: ${args.accessEpoch ?? 1}`,
313
+ copy.action,
314
+ 'Private: SOUL.md, MEMORY.md',
315
+ 'Safety: no transaction, spending, or approvals',
316
+ 'Version: 2',
317
+ ].join('\n')
318
+ }
319
+ return [
320
+ 'Authorize Wallet Restore Access',
321
+ `ERC-8004 Chain ID: ${token.chainId}`,
322
+ `ERC-8004 Registry: ${token.identityRegistryAddress}`,
323
+ `ERC-8004 Token ID: ${token.agentId}`,
324
+ `Owner: ${ownerAddress}`,
325
+ `Wallet: ${walletAddress}`,
326
+ `Access Epoch: ${args.accessEpoch ?? 1}`,
327
+ 'Action: create a restore key for encrypted identity snapshots',
328
+ 'Private: SOUL.md, MEMORY.md',
329
+ 'Safety: no transaction, spending, or approvals',
330
+ 'Version: 1',
331
+ ].join('\n')
332
+ }
333
+
334
+ export function createWalletRestoreAccessKey(args: {
335
+ token: ContinuitySnapshotToken
336
+ ownerAddress: string
337
+ walletAddress: string
338
+ walletSignature: string
339
+ accessEpoch?: number
340
+ createdAt?: string
341
+ salt?: string
342
+ purpose?: WalletChallengePurpose
343
+ }): WalletContinuityRestoreAccessKey {
344
+ const walletAddress = toChecksumAddress(args.walletAddress)
345
+ const challenge = createWalletRestoreAccessChallenge({
346
+ token: args.token,
347
+ ownerAddress: args.ownerAddress,
348
+ walletAddress,
349
+ accessEpoch: args.accessEpoch,
350
+ purpose: args.purpose,
351
+ })
352
+ assertSignatureForAddress(challenge, args.walletSignature, walletAddress)
353
+ const salt = args.salt ? fromBase64(args.salt) : crypto.randomBytes(32)
354
+ const kemSeed = deriveWalletRestoreKemSeed(args.walletSignature, salt, walletAddress, challenge)
355
+ const kemKeys = ml_kem1024.keygen(kemSeed)
356
+ return {
357
+ address: walletAddress,
358
+ challenge,
359
+ salt: toBase64(salt),
360
+ kemPublicKey: toBase64(kemKeys.publicKey),
361
+ ...(args.createdAt ? { createdAt: args.createdAt } : {}),
362
+ }
363
+ }
364
+
365
+ export function createContinuitySnapshotEnvelope(args: CreateContinuitySnapshotEnvelopeArgs): SignatureContinuitySnapshotEnvelope {
101
366
  const ownerAddress = toChecksumAddress(args.ownerAddress)
102
367
  const challenge = createContinuitySnapshotChallenge(ownerAddress)
103
368
  assertSignatureForAddress(challenge, args.walletSignature, ownerAddress)
104
369
 
105
370
  const createdAt = args.payload.createdAt ?? new Date().toISOString()
106
- const payload: ContinuitySnapshotPayload = {
107
- version: 1,
371
+ const payload = continuityPayloadFromArgs({
108
372
  ownerAddress,
109
373
  createdAt,
110
- ...(args.payload.sequence !== undefined ? { sequence: args.payload.sequence } : {}),
111
- agent: normalizeAgentSnapshot(args.payload.agent),
112
- files: normalizeContinuityFiles(args.payload.files),
113
- transcript: normalizeTranscript(args.payload.transcript),
114
- state: normalizeState(args.payload.state),
115
- }
374
+ payload: args.payload,
375
+ })
116
376
 
117
377
  const salt = crypto.randomBytes(32)
118
378
  const kemSeed = deriveContinuityKemSeed(args.walletSignature, salt, ownerAddress)
@@ -137,6 +397,7 @@ export function createContinuitySnapshotEnvelope(args: CreateContinuitySnapshotE
137
397
  aead: 'AES-256-GCM',
138
398
  kdf: 'HKDF-SHA256',
139
399
  signature: 'EIP-191',
400
+ decryptsWith: 'owner-signature',
140
401
  },
141
402
  salt: toBase64(salt),
142
403
  kemPublicKey: toBase64(kemKeys.publicKey),
@@ -147,8 +408,162 @@ export function createContinuitySnapshotEnvelope(args: CreateContinuitySnapshotE
147
408
  }
148
409
  }
149
410
 
411
+ export function createWalletContinuitySnapshotEnvelope(
412
+ args: CreateWalletContinuitySnapshotEnvelopeArgs,
413
+ ): WalletContinuitySnapshotEnvelope {
414
+ const ownerAddress = toChecksumAddress(args.ownerAddress)
415
+ const signerAddress = toChecksumAddress(args.signerAddress)
416
+ const token = normalizeContinuitySnapshotToken(args.token)
417
+ const accessEpoch = args.accessEpoch ?? 1
418
+ const accessKeys = normalizeWalletRestoreAccessKeys(args.accessKeys)
419
+ if (accessKeys.length === 0) throw new Error('At least one restore access key is required')
420
+ const signerKey = accessKeys.find(key => key.address.toLowerCase() === signerAddress.toLowerCase())
421
+ if (!signerKey) throw new Error('Snapshot signer is not an authorized restore wallet')
422
+ assertSignatureForAddress(signerKey.challenge, args.signerWalletSignature, signerAddress)
423
+
424
+ const createdAt = args.payload.createdAt ?? new Date().toISOString()
425
+ const payload = continuityPayloadFromArgs({
426
+ ownerAddress,
427
+ createdAt,
428
+ payload: {
429
+ ...args.payload,
430
+ agent: normalizeAgentSnapshot({
431
+ ...args.payload.agent,
432
+ chainId: args.payload.agent.chainId ?? token.chainId,
433
+ identityRegistryAddress: args.payload.agent.identityRegistryAddress ?? token.identityRegistryAddress,
434
+ agentId: args.payload.agent.agentId ?? token.agentId,
435
+ }),
436
+ },
437
+ })
438
+ const plaintext = Buffer.from(JSON.stringify(payload), 'utf8')
439
+ const contentKey = crypto.randomBytes(32)
440
+ const payloadNonce = crypto.randomBytes(12)
441
+ const accessManifestHash = walletAccessManifestHash({ ownerAddress, token, accessEpoch, accessKeys })
442
+ const payloadAad = walletPayloadAadFor({ ownerAddress, createdAt, token, accessEpoch, accessManifestHash })
443
+ const cipher = crypto.createCipheriv('aes-256-gcm', contentKey, payloadNonce)
444
+ cipher.setAAD(payloadAad)
445
+ const payloadCiphertext = Buffer.concat([cipher.update(plaintext), cipher.final()])
446
+ const payloadTag = cipher.getAuthTag()
447
+
448
+ return {
449
+ version: 1,
450
+ envelopeVersion: CONTINUITY_SNAPSHOT_ENVELOPE_VERSION,
451
+ ownerAddress,
452
+ createdAt,
453
+ token,
454
+ accessEpoch,
455
+ accessManifestHash,
456
+ crypto: {
457
+ kem: 'ML-KEM-1024',
458
+ aead: 'AES-256-GCM',
459
+ kdf: 'HKDF-SHA256',
460
+ signature: 'EIP-191',
461
+ decryptsWith: 'wallet-signature-slots',
462
+ },
463
+ payloadNonce: toBase64(payloadNonce),
464
+ payloadCiphertext: toBase64(payloadCiphertext),
465
+ payloadTag: toBase64(payloadTag),
466
+ payloadHash: sha256Hex(plaintext),
467
+ slots: accessKeys.map(key => createWalletSlot({ accessKey: key, contentKey, payloadAad, accessManifestHash })),
468
+ }
469
+ }
470
+
471
+ export function createTransferContinuitySnapshotEnvelope(
472
+ args: CreateTransferContinuitySnapshotEnvelopeArgs,
473
+ ): TransferContinuitySnapshotEnvelope {
474
+ const ownerAddress = toChecksumAddress(args.ownerAddress)
475
+ const targetAddress = toChecksumAddress(args.targetAddress)
476
+ if (ownerAddress.toLowerCase() === targetAddress.toLowerCase()) {
477
+ throw new Error('Receiver wallet must be different from sender wallet')
478
+ }
479
+ const token = normalizeContinuitySnapshotToken(args.token)
480
+ const senderChallenge = createTransferContinuitySnapshotChallenge({ token, ownerAddress, targetAddress, role: 'sender' })
481
+ const receiverChallenge = createTransferContinuitySnapshotChallenge({ token, ownerAddress, targetAddress, role: 'receiver' })
482
+ const challenge = createTransferContinuitySnapshotChallenge({ token, ownerAddress, targetAddress })
483
+ assertSignatureForAddress(senderChallenge, args.ownerWalletSignature, ownerAddress)
484
+ assertSignatureForAddress(receiverChallenge, args.targetWalletSignature, targetAddress)
485
+
486
+ const createdAt = args.payload.createdAt ?? new Date().toISOString()
487
+ const payload = continuityPayloadFromArgs({
488
+ ownerAddress,
489
+ createdAt,
490
+ payload: {
491
+ ...args.payload,
492
+ agent: normalizeAgentSnapshot({
493
+ ...args.payload.agent,
494
+ chainId: args.payload.agent.chainId ?? token.chainId,
495
+ identityRegistryAddress: args.payload.agent.identityRegistryAddress ?? token.identityRegistryAddress,
496
+ agentId: args.payload.agent.agentId ?? token.agentId,
497
+ }),
498
+ },
499
+ })
500
+ const plaintext = Buffer.from(JSON.stringify(payload), 'utf8')
501
+ const contentKey = crypto.randomBytes(32)
502
+ const payloadNonce = crypto.randomBytes(12)
503
+ const payloadAad = transferPayloadAadFor({ ownerAddress, targetAddress, createdAt, token })
504
+ const cipher = crypto.createCipheriv('aes-256-gcm', contentKey, payloadNonce)
505
+ cipher.setAAD(payloadAad)
506
+ const payloadCiphertext = Buffer.concat([cipher.update(plaintext), cipher.final()])
507
+ const payloadTag = cipher.getAuthTag()
508
+
509
+ const ownerSlot = createTransferSlot({
510
+ address: ownerAddress,
511
+ challenge: senderChallenge,
512
+ walletSignature: args.ownerWalletSignature,
513
+ contentKey,
514
+ payloadAad,
515
+ })
516
+ const targetSlot = createTransferSlot({
517
+ address: targetAddress,
518
+ challenge: receiverChallenge,
519
+ walletSignature: args.targetWalletSignature,
520
+ contentKey,
521
+ payloadAad,
522
+ })
523
+
524
+ return {
525
+ version: 1,
526
+ envelopeVersion: CONTINUITY_SNAPSHOT_ENVELOPE_VERSION,
527
+ ownerAddress,
528
+ createdAt,
529
+ challenge,
530
+ token,
531
+ targetAddress,
532
+ ...(args.targetHandle ? { targetHandle: args.targetHandle } : {}),
533
+ crypto: {
534
+ aead: 'AES-256-GCM',
535
+ kdf: 'HKDF-SHA256',
536
+ signature: 'EIP-191',
537
+ decryptsWith: 'transfer-signature-slot',
538
+ },
539
+ payloadNonce: toBase64(payloadNonce),
540
+ payloadCiphertext: toBase64(payloadCiphertext),
541
+ payloadTag: toBase64(payloadTag),
542
+ payloadHash: sha256Hex(plaintext),
543
+ slots: {
544
+ owner: ownerSlot,
545
+ target: targetSlot,
546
+ },
547
+ }
548
+ }
549
+
150
550
  export function restoreContinuitySnapshotEnvelope(args: RestoreContinuitySnapshotEnvelopeArgs): ContinuitySnapshotPayload {
151
551
  const envelope = normalizeContinuitySnapshotEnvelope(args.envelope)
552
+ if (isWalletContinuitySnapshotEnvelope(envelope)) {
553
+ return restoreWalletContinuitySnapshotEnvelope({
554
+ envelope,
555
+ walletSignature: args.walletSignature,
556
+ currentOwnerAddress: args.currentOwnerAddress,
557
+ })
558
+ }
559
+ if (isTransferContinuitySnapshotEnvelope(envelope)) {
560
+ return restoreTransferContinuitySnapshotEnvelope({
561
+ envelope,
562
+ walletSignature: args.walletSignature,
563
+ currentOwnerAddress: args.currentOwnerAddress,
564
+ })
565
+ }
566
+
152
567
  assertSignatureForAddress(envelope.challenge, args.walletSignature, envelope.ownerAddress)
153
568
 
154
569
  const salt = fromBase64(envelope.salt)
@@ -156,7 +571,7 @@ export function restoreContinuitySnapshotEnvelope(args: RestoreContinuitySnapsho
156
571
  const kemKeys = ml_kem1024.keygen(kemSeed)
157
572
  const expectedPublicKey = toBase64(kemKeys.publicKey)
158
573
  if (expectedPublicKey !== envelope.kemPublicKey) {
159
- throw new Error('wallet signature does not match this continuity snapshot')
574
+ throw new Error('Wallet signature does not match this continuity snapshot')
160
575
  }
161
576
 
162
577
  const sharedSecret = ml_kem1024.decapsulate(fromBase64(envelope.kemCiphertext), kemKeys.secretKey)
@@ -173,22 +588,36 @@ export function restoreContinuitySnapshotEnvelope(args: RestoreContinuitySnapsho
173
588
  ]).toString('utf8')
174
589
  decoded = JSON.parse(plaintext)
175
590
  } catch {
176
- throw new Error('could not decrypt continuity snapshot with the supplied wallet signature')
591
+ throw new Error('Could not decrypt continuity snapshot with the supplied wallet signature')
177
592
  }
178
593
 
179
594
  const payload = normalizeContinuityPayload(decoded)
180
- if (payload.ownerAddress.toLowerCase() !== envelope.ownerAddress.toLowerCase()) {
181
- throw new Error('continuity snapshot owner mismatch')
182
- }
183
- if (payload.createdAt !== envelope.createdAt) {
184
- throw new Error('continuity snapshot timestamp mismatch')
185
- }
595
+ assertPayloadMatchesEnvelope(payload, envelope.ownerAddress, envelope.createdAt)
186
596
  return payload
187
597
  }
188
598
 
189
599
  export function assertContinuitySnapshotOwner(envelope: ContinuitySnapshotEnvelope, currentOwner: string): void {
190
- const snapshotOwner = toChecksumAddress(envelope.ownerAddress)
600
+ const normalized = normalizeContinuitySnapshotEnvelope(envelope)
191
601
  const owner = toChecksumAddress(currentOwner)
602
+ if (isWalletContinuitySnapshotEnvelope(normalized)) {
603
+ const snapshotOwner = toChecksumAddress(normalized.ownerAddress)
604
+ if (snapshotOwner.toLowerCase() !== owner.toLowerCase()) {
605
+ throw new ContinuitySnapshotOwnerMismatchError(snapshotOwner, owner)
606
+ }
607
+ return
608
+ }
609
+ if (isTransferContinuitySnapshotEnvelope(normalized)) {
610
+ const snapshotOwner = toChecksumAddress(normalized.ownerAddress)
611
+ const targetOwner = toChecksumAddress(normalized.targetAddress)
612
+ if (
613
+ owner.toLowerCase() !== snapshotOwner.toLowerCase()
614
+ && owner.toLowerCase() !== targetOwner.toLowerCase()
615
+ ) {
616
+ throw new ContinuityTransferSnapshotTargetMismatchError(snapshotOwner, targetOwner, owner)
617
+ }
618
+ return
619
+ }
620
+ const snapshotOwner = toChecksumAddress(normalized.ownerAddress)
192
621
  if (snapshotOwner.toLowerCase() !== owner.toLowerCase()) {
193
622
  throw new ContinuitySnapshotOwnerMismatchError(snapshotOwner, owner)
194
623
  }
@@ -204,13 +633,337 @@ export function parseContinuitySnapshotEnvelope(raw: string | Uint8Array): Conti
204
633
  return normalizeContinuitySnapshotEnvelope(parsed)
205
634
  }
206
635
 
636
+ export function transferSnapshotMetadataFromEnvelope(
637
+ envelope: ContinuitySnapshotEnvelope,
638
+ ): TransferSnapshotMetadata | null {
639
+ const normalized = normalizeContinuitySnapshotEnvelope(envelope)
640
+ if (!isTransferContinuitySnapshotEnvelope(normalized)) return null
641
+ const slotCount = [normalized.slots.owner, normalized.slots.target]
642
+ .filter(slot => Boolean(slot?.encryptedKey && slot.address))
643
+ .length
644
+ if (slotCount < 2) return null
645
+ return {
646
+ kind: 'dual-wallet',
647
+ senderAddress: normalized.ownerAddress,
648
+ receiverAddress: normalized.targetAddress,
649
+ ...(normalized.targetHandle ? { receiverHandle: normalized.targetHandle } : {}),
650
+ slotCount,
651
+ createdAt: normalized.createdAt,
652
+ }
653
+ }
654
+
655
+ export function walletContinuitySnapshotSlotForAddress(
656
+ envelope: ContinuitySnapshotEnvelope,
657
+ address: string,
658
+ ): WalletContinuitySnapshotSlot | null {
659
+ const normalized = normalizeContinuitySnapshotEnvelope(envelope)
660
+ if (!isWalletContinuitySnapshotEnvelope(normalized)) return null
661
+ const checksum = toChecksumAddress(address)
662
+ return normalized.slots.find(slot => slot.address.toLowerCase() === checksum.toLowerCase()) ?? null
663
+ }
664
+
665
+ export function findRestorableAddressForSnapshot(
666
+ envelope: ContinuitySnapshotEnvelope,
667
+ candidates: ReadonlyArray<string>,
668
+ ): string | null {
669
+ const normalized = normalizeContinuitySnapshotEnvelope(envelope)
670
+ const seen = new Set<string>()
671
+ const lowerCandidates: string[] = []
672
+ for (const candidate of candidates) {
673
+ if (!candidate) continue
674
+ const lower = candidate.toLowerCase()
675
+ if (seen.has(lower)) continue
676
+ seen.add(lower)
677
+ lowerCandidates.push(lower)
678
+ }
679
+ if (lowerCandidates.length === 0) return null
680
+ if (isWalletContinuitySnapshotEnvelope(normalized)) {
681
+ for (const slot of normalized.slots) {
682
+ if (lowerCandidates.includes(slot.address.toLowerCase())) return toChecksumAddress(slot.address)
683
+ }
684
+ return null
685
+ }
686
+ if (isTransferContinuitySnapshotEnvelope(normalized)) {
687
+ const ownerLower = normalized.ownerAddress.toLowerCase()
688
+ const targetLower = normalized.targetAddress.toLowerCase()
689
+ if (lowerCandidates.includes(ownerLower)) return toChecksumAddress(normalized.ownerAddress)
690
+ if (lowerCandidates.includes(targetLower)) return toChecksumAddress(normalized.targetAddress)
691
+ return null
692
+ }
693
+ const ownerLower = normalized.ownerAddress.toLowerCase()
694
+ return lowerCandidates.includes(ownerLower) ? toChecksumAddress(normalized.ownerAddress) : null
695
+ }
696
+
697
+ export function isWalletContinuitySnapshotEnvelope(input: unknown): input is WalletContinuitySnapshotEnvelope {
698
+ if (!input || typeof input !== 'object' || Array.isArray(input)) return false
699
+ const obj = input as Partial<WalletContinuitySnapshotEnvelope> & { crypto?: Partial<WalletContinuitySnapshotEnvelope['crypto']> }
700
+ return obj.version === 1
701
+ && obj.envelopeVersion === CONTINUITY_SNAPSHOT_ENVELOPE_VERSION
702
+ && typeof obj.ownerAddress === 'string'
703
+ && typeof obj.createdAt === 'string'
704
+ && !!obj.token
705
+ && typeof obj.accessEpoch === 'number'
706
+ && typeof obj.accessManifestHash === 'string'
707
+ && obj.crypto?.decryptsWith === 'wallet-signature-slots'
708
+ && typeof obj.payloadNonce === 'string'
709
+ && typeof obj.payloadCiphertext === 'string'
710
+ && typeof obj.payloadTag === 'string'
711
+ && typeof obj.payloadHash === 'string'
712
+ && Array.isArray(obj.slots)
713
+ }
714
+
715
+ function isTransferContinuitySnapshotEnvelope(input: unknown): input is TransferContinuitySnapshotEnvelope {
716
+ if (!input || typeof input !== 'object' || Array.isArray(input)) return false
717
+ const obj = input as Partial<TransferContinuitySnapshotEnvelope> & { crypto?: Partial<TransferContinuitySnapshotEnvelope['crypto']> }
718
+ return obj.version === 1
719
+ && obj.envelopeVersion === CONTINUITY_SNAPSHOT_ENVELOPE_VERSION
720
+ && typeof obj.ownerAddress === 'string'
721
+ && typeof obj.createdAt === 'string'
722
+ && typeof obj.challenge === 'string'
723
+ && !!obj.token
724
+ && typeof obj.targetAddress === 'string'
725
+ && obj.crypto?.decryptsWith === 'transfer-signature-slot'
726
+ && typeof obj.payloadNonce === 'string'
727
+ && typeof obj.payloadCiphertext === 'string'
728
+ && typeof obj.payloadTag === 'string'
729
+ && typeof obj.payloadHash === 'string'
730
+ && !!obj.slots
731
+ && !!obj.slots.owner
732
+ && !!obj.slots.target
733
+ }
734
+
735
+ function restoreTransferContinuitySnapshotEnvelope(args: {
736
+ envelope: TransferContinuitySnapshotEnvelope
737
+ walletSignature: string
738
+ currentOwnerAddress?: string
739
+ }): ContinuitySnapshotPayload {
740
+ const currentAddress = args.currentOwnerAddress
741
+ ? toChecksumAddress(args.currentOwnerAddress)
742
+ : toChecksumAddress(recoverAddressFromSignature(args.envelope.challenge, args.walletSignature))
743
+ const slot = transferSlotForCurrentOwner(args.envelope, currentAddress)
744
+ assertSignatureForAddress(slot.challenge, args.walletSignature, slot.address)
745
+
746
+ let contentKey: Buffer
747
+ try {
748
+ const payloadAad = transferPayloadAadFor(args.envelope)
749
+ const slotKey = deriveTransferSlotKey(args.walletSignature, fromBase64(slot.salt), slot.address, slot.challenge)
750
+ const keyDecipher = crypto.createDecipheriv('aes-256-gcm', slotKey, fromBase64(slot.nonce))
751
+ keyDecipher.setAAD(transferSlotAadFor(slot, payloadAad))
752
+ keyDecipher.setAuthTag(fromBase64(slot.tag))
753
+ contentKey = Buffer.concat([
754
+ keyDecipher.update(fromBase64(slot.encryptedKey)),
755
+ keyDecipher.final(),
756
+ ])
757
+ } catch {
758
+ throw new Error('Could not decrypt transfer snapshot key with the supplied wallet signature')
759
+ }
760
+
761
+ let decoded: unknown
762
+ try {
763
+ const payloadAad = transferPayloadAadFor(args.envelope)
764
+ const decipher = crypto.createDecipheriv('aes-256-gcm', contentKey, fromBase64(args.envelope.payloadNonce))
765
+ decipher.setAAD(payloadAad)
766
+ decipher.setAuthTag(fromBase64(args.envelope.payloadTag))
767
+ const plaintext = Buffer.concat([
768
+ decipher.update(fromBase64(args.envelope.payloadCiphertext)),
769
+ decipher.final(),
770
+ ])
771
+ if (sha256Hex(plaintext) !== args.envelope.payloadHash) {
772
+ throw new Error('Transfer snapshot payload hash mismatch')
773
+ }
774
+ decoded = JSON.parse(plaintext.toString('utf8')) as unknown
775
+ } catch {
776
+ throw new Error('Could not decrypt continuity transfer snapshot with the supplied wallet signature')
777
+ }
778
+
779
+ const payload = normalizeContinuityPayload(decoded)
780
+ assertPayloadMatchesEnvelope(payload, args.envelope.ownerAddress, args.envelope.createdAt)
781
+ return payload
782
+ }
783
+
784
+ function restoreWalletContinuitySnapshotEnvelope(args: {
785
+ envelope: WalletContinuitySnapshotEnvelope
786
+ walletSignature: string
787
+ currentOwnerAddress?: string
788
+ }): ContinuitySnapshotPayload {
789
+ const slot = walletSlotForRestore(args.envelope, args.walletSignature, args.currentOwnerAddress)
790
+ assertSignatureForAddress(slot.challenge, args.walletSignature, slot.address)
791
+
792
+ let contentKey: Buffer
793
+ try {
794
+ const salt = fromBase64(slot.salt)
795
+ const kemSeed = deriveWalletRestoreKemSeed(args.walletSignature, salt, slot.address, slot.challenge)
796
+ const kemKeys = ml_kem1024.keygen(kemSeed)
797
+ if (toBase64(kemKeys.publicKey) !== slot.kemPublicKey) {
798
+ throw new Error('Wallet restore key mismatch')
799
+ }
800
+ const sharedSecret = ml_kem1024.decapsulate(fromBase64(slot.kemCiphertext), kemKeys.secretKey)
801
+ const slotKey = deriveWalletSlotAesKey(sharedSecret, salt, slot.address, slot.challenge, args.envelope.accessManifestHash)
802
+ const keyDecipher = crypto.createDecipheriv('aes-256-gcm', slotKey, fromBase64(slot.nonce))
803
+ const payloadAad = walletPayloadAadFor(args.envelope)
804
+ keyDecipher.setAAD(walletSlotAadFor(slot, payloadAad))
805
+ keyDecipher.setAuthTag(fromBase64(slot.tag))
806
+ contentKey = Buffer.concat([
807
+ keyDecipher.update(fromBase64(slot.encryptedKey)),
808
+ keyDecipher.final(),
809
+ ])
810
+ } catch {
811
+ throw new Error('Could not decrypt wallet restore snapshot key with the supplied wallet signature')
812
+ }
813
+
814
+ let decoded: unknown
815
+ try {
816
+ const payloadAad = walletPayloadAadFor(args.envelope)
817
+ const decipher = crypto.createDecipheriv('aes-256-gcm', contentKey, fromBase64(args.envelope.payloadNonce))
818
+ decipher.setAAD(payloadAad)
819
+ decipher.setAuthTag(fromBase64(args.envelope.payloadTag))
820
+ const plaintext = Buffer.concat([
821
+ decipher.update(fromBase64(args.envelope.payloadCiphertext)),
822
+ decipher.final(),
823
+ ])
824
+ if (sha256Hex(plaintext) !== args.envelope.payloadHash) {
825
+ throw new Error('Wallet restore snapshot payload hash mismatch')
826
+ }
827
+ decoded = JSON.parse(plaintext.toString('utf8')) as unknown
828
+ } catch {
829
+ throw new Error('Could not decrypt wallet restore snapshot with the supplied wallet signature')
830
+ }
831
+
832
+ const payload = normalizeContinuityPayload(decoded)
833
+ assertPayloadMatchesEnvelope(payload, args.envelope.ownerAddress, args.envelope.createdAt)
834
+ return payload
835
+ }
836
+
837
+ function walletSlotForRestore(
838
+ envelope: WalletContinuitySnapshotEnvelope,
839
+ walletSignature: string,
840
+ currentOwnerAddress?: string,
841
+ ): WalletContinuitySnapshotSlot {
842
+ if (currentOwnerAddress) {
843
+ const address = toChecksumAddress(currentOwnerAddress)
844
+ const slot = envelope.slots.find(item => item.address.toLowerCase() === address.toLowerCase())
845
+ if (!slot) throw new ContinuitySnapshotRestoreSlotMissingError(address)
846
+ return slot
847
+ }
848
+ for (const slot of envelope.slots) {
849
+ try {
850
+ const recovered = recoverAddressFromSignature(slot.challenge, walletSignature)
851
+ if (recovered.toLowerCase() === slot.address.toLowerCase()) return slot
852
+ } catch {
853
+ }
854
+ }
855
+ throw new ContinuitySnapshotRestoreSlotMissingError('unknown')
856
+ }
857
+
858
+ function transferSlotForCurrentOwner(
859
+ envelope: TransferContinuitySnapshotEnvelope,
860
+ currentOwner: string,
861
+ ): TransferContinuitySnapshotSlot {
862
+ if (currentOwner.toLowerCase() === envelope.ownerAddress.toLowerCase()) return envelope.slots.owner
863
+ if (currentOwner.toLowerCase() === envelope.targetAddress.toLowerCase()) return envelope.slots.target
864
+ throw new ContinuityTransferSnapshotTargetMismatchError(envelope.ownerAddress, envelope.targetAddress, currentOwner)
865
+ }
866
+
867
+ function createTransferSlot(args: {
868
+ address: string
869
+ challenge: string
870
+ walletSignature: string
871
+ contentKey: Uint8Array
872
+ payloadAad: Buffer
873
+ }): TransferContinuitySnapshotSlot {
874
+ const address = toChecksumAddress(args.address)
875
+ const salt = crypto.randomBytes(32)
876
+ const nonce = crypto.randomBytes(12)
877
+ const slotKey = deriveTransferSlotKey(args.walletSignature, salt, address, args.challenge)
878
+ const cipher = crypto.createCipheriv('aes-256-gcm', slotKey, nonce)
879
+ const slot: TransferContinuitySnapshotSlot = {
880
+ address,
881
+ challenge: args.challenge,
882
+ salt: toBase64(salt),
883
+ nonce: toBase64(nonce),
884
+ encryptedKey: '',
885
+ tag: '',
886
+ }
887
+ cipher.setAAD(transferSlotAadFor(slot, args.payloadAad))
888
+ const encryptedKey = Buffer.concat([cipher.update(args.contentKey), cipher.final()])
889
+ return {
890
+ ...slot,
891
+ encryptedKey: toBase64(encryptedKey),
892
+ tag: toBase64(cipher.getAuthTag()),
893
+ }
894
+ }
895
+
896
+ function createWalletSlot(args: {
897
+ accessKey: WalletContinuityRestoreAccessKey
898
+ contentKey: Uint8Array
899
+ payloadAad: Buffer
900
+ accessManifestHash: string
901
+ }): WalletContinuitySnapshotSlot {
902
+ const accessKey = normalizeWalletRestoreAccessKey(args.accessKey)
903
+ const publicKey = fromBase64(accessKey.kemPublicKey)
904
+ const kem = ml_kem1024.encapsulate(publicKey)
905
+ const salt = fromBase64(accessKey.salt)
906
+ const nonce = crypto.randomBytes(12)
907
+ const slotKey = deriveWalletSlotAesKey(kem.sharedSecret, salt, accessKey.address, accessKey.challenge, args.accessManifestHash)
908
+ const cipher = crypto.createCipheriv('aes-256-gcm', slotKey, nonce)
909
+ const slot: WalletContinuitySnapshotSlot = {
910
+ address: accessKey.address,
911
+ challenge: accessKey.challenge,
912
+ salt: accessKey.salt,
913
+ kemPublicKey: accessKey.kemPublicKey,
914
+ kemCiphertext: toBase64(kem.cipherText),
915
+ nonce: toBase64(nonce),
916
+ encryptedKey: '',
917
+ tag: '',
918
+ }
919
+ cipher.setAAD(walletSlotAadFor(slot, args.payloadAad))
920
+ const encryptedKey = Buffer.concat([cipher.update(args.contentKey), cipher.final()])
921
+ return {
922
+ ...slot,
923
+ encryptedKey: toBase64(encryptedKey),
924
+ tag: toBase64(cipher.getAuthTag()),
925
+ }
926
+ }
927
+
207
928
  function normalizeContinuitySnapshotEnvelope(input: unknown): ContinuitySnapshotEnvelope {
208
- if (!isContinuitySnapshotEnvelope(input)) throw new Error('invalid continuity snapshot envelope')
929
+ if (!isContinuitySnapshotEnvelope(input)) throw new Error('Invalid continuity snapshot envelope')
209
930
  if (input.envelopeVersion !== CONTINUITY_SNAPSHOT_ENVELOPE_VERSION) {
210
- throw new Error('unsupported continuity snapshot envelope version')
931
+ throw new Error('Unsupported continuity snapshot envelope version')
932
+ }
933
+ if (isWalletContinuitySnapshotEnvelope(input)) {
934
+ if (input.crypto.kem !== 'ML-KEM-1024' || input.crypto.aead !== 'AES-256-GCM' || input.crypto.decryptsWith !== 'wallet-signature-slots') {
935
+ throw new Error('Unsupported continuity snapshot crypto suite')
936
+ }
937
+ const ownerAddress = toChecksumAddress(input.ownerAddress)
938
+ const token = normalizeContinuitySnapshotToken(input.token)
939
+ const slots = input.slots.map(normalizeWalletSlot)
940
+ if (slots.length === 0) throw new Error('Continuity wallet snapshot needs at least one slot')
941
+ return {
942
+ ...input,
943
+ ownerAddress,
944
+ token,
945
+ slots,
946
+ }
947
+ }
948
+ if (isTransferContinuitySnapshotEnvelope(input)) {
949
+ if (input.crypto.aead !== 'AES-256-GCM' || input.crypto.decryptsWith !== 'transfer-signature-slot') {
950
+ throw new Error('Unsupported continuity snapshot crypto suite')
951
+ }
952
+ const ownerAddress = toChecksumAddress(input.ownerAddress)
953
+ const targetAddress = toChecksumAddress(input.targetAddress)
954
+ return {
955
+ ...input,
956
+ ownerAddress,
957
+ targetAddress,
958
+ token: normalizeContinuitySnapshotToken(input.token),
959
+ slots: {
960
+ owner: normalizeTransferSlot(input.slots.owner, ownerAddress),
961
+ target: normalizeTransferSlot(input.slots.target, targetAddress),
962
+ },
963
+ }
211
964
  }
212
965
  if (input.crypto.kem !== 'ML-KEM-1024' || input.crypto.aead !== 'AES-256-GCM') {
213
- throw new Error('unsupported continuity snapshot crypto suite')
966
+ throw new Error('Unsupported continuity snapshot crypto suite')
214
967
  }
215
968
  return {
216
969
  ...input,
@@ -220,28 +973,48 @@ function normalizeContinuitySnapshotEnvelope(input: unknown): ContinuitySnapshot
220
973
 
221
974
  function isContinuitySnapshotEnvelope(input: unknown): input is ContinuitySnapshotEnvelope {
222
975
  if (!input || typeof input !== 'object') return false
223
- const obj = input as Partial<ContinuitySnapshotEnvelope> & { walletSignature?: unknown }
224
- return obj.version === 1
976
+ const obj = input as Record<string, unknown> & { walletSignature?: unknown; crypto?: unknown }
977
+ const base = obj.version === 1
225
978
  && obj.envelopeVersion === CONTINUITY_SNAPSHOT_ENVELOPE_VERSION
226
979
  && typeof obj.ownerAddress === 'string'
227
980
  && typeof obj.createdAt === 'string'
228
- && typeof obj.challenge === 'string'
229
981
  && obj.walletSignature === undefined
982
+ && !!obj.crypto
983
+ if (!base) return false
984
+ if (isWalletContinuitySnapshotEnvelope(input)) return true
985
+ if (isTransferContinuitySnapshotEnvelope(input)) return true
986
+ return typeof obj.challenge === 'string'
230
987
  && typeof obj.salt === 'string'
231
988
  && typeof obj.kemPublicKey === 'string'
232
989
  && typeof obj.kemCiphertext === 'string'
233
990
  && typeof obj.nonce === 'string'
234
991
  && typeof obj.ciphertext === 'string'
235
992
  && typeof obj.tag === 'string'
236
- && !!obj.crypto
993
+ }
994
+
995
+ function continuityPayloadFromArgs(args: {
996
+ ownerAddress: string
997
+ createdAt: string
998
+ payload: Omit<ContinuitySnapshotPayload, 'version' | 'ownerAddress' | 'createdAt'> & { createdAt?: string }
999
+ }): ContinuitySnapshotPayload {
1000
+ return {
1001
+ version: 1,
1002
+ ownerAddress: args.ownerAddress,
1003
+ createdAt: args.createdAt,
1004
+ ...(args.payload.sequence !== undefined ? { sequence: args.payload.sequence } : {}),
1005
+ agent: normalizeAgentSnapshot(args.payload.agent),
1006
+ files: normalizeContinuityFiles(args.payload.files),
1007
+ transcript: normalizeTranscript(args.payload.transcript),
1008
+ state: normalizeState(args.payload.state),
1009
+ }
237
1010
  }
238
1011
 
239
1012
  function normalizeContinuityPayload(input: unknown): ContinuitySnapshotPayload {
240
- if (!input || typeof input !== 'object') throw new Error('continuity snapshot payload is invalid')
1013
+ if (!input || typeof input !== 'object') throw new Error('Continuity snapshot payload is invalid')
241
1014
  const obj = input as Partial<ContinuitySnapshotPayload>
242
- if (obj.version !== 1) throw new Error('continuity snapshot payload version is invalid')
243
- if (typeof obj.ownerAddress !== 'string') throw new Error('continuity snapshot owner is invalid')
244
- if (typeof obj.createdAt !== 'string') throw new Error('continuity snapshot timestamp is invalid')
1015
+ if (obj.version !== 1) throw new Error('Continuity snapshot payload version is invalid')
1016
+ if (typeof obj.ownerAddress !== 'string') throw new Error('Continuity snapshot owner is invalid')
1017
+ if (typeof obj.createdAt !== 'string') throw new Error('Continuity snapshot timestamp is invalid')
245
1018
  return {
246
1019
  version: 1,
247
1020
  ownerAddress: toChecksumAddress(obj.ownerAddress),
@@ -270,7 +1043,7 @@ function normalizeAgentSnapshot(input: unknown): ContinuityAgentSnapshot {
270
1043
 
271
1044
  function normalizeContinuityFiles(input: unknown): ContinuityFiles {
272
1045
  if (!input || typeof input !== 'object' || Array.isArray(input)) {
273
- throw new Error('continuity snapshot files are invalid')
1046
+ throw new Error('Continuity snapshot files are invalid')
274
1047
  }
275
1048
  const obj = input as Partial<ContinuityFiles>
276
1049
  if (typeof obj['SOUL.md'] !== 'string') throw new Error('SOUL.md is missing from continuity snapshot')
@@ -300,10 +1073,122 @@ function normalizeState(input: unknown): Record<string, unknown> {
300
1073
  return input as Record<string, unknown>
301
1074
  }
302
1075
 
1076
+ function normalizeContinuitySnapshotToken(input: unknown): ContinuitySnapshotToken {
1077
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
1078
+ throw new Error('Continuity snapshot token is invalid')
1079
+ }
1080
+ const obj = input as Partial<ContinuitySnapshotToken>
1081
+ if (typeof obj.chainId !== 'number' || !Number.isSafeInteger(obj.chainId) || obj.chainId <= 0) {
1082
+ throw new Error('Continuity snapshot token chain is invalid')
1083
+ }
1084
+ if (typeof obj.identityRegistryAddress !== 'string') {
1085
+ throw new Error('Continuity snapshot token registry is invalid')
1086
+ }
1087
+ if (typeof obj.agentId !== 'string' || !/^\d+$/.test(obj.agentId)) {
1088
+ throw new Error('Continuity snapshot token id is invalid')
1089
+ }
1090
+ return {
1091
+ chainId: obj.chainId,
1092
+ identityRegistryAddress: toChecksumAddress(obj.identityRegistryAddress),
1093
+ agentId: obj.agentId,
1094
+ }
1095
+ }
1096
+
1097
+ function normalizeTransferSlot(input: unknown, expectedAddress: string): TransferContinuitySnapshotSlot {
1098
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
1099
+ throw new Error('Continuity transfer slot is invalid')
1100
+ }
1101
+ const obj = input as Partial<TransferContinuitySnapshotSlot>
1102
+ if (typeof obj.address !== 'string') throw new Error('Continuity transfer slot address is invalid')
1103
+ const address = toChecksumAddress(obj.address)
1104
+ if (address.toLowerCase() !== expectedAddress.toLowerCase()) {
1105
+ throw new Error('Continuity transfer slot address mismatch')
1106
+ }
1107
+ if (typeof obj.challenge !== 'string') throw new Error('Continuity transfer slot challenge is invalid')
1108
+ if (typeof obj.salt !== 'string') throw new Error('Continuity transfer slot salt is invalid')
1109
+ if (typeof obj.nonce !== 'string') throw new Error('Continuity transfer slot nonce is invalid')
1110
+ if (typeof obj.encryptedKey !== 'string') throw new Error('Continuity transfer slot key is invalid')
1111
+ if (typeof obj.tag !== 'string') throw new Error('Continuity transfer slot tag is invalid')
1112
+ return {
1113
+ address,
1114
+ challenge: obj.challenge,
1115
+ salt: obj.salt,
1116
+ nonce: obj.nonce,
1117
+ encryptedKey: obj.encryptedKey,
1118
+ tag: obj.tag,
1119
+ }
1120
+ }
1121
+
1122
+ function normalizeWalletRestoreAccessKeys(input: unknown): WalletContinuityRestoreAccessKey[] {
1123
+ if (!Array.isArray(input)) return []
1124
+ const out: WalletContinuityRestoreAccessKey[] = []
1125
+ const seen = new Set<string>()
1126
+ for (const item of input) {
1127
+ const key = normalizeWalletRestoreAccessKey(item)
1128
+ const dedupe = key.address.toLowerCase()
1129
+ if (seen.has(dedupe)) continue
1130
+ seen.add(dedupe)
1131
+ out.push(key)
1132
+ }
1133
+ return out.sort((a, b) => a.address.toLowerCase().localeCompare(b.address.toLowerCase()))
1134
+ }
1135
+
1136
+ function normalizeWalletRestoreAccessKey(input: unknown): WalletContinuityRestoreAccessKey {
1137
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
1138
+ throw new Error('Wallet restore access key is invalid')
1139
+ }
1140
+ const obj = input as Partial<WalletContinuityRestoreAccessKey>
1141
+ if (typeof obj.address !== 'string') throw new Error('Wallet restore access address is invalid')
1142
+ if (typeof obj.challenge !== 'string') throw new Error('Wallet restore access challenge is invalid')
1143
+ if (typeof obj.salt !== 'string') throw new Error('Wallet restore access salt is invalid')
1144
+ if (typeof obj.kemPublicKey !== 'string') throw new Error('Wallet restore access public key is invalid')
1145
+ return {
1146
+ address: toChecksumAddress(obj.address),
1147
+ challenge: obj.challenge,
1148
+ salt: obj.salt,
1149
+ kemPublicKey: obj.kemPublicKey,
1150
+ ...(typeof obj.createdAt === 'string' ? { createdAt: obj.createdAt } : {}),
1151
+ }
1152
+ }
1153
+
1154
+ function normalizeWalletSlot(input: unknown): WalletContinuitySnapshotSlot {
1155
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
1156
+ throw new Error('Continuity wallet slot is invalid')
1157
+ }
1158
+ const obj = input as Partial<WalletContinuitySnapshotSlot>
1159
+ if (typeof obj.address !== 'string') throw new Error('Continuity wallet slot address is invalid')
1160
+ if (typeof obj.challenge !== 'string') throw new Error('Continuity wallet slot challenge is invalid')
1161
+ if (typeof obj.salt !== 'string') throw new Error('Continuity wallet slot salt is invalid')
1162
+ if (typeof obj.kemPublicKey !== 'string') throw new Error('Continuity wallet slot public key is invalid')
1163
+ if (typeof obj.kemCiphertext !== 'string') throw new Error('Continuity wallet slot ciphertext is invalid')
1164
+ if (typeof obj.nonce !== 'string') throw new Error('Continuity wallet slot nonce is invalid')
1165
+ if (typeof obj.encryptedKey !== 'string') throw new Error('Continuity wallet slot key is invalid')
1166
+ if (typeof obj.tag !== 'string') throw new Error('Continuity wallet slot tag is invalid')
1167
+ return {
1168
+ address: toChecksumAddress(obj.address),
1169
+ challenge: obj.challenge,
1170
+ salt: obj.salt,
1171
+ kemPublicKey: obj.kemPublicKey,
1172
+ kemCiphertext: obj.kemCiphertext,
1173
+ nonce: obj.nonce,
1174
+ encryptedKey: obj.encryptedKey,
1175
+ tag: obj.tag,
1176
+ }
1177
+ }
1178
+
1179
+ function assertPayloadMatchesEnvelope(payload: ContinuitySnapshotPayload, ownerAddress: string, createdAt: string): void {
1180
+ if (payload.ownerAddress.toLowerCase() !== ownerAddress.toLowerCase()) {
1181
+ throw new Error('Continuity snapshot owner mismatch')
1182
+ }
1183
+ if (payload.createdAt !== createdAt) {
1184
+ throw new Error('Continuity snapshot timestamp mismatch')
1185
+ }
1186
+ }
1187
+
303
1188
  function assertSignatureForAddress(challenge: string, signature: string, address: string): void {
304
1189
  const recovered = recoverAddressFromSignature(challenge, signature)
305
1190
  if (recovered.toLowerCase() !== address.toLowerCase()) {
306
- throw new Error('wallet signature does not match continuity snapshot owner')
1191
+ throw new Error('Wallet signature does not match continuity snapshot owner')
307
1192
  }
308
1193
  }
309
1194
 
@@ -334,14 +1219,137 @@ function deriveContinuityAesKey(
334
1219
  ))
335
1220
  }
336
1221
 
1222
+ function deriveTransferSlotKey(walletSignature: string, salt: Uint8Array, address: string, challenge: string): Buffer {
1223
+ return Buffer.from(hkdf(
1224
+ Buffer.from(walletSignature, 'utf8'),
1225
+ salt,
1226
+ `ethagent:${CONTINUITY_SNAPSHOT_ENVELOPE_VERSION}:transfer-slot:${address.toLowerCase()}:${sha256Hex(challenge)}`,
1227
+ 32,
1228
+ ))
1229
+ }
1230
+
1231
+ function deriveWalletRestoreKemSeed(walletSignature: string, salt: Uint8Array, address: string, challenge: string): Uint8Array {
1232
+ return hkdf(
1233
+ Buffer.from(walletSignature, 'utf8'),
1234
+ salt,
1235
+ `ethagent:${CONTINUITY_SNAPSHOT_ENVELOPE_VERSION}:wallet-restore-kem:${address.toLowerCase()}:${sha256Hex(challenge)}`,
1236
+ 64,
1237
+ )
1238
+ }
1239
+
1240
+ function deriveWalletSlotAesKey(
1241
+ sharedSecret: Uint8Array,
1242
+ salt: Uint8Array,
1243
+ address: string,
1244
+ challenge: string,
1245
+ accessManifestHash: string,
1246
+ ): Buffer {
1247
+ return Buffer.from(hkdf(
1248
+ sharedSecret,
1249
+ salt,
1250
+ `ethagent:${CONTINUITY_SNAPSHOT_ENVELOPE_VERSION}:wallet-slot:${address.toLowerCase()}:${sha256Hex(challenge)}:${accessManifestHash}`,
1251
+ 32,
1252
+ ))
1253
+ }
1254
+
337
1255
  function continuityAadFor(ownerAddress: string, createdAt: string): Buffer {
338
1256
  return Buffer.from(`${CONTINUITY_SNAPSHOT_ENVELOPE_VERSION}\n${ownerAddress.toLowerCase()}\n${createdAt}`, 'utf8')
339
1257
  }
340
1258
 
1259
+ function walletAccessManifestHash(args: {
1260
+ ownerAddress: string
1261
+ token: ContinuitySnapshotToken
1262
+ accessEpoch: number
1263
+ accessKeys: WalletContinuityRestoreAccessKey[]
1264
+ }): string {
1265
+ const token = normalizeContinuitySnapshotToken(args.token)
1266
+ const accessKeys = normalizeWalletRestoreAccessKeys(args.accessKeys)
1267
+ return sha256Hex(JSON.stringify({
1268
+ ownerAddress: toChecksumAddress(args.ownerAddress).toLowerCase(),
1269
+ token: {
1270
+ chainId: token.chainId,
1271
+ identityRegistryAddress: token.identityRegistryAddress.toLowerCase(),
1272
+ agentId: token.agentId,
1273
+ },
1274
+ accessEpoch: args.accessEpoch,
1275
+ wallets: accessKeys.map(key => ({
1276
+ address: key.address.toLowerCase(),
1277
+ challengeHash: sha256Hex(key.challenge),
1278
+ salt: key.salt,
1279
+ kemPublicKey: key.kemPublicKey,
1280
+ })),
1281
+ }))
1282
+ }
1283
+
1284
+ function walletPayloadAadFor(args: {
1285
+ ownerAddress: string
1286
+ createdAt: string
1287
+ token: ContinuitySnapshotToken
1288
+ accessEpoch: number
1289
+ accessManifestHash: string
1290
+ }): Buffer {
1291
+ const token = normalizeContinuitySnapshotToken(args.token)
1292
+ return Buffer.from([
1293
+ CONTINUITY_SNAPSHOT_ENVELOPE_VERSION,
1294
+ 'wallet-signature-slots',
1295
+ args.ownerAddress.toLowerCase(),
1296
+ args.createdAt,
1297
+ String(token.chainId),
1298
+ token.identityRegistryAddress.toLowerCase(),
1299
+ token.agentId,
1300
+ String(args.accessEpoch),
1301
+ args.accessManifestHash,
1302
+ ].join('\n'), 'utf8')
1303
+ }
1304
+
1305
+ function transferPayloadAadFor(args: {
1306
+ ownerAddress: string
1307
+ targetAddress: string
1308
+ createdAt: string
1309
+ token: ContinuitySnapshotToken
1310
+ }): Buffer {
1311
+ const token = normalizeContinuitySnapshotToken(args.token)
1312
+ return Buffer.from([
1313
+ CONTINUITY_SNAPSHOT_ENVELOPE_VERSION,
1314
+ 'transfer',
1315
+ args.ownerAddress.toLowerCase(),
1316
+ args.targetAddress.toLowerCase(),
1317
+ args.createdAt,
1318
+ String(token.chainId),
1319
+ token.identityRegistryAddress.toLowerCase(),
1320
+ token.agentId,
1321
+ ].join('\n'), 'utf8')
1322
+ }
1323
+
1324
+ function walletSlotAadFor(slot: Pick<WalletContinuitySnapshotSlot, 'address' | 'challenge' | 'kemPublicKey' | 'kemCiphertext'>, payloadAad: Buffer): Buffer {
1325
+ return Buffer.concat([
1326
+ payloadAad,
1327
+ Buffer.from([
1328
+ '',
1329
+ 'wallet-slot',
1330
+ slot.address.toLowerCase(),
1331
+ sha256Hex(slot.challenge),
1332
+ slot.kemPublicKey,
1333
+ slot.kemCiphertext,
1334
+ ].join('\n'), 'utf8'),
1335
+ ])
1336
+ }
1337
+
1338
+ function transferSlotAadFor(slot: Pick<TransferContinuitySnapshotSlot, 'address' | 'challenge'>, payloadAad: Buffer): Buffer {
1339
+ return Buffer.concat([
1340
+ payloadAad,
1341
+ Buffer.from(`\nslot\n${slot.address.toLowerCase()}\n${sha256Hex(slot.challenge)}`, 'utf8'),
1342
+ ])
1343
+ }
1344
+
341
1345
  function hkdf(ikm: Uint8Array, salt: Uint8Array, info: string, length: number): Uint8Array {
342
1346
  return new Uint8Array(crypto.hkdfSync('sha256', ikm, salt, Buffer.from(info, 'utf8'), length))
343
1347
  }
344
1348
 
1349
+ function sha256Hex(value: string | Uint8Array): string {
1350
+ return crypto.createHash('sha256').update(value).digest('hex')
1351
+ }
1352
+
345
1353
  function toBase64(bytes: Uint8Array): string {
346
1354
  return Buffer.from(bytes).toString('base64')
347
1355
  }