@bsv/wallet-toolbox-client
Version:
Client only Wallet Storage
1,100 lines • 97.3 kB
JavaScript
"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