UNPKG

@bsv/wallet-toolbox-client

Version:
1,100 lines 97.3 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.WalletPermissionsManager = void 0; const sdk_1 = require("@bsv/sdk"); const sdk_2 = require("./sdk"); ////// TODO: ADD SUPPORT FOR ADMIN COUNTERPARTIES BASED ON WALLET STORAGE ////// PROHIBITION OF SPECIAL OPERATIONS IS ALSO CRITICAL. ////// !!!!!!!! SECURITY-CRITICAL ADDITION — DO NOT USE UNTIL IMPLEMENTED. function deepEqual(object1, object2) { if (object1 === null || object1 === undefined || object2 === null || object2 === undefined) { return object1 === object2; } const keys1 = Object.keys(object1); const keys2 = Object.keys(object2); if (keys1.length !== keys2.length) { return false; } for (const key of keys1) { const val1 = object1[key]; const val2 = object2[key]; const areObjects = isObject(val1) && isObject(val2); if ((areObjects && !deepEqual(val1, val2)) || (!areObjects && val1 !== val2)) { return false; } } return true; } function isObject(object) { return object != null && typeof object === 'object'; } /** * A map from each permission type to a special "admin basket" name used for storing * the tokens. The tokens themselves are unspent transaction outputs (UTXOs) with a * specialized PushDrop script that references the originator, expiry, etc. */ const BASKET_MAP = { protocol: 'admin protocol-permission', basket: 'admin basket-access', certificate: 'admin certificate-access', spending: 'admin spending-authorization' }; /** * @class WalletPermissionsManager * * Wraps an underlying BRC-100 `Wallet` implementation with permissions management capabilities. * The manager intercepts calls from external applications (identified by originators), checks if the request is allowed, * and if not, orchestrates user permission flows. It creates or renews on-chain tokens in special * admin baskets to track these authorizations. Finally, it proxies the actual call to the underlying wallet. * * ### Key Responsibilities: * - **Permission Checking**: Before standard wallet operations (e.g. `encrypt`), * the manager checks if a valid permission token exists. If not, it attempts to request permission from the user. * - **On-Chain Tokens**: When permission is granted, the manager stores it as an unspent "PushDrop" output. * This can be spent later to revoke or renew the permission. * - **Callbacks**: The manager triggers user-defined callbacks on permission requests (to show a UI prompt), * on grants/denials, and on internal processes. * * ### Implementation Notes: * - The manager follows the BRC-100 `createAction` + `signAction` pattern for building or spending these tokens. * - Token revocation or renewal uses standard BRC-100 flows: we build a transaction that consumes * the old token UTXO and outputs a new one (or none, if fully revoked). */ class WalletPermissionsManager { /** * Constructs a new Permissions Manager instance. * * @param underlyingWallet The underlying BRC-100 wallet, where requests are forwarded after permission is granted * @param adminOriginator The domain or FQDN that is automatically allowed everything * @param config A set of boolean flags controlling how strictly permissions are enforced */ constructor(underlyingWallet, adminOriginator, config = {}) { /** * Event callbacks that external code can subscribe to, e.g. to show a UI prompt * or log events. Each event can have multiple handlers. */ this.callbacks = { onProtocolPermissionRequested: [], onBasketAccessRequested: [], onCertificateAccessRequested: [], onSpendingAuthorizationRequested: [], onGroupedPermissionRequested: [] }; /** * We queue parallel requests for the same resource so that only one * user prompt is created for a single resource. If multiple calls come * in at once for the same "protocol:domain:privileged:counterparty" etc., * they get merged. * * The key is a string derived from the operation; the value is an object with a reference to the * associated request and an array of pending promise resolve/reject pairs, one for each active * operation that's waiting on the particular resource described by the key. */ this.activeRequests = new Map(); /** Cache recently confirmed permissions to avoid repeated lookups. */ this.permissionCache = new Map(); this.underlying = underlyingWallet; this.adminOriginator = adminOriginator; // Default all config options to true unless specified this.config = { seekProtocolPermissionsForSigning: true, seekProtocolPermissionsForEncrypting: true, seekProtocolPermissionsForHMAC: true, seekPermissionsForKeyLinkageRevelation: true, seekPermissionsForPublicKeyRevelation: true, seekPermissionsForIdentityKeyRevelation: true, seekPermissionsForIdentityResolution: true, seekBasketInsertionPermissions: true, seekBasketRemovalPermissions: true, seekBasketListingPermissions: true, seekPermissionWhenApplyingActionLabels: true, seekPermissionWhenListingActionsByLabel: true, seekCertificateDisclosurePermissions: true, seekCertificateAcquisitionPermissions: true, seekCertificateRelinquishmentPermissions: true, seekCertificateListingPermissions: true, encryptWalletMetadata: true, seekSpendingPermissions: true, seekGroupedPermission: true, differentiatePrivilegedOperations: true, ...config // override with user-specified config }; } /* --------------------------------------------------------------------- * 1) PUBLIC API FOR REGISTERING CALLBACKS (UI PROMPTS, LOGGING, ETC.) * --------------------------------------------------------------------- */ /** * Binds a callback function to a named event, such as `onProtocolPermissionRequested`. * * @param eventName The name of the event to listen to * @param handler A function that handles the event * @returns A numeric ID you can use to unbind later */ bindCallback(eventName, handler) { const arr = this.callbacks[eventName]; arr.push(handler); return arr.length - 1; } /** * Unbinds a previously registered callback by either its numeric ID (returned by `bindCallback`) * or by exact function reference. * * @param eventName The event name, e.g. "onProtocolPermissionRequested" * @param reference Either the numeric ID or the function reference * @returns True if successfully unbound, false otherwise */ unbindCallback(eventName, reference) { if (!this.callbacks[eventName]) return false; const arr = this.callbacks[eventName]; if (typeof reference === 'number') { if (arr[reference]) { arr[reference] = null; return true; } return false; } else { const index = arr.indexOf(reference); if (index !== -1) { arr[index] = null; return true; } return false; } } /** * Internally triggers a named event, calling all subscribed listeners. * Each callback is awaited in turn (though errors are swallowed so that * one failing callback doesn't prevent the others). * * @param eventName The event name * @param param The parameter object passed to all listeners */ async callEvent(eventName, param) { const arr = this.callbacks[eventName] || []; for (const cb of arr) { if (typeof cb === 'function') { try { await cb(param); } catch (e) { // Intentionally swallow errors from user-provided callbacks } } } } /* --------------------------------------------------------------------- * 2) PERMISSION (GRANT / DENY) METHODS * --------------------------------------------------------------------- */ /** * Grants a previously requested permission. * This method: * 1) Resolves all pending promise calls waiting on this request * 2) Optionally creates or renews an on-chain PushDrop token (unless `ephemeral===true`) * * @param params requestID to identify which request is granted, plus optional expiry * or `ephemeral` usage, etc. */ async grantPermission(params) { // 1) Identify the matching queued requests in `activeRequests` const matching = this.activeRequests.get(params.requestID); if (!matching) { throw new Error('Request ID not found.'); } // 2) Mark all matching requests as resolved, deleting the entry for (const x of matching.pending) { x.resolve(true); } this.activeRequests.delete(params.requestID); // 3) If `ephemeral !== true`, we create or renew an on-chain token if (!params.ephemeral) { const request = matching.request; if (!request.renewal) { // brand-new permission token await this.createPermissionOnChain(request, params.expiry || Math.floor(Date.now() / 1000) + 3600 * 24 * 30, // default 30-day expiry params.amount); } else { // renewal => spend the old token, produce a new one await this.renewPermissionOnChain(request.previousToken, request, params.expiry || Math.floor(Date.now() / 1000) + 3600 * 24 * 30, params.amount); } } // Only cache non-ephemeral permissions // Ephemeral permissions should not be cached as they are one-time authorizations if (!params.ephemeral) { const expiry = params.expiry || Math.floor(Date.now() / 1000) + 3600 * 24 * 30; const key = this.buildRequestKey(matching.request); this.cachePermission(key, expiry); } } /** * Denies a previously requested permission. * This method rejects all pending promise calls waiting on that request * * @param requestID requestID identifying which request to deny */ async denyPermission(requestID) { // 1) Identify the matching requests const matching = this.activeRequests.get(requestID); if (!matching) { throw new Error('Request ID not found.'); } // 2) Reject all matching requests, deleting the entry for (const x of matching.pending) { x.reject(new Error('Permission denied.')); } this.activeRequests.delete(requestID); } /** * Grants a previously requested grouped permission. * @param params.requestID The ID of the request being granted. * @param params.granted A subset of the originally requested permissions that the user has granted. * @param params.expiry An optional expiry time (in seconds) for the new permission tokens. */ async grantGroupedPermission(params) { var _a, _b, _c; const matching = this.activeRequests.get(params.requestID); if (!matching) { throw new Error('Request ID not found.'); } const originalRequest = matching.request; const { originator, permissions: requestedPermissions } = originalRequest; // --- Validation: Ensure granted permissions are a subset of what was requested --- if (params.granted.spendingAuthorization && !deepEqual(params.granted.spendingAuthorization, requestedPermissions.spendingAuthorization)) { throw new Error('Granted spending authorization does not match the original request.'); } if ((_a = params.granted.protocolPermissions) === null || _a === void 0 ? void 0 : _a.some(g => { var _a; return !((_a = requestedPermissions.protocolPermissions) === null || _a === void 0 ? void 0 : _a.find(r => deepEqual(r, g))); })) { throw new Error('Granted protocol permissions are not a subset of the original request.'); } if ((_b = params.granted.basketAccess) === null || _b === void 0 ? void 0 : _b.some(g => { var _a; return !((_a = requestedPermissions.basketAccess) === null || _a === void 0 ? void 0 : _a.find(r => deepEqual(r, g))); })) { throw new Error('Granted basket access permissions are not a subset of the original request.'); } if ((_c = params.granted.certificateAccess) === null || _c === void 0 ? void 0 : _c.some(g => { var _a; return !((_a = requestedPermissions.certificateAccess) === null || _a === void 0 ? void 0 : _a.find(r => deepEqual(r, g))); })) { throw new Error('Granted certificate access permissions are not a subset of the original request.'); } // --- End Validation --- const expiry = params.expiry || Math.floor(Date.now() / 1000) + 3600 * 24 * 30; // 30-day default if (params.granted.spendingAuthorization) { await this.createPermissionOnChain({ type: 'spending', originator, spending: { satoshis: params.granted.spendingAuthorization.amount }, reason: params.granted.spendingAuthorization.description }, 0, // No expiry for spending tokens params.granted.spendingAuthorization.amount); } for (const p of params.granted.protocolPermissions || []) { await this.createPermissionOnChain({ type: 'protocol', originator, privileged: false, // No privileged protocols allowed in groups for added security. protocolID: p.protocolID, counterparty: p.counterparty || 'self', reason: p.description }, expiry); } for (const b of params.granted.basketAccess || []) { await this.createPermissionOnChain({ type: 'basket', originator, basket: b.basket, reason: b.description }, expiry); } for (const c of params.granted.certificateAccess || []) { await this.createPermissionOnChain({ type: 'certificate', originator, privileged: false, // No certificates on the privileged identity are allowed as part of groups. certificate: { verifier: c.verifierPublicKey, certType: c.type, fields: c.fields }, reason: c.description }, expiry); } // Resolve all pending promises for this request for (const p of matching.pending) { p.resolve(true); } this.activeRequests.delete(params.requestID); } /** * Denies a previously requested grouped permission. * @param requestID The ID of the request being denied. */ async denyGroupedPermission(requestID) { const matching = this.activeRequests.get(requestID); if (!matching) { throw new Error('Request ID not found.'); } const err = new Error('The user has denied the request for permission.'); err.code = 'ERR_PERMISSION_DENIED'; for (const p of matching.pending) { p.reject(err); } this.activeRequests.delete(requestID); } /* --------------------------------------------------------------------- * 3) THE "ENSURE" METHODS: CHECK IF PERMISSION EXISTS, OTHERWISE PROMPT * --------------------------------------------------------------------- */ /** * Ensures the originator has protocol usage permission. * If no valid (unexpired) permission token is found, triggers a permission request flow. */ async ensureProtocolPermission({ originator, privileged, protocolID, counterparty, reason, seekPermission = true, usageType }) { // 1) adminOriginator can do anything if (this.isAdminOriginator(originator)) return true; // 2) If security level=0, we consider it "open" usage const [level, protoName] = protocolID; if (level === 0) return true; // 3) If protocol is admin-reserved, block if (this.isAdminProtocol(protocolID)) { throw new Error(`Protocol “${protoName}” is admin-only.`); } // Allow the configured exceptions. if (usageType === 'signing' && !this.config.seekProtocolPermissionsForSigning) { return true; } if (usageType === 'encrypting' && !this.config.seekProtocolPermissionsForEncrypting) { return true; } if (usageType === 'hmac' && !this.config.seekProtocolPermissionsForHMAC) { return true; } if (usageType === 'publicKey' && !this.config.seekPermissionsForPublicKeyRevelation) { return true; } if (usageType === 'identityKey' && !this.config.seekPermissionsForIdentityKeyRevelation) { return true; } if (usageType === 'linkageRevelation' && !this.config.seekPermissionsForKeyLinkageRevelation) { return true; } if (!this.config.differentiatePrivilegedOperations) { privileged = false; } const cacheKey = this.buildRequestKey({ type: 'protocol', originator, privileged, protocolID, counterparty }); if (this.isPermissionCached(cacheKey)) { return true; } // 4) Attempt to find a valid token in the internal basket const token = await this.findProtocolToken(originator, privileged, protocolID, counterparty, /*includeExpired=*/ true); if (token) { if (!this.isTokenExpired(token.expiry)) { // valid and unexpired this.cachePermission(cacheKey, token.expiry); return true; } else { // has a token but expired => request renewal if allowed if (!seekPermission) { throw new Error(`Protocol permission expired and no further user consent allowed (seekPermission=false).`); } return await this.requestPermissionFlow({ type: 'protocol', originator, privileged, protocolID, counterparty, reason, renewal: true, previousToken: token }); } } else { // No token found => request a new one if allowed if (!seekPermission) { throw new Error(`No protocol permission token found (seekPermission=false).`); } const granted = await this.requestPermissionFlow({ type: 'protocol', originator, privileged, protocolID, counterparty, reason, renewal: false }); return granted; } } /** * Ensures the originator has basket usage permission for the specified basket. * If not, triggers a permission request flow. */ async ensureBasketAccess({ originator, basket, reason, seekPermission = true, usageType }) { if (this.isAdminOriginator(originator)) return true; if (this.isAdminBasket(basket)) { throw new Error(`Basket “${basket}” is admin-only.`); } if (usageType === 'insertion' && !this.config.seekBasketInsertionPermissions) return true; if (usageType === 'removal' && !this.config.seekBasketRemovalPermissions) return true; if (usageType === 'listing' && !this.config.seekBasketListingPermissions) return true; const cacheKey = this.buildRequestKey({ type: 'basket', originator, basket }); if (this.isPermissionCached(cacheKey)) { return true; } const token = await this.findBasketToken(originator, basket, true); if (token) { if (!this.isTokenExpired(token.expiry)) { this.cachePermission(cacheKey, token.expiry); return true; } else { if (!seekPermission) { throw new Error(`Basket permission expired (seekPermission=false).`); } return await this.requestPermissionFlow({ type: 'basket', originator, basket, reason, renewal: true, previousToken: token }); } } else { // none if (!seekPermission) { throw new Error(`No basket permission found, and no user consent allowed (seekPermission=false).`); } const granted = await this.requestPermissionFlow({ type: 'basket', originator, basket, reason, renewal: false }); return granted; } } /** * Ensures the originator has a valid certificate permission. * This is relevant when revealing certificate fields in DCAP contexts. */ async ensureCertificateAccess({ originator, privileged, verifier, certType, fields, reason, seekPermission = true, usageType }) { if (this.isAdminOriginator(originator)) return true; if (usageType === 'disclosure' && !this.config.seekCertificateDisclosurePermissions) { return true; } if (!this.config.differentiatePrivilegedOperations) { privileged = false; } const cacheKey = this.buildRequestKey({ type: 'certificate', originator, privileged, certificate: { verifier, certType, fields } }); if (this.isPermissionCached(cacheKey)) { return true; } const token = await this.findCertificateToken(originator, privileged, verifier, certType, fields, /*includeExpired=*/ true); if (token) { if (!this.isTokenExpired(token.expiry)) { this.cachePermission(cacheKey, token.expiry); return true; } else { if (!seekPermission) { throw new Error(`Certificate permission expired (seekPermission=false).`); } return await this.requestPermissionFlow({ type: 'certificate', originator, privileged, certificate: { verifier, certType, fields }, reason, renewal: true, previousToken: token }); } } else { if (!seekPermission) { throw new Error(`No certificate permission found (seekPermission=false).`); } const granted = await this.requestPermissionFlow({ type: 'certificate', originator, privileged, certificate: { verifier, certType, fields }, reason, renewal: false }); return granted; } } /** * Ensures the originator has spending authorization (DSAP) for a certain satoshi amount. * If the existing token limit is insufficient, attempts to renew. If no token, attempts to create one. */ async ensureSpendingAuthorization({ originator, satoshis, lineItems, reason, seekPermission = true }) { if (this.isAdminOriginator(originator)) return true; if (!this.config.seekSpendingPermissions) { // We skip spending permission entirely return true; } const cacheKey = this.buildRequestKey({ type: 'spending', originator, spending: { satoshis } }); if (this.isPermissionCached(cacheKey)) { return true; } const token = await this.findSpendingToken(originator); if (token === null || token === void 0 ? void 0 : token.authorizedAmount) { // Check how much has been spent so far const spentSoFar = await this.querySpentSince(token); if (spentSoFar + satoshis <= token.authorizedAmount) { this.cachePermission(cacheKey, token.expiry); return true; } else { // Renew if possible if (!seekPermission) { throw new Error(`Spending authorization insufficient for ${satoshis}, no user consent (seekPermission=false).`); } return await this.requestPermissionFlow({ type: 'spending', originator, spending: { satoshis, lineItems }, reason, renewal: true, previousToken: token }); } } else { // no token if (!seekPermission) { throw new Error(`No spending authorization found, (seekPermission=false).`); } return await this.requestPermissionFlow({ type: 'spending', originator, spending: { satoshis, lineItems }, reason, renewal: false }); } } /** * Ensures the originator has label usage permission. * If no valid (unexpired) permission token is found, triggers a permission request flow. */ async ensureLabelAccess({ originator, label, reason, seekPermission = true, usageType }) { // 1) adminOriginator can do anything if (this.isAdminOriginator(originator)) return true; // 2) If label is admin-reserved, block if (this.isAdminLabel(label)) { throw new Error(`Label “${label}” is admin-only.`); } if (usageType === 'apply' && !this.config.seekPermissionWhenApplyingActionLabels) { return true; } if (usageType === 'list' && !this.config.seekPermissionWhenListingActionsByLabel) { return true; } const cacheKey = this.buildRequestKey({ type: 'protocol', originator, privileged: false, protocolID: [1, `action label ${label}`], counterparty: 'self' }); if (this.isPermissionCached(cacheKey)) { return true; } // 3) Let ensureProtocolPermission handle the rest. return await this.ensureProtocolPermission({ originator, privileged: false, protocolID: [1, `action label ${label}`], counterparty: 'self', reason, seekPermission, usageType: 'generic' }); } /** * A central method that triggers the permission request flow. * - It checks if there's already an active request for the same key * - If so, we wait on that existing request rather than creating a duplicative one * - Otherwise we create a new request queue, call the relevant "onXXXRequested" event, * and return a promise that resolves once permission is granted or rejects if denied. */ async requestPermissionFlow(r) { const key = this.buildRequestKey(r); // If there's already a queue for the same resource, we piggyback on it const existingQueue = this.activeRequests.get(key); if (existingQueue && existingQueue.pending.length > 0) { return new Promise((resolve, reject) => { existingQueue.pending.push({ resolve, reject }); }); } // Otherwise, create a new queue with a single entry // Return a promise that resolves or rejects once the user grants/denies return new Promise(async (resolve, reject) => { this.activeRequests.set(key, { request: r, pending: [{ resolve, reject }] }); // Fire the relevant onXXXRequested event (which one depends on r.type) switch (r.type) { case 'protocol': await this.callEvent('onProtocolPermissionRequested', { ...r, requestID: key }); break; case 'basket': await this.callEvent('onBasketAccessRequested', { ...r, requestID: key }); break; case 'certificate': await this.callEvent('onCertificateAccessRequested', { ...r, requestID: key }); break; case 'spending': await this.callEvent('onSpendingAuthorizationRequested', { ...r, requestID: key }); break; } }); } /** We always use `keyID="1"` and `counterparty="self"` for these encryption ops. */ async encryptPermissionTokenField(plaintext) { const data = typeof plaintext === 'string' ? sdk_1.Utils.toArray(plaintext, 'utf8') : plaintext; const { ciphertext } = await this.underlying.encrypt({ plaintext: data, protocolID: WalletPermissionsManager.PERM_TOKEN_ENCRYPTION_PROTOCOL, keyID: '1' }, this.adminOriginator); return ciphertext; } async decryptPermissionTokenField(ciphertext) { try { const { plaintext } = await this.underlying.decrypt({ ciphertext, protocolID: WalletPermissionsManager.PERM_TOKEN_ENCRYPTION_PROTOCOL, keyID: '1' }, this.adminOriginator); return plaintext; } catch (e) { return ciphertext; } } /** * Encrypts wallet metadata if configured to do so, otherwise returns the original plaintext for storage. * @param plaintext The metadata to encrypt if configured to do so * @returns The encrypted metadata, or the original value if encryption was disabled. */ async maybeEncryptMetadata(plaintext) { if (!this.config.encryptWalletMetadata) { return plaintext; } const { ciphertext } = await this.underlying.encrypt({ plaintext: sdk_1.Utils.toArray(plaintext, 'utf8'), protocolID: WalletPermissionsManager.METADATA_ENCRYPTION_PROTOCOL, keyID: '1' }, this.adminOriginator); return sdk_1.Utils.toBase64(ciphertext); } /** * Attempts to decrypt metadata. if decryption fails, assumes the value is already plaintext and returns it. * @param ciphertext The metadata to attempt decryption for. * @returns The decrypted metadata. If decryption fails, returns the original value instead. */ async maybeDecryptMetadata(ciphertext) { try { const { plaintext } = await this.underlying.decrypt({ ciphertext: sdk_1.Utils.toArray(ciphertext, 'base64'), protocolID: WalletPermissionsManager.METADATA_ENCRYPTION_PROTOCOL, keyID: '1' }, this.adminOriginator); return sdk_1.Utils.toUTF8(plaintext); } catch (e) { return ciphertext; } } /** Helper to see if a token's expiry is in the past. */ isTokenExpired(expiry) { const now = Math.floor(Date.now() / 1000); return expiry > 0 && expiry < now; } /** Looks for a DPACP permission token matching origin/domain, privileged, protocol, cpty. */ async findProtocolToken(originator, privileged, protocolID, counterparty, includeExpired) { const [secLevel, protoName] = protocolID; const tags = [ `originator ${originator}`, `privileged ${!!privileged}`, `protocolName ${protoName}`, `protocolSecurityLevel ${secLevel}` ]; if (secLevel === 2) { tags.push(`counterparty ${counterparty}`); } const result = await this.underlying.listOutputs({ basket: BASKET_MAP.protocol, tags, tagQueryMode: 'all', include: 'entire transactions' }, this.adminOriginator); for (const out of result.outputs) { const [txid, outputIndexStr] = out.outpoint.split('.'); const tx = sdk_1.Transaction.fromBEEF(result.BEEF, txid); const dec = sdk_1.PushDrop.decode(tx.outputs[Number(outputIndexStr)].lockingScript); if (!dec || !dec.fields || dec.fields.length < 6) continue; const domainRaw = dec.fields[0]; const expiryRaw = dec.fields[1]; const privRaw = dec.fields[2]; const secLevelRaw = dec.fields[3]; const protoNameRaw = dec.fields[4]; const counterpartyRaw = dec.fields[5]; const domainDecoded = sdk_1.Utils.toUTF8(await this.decryptPermissionTokenField(domainRaw)); const expiryDecoded = parseInt(sdk_1.Utils.toUTF8(await this.decryptPermissionTokenField(expiryRaw)), 10); const privDecoded = sdk_1.Utils.toUTF8(await this.decryptPermissionTokenField(privRaw)) === 'true'; const secLevelDecoded = parseInt(sdk_1.Utils.toUTF8(await this.decryptPermissionTokenField(secLevelRaw)), 10); const protoNameDecoded = sdk_1.Utils.toUTF8(await this.decryptPermissionTokenField(protoNameRaw)); const cptyDecoded = sdk_1.Utils.toUTF8(await this.decryptPermissionTokenField(counterpartyRaw)); if (domainDecoded !== originator || privDecoded !== !!privileged || secLevelDecoded !== secLevel || protoNameDecoded !== protoName || (secLevelDecoded === 2 && cptyDecoded !== counterparty)) { continue; } if (!includeExpired && this.isTokenExpired(expiryDecoded)) { continue; } return { tx: tx.toBEEF(), txid: out.outpoint.split('.')[0], outputIndex: parseInt(out.outpoint.split('.')[1], 10), outputScript: tx.outputs[Number(outputIndexStr)].lockingScript.toHex(), satoshis: out.satoshis, originator, privileged, protocol: protoName, securityLevel: secLevel, expiry: expiryDecoded, counterparty: cptyDecoded }; } return undefined; } /** Looks for a DBAP token matching (originator, basket). */ async findBasketToken(originator, basket, includeExpired) { const result = await this.underlying.listOutputs({ basket: BASKET_MAP.basket, tags: [`originator ${originator}`, `basket ${basket}`], tagQueryMode: 'all', include: 'entire transactions' }, this.adminOriginator); for (const out of result.outputs) { const [txid, outputIndexStr] = out.outpoint.split('.'); const tx = sdk_1.Transaction.fromBEEF(result.BEEF, txid); const dec = sdk_1.PushDrop.decode(tx.outputs[Number(outputIndexStr)].lockingScript); if (!(dec === null || dec === void 0 ? void 0 : dec.fields) || dec.fields.length < 3) continue; const domainRaw = dec.fields[0]; const expiryRaw = dec.fields[1]; const basketRaw = dec.fields[2]; const domainDecoded = sdk_1.Utils.toUTF8(await this.decryptPermissionTokenField(domainRaw)); const expiryDecoded = parseInt(sdk_1.Utils.toUTF8(await this.decryptPermissionTokenField(expiryRaw)), 10); const basketDecoded = sdk_1.Utils.toUTF8(await this.decryptPermissionTokenField(basketRaw)); if (domainDecoded !== originator || basketDecoded !== basket) continue; if (!includeExpired && this.isTokenExpired(expiryDecoded)) continue; return { tx: tx.toBEEF(), txid: out.outpoint.split('.')[0], outputIndex: parseInt(out.outpoint.split('.')[1], 10), outputScript: tx.outputs[Number(outputIndexStr)].lockingScript.toHex(), satoshis: out.satoshis, originator, basketName: basketDecoded, expiry: expiryDecoded }; } return undefined; } /** Looks for a DCAP token matching (origin, privileged, verifier, certType, fields subset). */ async findCertificateToken(originator, privileged, verifier, certType, fields, includeExpired) { const result = await this.underlying.listOutputs({ basket: BASKET_MAP.certificate, tags: [`originator ${originator}`, `privileged ${!!privileged}`, `type ${certType}`, `verifier ${verifier}`], tagQueryMode: 'all', include: 'entire transactions' }, this.adminOriginator); for (const out of result.outputs) { const [txid, outputIndexStr] = out.outpoint.split('.'); const tx = sdk_1.Transaction.fromBEEF(result.BEEF, txid); const dec = sdk_1.PushDrop.decode(tx.outputs[Number(outputIndexStr)].lockingScript); if (!(dec === null || dec === void 0 ? void 0 : dec.fields) || dec.fields.length < 6) continue; const [domainRaw, expiryRaw, privRaw, typeRaw, fieldsRaw, verifierRaw] = dec.fields; const domainDecoded = sdk_1.Utils.toUTF8(await this.decryptPermissionTokenField(domainRaw)); const expiryDecoded = parseInt(sdk_1.Utils.toUTF8(await this.decryptPermissionTokenField(expiryRaw)), 10); const privDecoded = sdk_1.Utils.toUTF8(await this.decryptPermissionTokenField(privRaw)) === 'true'; const typeDecoded = sdk_1.Utils.toUTF8(await this.decryptPermissionTokenField(typeRaw)); const verifierDec = sdk_1.Utils.toUTF8(await this.decryptPermissionTokenField(verifierRaw)); const fieldsJson = await this.decryptPermissionTokenField(fieldsRaw); const allFields = JSON.parse(sdk_1.Utils.toUTF8(fieldsJson)); if (domainDecoded !== originator || privDecoded !== !!privileged || typeDecoded !== certType || verifierDec !== verifier) { continue; } // Check if 'fields' is a subset of 'allFields' const setAll = new Set(allFields); if (fields.some(f => !setAll.has(f))) { continue; } if (!includeExpired && this.isTokenExpired(expiryDecoded)) { continue; } return { tx: tx.toBEEF(), txid: out.outpoint.split('.')[0], outputIndex: parseInt(out.outpoint.split('.')[1], 10), outputScript: tx.outputs[Number(outputIndexStr)].lockingScript.toHex(), satoshis: out.satoshis, originator, privileged, verifier: verifierDec, certType: typeDecoded, certFields: allFields, expiry: expiryDecoded }; } return undefined; } /** Looks for a DSAP token matching origin, returning the first one found. */ async findSpendingToken(originator) { const result = await this.underlying.listOutputs({ basket: BASKET_MAP.spending, tags: [`originator ${originator}`], tagQueryMode: 'all', include: 'entire transactions' }, this.adminOriginator); for (const out of result.outputs) { const [txid, outputIndexStr] = out.outpoint.split('.'); const tx = sdk_1.Transaction.fromBEEF(result.BEEF, txid); const dec = sdk_1.PushDrop.decode(tx.outputs[Number(outputIndexStr)].lockingScript); if (!(dec === null || dec === void 0 ? void 0 : dec.fields) || dec.fields.length < 2) continue; const domainRaw = dec.fields[0]; const amtRaw = dec.fields[1]; const domainDecoded = sdk_1.Utils.toUTF8(await this.decryptPermissionTokenField(domainRaw)); if (domainDecoded !== originator) continue; const amtDecodedStr = sdk_1.Utils.toUTF8(await this.decryptPermissionTokenField(amtRaw)); const authorizedAmount = parseInt(amtDecodedStr, 10); return { tx: tx.toBEEF(), txid: out.outpoint.split('.')[0], outputIndex: parseInt(out.outpoint.split('.')[1], 10), outputScript: tx.outputs[Number(outputIndexStr)].lockingScript.toHex(), satoshis: out.satoshis, originator, authorizedAmount, expiry: 0 // Not time-limited, monthly authorization }; } return undefined; } /** * Returns the current month and year in UTC as a string in the format "YYYY-MM". * * @returns {string} The current month and year in UTC. */ getCurrentMonthYearUTC() { const now = new Date(); const year = now.getUTCFullYear(); const month = (now.getUTCMonth() + 1).toString().padStart(2, '0'); // Ensure 2-digit month return `${year}-${month}`; } /** * Returns spending for an originator in the current calendar month. */ async querySpentSince(token) { const { actions } = await this.underlying.listActions({ labels: [`admin originator ${token.originator}`, `admin month ${this.getCurrentMonthYearUTC()}`], labelQueryMode: 'all' }, this.adminOriginator); return actions.reduce((a, e) => a + e.satoshis, 0); } /* --------------------------------------------------------------------- * 5) CREATE / RENEW / REVOKE PERMISSION TOKENS ON CHAIN * --------------------------------------------------------------------- */ /** * Creates a brand-new permission token as a single-output PushDrop script in the relevant admin basket. * * The main difference between each type of token is in the "fields" we store in the PushDrop script. * * @param r The permission request * @param expiry The expiry epoch time * @param amount For DSAP, the authorized spending limit */ async createPermissionOnChain(r, expiry, amount) { const basketName = BASKET_MAP[r.type]; if (!basketName) return; // Build the array of encrypted fields for the PushDrop script const fields = await this.buildPushdropFields(r, expiry, amount); // Construct the script. We do a simple P2PK check. We ask `PushDrop.lock(...)` // to create a script with a single OP_CHECKSIG verifying ownership to redeem. const script = await new sdk_1.PushDrop(this.underlying).lock(fields, WalletPermissionsManager.PERM_TOKEN_ENCRYPTION_PROTOCOL, '1', 'self', true, true); // Create tags const tags = this.buildTagsForRequest(r); // Build a transaction with exactly one output, no explicit inputs since the wallet // can internally fund it from its balance. await this.createAction({ description: `Grant ${r.type} permission`, outputs: [ { lockingScript: script.toHex(), satoshis: 1, outputDescription: `${r.type} permission token`, basket: basketName, tags } ], options: { acceptDelayedBroadcast: false } }, this.adminOriginator); } /** * Renews a permission token by spending the old token as input and creating a new token output. * This invalidates the old token and replaces it with a new one. * * @param oldToken The old token to consume * @param r The permission request being renewed * @param newExpiry The new expiry epoch time * @param newAmount For DSAP, the new authorized amount */ async renewPermissionOnChain(oldToken, r, newExpiry, newAmount) { // 1) build new fields const newFields = await this.buildPushdropFields(r, newExpiry, newAmount); // 2) new script const newScript = await new sdk_1.PushDrop(this.underlying).lock(newFields, WalletPermissionsManager.PERM_TOKEN_ENCRYPTION_PROTOCOL, '1', 'self', true, true); const tags = this.buildTagsForRequest(r); // 3) For BRC-100, we do a "createAction" with a partial input referencing oldToken // plus a single new output. We'll hydrate the template, then signAction for the wallet to finalize. const oldOutpoint = `${oldToken.txid}.${oldToken.outputIndex}`; const { signableTransaction } = await this.createAction({ description: `Renew ${r.type} permission`, inputBEEF: oldToken.tx, inputs: [ { outpoint: oldOutpoint, unlockingScriptLength: 73, // length of signature inputDescription: `Consume old ${r.type} token` } ], outputs: [ { lockingScript: newScript.toHex(), satoshis: 1, outputDescription: `Renewed ${r.type} permission token`, basket: BASKET_MAP[r.type], tags } ], options: { acceptDelayedBroadcast: false } }, this.adminOriginator); const tx = sdk_1.Transaction.fromBEEF(signableTransaction.tx); const unlocker = new sdk_1.PushDrop(this.underlying).unlock(WalletPermissionsManager.PERM_TOKEN_ENCRYPTION_PROTOCOL, '1', 'self', 'all', false, 1, sdk_1.LockingScript.fromHex(oldToken.outputScript)); const unlockingScript = await unlocker.sign(tx, 0); await this.underlying.signAction({ reference: signableTransaction.reference, spends: { 0: { unlockingScript: unlockingScript.toHex() } } }); } /** * Builds the encrypted array of fields for a PushDrop permission token * (protocol / basket / certificate / spending). */ async buildPushdropFields(r, expiry, amount) { var _a; switch (r.type) { case 'protocol': { const [secLevel, protoName] = r.protocolID; return [ await this.encryptPermissionTokenField(r.originator), // domain await this.encryptPermissionTokenField(String(expiry)), // expiry await this.encryptPermissionTokenField(r.privileged === true ? 'true' : 'false'), await this.encryptPermissionTokenField(String(secLevel)), await this.encryptPermissionTokenField(protoName), await this.encryptPermissionTokenField(r.counterparty) ]; } case 'basket': { return [ await this.encryptPermissionTokenField(r.originator), await this.encryptPermissionTokenField(String(expiry)), await this.encryptPermissionTokenField(r.basket) ]; } case 'certificate': { const { certType, fields, verifier } = r.certificate; return [ await this.encryptPermissionTokenField(r.originator), await this.encryptPermissionTokenField(String(expiry)), await this.encryptPermissionTokenField(r.privileged ? 'true' : 'false'), await this.encryptPermissionTokenField(certType), await this.encryptPermissionTokenField(JSON.stringify(fields)), await this.encryptPermissionTokenField(verifier) ]; } case 'spending': { // DSAP const authAmt = amount !== null && amount !== void 0 ? amount : (((_a = r.spending) === null || _a === void 0 ? void 0 : _a.satoshis) || 0); return [ await this.encryptPermissionTokenField(r.originator), await this.encryptPermissionTokenField(String(authAmt)) ]; } } } /** * Helper to build an array of tags for the new output, matching the user request's * origin, basket, privileged, protocol name, etc. */ buildTagsForRequest(r) { const tags = [`origi