@authon/react-native-wallet 0.0.0-stage → 0.4.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 (183) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +251 -2
  3. package/android/build.gradle +40 -0
  4. package/android/consumer-rules.pro +10 -0
  5. package/android/src/main/AndroidManifest.xml +8 -0
  6. package/android/src/main/java/dev/authon/reactnative/wallet/AuthonWalletModule.kt +235 -0
  7. package/android/src/vendor/GENERATED.md +4 -0
  8. package/android/src/vendor/kotlin/dev/authon/sdk/wallet/AndroidCapabilityProbe.kt +86 -0
  9. package/android/src/vendor/kotlin/dev/authon/sdk/wallet/AuthonWallet.kt +218 -0
  10. package/android/src/vendor/kotlin/dev/authon/sdk/wallet/Bitcoin.kt +296 -0
  11. package/android/src/vendor/kotlin/dev/authon/sdk/wallet/Bytes.kt +74 -0
  12. package/android/src/vendor/kotlin/dev/authon/sdk/wallet/Chains.kt +157 -0
  13. package/android/src/vendor/kotlin/dev/authon/sdk/wallet/CredentialManagerPasskeys.kt +192 -0
  14. package/android/src/vendor/kotlin/dev/authon/sdk/wallet/Der.kt +17 -0
  15. package/android/src/vendor/kotlin/dev/authon/sdk/wallet/DeviceStore.kt +170 -0
  16. package/android/src/vendor/kotlin/dev/authon/sdk/wallet/DialogWalletApprover.kt +141 -0
  17. package/android/src/vendor/kotlin/dev/authon/sdk/wallet/Eip712.kt +176 -0
  18. package/android/src/vendor/kotlin/dev/authon/sdk/wallet/Encodings.kt +133 -0
  19. package/android/src/vendor/kotlin/dev/authon/sdk/wallet/EvmAccount.kt +45 -0
  20. package/android/src/vendor/kotlin/dev/authon/sdk/wallet/EvmTransaction.kt +180 -0
  21. package/android/src/vendor/kotlin/dev/authon/sdk/wallet/GfTables.kt +44 -0
  22. package/android/src/vendor/kotlin/dev/authon/sdk/wallet/HdWallet.kt +130 -0
  23. package/android/src/vendor/kotlin/dev/authon/sdk/wallet/Http.kt +86 -0
  24. package/android/src/vendor/kotlin/dev/authon/sdk/wallet/JsValues.kt +83 -0
  25. package/android/src/vendor/kotlin/dev/authon/sdk/wallet/KeystoreDeviceStore.kt +236 -0
  26. package/android/src/vendor/kotlin/dev/authon/sdk/wallet/Prf.kt +111 -0
  27. package/android/src/vendor/kotlin/dev/authon/sdk/wallet/Proof.kt +134 -0
  28. package/android/src/vendor/kotlin/dev/authon/sdk/wallet/Shamir.kt +106 -0
  29. package/android/src/vendor/kotlin/dev/authon/sdk/wallet/SignSummary.kt +335 -0
  30. package/android/src/vendor/kotlin/dev/authon/sdk/wallet/Solana.kt +195 -0
  31. package/android/src/vendor/kotlin/dev/authon/sdk/wallet/Tron.kt +184 -0
  32. package/android/src/vendor/kotlin/dev/authon/sdk/wallet/WalletApi.kt +361 -0
  33. package/android/src/vendor/kotlin/dev/authon/sdk/wallet/WalletCapabilities.kt +91 -0
  34. package/android/src/vendor/kotlin/dev/authon/sdk/wallet/WalletError.kt +74 -0
  35. package/android/src/vendor/kotlin/dev/authon/sdk/wallet/WalletOps.kt +551 -0
  36. package/android/src/vendor/kotlin/dev/authon/sdk/wallet/WalletTypes.kt +78 -0
  37. package/android/src/vendor/kotlin/dev/authon/sdk/wallet/Xrp.kt +193 -0
  38. package/build/authonContext.d.ts +19 -0
  39. package/build/authonContext.d.ts.map +1 -0
  40. package/build/authonContext.js +20 -0
  41. package/build/authonContext.js.map +1 -0
  42. package/build/client.d.ts +92 -0
  43. package/build/client.d.ts.map +1 -0
  44. package/build/client.js +431 -0
  45. package/build/client.js.map +1 -0
  46. package/build/errors.d.ts +25 -0
  47. package/build/errors.d.ts.map +1 -0
  48. package/build/errors.js +15 -0
  49. package/build/errors.js.map +1 -0
  50. package/build/index.d.ts +7 -0
  51. package/build/index.d.ts.map +1 -0
  52. package/build/index.js +4 -0
  53. package/build/index.js.map +1 -0
  54. package/build/native.d.ts +43 -0
  55. package/build/native.d.ts.map +1 -0
  56. package/build/native.js +12 -0
  57. package/build/native.js.map +1 -0
  58. package/build/types.d.ts +167 -0
  59. package/build/types.d.ts.map +1 -0
  60. package/build/types.js +3 -0
  61. package/build/types.js.map +1 -0
  62. package/build/useAuthonWallet.d.ts +25 -0
  63. package/build/useAuthonWallet.d.ts.map +1 -0
  64. package/build/useAuthonWallet.js +77 -0
  65. package/build/useAuthonWallet.js.map +1 -0
  66. package/expo-module.config.json +9 -0
  67. package/ios/AuthonReactNativeWallet.podspec +41 -0
  68. package/ios/AuthonWalletModule.swift +233 -0
  69. package/ios/vendor/AuthonWallet/AuthonWallet.swift +232 -0
  70. package/ios/vendor/AuthonWallet/Capabilities.swift +149 -0
  71. package/ios/vendor/AuthonWallet/Chains/Bitcoin.swift +296 -0
  72. package/ios/vendor/AuthonWallet/Chains/Solana.swift +207 -0
  73. package/ios/vendor/AuthonWallet/Chains/Tron.swift +181 -0
  74. package/ios/vendor/AuthonWallet/Chains/XRP.swift +191 -0
  75. package/ios/vendor/AuthonWallet/Crypto/BIP39Wordlist.swift +262 -0
  76. package/ios/vendor/AuthonWallet/Crypto/Chains.swift +278 -0
  77. package/ios/vendor/AuthonWallet/Crypto/HDKey.swift +108 -0
  78. package/ios/vendor/AuthonWallet/Crypto/Secp256k1.swift +128 -0
  79. package/ios/vendor/AuthonWallet/Crypto/Shamir.swift +123 -0
  80. package/ios/vendor/AuthonWallet/EVM/Address.swift +57 -0
  81. package/ios/vendor/AuthonWallet/EVM/EvmAccount.swift +48 -0
  82. package/ios/vendor/AuthonWallet/EVM/Transaction.swift +165 -0
  83. package/ios/vendor/AuthonWallet/EVM/TypedData.swift +193 -0
  84. package/ios/vendor/AuthonWallet/Support/BigInteger.swift +220 -0
  85. package/ios/vendor/AuthonWallet/Support/Bytes.swift +89 -0
  86. package/ios/vendor/AuthonWallet/Support/JSONValue.swift +341 -0
  87. package/ios/vendor/AuthonWallet/UI/WalletApprover.swift +250 -0
  88. package/ios/vendor/AuthonWallet/Wallet/Backup.swift +69 -0
  89. package/ios/vendor/AuthonWallet/Wallet/DeviceStore.swift +351 -0
  90. package/ios/vendor/AuthonWallet/Wallet/Passkeys.swift +190 -0
  91. package/ios/vendor/AuthonWallet/Wallet/Proof.swift +143 -0
  92. package/ios/vendor/AuthonWallet/Wallet/Shares.swift +116 -0
  93. package/ios/vendor/AuthonWallet/Wallet/Summary.swift +342 -0
  94. package/ios/vendor/AuthonWallet/Wallet/WalletAPI.swift +361 -0
  95. package/ios/vendor/AuthonWallet/Wallet/WalletOps.swift +654 -0
  96. package/ios/vendor/AuthonWallet/WalletError.swift +84 -0
  97. package/ios/vendor/CKeccak/LICENSE +22 -0
  98. package/ios/vendor/CKeccak/README.md +30 -0
  99. package/ios/vendor/CKeccak/byte_order.h +59 -0
  100. package/ios/vendor/CKeccak/include/ckeccak.h +13 -0
  101. package/ios/vendor/CKeccak/memzero.c +75 -0
  102. package/ios/vendor/CKeccak/memzero.h +8 -0
  103. package/ios/vendor/CKeccak/options.h +99 -0
  104. package/ios/vendor/CKeccak/ripemd160.c +343 -0
  105. package/ios/vendor/CKeccak/ripemd160.h +22 -0
  106. package/ios/vendor/CKeccak/sha3.c +413 -0
  107. package/ios/vendor/CKeccak/sha3.h +89 -0
  108. package/ios/vendor/CMonocypher/LICENCE.md +173 -0
  109. package/ios/vendor/CMonocypher/README.md +27 -0
  110. package/ios/vendor/CMonocypher/include/monocypher-ed25519.h +140 -0
  111. package/ios/vendor/CMonocypher/include/monocypher.h +321 -0
  112. package/ios/vendor/CMonocypher/monocypher-ed25519.c +500 -0
  113. package/ios/vendor/CMonocypher/monocypher.c +2956 -0
  114. package/ios/vendor/CSecp256k1/COPYING +19 -0
  115. package/ios/vendor/CSecp256k1/README.md +35 -0
  116. package/ios/vendor/CSecp256k1/build/precomputed_ecmult.c +3 -0
  117. package/ios/vendor/CSecp256k1/build/precomputed_ecmult_gen.c +3 -0
  118. package/ios/vendor/CSecp256k1/build/secp256k1.c +3 -0
  119. package/ios/vendor/CSecp256k1/include/secp256k1.h +899 -0
  120. package/ios/vendor/CSecp256k1/include/secp256k1_preallocated.h +134 -0
  121. package/ios/vendor/CSecp256k1/include/secp256k1_recovery.h +113 -0
  122. package/ios/vendor/CSecp256k1/src/assumptions.h +87 -0
  123. package/ios/vendor/CSecp256k1/src/checkmem.h +102 -0
  124. package/ios/vendor/CSecp256k1/src/ecdsa.h +21 -0
  125. package/ios/vendor/CSecp256k1/src/ecdsa_impl.h +304 -0
  126. package/ios/vendor/CSecp256k1/src/eckey.h +25 -0
  127. package/ios/vendor/CSecp256k1/src/eckey_impl.h +92 -0
  128. package/ios/vendor/CSecp256k1/src/ecmult.h +61 -0
  129. package/ios/vendor/CSecp256k1/src/ecmult_const.h +38 -0
  130. package/ios/vendor/CSecp256k1/src/ecmult_const_impl.h +399 -0
  131. package/ios/vendor/CSecp256k1/src/ecmult_gen.h +143 -0
  132. package/ios/vendor/CSecp256k1/src/ecmult_gen_impl.h +341 -0
  133. package/ios/vendor/CSecp256k1/src/ecmult_impl.h +853 -0
  134. package/ios/vendor/CSecp256k1/src/field.h +350 -0
  135. package/ios/vendor/CSecp256k1/src/field_10x26.h +57 -0
  136. package/ios/vendor/CSecp256k1/src/field_10x26_impl.h +1232 -0
  137. package/ios/vendor/CSecp256k1/src/field_5x52.h +62 -0
  138. package/ios/vendor/CSecp256k1/src/field_5x52_impl.h +522 -0
  139. package/ios/vendor/CSecp256k1/src/field_5x52_int128_impl.h +274 -0
  140. package/ios/vendor/CSecp256k1/src/field_impl.h +457 -0
  141. package/ios/vendor/CSecp256k1/src/group.h +212 -0
  142. package/ios/vendor/CSecp256k1/src/group_impl.h +974 -0
  143. package/ios/vendor/CSecp256k1/src/hash.h +44 -0
  144. package/ios/vendor/CSecp256k1/src/hash_impl.h +299 -0
  145. package/ios/vendor/CSecp256k1/src/hsort.h +33 -0
  146. package/ios/vendor/CSecp256k1/src/hsort_impl.h +125 -0
  147. package/ios/vendor/CSecp256k1/src/int128.h +90 -0
  148. package/ios/vendor/CSecp256k1/src/int128_impl.h +18 -0
  149. package/ios/vendor/CSecp256k1/src/int128_native.h +19 -0
  150. package/ios/vendor/CSecp256k1/src/int128_native_impl.h +94 -0
  151. package/ios/vendor/CSecp256k1/src/int128_struct.h +14 -0
  152. package/ios/vendor/CSecp256k1/src/int128_struct_impl.h +205 -0
  153. package/ios/vendor/CSecp256k1/src/modinv32.h +43 -0
  154. package/ios/vendor/CSecp256k1/src/modinv32_impl.h +725 -0
  155. package/ios/vendor/CSecp256k1/src/modinv64.h +47 -0
  156. package/ios/vendor/CSecp256k1/src/modinv64_impl.h +780 -0
  157. package/ios/vendor/CSecp256k1/src/modules/recovery/main_impl.h +159 -0
  158. package/ios/vendor/CSecp256k1/src/precomputed_ecmult.c +16456 -0
  159. package/ios/vendor/CSecp256k1/src/precomputed_ecmult.h +38 -0
  160. package/ios/vendor/CSecp256k1/src/precomputed_ecmult_gen.c +1779 -0
  161. package/ios/vendor/CSecp256k1/src/precomputed_ecmult_gen.h +26 -0
  162. package/ios/vendor/CSecp256k1/src/scalar.h +105 -0
  163. package/ios/vendor/CSecp256k1/src/scalar_4x64.h +19 -0
  164. package/ios/vendor/CSecp256k1/src/scalar_4x64_impl.h +1000 -0
  165. package/ios/vendor/CSecp256k1/src/scalar_8x32.h +19 -0
  166. package/ios/vendor/CSecp256k1/src/scalar_8x32_impl.h +816 -0
  167. package/ios/vendor/CSecp256k1/src/scalar_impl.h +321 -0
  168. package/ios/vendor/CSecp256k1/src/scalar_low.h +24 -0
  169. package/ios/vendor/CSecp256k1/src/scalar_low_impl.h +206 -0
  170. package/ios/vendor/CSecp256k1/src/scratch.h +44 -0
  171. package/ios/vendor/CSecp256k1/src/scratch_impl.h +99 -0
  172. package/ios/vendor/CSecp256k1/src/secp256k1.c +831 -0
  173. package/ios/vendor/CSecp256k1/src/selftest.h +32 -0
  174. package/ios/vendor/CSecp256k1/src/util.h +451 -0
  175. package/ios/vendor/GENERATED.md +4 -0
  176. package/package.json +66 -3
  177. package/src/authonContext.ts +30 -0
  178. package/src/client.ts +459 -0
  179. package/src/errors.ts +58 -0
  180. package/src/index.ts +26 -0
  181. package/src/native.ts +52 -0
  182. package/src/types.ts +171 -0
  183. package/src/useAuthonWallet.ts +103 -0
@@ -0,0 +1,654 @@
1
+ // GENERATED by scripts/sync-native-wallet.mjs from swift/Sources/AuthonWallet/Wallet/WalletOps.swift. Do not edit: change the source and run the script.
2
+ import Foundation
3
+
4
+ public struct WalletStatus: Equatable, Sendable {
5
+ public var exists: Bool
6
+ /// The server has a wallet that was never confirmed (device share not persisted).
7
+ public var pending: Bool
8
+ public var hasLocalShare: Bool
9
+ public var needsRestore: Bool
10
+ public var deviceId: String?
11
+ public var wallet: ServerWallet?
12
+ }
13
+
14
+ /// What apps see (ops.ts toPublicWallet).
15
+ public struct PublicWallet: Equatable, Sendable {
16
+ public struct Device: Equatable, Sendable {
17
+ public var id: String
18
+ public var label: String?
19
+ public var createdAt: String?
20
+ public var lastUsedAt: String?
21
+ public var current: Bool
22
+ }
23
+
24
+ public var address: String
25
+ /// False until a passkey backup exists. An unprotected wallet is lost with this device.
26
+ public var isProtected: Bool
27
+ /// A legacy backup without a passkey public key; call upgradeBackup() from a device that holds the wallet.
28
+ public var backupUpgradeRecommended: Bool
29
+ public var needsRestore: Bool
30
+ public var devices: [Device]
31
+
32
+ init(_ st: WalletStatus) throws {
33
+ guard let w = st.wallet, !st.pending else { throw WalletError(.notFound) }
34
+ address = w.address
35
+ isProtected = w.isProtected
36
+ backupUpgradeRecommended = w.isProtected && w.backupVerifiable == false
37
+ needsRestore = st.needsRestore
38
+ devices = w.devices.filter { $0.revokedAt == nil }.map {
39
+ Device(id: $0.id, label: $0.label, createdAt: $0.createdAt, lastUsedAt: $0.lastUsedAt, current: $0.id == st.deviceId)
40
+ }
41
+ }
42
+ }
43
+
44
+ public struct WalletAccount: Equatable, Sendable {
45
+ public var index: Int
46
+ public var path: String
47
+ public var address: String
48
+ public var chain: WalletChain
49
+ /// XRP only: the compressed public key (uppercase hex) to put in SigningPubKey.
50
+ public var publicKey: String? = nil
51
+ }
52
+
53
+ /// Which account signs. Account 0 is the wallet address the server knows; another account
54
+ /// must name the address the caller expects, and signing is refused when it differs.
55
+ public struct SignAccount: Equatable, Sendable {
56
+ public var index: Int
57
+ public var address: String?
58
+
59
+ public static let primary = SignAccount(index: 0, address: nil)
60
+
61
+ public init(index: Int, address: String?) {
62
+ self.index = index
63
+ self.address = address
64
+ }
65
+
66
+ /// ops.ts parseSignAccount.
67
+ public static func parse(index: Int?, address: String?) throws -> SignAccount {
68
+ guard let index, index != 0 else { return .primary }
69
+ guard WalletShares.isAccountIndex(index) else {
70
+ throw WalletError(.invalidRequest, "account must be an integer 0..\(WalletShares.maxAccountIndex)")
71
+ }
72
+ guard let address, address.count == 42, address.hasPrefix("0x"), Bytes.isHex(address) else {
73
+ throw WalletError(.invalidRequest, "address of the signing account required")
74
+ }
75
+ return SignAccount(index: index, address: address)
76
+ }
77
+ }
78
+
79
+ /// A personal_sign payload.
80
+ public enum WalletMessage: Equatable, Sendable {
81
+ case text(String)
82
+ /// 0x-prefixed hex bytes.
83
+ case raw(String)
84
+ }
85
+
86
+ enum ParsedMessage: Equatable {
87
+ case text(String)
88
+ case raw(String)
89
+ }
90
+
91
+ public enum RestoreMode: String, Sendable {
92
+ /// Enroll this device on the current shares; other devices keep working.
93
+ case add
94
+ /// Re-split the key and revoke every other device (lost device).
95
+ case reset
96
+ }
97
+
98
+ public struct ProtectResult: Equatable, Sendable {
99
+ public var shareVersion: Int
100
+ }
101
+
102
+ public struct RestoreResult: Equatable, Sendable {
103
+ public var shareVersion: Int
104
+ public var backupUpgradeRecommended: Bool
105
+ }
106
+
107
+ struct WalletContext {
108
+ var api: WalletAPIClient
109
+ var store: DeviceStore
110
+ var passkeys: PasskeyProvider
111
+ var projectId: String
112
+ /// From the project's server-side settings only.
113
+ var confirmSigning: Bool
114
+ var backupVersion: BackupVersion
115
+ var approver: WalletApprover?
116
+ var deviceAuthenticator: DeviceAuthenticator?
117
+ }
118
+
119
+ /// The wallet operations, step for step the same as `apps/wallet/src/ops.ts`.
120
+ struct WalletOps {
121
+ let ctx: WalletContext
122
+
123
+ static func decodeUserId(_ session: String) throws -> String {
124
+ let parts = session.split(separator: ".", omittingEmptySubsequences: false)
125
+ guard parts.count > 1, !parts[1].isEmpty else { throw WalletError(.unauthorized, "malformed session token") }
126
+ guard let bytes = Bytes.fromBase64(String(parts[1])),
127
+ let claims = try? JSONValue.parse(String(decoding: bytes, as: UTF8.self)),
128
+ case .string(let sub) = claims["sub"] ?? .null, !sub.isEmpty else {
129
+ throw WalletError(.unauthorized, "malformed session token")
130
+ }
131
+ return sub
132
+ }
133
+
134
+ private func recordKey(_ session: String) throws -> String {
135
+ storageKey(projectId: ctx.projectId, userId: try Self.decodeUserId(session))
136
+ }
137
+
138
+ func status(_ session: String) async throws -> WalletStatus {
139
+ let wallet = try await ctx.api.getMe(session)
140
+ let local = try ctx.store.get(try recordKey(session))
141
+ let pending = wallet?.status == "pending"
142
+ // A local share only counts while the server still trusts this device at the current share version.
143
+ var hasLocal = false
144
+ if !pending, let local, let wallet, local.walletId == wallet.id,
145
+ let device = wallet.devices.first(where: { $0.id == local.deviceId }), device.revokedAt == nil {
146
+ hasLocal = (device.shareVersion ?? local.shareVersion) == wallet.shareVersion && local.shareVersion == wallet.shareVersion
147
+ }
148
+ let exists = wallet != nil && !pending
149
+ return WalletStatus(
150
+ exists: exists,
151
+ pending: pending,
152
+ hasLocalShare: hasLocal,
153
+ needsRestore: exists && !hasLocal,
154
+ deviceId: hasLocal ? local?.deviceId : nil,
155
+ wallet: wallet
156
+ )
157
+ }
158
+
159
+ private func deviceProof(_ session: String, _ record: DeviceRecord) async throws -> DeviceProof {
160
+ let challenge = try await ctx.api.challenge(session, deviceId: record.deviceId)
161
+ let signature = Bytes.base64(try ctx.store.sign(challenge: challenge, record: record))
162
+ return DeviceProof(deviceId: record.deviceId, challenge: challenge, signature: signature)
163
+ }
164
+
165
+ /// Reads the record back and checks it opens to `share`; storage that drops writes fails here.
166
+ @discardableResult
167
+ private func verifyPersisted(_ key: String, deviceId: String, walletId: String, share: [UInt8]?) throws -> DeviceRecord {
168
+ guard let stored = try? ctx.store.get(key), stored.deviceId == deviceId, stored.walletId == walletId else {
169
+ throw WalletError(.storageUnavailable, "device share was not persisted")
170
+ }
171
+ if let share {
172
+ var opened: [UInt8]
173
+ do {
174
+ opened = try ctx.store.open(stored)
175
+ } catch {
176
+ throw WalletError(.storageUnavailable, "device share cannot be read back")
177
+ }
178
+ defer { opened.wipe() }
179
+ guard opened == share else { throw WalletError(.storageUnavailable, "device share read back differs") }
180
+ }
181
+ return stored
182
+ }
183
+
184
+ /// Confirms a pending wallet whose device record is present and readable here.
185
+ private func finishPending(_ session: String, _ wallet: ServerWallet) async throws -> Bool {
186
+ let key = try recordKey(session)
187
+ guard let record = try? ctx.store.get(key), record.walletId == wallet.id,
188
+ wallet.devices.contains(where: { $0.id == record.deviceId }) else { return false }
189
+ do {
190
+ var share = try ctx.store.open(record)
191
+ defer { share.wipe() }
192
+ try verifyPersisted(key, deviceId: record.deviceId, walletId: wallet.id, share: share)
193
+ } catch {
194
+ return false
195
+ }
196
+ try await ctx.api.confirmWallet(session, try await deviceProof(session, record))
197
+ return true
198
+ }
199
+
200
+ /// Two-phase creation: register address and A (pending), store D and read it back, then
201
+ /// confirm with a challenge signed by the stored device key. A pending wallet left by an
202
+ /// interrupted attempt is confirmed (D is here) or discarded and recreated (D is not).
203
+ func create(_ session: String, attempt: Int = 0) async throws -> WalletStatus {
204
+ let existing = try await status(session)
205
+ if existing.exists { return existing }
206
+ if existing.pending, let w = existing.wallet {
207
+ if try await finishPending(session, w) { return try await status(session) }
208
+ if attempt > 0 { throw WalletError(.storageUnavailable) }
209
+ try await ctx.api.discardPending(session)
210
+ try? ctx.store.delete(try recordKey(session))
211
+ return try await create(session, attempt: attempt + 1)
212
+ }
213
+
214
+ let key = try recordKey(session)
215
+ let userId = try Self.decodeUserId(session)
216
+ var secret = try WalletShares.generateSecret()
217
+ var device = [UInt8]()
218
+ var company = [UInt8]()
219
+ var registered = false
220
+ var keys: DeviceKeys?
221
+ var stored = false
222
+ defer {
223
+ secret.wipe()
224
+ device.wipe()
225
+ company.wipe()
226
+ }
227
+ do {
228
+ let address = try WalletShares.address(secret)
229
+ (device, company) = try WalletShares.splitUnprotected(secret)
230
+ let k = try ctx.store.generateKeys()
231
+ keys = k
232
+ let sealed = try ctx.store.seal(device, keys: k, storageKey: key)
233
+ let bound = try await ctx.api.proofChallenge(session, kind: .create)
234
+ let proof = try WalletProof.signCreate(secret, projectId: ctx.projectId, userId: userId, deviceKey: k.publicJwk, bound: bound)
235
+ let created = try await ctx.api.createWallet(session, address: address, companyShare: Bytes.base64(company), devicePublicKey: k.publicJwk, proof: proof, bound: bound)
236
+ registered = true
237
+ let record = DeviceRecord(
238
+ key: key, deviceId: created.deviceId, walletId: created.walletId, address: address,
239
+ shareVersion: created.shareVersion, createdAt: Date().timeIntervalSince1970 * 1000,
240
+ publicJwk: k.publicJwk, keyHandle: k.handle, sealedShare: sealed
241
+ )
242
+ do {
243
+ try ctx.store.put(record)
244
+ } catch {
245
+ throw WalletError(.storageUnavailable, "device share was not persisted")
246
+ }
247
+ stored = true
248
+ let readBack = try verifyPersisted(key, deviceId: created.deviceId, walletId: created.walletId, share: device)
249
+ try await ctx.api.confirmWallet(session, try await deviceProof(session, readBack))
250
+ } catch {
251
+ if !stored, let keys { ctx.store.discardKeys(keys) }
252
+ if let e = error as? WalletError, e.code == .alreadyExists, attempt == 0 {
253
+ return try await create(session, attempt: attempt + 1)
254
+ }
255
+ if registered, let e = error as? WalletError, e.code == .storageUnavailable {
256
+ try? await ctx.api.discardPending(session)
257
+ try? ctx.store.delete(key)
258
+ }
259
+ throw error
260
+ }
261
+ return try await status(session)
262
+ }
263
+
264
+ private func requireLocal(_ session: String) async throws -> (record: DeviceRecord, wallet: ServerWallet) {
265
+ guard let wallet = try await ctx.api.getMe(session), wallet.status != "pending" else { throw WalletError(.notFound) }
266
+ guard let record = try ctx.store.get(try recordKey(session)), record.walletId == wallet.id else {
267
+ throw WalletError(.needsRestore)
268
+ }
269
+ return (record, wallet)
270
+ }
271
+
272
+ private func fetchCompanyShare(_ session: String, _ record: DeviceRecord) async throws -> [UInt8] {
273
+ let b64 = try await ctx.api.releaseShare(session, try await deviceProof(session, record))
274
+ guard let bytes = Bytes.fromBase64(b64) else { throw WalletError(.apiError) }
275
+ return bytes
276
+ }
277
+
278
+ /// The user's approval for a signature, before A is requested. No approver means no signature.
279
+ private func approve(_ summary: SignSummary) async throws {
280
+ guard let approver = ctx.approver else { throw WalletError(.internal, "confirmation unavailable") }
281
+ try await approver.approveSignature(summary)
282
+ }
283
+
284
+ private func withSecret<T>(
285
+ _ session: String,
286
+ account target: SignAccount,
287
+ summary build: (() throws -> SignSummary)?,
288
+ _ fn: (EvmAccount) throws -> T
289
+ ) async throws -> T {
290
+ // Built (and size-checked) only when someone will see it.
291
+ let summary = try build.flatMap { b in ctx.confirmSigning ? Summaries.withAccountRow(try b(), target) : nil }
292
+ let local = try await requireLocal(session)
293
+ if let summary { try await approve(summary) }
294
+ if let auth = ctx.deviceAuthenticator { try await auth.authenticate(reason: summary?.title ?? "Sign with your wallet") }
295
+ var company = try await fetchCompanyShare(session, local.record)
296
+ var device = try ctx.store.open(local.record)
297
+ var secret = [UInt8]()
298
+ defer {
299
+ secret.wipe()
300
+ device.wipe()
301
+ company.wipe()
302
+ }
303
+ secret = try WalletShares.assemble(device, company)
304
+ let primary = try WalletShares.account(secret)
305
+ defer { primary.wipe() }
306
+ guard primary.address.lowercased() == local.wallet.address.lowercased() else { throw WalletError(.addressMismatch) }
307
+ if target.index == 0 { return try fn(primary) }
308
+ guard WalletShares.isHdSecret(secret) else { throw WalletError(.accountUnavailable, "this wallet has only account 0") }
309
+ let account = try WalletShares.account(secret, index: target.index)
310
+ defer { account.wipe() }
311
+ guard account.address.lowercased() == target.address?.lowercased() else { throw WalletError(.addressMismatch) }
312
+ return try fn(account)
313
+ }
314
+
315
+ func accounts(_ session: String, start: Int = 0, count: Int = 1, chain: WalletChain = .evm) async throws -> [WalletAccount] {
316
+ guard WalletShares.isAccountIndex(start), count >= 1, WalletShares.isAccountIndex(start + count - 1) else {
317
+ throw WalletError(.invalidRequest, "start..start+count-1 must stay within 0..\(WalletShares.maxAccountIndex)")
318
+ }
319
+ let local = try await requireLocal(session)
320
+ var company = try await fetchCompanyShare(session, local.record)
321
+ var device = try ctx.store.open(local.record)
322
+ var secret = [UInt8]()
323
+ defer {
324
+ secret.wipe()
325
+ device.wipe()
326
+ company.wipe()
327
+ }
328
+ secret = try WalletShares.assemble(device, company)
329
+ guard try WalletShares.address(secret).lowercased() == local.wallet.address.lowercased() else { throw WalletError(.addressMismatch) }
330
+ if chain != .evm {
331
+ let list = try ChainAddresses.addresses(secret, chain: chain, start: start, count: count)
332
+ let keys = chain == .xrp ? try ChainAddresses.xrpPublicKeys(secret, start: start, count: count) : nil
333
+ return list.enumerated().map {
334
+ WalletAccount(index: start + $0.offset, path: chain.path(start + $0.offset), address: $0.element, chain: chain, publicKey: keys?[$0.offset])
335
+ }
336
+ }
337
+ if !WalletShares.isHdSecret(secret) && (start != 0 || count != 1) {
338
+ throw WalletError(.accountUnavailable, "this wallet has only account 0")
339
+ }
340
+ let list = try WalletShares.addresses(secret, start: start, count: count)
341
+ return list.enumerated().map { WalletAccount(index: start + $0.offset, path: WalletShares.evmPath(start + $0.offset), address: $0.element, chain: .evm) }
342
+ }
343
+
344
+ private static func reserved() -> WalletError {
345
+ WalletError(.invalidRequest, "this message is reserved for wallet key proofs and cannot be signed")
346
+ }
347
+
348
+ static func parseMessage(_ m: WalletMessage) throws -> ParsedMessage {
349
+ switch m {
350
+ case .text(let s): return .text(s)
351
+ case .raw(let hex):
352
+ guard hex.hasPrefix("0x"), Bytes.isHex(hex) else { throw WalletError(.invalidRequest, "message must be a string or {raw}") }
353
+ return .raw(hex)
354
+ }
355
+ }
356
+
357
+ func signMessage(_ session: String, _ message: WalletMessage, account: SignAccount = .primary) async throws -> String {
358
+ let parsed = try Self.parseMessage(message)
359
+ let text: String?
360
+ let payload: EvmAccount.Message
361
+ switch parsed {
362
+ case .text(let s):
363
+ text = s
364
+ payload = .text(s)
365
+ case .raw(let hex):
366
+ text = WalletProof.rawMessageText(hex)
367
+ payload = .raw(Bytes.fromHex(hex) ?? [])
368
+ }
369
+ if let text, WalletProof.isReservedMessageText(text) { throw Self.reserved() }
370
+ return try await withSecret(session, account: account, summary: { try Summaries.message(parsed) }) { try $0.signMessage(payload) }
371
+ }
372
+
373
+ func signTypedData(_ session: String, _ typedData: JSONValue, account: SignAccount = .primary) async throws -> String {
374
+ guard case .object = typedData else { throw WalletError(.invalidRequest) }
375
+ guard typedData["types"]?.isTruthy == true, typedData["primaryType"]?.isTruthy == true, typedData["message"]?.isTruthy == true else {
376
+ throw WalletError(.invalidRequest, "typedData incomplete")
377
+ }
378
+ if WalletProof.isReservedTypedData(typedData) { throw Self.reserved() }
379
+ let td = try TypedData(typedData)
380
+ // Malformed typed data is refused before anyone is asked to approve it.
381
+ _ = try td.digest()
382
+ return try await withSecret(session, account: account, summary: { try Summaries.typedData(typedData) }) { try $0.signTypedData(td) }
383
+ }
384
+
385
+ private static let bigintFields = ["value", "gas", "gasPrice", "maxFeePerGas", "maxPriorityFeePerGas", "maxFeePerBlobGas"]
386
+ private static let numberFields = ["nonce", "chainId"]
387
+
388
+ /// ops.ts parseTransaction: numeric fields as BigInt, nonce and chainId as Number.
389
+ static func parseTransaction(_ value: JSONValue) throws -> JSONObject {
390
+ guard var tx = value.objectValue else { throw WalletError(.invalidRequest) }
391
+ for f in bigintFields + numberFields {
392
+ guard let v = tx[f] else { continue }
393
+ guard let n = v.jsBigInt else { throw WalletError(.invalidRequest, "transaction numeric field invalid") }
394
+ tx[f] = .number(n.description)
395
+ }
396
+ guard tx["chainId"] != nil else { throw WalletError(.invalidRequest, "chainId required") }
397
+ return tx
398
+ }
399
+
400
+ func signTransaction(_ session: String, _ transaction: JSONValue, account: SignAccount = .primary) async throws -> String {
401
+ let tx = try Self.parseTransaction(transaction)
402
+ let evmTx = try EvmTransaction(tx)
403
+ return try await withSecret(session, account: account, summary: { Summaries.transaction(tx) }) { try $0.signTransaction(evmTx) }
404
+ }
405
+
406
+ /// Signs a transaction on a chain other than EVM (section 12; Solana and TRON). The summary is
407
+ /// built from the payload and the expected address before the company share is requested.
408
+ func signChainTransaction(_ session: String, chain: WalletChain, account index: Int, address: String, payload: JSONValue) async throws -> [String: String] {
409
+ guard WalletShares.isAccountIndex(index) else {
410
+ throw WalletError(.invalidRequest, "account must be an integer 0..\(WalletShares.maxAccountIndex)")
411
+ }
412
+ guard !address.isEmpty else { throw WalletError(.invalidRequest, "address of the signing account required") }
413
+ let summary: SignSummary
414
+ let sign: ([UInt8]) throws -> [String: String]
415
+ switch chain {
416
+ case .solana:
417
+ guard case .string(let b64) = payload["message"] ?? .null, let message = Bytes.fromBase64(b64) else {
418
+ throw WalletError(.invalidRequest, "payload.message (base64) required")
419
+ }
420
+ let network: String
421
+ if case .string(let cluster) = payload["cluster"] ?? .null, cluster.utf16.count <= 32 {
422
+ network = "Solana \(cluster)"
423
+ } else {
424
+ network = "Solana (network chosen by the app)"
425
+ }
426
+ summary = try Solana.summarize(try Solana.parse(message), signer: address, network: network)
427
+ sign = { secret in
428
+ var key = try Solana.privateKey(secret, index: index)
429
+ defer { key.wipe() }
430
+ let signed = Solana.sign(message, privateKey: key)
431
+ guard signed.address == address else { throw WalletError(.addressMismatch) }
432
+ return ["signature": signed.signature]
433
+ }
434
+ case .bitcoin, .bitcoinTestnet:
435
+ guard case .string(let b64) = payload["psbt"] ?? .null, let bytes = Bytes.fromBase64(b64) else {
436
+ throw WalletError(.invalidRequest, "payload.psbt (base64) required")
437
+ }
438
+ let psbt = try Bitcoin.parse(bytes)
439
+ summary = try Bitcoin.summarize(psbt, signer: address, chain: chain)
440
+ sign = { secret in
441
+ var key = try ChainAddresses.secpPrivateKey(secret, chain: chain, index: index)
442
+ defer { key.wipe() }
443
+ guard try ChainAddresses.secpAddress(chain, privateKey: key) == address else { throw WalletError(.addressMismatch) }
444
+ return try Bitcoin.sign(psbt, privateKey: key)
445
+ }
446
+ case .xrp:
447
+ guard case .string(let hex) = payload["txBlobHex"] ?? .null, Self.isPlainHex(hex), let blob = Bytes.fromHex(hex) else {
448
+ throw WalletError(.invalidRequest, "payload.txBlobHex (encodeForSigning output) required")
449
+ }
450
+ let tx = try XRP.parse(blob)
451
+ guard tx.account == address else { throw WalletError(.addressMismatch, "the transaction account is not the signing account") }
452
+ summary = try XRP.summarize(tx, signer: address, signerPublicKey: tx.signingPubKey!)
453
+ sign = { secret in
454
+ var key = try ChainAddresses.secpPrivateKey(secret, chain: .xrp, index: index)
455
+ defer { key.wipe() }
456
+ let pub = try Secp256k1.publicKey(of: key, compressed: true)
457
+ guard try XRP.address(publicKey: pub) == address, pub == tx.signingPubKey else { throw WalletError(.addressMismatch) }
458
+ return try XRP.sign(blob, privateKey: key)
459
+ }
460
+ case .evm:
461
+ throw WalletError(.unsupportedType, "use signTransaction for EVM")
462
+ case .tron:
463
+ guard case .string(let hex) = payload["rawDataHex"] ?? .null, Self.isPlainHex(hex), let raw = Bytes.fromHex(hex) else {
464
+ throw WalletError(.invalidRequest, "payload.rawDataHex required")
465
+ }
466
+ summary = try Tron.summarize(try Tron.parse(raw), signer: address)
467
+ sign = { secret in
468
+ var key = try ChainAddresses.secpPrivateKey(secret, chain: .tron, index: index)
469
+ defer { key.wipe() }
470
+ guard try ChainAddresses.secpAddress(.tron, privateKey: key) == address else { throw WalletError(.addressMismatch) }
471
+ return try Tron.sign(raw, privateKey: key)
472
+ }
473
+ }
474
+ let local = try await requireLocal(session)
475
+ if ctx.confirmSigning { try await approve(summary) }
476
+ if let auth = ctx.deviceAuthenticator { try await auth.authenticate(reason: summary.title) }
477
+ var company = try await fetchCompanyShare(session, local.record)
478
+ var device = try ctx.store.open(local.record)
479
+ var secret = [UInt8]()
480
+ defer {
481
+ secret.wipe()
482
+ device.wipe()
483
+ company.wipe()
484
+ }
485
+ secret = try WalletShares.assemble(device, company)
486
+ guard try WalletShares.address(secret).lowercased() == local.wallet.address.lowercased() else { throw WalletError(.addressMismatch) }
487
+ return try sign(secret)
488
+ }
489
+
490
+ /// /^([0-9a-fA-F]{2})+$/
491
+ static func isPlainHex(_ s: String) -> Bool {
492
+ !s.isEmpty && s.count % 2 == 0 && s.utf8.allSatisfy { (0x30...0x39).contains($0) || (0x41...0x46).contains($0) || (0x61...0x66).contains($0) }
493
+ }
494
+
495
+ // MARK: - Backup
496
+
497
+ private func writeBackup(_ session: String, upgrade: Bool) async throws -> ProtectResult {
498
+ let (record, wallet) = try await requireLocal(session)
499
+ if !upgrade && wallet.isProtected { return ProtectResult(shareVersion: wallet.shareVersion) }
500
+ if upgrade {
501
+ guard wallet.isProtected else { throw WalletError(.notProtected) }
502
+ guard wallet.backupVerifiable == false else { throw WalletError(.alreadyUpgraded) }
503
+ }
504
+ let key = try recordKey(session)
505
+ let user = try Self.decodeUserId(session)
506
+ let version = ctx.backupVersion
507
+ let salt = WalletBackup.prfSalt(version, projectId: ctx.projectId, walletId: wallet.id)
508
+ let passkeyUser = WalletBackup.passkeyUserId(projectId: ctx.projectId, userId: user)
509
+ if let approver = ctx.approver { try await approver.confirmPasskeyAction(upgrade ? .upgradeBackup : .protect) }
510
+ var pk = try await ctx.passkeys.create(userId: passkeyUser, userName: wallet.address, salt: salt)
511
+ var oldCompany = [UInt8]()
512
+ var oldDevice = [UInt8]()
513
+ var secret = [UInt8]()
514
+ var shares: (device: [UInt8], company: [UInt8], backup: [UInt8]) = ([], [], [])
515
+ defer {
516
+ pk.prfOutput.wipe()
517
+ oldCompany.wipe()
518
+ oldDevice.wipe()
519
+ secret.wipe()
520
+ shares.device.wipe()
521
+ shares.company.wipe()
522
+ shares.backup.wipe()
523
+ }
524
+ oldCompany = try await fetchCompanyShare(session, record)
525
+ oldDevice = try ctx.store.open(record)
526
+ secret = try WalletShares.assemble(oldDevice, oldCompany)
527
+ guard try WalletShares.address(secret).lowercased() == wallet.address.lowercased() else { throw WalletError(.addressMismatch) }
528
+ shares = try WalletShares.splitProtected(secret)
529
+ let backupKey = WalletBackup.deriveKey(prfOutput: pk.prfOutput)
530
+ let backupCiphertext = try WalletBackup.encrypt(key: backupKey, share: shares.backup, walletId: wallet.id, version: version)
531
+ let proof = try await deviceProof(session, record)
532
+ let keyFields: WalletAPIClient.BackupKeyFields
533
+ if let pub = pk.publicKey, let alg = pk.publicKeyAlg {
534
+ keyFields = .init(credentialPublicKey: pub, credentialAlg: alg)
535
+ } else {
536
+ keyFields = .init(attestationObject: pk.attestationObject)
537
+ }
538
+ let shareVersion = try await ctx.api.putBackup(
539
+ session, proof: proof, backupCiphertext: backupCiphertext, credentialId: pk.credentialId,
540
+ newCompanyShare: Bytes.base64(shares.company), key: keyFields, upgrade: upgrade
541
+ )
542
+ let sealed = try ctx.store.seal(shares.device, keys: DeviceKeys(handle: record.keyHandle, publicJwk: record.publicJwk), storageKey: key)
543
+ var updated = record
544
+ updated.sealedShare = sealed
545
+ updated.shareVersion = shareVersion
546
+ try ctx.store.put(updated)
547
+ return ProtectResult(shareVersion: shareVersion)
548
+ }
549
+
550
+ func protect(_ session: String) async throws -> ProtectResult { try await writeBackup(session, upgrade: false) }
551
+
552
+ /// Replaces a legacy backup (no passkey public key) from a device that holds the wallet.
553
+ func upgradeBackup(_ session: String) async throws -> ProtectResult { try await writeBackup(session, upgrade: true) }
554
+
555
+ func restore(_ session: String, mode: RestoreMode = .add) async throws -> RestoreResult {
556
+ guard let wallet = try await ctx.api.getMe(session), wallet.status != "pending" else { throw WalletError(.notFound) }
557
+ let start = try await ctx.api.restoreStart(session)
558
+ guard let ciphertext = start.backupCiphertext, !ciphertext.isEmpty, let credentialId = start.credentialId, !credentialId.isEmpty else {
559
+ throw WalletError(.notProtected)
560
+ }
561
+ let key = try recordKey(session)
562
+ let version = WalletBackup.version(of: ciphertext)
563
+ let salt = WalletBackup.prfSalt(version, projectId: ctx.projectId, walletId: wallet.id)
564
+ let challenge = start.passkeyChallenge.flatMap(Bytes.fromBase64)
565
+ if let approver = ctx.approver { try await approver.confirmPasskeyAction(mode == .reset ? .restoreReset : .restore) }
566
+ var got = try await ctx.passkeys.get(credentialId: credentialId, salt: salt, challenge: challenge)
567
+ var company = [UInt8]()
568
+ var backup = [UInt8]()
569
+ var secret = [UInt8]()
570
+ var shares: (device: [UInt8], company: [UInt8], backup: [UInt8]) = ([], [], [])
571
+ defer {
572
+ got.prfOutput.wipe()
573
+ company.wipe()
574
+ backup.wipe()
575
+ secret.wipe()
576
+ shares.device.wipe()
577
+ shares.company.wipe()
578
+ shares.backup.wipe()
579
+ }
580
+ let backupKey = WalletBackup.deriveKey(prfOutput: got.prfOutput)
581
+ backup = try WalletBackup.decrypt(key: backupKey, payload: ciphertext, walletId: wallet.id)
582
+ let companyB64: String
583
+ if let c = start.companyShare, !c.isEmpty {
584
+ companyB64 = c
585
+ } else {
586
+ companyB64 = try await ctx.api.restoreAssert(session, got.assertion, challenge: start.passkeyChallenge ?? "")
587
+ }
588
+ guard let c = Bytes.fromBase64(companyB64) else { throw WalletError(.apiError) }
589
+ company = c
590
+ secret = try WalletShares.assemble(backup, company)
591
+ guard try WalletShares.address(secret).lowercased() == wallet.address.lowercased() else { throw WalletError(.addressMismatch) }
592
+
593
+ let upgradeRecommended = start.upgradeRecommended
594
+ if mode == .add {
595
+ let shareVersion = try await addThisDevice(session, key: key, wallet: wallet, secret: secret, company: company, backup: backup)
596
+ return RestoreResult(shareVersion: shareVersion, backupUpgradeRecommended: upgradeRecommended)
597
+ }
598
+
599
+ shares = try WalletShares.splitProtected(secret)
600
+ let keys = try ctx.store.generateKeys()
601
+ var stored = false
602
+ defer { if !stored { ctx.store.discardKeys(keys) } }
603
+ let bound = try await ctx.api.proofChallenge(session, kind: .restore)
604
+ let proof = try WalletProof.signRestore(secret, walletId: wallet.id, currentShareVersion: wallet.shareVersion, deviceKey: keys.publicJwk, bound: bound)
605
+ // The PRF output belongs to this backup's salt version, so the new backup keeps it.
606
+ let newBackup = try WalletBackup.encrypt(key: backupKey, share: shares.backup, walletId: wallet.id, version: version)
607
+ let res = try await ctx.api.restore(
608
+ session, newCompanyShare: Bytes.base64(shares.company), newBackupCiphertext: newBackup,
609
+ newDevicePublicKey: keys.publicJwk, credentialId: credentialId, proof: proof, bound: bound
610
+ )
611
+ let sealed = try ctx.store.seal(shares.device, keys: keys, storageKey: key)
612
+ try ctx.store.put(DeviceRecord(
613
+ key: key, deviceId: res.deviceId, walletId: wallet.id, address: wallet.address, shareVersion: res.shareVersion,
614
+ createdAt: Date().timeIntervalSince1970 * 1000, publicJwk: keys.publicJwk, keyHandle: keys.handle, sealedShare: sealed
615
+ ))
616
+ stored = true
617
+ return RestoreResult(shareVersion: res.shareVersion, backupUpgradeRecommended: upgradeRecommended)
618
+ }
619
+
620
+ private func addThisDevice(_ session: String, key: String, wallet: ServerWallet, secret: [UInt8], company: [UInt8], backup: [UInt8]) async throws -> Int {
621
+ var device = try WalletShares.deriveShare(company, backup)
622
+ var check1 = [UInt8]()
623
+ var check2 = [UInt8]()
624
+ defer {
625
+ device.wipe()
626
+ check1.wipe()
627
+ check2.wipe()
628
+ }
629
+ check1 = try WalletShares.assemble(device, company)
630
+ check2 = try WalletShares.assemble(device, backup)
631
+ guard check1 == secret, check2 == secret else { throw WalletError(.internal, "derived share mismatch") }
632
+ let keys = try ctx.store.generateKeys()
633
+ var stored = false
634
+ defer { if !stored { ctx.store.discardKeys(keys) } }
635
+ let bound = try await ctx.api.proofChallenge(session, kind: .device)
636
+ let proof = try WalletProof.signDevice(secret, walletId: wallet.id, shareVersion: wallet.shareVersion, deviceKey: keys.publicJwk, bound: bound)
637
+ let res = try await ctx.api.addDevice(session, devicePublicKey: keys.publicJwk, proof: proof, bound: bound)
638
+ let sealed = try ctx.store.seal(device, keys: keys, storageKey: key)
639
+ try ctx.store.put(DeviceRecord(
640
+ key: key, deviceId: res.deviceId, walletId: wallet.id, address: wallet.address, shareVersion: res.shareVersion,
641
+ createdAt: Date().timeIntervalSince1970 * 1000, publicJwk: keys.publicJwk, keyHandle: keys.handle, sealedShare: sealed
642
+ ))
643
+ stored = true
644
+ return res.shareVersion
645
+ }
646
+
647
+ /// Revokes a device with a request signed by this device's key.
648
+ func revokeDevice(_ session: String, deviceId: String) async throws {
649
+ guard !deviceId.isEmpty else { throw WalletError(.invalidRequest, "deviceId required") }
650
+ let (record, _) = try await requireLocal(session)
651
+ try await ctx.api.revokeDevice(session, deviceId: deviceId, proof: try await deviceProof(session, record))
652
+ if record.deviceId == deviceId { try ctx.store.delete(try recordKey(session)) }
653
+ }
654
+ }