@bopen-io/wallet-toolbox 1.7.18

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 (390) hide show
  1. package/.claude/settings.local.json +10 -0
  2. package/.env.template +22 -0
  3. package/.github/ISSUE_TEMPLATE/bug_report.md +40 -0
  4. package/.github/ISSUE_TEMPLATE/discussion.md +24 -0
  5. package/.github/pull_request_template.md +22 -0
  6. package/.github/workflows/push.yaml +145 -0
  7. package/.prettierrc +10 -0
  8. package/CHANGELOG.md +280 -0
  9. package/CONTRIBUTING.md +89 -0
  10. package/README.md +43 -0
  11. package/docs/README.md +85 -0
  12. package/docs/client.md +19627 -0
  13. package/docs/monitor.md +953 -0
  14. package/docs/open-rpc/index.html +46 -0
  15. package/docs/services.md +6377 -0
  16. package/docs/setup.md +1268 -0
  17. package/docs/storage.md +5367 -0
  18. package/docs/wallet.md +19626 -0
  19. package/jest.config.ts +25 -0
  20. package/license.md +28 -0
  21. package/out/tsconfig.all.tsbuildinfo +1 -0
  22. package/package.json +63 -0
  23. package/src/CWIStyleWalletManager.ts +1999 -0
  24. package/src/Setup.ts +579 -0
  25. package/src/SetupClient.ts +322 -0
  26. package/src/SetupWallet.ts +108 -0
  27. package/src/SimpleWalletManager.ts +526 -0
  28. package/src/Wallet.ts +1169 -0
  29. package/src/WalletAuthenticationManager.ts +153 -0
  30. package/src/WalletLogger.ts +213 -0
  31. package/src/WalletPermissionsManager.ts +3660 -0
  32. package/src/WalletSettingsManager.ts +114 -0
  33. package/src/__tests/CWIStyleWalletManager.test.d.ts.map +1 -0
  34. package/src/__tests/CWIStyleWalletManager.test.js.map +1 -0
  35. package/src/__tests/CWIStyleWalletManager.test.ts +675 -0
  36. package/src/__tests/WalletPermissionsManager.callbacks.test.ts +323 -0
  37. package/src/__tests/WalletPermissionsManager.checks.test.ts +844 -0
  38. package/src/__tests/WalletPermissionsManager.encryption.test.ts +412 -0
  39. package/src/__tests/WalletPermissionsManager.fixtures.ts +307 -0
  40. package/src/__tests/WalletPermissionsManager.flows.test.ts +462 -0
  41. package/src/__tests/WalletPermissionsManager.initialization.test.ts +300 -0
  42. package/src/__tests/WalletPermissionsManager.pmodules.test.ts +798 -0
  43. package/src/__tests/WalletPermissionsManager.proxying.test.ts +724 -0
  44. package/src/__tests/WalletPermissionsManager.tokens.test.ts +503 -0
  45. package/src/index.all.ts +27 -0
  46. package/src/index.client.ts +25 -0
  47. package/src/index.mobile.ts +21 -0
  48. package/src/index.ts +1 -0
  49. package/src/monitor/Monitor.ts +412 -0
  50. package/src/monitor/MonitorDaemon.ts +188 -0
  51. package/src/monitor/README.md +3 -0
  52. package/src/monitor/__test/MonitorDaemon.man.test.ts +45 -0
  53. package/src/monitor/tasks/TaskCheckForProofs.ts +243 -0
  54. package/src/monitor/tasks/TaskCheckNoSends.ts +73 -0
  55. package/src/monitor/tasks/TaskClock.ts +33 -0
  56. package/src/monitor/tasks/TaskFailAbandoned.ts +54 -0
  57. package/src/monitor/tasks/TaskMonitorCallHistory.ts +26 -0
  58. package/src/monitor/tasks/TaskNewHeader.ts +93 -0
  59. package/src/monitor/tasks/TaskPurge.ts +68 -0
  60. package/src/monitor/tasks/TaskReorg.ts +89 -0
  61. package/src/monitor/tasks/TaskReviewStatus.ts +48 -0
  62. package/src/monitor/tasks/TaskSendWaiting.ts +122 -0
  63. package/src/monitor/tasks/TaskSyncWhenIdle.ts +26 -0
  64. package/src/monitor/tasks/TaskUnFail.ts +151 -0
  65. package/src/monitor/tasks/WalletMonitorTask.ts +47 -0
  66. package/src/sdk/CertOpsWallet.ts +18 -0
  67. package/src/sdk/PrivilegedKeyManager.ts +372 -0
  68. package/src/sdk/README.md +13 -0
  69. package/src/sdk/WERR_errors.ts +234 -0
  70. package/src/sdk/WalletError.ts +170 -0
  71. package/src/sdk/WalletErrorFromJson.ts +80 -0
  72. package/src/sdk/WalletServices.interfaces.ts +700 -0
  73. package/src/sdk/WalletSigner.interfaces.ts +11 -0
  74. package/src/sdk/WalletStorage.interfaces.ts +606 -0
  75. package/src/sdk/__test/CertificateLifeCycle.test.ts +131 -0
  76. package/src/sdk/__test/PrivilegedKeyManager.test.ts +738 -0
  77. package/src/sdk/__test/WalletError.test.ts +318 -0
  78. package/src/sdk/__test/validationHelpers.test.ts +21 -0
  79. package/src/sdk/index.ts +10 -0
  80. package/src/sdk/types.ts +226 -0
  81. package/src/services/README.md +11 -0
  82. package/src/services/ServiceCollection.ts +248 -0
  83. package/src/services/Services.ts +603 -0
  84. package/src/services/__tests/ARC.man.test.ts +123 -0
  85. package/src/services/__tests/ARC.timeout.man.test.ts +79 -0
  86. package/src/services/__tests/ArcGorillaPool.man.test.ts +108 -0
  87. package/src/services/__tests/arcServices.test.ts +8 -0
  88. package/src/services/__tests/bitrails.test.ts +56 -0
  89. package/src/services/__tests/getMerklePath.test.ts +15 -0
  90. package/src/services/__tests/getRawTx.test.ts +13 -0
  91. package/src/services/__tests/postBeef.test.ts +104 -0
  92. package/src/services/__tests/verifyBeef.test.ts +50 -0
  93. package/src/services/chaintracker/BHServiceClient.ts +212 -0
  94. package/src/services/chaintracker/ChaintracksChainTracker.ts +71 -0
  95. package/src/services/chaintracker/__tests/ChaintracksChainTracker.test.ts +33 -0
  96. package/src/services/chaintracker/__tests/ChaintracksServiceClient.test.ts +29 -0
  97. package/src/services/chaintracker/chaintracks/Api/BlockHeaderApi.ts +72 -0
  98. package/src/services/chaintracker/chaintracks/Api/BulkIngestorApi.ts +83 -0
  99. package/src/services/chaintracker/chaintracks/Api/BulkStorageApi.ts +92 -0
  100. package/src/services/chaintracker/chaintracks/Api/ChaintracksApi.ts +64 -0
  101. package/src/services/chaintracker/chaintracks/Api/ChaintracksClientApi.ts +189 -0
  102. package/src/services/chaintracker/chaintracks/Api/ChaintracksFetchApi.ts +18 -0
  103. package/src/services/chaintracker/chaintracks/Api/ChaintracksFsApi.ts +58 -0
  104. package/src/services/chaintracker/chaintracks/Api/ChaintracksStorageApi.ts +386 -0
  105. package/src/services/chaintracker/chaintracks/Api/LiveIngestorApi.ts +25 -0
  106. package/src/services/chaintracker/chaintracks/Chaintracks.ts +609 -0
  107. package/src/services/chaintracker/chaintracks/ChaintracksService.ts +199 -0
  108. package/src/services/chaintracker/chaintracks/ChaintracksServiceClient.ts +154 -0
  109. package/src/services/chaintracker/chaintracks/Ingest/BulkIngestorBase.ts +176 -0
  110. package/src/services/chaintracker/chaintracks/Ingest/BulkIngestorCDN.ts +174 -0
  111. package/src/services/chaintracker/chaintracks/Ingest/BulkIngestorCDNBabbage.ts +18 -0
  112. package/src/services/chaintracker/chaintracks/Ingest/BulkIngestorWhatsOnChainCdn.ts +113 -0
  113. package/src/services/chaintracker/chaintracks/Ingest/BulkIngestorWhatsOnChainWs.ts +81 -0
  114. package/src/services/chaintracker/chaintracks/Ingest/LiveIngestorBase.ts +86 -0
  115. package/src/services/chaintracker/chaintracks/Ingest/LiveIngestorTeranodeP2P.ts +59 -0
  116. package/src/services/chaintracker/chaintracks/Ingest/LiveIngestorWhatsOnChainPoll.ts +104 -0
  117. package/src/services/chaintracker/chaintracks/Ingest/LiveIngestorWhatsOnChainWs.ts +66 -0
  118. package/src/services/chaintracker/chaintracks/Ingest/WhatsOnChainIngestorWs.ts +566 -0
  119. package/src/services/chaintracker/chaintracks/Ingest/WhatsOnChainServices.ts +219 -0
  120. package/src/services/chaintracker/chaintracks/Ingest/__tests/BulkIngestorCDNBabbage.test.ts +54 -0
  121. package/src/services/chaintracker/chaintracks/Ingest/__tests/LiveIngestorWhatsOnChainPoll.test.ts +33 -0
  122. package/src/services/chaintracker/chaintracks/Ingest/__tests/WhatsOnChainServices.test.ts +124 -0
  123. package/src/services/chaintracker/chaintracks/Storage/BulkStorageBase.ts +92 -0
  124. package/src/services/chaintracker/chaintracks/Storage/ChaintracksKnexMigrations.ts +104 -0
  125. package/src/services/chaintracker/chaintracks/Storage/ChaintracksStorageBase.ts +382 -0
  126. package/src/services/chaintracker/chaintracks/Storage/ChaintracksStorageIdb.ts +574 -0
  127. package/src/services/chaintracker/chaintracks/Storage/ChaintracksStorageKnex.ts +438 -0
  128. package/src/services/chaintracker/chaintracks/Storage/ChaintracksStorageMemory.ts +29 -0
  129. package/src/services/chaintracker/chaintracks/Storage/ChaintracksStorageNoDb.ts +304 -0
  130. package/src/services/chaintracker/chaintracks/Storage/__tests/ChaintracksStorageIdb.test.ts +102 -0
  131. package/src/services/chaintracker/chaintracks/Storage/__tests/ChaintracksStorageKnex.test.ts +45 -0
  132. package/src/services/chaintracker/chaintracks/__tests/Chaintracks.test.ts +77 -0
  133. package/src/services/chaintracker/chaintracks/__tests/ChaintracksClientApi.test.ts +192 -0
  134. package/src/services/chaintracker/chaintracks/__tests/LocalCdnServer.ts +75 -0
  135. package/src/services/chaintracker/chaintracks/__tests/createIdbChaintracks.test.ts +62 -0
  136. package/src/services/chaintracker/chaintracks/__tests/data/cdnTest349/mainNetBlockHeaders.json +1 -0
  137. package/src/services/chaintracker/chaintracks/__tests/data/cdnTest349/mainNet_0.headers +0 -0
  138. package/src/services/chaintracker/chaintracks/__tests/data/cdnTest349/mainNet_1.headers +0 -0
  139. package/src/services/chaintracker/chaintracks/__tests/data/cdnTest349/mainNet_2.headers +0 -0
  140. package/src/services/chaintracker/chaintracks/__tests/data/cdnTest349/mainNet_3.headers +0 -0
  141. package/src/services/chaintracker/chaintracks/__tests/data/cdnTest379/mainNetBlockHeaders.json +1 -0
  142. package/src/services/chaintracker/chaintracks/__tests/data/cdnTest379/mainNet_0.headers +0 -0
  143. package/src/services/chaintracker/chaintracks/__tests/data/cdnTest379/mainNet_1.headers +0 -0
  144. package/src/services/chaintracker/chaintracks/__tests/data/cdnTest379/mainNet_2.headers +0 -0
  145. package/src/services/chaintracker/chaintracks/__tests/data/cdnTest379/mainNet_3.headers +0 -0
  146. package/src/services/chaintracker/chaintracks/__tests/data/cdnTest399/mainNetBlockHeaders.json +1 -0
  147. package/src/services/chaintracker/chaintracks/__tests/data/cdnTest399/mainNet_0.headers +0 -0
  148. package/src/services/chaintracker/chaintracks/__tests/data/cdnTest399/mainNet_1.headers +0 -0
  149. package/src/services/chaintracker/chaintracks/__tests/data/cdnTest399/mainNet_2.headers +0 -0
  150. package/src/services/chaintracker/chaintracks/__tests/data/cdnTest399/mainNet_3.headers +0 -0
  151. package/src/services/chaintracker/chaintracks/__tests/data/cdnTest402/mainNetBlockHeaders.json +1 -0
  152. package/src/services/chaintracker/chaintracks/__tests/data/cdnTest402/mainNet_0.headers +0 -0
  153. package/src/services/chaintracker/chaintracks/__tests/data/cdnTest402/mainNet_1.headers +0 -0
  154. package/src/services/chaintracker/chaintracks/__tests/data/cdnTest402/mainNet_2.headers +0 -0
  155. package/src/services/chaintracker/chaintracks/__tests/data/cdnTest402/mainNet_3.headers +0 -0
  156. package/src/services/chaintracker/chaintracks/__tests/data/cdnTest402/mainNet_4.headers +0 -0
  157. package/src/services/chaintracker/chaintracks/__tests/data/cdnTest499/mainNetBlockHeaders.json +1 -0
  158. package/src/services/chaintracker/chaintracks/__tests/data/cdnTest499/mainNet_0.headers +0 -0
  159. package/src/services/chaintracker/chaintracks/__tests/data/cdnTest499/mainNet_1.headers +0 -0
  160. package/src/services/chaintracker/chaintracks/__tests/data/cdnTest499/mainNet_2.headers +0 -0
  161. package/src/services/chaintracker/chaintracks/__tests/data/cdnTest499/mainNet_3.headers +0 -0
  162. package/src/services/chaintracker/chaintracks/__tests/data/cdnTest499/mainNet_4.headers +0 -0
  163. package/src/services/chaintracker/chaintracks/createDefaultIdbChaintracksOptions.ts +92 -0
  164. package/src/services/chaintracker/chaintracks/createDefaultKnexChaintracksOptions.ts +111 -0
  165. package/src/services/chaintracker/chaintracks/createDefaultNoDbChaintracksOptions.ts +91 -0
  166. package/src/services/chaintracker/chaintracks/createIdbChaintracks.ts +60 -0
  167. package/src/services/chaintracker/chaintracks/createKnexChaintracks.ts +65 -0
  168. package/src/services/chaintracker/chaintracks/createNoDbChaintracks.ts +60 -0
  169. package/src/services/chaintracker/chaintracks/index.all.ts +12 -0
  170. package/src/services/chaintracker/chaintracks/index.client.ts +4 -0
  171. package/src/services/chaintracker/chaintracks/index.mobile.ts +37 -0
  172. package/src/services/chaintracker/chaintracks/util/BulkFileDataManager.ts +975 -0
  173. package/src/services/chaintracker/chaintracks/util/BulkFileDataReader.ts +60 -0
  174. package/src/services/chaintracker/chaintracks/util/BulkFilesReader.ts +336 -0
  175. package/src/services/chaintracker/chaintracks/util/BulkHeaderFile.ts +247 -0
  176. package/src/services/chaintracker/chaintracks/util/ChaintracksFetch.ts +69 -0
  177. package/src/services/chaintracker/chaintracks/util/ChaintracksFs.ts +141 -0
  178. package/src/services/chaintracker/chaintracks/util/HeightRange.ts +153 -0
  179. package/src/services/chaintracker/chaintracks/util/SingleWriterMultiReaderLock.ts +76 -0
  180. package/src/services/chaintracker/chaintracks/util/__tests/BulkFileDataManager.test.ts +304 -0
  181. package/src/services/chaintracker/chaintracks/util/__tests/ChaintracksFetch.test.ts +60 -0
  182. package/src/services/chaintracker/chaintracks/util/__tests/HeightRange.test.ts +67 -0
  183. package/src/services/chaintracker/chaintracks/util/__tests/SingleWriterMultiReaderLock.test.ts +49 -0
  184. package/src/services/chaintracker/chaintracks/util/blockHeaderUtilities.ts +573 -0
  185. package/src/services/chaintracker/chaintracks/util/dirtyHashes.ts +29 -0
  186. package/src/services/chaintracker/chaintracks/util/validBulkHeaderFilesByFileHash.ts +432 -0
  187. package/src/services/chaintracker/index.all.ts +4 -0
  188. package/src/services/chaintracker/index.client.ts +4 -0
  189. package/src/services/chaintracker/index.mobile.ts +4 -0
  190. package/src/services/createDefaultWalletServicesOptions.ts +77 -0
  191. package/src/services/index.ts +1 -0
  192. package/src/services/processingErrors/arcSuccessError.json +76 -0
  193. package/src/services/providers/ARC.ts +350 -0
  194. package/src/services/providers/Bitails.ts +256 -0
  195. package/src/services/providers/SdkWhatsOnChain.ts +83 -0
  196. package/src/services/providers/WhatsOnChain.ts +883 -0
  197. package/src/services/providers/__tests/WhatsOnChain.test.ts +242 -0
  198. package/src/services/providers/__tests/exchangeRates.test.ts +18 -0
  199. package/src/services/providers/exchangeRates.ts +265 -0
  200. package/src/services/providers/getBeefForTxid.ts +369 -0
  201. package/src/signer/README.md +5 -0
  202. package/src/signer/WalletSigner.ts +17 -0
  203. package/src/signer/methods/acquireDirectCertificate.ts +52 -0
  204. package/src/signer/methods/buildSignableTransaction.ts +183 -0
  205. package/src/signer/methods/completeSignedTransaction.ts +117 -0
  206. package/src/signer/methods/createAction.ts +172 -0
  207. package/src/signer/methods/internalizeAction.ts +106 -0
  208. package/src/signer/methods/proveCertificate.ts +43 -0
  209. package/src/signer/methods/signAction.ts +54 -0
  210. package/src/storage/README.md +14 -0
  211. package/src/storage/StorageIdb.ts +2304 -0
  212. package/src/storage/StorageKnex.ts +1425 -0
  213. package/src/storage/StorageProvider.ts +810 -0
  214. package/src/storage/StorageReader.ts +194 -0
  215. package/src/storage/StorageReaderWriter.ts +432 -0
  216. package/src/storage/StorageSyncReader.ts +34 -0
  217. package/src/storage/WalletStorageManager.ts +943 -0
  218. package/src/storage/__test/StorageIdb.test.ts +43 -0
  219. package/src/storage/__test/WalletStorageManager.test.ts +275 -0
  220. package/src/storage/__test/adminStats.man.test.ts +89 -0
  221. package/src/storage/__test/getBeefForTransaction.test.ts +385 -0
  222. package/src/storage/index.all.ts +11 -0
  223. package/src/storage/index.client.ts +7 -0
  224. package/src/storage/index.mobile.ts +6 -0
  225. package/src/storage/methods/ListActionsSpecOp.ts +70 -0
  226. package/src/storage/methods/ListOutputsSpecOp.ts +129 -0
  227. package/src/storage/methods/__test/GenerateChange/generateChangeSdk.test.ts +1057 -0
  228. package/src/storage/methods/__test/GenerateChange/randomValsUsed1.ts +20 -0
  229. package/src/storage/methods/__test/offsetKey.test.ts +274 -0
  230. package/src/storage/methods/attemptToPostReqsToNetwork.ts +389 -0
  231. package/src/storage/methods/createAction.ts +947 -0
  232. package/src/storage/methods/generateChange.ts +556 -0
  233. package/src/storage/methods/getBeefForTransaction.ts +139 -0
  234. package/src/storage/methods/getSyncChunk.ts +293 -0
  235. package/src/storage/methods/internalizeAction.ts +562 -0
  236. package/src/storage/methods/listActionsIdb.ts +183 -0
  237. package/src/storage/methods/listActionsKnex.ts +226 -0
  238. package/src/storage/methods/listCertificates.ts +73 -0
  239. package/src/storage/methods/listOutputsIdb.ts +203 -0
  240. package/src/storage/methods/listOutputsKnex.ts +263 -0
  241. package/src/storage/methods/offsetKey.ts +89 -0
  242. package/src/storage/methods/processAction.ts +420 -0
  243. package/src/storage/methods/purgeData.ts +251 -0
  244. package/src/storage/methods/purgeDataIdb.ts +10 -0
  245. package/src/storage/methods/reviewStatus.ts +101 -0
  246. package/src/storage/methods/reviewStatusIdb.ts +43 -0
  247. package/src/storage/methods/utils.Buffer.ts +33 -0
  248. package/src/storage/methods/utils.ts +56 -0
  249. package/src/storage/remoting/StorageClient.ts +567 -0
  250. package/src/storage/remoting/StorageMobile.ts +544 -0
  251. package/src/storage/remoting/StorageServer.ts +291 -0
  252. package/src/storage/remoting/__test/StorageClient.test.ts +113 -0
  253. package/src/storage/schema/KnexMigrations.ts +489 -0
  254. package/src/storage/schema/StorageIdbSchema.ts +150 -0
  255. package/src/storage/schema/entities/EntityBase.ts +210 -0
  256. package/src/storage/schema/entities/EntityCertificate.ts +188 -0
  257. package/src/storage/schema/entities/EntityCertificateField.ts +136 -0
  258. package/src/storage/schema/entities/EntityCommission.ts +148 -0
  259. package/src/storage/schema/entities/EntityOutput.ts +290 -0
  260. package/src/storage/schema/entities/EntityOutputBasket.ts +153 -0
  261. package/src/storage/schema/entities/EntityOutputTag.ts +121 -0
  262. package/src/storage/schema/entities/EntityOutputTagMap.ts +123 -0
  263. package/src/storage/schema/entities/EntityProvenTx.ts +319 -0
  264. package/src/storage/schema/entities/EntityProvenTxReq.ts +580 -0
  265. package/src/storage/schema/entities/EntitySyncState.ts +389 -0
  266. package/src/storage/schema/entities/EntityTransaction.ts +306 -0
  267. package/src/storage/schema/entities/EntityTxLabel.ts +121 -0
  268. package/src/storage/schema/entities/EntityTxLabelMap.ts +123 -0
  269. package/src/storage/schema/entities/EntityUser.ts +112 -0
  270. package/src/storage/schema/entities/MergeEntity.ts +73 -0
  271. package/src/storage/schema/entities/__tests/CertificateFieldTests.test.ts +353 -0
  272. package/src/storage/schema/entities/__tests/CertificateTests.test.ts +354 -0
  273. package/src/storage/schema/entities/__tests/CommissionTests.test.ts +371 -0
  274. package/src/storage/schema/entities/__tests/OutputBasketTests.test.ts +278 -0
  275. package/src/storage/schema/entities/__tests/OutputTagMapTests.test.ts +242 -0
  276. package/src/storage/schema/entities/__tests/OutputTagTests.test.ts +288 -0
  277. package/src/storage/schema/entities/__tests/OutputTests.test.ts +464 -0
  278. package/src/storage/schema/entities/__tests/ProvenTxReqTests.test.ts +340 -0
  279. package/src/storage/schema/entities/__tests/ProvenTxTests.test.ts +504 -0
  280. package/src/storage/schema/entities/__tests/SyncStateTests.test.ts +288 -0
  281. package/src/storage/schema/entities/__tests/TransactionTests.test.ts +604 -0
  282. package/src/storage/schema/entities/__tests/TxLabelMapTests.test.ts +361 -0
  283. package/src/storage/schema/entities/__tests/TxLabelTests.test.ts +198 -0
  284. package/src/storage/schema/entities/__tests/stampLogTests.test.ts +90 -0
  285. package/src/storage/schema/entities/__tests/usersTests.test.ts +340 -0
  286. package/src/storage/schema/entities/index.ts +16 -0
  287. package/src/storage/schema/tables/TableCertificate.ts +21 -0
  288. package/src/storage/schema/tables/TableCertificateField.ts +12 -0
  289. package/src/storage/schema/tables/TableCommission.ts +13 -0
  290. package/src/storage/schema/tables/TableMonitorEvent.ts +9 -0
  291. package/src/storage/schema/tables/TableOutput.ts +64 -0
  292. package/src/storage/schema/tables/TableOutputBasket.ts +12 -0
  293. package/src/storage/schema/tables/TableOutputTag.ts +10 -0
  294. package/src/storage/schema/tables/TableOutputTagMap.ts +9 -0
  295. package/src/storage/schema/tables/TableProvenTx.ts +14 -0
  296. package/src/storage/schema/tables/TableProvenTxReq.ts +65 -0
  297. package/src/storage/schema/tables/TableSettings.ts +17 -0
  298. package/src/storage/schema/tables/TableSyncState.ts +18 -0
  299. package/src/storage/schema/tables/TableTransaction.ts +54 -0
  300. package/src/storage/schema/tables/TableTxLabel.ts +10 -0
  301. package/src/storage/schema/tables/TableTxLabelMap.ts +9 -0
  302. package/src/storage/schema/tables/TableUser.ts +16 -0
  303. package/src/storage/schema/tables/index.ts +16 -0
  304. package/src/storage/sync/StorageMySQLDojoReader.ts +696 -0
  305. package/src/storage/sync/index.ts +1 -0
  306. package/src/utility/Format.ts +133 -0
  307. package/src/utility/README.md +3 -0
  308. package/src/utility/ReaderUint8Array.ts +187 -0
  309. package/src/utility/ScriptTemplateBRC29.ts +73 -0
  310. package/src/utility/__tests/utilityHelpers.noBuffer.test.ts +109 -0
  311. package/src/utility/aggregateResults.ts +68 -0
  312. package/src/utility/identityUtils.ts +159 -0
  313. package/src/utility/index.all.ts +7 -0
  314. package/src/utility/index.client.ts +7 -0
  315. package/src/utility/parseTxScriptOffsets.ts +29 -0
  316. package/src/utility/stampLog.ts +69 -0
  317. package/src/utility/tscProofToMerklePath.ts +48 -0
  318. package/src/utility/utilityHelpers.buffer.ts +34 -0
  319. package/src/utility/utilityHelpers.noBuffer.ts +60 -0
  320. package/src/utility/utilityHelpers.ts +275 -0
  321. package/src/wab-client/WABClient.ts +94 -0
  322. package/src/wab-client/__tests/WABClient.man.test.ts +59 -0
  323. package/src/wab-client/auth-method-interactors/AuthMethodInteractor.ts +47 -0
  324. package/src/wab-client/auth-method-interactors/DevConsoleInteractor.ts +73 -0
  325. package/src/wab-client/auth-method-interactors/PersonaIDInteractor.ts +35 -0
  326. package/src/wab-client/auth-method-interactors/TwilioPhoneInteractor.ts +72 -0
  327. package/syncVersions.js +71 -0
  328. package/test/Wallet/StorageClient/storageClient.man.test.ts +75 -0
  329. package/test/Wallet/action/abortAction.test.ts +47 -0
  330. package/test/Wallet/action/createAction.test.ts +299 -0
  331. package/test/Wallet/action/createAction2.test.ts +1273 -0
  332. package/test/Wallet/action/createActionToGenerateBeefs.man.test.ts +293 -0
  333. package/test/Wallet/action/internalizeAction.a.test.ts +286 -0
  334. package/test/Wallet/action/internalizeAction.test.ts +682 -0
  335. package/test/Wallet/action/relinquishOutput.test.ts +37 -0
  336. package/test/Wallet/certificate/acquireCertificate.test.ts +298 -0
  337. package/test/Wallet/certificate/listCertificates.test.ts +346 -0
  338. package/test/Wallet/construct/Wallet.constructor.test.ts +57 -0
  339. package/test/Wallet/get/getHeaderForHeight.test.ts +82 -0
  340. package/test/Wallet/get/getHeight.test.ts +52 -0
  341. package/test/Wallet/get/getKnownTxids.test.ts +86 -0
  342. package/test/Wallet/get/getNetwork.test.ts +27 -0
  343. package/test/Wallet/get/getVersion.test.ts +27 -0
  344. package/test/Wallet/list/listActions.test.ts +279 -0
  345. package/test/Wallet/list/listActions2.test.ts +1381 -0
  346. package/test/Wallet/list/listCertificates.test.ts +118 -0
  347. package/test/Wallet/list/listOutputs.test.ts +447 -0
  348. package/test/Wallet/live/walletLive.man.test.ts +521 -0
  349. package/test/Wallet/local/localWallet.man.test.ts +93 -0
  350. package/test/Wallet/local/localWallet2.man.test.ts +277 -0
  351. package/test/Wallet/signAction/mountaintop.man.test.ts +130 -0
  352. package/test/Wallet/specOps/specOps.man.test.ts +220 -0
  353. package/test/Wallet/support/janitor.man.test.ts +40 -0
  354. package/test/Wallet/support/operations.man.test.ts +407 -0
  355. package/test/Wallet/support/reqErrorReview.2025.05.06.man.test.ts +347 -0
  356. package/test/Wallet/sync/Wallet.sync.test.ts +215 -0
  357. package/test/Wallet/sync/Wallet.updateWalletLegacyTestData.man.test.ts +203 -0
  358. package/test/Wallet/sync/setActive.test.ts +170 -0
  359. package/test/WalletClient/LocalKVStore.man.test.ts +114 -0
  360. package/test/WalletClient/WERR.man.test.ts +35 -0
  361. package/test/bsv-ts-sdk/LocalKVStore.test.ts +102 -0
  362. package/test/checkDB.ts +57 -0
  363. package/test/checkdb +0 -0
  364. package/test/examples/backup.man.test.ts +59 -0
  365. package/test/examples/pushdrop.test.ts +282 -0
  366. package/test/monitor/Monitor.test.ts +620 -0
  367. package/test/services/Services.test.ts +263 -0
  368. package/test/storage/KnexMigrations.test.ts +86 -0
  369. package/test/storage/StorageMySQLDojoReader.man.test.ts +60 -0
  370. package/test/storage/count.test.ts +177 -0
  371. package/test/storage/find.test.ts +195 -0
  372. package/test/storage/findLegacy.test.ts +67 -0
  373. package/test/storage/idb/allocateChange.test.ts +251 -0
  374. package/test/storage/idb/count.test.ts +158 -0
  375. package/test/storage/idb/find.test.ts +177 -0
  376. package/test/storage/idb/idbSpeed.test.ts +36 -0
  377. package/test/storage/idb/insert.test.ts +268 -0
  378. package/test/storage/idb/transactionAbort.test.ts +108 -0
  379. package/test/storage/idb/update.test.ts +999 -0
  380. package/test/storage/insert.test.ts +278 -0
  381. package/test/storage/update.test.ts +1021 -0
  382. package/test/storage/update2.test.ts +897 -0
  383. package/test/utils/TestUtilsWalletStorage.ts +2526 -0
  384. package/test/utils/localWalletMethods.ts +363 -0
  385. package/test/utils/removeFailedFromDatabase.sql +17 -0
  386. package/ts2md.json +44 -0
  387. package/tsconfig.all.json +31 -0
  388. package/tsconfig.client.json +29 -0
  389. package/tsconfig.json +17 -0
  390. package/tsconfig.mobile.json +28 -0
@@ -0,0 +1,3660 @@
1
+ import {
2
+ WalletInterface,
3
+ Utils,
4
+ PushDrop,
5
+ LockingScript,
6
+ Transaction,
7
+ WalletProtocol,
8
+ Base64String,
9
+ PubKeyHex,
10
+ Beef,
11
+ Validation,
12
+ WalletEncryptArgs,
13
+ WalletDecryptArgs,
14
+ CreateHmacArgs,
15
+ VerifyHmacArgs,
16
+ CreateSignatureArgs,
17
+ VerifySignatureArgs,
18
+ InternalizeActionArgs,
19
+ ListOutputsArgs,
20
+ RelinquishOutputArgs,
21
+ GetPublicKeyArgs,
22
+ CreateActionArgs,
23
+ ListOutputsResult
24
+ } from '@bsv/sdk'
25
+
26
+ ////// TODO: ADD SUPPORT FOR ADMIN COUNTERPARTIES BASED ON WALLET STORAGE
27
+ ////// PROHIBITION OF SPECIAL OPERATIONS IS ALSO CRITICAL.
28
+ ////// !!!!!!!! SECURITY-CRITICAL ADDITION — DO NOT USE UNTIL IMPLEMENTED.
29
+
30
+ function deepEqual(object1: any, object2: any): boolean {
31
+ if (object1 === null || object1 === undefined || object2 === null || object2 === undefined) {
32
+ return object1 === object2
33
+ }
34
+ const keys1 = Object.keys(object1)
35
+ const keys2 = Object.keys(object2)
36
+
37
+ if (keys1.length !== keys2.length) {
38
+ return false
39
+ }
40
+
41
+ for (const key of keys1) {
42
+ const val1 = object1[key]
43
+ const val2 = object2[key]
44
+ const areObjects = isObject(val1) && isObject(val2)
45
+ if ((areObjects && !deepEqual(val1, val2)) || (!areObjects && val1 !== val2)) {
46
+ return false
47
+ }
48
+ }
49
+
50
+ return true
51
+ }
52
+
53
+ function isObject(object: any): boolean {
54
+ return object != null && typeof object === 'object'
55
+ }
56
+
57
+ /**
58
+ * A permissions module handles request/response transformation for a specific P-protocol or P-basket scheme under BRC-98/99.
59
+ * Modules are registered in the config mapped by their scheme ID.
60
+ */
61
+ export interface PermissionsModule {
62
+ /**
63
+ * Transforms the request before it's passed to the underlying wallet.
64
+ * Can check and enforce permissions, throw errors, or modify any arguments as needed prior to invocation.
65
+ *
66
+ * @param req - The incoming request with method, args, and originator
67
+ * @returns Transformed arguments that will be passed to the underlying wallet
68
+ */
69
+ onRequest(req: { method: string; args: object; originator: string }): Promise<{ args: object }>
70
+
71
+ /**
72
+ * Transforms the response from the underlying wallet before returning to caller.
73
+ *
74
+ * @param res - The response from the underlying wallet
75
+ * @param context - Metadata about the original request (method, originator)
76
+ * @returns Transformed response to return to the caller
77
+ */
78
+ onResponse(
79
+ res: any,
80
+ context: {
81
+ method: string
82
+ originator: string
83
+ }
84
+ ): Promise<any>
85
+ }
86
+
87
+ /**
88
+ * Describes a group of permissions that can be requested together.
89
+ * This structure is based on BRC-73.
90
+ */
91
+ export interface GroupedPermissions {
92
+ description?: string
93
+ spendingAuthorization?: {
94
+ amount: number
95
+ description: string
96
+ }
97
+ protocolPermissions?: Array<{
98
+ protocolID: WalletProtocol
99
+ counterparty?: string
100
+ description: string
101
+ }>
102
+ basketAccess?: Array<{
103
+ basket: string
104
+ description: string
105
+ }>
106
+ certificateAccess?: Array<{
107
+ type: string
108
+ fields: string[]
109
+ verifierPublicKey: string
110
+ description: string
111
+ }>
112
+ }
113
+
114
+ /**
115
+ * The object passed to the UI when a grouped permission is requested.
116
+ */
117
+ export interface GroupedPermissionRequest {
118
+ originator: string
119
+ requestID: string
120
+ permissions: GroupedPermissions
121
+ }
122
+
123
+ /**
124
+ * Signature for functions that handle a grouped permission request event.
125
+ */
126
+ export type GroupedPermissionEventHandler = (request: GroupedPermissionRequest) => void | Promise<void>
127
+
128
+ /**
129
+ * Describes a single requested permission that the user must either grant or deny.
130
+ *
131
+ * Four categories of permission are supported, each with a unique protocol:
132
+ * 1) protocol - "DPACP" (Domain Protocol Access Control Protocol)
133
+ * 2) basket - "DBAP" (Domain Basket Access Protocol)
134
+ * 3) certificate - "DCAP" (Domain Certificate Access Protocol)
135
+ * 4) spending - "DSAP" (Domain Spending Authorization Protocol)
136
+ *
137
+ * This model underpins "requests" made to the user for permission, which the user can
138
+ * either grant or deny. The manager can then create on-chain tokens (PushDrop outputs)
139
+ * if permission is granted. Denying requests cause the underlying operation to throw,
140
+ * and no token is created. An "ephemeral" grant is also possible, denoting a one-time
141
+ * authorization without an associated persistent on-chain token.
142
+ */
143
+ export interface PermissionRequest {
144
+ type: 'protocol' | 'basket' | 'certificate' | 'spending'
145
+ originator: string // The domain or FQDN of the requesting application
146
+ displayOriginator?: string // Optional raw/original originator string for UI purposes
147
+ privileged?: boolean // For "protocol" or "certificate" usage, indicating privileged key usage
148
+ protocolID?: WalletProtocol // For type='protocol': BRC-43 style (securityLevel, protocolName)
149
+ counterparty?: string // For type='protocol': e.g. target public key or "self"/"anyone"
150
+
151
+ basket?: string // For type='basket': the basket name being requested
152
+
153
+ certificate?: {
154
+ // For type='certificate': details about the cert usage
155
+ verifier: string
156
+ certType: string
157
+ fields: string[]
158
+ }
159
+
160
+ spending?: {
161
+ // For type='spending': details about the requested spend
162
+ satoshis: number
163
+ lineItems?: Array<{
164
+ type: 'input' | 'output' | 'fee'
165
+ description: string
166
+ satoshis: number
167
+ }>
168
+ }
169
+
170
+ reason?: string // Human-readable explanation for requesting permission
171
+ renewal?: boolean // Whether this request is for renewing an expired token
172
+ previousToken?: PermissionToken // If renewing an expired permission, reference to the old token
173
+ }
174
+
175
+ /**
176
+ * Signature for functions that handle a permission request event, e.g. "Please ask the user to allow basket X".
177
+ */
178
+ export type PermissionEventHandler = (request: PermissionRequest & { requestID: string }) => void | Promise<void>
179
+
180
+ /**
181
+ * Data structure representing an on-chain permission token.
182
+ * It is typically stored as a single unspent PushDrop output in a special "internal" admin basket belonging to
183
+ * the user, held in their underlying wallet.
184
+ *
185
+ * It can represent any of the four permission categories by having the relevant fields:
186
+ * - DPACP: originator, privileged, protocol, securityLevel, counterparty
187
+ * - DBAP: originator, basketName
188
+ * - DCAP: originator, privileged, verifier, certType, certFields
189
+ * - DSAP: originator, authorizedAmount
190
+ */
191
+ export interface PermissionToken {
192
+ /** The transaction ID where this token resides. */
193
+ txid: string
194
+
195
+ /** The current transaction encapsulating the token. */
196
+ tx: number[]
197
+
198
+ /** The output index within that transaction. */
199
+ outputIndex: number
200
+
201
+ /** The exact script hex for the locking script. */
202
+ outputScript: string
203
+
204
+ /** The amount of satoshis assigned to the permission output (often 1). */
205
+ satoshis: number
206
+
207
+ /** The originator domain or FQDN that is allowed to use this permission. */
208
+ originator: string
209
+
210
+ /**
211
+ * The raw, unnormalized originator string captured at the time the permission
212
+ * token was created. This is preserved so we can continue to recognize legacy
213
+ * permissions that were stored with different casing or explicit default ports.
214
+ */
215
+ rawOriginator?: string
216
+
217
+ /** The expiration time for this token in UNIX epoch seconds. (0 or omitted for spending authorizations, which are indefinite) */
218
+ expiry: number
219
+
220
+ /** Whether this token grants privileged usage (for protocol or certificate). */
221
+ privileged?: boolean
222
+
223
+ /** The protocol name, if this is a DPACP token. */
224
+ protocol?: string
225
+
226
+ /** The security level (0,1,2) for DPACP. */
227
+ securityLevel?: 0 | 1 | 2
228
+
229
+ /** The counterparty, for DPACP. */
230
+ counterparty?: string
231
+
232
+ /** The name of a basket, if this is a DBAP token. */
233
+ basketName?: string
234
+
235
+ /** The certificate type, if this is a DCAP token. */
236
+ certType?: string
237
+
238
+ /** The certificate fields that this token covers, if DCAP token. */
239
+ certFields?: string[]
240
+
241
+ /** The "verifier" public key string, if DCAP. */
242
+ verifier?: string
243
+
244
+ /** For DSAP, the maximum authorized spending for the month. */
245
+ authorizedAmount?: number
246
+ }
247
+
248
+ /**
249
+ * A map from each permission type to a special "admin basket" name used for storing
250
+ * the tokens. The tokens themselves are unspent transaction outputs (UTXOs) with a
251
+ * specialized PushDrop script that references the originator, expiry, etc.
252
+ */
253
+ const BASKET_MAP = {
254
+ protocol: 'admin protocol-permission',
255
+ basket: 'admin basket-access',
256
+ certificate: 'admin certificate-access',
257
+ spending: 'admin spending-authorization'
258
+ }
259
+
260
+ /**
261
+ * The set of callbacks that external code can bind to, e.g. to display UI prompts or logs
262
+ * when a permission is requested.
263
+ */
264
+ export interface WalletPermissionsManagerCallbacks {
265
+ onProtocolPermissionRequested?: PermissionEventHandler[]
266
+ onBasketAccessRequested?: PermissionEventHandler[]
267
+ onCertificateAccessRequested?: PermissionEventHandler[]
268
+ onSpendingAuthorizationRequested?: PermissionEventHandler[]
269
+ onGroupedPermissionRequested?: GroupedPermissionEventHandler[]
270
+ }
271
+
272
+ /**
273
+ * Configuration object for the WalletPermissionsManager. If a given option is `false`,
274
+ * the manager will skip or alter certain permission checks or behaviors.
275
+ *
276
+ * By default, all of these are `true` unless specified otherwise. This is the most secure configuration.
277
+ */
278
+ export interface PermissionsManagerConfig {
279
+ /**
280
+ * A map of P-basket/protocol permission scheme modules.
281
+ *
282
+ * Keys are scheme IDs (e.g., "btms"), values are PermissionsModule instances.
283
+ *
284
+ * Each module handles basket/protocol names of the form: `p <schemeID> <rest...>`
285
+ *
286
+ * The WalletPermissionManager detects P-prefix baskets/protocols and delegates
287
+ * request/response transformation to the corresponding module.
288
+ *
289
+ * If no module exists for a given schemeID, the wallet will reject access.
290
+ */
291
+ permissionModules?: Record<string, PermissionsModule>
292
+
293
+ /**
294
+ * For `createSignature` and `verifySignature`,
295
+ * require a "protocol usage" permission check?
296
+ */
297
+ seekProtocolPermissionsForSigning?: boolean
298
+
299
+ /**
300
+ * For methods that perform encryption (encrypt/decrypt), require
301
+ * a "protocol usage" permission check?
302
+ */
303
+ seekProtocolPermissionsForEncrypting?: boolean
304
+
305
+ /**
306
+ * For methods that perform HMAC creation or verification (createHmac, verifyHmac),
307
+ * require a "protocol usage" permission check?
308
+ */
309
+ seekProtocolPermissionsForHMAC?: boolean
310
+
311
+ /**
312
+ * For revealing counterparty-level or specific key linkage revelation information,
313
+ * should we require permission?
314
+ */
315
+ seekPermissionsForKeyLinkageRevelation?: boolean
316
+
317
+ /**
318
+ * For revealing any user public key (getPublicKey) **other** than the identity key,
319
+ * should we require permission?
320
+ */
321
+ seekPermissionsForPublicKeyRevelation?: boolean
322
+
323
+ /**
324
+ * If getPublicKey is requested with `identityKey=true`, do we require permission?
325
+ */
326
+ seekPermissionsForIdentityKeyRevelation?: boolean
327
+
328
+ /**
329
+ * If discoverByIdentityKey / discoverByAttributes are called, do we require permission
330
+ * for "identity resolution" usage?
331
+ */
332
+ seekPermissionsForIdentityResolution?: boolean
333
+
334
+ /**
335
+ * When we do internalizeAction with `basket insertion`, or include outputs in baskets
336
+ * with `createAction, do we ask for basket permission?
337
+ */
338
+ seekBasketInsertionPermissions?: boolean
339
+
340
+ /**
341
+ * When relinquishOutput is called, do we ask for basket permission?
342
+ */
343
+ seekBasketRemovalPermissions?: boolean
344
+
345
+ /**
346
+ * When listOutputs is called, do we ask for basket permission?
347
+ */
348
+ seekBasketListingPermissions?: boolean
349
+
350
+ /**
351
+ * When createAction is called with labels, do we ask for "label usage" permission?
352
+ */
353
+ seekPermissionWhenApplyingActionLabels?: boolean
354
+
355
+ /**
356
+ * When listActions is called with labels, do we ask for "label usage" permission?
357
+ */
358
+ seekPermissionWhenListingActionsByLabel?: boolean
359
+
360
+ /**
361
+ * If proving a certificate (proveCertificate) or revealing certificate fields,
362
+ * do we require a "certificate access" permission?
363
+ */
364
+ seekCertificateDisclosurePermissions?: boolean
365
+
366
+ /**
367
+ * If acquiring a certificate (acquireCertificate), do we require a permission check?
368
+ */
369
+ seekCertificateAcquisitionPermissions?: boolean
370
+
371
+ /**
372
+ * If relinquishing a certificate (relinquishCertificate), do we require a permission check?
373
+ */
374
+ seekCertificateRelinquishmentPermissions?: boolean
375
+
376
+ /**
377
+ * If listing a user's certificates (listCertificates), do we require a permission check?
378
+ */
379
+ seekCertificateListingPermissions?: boolean
380
+
381
+ /**
382
+ * Should transaction descriptions, input descriptions, and output descriptions be encrypted
383
+ * when before they are passed to the underlying wallet, and transparently decrypted when retrieved?
384
+ */
385
+ encryptWalletMetadata?: boolean
386
+
387
+ /**
388
+ * If the originator tries to spend wallet funds (netSpent > 0 in createAction),
389
+ * do we seek spending authorization?
390
+ */
391
+ seekSpendingPermissions?: boolean
392
+
393
+ /**
394
+ * If true, triggers a grouped permission request flow based on the originator's `manifest.json`.
395
+ */
396
+ seekGroupedPermission?: boolean
397
+
398
+ /**
399
+ * If false, permissions are checked without regard for whether we are in
400
+ * privileged mode. Privileged status is ignored with respect to whether
401
+ * permissions are granted. Internally, they are always sought and checked
402
+ * with privileged=false, regardless of the actual value.
403
+ */
404
+ differentiatePrivilegedOperations?: boolean
405
+ }
406
+
407
+ /**
408
+ * @class WalletPermissionsManager
409
+ *
410
+ * Wraps an underlying BRC-100 `Wallet` implementation with permissions management capabilities.
411
+ * The manager intercepts calls from external applications (identified by originators), checks if the request is allowed,
412
+ * and if not, orchestrates user permission flows. It creates or renews on-chain tokens in special
413
+ * admin baskets to track these authorizations. Finally, it proxies the actual call to the underlying wallet.
414
+ *
415
+ * ### Key Responsibilities:
416
+ * - **Permission Checking**: Before standard wallet operations (e.g. `encrypt`),
417
+ * the manager checks if a valid permission token exists. If not, it attempts to request permission from the user.
418
+ * - **On-Chain Tokens**: When permission is granted, the manager stores it as an unspent "PushDrop" output.
419
+ * This can be spent later to revoke or renew the permission.
420
+ * - **Callbacks**: The manager triggers user-defined callbacks on permission requests (to show a UI prompt),
421
+ * on grants/denials, and on internal processes.
422
+ *
423
+ * ### Implementation Notes:
424
+ * - The manager follows the BRC-100 `createAction` + `signAction` pattern for building or spending these tokens.
425
+ * - Token revocation or renewal uses standard BRC-100 flows: we build a transaction that consumes
426
+ * the old token UTXO and outputs a new one (or none, if fully revoked).
427
+ */
428
+ export class WalletPermissionsManager implements WalletInterface {
429
+ /** A reference to the BRC-100 wallet instance. */
430
+ private underlying: WalletInterface
431
+
432
+ /** The "admin" domain or FQDN that is implicitly allowed to do everything. */
433
+ private adminOriginator: string
434
+
435
+ /**
436
+ * Event callbacks that external code can subscribe to, e.g. to show a UI prompt
437
+ * or log events. Each event can have multiple handlers.
438
+ */
439
+ private callbacks: WalletPermissionsManagerCallbacks = {
440
+ onProtocolPermissionRequested: [],
441
+ onBasketAccessRequested: [],
442
+ onCertificateAccessRequested: [],
443
+ onSpendingAuthorizationRequested: [],
444
+ onGroupedPermissionRequested: []
445
+ }
446
+
447
+ /**
448
+ * We queue parallel requests for the same resource so that only one
449
+ * user prompt is created for a single resource. If multiple calls come
450
+ * in at once for the same "protocol:domain:privileged:counterparty" etc.,
451
+ * they get merged.
452
+ *
453
+ * The key is a string derived from the operation; the value is an object with a reference to the
454
+ * associated request and an array of pending promise resolve/reject pairs, one for each active
455
+ * operation that's waiting on the particular resource described by the key.
456
+ */
457
+ private activeRequests: Map<
458
+ string,
459
+ {
460
+ request: PermissionRequest | { originator: string; permissions: GroupedPermissions }
461
+ pending: Array<{
462
+ resolve: (val: any) => void
463
+ reject: (err: any) => void
464
+ }>
465
+ }
466
+ > = new Map()
467
+
468
+ /** Cache recently confirmed permissions to avoid repeated lookups. */
469
+ private permissionCache: Map<string, { expiry: number; cachedAt: number }> = new Map()
470
+ private recentGrants: Map<string, number> = new Map()
471
+
472
+ /** How long a cached permission remains valid (5 minutes). */
473
+ private static readonly CACHE_TTL_MS = 5 * 60 * 1000
474
+ /** Window during which freshly granted permissions are auto-allowed (except spending). */
475
+ private static readonly RECENT_GRANT_COVER_MS = 15 * 1000
476
+
477
+ /** Default ports used when normalizing originator values. */
478
+ private static readonly DEFAULT_PORTS: Record<string, string> = {
479
+ 'http:': '80',
480
+ 'https:': '443'
481
+ }
482
+
483
+ /**
484
+ * Configuration that determines whether to skip or apply various checks and encryption.
485
+ */
486
+ private config: PermissionsManagerConfig
487
+
488
+ /**
489
+ * Constructs a new Permissions Manager instance.
490
+ *
491
+ * @param underlyingWallet The underlying BRC-100 wallet, where requests are forwarded after permission is granted
492
+ * @param adminOriginator The domain or FQDN that is automatically allowed everything
493
+ * @param config A set of boolean flags controlling how strictly permissions are enforced
494
+ */
495
+ constructor(underlyingWallet: WalletInterface, adminOriginator: string, config: PermissionsManagerConfig = {}) {
496
+ this.underlying = underlyingWallet
497
+ this.adminOriginator = this.normalizeOriginator(adminOriginator) || adminOriginator
498
+
499
+ // Default all config options to true unless specified
500
+ this.config = {
501
+ seekProtocolPermissionsForSigning: true,
502
+ seekProtocolPermissionsForEncrypting: true,
503
+ seekProtocolPermissionsForHMAC: true,
504
+ seekPermissionsForKeyLinkageRevelation: true,
505
+ seekPermissionsForPublicKeyRevelation: true,
506
+ seekPermissionsForIdentityKeyRevelation: true,
507
+ seekPermissionsForIdentityResolution: true,
508
+ seekBasketInsertionPermissions: true,
509
+ seekBasketRemovalPermissions: true,
510
+ seekBasketListingPermissions: true,
511
+ seekPermissionWhenApplyingActionLabels: true,
512
+ seekPermissionWhenListingActionsByLabel: true,
513
+ seekCertificateDisclosurePermissions: true,
514
+ seekCertificateAcquisitionPermissions: true,
515
+ seekCertificateRelinquishmentPermissions: true,
516
+ seekCertificateListingPermissions: true,
517
+ encryptWalletMetadata: true,
518
+ seekSpendingPermissions: true,
519
+ seekGroupedPermission: true,
520
+ differentiatePrivilegedOperations: true,
521
+ ...config // override with user-specified config
522
+ }
523
+ }
524
+
525
+ /* ---------------------------------------------------------------------
526
+ * HELPER METHODS FOR P-MODULE DELEGATION
527
+ * --------------------------------------------------------------------- */
528
+
529
+ /**
530
+ * Delegates a wallet method call to a P-module if the basket or protocol name uses a P-scheme.
531
+ * Handles the full request/response transformation flow.
532
+ *
533
+ * @param basketOrProtocolName - The basket or protocol name to check for p-module delegation
534
+ * @param method - The wallet method name being called
535
+ * @param args - The original args passed to the method
536
+ * @param originator - The originator of the request
537
+ * @param underlyingCall - Callback that executes the underlying wallet method with transformed args
538
+ * @returns The transformed response, or null if not a P-basket/protocol (caller should continue normal flow)
539
+ */
540
+ private async delegateToPModuleIfNeeded<T>(
541
+ basketOrProtocolName: string,
542
+ method: string,
543
+ args: object,
544
+ originator: string,
545
+ underlyingCall: (transformedArgs: object, originator: string) => Promise<T>
546
+ ): Promise<T | null> {
547
+ // Check if this is a P-protocol/basket
548
+ if (!basketOrProtocolName.startsWith('p ')) {
549
+ return null // If not, caller should continue normal flow
550
+ }
551
+
552
+ const schemeID = basketOrProtocolName.split(' ')[1]
553
+ const module = this.config.permissionModules?.[schemeID]
554
+
555
+ if (!module) {
556
+ throw new Error(`Unsupported P-module scheme: p ${schemeID}`)
557
+ }
558
+
559
+ // Transform request with module
560
+ const transformedReq = await module.onRequest({
561
+ method,
562
+ args,
563
+ originator
564
+ })
565
+
566
+ // Call underlying method with transformed request
567
+ const results = await underlyingCall(transformedReq.args, originator)
568
+
569
+ // Transform response with module
570
+ return await module.onResponse(results, {
571
+ method,
572
+ originator
573
+ })
574
+ }
575
+
576
+ /**
577
+ * Decrypts custom instructions in listOutputs results if encryption is configured.
578
+ */
579
+ private async decryptListOutputsMetadata(results: ListOutputsResult): Promise<ListOutputsResult> {
580
+ if (results.outputs) {
581
+ for (let i = 0; i < results.outputs.length; i++) {
582
+ if (results.outputs[i].customInstructions) {
583
+ results.outputs[i].customInstructions = await this.maybeDecryptMetadata(
584
+ results.outputs[i].customInstructions!
585
+ )
586
+ }
587
+ }
588
+ }
589
+ return results
590
+ }
591
+
592
+ /* ---------------------------------------------------------------------
593
+ * 1) PUBLIC API FOR REGISTERING CALLBACKS (UI PROMPTS, LOGGING, ETC.)
594
+ * --------------------------------------------------------------------- */
595
+
596
+ /**
597
+ * Binds a callback function to a named event, such as `onProtocolPermissionRequested`.
598
+ *
599
+ * @param eventName The name of the event to listen to
600
+ * @param handler A function that handles the event
601
+ * @returns A numeric ID you can use to unbind later
602
+ */
603
+ public bindCallback(
604
+ eventName: keyof WalletPermissionsManagerCallbacks,
605
+ handler: PermissionEventHandler | GroupedPermissionEventHandler
606
+ ): number {
607
+ const arr = this.callbacks[eventName]! as any[]
608
+ arr.push(handler)
609
+ return arr.length - 1
610
+ }
611
+
612
+ /**
613
+ * Unbinds a previously registered callback by either its numeric ID (returned by `bindCallback`)
614
+ * or by exact function reference.
615
+ *
616
+ * @param eventName The event name, e.g. "onProtocolPermissionRequested"
617
+ * @param reference Either the numeric ID or the function reference
618
+ * @returns True if successfully unbound, false otherwise
619
+ */
620
+ public unbindCallback(eventName: keyof WalletPermissionsManagerCallbacks, reference: number | Function): boolean {
621
+ if (!this.callbacks[eventName]) return false
622
+ const arr = this.callbacks[eventName] as any[]
623
+ if (typeof reference === 'number') {
624
+ if (arr[reference]) {
625
+ arr[reference] = null
626
+ return true
627
+ }
628
+ return false
629
+ } else {
630
+ const index = arr.indexOf(reference)
631
+ if (index !== -1) {
632
+ arr[index] = null
633
+ return true
634
+ }
635
+ return false
636
+ }
637
+ }
638
+
639
+ /**
640
+ * Internally triggers a named event, calling all subscribed listeners.
641
+ * Each callback is awaited in turn (though errors are swallowed so that
642
+ * one failing callback doesn't prevent the others).
643
+ *
644
+ * @param eventName The event name
645
+ * @param param The parameter object passed to all listeners
646
+ */
647
+ private async callEvent(eventName: keyof WalletPermissionsManagerCallbacks, param: any): Promise<void> {
648
+ const arr = this.callbacks[eventName] || []
649
+ for (const cb of arr) {
650
+ if (typeof cb === 'function') {
651
+ try {
652
+ await cb(param)
653
+ } catch (e) {
654
+ // Intentionally swallow errors from user-provided callbacks
655
+ }
656
+ }
657
+ }
658
+ }
659
+
660
+ /* ---------------------------------------------------------------------
661
+ * 2) PERMISSION (GRANT / DENY) METHODS
662
+ * --------------------------------------------------------------------- */
663
+
664
+ /**
665
+ * Grants a previously requested permission.
666
+ * This method:
667
+ * 1) Resolves all pending promise calls waiting on this request
668
+ * 2) Optionally creates or renews an on-chain PushDrop token (unless `ephemeral===true`)
669
+ *
670
+ * @param params requestID to identify which request is granted, plus optional expiry
671
+ * or `ephemeral` usage, etc.
672
+ */
673
+ public async grantPermission(params: {
674
+ requestID: string
675
+ expiry?: number
676
+ ephemeral?: boolean
677
+ amount?: number
678
+ }): Promise<void> {
679
+ // 1) Identify the matching queued requests in `activeRequests`
680
+ const matching = this.activeRequests.get(params.requestID)
681
+ if (!matching) {
682
+ throw new Error('Request ID not found.')
683
+ }
684
+
685
+ // 2) Mark all matching requests as resolved, deleting the entry
686
+ for (const x of matching.pending) {
687
+ x.resolve(true)
688
+ }
689
+ this.activeRequests.delete(params.requestID)
690
+
691
+ // 3) If `ephemeral !== true`, we create or renew an on-chain token
692
+ if (!params.ephemeral) {
693
+ const request = matching.request as PermissionRequest
694
+ if (!request.renewal) {
695
+ // brand-new permission token
696
+ await this.createPermissionOnChain(
697
+ request,
698
+ params.expiry || 0, // default: never expires
699
+ params.amount
700
+ )
701
+ } else {
702
+ // renewal => spend the old token, produce a new one
703
+ await this.renewPermissionOnChain(
704
+ request.previousToken!,
705
+ request,
706
+ params.expiry || 0, // default: never expires
707
+ params.amount
708
+ )
709
+ }
710
+ }
711
+
712
+ // Only cache non-ephemeral permissions
713
+ // Ephemeral permissions should not be cached as they are one-time authorizations
714
+ if (!params.ephemeral) {
715
+ const expiry = params.expiry || 0 // default: never expires
716
+ const key = this.buildRequestKey(matching.request as PermissionRequest)
717
+ this.cachePermission(key, expiry)
718
+ this.markRecentGrant(matching.request as PermissionRequest)
719
+ }
720
+ }
721
+
722
+ /**
723
+ * Denies a previously requested permission.
724
+ * This method rejects all pending promise calls waiting on that request
725
+ *
726
+ * @param requestID requestID identifying which request to deny
727
+ */
728
+ public async denyPermission(requestID: string): Promise<void> {
729
+ // 1) Identify the matching requests
730
+ const matching = this.activeRequests.get(requestID)
731
+ if (!matching) {
732
+ throw new Error('Request ID not found.')
733
+ }
734
+
735
+ // 2) Reject all matching requests, deleting the entry
736
+ for (const x of matching.pending) {
737
+ x.reject(new Error('Permission denied.'))
738
+ }
739
+ this.activeRequests.delete(requestID)
740
+ }
741
+
742
+ /**
743
+ * Grants a previously requested grouped permission.
744
+ * @param params.requestID The ID of the request being granted.
745
+ * @param params.granted A subset of the originally requested permissions that the user has granted.
746
+ * @param params.expiry An optional expiry time (in seconds) for the new permission tokens.
747
+ */
748
+ public async grantGroupedPermission(params: {
749
+ requestID: string
750
+ granted: Partial<GroupedPermissions>
751
+ expiry?: number
752
+ }): Promise<void> {
753
+ const matching = this.activeRequests.get(params.requestID)
754
+ if (!matching) {
755
+ throw new Error('Request ID not found.')
756
+ }
757
+
758
+ const originalRequest = matching.request as {
759
+ originator: string
760
+ permissions: GroupedPermissions
761
+ displayOriginator?: string
762
+ }
763
+ const { originator, permissions: requestedPermissions, displayOriginator } = originalRequest
764
+ const originLookupValues = this.buildOriginatorLookupValues(displayOriginator, originator)
765
+
766
+ // --- Validation: Ensure granted permissions are a subset of what was requested ---
767
+ if (
768
+ params.granted.spendingAuthorization &&
769
+ !deepEqual(params.granted.spendingAuthorization, requestedPermissions.spendingAuthorization)
770
+ ) {
771
+ throw new Error('Granted spending authorization does not match the original request.')
772
+ }
773
+ if (
774
+ params.granted.protocolPermissions?.some(
775
+ g => !requestedPermissions.protocolPermissions?.find(r => deepEqual(r, g))
776
+ )
777
+ ) {
778
+ throw new Error('Granted protocol permissions are not a subset of the original request.')
779
+ }
780
+ if (params.granted.basketAccess?.some(g => !requestedPermissions.basketAccess?.find(r => deepEqual(r, g)))) {
781
+ throw new Error('Granted basket access permissions are not a subset of the original request.')
782
+ }
783
+ if (
784
+ params.granted.certificateAccess?.some(g => !requestedPermissions.certificateAccess?.find(r => deepEqual(r, g)))
785
+ ) {
786
+ throw new Error('Granted certificate access permissions are not a subset of the original request.')
787
+ }
788
+ // --- End Validation ---
789
+
790
+ const expiry = params.expiry || 0 // default: never expires
791
+
792
+ if (params.granted.spendingAuthorization) {
793
+ await this.createPermissionOnChain(
794
+ {
795
+ type: 'spending',
796
+ originator,
797
+ spending: { satoshis: params.granted.spendingAuthorization.amount },
798
+ reason: params.granted.spendingAuthorization.description
799
+ },
800
+ 0, // No expiry for spending tokens
801
+ params.granted.spendingAuthorization.amount
802
+ )
803
+ }
804
+ for (const p of params.granted.protocolPermissions || []) {
805
+ const token = await this.findProtocolToken(
806
+ originator,
807
+ false, // No privileged protocols allowed in groups for added security.
808
+ p.protocolID,
809
+ p.counterparty || 'self',
810
+ true,
811
+ originLookupValues
812
+ )
813
+ if (token) {
814
+ const request: PermissionRequest = {
815
+ type: 'protocol',
816
+ originator,
817
+ privileged: false, // No privileged protocols allowed in groups for added security.
818
+ protocolID: p.protocolID,
819
+ counterparty: p.counterparty || 'self',
820
+ reason: p.description
821
+ }
822
+ await this.renewPermissionOnChain(token, request, expiry)
823
+ this.markRecentGrant(request)
824
+ } else {
825
+ const request: PermissionRequest = {
826
+ type: 'protocol',
827
+ originator,
828
+ privileged: false, // No privileged protocols allowed in groups for added security.
829
+ protocolID: p.protocolID,
830
+ counterparty: p.counterparty || 'self',
831
+ reason: p.description
832
+ }
833
+ await this.createPermissionOnChain(request, expiry)
834
+ this.markRecentGrant(request)
835
+ }
836
+ }
837
+ for (const b of params.granted.basketAccess || []) {
838
+ const request: PermissionRequest = { type: 'basket', originator, basket: b.basket, reason: b.description }
839
+ await this.createPermissionOnChain(request, expiry)
840
+ this.markRecentGrant(request)
841
+ }
842
+ for (const c of params.granted.certificateAccess || []) {
843
+ const request: PermissionRequest = {
844
+ type: 'certificate',
845
+ originator,
846
+ privileged: false, // No certificates on the privileged identity are allowed as part of groups.
847
+ certificate: {
848
+ verifier: c.verifierPublicKey,
849
+ certType: c.type,
850
+ fields: c.fields
851
+ },
852
+ reason: c.description
853
+ }
854
+ await this.createPermissionOnChain(request, expiry)
855
+ this.markRecentGrant(request)
856
+ }
857
+
858
+ // Resolve all pending promises for this request
859
+ for (const p of matching.pending) {
860
+ p.resolve(true)
861
+ }
862
+ this.activeRequests.delete(params.requestID)
863
+ }
864
+
865
+ /**
866
+ * Denies a previously requested grouped permission.
867
+ * @param requestID The ID of the request being denied.
868
+ */
869
+ public async denyGroupedPermission(requestID: string): Promise<void> {
870
+ const matching = this.activeRequests.get(requestID)
871
+ if (!matching) {
872
+ throw new Error('Request ID not found.')
873
+ }
874
+ const err = new Error('The user has denied the request for permission.')
875
+ ;(err as any).code = 'ERR_PERMISSION_DENIED'
876
+ for (const p of matching.pending) {
877
+ p.reject(err)
878
+ }
879
+ this.activeRequests.delete(requestID)
880
+ }
881
+
882
+ /* ---------------------------------------------------------------------
883
+ * 3) THE "ENSURE" METHODS: CHECK IF PERMISSION EXISTS, OTHERWISE PROMPT
884
+ * --------------------------------------------------------------------- */
885
+
886
+ /**
887
+ * Ensures the originator has protocol usage permission.
888
+ * If no valid (unexpired) permission token is found, triggers a permission request flow.
889
+ */
890
+ public async ensureProtocolPermission({
891
+ originator,
892
+ privileged,
893
+ protocolID,
894
+ counterparty,
895
+ reason,
896
+ seekPermission = true,
897
+ usageType
898
+ }: {
899
+ originator: string
900
+ privileged: boolean
901
+ protocolID: WalletProtocol
902
+ counterparty: string
903
+ reason?: string
904
+ seekPermission?: boolean
905
+ usageType: 'signing' | 'encrypting' | 'hmac' | 'publicKey' | 'identityKey' | 'linkageRevelation' | 'generic'
906
+ }): Promise<boolean> {
907
+ const { normalized: normalizedOriginator, lookupValues } = this.prepareOriginator(originator)
908
+ originator = normalizedOriginator
909
+ // 1) adminOriginator can do anything
910
+ if (this.isAdminOriginator(originator)) return true
911
+
912
+ // 2) If security level=0, we consider it "open" usage
913
+ const [level, protoName] = protocolID
914
+ if (level === 0) return true
915
+
916
+ // 3) If protocol is admin-reserved, block
917
+ if (this.isAdminProtocol(protocolID)) {
918
+ throw new Error(`Protocol “${protoName}” is admin-only.`)
919
+ }
920
+
921
+ // Allow the configured exceptions.
922
+ if (usageType === 'signing' && !this.config.seekProtocolPermissionsForSigning) {
923
+ return true
924
+ }
925
+ if (usageType === 'encrypting' && !this.config.seekProtocolPermissionsForEncrypting) {
926
+ return true
927
+ }
928
+ if (usageType === 'hmac' && !this.config.seekProtocolPermissionsForHMAC) {
929
+ return true
930
+ }
931
+ if (usageType === 'publicKey' && !this.config.seekPermissionsForPublicKeyRevelation) {
932
+ return true
933
+ }
934
+ if (usageType === 'identityKey' && !this.config.seekPermissionsForIdentityKeyRevelation) {
935
+ return true
936
+ }
937
+ if (usageType === 'linkageRevelation' && !this.config.seekPermissionsForKeyLinkageRevelation) {
938
+ return true
939
+ }
940
+ if (!this.config.differentiatePrivilegedOperations) {
941
+ privileged = false
942
+ }
943
+
944
+ const cacheKey = this.buildRequestKey({
945
+ type: 'protocol',
946
+ originator,
947
+ privileged,
948
+ protocolID,
949
+ counterparty
950
+ })
951
+ if (this.isPermissionCached(cacheKey)) {
952
+ return true
953
+ }
954
+ if (this.isRecentlyGranted(cacheKey)) {
955
+ return true
956
+ }
957
+
958
+ // 4) Attempt to find a valid token in the internal basket
959
+ const token = await this.findProtocolToken(
960
+ originator,
961
+ privileged,
962
+ protocolID,
963
+ counterparty,
964
+ /*includeExpired=*/ true,
965
+ lookupValues
966
+ )
967
+ if (token) {
968
+ if (!this.isTokenExpired(token.expiry)) {
969
+ // valid and unexpired
970
+ this.cachePermission(cacheKey, token.expiry)
971
+ return true
972
+ } else {
973
+ // has a token but expired => request renewal if allowed
974
+ if (!seekPermission) {
975
+ throw new Error(`Protocol permission expired and no further user consent allowed (seekPermission=false).`)
976
+ }
977
+ return await this.requestPermissionFlow({
978
+ type: 'protocol',
979
+ originator,
980
+ privileged,
981
+ protocolID,
982
+ counterparty,
983
+ reason,
984
+ renewal: true,
985
+ previousToken: token
986
+ })
987
+ }
988
+ } else {
989
+ // No token found => request a new one if allowed
990
+ if (!seekPermission) {
991
+ throw new Error(`No protocol permission token found (seekPermission=false).`)
992
+ }
993
+ const granted = await this.requestPermissionFlow({
994
+ type: 'protocol',
995
+ originator,
996
+ privileged,
997
+ protocolID,
998
+ counterparty,
999
+ reason,
1000
+ renewal: false
1001
+ })
1002
+ return granted
1003
+ }
1004
+ }
1005
+
1006
+ /**
1007
+ * Ensures the originator has basket usage permission for the specified basket.
1008
+ * If not, triggers a permission request flow.
1009
+ */
1010
+ public async ensureBasketAccess({
1011
+ originator,
1012
+ basket,
1013
+ reason,
1014
+ seekPermission = true,
1015
+ usageType
1016
+ }: {
1017
+ originator: string
1018
+ basket: string
1019
+ reason?: string
1020
+ seekPermission?: boolean
1021
+ usageType: 'insertion' | 'removal' | 'listing'
1022
+ }): Promise<boolean> {
1023
+ const { normalized: normalizedOriginator, lookupValues } = this.prepareOriginator(originator)
1024
+ originator = normalizedOriginator
1025
+ if (this.isAdminOriginator(originator)) return true
1026
+ if (this.isAdminBasket(basket)) {
1027
+ throw new Error(`Basket “${basket}” is admin-only.`)
1028
+ }
1029
+ if (usageType === 'insertion' && !this.config.seekBasketInsertionPermissions) return true
1030
+ if (usageType === 'removal' && !this.config.seekBasketRemovalPermissions) return true
1031
+ if (usageType === 'listing' && !this.config.seekBasketListingPermissions) return true
1032
+ const cacheKey = this.buildRequestKey({ type: 'basket', originator, basket })
1033
+ if (this.isPermissionCached(cacheKey)) {
1034
+ return true
1035
+ }
1036
+ if (this.isRecentlyGranted(cacheKey)) {
1037
+ return true
1038
+ }
1039
+
1040
+ const token = await this.findBasketToken(originator, basket, true, lookupValues)
1041
+ if (token) {
1042
+ if (!this.isTokenExpired(token.expiry)) {
1043
+ this.cachePermission(cacheKey, token.expiry)
1044
+ return true
1045
+ } else {
1046
+ if (!seekPermission) {
1047
+ throw new Error(`Basket permission expired (seekPermission=false).`)
1048
+ }
1049
+ return await this.requestPermissionFlow({
1050
+ type: 'basket',
1051
+ originator,
1052
+ basket,
1053
+ reason,
1054
+ renewal: true,
1055
+ previousToken: token
1056
+ })
1057
+ }
1058
+ } else {
1059
+ // none
1060
+ if (!seekPermission) {
1061
+ throw new Error(`No basket permission found, and no user consent allowed (seekPermission=false).`)
1062
+ }
1063
+ const granted = await this.requestPermissionFlow({
1064
+ type: 'basket',
1065
+ originator,
1066
+ basket,
1067
+ reason,
1068
+ renewal: false
1069
+ })
1070
+ return granted
1071
+ }
1072
+ }
1073
+
1074
+ /**
1075
+ * Ensures the originator has a valid certificate permission.
1076
+ * This is relevant when revealing certificate fields in DCAP contexts.
1077
+ */
1078
+ public async ensureCertificateAccess({
1079
+ originator,
1080
+ privileged,
1081
+ verifier,
1082
+ certType,
1083
+ fields,
1084
+ reason,
1085
+ seekPermission = true,
1086
+ usageType
1087
+ }: {
1088
+ originator: string
1089
+ privileged: boolean
1090
+ verifier: string
1091
+ certType: string
1092
+ fields: string[]
1093
+ reason?: string
1094
+ seekPermission?: boolean
1095
+ usageType: 'disclosure'
1096
+ }): Promise<boolean> {
1097
+ const { normalized: normalizedOriginator, lookupValues } = this.prepareOriginator(originator)
1098
+ originator = normalizedOriginator
1099
+ if (this.isAdminOriginator(originator)) return true
1100
+ if (usageType === 'disclosure' && !this.config.seekCertificateDisclosurePermissions) {
1101
+ return true
1102
+ }
1103
+ if (!this.config.differentiatePrivilegedOperations) {
1104
+ privileged = false
1105
+ }
1106
+ const cacheKey = this.buildRequestKey({
1107
+ type: 'certificate',
1108
+ originator,
1109
+ privileged,
1110
+ certificate: { verifier, certType, fields }
1111
+ })
1112
+ if (this.isPermissionCached(cacheKey)) {
1113
+ return true
1114
+ }
1115
+ if (this.isRecentlyGranted(cacheKey)) {
1116
+ return true
1117
+ }
1118
+
1119
+ const token = await this.findCertificateToken(
1120
+ originator,
1121
+ privileged,
1122
+ verifier,
1123
+ certType,
1124
+ fields,
1125
+ /*includeExpired=*/ true,
1126
+ lookupValues
1127
+ )
1128
+ if (token) {
1129
+ if (!this.isTokenExpired(token.expiry)) {
1130
+ this.cachePermission(cacheKey, token.expiry)
1131
+ return true
1132
+ } else {
1133
+ if (!seekPermission) {
1134
+ throw new Error(`Certificate permission expired (seekPermission=false).`)
1135
+ }
1136
+ return await this.requestPermissionFlow({
1137
+ type: 'certificate',
1138
+ originator,
1139
+ privileged,
1140
+ certificate: { verifier, certType, fields },
1141
+ reason,
1142
+ renewal: true,
1143
+ previousToken: token
1144
+ })
1145
+ }
1146
+ } else {
1147
+ if (!seekPermission) {
1148
+ throw new Error(`No certificate permission found (seekPermission=false).`)
1149
+ }
1150
+ const granted = await this.requestPermissionFlow({
1151
+ type: 'certificate',
1152
+ originator,
1153
+ privileged,
1154
+ certificate: { verifier, certType, fields },
1155
+ reason,
1156
+ renewal: false
1157
+ })
1158
+ return granted
1159
+ }
1160
+ }
1161
+
1162
+ /**
1163
+ * Ensures the originator has spending authorization (DSAP) for a certain satoshi amount.
1164
+ * If the existing token limit is insufficient, attempts to renew. If no token, attempts to create one.
1165
+ */
1166
+ public async ensureSpendingAuthorization({
1167
+ originator,
1168
+ satoshis,
1169
+ lineItems,
1170
+ reason,
1171
+ seekPermission = true
1172
+ }: {
1173
+ originator: string
1174
+ satoshis: number
1175
+ lineItems?: Array<{
1176
+ type: 'input' | 'output' | 'fee'
1177
+ description: string
1178
+ satoshis: number
1179
+ }>
1180
+ reason?: string
1181
+ seekPermission?: boolean
1182
+ }): Promise<boolean> {
1183
+ const { normalized: normalizedOriginator, lookupValues } = this.prepareOriginator(originator)
1184
+ originator = normalizedOriginator
1185
+ if (this.isAdminOriginator(originator)) return true
1186
+ if (!this.config.seekSpendingPermissions) {
1187
+ // We skip spending permission entirely
1188
+ return true
1189
+ }
1190
+ const cacheKey = this.buildRequestKey({ type: 'spending', originator, spending: { satoshis } })
1191
+ if (this.isPermissionCached(cacheKey)) {
1192
+ return true
1193
+ }
1194
+ const token = await this.findSpendingToken(originator, lookupValues)
1195
+ if (token?.authorizedAmount) {
1196
+ // Check how much has been spent so far
1197
+ const spentSoFar = await this.querySpentSince(token)
1198
+ if (spentSoFar + satoshis <= token.authorizedAmount) {
1199
+ this.cachePermission(cacheKey, token.expiry)
1200
+ return true
1201
+ } else {
1202
+ // Renew if possible
1203
+ if (!seekPermission) {
1204
+ throw new Error(
1205
+ `Spending authorization insufficient for ${satoshis}, no user consent (seekPermission=false).`
1206
+ )
1207
+ }
1208
+ return await this.requestPermissionFlow({
1209
+ type: 'spending',
1210
+ originator,
1211
+ spending: { satoshis, lineItems },
1212
+ reason,
1213
+ renewal: true,
1214
+ previousToken: token
1215
+ })
1216
+ }
1217
+ } else {
1218
+ // no token
1219
+ if (!seekPermission) {
1220
+ throw new Error(`No spending authorization found, (seekPermission=false).`)
1221
+ }
1222
+ return await this.requestPermissionFlow({
1223
+ type: 'spending',
1224
+ originator,
1225
+ spending: { satoshis, lineItems },
1226
+ reason,
1227
+ renewal: false
1228
+ })
1229
+ }
1230
+ }
1231
+
1232
+ /**
1233
+ * Ensures the originator has label usage permission.
1234
+ * If no valid (unexpired) permission token is found, triggers a permission request flow.
1235
+ */
1236
+ public async ensureLabelAccess({
1237
+ originator,
1238
+ label,
1239
+ reason,
1240
+ seekPermission = true,
1241
+ usageType
1242
+ }: {
1243
+ originator: string
1244
+ label: string
1245
+ reason?: string
1246
+ seekPermission?: boolean
1247
+ usageType: 'apply' | 'list'
1248
+ }): Promise<boolean> {
1249
+ const { normalized: normalizedOriginator } = this.prepareOriginator(originator)
1250
+ originator = normalizedOriginator
1251
+ // 1) adminOriginator can do anything
1252
+ if (this.isAdminOriginator(originator)) return true
1253
+
1254
+ // 2) If label is admin-reserved, block
1255
+ if (this.isAdminLabel(label)) {
1256
+ throw new Error(`Label “${label}” is admin-only.`)
1257
+ }
1258
+
1259
+ if (usageType === 'apply' && !this.config.seekPermissionWhenApplyingActionLabels) {
1260
+ return true
1261
+ }
1262
+ if (usageType === 'list' && !this.config.seekPermissionWhenListingActionsByLabel) {
1263
+ return true
1264
+ }
1265
+
1266
+ const cacheKey = this.buildRequestKey({
1267
+ type: 'protocol',
1268
+ originator,
1269
+ privileged: false,
1270
+ protocolID: [1, `action label ${label}`],
1271
+ counterparty: 'self'
1272
+ })
1273
+ if (this.isPermissionCached(cacheKey)) {
1274
+ return true
1275
+ }
1276
+
1277
+ // 3) Let ensureProtocolPermission handle the rest.
1278
+ return await this.ensureProtocolPermission({
1279
+ originator,
1280
+ privileged: false,
1281
+ protocolID: [1, `action label ${label}`],
1282
+ counterparty: 'self',
1283
+ reason,
1284
+ seekPermission,
1285
+ usageType: 'generic'
1286
+ })
1287
+ }
1288
+
1289
+ /**
1290
+ * A central method that triggers the permission request flow.
1291
+ * - It checks if there's already an active request for the same key
1292
+ * - If so, we wait on that existing request rather than creating a duplicative one
1293
+ * - Otherwise we create a new request queue, call the relevant "onXXXRequested" event,
1294
+ * and return a promise that resolves once permission is granted or rejects if denied.
1295
+ */
1296
+ private async requestPermissionFlow(r: PermissionRequest): Promise<boolean> {
1297
+ const normalizedOriginator = this.normalizeOriginator(r.originator) || r.originator
1298
+ const preparedRequest: PermissionRequest = {
1299
+ ...r,
1300
+ originator: normalizedOriginator,
1301
+ displayOriginator: r.displayOriginator ?? r.originator
1302
+ }
1303
+ const key = this.buildRequestKey(preparedRequest)
1304
+
1305
+ // If there's already a queue for the same resource, we piggyback on it
1306
+ const existingQueue = this.activeRequests.get(key)
1307
+ if (existingQueue && existingQueue.pending.length > 0) {
1308
+ return new Promise<boolean>((resolve, reject) => {
1309
+ existingQueue.pending.push({ resolve, reject })
1310
+ })
1311
+ }
1312
+
1313
+ // Otherwise, create a new queue with a single entry
1314
+ // Return a promise that resolves or rejects once the user grants/denies
1315
+ return new Promise<boolean>(async (resolve, reject) => {
1316
+ this.activeRequests.set(key, {
1317
+ request: preparedRequest,
1318
+ pending: [{ resolve, reject }]
1319
+ })
1320
+
1321
+ // Fire the relevant onXXXRequested event (which one depends on r.type)
1322
+ switch (preparedRequest.type) {
1323
+ case 'protocol':
1324
+ await this.callEvent('onProtocolPermissionRequested', {
1325
+ ...preparedRequest,
1326
+ requestID: key
1327
+ })
1328
+ break
1329
+ case 'basket':
1330
+ await this.callEvent('onBasketAccessRequested', {
1331
+ ...preparedRequest,
1332
+ requestID: key
1333
+ })
1334
+ break
1335
+ case 'certificate':
1336
+ await this.callEvent('onCertificateAccessRequested', {
1337
+ ...preparedRequest,
1338
+ requestID: key
1339
+ })
1340
+ break
1341
+ case 'spending':
1342
+ await this.callEvent('onSpendingAuthorizationRequested', {
1343
+ ...preparedRequest,
1344
+ requestID: key
1345
+ })
1346
+ break
1347
+ }
1348
+ })
1349
+ }
1350
+
1351
+ /* ---------------------------------------------------------------------
1352
+ * 4) SEARCH / DECODE / DECRYPT ON-CHAIN TOKENS (PushDrop Scripts)
1353
+ * --------------------------------------------------------------------- */
1354
+
1355
+ /**
1356
+ * We will use a administrative "permission token encryption" protocol to store fields
1357
+ * in each permission's PushDrop script. This ensures that only the user's wallet
1358
+ * can decrypt them. In practice, this data is not super sensitive, but we still
1359
+ * follow the principle of least exposure.
1360
+ */
1361
+ private static readonly PERM_TOKEN_ENCRYPTION_PROTOCOL: [2, 'admin permission token encryption'] = [
1362
+ 2,
1363
+ 'admin permission token encryption'
1364
+ ]
1365
+
1366
+ /**
1367
+ * Similarly, we will use a "metadata encryption" protocol to preserve the confidentiality
1368
+ * of transaction descriptions and input/output descriptions from lower storage layers.
1369
+ */
1370
+ private static readonly METADATA_ENCRYPTION_PROTOCOL: [2, 'admin metadata encryption'] = [
1371
+ 2,
1372
+ 'admin metadata encryption'
1373
+ ]
1374
+
1375
+ /** We always use `keyID="1"` and `counterparty="self"` for these encryption ops. */
1376
+ private async encryptPermissionTokenField(plaintext: string | number[]): Promise<number[]> {
1377
+ const data = typeof plaintext === 'string' ? Utils.toArray(plaintext, 'utf8') : plaintext
1378
+ const { ciphertext } = await this.underlying.encrypt(
1379
+ {
1380
+ plaintext: data,
1381
+ protocolID: WalletPermissionsManager.PERM_TOKEN_ENCRYPTION_PROTOCOL,
1382
+ keyID: '1'
1383
+ },
1384
+ this.adminOriginator
1385
+ )
1386
+ return ciphertext
1387
+ }
1388
+
1389
+ private async decryptPermissionTokenField(ciphertext: number[]): Promise<number[]> {
1390
+ try {
1391
+ const { plaintext } = await this.underlying.decrypt(
1392
+ {
1393
+ ciphertext,
1394
+ protocolID: WalletPermissionsManager.PERM_TOKEN_ENCRYPTION_PROTOCOL,
1395
+ keyID: '1'
1396
+ },
1397
+ this.adminOriginator
1398
+ )
1399
+ return plaintext
1400
+ } catch (e) {
1401
+ return ciphertext
1402
+ }
1403
+ }
1404
+
1405
+ /**
1406
+ * Encrypts wallet metadata if configured to do so, otherwise returns the original plaintext for storage.
1407
+ * @param plaintext The metadata to encrypt if configured to do so
1408
+ * @returns The encrypted metadata, or the original value if encryption was disabled.
1409
+ */
1410
+ private async maybeEncryptMetadata(plaintext: string): Promise<Base64String> {
1411
+ if (!this.config.encryptWalletMetadata) {
1412
+ return plaintext
1413
+ }
1414
+ const { ciphertext } = await this.underlying.encrypt(
1415
+ {
1416
+ plaintext: Utils.toArray(plaintext, 'utf8'),
1417
+ protocolID: WalletPermissionsManager.METADATA_ENCRYPTION_PROTOCOL,
1418
+ keyID: '1'
1419
+ },
1420
+ this.adminOriginator
1421
+ )
1422
+ return Utils.toBase64(ciphertext)
1423
+ }
1424
+
1425
+ /**
1426
+ * Attempts to decrypt metadata. if decryption fails, assumes the value is already plaintext and returns it.
1427
+ * @param ciphertext The metadata to attempt decryption for.
1428
+ * @returns The decrypted metadata. If decryption fails, returns the original value instead.
1429
+ */
1430
+ private async maybeDecryptMetadata(ciphertext: Base64String): Promise<string> {
1431
+ try {
1432
+ const { plaintext } = await this.underlying.decrypt(
1433
+ {
1434
+ ciphertext: Utils.toArray(ciphertext, 'base64'),
1435
+ protocolID: WalletPermissionsManager.METADATA_ENCRYPTION_PROTOCOL,
1436
+ keyID: '1'
1437
+ },
1438
+ this.adminOriginator
1439
+ )
1440
+ return Utils.toUTF8(plaintext)
1441
+ } catch (e) {
1442
+ return ciphertext
1443
+ }
1444
+ }
1445
+
1446
+ /** Helper to see if a token's expiry is in the past. */
1447
+ private isTokenExpired(expiry: number): boolean {
1448
+ const now = Math.floor(Date.now() / 1000)
1449
+ return expiry > 0 && expiry < now
1450
+ }
1451
+
1452
+ /** Looks for a DPACP permission token matching origin/domain, privileged, protocol, cpty. */
1453
+ private async findProtocolToken(
1454
+ originator: string,
1455
+ privileged: boolean,
1456
+ protocolID: WalletProtocol,
1457
+ counterparty: string,
1458
+ includeExpired: boolean,
1459
+ originatorLookupValues?: string[]
1460
+ ): Promise<PermissionToken | undefined> {
1461
+ const [secLevel, protoName] = protocolID
1462
+ const originsToTry = originatorLookupValues?.length ? originatorLookupValues : [originator]
1463
+
1464
+ for (const originTag of originsToTry) {
1465
+ const tags = [
1466
+ `originator ${originTag}`,
1467
+ `privileged ${!!privileged}`,
1468
+ `protocolName ${protoName}`,
1469
+ `protocolSecurityLevel ${secLevel}`
1470
+ ]
1471
+ if (secLevel === 2) {
1472
+ tags.push(`counterparty ${counterparty}`)
1473
+ }
1474
+
1475
+ const result = await this.underlying.listOutputs(
1476
+ {
1477
+ basket: BASKET_MAP.protocol,
1478
+ tags,
1479
+ tagQueryMode: 'all',
1480
+ include: 'entire transactions'
1481
+ },
1482
+ this.adminOriginator
1483
+ )
1484
+
1485
+ for (const out of result.outputs) {
1486
+ const [txid, outputIndexStr] = out.outpoint.split('.')
1487
+ const tx = Transaction.fromBEEF(result.BEEF!, txid)
1488
+ const dec = PushDrop.decode(tx.outputs[Number(outputIndexStr)].lockingScript)
1489
+ if (!dec || !dec.fields || dec.fields.length < 6) continue
1490
+ const domainRaw = dec.fields[0]
1491
+ const expiryRaw = dec.fields[1]
1492
+ const privRaw = dec.fields[2]
1493
+ const secLevelRaw = dec.fields[3]
1494
+ const protoNameRaw = dec.fields[4]
1495
+ const counterpartyRaw = dec.fields[5]
1496
+
1497
+ const domainDecoded = Utils.toUTF8(await this.decryptPermissionTokenField(domainRaw))
1498
+ const normalizedDomain = this.normalizeOriginator(domainDecoded)
1499
+ if (normalizedDomain !== originator) {
1500
+ continue
1501
+ }
1502
+
1503
+ const expiryDecoded = parseInt(Utils.toUTF8(await this.decryptPermissionTokenField(expiryRaw)), 10)
1504
+ const privDecoded = Utils.toUTF8(await this.decryptPermissionTokenField(privRaw)) === 'true'
1505
+ const secLevelDecoded = parseInt(Utils.toUTF8(await this.decryptPermissionTokenField(secLevelRaw)), 10) as
1506
+ | 0
1507
+ | 1
1508
+ | 2
1509
+ const protoNameDecoded = Utils.toUTF8(await this.decryptPermissionTokenField(protoNameRaw))
1510
+ const cptyDecoded = Utils.toUTF8(await this.decryptPermissionTokenField(counterpartyRaw))
1511
+
1512
+ if (
1513
+ privDecoded !== !!privileged ||
1514
+ secLevelDecoded !== secLevel ||
1515
+ protoNameDecoded !== protoName ||
1516
+ (secLevelDecoded === 2 && cptyDecoded !== counterparty)
1517
+ ) {
1518
+ continue
1519
+ }
1520
+ if (!includeExpired && this.isTokenExpired(expiryDecoded)) {
1521
+ continue
1522
+ }
1523
+ return {
1524
+ tx: tx.toBEEF(),
1525
+ txid: out.outpoint.split('.')[0],
1526
+ outputIndex: parseInt(out.outpoint.split('.')[1], 10),
1527
+ outputScript: tx.outputs[Number(outputIndexStr)].lockingScript.toHex(),
1528
+ satoshis: out.satoshis,
1529
+ originator,
1530
+ rawOriginator: domainDecoded,
1531
+ privileged,
1532
+ protocol: protoName,
1533
+ securityLevel: secLevel,
1534
+ expiry: expiryDecoded,
1535
+ counterparty: cptyDecoded
1536
+ }
1537
+ }
1538
+ }
1539
+ return undefined
1540
+ }
1541
+
1542
+ /** Finds ALL DPACP permission tokens matching origin/domain, privileged, protocol, cpty. Never filters by expiry. */
1543
+ private async findAllProtocolTokens(
1544
+ originator: string,
1545
+ privileged: boolean,
1546
+ protocolID: WalletProtocol,
1547
+ counterparty: string,
1548
+ originatorLookupValues?: string[]
1549
+ ): Promise<PermissionToken[]> {
1550
+ const [secLevel, protoName] = protocolID
1551
+ const originsToTry = originatorLookupValues?.length ? originatorLookupValues : [originator]
1552
+ const matches: PermissionToken[] = []
1553
+ const seen = new Set<string>()
1554
+
1555
+ for (const originTag of originsToTry) {
1556
+ const tags = [
1557
+ `originator ${originTag}`,
1558
+ `privileged ${!!privileged}`,
1559
+ `protocolName ${protoName}`,
1560
+ `protocolSecurityLevel ${secLevel}`
1561
+ ]
1562
+ if (secLevel === 2) {
1563
+ tags.push(`counterparty ${counterparty}`)
1564
+ }
1565
+
1566
+ const result = await this.underlying.listOutputs(
1567
+ {
1568
+ basket: BASKET_MAP.protocol,
1569
+ tags,
1570
+ tagQueryMode: 'all',
1571
+ include: 'entire transactions'
1572
+ },
1573
+ this.adminOriginator
1574
+ )
1575
+
1576
+ for (const out of result.outputs) {
1577
+ if (seen.has(out.outpoint)) continue
1578
+ const [txid, outputIndexStr] = out.outpoint.split('.')
1579
+ const tx = Transaction.fromBEEF(result.BEEF!, txid)
1580
+ const vout = Number(outputIndexStr)
1581
+ const dec = PushDrop.decode(tx.outputs[vout].lockingScript)
1582
+ if (!dec || !dec.fields || dec.fields.length < 6) continue
1583
+
1584
+ const domainRaw = dec.fields[0]
1585
+ const expiryRaw = dec.fields[1]
1586
+ const privRaw = dec.fields[2]
1587
+ const secLevelRaw = dec.fields[3]
1588
+ const protoNameRaw = dec.fields[4]
1589
+ const counterpartyRaw = dec.fields[5]
1590
+
1591
+ const domainDecoded = Utils.toUTF8(await this.decryptPermissionTokenField(domainRaw))
1592
+ const normalizedDomain = this.normalizeOriginator(domainDecoded)
1593
+ if (normalizedDomain !== originator) {
1594
+ continue
1595
+ }
1596
+
1597
+ const expiryDecoded = parseInt(Utils.toUTF8(await this.decryptPermissionTokenField(expiryRaw)), 10)
1598
+ const privDecoded = Utils.toUTF8(await this.decryptPermissionTokenField(privRaw)) === 'true'
1599
+ const secLevelDecoded = parseInt(Utils.toUTF8(await this.decryptPermissionTokenField(secLevelRaw)), 10) as
1600
+ | 0
1601
+ | 1
1602
+ | 2
1603
+ const protoNameDecoded = Utils.toUTF8(await this.decryptPermissionTokenField(protoNameRaw))
1604
+ const cptyDecoded = Utils.toUTF8(await this.decryptPermissionTokenField(counterpartyRaw))
1605
+
1606
+ if (
1607
+ privDecoded !== !!privileged ||
1608
+ secLevelDecoded !== secLevel ||
1609
+ protoNameDecoded !== protoName ||
1610
+ (secLevelDecoded === 2 && cptyDecoded !== counterparty)
1611
+ ) {
1612
+ continue
1613
+ }
1614
+
1615
+ seen.add(out.outpoint)
1616
+ matches.push({
1617
+ tx: tx.toBEEF(),
1618
+ txid,
1619
+ outputIndex: vout,
1620
+ outputScript: tx.outputs[vout].lockingScript.toHex(),
1621
+ satoshis: out.satoshis,
1622
+ originator,
1623
+ rawOriginator: domainDecoded,
1624
+ privileged,
1625
+ protocol: protoName,
1626
+ securityLevel: secLevel,
1627
+ expiry: expiryDecoded,
1628
+ counterparty: cptyDecoded
1629
+ })
1630
+ }
1631
+ }
1632
+
1633
+ return matches
1634
+ }
1635
+ /** Looks for a DBAP token matching (originator, basket). */
1636
+ private async findBasketToken(
1637
+ originator: string,
1638
+ basket: string,
1639
+ includeExpired: boolean,
1640
+ originatorLookupValues?: string[]
1641
+ ): Promise<PermissionToken | undefined> {
1642
+ const originsToTry = originatorLookupValues?.length ? originatorLookupValues : [originator]
1643
+
1644
+ for (const originTag of originsToTry) {
1645
+ const result = await this.underlying.listOutputs(
1646
+ {
1647
+ basket: BASKET_MAP.basket,
1648
+ tags: [`originator ${originTag}`, `basket ${basket}`],
1649
+ tagQueryMode: 'all',
1650
+ include: 'entire transactions'
1651
+ },
1652
+ this.adminOriginator
1653
+ )
1654
+
1655
+ for (const out of result.outputs) {
1656
+ const [txid, outputIndexStr] = out.outpoint.split('.')
1657
+ const tx = Transaction.fromBEEF(result.BEEF!, txid)
1658
+ const dec = PushDrop.decode(tx.outputs[Number(outputIndexStr)].lockingScript)
1659
+ if (!dec?.fields || dec.fields.length < 3) continue
1660
+ const domainRaw = dec.fields[0]
1661
+ const expiryRaw = dec.fields[1]
1662
+ const basketRaw = dec.fields[2]
1663
+
1664
+ const domainDecoded = Utils.toUTF8(await this.decryptPermissionTokenField(domainRaw))
1665
+ const normalizedDomain = this.normalizeOriginator(domainDecoded)
1666
+ if (normalizedDomain !== originator) {
1667
+ continue
1668
+ }
1669
+
1670
+ const expiryDecoded = parseInt(Utils.toUTF8(await this.decryptPermissionTokenField(expiryRaw)), 10)
1671
+ const basketDecoded = Utils.toUTF8(await this.decryptPermissionTokenField(basketRaw))
1672
+ if (basketDecoded !== basket) continue
1673
+ if (!includeExpired && this.isTokenExpired(expiryDecoded)) continue
1674
+
1675
+ return {
1676
+ tx: tx.toBEEF(),
1677
+ txid: out.outpoint.split('.')[0],
1678
+ outputIndex: parseInt(out.outpoint.split('.')[1], 10),
1679
+ outputScript: tx.outputs[Number(outputIndexStr)].lockingScript.toHex(),
1680
+ satoshis: out.satoshis,
1681
+ originator,
1682
+ rawOriginator: domainDecoded,
1683
+ basketName: basketDecoded,
1684
+ expiry: expiryDecoded
1685
+ }
1686
+ }
1687
+ }
1688
+ return undefined
1689
+ }
1690
+
1691
+ /** Looks for a DCAP token matching (origin, privileged, verifier, certType, fields subset). */
1692
+ private async findCertificateToken(
1693
+ originator: string,
1694
+ privileged: boolean,
1695
+ verifier: string,
1696
+ certType: string,
1697
+ fields: string[],
1698
+ includeExpired: boolean,
1699
+ originatorLookupValues?: string[]
1700
+ ): Promise<PermissionToken | undefined> {
1701
+ const originsToTry = originatorLookupValues?.length ? originatorLookupValues : [originator]
1702
+
1703
+ for (const originTag of originsToTry) {
1704
+ const result = await this.underlying.listOutputs(
1705
+ {
1706
+ basket: BASKET_MAP.certificate,
1707
+ tags: [`originator ${originTag}`, `privileged ${!!privileged}`, `type ${certType}`, `verifier ${verifier}`],
1708
+ tagQueryMode: 'all',
1709
+ include: 'entire transactions'
1710
+ },
1711
+ this.adminOriginator
1712
+ )
1713
+
1714
+ for (const out of result.outputs) {
1715
+ const [txid, outputIndexStr] = out.outpoint.split('.')
1716
+ const tx = Transaction.fromBEEF(result.BEEF!, txid)
1717
+ const dec = PushDrop.decode(tx.outputs[Number(outputIndexStr)].lockingScript)
1718
+ if (!dec?.fields || dec.fields.length < 6) continue
1719
+ const [domainRaw, expiryRaw, privRaw, typeRaw, fieldsRaw, verifierRaw] = dec.fields
1720
+
1721
+ const domainDecoded = Utils.toUTF8(await this.decryptPermissionTokenField(domainRaw))
1722
+ const normalizedDomain = this.normalizeOriginator(domainDecoded)
1723
+ if (normalizedDomain !== originator) {
1724
+ continue
1725
+ }
1726
+
1727
+ const expiryDecoded = parseInt(Utils.toUTF8(await this.decryptPermissionTokenField(expiryRaw)), 10)
1728
+ const privDecoded = Utils.toUTF8(await this.decryptPermissionTokenField(privRaw)) === 'true'
1729
+ const typeDecoded = Utils.toUTF8(await this.decryptPermissionTokenField(typeRaw))
1730
+ const verifierDec = Utils.toUTF8(await this.decryptPermissionTokenField(verifierRaw))
1731
+
1732
+ const fieldsJson = await this.decryptPermissionTokenField(fieldsRaw)
1733
+ const allFields = JSON.parse(Utils.toUTF8(fieldsJson)) as string[]
1734
+
1735
+ if (privDecoded !== !!privileged || typeDecoded !== certType || verifierDec !== verifier) {
1736
+ continue
1737
+ }
1738
+ // Check if 'fields' is a subset of 'allFields'
1739
+ const setAll = new Set(allFields)
1740
+ if (fields.some(f => !setAll.has(f))) {
1741
+ continue
1742
+ }
1743
+ if (!includeExpired && this.isTokenExpired(expiryDecoded)) {
1744
+ continue
1745
+ }
1746
+ return {
1747
+ tx: tx.toBEEF(),
1748
+ txid: out.outpoint.split('.')[0],
1749
+ outputIndex: parseInt(out.outpoint.split('.')[1], 10),
1750
+ outputScript: tx.outputs[Number(outputIndexStr)].lockingScript.toHex(),
1751
+ satoshis: out.satoshis,
1752
+ originator,
1753
+ rawOriginator: domainDecoded,
1754
+ privileged,
1755
+ verifier: verifierDec,
1756
+ certType: typeDecoded,
1757
+ certFields: allFields,
1758
+ expiry: expiryDecoded
1759
+ }
1760
+ }
1761
+ }
1762
+ return undefined
1763
+ }
1764
+
1765
+ /** Looks for a DSAP token matching origin, returning the first one found. */
1766
+ private async findSpendingToken(
1767
+ originator: string,
1768
+ originatorLookupValues?: string[]
1769
+ ): Promise<PermissionToken | undefined> {
1770
+ const originsToTry = originatorLookupValues?.length ? originatorLookupValues : [originator]
1771
+
1772
+ for (const originTag of originsToTry) {
1773
+ const result = await this.underlying.listOutputs(
1774
+ {
1775
+ basket: BASKET_MAP.spending,
1776
+ tags: [`originator ${originTag}`],
1777
+ tagQueryMode: 'all',
1778
+ include: 'entire transactions'
1779
+ },
1780
+ this.adminOriginator
1781
+ )
1782
+
1783
+ for (const out of result.outputs) {
1784
+ const [txid, outputIndexStr] = out.outpoint.split('.')
1785
+ const tx = Transaction.fromBEEF(result.BEEF!, txid)
1786
+ const dec = PushDrop.decode(tx.outputs[Number(outputIndexStr)].lockingScript)
1787
+ if (!dec?.fields || dec.fields.length < 2) continue
1788
+ const domainRaw = dec.fields[0]
1789
+ const amtRaw = dec.fields[1]
1790
+
1791
+ const domainDecoded = Utils.toUTF8(await this.decryptPermissionTokenField(domainRaw))
1792
+ const normalizedDomain = this.normalizeOriginator(domainDecoded)
1793
+ if (normalizedDomain !== originator) continue
1794
+ const amtDecodedStr = Utils.toUTF8(await this.decryptPermissionTokenField(amtRaw))
1795
+ const authorizedAmount = parseInt(amtDecodedStr, 10)
1796
+
1797
+ return {
1798
+ tx: tx.toBEEF(),
1799
+ txid: out.outpoint.split('.')[0],
1800
+ outputIndex: parseInt(out.outpoint.split('.')[1], 10),
1801
+ outputScript: tx.outputs[Number(outputIndexStr)].lockingScript.toHex(),
1802
+ satoshis: out.satoshis,
1803
+ originator,
1804
+ rawOriginator: domainDecoded,
1805
+ authorizedAmount,
1806
+ expiry: 0 // Not time-limited, monthly authorization
1807
+ }
1808
+ }
1809
+ }
1810
+ return undefined
1811
+ }
1812
+
1813
+ /**
1814
+ * Returns the current month and year in UTC as a string in the format "YYYY-MM".
1815
+ *
1816
+ * @returns {string} The current month and year in UTC.
1817
+ */
1818
+ private getCurrentMonthYearUTC(): string {
1819
+ const now = new Date()
1820
+ const year = now.getUTCFullYear()
1821
+ const month = (now.getUTCMonth() + 1).toString().padStart(2, '0') // Ensure 2-digit month
1822
+ return `${year}-${month}`
1823
+ }
1824
+
1825
+ /**
1826
+ * Returns spending for an originator in the current calendar month.
1827
+ */
1828
+ public async querySpentSince(token: PermissionToken): Promise<number> {
1829
+ const labelOrigins = this.buildOriginatorLookupValues(token.rawOriginator, token.originator)
1830
+ let total = 0
1831
+
1832
+ for (const labelOrigin of labelOrigins) {
1833
+ const { actions } = await this.underlying.listActions(
1834
+ {
1835
+ labels: [`admin originator ${labelOrigin}`, `admin month ${this.getCurrentMonthYearUTC()}`],
1836
+ labelQueryMode: 'all'
1837
+ },
1838
+ this.adminOriginator
1839
+ )
1840
+ total += actions.reduce((a, e) => a + e.satoshis, 0)
1841
+ }
1842
+
1843
+ return total
1844
+ }
1845
+
1846
+ /* ---------------------------------------------------------------------
1847
+ * 5) CREATE / RENEW / REVOKE PERMISSION TOKENS ON CHAIN
1848
+ * --------------------------------------------------------------------- */
1849
+
1850
+ /**
1851
+ * Creates a brand-new permission token as a single-output PushDrop script in the relevant admin basket.
1852
+ *
1853
+ * The main difference between each type of token is in the "fields" we store in the PushDrop script.
1854
+ *
1855
+ * @param r The permission request
1856
+ * @param expiry The expiry epoch time
1857
+ * @param amount For DSAP, the authorized spending limit
1858
+ */
1859
+ private async createPermissionOnChain(r: PermissionRequest, expiry: number, amount?: number): Promise<void> {
1860
+ const normalizedOriginator = this.normalizeOriginator(r.originator) || r.originator
1861
+ r.originator = normalizedOriginator
1862
+ const basketName = BASKET_MAP[r.type]
1863
+ if (!basketName) return
1864
+
1865
+ // Build the array of encrypted fields for the PushDrop script
1866
+ const fields: number[][] = await this.buildPushdropFields(r, expiry, amount)
1867
+
1868
+ // Construct the script. We do a simple P2PK check. We ask `PushDrop.lock(...)`
1869
+ // to create a script with a single OP_CHECKSIG verifying ownership to redeem.
1870
+ const script = await new PushDrop(this.underlying).lock(
1871
+ fields,
1872
+ WalletPermissionsManager.PERM_TOKEN_ENCRYPTION_PROTOCOL,
1873
+ '1',
1874
+ 'self',
1875
+ true,
1876
+ true
1877
+ )
1878
+
1879
+ // Create tags
1880
+ const tags = this.buildTagsForRequest(r)
1881
+
1882
+ // Build a transaction with exactly one output, no explicit inputs since the wallet
1883
+ // can internally fund it from its balance.
1884
+ await this.createAction(
1885
+ {
1886
+ description: `Grant ${r.type} permission`,
1887
+ outputs: [
1888
+ {
1889
+ lockingScript: script.toHex(),
1890
+ satoshis: 1,
1891
+ outputDescription: `${r.type} permission token`,
1892
+ basket: basketName,
1893
+ tags
1894
+ }
1895
+ ],
1896
+ options: {
1897
+ acceptDelayedBroadcast: false
1898
+ }
1899
+ },
1900
+ this.adminOriginator
1901
+ )
1902
+ }
1903
+
1904
+ private async coalescePermissionTokens(
1905
+ oldTokens: PermissionToken[],
1906
+ newScript: LockingScript,
1907
+ opts?: {
1908
+ tags?: string[]
1909
+ basket?: string
1910
+ description?: string
1911
+ }
1912
+ ): Promise<string> {
1913
+ if (!oldTokens?.length) throw new Error('No permission tokens to coalesce')
1914
+ if (oldTokens.length < 2) throw new Error('Need at least 2 tokens to coalesce')
1915
+ // 1) Create a signable action with N inputs and a single renewed output
1916
+ // Merge all input token BEEFs into a single BEEF structure
1917
+ const inputBeef = new Beef()
1918
+ for (const token of oldTokens) {
1919
+ inputBeef.mergeBeef(Beef.fromBinary(token.tx))
1920
+ }
1921
+
1922
+ const { signableTransaction } = await this.createAction(
1923
+ {
1924
+ description: opts?.description ?? `Coalesce ${oldTokens.length} permission tokens`,
1925
+ inputBEEF: inputBeef.toBinary(),
1926
+ inputs: oldTokens.map((t, i) => ({
1927
+ outpoint: `${t.txid}.${t.outputIndex}`,
1928
+ unlockingScriptLength: 74,
1929
+ inputDescription: `Consume permission token #${i + 1}`
1930
+ })),
1931
+ outputs: [
1932
+ {
1933
+ lockingScript: newScript.toHex(),
1934
+ satoshis: 1,
1935
+ outputDescription: 'Renewed permission token',
1936
+ ...(opts?.basket ? { basket: opts.basket } : {}),
1937
+ ...(opts?.tags ? { tags: opts.tags } : {})
1938
+ }
1939
+ ],
1940
+ options: {
1941
+ acceptDelayedBroadcast: false,
1942
+ randomizeOutputs: false,
1943
+ signAndProcess: false
1944
+ }
1945
+ },
1946
+ this.adminOriginator
1947
+ )
1948
+
1949
+ if (!signableTransaction?.reference || !signableTransaction.tx) {
1950
+ throw new Error('Failed to create signable transaction')
1951
+ }
1952
+
1953
+ // 2) Sign each input - each token needs its own unlocker with the correct locking script
1954
+ const partialTx = Transaction.fromAtomicBEEF(signableTransaction.tx)
1955
+ const pushdrop = new PushDrop(this.underlying)
1956
+
1957
+ const spends: Record<number, { unlockingScript: string }> = {}
1958
+ for (let i = 0; i < oldTokens.length; i++) {
1959
+ const token = oldTokens[i]
1960
+ // Each token requires its own unlocker with the specific locking script
1961
+ const unlocker = pushdrop.unlock(
1962
+ WalletPermissionsManager.PERM_TOKEN_ENCRYPTION_PROTOCOL,
1963
+ '1',
1964
+ 'self',
1965
+ 'all',
1966
+ false,
1967
+ 1,
1968
+ LockingScript.fromHex(token.outputScript)
1969
+ )
1970
+ const unlockingScript = await unlocker.sign(partialTx, i)
1971
+ spends[i] = { unlockingScript: unlockingScript.toHex() }
1972
+ }
1973
+
1974
+ // 3) Finalize the action
1975
+ const { txid } = await this.underlying.signAction({
1976
+ reference: signableTransaction.reference,
1977
+ spends
1978
+ })
1979
+ if (!txid) throw new Error('Failed to finalize coalescing transaction')
1980
+ return txid
1981
+ }
1982
+ /**
1983
+ * Renews a permission token by spending the old token as input and creating a new token output.
1984
+ * This invalidates the old token and replaces it with a new one.
1985
+ *
1986
+ * @param oldToken The old token to consume
1987
+ * @param r The permission request being renewed
1988
+ * @param newExpiry The new expiry epoch time
1989
+ * @param newAmount For DSAP, the new authorized amount
1990
+ */
1991
+ private async renewPermissionOnChain(
1992
+ oldToken: PermissionToken,
1993
+ r: PermissionRequest,
1994
+ newExpiry: number,
1995
+ newAmount?: number
1996
+ ): Promise<void> {
1997
+ r.originator = this.normalizeOriginator(r.originator) || r.originator
1998
+ // 1) build new fields
1999
+ const newFields = await this.buildPushdropFields(r, newExpiry, newAmount)
2000
+
2001
+ // 2) new script
2002
+ const newScript = await new PushDrop(this.underlying).lock(
2003
+ newFields,
2004
+ WalletPermissionsManager.PERM_TOKEN_ENCRYPTION_PROTOCOL,
2005
+ '1',
2006
+ 'self',
2007
+ true,
2008
+ true
2009
+ )
2010
+ const tags = this.buildTagsForRequest(r)
2011
+ // Check if there are multiple old tokens for the same parameters (shouldn't usually happen)
2012
+ const oldTokens = await this.findAllProtocolTokens(
2013
+ oldToken.originator,
2014
+ oldToken.privileged!,
2015
+ [oldToken.securityLevel!, oldToken.protocol!],
2016
+ oldToken.counterparty!,
2017
+ this.buildOriginatorLookupValues(oldToken.rawOriginator, oldToken.originator)
2018
+ )
2019
+
2020
+ // If so, coalesce them into a single token first, to avoid bloat
2021
+ if (oldTokens.length > 1) {
2022
+ await this.coalescePermissionTokens(oldTokens, newScript, {
2023
+ tags,
2024
+ basket: BASKET_MAP[r.type],
2025
+ description: `Coalesce ${r.type} permission tokens`
2026
+ })
2027
+ } else {
2028
+ // Otherwise, just proceed with the single-token renewal
2029
+ // 3) For BRC-100, we do a "createAction" with a partial input referencing oldToken
2030
+ // plus a single new output. We'll hydrate the template, then signAction for the wallet to finalize.
2031
+ const oldOutpoint = `${oldToken.txid}.${oldToken.outputIndex}`
2032
+ const { signableTransaction } = await this.createAction(
2033
+ {
2034
+ description: `Renew ${r.type} permission`,
2035
+ inputBEEF: oldToken.tx,
2036
+ inputs: [
2037
+ {
2038
+ outpoint: oldOutpoint,
2039
+ unlockingScriptLength: 73, // length of signature
2040
+ inputDescription: `Consume old ${r.type} token`
2041
+ }
2042
+ ],
2043
+ outputs: [
2044
+ {
2045
+ lockingScript: newScript.toHex(),
2046
+ satoshis: 1,
2047
+ outputDescription: `Renewed ${r.type} permission token`,
2048
+ basket: BASKET_MAP[r.type],
2049
+ tags
2050
+ }
2051
+ ],
2052
+ options: {
2053
+ acceptDelayedBroadcast: false
2054
+ }
2055
+ },
2056
+ this.adminOriginator
2057
+ )
2058
+ const tx = Transaction.fromBEEF(signableTransaction!.tx)
2059
+ const unlocker = new PushDrop(this.underlying).unlock(
2060
+ WalletPermissionsManager.PERM_TOKEN_ENCRYPTION_PROTOCOL,
2061
+ '1',
2062
+ 'self',
2063
+ 'all',
2064
+ false,
2065
+ 1,
2066
+ LockingScript.fromHex(oldToken.outputScript)
2067
+ )
2068
+ const unlockingScript = await unlocker.sign(tx, 0)
2069
+ await this.underlying.signAction({
2070
+ reference: signableTransaction!.reference,
2071
+ spends: {
2072
+ 0: {
2073
+ unlockingScript: unlockingScript.toHex()
2074
+ }
2075
+ }
2076
+ })
2077
+ }
2078
+ }
2079
+
2080
+ /**
2081
+ * Builds the encrypted array of fields for a PushDrop permission token
2082
+ * (protocol / basket / certificate / spending).
2083
+ */
2084
+ private async buildPushdropFields(r: PermissionRequest, expiry: number, amount?: number): Promise<number[][]> {
2085
+ switch (r.type) {
2086
+ case 'protocol': {
2087
+ const [secLevel, protoName] = r.protocolID!
2088
+ return [
2089
+ await this.encryptPermissionTokenField(r.originator), // domain
2090
+ await this.encryptPermissionTokenField(String(expiry)), // expiry
2091
+ await this.encryptPermissionTokenField(r.privileged === true ? 'true' : 'false'),
2092
+ await this.encryptPermissionTokenField(String(secLevel)),
2093
+ await this.encryptPermissionTokenField(protoName),
2094
+ await this.encryptPermissionTokenField(r.counterparty!)
2095
+ ]
2096
+ }
2097
+ case 'basket': {
2098
+ return [
2099
+ await this.encryptPermissionTokenField(r.originator),
2100
+ await this.encryptPermissionTokenField(String(expiry)),
2101
+ await this.encryptPermissionTokenField(r.basket!)
2102
+ ]
2103
+ }
2104
+ case 'certificate': {
2105
+ const { certType, fields, verifier } = r.certificate!
2106
+ return [
2107
+ await this.encryptPermissionTokenField(r.originator),
2108
+ await this.encryptPermissionTokenField(String(expiry)),
2109
+ await this.encryptPermissionTokenField(r.privileged ? 'true' : 'false'),
2110
+ await this.encryptPermissionTokenField(certType),
2111
+ await this.encryptPermissionTokenField(JSON.stringify(fields)),
2112
+ await this.encryptPermissionTokenField(verifier)
2113
+ ]
2114
+ }
2115
+ case 'spending': {
2116
+ // DSAP
2117
+ const authAmt = amount ?? (r.spending?.satoshis || 0)
2118
+ return [
2119
+ await this.encryptPermissionTokenField(r.originator),
2120
+ await this.encryptPermissionTokenField(String(authAmt))
2121
+ ]
2122
+ }
2123
+ }
2124
+ }
2125
+
2126
+ /**
2127
+ * Helper to build an array of tags for the new output, matching the user request's
2128
+ * origin, basket, privileged, protocol name, etc.
2129
+ */
2130
+ private buildTagsForRequest(r: PermissionRequest): string[] {
2131
+ const tags: string[] = [`originator ${r.originator}`]
2132
+ switch (r.type) {
2133
+ case 'protocol': {
2134
+ tags.push(`privileged ${!!r.privileged}`)
2135
+ tags.push(`protocolName ${r.protocolID![1]}`)
2136
+ tags.push(`protocolSecurityLevel ${r.protocolID![0]}`)
2137
+ if (r.protocolID![0] === 2) {
2138
+ tags.push(`counterparty ${r.counterparty}`)
2139
+ }
2140
+ break
2141
+ }
2142
+ case 'basket': {
2143
+ tags.push(`basket ${r.basket}`)
2144
+ break
2145
+ }
2146
+ case 'certificate': {
2147
+ tags.push(`privileged ${!!r.privileged}`)
2148
+ tags.push(`type ${r.certificate!.certType}`)
2149
+ tags.push(`verifier ${r.certificate!.verifier}`)
2150
+ break
2151
+ }
2152
+ case 'spending': {
2153
+ // Only 'originator' is strictly required as a tag.
2154
+ break
2155
+ }
2156
+ }
2157
+ return tags
2158
+ }
2159
+
2160
+ /* ---------------------------------------------------------------------
2161
+ * 6) PUBLIC "LIST/HAS/REVOKE" METHODS
2162
+ * --------------------------------------------------------------------- */
2163
+
2164
+ /**
2165
+ * Lists all protocol permission tokens (DPACP) with optional filters.
2166
+ * @param originator Optional originator domain to filter by
2167
+ * @param privileged Optional boolean to filter by privileged status
2168
+ * @param protocolName Optional protocol name to filter by
2169
+ * @param protocolSecurityLevel Optional protocol security level to filter by
2170
+ * @param counterparty Optional counterparty to filter by
2171
+ * @returns Array of permission tokens that match the filter criteria
2172
+ */
2173
+ public async listProtocolPermissions({
2174
+ originator,
2175
+ privileged,
2176
+ protocolName,
2177
+ protocolSecurityLevel,
2178
+ counterparty
2179
+ }: {
2180
+ originator?: string
2181
+ privileged?: boolean
2182
+ protocolName?: string
2183
+ protocolSecurityLevel?: number
2184
+ counterparty?: string
2185
+ } = {}): Promise<PermissionToken[]> {
2186
+ const basketName = BASKET_MAP.protocol
2187
+ const baseTags: string[] = []
2188
+
2189
+ if (privileged !== undefined) {
2190
+ baseTags.push(`privileged ${!!privileged}`)
2191
+ }
2192
+
2193
+ if (protocolName) {
2194
+ baseTags.push(`protocolName ${protocolName}`)
2195
+ }
2196
+
2197
+ if (protocolSecurityLevel !== undefined) {
2198
+ baseTags.push(`protocolSecurityLevel ${protocolSecurityLevel}`)
2199
+ }
2200
+
2201
+ if (counterparty) {
2202
+ baseTags.push(`counterparty ${counterparty}`)
2203
+ }
2204
+
2205
+ const originFilter = originator ? this.prepareOriginator(originator) : undefined
2206
+ const originVariants = originFilter ? originFilter.lookupValues : [undefined]
2207
+ const seen = new Set<string>()
2208
+ const tokens: PermissionToken[] = []
2209
+
2210
+ for (const originTag of originVariants) {
2211
+ const tags = [...baseTags]
2212
+ if (originTag) {
2213
+ tags.push(`originator ${originTag}`)
2214
+ }
2215
+ const result = await this.underlying.listOutputs(
2216
+ {
2217
+ basket: basketName,
2218
+ tags,
2219
+ tagQueryMode: 'all',
2220
+ include: 'entire transactions',
2221
+ limit: 100
2222
+ },
2223
+ this.adminOriginator
2224
+ )
2225
+
2226
+ for (const out of result.outputs) {
2227
+ if (seen.has(out.outpoint)) continue
2228
+ const [txid, outputIndexStr] = out.outpoint.split('.')
2229
+ const tx = Transaction.fromBEEF(result.BEEF!, txid)
2230
+ const dec = PushDrop.decode(tx.outputs[Number(outputIndexStr)].lockingScript)
2231
+ if (!dec?.fields || dec.fields.length < 6) continue
2232
+ const [domainRaw, expiryRaw, privRaw, secRaw, protoRaw, cptyRaw] = dec.fields
2233
+
2234
+ const domainDec = Utils.toUTF8(await this.decryptPermissionTokenField(domainRaw))
2235
+ const normalizedDomain = this.normalizeOriginator(domainDec)
2236
+ if (originFilter && normalizedDomain !== originFilter.normalized) {
2237
+ continue
2238
+ }
2239
+
2240
+ const expiryDec = parseInt(Utils.toUTF8(await this.decryptPermissionTokenField(expiryRaw)), 10)
2241
+ const privDec = Utils.toUTF8(await this.decryptPermissionTokenField(privRaw)) === 'true'
2242
+ const secDec = parseInt(Utils.toUTF8(await this.decryptPermissionTokenField(secRaw)), 10) as 0 | 1 | 2
2243
+ const protoDec = Utils.toUTF8(await this.decryptPermissionTokenField(protoRaw))
2244
+ const cptyDec = Utils.toUTF8(await this.decryptPermissionTokenField(cptyRaw))
2245
+
2246
+ seen.add(out.outpoint)
2247
+ tokens.push({
2248
+ tx: tx.toBEEF(),
2249
+ txid: out.outpoint.split('.')[0],
2250
+ outputIndex: parseInt(out.outpoint.split('.')[1], 10),
2251
+ outputScript: tx.outputs[Number(outputIndexStr)].lockingScript.toHex(),
2252
+ satoshis: out.satoshis,
2253
+ originator: normalizedDomain,
2254
+ rawOriginator: domainDec,
2255
+ expiry: expiryDec,
2256
+ privileged: privDec,
2257
+ securityLevel: secDec,
2258
+ protocol: protoDec,
2259
+ counterparty: cptyDec
2260
+ })
2261
+ }
2262
+ }
2263
+ return tokens
2264
+ }
2265
+
2266
+ /**
2267
+ * Returns true if the originator already holds a valid unexpired protocol permission.
2268
+ * This calls `ensureProtocolPermission` with `seekPermission=false`, so it won't prompt.
2269
+ */
2270
+ public async hasProtocolPermission(params: {
2271
+ originator: string
2272
+ privileged: boolean
2273
+ protocolID: WalletProtocol
2274
+ counterparty: string
2275
+ }): Promise<boolean> {
2276
+ try {
2277
+ await this.ensureProtocolPermission({
2278
+ ...params,
2279
+ reason: 'hasProtocolPermission',
2280
+ seekPermission: false,
2281
+ usageType: 'generic'
2282
+ })
2283
+ return true
2284
+ } catch {
2285
+ return false
2286
+ }
2287
+ }
2288
+
2289
+ /**
2290
+ * Lists basket permission tokens (DBAP) for a given originator or basket (or for all if not specified).
2291
+ * @param params.originator Optional originator to filter by
2292
+ * @param params.basket Optional basket name to filter by
2293
+ * @returns Array of permission tokens that match the filter criteria
2294
+ */
2295
+ public async listBasketAccess(params: { originator?: string; basket?: string } = {}): Promise<PermissionToken[]> {
2296
+ const basketName = BASKET_MAP.basket
2297
+ const baseTags: string[] = []
2298
+
2299
+ if (params.basket) {
2300
+ baseTags.push(`basket ${params.basket}`)
2301
+ }
2302
+
2303
+ const originFilter = params.originator ? this.prepareOriginator(params.originator) : undefined
2304
+ const originVariants = originFilter ? originFilter.lookupValues : [undefined]
2305
+ const seen = new Set<string>()
2306
+ const tokens: PermissionToken[] = []
2307
+
2308
+ for (const originTag of originVariants) {
2309
+ const tags = [...baseTags]
2310
+ if (originTag) {
2311
+ tags.push(`originator ${originTag}`)
2312
+ }
2313
+ const result = await this.underlying.listOutputs(
2314
+ {
2315
+ basket: basketName,
2316
+ tags,
2317
+ tagQueryMode: 'all',
2318
+ include: 'entire transactions',
2319
+ limit: 10000
2320
+ },
2321
+ this.adminOriginator
2322
+ )
2323
+
2324
+ for (const out of result.outputs) {
2325
+ if (seen.has(out.outpoint)) continue
2326
+ const [txid, outputIndexStr] = out.outpoint.split('.')
2327
+ const tx = Transaction.fromBEEF(result.BEEF!, txid)
2328
+ const dec = PushDrop.decode(tx.outputs[Number(outputIndexStr)].lockingScript)
2329
+ if (!dec?.fields || dec.fields.length < 3) continue
2330
+ const [domainRaw, expiryRaw, basketRaw] = dec.fields
2331
+ const domainDecoded = Utils.toUTF8(await this.decryptPermissionTokenField(domainRaw))
2332
+ const normalizedDomain = this.normalizeOriginator(domainDecoded)
2333
+ if (originFilter && normalizedDomain !== originFilter.normalized) {
2334
+ continue
2335
+ }
2336
+ const expiryDecoded = parseInt(Utils.toUTF8(await this.decryptPermissionTokenField(expiryRaw)), 10)
2337
+ const basketDecoded = Utils.toUTF8(await this.decryptPermissionTokenField(basketRaw))
2338
+ seen.add(out.outpoint)
2339
+ tokens.push({
2340
+ tx: tx.toBEEF(),
2341
+ txid: out.outpoint.split('.')[0],
2342
+ outputIndex: parseInt(out.outpoint.split('.')[1], 10),
2343
+ satoshis: out.satoshis,
2344
+ outputScript: tx.outputs[Number(outputIndexStr)].lockingScript.toHex(),
2345
+ originator: normalizedDomain,
2346
+ rawOriginator: domainDecoded,
2347
+ basketName: basketDecoded,
2348
+ expiry: expiryDecoded
2349
+ })
2350
+ }
2351
+ }
2352
+ return tokens
2353
+ }
2354
+
2355
+ /**
2356
+ * Returns `true` if the originator already holds a valid unexpired basket permission for `basket`.
2357
+ */
2358
+ public async hasBasketAccess(params: { originator: string; basket: string }): Promise<boolean> {
2359
+ try {
2360
+ await this.ensureBasketAccess({
2361
+ originator: params.originator,
2362
+ basket: params.basket,
2363
+ seekPermission: false,
2364
+ usageType: 'insertion' // TODO: Consider a generic case for "has"
2365
+ })
2366
+ return true
2367
+ } catch {
2368
+ return false
2369
+ }
2370
+ }
2371
+
2372
+ /**
2373
+ * Lists spending authorization tokens (DSAP) for a given originator (or all).
2374
+ */
2375
+ public async listSpendingAuthorizations(params: { originator?: string }): Promise<PermissionToken[]> {
2376
+ const basketName = BASKET_MAP.spending
2377
+ const tags: string[] = []
2378
+ if (params.originator) {
2379
+ tags.push(`originator ${params.originator}`)
2380
+ }
2381
+ const result = await this.underlying.listOutputs(
2382
+ {
2383
+ basket: basketName,
2384
+ tags,
2385
+ tagQueryMode: 'all',
2386
+ include: 'entire transactions',
2387
+ limit: 10000
2388
+ },
2389
+ this.adminOriginator
2390
+ )
2391
+
2392
+ const tokens: PermissionToken[] = []
2393
+ for (const out of result.outputs) {
2394
+ const [txid, outputIndexStr] = out.outpoint.split('.')
2395
+ const tx = Transaction.fromBEEF(result.BEEF!, txid)
2396
+ const dec = PushDrop.decode(tx.outputs[Number(outputIndexStr)].lockingScript)
2397
+ if (!dec?.fields || dec.fields.length < 2) continue
2398
+ const [domainRaw, amtRaw] = dec.fields
2399
+ const domainDecoded = Utils.toUTF8(await this.decryptPermissionTokenField(domainRaw))
2400
+ const amtDecodedStr = Utils.toUTF8(await this.decryptPermissionTokenField(amtRaw))
2401
+ const authorizedAmount = parseInt(amtDecodedStr, 10)
2402
+ tokens.push({
2403
+ tx: tx.toBEEF(),
2404
+ txid: out.outpoint.split('.')[0],
2405
+ outputIndex: parseInt(out.outpoint.split('.')[1], 10),
2406
+ satoshis: out.satoshis,
2407
+ outputScript: tx.outputs[Number(outputIndexStr)].lockingScript.toHex(),
2408
+ originator: domainDecoded,
2409
+ authorizedAmount,
2410
+ expiry: 0
2411
+ })
2412
+ }
2413
+ return tokens
2414
+ }
2415
+
2416
+ /**
2417
+ * Returns `true` if the originator already holds a valid spending authorization token
2418
+ * with enough available monthly spend. We do not prompt (seekPermission=false).
2419
+ */
2420
+ public async hasSpendingAuthorization(params: { originator: string; satoshis: number }): Promise<boolean> {
2421
+ try {
2422
+ await this.ensureSpendingAuthorization({
2423
+ originator: params.originator,
2424
+ satoshis: params.satoshis,
2425
+ seekPermission: false
2426
+ })
2427
+ return true
2428
+ } catch {
2429
+ return false
2430
+ }
2431
+ }
2432
+
2433
+ /**
2434
+ * Lists certificate permission tokens (DCAP) with optional filters.
2435
+ * @param originator Optional originator domain to filter by
2436
+ * @param privileged Optional boolean to filter by privileged status
2437
+ * @param certType Optional certificate type to filter by
2438
+ * @param verifier Optional verifier to filter by
2439
+ * @returns Array of permission tokens that match the filter criteria
2440
+ */
2441
+ public async listCertificateAccess(
2442
+ params: {
2443
+ originator?: string
2444
+ privileged?: boolean
2445
+ certType?: Base64String
2446
+ verifier?: PubKeyHex
2447
+ } = {}
2448
+ ): Promise<PermissionToken[]> {
2449
+ const basketName = BASKET_MAP.certificate
2450
+ const baseTags: string[] = []
2451
+
2452
+ if (params.privileged !== undefined) {
2453
+ baseTags.push(`privileged ${!!params.privileged}`)
2454
+ }
2455
+
2456
+ if (params.certType) {
2457
+ baseTags.push(`type ${params.certType}`)
2458
+ }
2459
+
2460
+ if (params.verifier) {
2461
+ baseTags.push(`verifier ${params.verifier}`)
2462
+ }
2463
+
2464
+ const originFilter = params.originator ? this.prepareOriginator(params.originator) : undefined
2465
+ const originVariants = originFilter ? originFilter.lookupValues : [undefined]
2466
+ const seen = new Set<string>()
2467
+ const tokens: PermissionToken[] = []
2468
+
2469
+ for (const originTag of originVariants) {
2470
+ const tags = [...baseTags]
2471
+ if (originTag) {
2472
+ tags.push(`originator ${originTag}`)
2473
+ }
2474
+ const result = await this.underlying.listOutputs(
2475
+ {
2476
+ basket: basketName,
2477
+ tags,
2478
+ tagQueryMode: 'all',
2479
+ include: 'entire transactions',
2480
+ limit: 10000
2481
+ },
2482
+ this.adminOriginator
2483
+ )
2484
+
2485
+ for (const out of result.outputs) {
2486
+ if (seen.has(out.outpoint)) continue
2487
+ const [txid, outputIndexStr] = out.outpoint.split('.')
2488
+ const tx = Transaction.fromBEEF(result.BEEF!, txid)
2489
+ const dec = PushDrop.decode(tx.outputs[Number(outputIndexStr)].lockingScript)
2490
+ if (!dec?.fields || dec.fields.length < 6) continue
2491
+ const [domainRaw, expiryRaw, privRaw, typeRaw, fieldsRaw, verifierRaw] = dec.fields
2492
+ const domainDecoded = Utils.toUTF8(await this.decryptPermissionTokenField(domainRaw))
2493
+ const normalizedDomain = this.normalizeOriginator(domainDecoded)
2494
+ if (originFilter && normalizedDomain !== originFilter.normalized) {
2495
+ continue
2496
+ }
2497
+ const expiryDecoded = parseInt(Utils.toUTF8(await this.decryptPermissionTokenField(expiryRaw)), 10)
2498
+ const privDecoded = Utils.toUTF8(await this.decryptPermissionTokenField(privRaw)) === 'true'
2499
+ const typeDecoded = Utils.toUTF8(await this.decryptPermissionTokenField(typeRaw))
2500
+ const verifierDec = Utils.toUTF8(await this.decryptPermissionTokenField(verifierRaw))
2501
+ const fieldsJson = await this.decryptPermissionTokenField(fieldsRaw)
2502
+ const allFields = JSON.parse(Utils.toUTF8(fieldsJson)) as string[]
2503
+ seen.add(out.outpoint)
2504
+ tokens.push({
2505
+ tx: tx.toBEEF(),
2506
+ txid: out.outpoint.split('.')[0],
2507
+ outputIndex: parseInt(out.outpoint.split('.')[1], 10),
2508
+ satoshis: out.satoshis,
2509
+ outputScript: tx.outputs[Number(outputIndexStr)].lockingScript.toHex(),
2510
+ originator: normalizedDomain,
2511
+ rawOriginator: domainDecoded,
2512
+ privileged: privDecoded,
2513
+ certType: typeDecoded,
2514
+ certFields: allFields,
2515
+ verifier: verifierDec,
2516
+ expiry: expiryDecoded
2517
+ })
2518
+ }
2519
+ }
2520
+ return tokens
2521
+ }
2522
+
2523
+ /**
2524
+ * Returns `true` if the originator already holds a valid unexpired certificate access
2525
+ * for the given certType/fields. Does not prompt the user.
2526
+ */
2527
+ public async hasCertificateAccess(params: {
2528
+ originator: string
2529
+ privileged: boolean
2530
+ verifier: string
2531
+ certType: string
2532
+ fields: string[]
2533
+ }): Promise<boolean> {
2534
+ try {
2535
+ await this.ensureCertificateAccess({
2536
+ originator: params.originator,
2537
+ privileged: params.privileged,
2538
+ verifier: params.verifier,
2539
+ certType: params.certType,
2540
+ fields: params.fields,
2541
+ seekPermission: false,
2542
+ usageType: 'disclosure'
2543
+ })
2544
+ return true
2545
+ } catch {
2546
+ return false
2547
+ }
2548
+ }
2549
+
2550
+ /**
2551
+ * Revokes a permission token by spending it with no replacement output.
2552
+ * The manager builds a BRC-100 transaction that consumes the token, effectively invalidating it.
2553
+ */
2554
+ public async revokePermission(oldToken: PermissionToken): Promise<void> {
2555
+ const oldOutpoint = `${oldToken.txid}.${oldToken.outputIndex}`
2556
+ const { signableTransaction } = await this.createAction(
2557
+ {
2558
+ description: `Revoke permission`,
2559
+ inputBEEF: oldToken.tx,
2560
+ inputs: [
2561
+ {
2562
+ outpoint: oldOutpoint,
2563
+ unlockingScriptLength: 73, // length of signature
2564
+ inputDescription: `Consume old permission token`
2565
+ }
2566
+ ],
2567
+ options: {
2568
+ acceptDelayedBroadcast: false
2569
+ }
2570
+ },
2571
+ this.adminOriginator
2572
+ )
2573
+ const tx = Transaction.fromBEEF(signableTransaction!.tx)
2574
+ const unlocker = new PushDrop(this.underlying).unlock(
2575
+ WalletPermissionsManager.PERM_TOKEN_ENCRYPTION_PROTOCOL,
2576
+ '1',
2577
+ 'self',
2578
+ 'all',
2579
+ false,
2580
+ 1,
2581
+ LockingScript.fromHex(oldToken.outputScript)
2582
+ )
2583
+ const unlockingScript = await unlocker.sign(tx, 0)
2584
+ await this.underlying.signAction({
2585
+ reference: signableTransaction!.reference,
2586
+ spends: {
2587
+ 0: {
2588
+ unlockingScript: unlockingScript.toHex()
2589
+ }
2590
+ }
2591
+ })
2592
+ }
2593
+
2594
+ /* ---------------------------------------------------------------------
2595
+ * 7) BRC-100 WALLET INTERFACE FORWARDING WITH PERMISSION CHECKS
2596
+ * --------------------------------------------------------------------- */
2597
+
2598
+ public async createAction(
2599
+ args: Parameters<WalletInterface['createAction']>[0],
2600
+ originator?: string
2601
+ ): ReturnType<WalletInterface['createAction']> {
2602
+ // 1) Identify unique P-modules involved (one per schemeID)
2603
+ const pModulesByScheme = new Map<string, PermissionsModule>()
2604
+ const nonPBaskets: string[] = []
2605
+
2606
+ if (args.outputs) {
2607
+ for (const out of args.outputs) {
2608
+ if (out.basket) {
2609
+ if (out.basket.startsWith('p ')) {
2610
+ const schemeID = out.basket.split(' ')[1]
2611
+ if (!pModulesByScheme.has(schemeID)) {
2612
+ const module = this.config.permissionModules?.[schemeID]
2613
+ if (!module) {
2614
+ throw new Error(`Unsupported P-basket scheme: p ${schemeID}`)
2615
+ }
2616
+ pModulesByScheme.set(schemeID, module)
2617
+ }
2618
+ } else {
2619
+ // Track non-P baskets for normal permission checks
2620
+ nonPBaskets.push(out.basket)
2621
+ }
2622
+ }
2623
+ }
2624
+ }
2625
+
2626
+ // 2) Check permissions for non-P baskets
2627
+ for (const basket of nonPBaskets) {
2628
+ await this.ensureBasketAccess({
2629
+ originator: originator!,
2630
+ basket,
2631
+ reason: args.description,
2632
+ usageType: 'insertion'
2633
+ })
2634
+ }
2635
+
2636
+ if (args.labels) {
2637
+ for (const lbl of args.labels) {
2638
+ await this.ensureLabelAccess({
2639
+ originator: originator!,
2640
+ label: lbl,
2641
+ reason: args.description,
2642
+ usageType: 'apply'
2643
+ })
2644
+ }
2645
+ }
2646
+
2647
+ /**
2648
+ * 4) Force signAndProcess=false unless the originator is admin and explicitly sets it to true.
2649
+ * This ensures the underlying wallet returns a signableTransaction, letting us parse the transaction
2650
+ * to determine net spending and request authorization if needed.
2651
+ */
2652
+ const modifiedOptions = { ...(args.options || {}) }
2653
+ if (modifiedOptions.signAndProcess !== true) {
2654
+ modifiedOptions.signAndProcess = false
2655
+ } else if (!this.isAdminOriginator(originator!)) {
2656
+ throw new Error('Only the admin originator can set signAndProcess=true explicitly.')
2657
+ }
2658
+
2659
+ // 5) Encrypt transaction metadata, saving originals for use in permissions and line items.
2660
+ const originalDescription = args.description
2661
+ const originalInputDescriptions = {}
2662
+ const originalOutputDescriptions = {}
2663
+ args.description = await this.maybeEncryptMetadata(args.description)
2664
+ for (let i = 0; i < (args.inputs || []).length; i++) {
2665
+ if (args.inputs![i].inputDescription) {
2666
+ originalInputDescriptions[i] = args.inputs![i].inputDescription
2667
+ args.inputs![i].inputDescription = await this.maybeEncryptMetadata(args.inputs![i].inputDescription)
2668
+ }
2669
+ }
2670
+ for (let i = 0; i < (args.outputs || []).length; i++) {
2671
+ if (args.outputs![i].outputDescription) {
2672
+ originalOutputDescriptions[i] = args.outputs![i].outputDescription
2673
+ args.outputs![i].outputDescription = await this.maybeEncryptMetadata(args.outputs![i].outputDescription)
2674
+ }
2675
+ if (args.outputs![i].customInstructions) {
2676
+ args.outputs![i].customInstructions = await this.maybeEncryptMetadata(args.outputs![i].customInstructions!)
2677
+ }
2678
+ }
2679
+
2680
+ /**
2681
+ * 6) Call the underlying wallet's createAction.
2682
+ * - If P-modules are involved, chain request transformations through them first
2683
+ * - Add two "admin" labels for tracking: "admin originator <domain>" and "admin month YYYY-MM"
2684
+ * - If P-modules are involved, chain response transformations back through them
2685
+ */
2686
+ const finalArgs = {
2687
+ ...args,
2688
+ options: modifiedOptions,
2689
+ labels: [...(args.labels || []), `admin originator ${originator}`, `admin month ${this.getCurrentMonthYearUTC()}`]
2690
+ }
2691
+
2692
+ let createResult: Awaited<ReturnType<WalletInterface['createAction']>>
2693
+
2694
+ if (pModulesByScheme.size > 0) {
2695
+ // P-modules are involved - chain transformations
2696
+ const pModules = Array.from(pModulesByScheme.values())
2697
+
2698
+ // Chain onRequest calls through all modules
2699
+ let transformedArgs: object = finalArgs
2700
+ for (const module of pModules) {
2701
+ const transformed = await module.onRequest({
2702
+ method: 'createAction',
2703
+ args: transformedArgs,
2704
+ originator: originator!
2705
+ })
2706
+ transformedArgs = transformed.args
2707
+ }
2708
+
2709
+ // Call underlying wallet with transformed args
2710
+ createResult = await this.underlying.createAction(transformedArgs as CreateActionArgs, originator!)
2711
+
2712
+ // Chain onResponse calls in reverse order
2713
+ for (let i = pModules.length - 1; i >= 0; i--) {
2714
+ createResult = await pModules[i].onResponse(createResult, {
2715
+ method: 'createAction',
2716
+ originator: originator!
2717
+ })
2718
+ }
2719
+ } else {
2720
+ // No P-modules - call underlying wallet directly
2721
+ createResult = await this.underlying.createAction(finalArgs, originator!)
2722
+ }
2723
+
2724
+ // If there's no signableTransaction, the underlying wallet must have fully finalized it. Return as is.
2725
+ if (!createResult.signableTransaction) {
2726
+ return createResult
2727
+ }
2728
+
2729
+ /**
2730
+ * 7) We have a signable transaction. Parse it to determine how much the originator is actually spending.
2731
+ * We only consider inputs the originator explicitly listed in args.inputs.
2732
+ * netSpent = (sum of originator-requested outputs) - (sum of matching originator inputs).
2733
+ * If netSpent > 0, we need spending authorization.
2734
+ */
2735
+ const tx = Transaction.fromAtomicBEEF(createResult.signableTransaction.tx)
2736
+ const reference = createResult.signableTransaction.reference
2737
+
2738
+ let netSpent = 0
2739
+ const lineItems: Array<{
2740
+ type: 'input' | 'output' | 'fee'
2741
+ description: string
2742
+ satoshis: number
2743
+ }> = []
2744
+
2745
+ // Sum originator-provided inputs:
2746
+ let totalInputSatoshis = 0
2747
+ for (const input of tx.inputs) {
2748
+ const outpoint = `${input.sourceTXID}.${input.sourceOutputIndex}`
2749
+ const matchingIndex = (args.inputs || []).findIndex(i => i.outpoint === outpoint)
2750
+ if (matchingIndex !== -1) {
2751
+ const satoshis = input.sourceTransaction!.outputs[input.sourceOutputIndex].satoshis
2752
+ totalInputSatoshis += satoshis!
2753
+ lineItems.push({
2754
+ type: 'input',
2755
+ description: originalInputDescriptions[matchingIndex] || 'No input description provided',
2756
+ satoshis: satoshis!
2757
+ })
2758
+ }
2759
+ }
2760
+
2761
+ // Sum originator-requested outputs:
2762
+ const totalOutputSatoshis = (args.outputs || []).reduce((acc, out) => acc + out.satoshis, 0)
2763
+ for (const outIndex in args.outputs || []) {
2764
+ const out = args.outputs![outIndex]
2765
+ lineItems.push({
2766
+ type: 'output',
2767
+ satoshis: out.satoshis,
2768
+ description: originalOutputDescriptions[outIndex] || 'No output description provided'
2769
+ })
2770
+ }
2771
+
2772
+ // Add an entry for the transaction fee:
2773
+ lineItems.push({
2774
+ type: 'fee',
2775
+ satoshis: tx.getFee(),
2776
+ description: 'Network fee'
2777
+ })
2778
+
2779
+ /**
2780
+ * When it comes to spending authorizations, and the computation of net spend, there are
2781
+ * two types of inputs and two types of outputs:
2782
+ *
2783
+ * There are foreign (originator-requested) ones, and domestic (internally-provided) ones.
2784
+ * The net spend is always calculated from the domestic, internal perspective. Therefore, the
2785
+ * cost of funding the foreign outputs is the base cost to the domestic user, unless this is
2786
+ * somehow offset.
2787
+ *
2788
+ * The only way to offset this cost is when the foreign inputs help carry some of the burden.
2789
+ * This is why we can subtract the sum of the foreign inputs from the sum of foreign outputs,
2790
+ * to gague how much of that cost needs to be born domestically by the user.
2791
+ *
2792
+ * The logic does not need to account for whatever domestic inputs are provided, or whatever
2793
+ * domestic outputs are re-captured by the wallet back as change. The wallet could conceivably
2794
+ * provide 21e8 satoshis as input and re-capture the same amount as change, but the net effect
2795
+ * on actual spending would be zero. Therefore, we base net spend on total foreign outflows
2796
+ * minus total foreign inflows. Fees are also considered.
2797
+ */
2798
+ netSpent = totalOutputSatoshis + tx.getFee() - totalInputSatoshis
2799
+
2800
+ // 8) If netSpent > 0, require spending authorization. Abort if denied.
2801
+ if (netSpent > 0) {
2802
+ try {
2803
+ await this.ensureSpendingAuthorization({
2804
+ originator: originator!,
2805
+ satoshis: netSpent,
2806
+ lineItems,
2807
+ reason: originalDescription
2808
+ })
2809
+ } catch (err) {
2810
+ await this.underlying.abortAction({ reference })
2811
+ throw err
2812
+ }
2813
+ }
2814
+
2815
+ /**
2816
+ * 9) Decide whether to finalize the transaction automatically or return the signableTransaction:
2817
+ * - 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.
2818
+ * - If the transaction still needs more signatures, we must return the signableTransaction.
2819
+ */
2820
+ const vargs = Validation.validateCreateActionArgs(args)
2821
+ if (vargs.isSignAction) {
2822
+ return createResult
2823
+ }
2824
+
2825
+ const signResult = await this.underlying.signAction({ reference, spends: {}, options: args.options }, originator)
2826
+ // Merge signResult into createResult and remove signableTransaction:
2827
+ return {
2828
+ ...createResult,
2829
+ ...signResult,
2830
+ signableTransaction: undefined
2831
+ }
2832
+ }
2833
+
2834
+ public async signAction(
2835
+ ...args: Parameters<WalletInterface['signAction']>
2836
+ ): ReturnType<WalletInterface['signAction']> {
2837
+ return this.underlying.signAction(...args)
2838
+ }
2839
+
2840
+ public async abortAction(
2841
+ ...args: Parameters<WalletInterface['abortAction']>
2842
+ ): ReturnType<WalletInterface['abortAction']> {
2843
+ return this.underlying.abortAction(...args)
2844
+ }
2845
+
2846
+ public async listActions(
2847
+ ...args: Parameters<WalletInterface['listActions']>
2848
+ ): ReturnType<WalletInterface['listActions']> {
2849
+ const [requestArgs, originator] = args
2850
+ // for each label, ensure label access
2851
+ if (requestArgs.labels) {
2852
+ for (const lbl of requestArgs.labels) {
2853
+ await this.ensureLabelAccess({
2854
+ originator: originator!,
2855
+ label: lbl,
2856
+ reason: 'listActions',
2857
+ usageType: 'list'
2858
+ })
2859
+ }
2860
+ }
2861
+ const results = await this.underlying.listActions(...args)
2862
+ // Transparently decrypt transaction metadata, if configured to do so.
2863
+ if (results.actions) {
2864
+ for (let i = 0; i < results.actions.length; i++) {
2865
+ if (results.actions[i].description) {
2866
+ results.actions[i].description = await this.maybeDecryptMetadata(results.actions[i].description)
2867
+ }
2868
+ if (results.actions[i].inputs) {
2869
+ for (let j = 0; j < results.actions[i].inputs!.length; j++) {
2870
+ if (results.actions[i].inputs![j].inputDescription) {
2871
+ results.actions[i].inputs![j].inputDescription = await this.maybeDecryptMetadata(
2872
+ results.actions[i].inputs![j].inputDescription
2873
+ )
2874
+ }
2875
+ }
2876
+ }
2877
+ if (results.actions[i].outputs) {
2878
+ for (let j = 0; j < results.actions[i].outputs!.length; j++) {
2879
+ if (results.actions[i].outputs![j].outputDescription) {
2880
+ results.actions[i].outputs![j].outputDescription = await this.maybeDecryptMetadata(
2881
+ results.actions[i].outputs![j].outputDescription
2882
+ )
2883
+ }
2884
+ if (results.actions[i].outputs![j].customInstructions) {
2885
+ results.actions[i].outputs![j].customInstructions = await this.maybeDecryptMetadata(
2886
+ results.actions[i].outputs![j].customInstructions!
2887
+ )
2888
+ }
2889
+ }
2890
+ }
2891
+ }
2892
+ }
2893
+ return results
2894
+ }
2895
+
2896
+ public async internalizeAction(
2897
+ ...args: Parameters<WalletInterface['internalizeAction']>
2898
+ ): ReturnType<WalletInterface['internalizeAction']> {
2899
+ const [requestArgs, originator] = args
2900
+ // If the transaction is inserting outputs into baskets, we also ensure basket permission
2901
+ for (const outIndex in requestArgs.outputs) {
2902
+ const out = requestArgs.outputs[outIndex]
2903
+ if (out.protocol === 'basket insertion') {
2904
+ // Delegate to permission module if needed
2905
+ const pModuleResult = await this.delegateToPModuleIfNeeded(
2906
+ out.insertionRemittance!.basket,
2907
+ 'internalizeAction',
2908
+ requestArgs,
2909
+ originator!,
2910
+ async transformedArgs => {
2911
+ if (out.insertionRemittance!.customInstructions) {
2912
+ ;(transformedArgs as InternalizeActionArgs).outputs[outIndex].insertionRemittance!.customInstructions =
2913
+ await this.maybeEncryptMetadata(out.insertionRemittance!.customInstructions)
2914
+ }
2915
+ return await this.underlying.internalizeAction(transformedArgs as InternalizeActionArgs, originator!)
2916
+ }
2917
+ )
2918
+ if (pModuleResult !== null) {
2919
+ return pModuleResult
2920
+ }
2921
+
2922
+ await this.ensureBasketAccess({
2923
+ originator: originator!,
2924
+ basket: out.insertionRemittance!.basket,
2925
+ reason: requestArgs.description,
2926
+ usageType: 'insertion'
2927
+ })
2928
+ if (out.insertionRemittance!.customInstructions) {
2929
+ requestArgs.outputs[outIndex].insertionRemittance!.customInstructions = await this.maybeEncryptMetadata(
2930
+ out.insertionRemittance!.customInstructions
2931
+ )
2932
+ }
2933
+ }
2934
+ }
2935
+ return this.underlying.internalizeAction(...args)
2936
+ }
2937
+
2938
+ public async listOutputs(
2939
+ ...args: Parameters<WalletInterface['listOutputs']>
2940
+ ): ReturnType<WalletInterface['listOutputs']> {
2941
+ const [requestArgs, originator] = args
2942
+
2943
+ // Delegate to permission module if needed
2944
+ const pModuleResult = await this.delegateToPModuleIfNeeded(
2945
+ requestArgs.basket,
2946
+ 'listOutputs',
2947
+ requestArgs,
2948
+ originator!,
2949
+ async transformedArgs => {
2950
+ const result = await this.underlying.listOutputs(transformedArgs as ListOutputsArgs, originator!)
2951
+ // Apply metadata decryption to permission module response
2952
+ return await this.decryptListOutputsMetadata(result)
2953
+ }
2954
+ )
2955
+ if (pModuleResult !== null) {
2956
+ return pModuleResult
2957
+ }
2958
+
2959
+ // Ensure the originator has permission for the basket.
2960
+ await this.ensureBasketAccess({
2961
+ originator: originator!,
2962
+ basket: requestArgs.basket,
2963
+ reason: 'listOutputs',
2964
+ usageType: 'listing'
2965
+ })
2966
+ const results = await this.underlying.listOutputs(...args)
2967
+
2968
+ // Apply metadata decryption to regular response
2969
+ return await this.decryptListOutputsMetadata(results)
2970
+ }
2971
+
2972
+ public async relinquishOutput(
2973
+ ...args: Parameters<WalletInterface['relinquishOutput']>
2974
+ ): ReturnType<WalletInterface['relinquishOutput']> {
2975
+ const [requestArgs, originator] = args
2976
+
2977
+ // Delegate to permission module if needed
2978
+ const pModuleResult = await this.delegateToPModuleIfNeeded(
2979
+ requestArgs.basket,
2980
+ 'relinquishOutput',
2981
+ requestArgs,
2982
+ originator!,
2983
+ async transformedArgs => {
2984
+ return await this.underlying.relinquishOutput(transformedArgs as RelinquishOutputArgs, originator!)
2985
+ }
2986
+ )
2987
+ if (pModuleResult !== null) {
2988
+ return pModuleResult
2989
+ }
2990
+
2991
+ await this.ensureBasketAccess({
2992
+ originator: originator!,
2993
+ basket: requestArgs.basket,
2994
+ reason: 'relinquishOutput',
2995
+ usageType: 'removal'
2996
+ })
2997
+ return this.underlying.relinquishOutput(...args)
2998
+ }
2999
+
3000
+ public async getPublicKey(
3001
+ ...args: Parameters<WalletInterface['getPublicKey']>
3002
+ ): ReturnType<WalletInterface['getPublicKey']> {
3003
+ const [requestArgs, originator] = args
3004
+
3005
+ if (requestArgs.protocolID) {
3006
+ // Delegate to permission module if needed
3007
+ const pModuleResult = await this.delegateToPModuleIfNeeded(
3008
+ requestArgs.protocolID[1],
3009
+ 'getPublicKey',
3010
+ requestArgs,
3011
+ originator!,
3012
+ async transformedArgs => {
3013
+ return await this.underlying.getPublicKey(transformedArgs as GetPublicKeyArgs, originator!)
3014
+ }
3015
+ )
3016
+ if (pModuleResult !== null) {
3017
+ return pModuleResult
3018
+ }
3019
+
3020
+ // Not a P-protocol, continue with normal permission flow
3021
+ await this.ensureProtocolPermission({
3022
+ originator: originator!,
3023
+ privileged: requestArgs.privileged!,
3024
+ protocolID: requestArgs.protocolID,
3025
+ counterparty: requestArgs.counterparty || 'self',
3026
+ reason: requestArgs.privilegedReason,
3027
+ usageType: 'publicKey'
3028
+ })
3029
+ }
3030
+
3031
+ if (requestArgs.identityKey) {
3032
+ // We also require a minimal protocol permission to retrieve the user's identity key
3033
+ await this.ensureProtocolPermission({
3034
+ originator: originator!,
3035
+ privileged: requestArgs.privileged!,
3036
+ protocolID: [1, 'identity key retrieval'],
3037
+ counterparty: 'self',
3038
+ reason: requestArgs.privilegedReason,
3039
+ usageType: 'identityKey'
3040
+ })
3041
+ }
3042
+
3043
+ return this.underlying.getPublicKey(...args)
3044
+ }
3045
+
3046
+ public async revealCounterpartyKeyLinkage(
3047
+ ...args: Parameters<WalletInterface['revealCounterpartyKeyLinkage']>
3048
+ ): ReturnType<WalletInterface['revealCounterpartyKeyLinkage']> {
3049
+ const [requestArgs, originator] = args
3050
+ await this.ensureProtocolPermission({
3051
+ originator: originator!,
3052
+ privileged: requestArgs.privileged!,
3053
+ protocolID: [2, `counterparty key linkage revelation ${requestArgs.counterparty}`],
3054
+ counterparty: requestArgs.verifier,
3055
+ reason: requestArgs.privilegedReason,
3056
+ usageType: 'linkageRevelation'
3057
+ })
3058
+ return this.underlying.revealCounterpartyKeyLinkage(...args)
3059
+ }
3060
+
3061
+ public async revealSpecificKeyLinkage(
3062
+ ...args: Parameters<WalletInterface['revealSpecificKeyLinkage']>
3063
+ ): ReturnType<WalletInterface['revealSpecificKeyLinkage']> {
3064
+ const [requestArgs, originator] = args
3065
+ await this.ensureProtocolPermission({
3066
+ originator: originator!,
3067
+ privileged: requestArgs.privileged!,
3068
+ protocolID: [
3069
+ 2,
3070
+ `specific key linkage revelation ${requestArgs.protocolID[1]} ${requestArgs.protocolID[0] === 2 ? requestArgs.keyID : 'all'}`
3071
+ ],
3072
+ counterparty: requestArgs.verifier,
3073
+ reason: requestArgs.privilegedReason,
3074
+ usageType: 'linkageRevelation'
3075
+ })
3076
+ return this.underlying.revealSpecificKeyLinkage(...args)
3077
+ }
3078
+
3079
+ public async encrypt(...args: Parameters<WalletInterface['encrypt']>): ReturnType<WalletInterface['encrypt']> {
3080
+ const [requestArgs, originator] = args
3081
+ // Delegate to permission module if needed
3082
+ const pModuleResult = await this.delegateToPModuleIfNeeded(
3083
+ requestArgs.protocolID[1],
3084
+ 'encrypt',
3085
+ requestArgs,
3086
+ originator!,
3087
+ async transformedArgs => {
3088
+ return await this.underlying.encrypt(transformedArgs as WalletEncryptArgs, originator!)
3089
+ }
3090
+ )
3091
+ if (pModuleResult !== null) {
3092
+ return pModuleResult
3093
+ }
3094
+ await this.ensureProtocolPermission({
3095
+ originator: originator!,
3096
+ protocolID: requestArgs.protocolID,
3097
+ privileged: requestArgs.privileged!,
3098
+ counterparty: requestArgs.counterparty || 'self',
3099
+ reason: requestArgs.privilegedReason,
3100
+ usageType: 'encrypting'
3101
+ })
3102
+ return this.underlying.encrypt(...args)
3103
+ }
3104
+
3105
+ public async decrypt(...args: Parameters<WalletInterface['decrypt']>): ReturnType<WalletInterface['decrypt']> {
3106
+ const [requestArgs, originator] = args
3107
+ // Delegate to permission module if needed
3108
+ const pModuleResult = await this.delegateToPModuleIfNeeded(
3109
+ requestArgs.protocolID[1],
3110
+ 'decrypt',
3111
+ requestArgs,
3112
+ originator!,
3113
+ async transformedArgs => {
3114
+ return await this.underlying.decrypt(transformedArgs as WalletDecryptArgs, originator!)
3115
+ }
3116
+ )
3117
+ if (pModuleResult !== null) {
3118
+ return pModuleResult
3119
+ }
3120
+ await this.ensureProtocolPermission({
3121
+ originator: originator!,
3122
+ privileged: requestArgs.privileged!,
3123
+ protocolID: requestArgs.protocolID,
3124
+ counterparty: requestArgs.counterparty || 'self',
3125
+ reason: requestArgs.privilegedReason,
3126
+ usageType: 'encrypting'
3127
+ })
3128
+ return this.underlying.decrypt(...args)
3129
+ }
3130
+
3131
+ public async createHmac(
3132
+ ...args: Parameters<WalletInterface['createHmac']>
3133
+ ): ReturnType<WalletInterface['createHmac']> {
3134
+ const [requestArgs, originator] = args
3135
+ // Delegate to permission module if needed
3136
+ const pModuleResult = await this.delegateToPModuleIfNeeded(
3137
+ requestArgs.protocolID[1],
3138
+ 'createHmac',
3139
+ requestArgs,
3140
+ originator!,
3141
+ async transformedArgs => {
3142
+ return await this.underlying.createHmac(transformedArgs as CreateHmacArgs, originator!)
3143
+ }
3144
+ )
3145
+ if (pModuleResult !== null) {
3146
+ return pModuleResult
3147
+ }
3148
+ await this.ensureProtocolPermission({
3149
+ originator: originator!,
3150
+ privileged: requestArgs.privileged!,
3151
+ protocolID: requestArgs.protocolID,
3152
+ counterparty: requestArgs.counterparty || 'self',
3153
+ reason: requestArgs.privilegedReason,
3154
+ usageType: 'hmac'
3155
+ })
3156
+ return this.underlying.createHmac(...args)
3157
+ }
3158
+
3159
+ public async verifyHmac(
3160
+ ...args: Parameters<WalletInterface['verifyHmac']>
3161
+ ): ReturnType<WalletInterface['verifyHmac']> {
3162
+ const [requestArgs, originator] = args
3163
+ // Delegate to permission module if needed
3164
+ const pModuleResult = await this.delegateToPModuleIfNeeded(
3165
+ requestArgs.protocolID[1],
3166
+ 'verifyHmac',
3167
+ requestArgs,
3168
+ originator!,
3169
+ async transformedArgs => {
3170
+ return await this.underlying.verifyHmac(transformedArgs as VerifyHmacArgs, originator!)
3171
+ }
3172
+ )
3173
+ if (pModuleResult !== null) {
3174
+ return pModuleResult
3175
+ }
3176
+ await this.ensureProtocolPermission({
3177
+ originator: originator!,
3178
+ privileged: requestArgs.privileged!,
3179
+ protocolID: requestArgs.protocolID,
3180
+ counterparty: requestArgs.counterparty || 'self',
3181
+ reason: requestArgs.privilegedReason,
3182
+ usageType: 'hmac'
3183
+ })
3184
+ return this.underlying.verifyHmac(...args)
3185
+ }
3186
+
3187
+ public async createSignature(
3188
+ ...args: Parameters<WalletInterface['createSignature']>
3189
+ ): ReturnType<WalletInterface['createSignature']> {
3190
+ const [requestArgs, originator] = args
3191
+ // Delegate to permission module if needed
3192
+ const pModuleResult = await this.delegateToPModuleIfNeeded(
3193
+ requestArgs.protocolID[1],
3194
+ 'createSignature',
3195
+ requestArgs,
3196
+ originator!,
3197
+ async transformedArgs => {
3198
+ return await this.underlying.createSignature(transformedArgs as CreateSignatureArgs, originator!)
3199
+ }
3200
+ )
3201
+ if (pModuleResult !== null) {
3202
+ return pModuleResult
3203
+ }
3204
+ await this.ensureProtocolPermission({
3205
+ originator: originator!,
3206
+ privileged: requestArgs.privileged!,
3207
+ protocolID: requestArgs.protocolID,
3208
+ counterparty: requestArgs.counterparty || 'self',
3209
+ reason: requestArgs.privilegedReason,
3210
+ usageType: 'signing'
3211
+ })
3212
+ return this.underlying.createSignature(...args)
3213
+ }
3214
+
3215
+ public async verifySignature(
3216
+ ...args: Parameters<WalletInterface['verifySignature']>
3217
+ ): ReturnType<WalletInterface['verifySignature']> {
3218
+ const [requestArgs, originator] = args
3219
+ // Delegate to permission module if needed
3220
+ const pModuleResult = await this.delegateToPModuleIfNeeded(
3221
+ requestArgs.protocolID[1],
3222
+ 'verifySignature',
3223
+ requestArgs,
3224
+ originator!,
3225
+ async transformedArgs => {
3226
+ return await this.underlying.verifySignature(transformedArgs as VerifySignatureArgs, originator!)
3227
+ }
3228
+ )
3229
+ if (pModuleResult !== null) {
3230
+ return pModuleResult
3231
+ }
3232
+ await this.ensureProtocolPermission({
3233
+ originator: originator!,
3234
+ privileged: requestArgs.privileged!,
3235
+ protocolID: requestArgs.protocolID,
3236
+ counterparty: requestArgs.counterparty || 'self',
3237
+ reason: requestArgs.privilegedReason,
3238
+ usageType: 'signing'
3239
+ })
3240
+ return this.underlying.verifySignature(...args)
3241
+ }
3242
+
3243
+ public async acquireCertificate(
3244
+ ...args: Parameters<WalletInterface['acquireCertificate']>
3245
+ ): ReturnType<WalletInterface['acquireCertificate']> {
3246
+ const [requestArgs, originator] = args
3247
+ if (this.config.seekCertificateAcquisitionPermissions) {
3248
+ await this.ensureProtocolPermission({
3249
+ originator: originator!,
3250
+ privileged: requestArgs.privileged!,
3251
+ protocolID: [1, `certificate acquisition ${requestArgs.type}`],
3252
+ counterparty: 'self',
3253
+ reason: requestArgs.privilegedReason,
3254
+ usageType: 'generic'
3255
+ })
3256
+ }
3257
+ return this.underlying.acquireCertificate(...args)
3258
+ }
3259
+
3260
+ public async listCertificates(
3261
+ ...args: Parameters<WalletInterface['listCertificates']>
3262
+ ): ReturnType<WalletInterface['listCertificates']> {
3263
+ const [requestArgs, originator] = args
3264
+ if (this.config.seekCertificateListingPermissions) {
3265
+ await this.ensureProtocolPermission({
3266
+ originator: originator!,
3267
+ privileged: requestArgs.privileged!,
3268
+ protocolID: [1, `certificate list`],
3269
+ counterparty: 'self',
3270
+ reason: requestArgs.privilegedReason,
3271
+ usageType: 'generic'
3272
+ })
3273
+ }
3274
+ return this.underlying.listCertificates(...args)
3275
+ }
3276
+
3277
+ public async proveCertificate(
3278
+ ...args: Parameters<WalletInterface['proveCertificate']>
3279
+ ): ReturnType<WalletInterface['proveCertificate']> {
3280
+ const [requestArgs, originator] = args
3281
+ await this.ensureCertificateAccess({
3282
+ originator: originator!,
3283
+ privileged: requestArgs.privileged!,
3284
+ verifier: requestArgs.verifier,
3285
+ certType: requestArgs.certificate.type!,
3286
+ fields: requestArgs.fieldsToReveal,
3287
+ reason: 'proveCertificate',
3288
+ usageType: 'disclosure'
3289
+ })
3290
+ return this.underlying.proveCertificate(...args)
3291
+ }
3292
+
3293
+ public async relinquishCertificate(
3294
+ ...args: Parameters<WalletInterface['relinquishCertificate']>
3295
+ ): ReturnType<WalletInterface['relinquishCertificate']> {
3296
+ const [requestArgs, originator] = args
3297
+ if (this.config.seekCertificateRelinquishmentPermissions) {
3298
+ await this.ensureProtocolPermission({
3299
+ originator: originator!,
3300
+ privileged: (requestArgs as any).privileged ? true : false,
3301
+ protocolID: [1, `certificate relinquishment ${requestArgs.type}`],
3302
+ counterparty: 'self',
3303
+ reason: (requestArgs as any).privilegedReason || 'relinquishCertificate',
3304
+ usageType: 'generic'
3305
+ })
3306
+ }
3307
+ return this.underlying.relinquishCertificate(...args)
3308
+ }
3309
+
3310
+ public async discoverByIdentityKey(
3311
+ ...args: Parameters<WalletInterface['discoverByIdentityKey']>
3312
+ ): ReturnType<WalletInterface['discoverByIdentityKey']> {
3313
+ const [_, originator] = args
3314
+ if (this.config.seekPermissionsForIdentityResolution) {
3315
+ await this.ensureProtocolPermission({
3316
+ originator: originator!,
3317
+ privileged: false,
3318
+ protocolID: [1, `identity resolution`],
3319
+ counterparty: 'self',
3320
+ reason: 'discoverByIdentityKey',
3321
+ usageType: 'generic'
3322
+ })
3323
+ }
3324
+ return this.underlying.discoverByIdentityKey(...args)
3325
+ }
3326
+
3327
+ public async discoverByAttributes(
3328
+ ...args: Parameters<WalletInterface['discoverByAttributes']>
3329
+ ): ReturnType<WalletInterface['discoverByAttributes']> {
3330
+ const [_, originator] = args
3331
+ if (this.config.seekPermissionsForIdentityResolution) {
3332
+ await this.ensureProtocolPermission({
3333
+ originator: originator!,
3334
+ privileged: false,
3335
+ protocolID: [1, `identity resolution`],
3336
+ counterparty: 'self',
3337
+ reason: 'discoverByAttributes',
3338
+ usageType: 'generic'
3339
+ })
3340
+ }
3341
+ return this.underlying.discoverByAttributes(...args)
3342
+ }
3343
+
3344
+ public async isAuthenticated(
3345
+ ...args: Parameters<WalletInterface['isAuthenticated']>
3346
+ ): ReturnType<WalletInterface['isAuthenticated']> {
3347
+ return this.underlying.isAuthenticated(...args)
3348
+ }
3349
+
3350
+ public async waitForAuthentication(
3351
+ ...args: Parameters<WalletInterface['waitForAuthentication']>
3352
+ ): ReturnType<WalletInterface['waitForAuthentication']> {
3353
+ let [_, originator] = args
3354
+ if (this.config.seekGroupedPermission && originator) {
3355
+ const { normalized: normalizedOriginator } = this.prepareOriginator(originator)
3356
+ originator = normalizedOriginator
3357
+ // 1. Fetch manifest.json from the originator
3358
+ let groupPermissions: GroupedPermissions | undefined
3359
+ try {
3360
+ const proto = originator.startsWith('localhost:') ? 'http' : 'https'
3361
+ const response = await fetch(`${proto}://${originator}/manifest.json`)
3362
+ if (response.ok) {
3363
+ const manifest = await response.json()
3364
+ if (manifest?.babbage?.groupPermissions) {
3365
+ groupPermissions = manifest.babbage.groupPermissions
3366
+ }
3367
+ }
3368
+ } catch (e) {
3369
+ // Ignore fetch/parse errors, just proceed without group permissions.
3370
+ }
3371
+
3372
+ if (groupPermissions) {
3373
+ // 2. Filter out already-granted permissions
3374
+ const permissionsToRequest: GroupedPermissions = {
3375
+ protocolPermissions: [],
3376
+ basketAccess: [],
3377
+ certificateAccess: []
3378
+ }
3379
+
3380
+ if (groupPermissions.spendingAuthorization) {
3381
+ const hasAuth = await this.hasSpendingAuthorization({
3382
+ originator,
3383
+ satoshis: groupPermissions.spendingAuthorization.amount
3384
+ })
3385
+ if (!hasAuth) {
3386
+ permissionsToRequest.spendingAuthorization = groupPermissions.spendingAuthorization
3387
+ }
3388
+ }
3389
+
3390
+ for (const p of groupPermissions.protocolPermissions || []) {
3391
+ const hasPerm = await this.hasProtocolPermission({
3392
+ originator,
3393
+ privileged: false, // Privilege is never allowed here
3394
+ protocolID: p.protocolID,
3395
+ counterparty: p.counterparty || 'self'
3396
+ })
3397
+ if (!hasPerm) {
3398
+ permissionsToRequest.protocolPermissions!.push(p)
3399
+ }
3400
+ }
3401
+
3402
+ for (const b of groupPermissions.basketAccess || []) {
3403
+ const hasAccess = await this.hasBasketAccess({
3404
+ originator,
3405
+ basket: b.basket
3406
+ })
3407
+ if (!hasAccess) {
3408
+ permissionsToRequest.basketAccess!.push(b)
3409
+ }
3410
+ }
3411
+
3412
+ for (const c of groupPermissions.certificateAccess || []) {
3413
+ const hasAccess = await this.hasCertificateAccess({
3414
+ originator,
3415
+ privileged: false, // Privilege is never allowed here for security
3416
+ verifier: c.verifierPublicKey,
3417
+ certType: c.type,
3418
+ fields: c.fields
3419
+ })
3420
+ if (!hasAccess) {
3421
+ permissionsToRequest.certificateAccess!.push(c)
3422
+ }
3423
+ }
3424
+
3425
+ // 3. If any permissions are left to request, start the flow
3426
+ const hasRequests =
3427
+ permissionsToRequest.spendingAuthorization ||
3428
+ (permissionsToRequest.protocolPermissions?.length ?? 0) > 0 ||
3429
+ (permissionsToRequest.basketAccess?.length ?? 0) > 0 ||
3430
+ (permissionsToRequest.certificateAccess?.length ?? 0) > 0
3431
+
3432
+ if (hasRequests) {
3433
+ const key = `group:${originator}`
3434
+ if (this.activeRequests.has(key)) {
3435
+ // Another call is already waiting, piggyback on it
3436
+ await new Promise<boolean>((resolve, reject) => {
3437
+ this.activeRequests.get(key)!.pending.push({ resolve, reject })
3438
+ })
3439
+ } else {
3440
+ // This is the first call, create a new request
3441
+ try {
3442
+ await new Promise<boolean>(async (resolve, reject) => {
3443
+ this.activeRequests.set(key, {
3444
+ request: { originator: originator as string, permissions: permissionsToRequest },
3445
+ pending: [{ resolve, reject }]
3446
+ })
3447
+
3448
+ await this.callEvent('onGroupedPermissionRequested', {
3449
+ requestID: key,
3450
+ originator,
3451
+ permissions: permissionsToRequest
3452
+ })
3453
+ })
3454
+ } catch (e) {
3455
+ // Permission was denied, re-throw to stop execution
3456
+ throw e
3457
+ }
3458
+ }
3459
+ }
3460
+ }
3461
+ }
3462
+
3463
+ // Finally, after handling grouped permissions, call the underlying method.
3464
+ return this.underlying.waitForAuthentication(...args)
3465
+ }
3466
+
3467
+ public async getHeight(...args: Parameters<WalletInterface['getHeight']>): ReturnType<WalletInterface['getHeight']> {
3468
+ return this.underlying.getHeight(...args)
3469
+ }
3470
+
3471
+ public async getHeaderForHeight(
3472
+ ...args: Parameters<WalletInterface['getHeaderForHeight']>
3473
+ ): ReturnType<WalletInterface['getHeaderForHeight']> {
3474
+ return this.underlying.getHeaderForHeight(...args)
3475
+ }
3476
+
3477
+ public async getNetwork(
3478
+ ...args: Parameters<WalletInterface['getNetwork']>
3479
+ ): ReturnType<WalletInterface['getNetwork']> {
3480
+ return this.underlying.getNetwork(...args)
3481
+ }
3482
+
3483
+ public async getVersion(
3484
+ ...args: Parameters<WalletInterface['getVersion']>
3485
+ ): ReturnType<WalletInterface['getVersion']> {
3486
+ return this.underlying.getVersion(...args)
3487
+ }
3488
+
3489
+ /* ---------------------------------------------------------------------
3490
+ * 8) INTERNAL HELPER UTILITIES
3491
+ * --------------------------------------------------------------------- */
3492
+
3493
+ /** Returns true if the specified origin is the admin originator. */
3494
+ private isAdminOriginator(originator: string): boolean {
3495
+ return this.normalizeOriginator(originator) === this.adminOriginator
3496
+ }
3497
+
3498
+ /**
3499
+ * Checks if the given protocol is admin-reserved per BRC-100 rules:
3500
+ *
3501
+ * - Must not start with `admin` (admin-reserved)
3502
+ * - Must not start with `p ` (allows for future specially permissioned protocols)
3503
+ *
3504
+ * If it violates these rules and the caller is not admin, we consider it "admin-only."
3505
+ */
3506
+ private isAdminProtocol(proto: WalletProtocol): boolean {
3507
+ const protocolName = proto[1]
3508
+ if (protocolName.startsWith('admin')) {
3509
+ return true
3510
+ }
3511
+ return false
3512
+ }
3513
+
3514
+ /**
3515
+ * Checks if the given label is admin-reserved per BRC-100 rules:
3516
+ *
3517
+ * - Must not start with `admin` (admin-reserved)
3518
+ *
3519
+ * If it violates these rules and the caller is not admin, we consider it "admin-only."
3520
+ */
3521
+ private isAdminLabel(label: string): boolean {
3522
+ if (label.startsWith('admin')) {
3523
+ return true
3524
+ }
3525
+ return false
3526
+ }
3527
+
3528
+ /**
3529
+ * Checks if the given basket is admin-reserved per BRC-100 rules:
3530
+ *
3531
+ * - Must not start with `admin`
3532
+ * - Must not be `default` (some wallets use this for internal operations)
3533
+ * - Must not start with `p ` (future specially permissioned baskets)
3534
+ */
3535
+ private isAdminBasket(basket: string): boolean {
3536
+ if (basket === 'default') return true
3537
+ if (basket.startsWith('admin')) return true
3538
+ return false
3539
+ }
3540
+
3541
+ /**
3542
+ * Returns true if we have a cached record that the permission identified by
3543
+ * `key` is valid and unexpired.
3544
+ */
3545
+ private isPermissionCached(key: string): boolean {
3546
+ const entry = this.permissionCache.get(key)
3547
+ if (!entry) return false
3548
+ if (Date.now() - entry.cachedAt > WalletPermissionsManager.CACHE_TTL_MS) {
3549
+ this.permissionCache.delete(key)
3550
+ return false
3551
+ }
3552
+ if (this.isTokenExpired(entry.expiry)) {
3553
+ this.permissionCache.delete(key)
3554
+ return false
3555
+ }
3556
+ return true
3557
+ }
3558
+
3559
+ /** Caches the fact that the permission for `key` is valid until `expiry`. */
3560
+ private cachePermission(key: string, expiry: number): void {
3561
+ this.permissionCache.set(key, { expiry, cachedAt: Date.now() })
3562
+ }
3563
+
3564
+ /** Records that a non-spending permission was just granted so we can skip re-prompting briefly. */
3565
+ private markRecentGrant(request: PermissionRequest): void {
3566
+ if (request.type === 'spending') return
3567
+ const key = this.buildRequestKey(request)
3568
+ if (!key) return
3569
+ this.recentGrants.set(key, Date.now() + WalletPermissionsManager.RECENT_GRANT_COVER_MS)
3570
+ }
3571
+
3572
+ /** Returns true if we are inside the short "cover window" immediately after granting permission. */
3573
+ private isRecentlyGranted(key: string): boolean {
3574
+ const expiry = this.recentGrants.get(key)
3575
+ if (!expiry) return false
3576
+ if (Date.now() > expiry) {
3577
+ this.recentGrants.delete(key)
3578
+ return false
3579
+ }
3580
+ return true
3581
+ }
3582
+
3583
+ /** Normalizes and canonicalizes originator domains (e.g., lowercase + drop default ports). */
3584
+ private normalizeOriginator(originator?: string): string {
3585
+ if (!originator) return ''
3586
+ const trimmed = originator.trim()
3587
+ if (!trimmed) {
3588
+ return ''
3589
+ }
3590
+
3591
+ try {
3592
+ const hasScheme = /^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//.test(trimmed)
3593
+ const candidate = hasScheme ? trimmed : `https://${trimmed}`
3594
+ const url = new URL(candidate)
3595
+ if (!url.hostname) {
3596
+ return trimmed.toLowerCase()
3597
+ }
3598
+ const hostname = url.hostname.toLowerCase()
3599
+ const needsBrackets = hostname.includes(':')
3600
+ const baseHost = needsBrackets ? `[${hostname}]` : hostname
3601
+ const port = url.port
3602
+ const defaultPort = WalletPermissionsManager.DEFAULT_PORTS[url.protocol]
3603
+ if (port && defaultPort && port === defaultPort) {
3604
+ return baseHost
3605
+ }
3606
+ return port ? `${baseHost}:${port}` : baseHost
3607
+ } catch {
3608
+ // Fall back to a conservative lowercase trim if URL parsing fails.
3609
+ return trimmed.toLowerCase()
3610
+ }
3611
+ }
3612
+
3613
+ /**
3614
+ * Produces a normalized originator value along with the set of legacy
3615
+ * representations that should be considered when searching for existing
3616
+ * permission tokens (for backwards compatibility).
3617
+ */
3618
+ private prepareOriginator(originator?: string): { normalized: string; lookupValues: string[] } {
3619
+ const trimmed = originator?.trim()
3620
+ if (!trimmed) {
3621
+ throw new Error('Originator is required for permission checks.')
3622
+ }
3623
+ const normalized = this.normalizeOriginator(trimmed) || trimmed.toLowerCase()
3624
+ const lookupValues = Array.from(new Set([trimmed, normalized])).filter(Boolean)
3625
+ return { normalized, lookupValues }
3626
+ }
3627
+
3628
+ /**
3629
+ * Builds a unique list of originator variants that should be searched when
3630
+ * looking up on-chain tokens (e.g., legacy raw + normalized forms).
3631
+ */
3632
+ private buildOriginatorLookupValues(...origins: Array<string | undefined>): string[] {
3633
+ const variants = new Set<string>()
3634
+ for (const origin of origins) {
3635
+ const trimmed = origin?.trim()
3636
+ if (trimmed) {
3637
+ variants.add(trimmed)
3638
+ }
3639
+ }
3640
+ return Array.from(variants)
3641
+ }
3642
+
3643
+ /**
3644
+ * Builds a "map key" string so that identical requests (e.g. "protocol:domain:true:protoName:counterparty")
3645
+ * do not produce multiple user prompts.
3646
+ */
3647
+ private buildRequestKey(r: PermissionRequest): string {
3648
+ const normalizedOriginator = this.normalizeOriginator(r.originator)
3649
+ switch (r.type) {
3650
+ case 'protocol':
3651
+ return `proto:${normalizedOriginator}:${!!r.privileged}:${r.protocolID?.join(',')}:${r.counterparty}`
3652
+ case 'basket':
3653
+ return `basket:${normalizedOriginator}:${r.basket}`
3654
+ case 'certificate':
3655
+ return `cert:${normalizedOriginator}:${!!r.privileged}:${r.certificate?.verifier}:${r.certificate?.certType}:${r.certificate?.fields.join('|')}`
3656
+ case 'spending':
3657
+ return `spend:${normalizedOriginator}:${r.spending?.satoshis}`
3658
+ }
3659
+ }
3660
+ }