@bsv/wallet-toolbox 1.1.62 → 1.2.1

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 (185) hide show
  1. package/docs/client.md +2339 -182
  2. package/docs/wallet.md +2339 -182
  3. package/out/src/CWIStyleWalletManager.d.ts +417 -0
  4. package/out/src/CWIStyleWalletManager.d.ts.map +1 -0
  5. package/out/src/CWIStyleWalletManager.js +1153 -0
  6. package/out/src/CWIStyleWalletManager.js.map +1 -0
  7. package/out/src/SimpleWalletManager.d.ts +169 -0
  8. package/out/src/SimpleWalletManager.d.ts.map +1 -0
  9. package/out/src/SimpleWalletManager.js +315 -0
  10. package/out/src/SimpleWalletManager.js.map +1 -0
  11. package/out/src/Wallet.d.ts +6 -1
  12. package/out/src/Wallet.d.ts.map +1 -1
  13. package/out/src/Wallet.js +39 -7
  14. package/out/src/Wallet.js.map +1 -1
  15. package/out/src/WalletAuthenticationManager.d.ts +33 -0
  16. package/out/src/WalletAuthenticationManager.d.ts.map +1 -0
  17. package/out/src/WalletAuthenticationManager.js +110 -0
  18. package/out/src/WalletAuthenticationManager.js.map +1 -0
  19. package/out/src/WalletPermissionsManager.d.ts +575 -0
  20. package/out/src/WalletPermissionsManager.d.ts.map +1 -0
  21. package/out/src/WalletPermissionsManager.js +1789 -0
  22. package/out/src/WalletPermissionsManager.js.map +1 -0
  23. package/out/src/WalletSettingsManager.d.ts +59 -0
  24. package/out/src/WalletSettingsManager.d.ts.map +1 -0
  25. package/out/src/WalletSettingsManager.js +189 -0
  26. package/out/src/WalletSettingsManager.js.map +1 -0
  27. package/out/src/__tests/CWIStyleWalletManager.test.d.ts +2 -0
  28. package/out/src/__tests/CWIStyleWalletManager.test.d.ts.map +1 -0
  29. package/out/src/__tests/CWIStyleWalletManager.test.js +471 -0
  30. package/out/src/__tests/CWIStyleWalletManager.test.js.map +1 -0
  31. package/out/src/__tests/WalletPermissionsManager.callbacks.test.d.ts +2 -0
  32. package/out/src/__tests/WalletPermissionsManager.callbacks.test.d.ts.map +1 -0
  33. package/out/src/__tests/WalletPermissionsManager.callbacks.test.js +239 -0
  34. package/out/src/__tests/WalletPermissionsManager.callbacks.test.js.map +1 -0
  35. package/out/src/__tests/WalletPermissionsManager.checks.test.d.ts +2 -0
  36. package/out/src/__tests/WalletPermissionsManager.checks.test.d.ts.map +1 -0
  37. package/out/src/__tests/WalletPermissionsManager.checks.test.js +637 -0
  38. package/out/src/__tests/WalletPermissionsManager.checks.test.js.map +1 -0
  39. package/out/src/__tests/WalletPermissionsManager.encryption.test.d.ts +2 -0
  40. package/out/src/__tests/WalletPermissionsManager.encryption.test.d.ts.map +1 -0
  41. package/out/src/__tests/WalletPermissionsManager.encryption.test.js +295 -0
  42. package/out/src/__tests/WalletPermissionsManager.encryption.test.js.map +1 -0
  43. package/out/src/__tests/WalletPermissionsManager.fixtures.d.ts +83 -0
  44. package/out/src/__tests/WalletPermissionsManager.fixtures.d.ts.map +1 -0
  45. package/out/src/__tests/WalletPermissionsManager.fixtures.js +261 -0
  46. package/out/src/__tests/WalletPermissionsManager.fixtures.js.map +1 -0
  47. package/out/src/__tests/WalletPermissionsManager.flows.test.d.ts +2 -0
  48. package/out/src/__tests/WalletPermissionsManager.flows.test.d.ts.map +1 -0
  49. package/out/src/__tests/WalletPermissionsManager.flows.test.js +377 -0
  50. package/out/src/__tests/WalletPermissionsManager.flows.test.js.map +1 -0
  51. package/out/src/__tests/WalletPermissionsManager.initialization.test.d.ts +2 -0
  52. package/out/src/__tests/WalletPermissionsManager.initialization.test.d.ts.map +1 -0
  53. package/out/src/__tests/WalletPermissionsManager.initialization.test.js +227 -0
  54. package/out/src/__tests/WalletPermissionsManager.initialization.test.js.map +1 -0
  55. package/out/src/__tests/WalletPermissionsManager.proxying.test.d.ts +2 -0
  56. package/out/src/__tests/WalletPermissionsManager.proxying.test.d.ts.map +1 -0
  57. package/out/src/__tests/WalletPermissionsManager.proxying.test.js +566 -0
  58. package/out/src/__tests/WalletPermissionsManager.proxying.test.js.map +1 -0
  59. package/out/src/__tests/WalletPermissionsManager.tokens.test.d.ts +2 -0
  60. package/out/src/__tests/WalletPermissionsManager.tokens.test.d.ts.map +1 -0
  61. package/out/src/__tests/WalletPermissionsManager.tokens.test.js +454 -0
  62. package/out/src/__tests/WalletPermissionsManager.tokens.test.js.map +1 -0
  63. package/out/src/index.all.d.ts +9 -0
  64. package/out/src/index.all.d.ts.map +1 -1
  65. package/out/src/index.all.js +9 -0
  66. package/out/src/index.all.js.map +1 -1
  67. package/out/src/index.client.d.ts +9 -0
  68. package/out/src/index.client.d.ts.map +1 -1
  69. package/out/src/index.client.js +9 -0
  70. package/out/src/index.client.js.map +1 -1
  71. package/out/src/sdk/CertOpsWallet.d.ts +7 -0
  72. package/out/src/sdk/CertOpsWallet.d.ts.map +1 -0
  73. package/out/src/sdk/CertOpsWallet.js +3 -0
  74. package/out/src/sdk/CertOpsWallet.js.map +1 -0
  75. package/out/src/sdk/__test/CertificateLifeCycle.test.js +19 -82
  76. package/out/src/sdk/__test/CertificateLifeCycle.test.js.map +1 -1
  77. package/out/src/sdk/index.d.ts +1 -1
  78. package/out/src/sdk/index.d.ts.map +1 -1
  79. package/out/src/sdk/index.js +1 -1
  80. package/out/src/sdk/index.js.map +1 -1
  81. package/out/src/sdk/validationHelpers.d.ts.map +1 -1
  82. package/out/src/sdk/validationHelpers.js +13 -12
  83. package/out/src/sdk/validationHelpers.js.map +1 -1
  84. package/out/src/services/__tests/bitrails.test.js +7 -2
  85. package/out/src/services/__tests/bitrails.test.js.map +1 -1
  86. package/out/src/services/providers/__tests/WhatsOnChain.test.js +3 -3
  87. package/out/src/services/providers/__tests/WhatsOnChain.test.js.map +1 -1
  88. package/out/src/signer/methods/proveCertificate.d.ts.map +1 -1
  89. package/out/src/signer/methods/proveCertificate.js +3 -19
  90. package/out/src/signer/methods/proveCertificate.js.map +1 -1
  91. package/out/src/storage/__test/WalletStorageManager.test.js +1 -1
  92. package/out/src/storage/__test/WalletStorageManager.test.js.map +1 -1
  93. package/out/src/storage/remoting/StorageClient.d.ts +2 -2
  94. package/out/src/storage/remoting/StorageClient.d.ts.map +1 -1
  95. package/out/src/storage/remoting/StorageClient.js +1 -1
  96. package/out/src/storage/remoting/StorageClient.js.map +1 -1
  97. package/out/src/utility/identityUtils.d.ts +31 -0
  98. package/out/src/utility/identityUtils.d.ts.map +1 -0
  99. package/out/src/utility/identityUtils.js +116 -0
  100. package/out/src/utility/identityUtils.js.map +1 -0
  101. package/out/src/wab-client/WABClient.d.ts +49 -0
  102. package/out/src/wab-client/WABClient.d.ts.map +1 -0
  103. package/out/src/wab-client/WABClient.js +83 -0
  104. package/out/src/wab-client/WABClient.js.map +1 -0
  105. package/out/src/wab-client/__tests/WABClient.man.test.d.ts +2 -0
  106. package/out/src/wab-client/__tests/WABClient.man.test.d.ts.map +1 -0
  107. package/out/src/wab-client/__tests/WABClient.man.test.js +52 -0
  108. package/out/src/wab-client/__tests/WABClient.man.test.js.map +1 -0
  109. package/out/src/wab-client/auth-method-interactors/AuthMethodInteractor.d.ts +34 -0
  110. package/out/src/wab-client/auth-method-interactors/AuthMethodInteractor.d.ts.map +1 -0
  111. package/out/src/wab-client/auth-method-interactors/AuthMethodInteractor.js +16 -0
  112. package/out/src/wab-client/auth-method-interactors/AuthMethodInteractor.js.map +1 -0
  113. package/out/src/wab-client/auth-method-interactors/PersonaIDInteractor.d.ts +7 -0
  114. package/out/src/wab-client/auth-method-interactors/PersonaIDInteractor.d.ts.map +1 -0
  115. package/out/src/wab-client/auth-method-interactors/PersonaIDInteractor.js +36 -0
  116. package/out/src/wab-client/auth-method-interactors/PersonaIDInteractor.js.map +1 -0
  117. package/out/src/wab-client/auth-method-interactors/TwilioPhoneInteractor.d.ts +28 -0
  118. package/out/src/wab-client/auth-method-interactors/TwilioPhoneInteractor.d.ts.map +1 -0
  119. package/out/src/wab-client/auth-method-interactors/TwilioPhoneInteractor.js +69 -0
  120. package/out/src/wab-client/auth-method-interactors/TwilioPhoneInteractor.js.map +1 -0
  121. package/out/test/Wallet/action/internalizeAction.a.test.js +1 -1
  122. package/out/test/Wallet/action/internalizeAction.a.test.js.map +1 -1
  123. package/out/test/Wallet/certificate/acquireCertificate.test.js +26 -29
  124. package/out/test/Wallet/certificate/acquireCertificate.test.js.map +1 -1
  125. package/out/test/storage/KnexMigrations.test.js +1 -1
  126. package/out/test/storage/KnexMigrations.test.js.map +1 -1
  127. package/out/test/storage/update.test.js +1 -1
  128. package/out/test/storage/update.test.js.map +1 -1
  129. package/out/test/utils/TestUtilsWalletStorage.d.ts +9 -5
  130. package/out/test/utils/TestUtilsWalletStorage.d.ts.map +1 -1
  131. package/out/test/utils/TestUtilsWalletStorage.js +15 -9
  132. package/out/test/utils/TestUtilsWalletStorage.js.map +1 -1
  133. package/out/test/wallet/action/internalizeAction.test.js +1 -1
  134. package/out/test/wallet/action/internalizeAction.test.js.map +1 -1
  135. package/out/test/wallet/list/listActions2.test.js +1 -1
  136. package/out/test/wallet/list/listActions2.test.js.map +1 -1
  137. package/out/test/wallet/sync/Wallet.sync.test.js +1 -1
  138. package/out/test/wallet/sync/Wallet.sync.test.js.map +1 -1
  139. package/out/tsconfig.all.tsbuildinfo +1 -1
  140. package/package.json +3 -4
  141. package/src/CWIStyleWalletManager.ts +1738 -0
  142. package/src/SimpleWalletManager.ts +526 -0
  143. package/src/Wallet.ts +70 -7
  144. package/src/WalletAuthenticationManager.ts +150 -0
  145. package/src/WalletPermissionsManager.ts +2424 -0
  146. package/src/WalletSettingsManager.ts +243 -0
  147. package/src/__tests/CWIStyleWalletManager.test.ts +604 -0
  148. package/src/__tests/WalletPermissionsManager.callbacks.test.ts +323 -0
  149. package/src/__tests/WalletPermissionsManager.checks.test.ts +839 -0
  150. package/src/__tests/WalletPermissionsManager.encryption.test.ts +370 -0
  151. package/src/__tests/WalletPermissionsManager.fixtures.ts +284 -0
  152. package/src/__tests/WalletPermissionsManager.flows.test.ts +457 -0
  153. package/src/__tests/WalletPermissionsManager.initialization.test.ts +300 -0
  154. package/src/__tests/WalletPermissionsManager.proxying.test.ts +706 -0
  155. package/src/__tests/WalletPermissionsManager.tokens.test.ts +546 -0
  156. package/src/index.all.ts +9 -0
  157. package/src/index.client.ts +9 -0
  158. package/src/sdk/CertOpsWallet.ts +18 -0
  159. package/src/sdk/__test/CertificateLifeCycle.test.ts +66 -113
  160. package/src/sdk/index.ts +1 -1
  161. package/src/sdk/validationHelpers.ts +12 -11
  162. package/src/services/__tests/bitrails.test.ts +7 -2
  163. package/src/services/providers/__tests/WhatsOnChain.test.ts +3 -3
  164. package/src/signer/methods/proveCertificate.ts +14 -21
  165. package/src/storage/__test/WalletStorageManager.test.ts +1 -1
  166. package/src/storage/remoting/StorageClient.ts +4 -4
  167. package/src/utility/identityUtils.ts +159 -0
  168. package/src/wab-client/WABClient.ts +94 -0
  169. package/src/wab-client/__tests/WABClient.man.test.ts +59 -0
  170. package/src/wab-client/auth-method-interactors/AuthMethodInteractor.ts +47 -0
  171. package/src/wab-client/auth-method-interactors/PersonaIDInteractor.ts +35 -0
  172. package/src/wab-client/auth-method-interactors/TwilioPhoneInteractor.ts +72 -0
  173. package/test/Wallet/action/internalizeAction.a.test.ts +1 -1
  174. package/test/Wallet/certificate/acquireCertificate.test.ts +89 -30
  175. package/test/storage/KnexMigrations.test.ts +1 -1
  176. package/test/storage/update.test.ts +1 -1
  177. package/test/utils/TestUtilsWalletStorage.ts +24 -13
  178. package/test/wallet/action/internalizeAction.test.ts +1 -1
  179. package/test/wallet/list/listActions2.test.ts +1 -1
  180. package/test/wallet/sync/Wallet.sync.test.ts +1 -1
  181. package/out/src/sdk/CertOps.d.ts +0 -66
  182. package/out/src/sdk/CertOps.d.ts.map +0 -1
  183. package/out/src/sdk/CertOps.js +0 -190
  184. package/out/src/sdk/CertOps.js.map +0 -1
  185. package/src/sdk/CertOps.ts +0 -274
@@ -0,0 +1,2424 @@
1
+ import { WalletInterface, Utils, PushDrop, LockingScript, Transaction } from '@bsv/sdk'
2
+ import { validateCreateActionArgs } from './sdk'
3
+
4
+ ////// TODO: ADD SUPPORT FOR ADMIN COUNTERPARTIES BASED ON WALLET STORAGE
5
+ ////// !!!!!!!! SECURITY-CRITICAL ADDITION — DO NOT USE UNTIL IMPLEMENTED.
6
+
7
+ /**
8
+ * Describes a single requested permission that the user must either grant or deny.
9
+ *
10
+ * Four categories of permission are supported, each with a unique protocol:
11
+ * 1) protocol - "DPACP" (Domain Protocol Access Control Protocol)
12
+ * 2) basket - "DBAP" (Domain Basket Access Protocol)
13
+ * 3) certificate - "DCAP" (Domain Certificate Access Protocol)
14
+ * 4) spending - "DSAP" (Domain Spending Authorization Protocol)
15
+ *
16
+ * This model underpins "requests" made to the user for permission, which the user can
17
+ * either grant or deny. The manager can then create on-chain tokens (PushDrop outputs)
18
+ * if permission is granted. Denying requests cause the underlying operation to throw,
19
+ * and no token is created. An "ephemeral" grant is also possible, denoting a one-time
20
+ * authorization without an associated persistent on-chain token.
21
+ */
22
+ export interface PermissionRequest {
23
+ type: 'protocol' | 'basket' | 'certificate' | 'spending'
24
+ originator: string // The domain or FQDN of the requesting application
25
+ privileged?: boolean // For "protocol" or "certificate" usage, indicating privileged key usage
26
+ protocolID?: [0 | 1 | 2, string] // For type='protocol': BRC-43 style (securityLevel, protocolName)
27
+ counterparty?: string // For type='protocol': e.g. target public key or "self"/"anyone"
28
+
29
+ basket?: string // For type='basket': the basket name being requested
30
+
31
+ certificate?: {
32
+ // For type='certificate': details about the cert usage
33
+ verifier: string
34
+ certType: string
35
+ fields: string[]
36
+ }
37
+
38
+ spending?: {
39
+ // For type='spending': details about the requested spend
40
+ satoshis: number
41
+ lineItems?: Array<{
42
+ type: 'input' | 'output' | 'fee'
43
+ description: string
44
+ satoshis: number
45
+ }>
46
+ }
47
+
48
+ reason?: string // Human-readable explanation for requesting permission
49
+ renewal?: boolean // Whether this request is for renewing an expired token
50
+ previousToken?: PermissionToken // If renewing an expired permission, reference to the old token
51
+ }
52
+
53
+ /**
54
+ * Signature for functions that handle a permission request event, e.g. "Please ask the user to allow basket X".
55
+ */
56
+ export type PermissionEventHandler = (request: PermissionRequest & { requestID: string }) => void | Promise<void>
57
+
58
+ /**
59
+ * Data structure representing an on-chain permission token.
60
+ * It is typically stored as a single unspent PushDrop output in a special "internal" admin basket belonging to
61
+ * the user, held in their underlying wallet.
62
+ *
63
+ * It can represent any of the four permission categories by having the relevant fields:
64
+ * - DPACP: originator, privileged, protocol, securityLevel, counterparty
65
+ * - DBAP: originator, basketName
66
+ * - DCAP: originator, privileged, verifier, certType, certFields
67
+ * - DSAP: originator, authorizedAmount
68
+ */
69
+ export interface PermissionToken {
70
+ /** The transaction ID where this token resides. */
71
+ txid: string
72
+
73
+ /** The output index within that transaction. */
74
+ outputIndex: number
75
+
76
+ /** The exact script hex for the locking script. */
77
+ outputScript: string
78
+
79
+ /** The amount of satoshis assigned to the permission output (often 1). */
80
+ satoshis: number
81
+
82
+ /** The originator domain or FQDN that is allowed to use this permission. */
83
+ originator: string
84
+
85
+ /** The expiration time for this token in UNIX epoch seconds. (0 or omitted for spending authorizations, which are indefinite) */
86
+ expiry: number
87
+
88
+ /** Whether this token grants privileged usage (for protocol or certificate). */
89
+ privileged?: boolean
90
+
91
+ /** The protocol name, if this is a DPACP token. */
92
+ protocol?: string
93
+
94
+ /** The security level (0,1,2) for DPACP. */
95
+ securityLevel?: 0 | 1 | 2
96
+
97
+ /** The counterparty, for DPACP. */
98
+ counterparty?: string
99
+
100
+ /** The name of a basket, if this is a DBAP token. */
101
+ basketName?: string
102
+
103
+ /** The certificate type, if this is a DCAP token. */
104
+ certType?: string
105
+
106
+ /** The certificate fields that this token covers, if DCAP token. */
107
+ certFields?: string[]
108
+
109
+ /** The "verifier" public key string, if DCAP. */
110
+ verifier?: string
111
+
112
+ /** For DSAP, the maximum authorized spending for the month. */
113
+ authorizedAmount?: number
114
+ }
115
+
116
+ /**
117
+ * A map from each permission type to a special "admin basket" name used for storing
118
+ * the tokens. The tokens themselves are unspent transaction outputs (UTXOs) with a
119
+ * specialized PushDrop script that references the originator, expiry, etc.
120
+ */
121
+ const BASKET_MAP = {
122
+ protocol: 'admin protocol-permission',
123
+ basket: 'admin basket-access',
124
+ certificate: 'admin certificate-access',
125
+ spending: 'admin spending-authorization'
126
+ }
127
+
128
+ /**
129
+ * The set of callbacks that external code can bind to, e.g. to display UI prompts or logs
130
+ * when a permission is requested.
131
+ */
132
+ export interface WalletPermissionsManagerCallbacks {
133
+ onProtocolPermissionRequested?: PermissionEventHandler[]
134
+ onBasketAccessRequested?: PermissionEventHandler[]
135
+ onCertificateAccessRequested?: PermissionEventHandler[]
136
+ onSpendingAuthorizationRequested?: PermissionEventHandler[]
137
+ }
138
+
139
+ /**
140
+ * Configuration object for the WalletPermissionsManager. If a given option is `false`,
141
+ * the manager will skip or alter certain permission checks or behaviors.
142
+ *
143
+ * By default, all of these are `true` unless specified otherwise. This is the most secure configuration.
144
+ */
145
+ export interface PermissionsManagerConfig {
146
+ /**
147
+ * For `createSignature` and `verifySignature`,
148
+ * require a "protocol usage" permission check?
149
+ */
150
+ seekProtocolPermissionsForSigning?: boolean
151
+
152
+ /**
153
+ * For methods that perform encryption (encrypt/decrypt), require
154
+ * a "protocol usage" permission check?
155
+ */
156
+ seekProtocolPermissionsForEncrypting?: boolean
157
+
158
+ /**
159
+ * For methods that perform HMAC creation or verification (createHmac, verifyHmac),
160
+ * require a "protocol usage" permission check?
161
+ */
162
+ seekProtocolPermissionsForHMAC?: boolean
163
+
164
+ /**
165
+ * For revealing counterparty-level or specific key linkage revelation information,
166
+ * should we require permission?
167
+ */
168
+ seekPermissionsForKeyLinkageRevelation?: boolean
169
+
170
+ /**
171
+ * For revealing any user public key (getPublicKey) **other** than the identity key,
172
+ * should we require permission?
173
+ */
174
+ seekPermissionsForPublicKeyRevelation?: boolean
175
+
176
+ /**
177
+ * If getPublicKey is requested with `identityKey=true`, do we require permission?
178
+ */
179
+ seekPermissionsForIdentityKeyRevelation?: boolean
180
+
181
+ /**
182
+ * If discoverByIdentityKey / discoverByAttributes are called, do we require permission
183
+ * for "identity resolution" usage?
184
+ */
185
+ seekPermissionsForIdentityResolution?: boolean
186
+
187
+ /**
188
+ * When we do internalizeAction with `basket insertion`, or include outputs in baskets
189
+ * with `createAction, do we ask for basket permission?
190
+ */
191
+ seekBasketInsertionPermissions?: boolean
192
+
193
+ /**
194
+ * When relinquishOutput is called, do we ask for basket permission?
195
+ */
196
+ seekBasketRemovalPermissions?: boolean
197
+
198
+ /**
199
+ * When listOutputs is called, do we ask for basket permission?
200
+ */
201
+ seekBasketListingPermissions?: boolean
202
+
203
+ /**
204
+ * When createAction is called with labels, do we ask for "label usage" permission?
205
+ */
206
+ seekPermissionWhenApplyingActionLabels?: boolean
207
+
208
+ /**
209
+ * When listActions is called with labels, do we ask for "label usage" permission?
210
+ */
211
+ seekPermissionWhenListingActionsByLabel?: boolean
212
+
213
+ /**
214
+ * If proving a certificate (proveCertificate) or revealing certificate fields,
215
+ * do we require a "certificate access" permission?
216
+ */
217
+ seekCertificateDisclosurePermissions?: boolean
218
+
219
+ /**
220
+ * If acquiring a certificate (acquireCertificate), do we require a permission check?
221
+ */
222
+ seekCertificateAcquisitionPermissions?: boolean
223
+
224
+ /**
225
+ * If relinquishing a certificate (relinquishCertificate), do we require a permission check?
226
+ */
227
+ seekCertificateRelinquishmentPermissions?: boolean
228
+
229
+ /**
230
+ * If listing a user's certificates (listCertificates), do we require a permission check?
231
+ */
232
+ seekCertificateListingPermissions?: boolean
233
+
234
+ /**
235
+ * Should transaction descriptions, input descriptions, and output descriptions be encrypted
236
+ * when before they are passed to the underlying wallet, and transparently decrypted when retrieved?
237
+ */
238
+ encryptWalletMetadata?: boolean
239
+
240
+ /**
241
+ * If the originator tries to spend wallet funds (netSpent > 0 in createAction),
242
+ * do we seek spending authorization?
243
+ */
244
+ seekSpendingPermissions?: boolean
245
+
246
+ /**
247
+ * If false, permissions are checked without regard for whether we are in
248
+ * privileged mode. Privileged status is ignored with respect to whether
249
+ * permissions are granted. Internally, they are always sought and checked
250
+ * with privileged=false, regardless of the actual value.
251
+ */
252
+ differentiatePrivilegedOperations?: boolean
253
+ }
254
+
255
+ /**
256
+ * @class WalletPermissionsManager
257
+ *
258
+ * Wraps an underlying BRC-100 `Wallet` implementation with permissions management capabilities.
259
+ * The manager intercepts calls from external applications (identified by originators), checks if the request is allowed,
260
+ * and if not, orchestrates user permission flows. It creates or renews on-chain tokens in special
261
+ * admin baskets to track these authorizations. Finally, it proxies the actual call to the underlying wallet.
262
+ *
263
+ * ### Key Responsibilities:
264
+ * - **Permission Checking**: Before standard wallet operations (e.g. `encrypt`),
265
+ * the manager checks if a valid permission token exists. If not, it attempts to request permission from the user.
266
+ * - **On-Chain Tokens**: When permission is granted, the manager stores it as an unspent "PushDrop" output.
267
+ * This can be spent later to revoke or renew the permission.
268
+ * - **Callbacks**: The manager triggers user-defined callbacks on permission requests (to show a UI prompt),
269
+ * on grants/denials, and on internal processes.
270
+ *
271
+ * ### Implementation Notes:
272
+ * - The manager follows the BRC-100 `createAction` + `signAction` pattern for building or spending these tokens.
273
+ * - Token revocation or renewal uses standard BRC-100 flows: we build a transaction that consumes
274
+ * the old token UTXO and outputs a new one (or none, if fully revoked).
275
+ */
276
+ export class WalletPermissionsManager implements WalletInterface {
277
+ /** A reference to the BRC-100 wallet instance. */
278
+ private underlying: WalletInterface
279
+
280
+ /** The "admin" domain or FQDN that is implicitly allowed to do everything. */
281
+ private adminOriginator: string
282
+
283
+ /**
284
+ * Event callbacks that external code can subscribe to, e.g. to show a UI prompt
285
+ * or log events. Each event can have multiple handlers.
286
+ */
287
+ private callbacks: WalletPermissionsManagerCallbacks = {
288
+ onProtocolPermissionRequested: [],
289
+ onBasketAccessRequested: [],
290
+ onCertificateAccessRequested: [],
291
+ onSpendingAuthorizationRequested: []
292
+ }
293
+
294
+ /**
295
+ * We queue parallel requests for the same resource so that only one
296
+ * user prompt is created for a single resource. If multiple calls come
297
+ * in at once for the same "protocol:domain:privileged:counterparty" etc.,
298
+ * they get merged.
299
+ *
300
+ * The key is a string derived from the operation; the value is an object with a reference to the
301
+ * associated request and an array of pending promise resolve/reject pairs, one for each active
302
+ * operation that's waiting on the particular resource described by the key.
303
+ */
304
+ private activeRequests: Map<
305
+ string,
306
+ {
307
+ request: PermissionRequest
308
+ pending: Array<{
309
+ resolve: (val: boolean) => void
310
+ reject: (err: any) => void
311
+ }>
312
+ }
313
+ > = new Map()
314
+
315
+ /**
316
+ * Configuration that determines whether to skip or apply various checks and encryption.
317
+ */
318
+ private config: PermissionsManagerConfig
319
+
320
+ /**
321
+ * Constructs a new Permissions Manager instance.
322
+ *
323
+ * @param underlyingWallet The underlying BRC-100 wallet, where requests are forwarded after permission is granted
324
+ * @param adminOriginator The domain or FQDN that is automatically allowed everything
325
+ * @param config A set of boolean flags controlling how strictly permissions are enforced
326
+ */
327
+ constructor(underlyingWallet: WalletInterface, adminOriginator: string, config: PermissionsManagerConfig = {}) {
328
+ this.underlying = underlyingWallet
329
+ this.adminOriginator = adminOriginator
330
+
331
+ // Default all config options to true unless specified
332
+ this.config = {
333
+ seekProtocolPermissionsForSigning: true,
334
+ seekProtocolPermissionsForEncrypting: true,
335
+ seekProtocolPermissionsForHMAC: true,
336
+ seekPermissionsForKeyLinkageRevelation: true,
337
+ seekPermissionsForPublicKeyRevelation: true,
338
+ seekPermissionsForIdentityKeyRevelation: true,
339
+ seekPermissionsForIdentityResolution: true,
340
+ seekBasketInsertionPermissions: true,
341
+ seekBasketRemovalPermissions: true,
342
+ seekBasketListingPermissions: true,
343
+ seekPermissionWhenApplyingActionLabels: true,
344
+ seekPermissionWhenListingActionsByLabel: true,
345
+ seekCertificateDisclosurePermissions: true,
346
+ seekCertificateAcquisitionPermissions: true,
347
+ seekCertificateRelinquishmentPermissions: true,
348
+ seekCertificateListingPermissions: true,
349
+ encryptWalletMetadata: true,
350
+ seekSpendingPermissions: true,
351
+ differentiatePrivilegedOperations: true,
352
+ ...config // override with user-specified config
353
+ }
354
+ }
355
+
356
+ /* ---------------------------------------------------------------------
357
+ * 1) PUBLIC API FOR REGISTERING CALLBACKS (UI PROMPTS, LOGGING, ETC.)
358
+ * --------------------------------------------------------------------- */
359
+
360
+ /**
361
+ * Binds a callback function to a named event, such as `onProtocolPermissionRequested`.
362
+ *
363
+ * @param eventName The name of the event to listen to
364
+ * @param handler A function that handles the event
365
+ * @returns A numeric ID you can use to unbind later
366
+ */
367
+ public bindCallback(eventName: keyof WalletPermissionsManagerCallbacks, handler: PermissionEventHandler): number {
368
+ const arr = this.callbacks[eventName]!
369
+ arr.push(handler)
370
+ return arr.length - 1
371
+ }
372
+
373
+ /**
374
+ * Unbinds a previously registered callback by either its numeric ID (returned by `bindCallback`)
375
+ * or by exact function reference.
376
+ *
377
+ * @param eventName The event name, e.g. "onProtocolPermissionRequested"
378
+ * @param reference Either the numeric ID or the function reference
379
+ * @returns True if successfully unbound, false otherwise
380
+ */
381
+ public unbindCallback(eventName: keyof WalletPermissionsManagerCallbacks, reference: number | Function): boolean {
382
+ if (!this.callbacks[eventName]) return false
383
+ const arr = this.callbacks[eventName] as any[]
384
+ if (typeof reference === 'number') {
385
+ if (arr[reference]) {
386
+ arr[reference] = null
387
+ return true
388
+ }
389
+ return false
390
+ } else {
391
+ const index = arr.indexOf(reference)
392
+ if (index !== -1) {
393
+ arr[index] = null
394
+ return true
395
+ }
396
+ return false
397
+ }
398
+ }
399
+
400
+ /**
401
+ * Internally triggers a named event, calling all subscribed listeners.
402
+ * Each callback is awaited in turn (though errors are swallowed so that
403
+ * one failing callback doesn't prevent the others).
404
+ *
405
+ * @param eventName The event name
406
+ * @param param The parameter object passed to all listeners
407
+ */
408
+ private async callEvent(eventName: keyof WalletPermissionsManagerCallbacks, param: any): Promise<void> {
409
+ const arr = this.callbacks[eventName] || []
410
+ for (const cb of arr) {
411
+ if (typeof cb === 'function') {
412
+ try {
413
+ await cb(param)
414
+ } catch (e) {
415
+ // Intentionally swallow errors from user-provided callbacks
416
+ }
417
+ }
418
+ }
419
+ }
420
+
421
+ /* ---------------------------------------------------------------------
422
+ * 2) PERMISSION (GRANT / DENY) METHODS
423
+ * --------------------------------------------------------------------- */
424
+
425
+ /**
426
+ * Grants a previously requested permission.
427
+ * This method:
428
+ * 1) Resolves all pending promise calls waiting on this request
429
+ * 2) Optionally creates or renews an on-chain PushDrop token (unless `ephemeral===true`)
430
+ *
431
+ * @param params requestID to identify which request is granted, plus optional expiry
432
+ * or `ephemeral` usage, etc.
433
+ */
434
+ public async grantPermission(params: {
435
+ requestID: string
436
+ expiry?: number
437
+ ephemeral?: boolean
438
+ amount?: number
439
+ }): Promise<void> {
440
+ // 1) Identify the matching queued requests in `activeRequests`
441
+ const matching = this.activeRequests.get(params.requestID)
442
+ if (!matching) {
443
+ throw new Error('Request ID not found.')
444
+ }
445
+
446
+ // 2) Mark all matching requests as resolved, deleting the entry
447
+ for (const x of matching.pending) {
448
+ x.resolve(true)
449
+ }
450
+ this.activeRequests.delete(params.requestID)
451
+
452
+ // 3) If `ephemeral !== true`, we create or renew an on-chain token
453
+ if (!params.ephemeral) {
454
+ if (!matching.request.renewal) {
455
+ // brand-new permission token
456
+ await this.createPermissionOnChain(
457
+ matching.request,
458
+ params.expiry || Math.floor(Date.now() / 1000) + 3600 * 24 * 30, // default 30-day expiry
459
+ params.amount
460
+ )
461
+ } else {
462
+ // renewal => spend the old token, produce a new one
463
+ await this.renewPermissionOnChain(
464
+ matching.request.previousToken!,
465
+ matching.request,
466
+ params.expiry || Math.floor(Date.now() / 1000) + 3600 * 24 * 30,
467
+ params.amount
468
+ )
469
+ }
470
+ }
471
+ }
472
+
473
+ /**
474
+ * Denies a previously requested permission.
475
+ * This method rejects all pending promise calls waiting on that request
476
+ *
477
+ * @param requestID requestID identifying which request to deny
478
+ */
479
+ public async denyPermission(requestID: string): Promise<void> {
480
+ // 1) Identify the matching requests
481
+ const matching = this.activeRequests.get(requestID)
482
+ if (!matching) {
483
+ throw new Error('Request ID not found.')
484
+ }
485
+
486
+ // 2) Reject all matching requests, deleting the entry
487
+ for (const x of matching.pending) {
488
+ x.reject(new Error('Permission denied.'))
489
+ }
490
+ this.activeRequests.delete(requestID)
491
+ }
492
+
493
+ /* ---------------------------------------------------------------------
494
+ * 3) THE "ENSURE" METHODS: CHECK IF PERMISSION EXISTS, OTHERWISE PROMPT
495
+ * --------------------------------------------------------------------- */
496
+
497
+ /**
498
+ * Ensures the originator has protocol usage permission.
499
+ * If no valid (unexpired) permission token is found, triggers a permission request flow.
500
+ */
501
+ public async ensureProtocolPermission({
502
+ originator,
503
+ privileged,
504
+ protocolID,
505
+ counterparty,
506
+ reason,
507
+ seekPermission = true,
508
+ usageType
509
+ }: {
510
+ originator: string
511
+ privileged: boolean
512
+ protocolID: [0 | 1 | 2, string]
513
+ counterparty: string
514
+ reason?: string
515
+ seekPermission?: boolean
516
+ usageType: 'signing' | 'encrypting' | 'hmac' | 'publicKey' | 'identityKey' | 'linkageRevelation' | 'generic'
517
+ }): Promise<boolean> {
518
+ // 1) adminOriginator can do anything
519
+ if (this.isAdminOriginator(originator)) return true
520
+
521
+ // 2) If security level=0, we consider it "open" usage
522
+ const [level, protoName] = protocolID
523
+ if (level === 0) return true
524
+
525
+ // 3) If protocol is admin-reserved, block
526
+ if (this.isAdminProtocol(protocolID)) {
527
+ throw new Error(`Protocol “${protoName}” is admin-only.`)
528
+ }
529
+
530
+ // Allow the configured exceptions.
531
+ if (usageType === 'signing' && !this.config.seekProtocolPermissionsForSigning) {
532
+ return true
533
+ }
534
+ if (usageType === 'encrypting' && !this.config.seekProtocolPermissionsForEncrypting) {
535
+ return true
536
+ }
537
+ if (usageType === 'hmac' && !this.config.seekProtocolPermissionsForHMAC) {
538
+ return true
539
+ }
540
+ if (usageType === 'publicKey' && !this.config.seekPermissionsForPublicKeyRevelation) {
541
+ return true
542
+ }
543
+ if (usageType === 'identityKey' && !this.config.seekPermissionsForIdentityKeyRevelation) {
544
+ return true
545
+ }
546
+ if (usageType === 'linkageRevelation' && !this.config.seekPermissionsForKeyLinkageRevelation) {
547
+ return true
548
+ }
549
+ if (!this.config.differentiatePrivilegedOperations) {
550
+ privileged = false
551
+ }
552
+
553
+ // 4) Attempt to find a valid token in the internal basket
554
+ const token = await this.findProtocolToken(
555
+ originator,
556
+ privileged,
557
+ protocolID,
558
+ counterparty,
559
+ /*includeExpired=*/ true
560
+ )
561
+ if (token) {
562
+ if (!this.isTokenExpired(token.expiry)) {
563
+ // valid and unexpired
564
+ return true
565
+ } else {
566
+ // has a token but expired => request renewal if allowed
567
+ if (!seekPermission) {
568
+ throw new Error(`Protocol permission expired and no further user consent allowed (seekPermission=false).`)
569
+ }
570
+ return await this.requestPermissionFlow({
571
+ type: 'protocol',
572
+ originator,
573
+ privileged,
574
+ protocolID,
575
+ counterparty,
576
+ reason,
577
+ renewal: true,
578
+ previousToken: token
579
+ })
580
+ }
581
+ } else {
582
+ // No token found => request a new one if allowed
583
+ if (!seekPermission) {
584
+ throw new Error(`No protocol permission token found (seekPermission=false).`)
585
+ }
586
+ return await this.requestPermissionFlow({
587
+ type: 'protocol',
588
+ originator,
589
+ privileged,
590
+ protocolID,
591
+ counterparty,
592
+ reason,
593
+ renewal: false
594
+ })
595
+ }
596
+ }
597
+
598
+ /**
599
+ * Ensures the originator has basket usage permission for the specified basket.
600
+ * If not, triggers a permission request flow.
601
+ */
602
+ public async ensureBasketAccess({
603
+ originator,
604
+ basket,
605
+ reason,
606
+ seekPermission = true,
607
+ usageType
608
+ }: {
609
+ originator: string
610
+ basket: string
611
+ reason?: string
612
+ seekPermission?: boolean
613
+ usageType: 'insertion' | 'removal' | 'listing'
614
+ }): Promise<boolean> {
615
+ if (this.isAdminOriginator(originator)) return true
616
+ if (this.isAdminBasket(basket)) {
617
+ throw new Error(`Basket “${basket}” is admin-only.`)
618
+ }
619
+ if (usageType === 'insertion' && !this.config.seekBasketInsertionPermissions) return true
620
+ if (usageType === 'removal' && !this.config.seekBasketRemovalPermissions) return true
621
+ if (usageType === 'listing' && !this.config.seekBasketListingPermissions) return true
622
+ const token = await this.findBasketToken(originator, basket, true)
623
+ if (token) {
624
+ if (!this.isTokenExpired(token.expiry)) {
625
+ return true
626
+ } else {
627
+ if (!seekPermission) {
628
+ throw new Error(`Basket permission expired (seekPermission=false).`)
629
+ }
630
+ return await this.requestPermissionFlow({
631
+ type: 'basket',
632
+ originator,
633
+ basket,
634
+ reason,
635
+ renewal: true,
636
+ previousToken: token
637
+ })
638
+ }
639
+ } else {
640
+ // none
641
+ if (!seekPermission) {
642
+ throw new Error(`No basket permission found, and no user consent allowed (seekPermission=false).`)
643
+ }
644
+ return await this.requestPermissionFlow({
645
+ type: 'basket',
646
+ originator,
647
+ basket,
648
+ reason,
649
+ renewal: false
650
+ })
651
+ }
652
+ }
653
+
654
+ /**
655
+ * Ensures the originator has a valid certificate permission.
656
+ * This is relevant when revealing certificate fields in DCAP contexts.
657
+ */
658
+ public async ensureCertificateAccess({
659
+ originator,
660
+ privileged,
661
+ verifier,
662
+ certType,
663
+ fields,
664
+ reason,
665
+ seekPermission = true,
666
+ usageType
667
+ }: {
668
+ originator: string
669
+ privileged: boolean
670
+ verifier: string
671
+ certType: string
672
+ fields: string[]
673
+ reason?: string
674
+ seekPermission?: boolean
675
+ usageType: 'disclosure'
676
+ }): Promise<boolean> {
677
+ if (this.isAdminOriginator(originator)) return true
678
+ if (usageType === 'disclosure' && !this.config.seekCertificateDisclosurePermissions) {
679
+ return true
680
+ }
681
+ if (!this.config.differentiatePrivilegedOperations) {
682
+ privileged = false
683
+ }
684
+ const token = await this.findCertificateToken(
685
+ originator,
686
+ privileged,
687
+ verifier,
688
+ certType,
689
+ fields,
690
+ /*includeExpired=*/ true
691
+ )
692
+ if (token) {
693
+ if (!this.isTokenExpired(token.expiry)) {
694
+ return true
695
+ } else {
696
+ if (!seekPermission) {
697
+ throw new Error(`Certificate permission expired (seekPermission=false).`)
698
+ }
699
+ return await this.requestPermissionFlow({
700
+ type: 'certificate',
701
+ originator,
702
+ privileged,
703
+ certificate: { verifier, certType, fields },
704
+ reason,
705
+ renewal: true,
706
+ previousToken: token
707
+ })
708
+ }
709
+ } else {
710
+ if (!seekPermission) {
711
+ throw new Error(`No certificate permission found (seekPermission=false).`)
712
+ }
713
+ return await this.requestPermissionFlow({
714
+ type: 'certificate',
715
+ originator,
716
+ privileged,
717
+ certificate: { verifier, certType, fields },
718
+ reason,
719
+ renewal: false
720
+ })
721
+ }
722
+ }
723
+
724
+ /**
725
+ * Ensures the originator has spending authorization (DSAP) for a certain satoshi amount.
726
+ * If the existing token limit is insufficient, attempts to renew. If no token, attempts to create one.
727
+ */
728
+ public async ensureSpendingAuthorization({
729
+ originator,
730
+ satoshis,
731
+ lineItems,
732
+ reason,
733
+ seekPermission = true
734
+ }: {
735
+ originator: string
736
+ satoshis: number
737
+ lineItems?: Array<{
738
+ type: 'input' | 'output' | 'fee'
739
+ description: string
740
+ satoshis: number
741
+ }>
742
+ reason?: string
743
+ seekPermission?: boolean
744
+ }): Promise<boolean> {
745
+ if (this.isAdminOriginator(originator)) return true
746
+ if (!this.config.seekSpendingPermissions) {
747
+ // We skip spending permission entirely
748
+ return true
749
+ }
750
+ const token = await this.findSpendingToken(originator)
751
+ if (token?.authorizedAmount) {
752
+ // Check how much has been spent so far
753
+ const spentSoFar = await this.querySpentSince(token)
754
+ if (spentSoFar + satoshis <= token.authorizedAmount) {
755
+ return true
756
+ } else {
757
+ // Renew if possible
758
+ if (!seekPermission) {
759
+ throw new Error(
760
+ `Spending authorization insufficient for ${satoshis}, no user consent (seekPermission=false).`
761
+ )
762
+ }
763
+ return await this.requestPermissionFlow({
764
+ type: 'spending',
765
+ originator,
766
+ spending: { satoshis, lineItems },
767
+ reason,
768
+ renewal: true,
769
+ previousToken: token
770
+ })
771
+ }
772
+ } else {
773
+ // no token
774
+ if (!seekPermission) {
775
+ throw new Error(`No spending authorization found, (seekPermission=false).`)
776
+ }
777
+ return await this.requestPermissionFlow({
778
+ type: 'spending',
779
+ originator,
780
+ spending: { satoshis, lineItems },
781
+ reason,
782
+ renewal: false
783
+ })
784
+ }
785
+ }
786
+
787
+ /**
788
+ * Ensures the originator has label usage permission.
789
+ * If no valid (unexpired) permission token is found, triggers a permission request flow.
790
+ */
791
+ public async ensureLabelAccess({
792
+ originator,
793
+ label,
794
+ reason,
795
+ seekPermission = true,
796
+ usageType
797
+ }: {
798
+ originator: string
799
+ label: string
800
+ reason?: string
801
+ seekPermission?: boolean
802
+ usageType: 'apply' | 'list'
803
+ }): Promise<boolean> {
804
+ // 1) adminOriginator can do anything
805
+ if (this.isAdminOriginator(originator)) return true
806
+
807
+ // 2) If label is admin-reserved, block
808
+ if (this.isAdminLabel(label)) {
809
+ throw new Error(`Label “${label}” is admin-only.`)
810
+ }
811
+
812
+ if (usageType === 'apply' && !this.config.seekPermissionWhenApplyingActionLabels) {
813
+ return true
814
+ }
815
+ if (usageType === 'list' && !this.config.seekPermissionWhenListingActionsByLabel) {
816
+ return true
817
+ }
818
+
819
+ // 3) Let ensureProtocolPermission handle the rest.
820
+ return await this.ensureProtocolPermission({
821
+ originator,
822
+ privileged: false,
823
+ protocolID: [1, `action label ${label}`],
824
+ counterparty: 'self',
825
+ reason,
826
+ seekPermission,
827
+ usageType: 'generic'
828
+ })
829
+ }
830
+
831
+ /**
832
+ * A central method that triggers the permission request flow.
833
+ * - It checks if there's already an active request for the same key
834
+ * - If so, we wait on that existing request rather than creating a duplicative one
835
+ * - Otherwise we create a new request queue, call the relevant "onXXXRequested" event,
836
+ * and return a promise that resolves once permission is granted or rejects if denied.
837
+ */
838
+ private async requestPermissionFlow(r: PermissionRequest): Promise<boolean> {
839
+ const key = this.buildRequestKey(r)
840
+
841
+ // If there's already a queue for the same resource, we piggyback on it
842
+ const existingQueue = this.activeRequests.get(key)
843
+ if (existingQueue && existingQueue.pending.length > 0) {
844
+ return new Promise<boolean>((resolve, reject) => {
845
+ existingQueue.pending.push({ resolve, reject })
846
+ })
847
+ }
848
+
849
+ // Otherwise, create a new queue with a single entry
850
+ // Return a promise that resolves or rejects once the user grants/denies
851
+ return new Promise<boolean>(async (resolve, reject) => {
852
+ this.activeRequests.set(key, {
853
+ request: r,
854
+ pending: [{ resolve, reject }]
855
+ })
856
+
857
+ // Fire the relevant onXXXRequested event (which one depends on r.type)
858
+ switch (r.type) {
859
+ case 'protocol':
860
+ await this.callEvent('onProtocolPermissionRequested', {
861
+ ...r,
862
+ requestID: key
863
+ })
864
+ break
865
+ case 'basket':
866
+ await this.callEvent('onBasketAccessRequested', {
867
+ ...r,
868
+ requestID: key
869
+ })
870
+ break
871
+ case 'certificate':
872
+ await this.callEvent('onCertificateAccessRequested', {
873
+ ...r,
874
+ requestID: key
875
+ })
876
+ break
877
+ case 'spending':
878
+ await this.callEvent('onSpendingAuthorizationRequested', {
879
+ ...r,
880
+ requestID: key
881
+ })
882
+ break
883
+ }
884
+ })
885
+ }
886
+
887
+ /* ---------------------------------------------------------------------
888
+ * 4) SEARCH / DECODE / DECRYPT ON-CHAIN TOKENS (PushDrop Scripts)
889
+ * --------------------------------------------------------------------- */
890
+
891
+ /**
892
+ * We will use a administrative "permission token encryption" protocol to store fields
893
+ * in each permission's PushDrop script. This ensures that only the user's wallet
894
+ * can decrypt them. In practice, this data is not super sensitive, but we still
895
+ * follow the principle of least exposure.
896
+ */
897
+ private static readonly PERM_TOKEN_ENCRYPTION_PROTOCOL: [2, 'admin permission token encryption'] = [
898
+ 2,
899
+ 'admin permission token encryption'
900
+ ]
901
+
902
+ /**
903
+ * Similarly, we will use a "metadata encryption" protocol to preserve the confidentiality
904
+ * of transaction descriptions and input/output descriptions from lower storage layers.
905
+ */
906
+ private static readonly METADATA_ENCRYPTION_PROTOCOL: [2, 'admin metadata encryption'] = [
907
+ 2,
908
+ 'admin metadata encryption'
909
+ ]
910
+
911
+ /** We always use `keyID="1"` and `counterparty="self"` for these encryption ops. */
912
+ private async encryptPermissionTokenField(plaintext: string | number[]): Promise<number[]> {
913
+ const data = typeof plaintext === 'string' ? Utils.toArray(plaintext, 'utf8') : plaintext
914
+ const { ciphertext } = await this.underlying.encrypt(
915
+ {
916
+ plaintext: data,
917
+ protocolID: WalletPermissionsManager.PERM_TOKEN_ENCRYPTION_PROTOCOL,
918
+ keyID: '1'
919
+ },
920
+ this.adminOriginator
921
+ )
922
+ return ciphertext
923
+ }
924
+
925
+ private async decryptPermissionTokenField(ciphertext: number[]): Promise<number[]> {
926
+ const { plaintext } = await this.underlying.decrypt(
927
+ {
928
+ ciphertext,
929
+ protocolID: WalletPermissionsManager.PERM_TOKEN_ENCRYPTION_PROTOCOL,
930
+ keyID: '1'
931
+ },
932
+ this.adminOriginator
933
+ )
934
+ return plaintext
935
+ }
936
+
937
+ /**
938
+ * Encrypts wallet metadata if configured to do so, otherwise returns the original plaintext for storage.
939
+ * @param plaintext The metadata to encrypt if configured to do so
940
+ * @returns The encrypted metadata, or the original value if encryption was disabled.
941
+ */
942
+ private async maybeEncryptMetadata(plaintext: string): Promise<string> {
943
+ if (!this.config.encryptWalletMetadata) {
944
+ return plaintext
945
+ }
946
+ const { ciphertext } = await this.underlying.encrypt(
947
+ {
948
+ plaintext: Utils.toArray(plaintext, 'utf8'),
949
+ protocolID: WalletPermissionsManager.METADATA_ENCRYPTION_PROTOCOL,
950
+ keyID: '1'
951
+ },
952
+ this.adminOriginator
953
+ )
954
+ return Utils.toUTF8(ciphertext) // Still a string, but scrambled.
955
+ }
956
+
957
+ /**
958
+ * Attempts to decrypt metadata. if decryption fails, assumes the value is already plaintext and returns it.
959
+ * @param ciphertext The metadata to attempt decryption for.
960
+ * @returns The decrypted metadata. If decryption fails, returns the original value instead.
961
+ */
962
+ private async maybeDecryptMetadata(ciphertext: string): Promise<string> {
963
+ try {
964
+ const { plaintext } = await this.underlying.decrypt(
965
+ {
966
+ ciphertext: Utils.toArray(ciphertext, 'utf8'),
967
+ protocolID: WalletPermissionsManager.METADATA_ENCRYPTION_PROTOCOL,
968
+ keyID: '1'
969
+ },
970
+ this.adminOriginator
971
+ )
972
+ return Utils.toUTF8(plaintext)
973
+ } catch (e) {
974
+ return ciphertext
975
+ }
976
+ }
977
+
978
+ /** Helper to see if a token's expiry is in the past. */
979
+ private isTokenExpired(expiry: number): boolean {
980
+ const now = Math.floor(Date.now() / 1000)
981
+ return expiry > 0 && expiry < now
982
+ }
983
+
984
+ /** Looks for a DPACP permission token matching origin/domain, privileged, protocol, cpty. */
985
+ private async findProtocolToken(
986
+ originator: string,
987
+ privileged: boolean,
988
+ protocolID: [0 | 1 | 2, string],
989
+ counterparty: string,
990
+ includeExpired: boolean
991
+ ): Promise<PermissionToken | undefined> {
992
+ const [secLevel, protoName] = protocolID
993
+ const result = await this.underlying.listOutputs(
994
+ {
995
+ basket: BASKET_MAP.protocol,
996
+ tags: [
997
+ `originator ${originator}`,
998
+ `privileged ${privileged}`,
999
+ `protocolName ${protoName}`,
1000
+ `protocolSecurityLevel ${secLevel}`,
1001
+ `counterparty ${counterparty}`
1002
+ ],
1003
+ tagQueryMode: 'all',
1004
+ include: 'locking scripts'
1005
+ },
1006
+ this.adminOriginator
1007
+ )
1008
+
1009
+ for (const out of result.outputs) {
1010
+ const script = LockingScript.fromHex(out.lockingScript!)
1011
+ const dec = PushDrop.decode(script)
1012
+ if (!dec || !dec.fields || dec.fields.length < 6) continue
1013
+ const domainRaw = dec.fields[0]
1014
+ const expiryRaw = dec.fields[1]
1015
+ const privRaw = dec.fields[2]
1016
+ const secLevelRaw = dec.fields[3]
1017
+ const protoNameRaw = dec.fields[4]
1018
+ const counterpartyRaw = dec.fields[5]
1019
+
1020
+ const domainDecoded = Utils.toUTF8(await this.decryptPermissionTokenField(domainRaw))
1021
+ const expiryDecoded = parseInt(Utils.toUTF8(await this.decryptPermissionTokenField(expiryRaw)), 10)
1022
+ const privDecoded = Utils.toUTF8(await this.decryptPermissionTokenField(privRaw)) === 'true'
1023
+ const secLevelDecoded = parseInt(Utils.toUTF8(await this.decryptPermissionTokenField(secLevelRaw)), 10) as
1024
+ | 0
1025
+ | 1
1026
+ | 2
1027
+ const protoNameDecoded = Utils.toUTF8(await this.decryptPermissionTokenField(protoNameRaw))
1028
+ const cptyDecoded = Utils.toUTF8(await this.decryptPermissionTokenField(counterpartyRaw))
1029
+
1030
+ if (
1031
+ domainDecoded !== originator ||
1032
+ privDecoded !== privileged ||
1033
+ secLevelDecoded !== secLevel ||
1034
+ protoNameDecoded !== protoName ||
1035
+ cptyDecoded !== counterparty
1036
+ ) {
1037
+ continue
1038
+ }
1039
+ if (!includeExpired && this.isTokenExpired(expiryDecoded)) {
1040
+ continue
1041
+ }
1042
+ return {
1043
+ txid: out.outpoint.split('.')[0],
1044
+ outputIndex: parseInt(out.outpoint.split('.')[1], 10),
1045
+ outputScript: out.lockingScript!,
1046
+ satoshis: out.satoshis,
1047
+ originator,
1048
+ privileged,
1049
+ protocol: protoName,
1050
+ securityLevel: secLevel,
1051
+ expiry: expiryDecoded,
1052
+ counterparty: cptyDecoded
1053
+ }
1054
+ }
1055
+ return undefined
1056
+ }
1057
+
1058
+ /** Looks for a DBAP token matching (originator, basket). */
1059
+ private async findBasketToken(
1060
+ originator: string,
1061
+ basket: string,
1062
+ includeExpired: boolean
1063
+ ): Promise<PermissionToken | undefined> {
1064
+ const result = await this.underlying.listOutputs(
1065
+ {
1066
+ basket: BASKET_MAP.basket,
1067
+ tags: [`originator ${originator}`, `basket ${basket}`],
1068
+ tagQueryMode: 'all',
1069
+ include: 'locking scripts'
1070
+ },
1071
+ this.adminOriginator
1072
+ )
1073
+
1074
+ for (const out of result.outputs) {
1075
+ const dec = PushDrop.decode(LockingScript.fromHex(out.lockingScript!))
1076
+ if (!dec?.fields || dec.fields.length < 3) continue
1077
+ const domainRaw = dec.fields[0]
1078
+ const expiryRaw = dec.fields[1]
1079
+ const basketRaw = dec.fields[2]
1080
+
1081
+ const domainDecoded = Utils.toUTF8(await this.decryptPermissionTokenField(domainRaw))
1082
+ const expiryDecoded = parseInt(Utils.toUTF8(await this.decryptPermissionTokenField(expiryRaw)), 10)
1083
+ const basketDecoded = Utils.toUTF8(await this.decryptPermissionTokenField(basketRaw))
1084
+ if (domainDecoded !== originator || basketDecoded !== basket) continue
1085
+ if (!includeExpired && this.isTokenExpired(expiryDecoded)) continue
1086
+
1087
+ return {
1088
+ txid: out.outpoint.split('.')[0],
1089
+ outputIndex: parseInt(out.outpoint.split('.')[1], 10),
1090
+ outputScript: out.lockingScript!,
1091
+ satoshis: out.satoshis,
1092
+ originator,
1093
+ basketName: basketDecoded,
1094
+ expiry: expiryDecoded
1095
+ }
1096
+ }
1097
+ return undefined
1098
+ }
1099
+
1100
+ /** Looks for a DCAP token matching (origin, privileged, verifier, certType, fields subset). */
1101
+ private async findCertificateToken(
1102
+ originator: string,
1103
+ privileged: boolean,
1104
+ verifier: string,
1105
+ certType: string,
1106
+ fields: string[],
1107
+ includeExpired: boolean
1108
+ ): Promise<PermissionToken | undefined> {
1109
+ const result = await this.underlying.listOutputs(
1110
+ {
1111
+ basket: BASKET_MAP.certificate,
1112
+ tags: [`originator ${originator}`, `privileged ${privileged}`, `type ${certType}`, `verifier ${verifier}`],
1113
+ tagQueryMode: 'all',
1114
+ include: 'locking scripts'
1115
+ },
1116
+ this.adminOriginator
1117
+ )
1118
+
1119
+ for (const out of result.outputs) {
1120
+ const dec = PushDrop.decode(LockingScript.fromHex(out.lockingScript!))
1121
+ if (!dec?.fields || dec.fields.length < 6) continue
1122
+ const [domainRaw, expiryRaw, privRaw, typeRaw, fieldsRaw, verifierRaw] = dec.fields
1123
+
1124
+ const domainDecoded = Utils.toUTF8(await this.decryptPermissionTokenField(domainRaw))
1125
+ const expiryDecoded = parseInt(Utils.toUTF8(await this.decryptPermissionTokenField(expiryRaw)), 10)
1126
+ const privDecoded = Utils.toUTF8(await this.decryptPermissionTokenField(privRaw)) === 'true'
1127
+ const typeDecoded = Utils.toUTF8(await this.decryptPermissionTokenField(typeRaw))
1128
+ const verifierDec = Utils.toUTF8(await this.decryptPermissionTokenField(verifierRaw))
1129
+
1130
+ const fieldsJson = await this.decryptPermissionTokenField(fieldsRaw)
1131
+ const allFields = JSON.parse(Utils.toUTF8(fieldsJson)) as string[]
1132
+
1133
+ if (
1134
+ domainDecoded !== originator ||
1135
+ privDecoded !== privileged ||
1136
+ typeDecoded !== certType ||
1137
+ verifierDec !== verifier
1138
+ ) {
1139
+ continue
1140
+ }
1141
+ // Check if 'fields' is a subset of 'allFields'
1142
+ const setAll = new Set(allFields)
1143
+ if (fields.some(f => !setAll.has(f))) {
1144
+ continue
1145
+ }
1146
+ if (!includeExpired && this.isTokenExpired(expiryDecoded)) {
1147
+ continue
1148
+ }
1149
+ return {
1150
+ txid: out.outpoint.split('.')[0],
1151
+ outputIndex: parseInt(out.outpoint.split('.')[1], 10),
1152
+ outputScript: out.lockingScript!,
1153
+ satoshis: out.satoshis,
1154
+ originator,
1155
+ privileged,
1156
+ verifier: verifierDec,
1157
+ certType: typeDecoded,
1158
+ certFields: allFields,
1159
+ expiry: expiryDecoded
1160
+ }
1161
+ }
1162
+ return undefined
1163
+ }
1164
+
1165
+ /** Looks for a DSAP token matching origin, returning the first one found. */
1166
+ private async findSpendingToken(originator: string): Promise<PermissionToken | undefined> {
1167
+ const result = await this.underlying.listOutputs(
1168
+ {
1169
+ basket: BASKET_MAP.spending,
1170
+ tags: [`originator ${originator}`],
1171
+ tagQueryMode: 'all',
1172
+ include: 'locking scripts'
1173
+ },
1174
+ this.adminOriginator
1175
+ )
1176
+
1177
+ for (const out of result.outputs) {
1178
+ const dec = PushDrop.decode(LockingScript.fromHex(out.lockingScript!))
1179
+ if (!dec?.fields || dec.fields.length < 2) continue
1180
+ const domainRaw = dec.fields[0]
1181
+ const amtRaw = dec.fields[1]
1182
+
1183
+ const domainDecoded = Utils.toUTF8(await this.decryptPermissionTokenField(domainRaw))
1184
+ if (domainDecoded !== originator) continue
1185
+ const amtDecodedStr = Utils.toUTF8(await this.decryptPermissionTokenField(amtRaw))
1186
+ const authorizedAmount = parseInt(amtDecodedStr, 10)
1187
+
1188
+ return {
1189
+ txid: out.outpoint.split('.')[0],
1190
+ outputIndex: parseInt(out.outpoint.split('.')[1], 10),
1191
+ outputScript: out.lockingScript!,
1192
+ satoshis: out.satoshis,
1193
+ originator,
1194
+ authorizedAmount,
1195
+ expiry: 0 // Not time-limited, monthly authorization
1196
+ }
1197
+ }
1198
+ return undefined
1199
+ }
1200
+
1201
+ /**
1202
+ * Returns the current month and year in UTC as a string in the format "YYYY-MM".
1203
+ *
1204
+ * @returns {string} The current month and year in UTC.
1205
+ */
1206
+ private getCurrentMonthYearUTC(): string {
1207
+ const now = new Date()
1208
+ const year = now.getUTCFullYear()
1209
+ const month = (now.getUTCMonth() + 1).toString().padStart(2, '0') // Ensure 2-digit month
1210
+ return `${year}-${month}`
1211
+ }
1212
+
1213
+ /**
1214
+ * Returns spending for an originator in the current calendar month.
1215
+ */
1216
+ public async querySpentSince(token: PermissionToken): Promise<number> {
1217
+ const { actions } = await this.underlying.listActions(
1218
+ {
1219
+ labels: [`admin originator ${token.originator}`, `admin month ${this.getCurrentMonthYearUTC()}`],
1220
+ labelQueryMode: 'all'
1221
+ },
1222
+ this.adminOriginator
1223
+ )
1224
+ return actions.reduce((a, e) => a + e.satoshis, 0)
1225
+ }
1226
+
1227
+ /* ---------------------------------------------------------------------
1228
+ * 5) CREATE / RENEW / REVOKE PERMISSION TOKENS ON CHAIN
1229
+ * --------------------------------------------------------------------- */
1230
+
1231
+ /**
1232
+ * Creates a brand-new permission token as a single-output PushDrop script in the relevant admin basket.
1233
+ *
1234
+ * The main difference between each type of token is in the "fields" we store in the PushDrop script.
1235
+ *
1236
+ * @param r The permission request
1237
+ * @param expiry The expiry epoch time
1238
+ * @param amount For DSAP, the authorized spending limit
1239
+ */
1240
+ private async createPermissionOnChain(r: PermissionRequest, expiry: number, amount?: number): Promise<void> {
1241
+ const basketName = BASKET_MAP[r.type]
1242
+ if (!basketName) return
1243
+
1244
+ // Build the array of encrypted fields for the PushDrop script
1245
+ const fields: number[][] = await this.buildPushdropFields(r, expiry, amount)
1246
+
1247
+ // Construct the script. We do a simple P2PK check. We ask `PushDrop.lock(...)`
1248
+ // to create a script with a single OP_CHECKSIG verifying ownership to redeem.
1249
+ const script = await new PushDrop(this.underlying).lock(
1250
+ fields,
1251
+ WalletPermissionsManager.PERM_TOKEN_ENCRYPTION_PROTOCOL,
1252
+ '1',
1253
+ 'self',
1254
+ true,
1255
+ true
1256
+ )
1257
+
1258
+ // Create tags
1259
+ const tags = this.buildTagsForRequest(r)
1260
+
1261
+ // Build a transaction with exactly one output, no explicit inputs since the wallet
1262
+ // can internally fund it from its balance.
1263
+ await this.createAction(
1264
+ {
1265
+ description: `Grant ${r.type} permission`,
1266
+ outputs: [
1267
+ {
1268
+ lockingScript: script.toHex(),
1269
+ satoshis: 1,
1270
+ outputDescription: `${r.type} permission token`,
1271
+ basket: basketName,
1272
+ tags
1273
+ }
1274
+ ]
1275
+ },
1276
+ this.adminOriginator
1277
+ )
1278
+ }
1279
+
1280
+ /**
1281
+ * Renews a permission token by spending the old token as input and creating a new token output.
1282
+ * This invalidates the old token and replaces it with a new one.
1283
+ *
1284
+ * @param oldToken The old token to consume
1285
+ * @param r The permission request being renewed
1286
+ * @param newExpiry The new expiry epoch time
1287
+ * @param newAmount For DSAP, the new authorized amount
1288
+ */
1289
+ private async renewPermissionOnChain(
1290
+ oldToken: PermissionToken,
1291
+ r: PermissionRequest,
1292
+ newExpiry: number,
1293
+ newAmount?: number
1294
+ ): Promise<void> {
1295
+ // 1) build new fields
1296
+ const newFields = await this.buildPushdropFields(r, newExpiry, newAmount)
1297
+
1298
+ // 2) new script
1299
+ const newScript = await new PushDrop(this.underlying).lock(
1300
+ newFields,
1301
+ WalletPermissionsManager.PERM_TOKEN_ENCRYPTION_PROTOCOL,
1302
+ '1',
1303
+ 'self',
1304
+ true,
1305
+ true
1306
+ )
1307
+
1308
+ const tags = this.buildTagsForRequest(r)
1309
+
1310
+ // 3) For BRC-100, we do a "createAction" with a partial input referencing oldToken
1311
+ // plus a single new output. We'll hydrate the template, then signAction for the wallet to finalize.
1312
+ const oldOutpoint = `${oldToken.txid}.${oldToken.outputIndex}`
1313
+ const { signableTransaction } = await this.createAction(
1314
+ {
1315
+ description: `Renew ${r.type} permission`,
1316
+ inputs: [
1317
+ {
1318
+ outpoint: oldOutpoint,
1319
+ unlockingScriptLength: 73, // length of signature
1320
+ inputDescription: `Consume old ${r.type} token`
1321
+ }
1322
+ ],
1323
+ outputs: [
1324
+ {
1325
+ lockingScript: newScript.toHex(),
1326
+ satoshis: 1,
1327
+ outputDescription: `Renewed ${r.type} permission token`,
1328
+ basket: BASKET_MAP[r.type],
1329
+ tags
1330
+ }
1331
+ ]
1332
+ },
1333
+ this.adminOriginator
1334
+ )
1335
+ const tx = Transaction.fromBEEF(signableTransaction!.tx)
1336
+ const unlocker = new PushDrop(this.underlying).unlock(
1337
+ WalletPermissionsManager.PERM_TOKEN_ENCRYPTION_PROTOCOL,
1338
+ '1',
1339
+ 'self',
1340
+ 'all',
1341
+ false,
1342
+ 1,
1343
+ LockingScript.fromHex(oldToken.outputScript)
1344
+ )
1345
+ const unlockingScript = await unlocker.sign(tx, 0)
1346
+ await this.underlying.signAction({
1347
+ reference: signableTransaction!.reference,
1348
+ spends: {
1349
+ 0: {
1350
+ unlockingScript: unlockingScript.toHex()
1351
+ }
1352
+ }
1353
+ })
1354
+ }
1355
+
1356
+ /**
1357
+ * Builds the encrypted array of fields for a PushDrop permission token
1358
+ * (protocol / basket / certificate / spending).
1359
+ */
1360
+ private async buildPushdropFields(r: PermissionRequest, expiry: number, amount?: number): Promise<number[][]> {
1361
+ switch (r.type) {
1362
+ case 'protocol': {
1363
+ const [secLevel, protoName] = r.protocolID!
1364
+ return [
1365
+ await this.encryptPermissionTokenField(r.originator), // domain
1366
+ await this.encryptPermissionTokenField(String(expiry)), // expiry
1367
+ await this.encryptPermissionTokenField(r.privileged ? 'true' : 'false'),
1368
+ await this.encryptPermissionTokenField(String(secLevel)),
1369
+ await this.encryptPermissionTokenField(protoName),
1370
+ await this.encryptPermissionTokenField(r.counterparty!)
1371
+ ]
1372
+ }
1373
+ case 'basket': {
1374
+ return [
1375
+ await this.encryptPermissionTokenField(r.originator),
1376
+ await this.encryptPermissionTokenField(String(expiry)),
1377
+ await this.encryptPermissionTokenField(r.basket!)
1378
+ ]
1379
+ }
1380
+ case 'certificate': {
1381
+ const { certType, fields, verifier } = r.certificate!
1382
+ return [
1383
+ await this.encryptPermissionTokenField(r.originator),
1384
+ await this.encryptPermissionTokenField(String(expiry)),
1385
+ await this.encryptPermissionTokenField(r.privileged ? 'true' : 'false'),
1386
+ await this.encryptPermissionTokenField(certType),
1387
+ await this.encryptPermissionTokenField(JSON.stringify(fields)),
1388
+ await this.encryptPermissionTokenField(verifier)
1389
+ ]
1390
+ }
1391
+ case 'spending': {
1392
+ // DSAP
1393
+ const authAmt = amount ?? (r.spending?.satoshis || 0)
1394
+ return [
1395
+ await this.encryptPermissionTokenField(r.originator),
1396
+ await this.encryptPermissionTokenField(String(authAmt))
1397
+ ]
1398
+ }
1399
+ }
1400
+ }
1401
+
1402
+ /**
1403
+ * Helper to build an array of tags for the new output, matching the user request's
1404
+ * origin, basket, privileged, protocol name, etc.
1405
+ */
1406
+ private buildTagsForRequest(r: PermissionRequest): string[] {
1407
+ const tags: string[] = [`originator ${r.originator}`]
1408
+ switch (r.type) {
1409
+ case 'protocol': {
1410
+ tags.push(`privileged ${r.privileged}`)
1411
+ tags.push(`protocolName ${r.protocolID![1]}`)
1412
+ tags.push(`protocolSecurityLevel ${r.protocolID![0]}`)
1413
+ tags.push(`counterparty ${r.counterparty}`)
1414
+ break
1415
+ }
1416
+ case 'basket': {
1417
+ tags.push(`basket ${r.basket}`)
1418
+ break
1419
+ }
1420
+ case 'certificate': {
1421
+ tags.push(`privileged ${r.privileged}`)
1422
+ tags.push(`type ${r.certificate!.certType}`)
1423
+ tags.push(`verifier ${r.certificate!.verifier}`)
1424
+ break
1425
+ }
1426
+ case 'spending': {
1427
+ // Only 'originator' is strictly required as a tag.
1428
+ break
1429
+ }
1430
+ }
1431
+ return tags
1432
+ }
1433
+
1434
+ /* ---------------------------------------------------------------------
1435
+ * 6) PUBLIC "LIST/HAS/REVOKE" METHODS
1436
+ * --------------------------------------------------------------------- */
1437
+
1438
+ /**
1439
+ * Lists all protocol permission tokens (DPACP) for a given originator or for all if originator is undefined.
1440
+ * This is a convenience method for UI or debug.
1441
+ */
1442
+ public async listProtocolPermissions({ originator }: { originator?: string }): Promise<PermissionToken[]> {
1443
+ const basketName = BASKET_MAP.protocol
1444
+ const tags: string[] = originator ? [`originator ${originator}`] : []
1445
+ const result = await this.underlying.listOutputs(
1446
+ {
1447
+ basket: basketName,
1448
+ tags,
1449
+ tagQueryMode: 'all',
1450
+ include: 'locking scripts',
1451
+ limit: 100
1452
+ },
1453
+ this.adminOriginator
1454
+ )
1455
+
1456
+ const tokens: PermissionToken[] = []
1457
+ for (const out of result.outputs) {
1458
+ const dec = PushDrop.decode(LockingScript.fromHex(out.lockingScript!))
1459
+ if (!dec?.fields || dec.fields.length < 6) continue
1460
+ const [domainRaw, expiryRaw, privRaw, secRaw, protoRaw, cptyRaw] = dec.fields
1461
+
1462
+ const domainDec = Utils.toUTF8(await this.decryptPermissionTokenField(domainRaw))
1463
+ const expiryDec = parseInt(Utils.toUTF8(await this.decryptPermissionTokenField(expiryRaw)), 10)
1464
+ const privDec = Utils.toUTF8(await this.decryptPermissionTokenField(privRaw)) === 'true'
1465
+ const secDec = parseInt(Utils.toUTF8(await this.decryptPermissionTokenField(secRaw)), 10) as 0 | 1 | 2
1466
+ const protoDec = Utils.toUTF8(await this.decryptPermissionTokenField(protoRaw))
1467
+ const cptyDec = Utils.toUTF8(await this.decryptPermissionTokenField(cptyRaw))
1468
+
1469
+ tokens.push({
1470
+ txid: out.outpoint.split('.')[0],
1471
+ outputIndex: parseInt(out.outpoint.split('.')[1], 10),
1472
+ outputScript: out.lockingScript!,
1473
+ satoshis: out.satoshis,
1474
+ originator: domainDec,
1475
+ expiry: expiryDec,
1476
+ privileged: privDec,
1477
+ securityLevel: secDec,
1478
+ protocol: protoDec,
1479
+ counterparty: cptyDec
1480
+ })
1481
+ }
1482
+ return tokens
1483
+ }
1484
+
1485
+ /**
1486
+ * Returns true if the originator already holds a valid unexpired protocol permission.
1487
+ * This calls `ensureProtocolPermission` with `seekPermission=false`, so it won't prompt.
1488
+ */
1489
+ public async hasProtocolPermission(params: {
1490
+ originator: string
1491
+ privileged: boolean
1492
+ protocolID: [0 | 1 | 2, string]
1493
+ counterparty: string
1494
+ }): Promise<boolean> {
1495
+ try {
1496
+ await this.ensureProtocolPermission({
1497
+ ...params,
1498
+ reason: 'hasProtocolPermission',
1499
+ seekPermission: false,
1500
+ usageType: 'generic'
1501
+ })
1502
+ return true
1503
+ } catch {
1504
+ return false
1505
+ }
1506
+ }
1507
+
1508
+ /**
1509
+ * Lists basket permission tokens (DBAP) for a given originator (or for all if not specified).
1510
+ */
1511
+ public async listBasketAccess(params: { originator?: string }): Promise<PermissionToken[]> {
1512
+ const basketName = BASKET_MAP.basket
1513
+ const tags: string[] = []
1514
+ if (params.originator) {
1515
+ tags.push(`originator ${params.originator}`)
1516
+ }
1517
+ const result = await this.underlying.listOutputs(
1518
+ {
1519
+ basket: basketName,
1520
+ tags,
1521
+ tagQueryMode: 'all',
1522
+ include: 'locking scripts',
1523
+ limit: 10000
1524
+ },
1525
+ this.adminOriginator
1526
+ )
1527
+
1528
+ const tokens: PermissionToken[] = []
1529
+ for (const out of result.outputs) {
1530
+ const dec = PushDrop.decode(LockingScript.fromHex(out.lockingScript!))
1531
+ if (!dec?.fields || dec.fields.length < 3) continue
1532
+ const [domainRaw, expiryRaw, basketRaw] = dec.fields
1533
+ const domainDecoded = Utils.toUTF8(await this.decryptPermissionTokenField(domainRaw))
1534
+ const expiryDecoded = parseInt(Utils.toUTF8(await this.decryptPermissionTokenField(expiryRaw)), 10)
1535
+ const basketDecoded = Utils.toUTF8(await this.decryptPermissionTokenField(basketRaw))
1536
+ tokens.push({
1537
+ txid: out.outpoint.split('.')[0],
1538
+ outputIndex: parseInt(out.outpoint.split('.')[1], 10),
1539
+ satoshis: out.satoshis,
1540
+ outputScript: out.lockingScript!,
1541
+ originator: domainDecoded,
1542
+ basketName: basketDecoded,
1543
+ expiry: expiryDecoded
1544
+ })
1545
+ }
1546
+ return tokens
1547
+ }
1548
+
1549
+ /**
1550
+ * Returns `true` if the originator already holds a valid unexpired basket permission for `basket`.
1551
+ */
1552
+ public async hasBasketAccess(params: { originator: string; basket: string }): Promise<boolean> {
1553
+ try {
1554
+ await this.ensureBasketAccess({
1555
+ originator: params.originator,
1556
+ basket: params.basket,
1557
+ seekPermission: false,
1558
+ usageType: 'insertion' // TODO: Consider a generic case for "has"
1559
+ })
1560
+ return true
1561
+ } catch {
1562
+ return false
1563
+ }
1564
+ }
1565
+
1566
+ /**
1567
+ * Lists spending authorization tokens (DSAP) for a given originator (or all).
1568
+ */
1569
+ public async listSpendingAuthorizations(params: { originator?: string }): Promise<PermissionToken[]> {
1570
+ const basketName = BASKET_MAP.spending
1571
+ const tags: string[] = []
1572
+ if (params.originator) {
1573
+ tags.push(`originator ${params.originator}`)
1574
+ }
1575
+ const result = await this.underlying.listOutputs(
1576
+ {
1577
+ basket: basketName,
1578
+ tags,
1579
+ tagQueryMode: 'all',
1580
+ include: 'locking scripts',
1581
+ limit: 10000
1582
+ },
1583
+ this.adminOriginator
1584
+ )
1585
+
1586
+ const tokens: PermissionToken[] = []
1587
+ for (const out of result.outputs) {
1588
+ const dec = PushDrop.decode(LockingScript.fromHex(out.lockingScript!))
1589
+ if (!dec?.fields || dec.fields.length < 2) continue
1590
+ const [domainRaw, amtRaw] = dec.fields
1591
+ const domainDecoded = Utils.toUTF8(await this.decryptPermissionTokenField(domainRaw))
1592
+ const amtDecodedStr = Utils.toUTF8(await this.decryptPermissionTokenField(amtRaw))
1593
+ const authorizedAmount = parseInt(amtDecodedStr, 10)
1594
+ tokens.push({
1595
+ txid: out.outpoint.split('.')[0],
1596
+ outputIndex: parseInt(out.outpoint.split('.')[1], 10),
1597
+ satoshis: out.satoshis,
1598
+ outputScript: out.lockingScript!,
1599
+ originator: domainDecoded,
1600
+ authorizedAmount,
1601
+ expiry: 0
1602
+ })
1603
+ }
1604
+ return tokens
1605
+ }
1606
+
1607
+ /**
1608
+ * Returns `true` if the originator already holds a valid spending authorization token
1609
+ * with enough available monthly spend. We do not prompt (seekPermission=false).
1610
+ */
1611
+ public async hasSpendingAuthorization(params: { originator: string; satoshis: number }): Promise<boolean> {
1612
+ try {
1613
+ await this.ensureSpendingAuthorization({
1614
+ originator: params.originator,
1615
+ satoshis: params.satoshis,
1616
+ seekPermission: false
1617
+ })
1618
+ return true
1619
+ } catch {
1620
+ return false
1621
+ }
1622
+ }
1623
+
1624
+ /**
1625
+ * Lists certificate permission tokens (DCAP) for a given originator (or all).
1626
+ */
1627
+ public async listCertificateAccess(params: { originator?: string }): Promise<PermissionToken[]> {
1628
+ const basketName = BASKET_MAP.certificate
1629
+ const tags: string[] = []
1630
+ if (params.originator) {
1631
+ tags.push(`originator ${params.originator}`)
1632
+ }
1633
+ const result = await this.underlying.listOutputs(
1634
+ {
1635
+ basket: basketName,
1636
+ tags,
1637
+ tagQueryMode: 'all',
1638
+ include: 'locking scripts',
1639
+ limit: 10000
1640
+ },
1641
+ this.adminOriginator
1642
+ )
1643
+
1644
+ const tokens: PermissionToken[] = []
1645
+ for (const out of result.outputs) {
1646
+ const dec = PushDrop.decode(LockingScript.fromHex(out.lockingScript!))
1647
+ if (!dec?.fields || dec.fields.length < 6) continue
1648
+ const [domainRaw, expiryRaw, privRaw, typeRaw, fieldsRaw, verifierRaw] = dec.fields
1649
+ const domainDecoded = Utils.toUTF8(await this.decryptPermissionTokenField(domainRaw))
1650
+ const expiryDecoded = parseInt(Utils.toUTF8(await this.decryptPermissionTokenField(expiryRaw)), 10)
1651
+ const privDecoded = Utils.toUTF8(await this.decryptPermissionTokenField(privRaw)) === 'true'
1652
+ const typeDecoded = Utils.toUTF8(await this.decryptPermissionTokenField(typeRaw))
1653
+ const verifierDec = Utils.toUTF8(await this.decryptPermissionTokenField(verifierRaw))
1654
+ const fieldsJson = await this.decryptPermissionTokenField(fieldsRaw)
1655
+ const allFields = JSON.parse(Utils.toUTF8(fieldsJson)) as string[]
1656
+ tokens.push({
1657
+ txid: out.outpoint.split('.')[0],
1658
+ outputIndex: parseInt(out.outpoint.split('.')[1], 10),
1659
+ satoshis: out.satoshis,
1660
+ outputScript: out.lockingScript!,
1661
+ originator: domainDecoded,
1662
+ privileged: privDecoded,
1663
+ certType: typeDecoded,
1664
+ certFields: allFields,
1665
+ verifier: verifierDec,
1666
+ expiry: expiryDecoded
1667
+ })
1668
+ }
1669
+ return tokens
1670
+ }
1671
+
1672
+ /**
1673
+ * Returns `true` if the originator already holds a valid unexpired certificate access
1674
+ * for the given certType/fields. Does not prompt the user.
1675
+ */
1676
+ public async hasCertificateAccess(params: {
1677
+ originator: string
1678
+ privileged: boolean
1679
+ verifier: string
1680
+ certType: string
1681
+ fields: string[]
1682
+ }): Promise<boolean> {
1683
+ try {
1684
+ await this.ensureCertificateAccess({
1685
+ originator: params.originator,
1686
+ privileged: params.privileged,
1687
+ verifier: params.verifier,
1688
+ certType: params.certType,
1689
+ fields: params.fields,
1690
+ seekPermission: false,
1691
+ usageType: 'disclosure'
1692
+ })
1693
+ return true
1694
+ } catch {
1695
+ return false
1696
+ }
1697
+ }
1698
+
1699
+ /**
1700
+ * Revokes a permission token by spending it with no replacement output.
1701
+ * The manager builds a BRC-100 transaction that consumes the token, effectively invalidating it.
1702
+ */
1703
+ public async revokePermission(oldToken: PermissionToken): Promise<void> {
1704
+ const oldOutpoint = `${oldToken.txid}.${oldToken.outputIndex}`
1705
+ const { signableTransaction } = await this.createAction(
1706
+ {
1707
+ description: `Revoke permission`,
1708
+ inputs: [
1709
+ {
1710
+ outpoint: oldOutpoint,
1711
+ unlockingScriptLength: 73, // length of signature
1712
+ inputDescription: `Consume old permission token`
1713
+ }
1714
+ ]
1715
+ },
1716
+ this.adminOriginator
1717
+ )
1718
+ const tx = Transaction.fromBEEF(signableTransaction!.tx)
1719
+ const unlocker = new PushDrop(this.underlying).unlock(
1720
+ WalletPermissionsManager.PERM_TOKEN_ENCRYPTION_PROTOCOL,
1721
+ '1',
1722
+ 'self',
1723
+ 'all',
1724
+ false,
1725
+ 1,
1726
+ LockingScript.fromHex(oldToken.outputScript)
1727
+ )
1728
+ const unlockingScript = await unlocker.sign(tx, 0)
1729
+ await this.underlying.signAction({
1730
+ reference: signableTransaction!.reference,
1731
+ spends: {
1732
+ 0: {
1733
+ unlockingScript: unlockingScript.toHex()
1734
+ }
1735
+ }
1736
+ })
1737
+ }
1738
+
1739
+ /* ---------------------------------------------------------------------
1740
+ * 7) BRC-100 WALLET INTERFACE FORWARDING WITH PERMISSION CHECKS
1741
+ * --------------------------------------------------------------------- */
1742
+
1743
+ public async createAction(
1744
+ args: Parameters<WalletInterface['createAction']>[0],
1745
+ originator?: string
1746
+ ): ReturnType<WalletInterface['createAction']> {
1747
+ // 1) Ensure basket and label permissions
1748
+ if (args.outputs) {
1749
+ for (const out of args.outputs) {
1750
+ if (out.basket) {
1751
+ await this.ensureBasketAccess({
1752
+ originator: originator!,
1753
+ basket: out.basket,
1754
+ reason: args.description,
1755
+ usageType: 'insertion'
1756
+ })
1757
+ }
1758
+ }
1759
+ }
1760
+ if (args.labels) {
1761
+ for (const lbl of args.labels) {
1762
+ await this.ensureLabelAccess({
1763
+ originator: originator!,
1764
+ label: lbl,
1765
+ reason: args.description,
1766
+ usageType: 'apply'
1767
+ })
1768
+ }
1769
+ }
1770
+
1771
+ /**
1772
+ * 2) Force signAndProcess=false unless the originator is admin and explicitly sets it to true.
1773
+ * This ensures the underlying wallet returns a signableTransaction, letting us parse the transaction
1774
+ * to determine net spending and request authorization if needed.
1775
+ */
1776
+ const modifiedOptions = { ...(args.options || {}) }
1777
+ if (modifiedOptions.signAndProcess !== true) {
1778
+ modifiedOptions.signAndProcess = false
1779
+ } else if (!this.isAdminOriginator(originator!)) {
1780
+ throw new Error('Only the admin originator can set signAndProcess=true explicitly.')
1781
+ }
1782
+
1783
+ // 3) Encrypt transaction metadata, saving originals for use in permissions and line items.
1784
+ const originalDescription = args.description
1785
+ const originalInputDescriptions = {}
1786
+ const originalOutputDescriptions = {}
1787
+ args.description = await this.maybeEncryptMetadata(args.description)
1788
+ for (let i = 0; i < (args.inputs || []).length; i++) {
1789
+ if (args.inputs![i].inputDescription) {
1790
+ originalInputDescriptions[i] = args.inputs![i].inputDescription
1791
+ args.inputs![i].inputDescription = await this.maybeEncryptMetadata(args.inputs![i].inputDescription)
1792
+ }
1793
+ }
1794
+ for (let i = 0; i < (args.outputs || []).length; i++) {
1795
+ if (args.outputs![i].outputDescription) {
1796
+ originalOutputDescriptions[i] = args.outputs![i].outputDescription
1797
+ args.outputs![i].outputDescription = await this.maybeEncryptMetadata(args.outputs![i].outputDescription)
1798
+ }
1799
+ if (args.outputs![i].customInstructions) {
1800
+ args.outputs![i].customInstructions = await this.maybeEncryptMetadata(args.outputs![i].customInstructions!)
1801
+ }
1802
+ }
1803
+
1804
+ /**
1805
+ * 4) Call the underlying wallet’s createAction. We add two “admin” labels:
1806
+ * - "admin originator <domain>"
1807
+ * - "admin month YYYY-MM"
1808
+ * These labels help track the originator’s monthly spending.
1809
+ */
1810
+ const createResult = await this.underlying.createAction(
1811
+ {
1812
+ ...args,
1813
+ options: modifiedOptions,
1814
+ labels: [
1815
+ ...(args.labels || []),
1816
+ `admin originator ${originator}`,
1817
+ `admin month ${this.getCurrentMonthYearUTC()}`
1818
+ ]
1819
+ },
1820
+ originator
1821
+ )
1822
+
1823
+ // If there's no signableTransaction, the underlying wallet must have fully finalized it. Return as is.
1824
+ if (!createResult.signableTransaction) {
1825
+ return createResult
1826
+ }
1827
+
1828
+ /**
1829
+ * 5) We have a signable transaction. Parse it to determine how much the originator is actually spending.
1830
+ * We only consider inputs the originator explicitly listed in args.inputs.
1831
+ * netSpent = (sum of originator-requested outputs) - (sum of matching originator inputs).
1832
+ * If netSpent > 0, we need spending authorization.
1833
+ */
1834
+ const tx = Transaction.fromAtomicBEEF(createResult.signableTransaction.tx)
1835
+ const reference = createResult.signableTransaction.reference
1836
+
1837
+ let netSpent = 0
1838
+ const lineItems: Array<{
1839
+ type: 'input' | 'output' | 'fee'
1840
+ description: string
1841
+ satoshis: number
1842
+ }> = []
1843
+
1844
+ // Sum originator-provided inputs:
1845
+ let totalInputSatoshis = 0
1846
+ for (const input of tx.inputs) {
1847
+ const outpoint = `${input.sourceTXID}.${input.sourceOutputIndex}`
1848
+ const matchingIndex = (args.inputs || []).findIndex(i => i.outpoint === outpoint)
1849
+ if (matchingIndex !== -1) {
1850
+ const satoshis = input.sourceTransaction!.outputs[input.sourceOutputIndex].satoshis
1851
+ totalInputSatoshis += satoshis!
1852
+ lineItems.push({
1853
+ type: 'input',
1854
+ description: originalInputDescriptions[matchingIndex] || 'No input description provided',
1855
+ satoshis: satoshis!
1856
+ })
1857
+ }
1858
+ }
1859
+
1860
+ // Sum originator-requested outputs:
1861
+ const totalOutputSatoshis = (args.outputs || []).reduce((acc, out) => acc + out.satoshis, 0)
1862
+ for (const outIndex in args.outputs || []) {
1863
+ const out = args.outputs![outIndex]
1864
+ lineItems.push({
1865
+ type: 'output',
1866
+ satoshis: out.satoshis,
1867
+ description: originalOutputDescriptions[outIndex] || 'No output description provided'
1868
+ })
1869
+ }
1870
+
1871
+ // Add an entry for the transaction fee:
1872
+ lineItems.push({
1873
+ type: 'fee',
1874
+ satoshis: tx.getFee(),
1875
+ description: 'Network fee'
1876
+ })
1877
+
1878
+ /**
1879
+ * When it comes to spending authorizations, and the computation of net spend, there are
1880
+ * two types of inputs and two types of outputs:
1881
+ *
1882
+ * There are foreign (originator-requested) ones, and domestic (internally-provided) ones.
1883
+ * The net spend is always calculated from the domestic, internal perspective. Therefore, the
1884
+ * cost of funding the foreign outputs is the base cost to the domestic user, unless this is
1885
+ * somehow offset.
1886
+ *
1887
+ * The only way to offset this cost is when the foreign inputs help carry some of the burden.
1888
+ * This is why we can subtract the sum of the foreign inputs from the sum of foreign outputs,
1889
+ * to gague how much of that cost needs to be born domestically by the user.
1890
+ *
1891
+ * The logic does not need to account for whatever domestic inputs are provided, or whatever
1892
+ * domestic outputs are re-captured by the wallet back as change. The wallet could conceivably
1893
+ * provide 21e8 satoshis as input and re-capture the same amount as change, but the net effect
1894
+ * on actual spending would be zero. Therefore, we base net spend on total foreign outflows
1895
+ * minus total foreign inflows. Fees are also considered.
1896
+ */
1897
+ netSpent = totalOutputSatoshis + tx.getFee() - totalInputSatoshis
1898
+
1899
+ // 6) If netSpent > 0, require spending authorization. Abort if denied.
1900
+ if (netSpent > 0) {
1901
+ try {
1902
+ await this.ensureSpendingAuthorization({
1903
+ originator: originator!,
1904
+ satoshis: netSpent,
1905
+ lineItems,
1906
+ reason: originalDescription
1907
+ })
1908
+ } catch (err) {
1909
+ await this.underlying.abortAction({ reference })
1910
+ throw err
1911
+ }
1912
+ }
1913
+
1914
+ /**
1915
+ * 7) Decide whether to finalize the transaction automatically or return the signableTransaction:
1916
+ * - If the user originally wanted signAndProcess (the default when undefined), we forcibly set it to false earlier, so check if we should now finalize it.
1917
+ * - If the transaction still needs more signatures, we must return the signableTransaction.
1918
+ */
1919
+ const vargs = validateCreateActionArgs(args)
1920
+ if (vargs.isSignAction) {
1921
+ return createResult
1922
+ }
1923
+
1924
+ const signResult = await this.underlying.signAction({ reference, spends: {}, options: args.options }, originator)
1925
+ // Merge signResult into createResult and remove signableTransaction:
1926
+ return {
1927
+ ...createResult,
1928
+ ...signResult,
1929
+ signableTransaction: undefined
1930
+ }
1931
+
1932
+ // const userWantedImmediate = args.options?.signAndProcess !== false
1933
+ // const weOverrode =
1934
+ // modifiedOptions.signAndProcess === false &&
1935
+ // args.options?.signAndProcess !== false
1936
+
1937
+ // // Check if all inputs already have valid unlockingScripts (no partial signatures needed)
1938
+ const allInputsHaveScripts = tx.inputs.every(i => i.unlockingScript)
1939
+
1940
+ // // If user wanted immediate broadcast and we forcibly suppressed it, we can finalize now by calling signAction with no additional scripts:
1941
+ // if (allInputsHaveScripts && userWantedImmediate && weOverrode) {
1942
+
1943
+ // }
1944
+
1945
+ // // Otherwise, return the partial transaction so the caller can do signAction.
1946
+ // return createResult
1947
+ }
1948
+
1949
+ public async signAction(
1950
+ ...args: Parameters<WalletInterface['signAction']>
1951
+ ): ReturnType<WalletInterface['signAction']> {
1952
+ return this.underlying.signAction(...args)
1953
+ }
1954
+
1955
+ public async abortAction(
1956
+ ...args: Parameters<WalletInterface['abortAction']>
1957
+ ): ReturnType<WalletInterface['abortAction']> {
1958
+ return this.underlying.abortAction(...args)
1959
+ }
1960
+
1961
+ public async listActions(
1962
+ ...args: Parameters<WalletInterface['listActions']>
1963
+ ): ReturnType<WalletInterface['listActions']> {
1964
+ const [requestArgs, originator] = args
1965
+ // for each label, ensure label access
1966
+ if (requestArgs.labels) {
1967
+ for (const lbl of requestArgs.labels) {
1968
+ await this.ensureLabelAccess({
1969
+ originator: originator!,
1970
+ label: lbl,
1971
+ reason: 'listActions',
1972
+ usageType: 'list'
1973
+ })
1974
+ }
1975
+ }
1976
+ const results = await this.underlying.listActions(...args)
1977
+ // Transparently decrypt transaction metadata, if configured to do so.
1978
+ if (results.actions) {
1979
+ for (let i = 0; i < results.actions.length; i++) {
1980
+ if (results.actions[i].description) {
1981
+ results.actions[i].description = await this.maybeDecryptMetadata(results.actions[i].description)
1982
+ }
1983
+ if (results.actions[i].inputs) {
1984
+ for (let j = 0; j < results.actions[i].inputs!.length; j++) {
1985
+ if (results.actions[i].inputs![j].inputDescription) {
1986
+ results.actions[i].inputs![j].inputDescription = await this.maybeDecryptMetadata(
1987
+ results.actions[i].inputs![j].inputDescription
1988
+ )
1989
+ }
1990
+ }
1991
+ }
1992
+ if (results.actions[i].outputs) {
1993
+ for (let j = 0; j < results.actions[i].outputs!.length; j++) {
1994
+ if (results.actions[i].outputs![j].outputDescription) {
1995
+ results.actions[i].outputs![j].outputDescription = await this.maybeDecryptMetadata(
1996
+ results.actions[i].outputs![j].outputDescription
1997
+ )
1998
+ }
1999
+ if (results.actions[i].outputs![j].customInstructions) {
2000
+ results.actions[i].outputs![j].customInstructions = await this.maybeDecryptMetadata(
2001
+ results.actions[i].outputs![j].customInstructions!
2002
+ )
2003
+ }
2004
+ }
2005
+ }
2006
+ }
2007
+ }
2008
+ return results
2009
+ }
2010
+
2011
+ public async internalizeAction(
2012
+ ...args: Parameters<WalletInterface['internalizeAction']>
2013
+ ): ReturnType<WalletInterface['internalizeAction']> {
2014
+ const [requestArgs, originator] = args
2015
+ // If the transaction is inserting outputs into baskets, we also ensure basket permission
2016
+ for (const outIndex in requestArgs.outputs) {
2017
+ const out = requestArgs.outputs[outIndex]
2018
+ if (out.protocol === 'basket insertion') {
2019
+ await this.ensureBasketAccess({
2020
+ originator: originator!,
2021
+ basket: out.insertionRemittance!.basket,
2022
+ reason: requestArgs.description,
2023
+ usageType: 'insertion'
2024
+ })
2025
+ if (out.insertionRemittance!.customInstructions) {
2026
+ requestArgs.outputs[outIndex].insertionRemittance!.customInstructions = await this.maybeEncryptMetadata(
2027
+ out.insertionRemittance!.customInstructions
2028
+ )
2029
+ }
2030
+ }
2031
+ }
2032
+ return this.underlying.internalizeAction(...args)
2033
+ }
2034
+
2035
+ public async listOutputs(
2036
+ ...args: Parameters<WalletInterface['listOutputs']>
2037
+ ): ReturnType<WalletInterface['listOutputs']> {
2038
+ const [requestArgs, originator] = args
2039
+ // Ensure the originator has permission for the basket.
2040
+ await this.ensureBasketAccess({
2041
+ originator: originator!,
2042
+ basket: requestArgs.basket,
2043
+ reason: 'listOutputs',
2044
+ usageType: 'listing'
2045
+ })
2046
+ const results = await this.underlying.listOutputs(...args)
2047
+ // Transparently decrypt transaction metadata, if configured to do so.
2048
+ if (results.outputs) {
2049
+ for (let i = 0; i < results.outputs.length; i++) {
2050
+ if (results.outputs[i].customInstructions) {
2051
+ results.outputs[i].customInstructions = await this.maybeDecryptMetadata(
2052
+ results.outputs[i].customInstructions!
2053
+ )
2054
+ }
2055
+ }
2056
+ }
2057
+ return results
2058
+ }
2059
+
2060
+ public async relinquishOutput(
2061
+ ...args: Parameters<WalletInterface['relinquishOutput']>
2062
+ ): ReturnType<WalletInterface['relinquishOutput']> {
2063
+ const [requestArgs, originator] = args
2064
+ await this.ensureBasketAccess({
2065
+ originator: originator!,
2066
+ basket: requestArgs.basket,
2067
+ reason: 'relinquishOutput',
2068
+ usageType: 'removal'
2069
+ })
2070
+ return this.underlying.relinquishOutput(...args)
2071
+ }
2072
+
2073
+ public async getPublicKey(
2074
+ ...args: Parameters<WalletInterface['getPublicKey']>
2075
+ ): ReturnType<WalletInterface['getPublicKey']> {
2076
+ const [requestArgs, originator] = args
2077
+ if (requestArgs.protocolID) {
2078
+ await this.ensureProtocolPermission({
2079
+ originator: originator!,
2080
+ privileged: requestArgs.privileged!,
2081
+ protocolID: requestArgs.protocolID,
2082
+ counterparty: requestArgs.counterparty || 'self',
2083
+ reason: requestArgs.privilegedReason,
2084
+ usageType: 'publicKey'
2085
+ })
2086
+ }
2087
+ if (requestArgs.identityKey) {
2088
+ // We also require a minimal protocol permission to retrieve the user's identity key
2089
+ await this.ensureProtocolPermission({
2090
+ originator: originator!,
2091
+ privileged: requestArgs.privileged!,
2092
+ protocolID: [1, 'identity key retrieval'],
2093
+ counterparty: 'self',
2094
+ reason: requestArgs.privilegedReason,
2095
+ usageType: 'identityKey'
2096
+ })
2097
+ }
2098
+ return this.underlying.getPublicKey(...args)
2099
+ }
2100
+
2101
+ public async revealCounterpartyKeyLinkage(
2102
+ ...args: Parameters<WalletInterface['revealCounterpartyKeyLinkage']>
2103
+ ): ReturnType<WalletInterface['revealCounterpartyKeyLinkage']> {
2104
+ const [requestArgs, originator] = args
2105
+ await this.ensureProtocolPermission({
2106
+ originator: originator!,
2107
+ privileged: requestArgs.privileged!,
2108
+ protocolID: [2, `counterparty key linkage revelation ${requestArgs.counterparty}`],
2109
+ counterparty: requestArgs.verifier,
2110
+ reason: requestArgs.privilegedReason,
2111
+ usageType: 'linkageRevelation'
2112
+ })
2113
+ return this.underlying.revealCounterpartyKeyLinkage(...args)
2114
+ }
2115
+
2116
+ public async revealSpecificKeyLinkage(
2117
+ ...args: Parameters<WalletInterface['revealSpecificKeyLinkage']>
2118
+ ): ReturnType<WalletInterface['revealSpecificKeyLinkage']> {
2119
+ const [requestArgs, originator] = args
2120
+ await this.ensureProtocolPermission({
2121
+ originator: originator!,
2122
+ privileged: requestArgs.privileged!,
2123
+ protocolID: [
2124
+ 2,
2125
+ `specific key linkage revelation ${requestArgs.protocolID[1]} ${requestArgs.protocolID[0] === 2 ? requestArgs.keyID : 'all'}`
2126
+ ],
2127
+ counterparty: requestArgs.verifier,
2128
+ reason: requestArgs.privilegedReason,
2129
+ usageType: 'linkageRevelation'
2130
+ })
2131
+ return this.underlying.revealSpecificKeyLinkage(...args)
2132
+ }
2133
+
2134
+ public async encrypt(...args: Parameters<WalletInterface['encrypt']>): ReturnType<WalletInterface['encrypt']> {
2135
+ const [requestArgs, originator] = args
2136
+ await this.ensureProtocolPermission({
2137
+ originator: originator!,
2138
+ protocolID: requestArgs.protocolID,
2139
+ privileged: requestArgs.privileged!,
2140
+ counterparty: requestArgs.counterparty || 'self',
2141
+ reason: requestArgs.privilegedReason,
2142
+ usageType: 'encrypting'
2143
+ })
2144
+ return this.underlying.encrypt(...args)
2145
+ }
2146
+
2147
+ public async decrypt(...args: Parameters<WalletInterface['decrypt']>): ReturnType<WalletInterface['decrypt']> {
2148
+ const [requestArgs, originator] = args
2149
+ await this.ensureProtocolPermission({
2150
+ originator: originator!,
2151
+ privileged: requestArgs.privileged!,
2152
+ protocolID: requestArgs.protocolID,
2153
+ counterparty: requestArgs.counterparty || 'self',
2154
+ reason: requestArgs.privilegedReason,
2155
+ usageType: 'encrypting'
2156
+ })
2157
+ return this.underlying.decrypt(...args)
2158
+ }
2159
+
2160
+ public async createHmac(
2161
+ ...args: Parameters<WalletInterface['createHmac']>
2162
+ ): ReturnType<WalletInterface['createHmac']> {
2163
+ const [requestArgs, originator] = args
2164
+ await this.ensureProtocolPermission({
2165
+ originator: originator!,
2166
+ privileged: requestArgs.privileged!,
2167
+ protocolID: requestArgs.protocolID,
2168
+ counterparty: requestArgs.counterparty || 'self',
2169
+ reason: requestArgs.privilegedReason,
2170
+ usageType: 'hmac'
2171
+ })
2172
+ return this.underlying.createHmac(...args)
2173
+ }
2174
+
2175
+ public async verifyHmac(
2176
+ ...args: Parameters<WalletInterface['verifyHmac']>
2177
+ ): ReturnType<WalletInterface['verifyHmac']> {
2178
+ const [requestArgs, originator] = args
2179
+ await this.ensureProtocolPermission({
2180
+ originator: originator!,
2181
+ privileged: requestArgs.privileged!,
2182
+ protocolID: requestArgs.protocolID,
2183
+ counterparty: requestArgs.counterparty || 'self',
2184
+ reason: requestArgs.privilegedReason,
2185
+ usageType: 'hmac'
2186
+ })
2187
+ return this.underlying.verifyHmac(...args)
2188
+ }
2189
+
2190
+ public async createSignature(
2191
+ ...args: Parameters<WalletInterface['createSignature']>
2192
+ ): ReturnType<WalletInterface['createSignature']> {
2193
+ const [requestArgs, originator] = args
2194
+ await this.ensureProtocolPermission({
2195
+ originator: originator!,
2196
+ privileged: requestArgs.privileged!,
2197
+ protocolID: requestArgs.protocolID,
2198
+ counterparty: requestArgs.counterparty || 'self',
2199
+ reason: requestArgs.privilegedReason,
2200
+ usageType: 'signing'
2201
+ })
2202
+ return this.underlying.createSignature(...args)
2203
+ }
2204
+
2205
+ public async verifySignature(
2206
+ ...args: Parameters<WalletInterface['verifySignature']>
2207
+ ): ReturnType<WalletInterface['verifySignature']> {
2208
+ const [requestArgs, originator] = args
2209
+ await this.ensureProtocolPermission({
2210
+ originator: originator!,
2211
+ privileged: requestArgs.privileged!,
2212
+ protocolID: requestArgs.protocolID,
2213
+ counterparty: requestArgs.counterparty || 'self',
2214
+ reason: requestArgs.privilegedReason,
2215
+ usageType: 'signing'
2216
+ })
2217
+ return this.underlying.verifySignature(...args)
2218
+ }
2219
+
2220
+ public async acquireCertificate(
2221
+ ...args: Parameters<WalletInterface['acquireCertificate']>
2222
+ ): ReturnType<WalletInterface['acquireCertificate']> {
2223
+ const [requestArgs, originator] = args
2224
+ if (this.config.seekCertificateAcquisitionPermissions) {
2225
+ await this.ensureProtocolPermission({
2226
+ originator: originator!,
2227
+ privileged: requestArgs.privileged!,
2228
+ protocolID: [1, `certificate acquisition ${requestArgs.type}`],
2229
+ counterparty: 'self',
2230
+ reason: requestArgs.privilegedReason,
2231
+ usageType: 'generic'
2232
+ })
2233
+ }
2234
+ return this.underlying.acquireCertificate(...args)
2235
+ }
2236
+
2237
+ public async listCertificates(
2238
+ ...args: Parameters<WalletInterface['listCertificates']>
2239
+ ): ReturnType<WalletInterface['listCertificates']> {
2240
+ const [requestArgs, originator] = args
2241
+ if (this.config.seekCertificateListingPermissions) {
2242
+ await this.ensureProtocolPermission({
2243
+ originator: originator!,
2244
+ privileged: requestArgs.privileged!,
2245
+ protocolID: [1, `certificate list`],
2246
+ counterparty: 'self',
2247
+ reason: requestArgs.privilegedReason,
2248
+ usageType: 'generic'
2249
+ })
2250
+ }
2251
+ return this.underlying.listCertificates(...args)
2252
+ }
2253
+
2254
+ public async proveCertificate(
2255
+ ...args: Parameters<WalletInterface['proveCertificate']>
2256
+ ): ReturnType<WalletInterface['proveCertificate']> {
2257
+ const [requestArgs, originator] = args
2258
+ await this.ensureCertificateAccess({
2259
+ originator: originator!,
2260
+ privileged: requestArgs.privileged!,
2261
+ verifier: requestArgs.verifier,
2262
+ certType: requestArgs.certificate.type!,
2263
+ fields: requestArgs.fieldsToReveal,
2264
+ reason: 'proveCertificate',
2265
+ usageType: 'disclosure'
2266
+ })
2267
+ return this.underlying.proveCertificate(...args)
2268
+ }
2269
+
2270
+ public async relinquishCertificate(
2271
+ ...args: Parameters<WalletInterface['relinquishCertificate']>
2272
+ ): ReturnType<WalletInterface['relinquishCertificate']> {
2273
+ const [requestArgs, originator] = args
2274
+ if (this.config.seekCertificateRelinquishmentPermissions) {
2275
+ await this.ensureProtocolPermission({
2276
+ originator: originator!,
2277
+ privileged: (requestArgs as any).privileged ? true : false,
2278
+ protocolID: [1, `certificate relinquishment ${requestArgs.type}`],
2279
+ counterparty: 'self',
2280
+ reason: (requestArgs as any).privilegedReason || 'relinquishCertificate',
2281
+ usageType: 'generic'
2282
+ })
2283
+ }
2284
+ return this.underlying.relinquishCertificate(...args)
2285
+ }
2286
+
2287
+ public async discoverByIdentityKey(
2288
+ ...args: Parameters<WalletInterface['discoverByIdentityKey']>
2289
+ ): ReturnType<WalletInterface['discoverByIdentityKey']> {
2290
+ const [_, originator] = args
2291
+ if (this.config.seekPermissionsForIdentityResolution) {
2292
+ await this.ensureProtocolPermission({
2293
+ originator: originator!,
2294
+ privileged: false,
2295
+ protocolID: [1, `identity resolution`],
2296
+ counterparty: 'self',
2297
+ reason: 'discoverByIdentityKey',
2298
+ usageType: 'generic'
2299
+ })
2300
+ }
2301
+ return this.underlying.discoverByIdentityKey(...args)
2302
+ }
2303
+
2304
+ public async discoverByAttributes(
2305
+ ...args: Parameters<WalletInterface['discoverByAttributes']>
2306
+ ): ReturnType<WalletInterface['discoverByAttributes']> {
2307
+ const [_, originator] = args
2308
+ if (this.config.seekPermissionsForIdentityResolution) {
2309
+ await this.ensureProtocolPermission({
2310
+ originator: originator!,
2311
+ privileged: false,
2312
+ protocolID: [1, `identity resolution`],
2313
+ counterparty: 'self',
2314
+ reason: 'discoverByAttributes',
2315
+ usageType: 'generic'
2316
+ })
2317
+ }
2318
+ return this.underlying.discoverByAttributes(...args)
2319
+ }
2320
+
2321
+ public async isAuthenticated(
2322
+ ...args: Parameters<WalletInterface['isAuthenticated']>
2323
+ ): ReturnType<WalletInterface['isAuthenticated']> {
2324
+ return this.underlying.isAuthenticated(...args)
2325
+ }
2326
+
2327
+ public async waitForAuthentication(
2328
+ ...args: Parameters<WalletInterface['waitForAuthentication']>
2329
+ ): ReturnType<WalletInterface['waitForAuthentication']> {
2330
+ return this.underlying.waitForAuthentication(...args)
2331
+ }
2332
+
2333
+ public async getHeight(...args: Parameters<WalletInterface['getHeight']>): ReturnType<WalletInterface['getHeight']> {
2334
+ return this.underlying.getHeight(...args)
2335
+ }
2336
+
2337
+ public async getHeaderForHeight(
2338
+ ...args: Parameters<WalletInterface['getHeaderForHeight']>
2339
+ ): ReturnType<WalletInterface['getHeaderForHeight']> {
2340
+ return this.underlying.getHeaderForHeight(...args)
2341
+ }
2342
+
2343
+ public async getNetwork(
2344
+ ...args: Parameters<WalletInterface['getNetwork']>
2345
+ ): ReturnType<WalletInterface['getNetwork']> {
2346
+ return this.underlying.getNetwork(...args)
2347
+ }
2348
+
2349
+ public async getVersion(
2350
+ ...args: Parameters<WalletInterface['getVersion']>
2351
+ ): ReturnType<WalletInterface['getVersion']> {
2352
+ return this.underlying.getVersion(...args)
2353
+ }
2354
+
2355
+ /* ---------------------------------------------------------------------
2356
+ * 8) INTERNAL HELPER UTILITIES
2357
+ * --------------------------------------------------------------------- */
2358
+
2359
+ /** Returns true if the specified origin is the admin originator. */
2360
+ private isAdminOriginator(originator: string): boolean {
2361
+ return originator === this.adminOriginator
2362
+ }
2363
+
2364
+ /**
2365
+ * Checks if the given protocol is admin-reserved per BRC-100 rules:
2366
+ *
2367
+ * - Must not start with `admin` (admin-reserved)
2368
+ * - Must not start with `p ` (allows for future specially permissioned protocols)
2369
+ *
2370
+ * If it violates these rules and the caller is not admin, we consider it "admin-only."
2371
+ */
2372
+ private isAdminProtocol(proto: [0 | 1 | 2, string]): boolean {
2373
+ const protocolName = proto[1]
2374
+ if (protocolName.startsWith('admin') || protocolName.startsWith('p ')) {
2375
+ return true
2376
+ }
2377
+ return false
2378
+ }
2379
+
2380
+ /**
2381
+ * Checks if the given label is admin-reserved per BRC-100 rules:
2382
+ *
2383
+ * - Must not start with `admin` (admin-reserved)
2384
+ *
2385
+ * If it violates these rules and the caller is not admin, we consider it "admin-only."
2386
+ */
2387
+ private isAdminLabel(label: string): boolean {
2388
+ if (label.startsWith('admin')) {
2389
+ return true
2390
+ }
2391
+ return false
2392
+ }
2393
+
2394
+ /**
2395
+ * Checks if the given basket is admin-reserved per BRC-100 rules:
2396
+ *
2397
+ * - Must not start with `admin`
2398
+ * - Must not be `default` (some wallets use this for internal operations)
2399
+ * - Must not start with `p ` (future specially permissioned baskets)
2400
+ */
2401
+ private isAdminBasket(basket: string): boolean {
2402
+ if (basket === 'default') return true
2403
+ if (basket.startsWith('admin')) return true
2404
+ if (basket.startsWith('p ')) return true
2405
+ return false
2406
+ }
2407
+
2408
+ /**
2409
+ * Builds a "map key" string so that identical requests (e.g. "protocol:domain:true:protoName:counterparty")
2410
+ * do not produce multiple user prompts.
2411
+ */
2412
+ private buildRequestKey(r: PermissionRequest): string {
2413
+ switch (r.type) {
2414
+ case 'protocol':
2415
+ return `proto:${r.originator}:${r.privileged}:${r.protocolID?.join(',')}:${r.counterparty}`
2416
+ case 'basket':
2417
+ return `basket:${r.originator}:${r.basket}`
2418
+ case 'certificate':
2419
+ return `cert:${r.originator}:${r.privileged}:${r.certificate?.verifier}:${r.certificate?.certType}:${r.certificate?.fields.join('|')}`
2420
+ case 'spending':
2421
+ return `spend:${r.originator}`
2422
+ }
2423
+ }
2424
+ }