@bsv/wallet-toolbox
Version:
BRC100 conforming wallet, wallet storage and wallet signer components
586 lines (504 loc) • 22.5 kB
text/typescript
import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals'
import { mockUnderlyingWallet, MockedBSV_SDK, MockTransaction } from './WalletPermissionsManager.fixtures'
import { WalletPermissionsManager, PermissionRequest, PermissionToken } from '../WalletPermissionsManager'
import { Utils } from '@bsv/sdk'
// Re-mock @bsv/sdk with our fixture classes (MockTransaction, MockLockingScript, etc.)
jest.mock('@bsv/sdk', () => MockedBSV_SDK)
describe('WalletPermissionsManager - On-Chain Token Creation, Renewal & Revocation', () => {
let underlying: ReturnType<typeof mockUnderlyingWallet>
let manager: WalletPermissionsManager
beforeEach(() => {
// Fresh mock wallet before each test
underlying = mockUnderlyingWallet()
manager = new WalletPermissionsManager(underlying, 'admin.domain.com')
})
afterEach(() => {
jest.clearAllMocks()
})
/* ------------------------------------------------------------------------
* 1) UNIT TESTS: buildPushdropFields() correctness
* ------------------------------------------------------------------------
* We directly call the manager’s internal buildPushdropFields(...) via
* a cast to "any" so we can test each permission type’s field ordering,
* encryption calls, and final arrays.
* ------------------------------------------------------------------------
*/
describe('buildPushdropFields() - unit tests for each permission type', () => {
// We’ll cast the manager to `any` to access the private method.
const privateManager = () => manager as any
it('should build correct fields for a protocol token (DPACP)', async () => {
const request: PermissionRequest = {
type: 'protocol',
originator: 'some-app.com',
privileged: true,
protocolID: [2, 'myProto'],
counterparty: 'some-other-pubkey',
reason: 'test-protocol-creation'
}
const expiry = 1234567890
// Because manager.encryptPermissionTokenField calls underlying.encrypt,
// we can observe how many times it's called & with what plaintext.
underlying.encrypt.mockClear()
const fields: number[][] = await privateManager().buildPushdropFields(request, expiry)
// We expect 6 encryption calls (domain, expiry, privileged, secLevel, protoName, cpty).
expect(underlying.encrypt).toHaveBeenCalledTimes(6)
// The final array must have length=6
expect(fields).toHaveLength(6)
// Confirm the 1st call was the domain
expect(underlying.encrypt.mock.calls[0][0].plaintext).toEqual(
expect.arrayContaining([...'some-app.com'].map(c => c.charCodeAt(0)))
)
// Confirm the 2nd call was the expiry, as a string
expect(underlying.encrypt.mock.calls[1][0].plaintext).toEqual(
expect.arrayContaining([...'1234567890'].map(c => c.charCodeAt(0)))
)
// 3rd => privileged? 'true'
expect(underlying.encrypt.mock.calls[2][0].plaintext).toEqual(
expect.arrayContaining([...'true'].map(c => c.charCodeAt(0)))
)
// 4th => security level => '2'
expect(underlying.encrypt.mock.calls[3][0].plaintext).toEqual(
expect.arrayContaining([...'2'].map(c => c.charCodeAt(0)))
)
// 5th => protoName => 'myProto'
expect(underlying.encrypt.mock.calls[4][0].plaintext).toEqual(
expect.arrayContaining([...'myProto'].map(c => c.charCodeAt(0)))
)
// 6th => counterparty => 'some-other-pubkey'
expect(underlying.encrypt.mock.calls[5][0].plaintext).toEqual(
expect.arrayContaining([...'some-other-pubkey'].map(c => c.charCodeAt(0)))
)
})
it('should build correct fields for a basket token (DBAP)', async () => {
const request: PermissionRequest = {
type: 'basket',
originator: 'origin.example',
basket: 'someBasket',
reason: 'basket usage'
}
const expiry = 999999999
underlying.encrypt.mockClear()
const fields: number[][] = await privateManager().buildPushdropFields(request, expiry)
// We expect 3 encryption calls: domain, expiry, basket
expect(underlying.encrypt).toHaveBeenCalledTimes(3)
expect(fields).toHaveLength(3)
})
it('should build correct fields for a certificate token (DCAP)', async () => {
const request: PermissionRequest = {
type: 'certificate',
originator: 'cert-user.org',
privileged: false,
certificate: {
verifier: '02abcdef...',
certType: 'KYC',
fields: ['name', 'dob']
},
reason: 'certificate usage'
}
const expiry = 2222222222
underlying.encrypt.mockClear()
const fields: number[][] = await privateManager().buildPushdropFields(request, expiry)
// DP = domain, expiry, privileged, certType, fieldsJson, verifier
expect(underlying.encrypt).toHaveBeenCalledTimes(6)
expect(fields).toHaveLength(6)
// 5th encryption call is the fields JSON => ["name","dob"]
const fifthCallPlaintext = underlying.encrypt.mock.calls[4][0].plaintext
const str = String.fromCharCode(...fifthCallPlaintext)
expect(str).toContain('"name"')
expect(str).toContain('"dob"')
})
it('should build correct fields for a spending token (DSAP)', async () => {
const request: PermissionRequest = {
type: 'spending',
originator: 'money-spender.com',
spending: { satoshis: 5000 },
reason: 'monthly spending'
}
const expiry = 0 // DSAP typically not time-limited, but manager can pass 0.
underlying.encrypt.mockClear()
const fields: number[][] = await privateManager().buildPushdropFields(request, expiry, /*amount=*/ 10000)
// For DSAP: domain + authorizedAmount (2 fields)
expect(underlying.encrypt).toHaveBeenCalledTimes(2)
expect(fields).toHaveLength(2)
// The second encryption call is '10000'
const secondPlaintext = underlying.encrypt.mock.calls[1][0].plaintext
const asString = String.fromCharCode(...secondPlaintext)
expect(asString).toBe('10000')
})
})
/* ------------------------------------------------------------------------
* 2) INTEGRATION TESTS: Token Creation
* ------------------------------------------------------------------------
* We'll simulate a user request flow, then call `grantPermission` with
* ephemeral=false to see if createAction is called with the correct script,
* basket name, tags, etc. We also decode the script to confirm it has the
* correct (encrypted) fields.
* ------------------------------------------------------------------------
*/
describe('Token Creation - integration tests', () => {
it('should create a new protocol token with the correct basket, script, and tags', async () => {
// 1) Simulate the manager having an active request for a protocol token.
const request: PermissionRequest = {
type: 'protocol',
originator: 'app.example',
privileged: false,
protocolID: [1, 'testProto'],
counterparty: 'self',
reason: 'Need protocol usage'
}
// We'll emulate that the manager queued it:
const key = (manager as any).buildRequestKey(request)
;(manager as any).activeRequests.set(key, {
request,
pending: [{ resolve: () => {}, reject: () => {} }]
})
// 2) Grant the permission with ephemeral=false => must create the token
underlying.createAction.mockClear()
await manager.grantPermission({
requestID: key,
expiry: 999999, // set some expiry
ephemeral: false
})
// 3) Expect createAction to have been called once with a single output
expect(underlying.createAction).toHaveBeenCalledTimes(1)
const actionArgs = underlying.createAction.mock.calls[0][0]
expect(actionArgs.outputs).toHaveLength(1)
// The basket name must be "admin protocol-permission" as per BASKET_MAP
expect(actionArgs.outputs[0].basket).toBe('admin protocol-permission')
// The tags must contain e.g. "originator app.example", "protocolName testProto", etc.
const outputTags = actionArgs.outputs[0].tags
expect(outputTags).toEqual(
expect.arrayContaining([
'originator app.example',
'privileged false',
'protocolName testProto',
'protocolSecurityLevel 1',
'counterparty self'
])
)
// The lockingScript is built by "PushDrop.lock(...)" with 6 fields
const lockingScriptHex = actionArgs.outputs[0].lockingScript
expect(lockingScriptHex).toBeTruthy()
// Because we’re using our mock pushdrop, we might see an empty decode.
// In a real environment, you would decode and confirm the fields. Here we just confirm
// that the manager called the underlying encrypt 6 times, plus the script creation.
// Two more encrypt calls should have been made within createAction (metadata encryption
// of the top-level Action description, and the output's description) for a total of 8.
expect(underlying.encrypt).toHaveBeenCalledTimes(8)
})
it('should create a new basket token (DBAP)', async () => {
const request: PermissionRequest = {
type: 'basket',
originator: 'shopper.com',
basket: 'myBasket',
reason: 'I want to store items'
}
const key = (manager as any).buildRequestKey(request)
;(manager as any).activeRequests.set(key, {
request,
pending: [{ resolve() {}, reject() {} }]
})
underlying.createAction.mockClear()
await manager.grantPermission({
requestID: key,
ephemeral: false,
expiry: 123456789
})
expect(underlying.createAction).toHaveBeenCalledTimes(1)
const { outputs } = underlying.createAction.mock.calls[0][0]
expect(outputs).toHaveLength(1)
// "admin basket-access"
expect(outputs[0].basket).toBe('admin basket-access')
expect(outputs[0].tags).toEqual(expect.arrayContaining(['originator shopper.com', 'basket myBasket']))
// 3 fields => domain, expiry, basket, plus two metadata calls (description, outputDescription)
expect(underlying.encrypt).toHaveBeenCalledTimes(5)
})
it('should create a new certificate token (DCAP)', async () => {
const request: PermissionRequest = {
type: 'certificate',
originator: 'org.certs',
privileged: true,
certificate: {
verifier: '02cccccc',
certType: 'KYC',
fields: ['name', 'id', 'photo']
},
reason: 'Present KYC docs'
}
const key = (manager as any).buildRequestKey(request)
;(manager as any).activeRequests.set(key, {
request,
pending: [{ resolve() {}, reject() {} }]
})
underlying.createAction.mockClear()
await manager.grantPermission({
requestID: key,
ephemeral: false,
expiry: 44444444
})
expect(underlying.createAction).toHaveBeenCalledTimes(1)
const { outputs } = underlying.createAction.mock.calls[0][0]
expect(outputs[0].basket).toBe('admin certificate-access')
expect(outputs[0].tags).toEqual(
expect.arrayContaining(['originator org.certs', 'privileged true', 'type KYC', 'verifier 02cccccc'])
)
// DP = domain, expiry, privileged, certType, fieldsJson, verifier => 6 encryption calls
// Two additional ones for metadata encryption (action description, output description) for 8 total.
expect(underlying.encrypt).toHaveBeenCalledTimes(8)
})
it('should create a new spending authorization token (DSAP)', async () => {
const request: PermissionRequest = {
type: 'spending',
originator: 'spender.com',
spending: {
satoshis: 9999
}
}
const key = (manager as any).buildRequestKey(request)
;(manager as any).activeRequests.set(key, {
request,
pending: [{ resolve() {}, reject() {} }]
})
underlying.createAction.mockClear()
// We'll set "amount=20000" as the monthly limit
await manager.grantPermission({
requestID: key,
ephemeral: false,
amount: 20000
})
expect(underlying.createAction).toHaveBeenCalledTimes(1)
const { outputs } = underlying.createAction.mock.calls[0][0]
// "admin spending-authorization"
expect(outputs[0].basket).toBe('admin spending-authorization')
expect(outputs[0].tags).toEqual(expect.arrayContaining(['originator spender.com']))
// domain, amount => 2 calls, plus two metadata encryption calls (description, outputDescription)
expect(underlying.encrypt).toHaveBeenCalledTimes(4)
})
})
/* ------------------------------------------------------------------------
* 3) INTEGRATION TESTS: Token Renewal
* ------------------------------------------------------------------------
* We test that renewing a token:
* - Spends the old token with createAction input referencing oldToken.txid/index
* - Produces a new token output in the same transaction with updated fields
* ------------------------------------------------------------------------
*/
describe('Token Renewal - integration tests', () => {
it('should spend the old token input and create a new protocol token output with updated expiry', async () => {
// Suppose the user has an old protocol token:
const oldToken: PermissionToken = {
tx: [],
txid: 'oldTokenTX',
outputIndex: 2,
outputScript: '76a914...ac', // not used by the mock
satoshis: 1,
originator: 'some-site.io',
expiry: 222222,
privileged: false,
securityLevel: 1,
protocol: 'coolProto',
counterparty: 'self'
}
// The user’s request to renew:
const request: PermissionRequest = {
type: 'protocol',
originator: 'some-site.io',
privileged: false,
protocolID: [1, 'coolProto'],
counterparty: 'self',
renewal: true,
previousToken: oldToken
}
// Manager normally calls requestPermissionFlow, but let's skip ahead:
// We'll place the request in activeRequests:
const key = (manager as any).buildRequestKey(request)
;(manager as any).activeRequests.set(key, {
request,
pending: [{ resolve() {}, reject() {} }]
})
// Clear the mock calls, then renew with ephemeral=false
underlying.createAction.mockClear()
await manager.grantPermission({
requestID: key,
ephemeral: false,
expiry: 999999 // new expiry
})
// We expect createAction with:
// - 1 input referencing oldToken "oldTokenTX.2"
// - 1 output with the new script
expect(underlying.createAction).toHaveBeenCalledTimes(1)
const createArgs = underlying.createAction.mock.calls[0][0]
expect(createArgs.inputs).toHaveLength(1)
expect(createArgs.inputs[0].outpoint).toBe('oldTokenTX.2')
expect(createArgs.outputs).toHaveLength(1)
// The new basket is still "admin protocol-permission"
expect(createArgs.outputs[0].basket).toBe('admin protocol-permission')
// And we must confirm "renew" means 6 encryption calls again
// Metadata encryption means three extra calls (inputDescription, outputDescription, and Action description)
// this means a total of 9.
expect(underlying.encrypt).toHaveBeenCalledTimes(9)
})
it('should allow updating the authorizedAmount in DSAP renewal', async () => {
const oldToken: PermissionToken = {
tx: [],
txid: 'dsap-old-tx',
outputIndex: 0,
outputScript: 'sample script',
satoshis: 1,
originator: 'spenderX.com',
authorizedAmount: 10000,
expiry: 0
}
const request: PermissionRequest = {
type: 'spending',
originator: 'spenderX.com',
spending: { satoshis: 3000 },
renewal: true,
previousToken: oldToken
}
const key = (manager as any).buildRequestKey(request)
;(manager as any).activeRequests.set(key, {
request,
pending: [{ resolve() {}, reject() {} }]
})
underlying.createAction.mockClear()
// Renew with new monthly limit 50000
await manager.grantPermission({
requestID: key,
amount: 50000,
ephemeral: false
})
// check
const { inputs, outputs } = underlying.createAction.mock.calls[0][0]
expect(inputs).toHaveLength(1)
expect(inputs[0].outpoint).toBe('dsap-old-tx.0')
expect(outputs).toHaveLength(1)
expect(outputs[0].basket).toBe('admin spending-authorization')
// domain + new authorizedAmount => 2 encryption calls
// For metadata encryption, we have an input description, an output description, and a top-level description.
// This makes for a total of 5 calls.
expect(underlying.encrypt).toHaveBeenCalledTimes(5)
// The second call’s plaintext should be "50000"
const secondPlaintext = underlying.encrypt.mock.calls[1][0].plaintext
const asStr = String.fromCharCode(...secondPlaintext)
expect(asStr).toBe('50000')
})
})
/* ------------------------------------------------------------------------
* 4) INTEGRATION TESTS: Token Revocation
* ------------------------------------------------------------------------
* - Revoking a token means we build a transaction that consumes the old
* token UTXO with no replacement output.
* - Then we typically call signAction to finalize. The old token is no
* longer listed as an unspent output.
* ------------------------------------------------------------------------
*/
describe('Token Revocation - integration tests', () => {
it('should create a transaction that consumes (spends) the old token with no new outputs', async () => {
// A sample old token
const oldToken: PermissionToken = {
tx: [],
txid: 'revocableToken.txid',
outputIndex: 1,
outputScript: 'fakePushdropScript',
satoshis: 1,
originator: 'shopper.com',
basketName: 'myBasket',
expiry: 1111111111
}
underlying.createAction.mockClear()
underlying.signAction.mockClear()
await manager.revokePermission(oldToken)
// 1) The manager calls createAction with an input referencing oldToken
expect(underlying.createAction).toHaveBeenCalledTimes(1)
const createArgs = underlying.createAction.mock.calls[0][0]
expect(createArgs.inputs).toHaveLength(1)
expect(createArgs.inputs[0].outpoint).toBe('revocableToken.txid.1')
// No new outputs => final array is empty
expect(createArgs.outputs || []).toHaveLength(0)
// 2) The manager then calls signAction to finalize the spending
expect(underlying.signAction).toHaveBeenCalledTimes(1)
const signArgs = underlying.signAction.mock.calls[0][0]
// signArgs.reference should be the same from createAction’s result
expect(signArgs.reference).toBe('mockReference')
// The “spends” object should have an unlockingScript at index 0.
expect(signArgs.spends).toHaveProperty('0.unlockingScript')
// The content can be a mock, we just check it’s not empty
expect(signArgs.spends[0].unlockingScript).toBeDefined()
})
it('should remove the old token from listing after revocation', async () => {
jest.spyOn(MockedBSV_SDK.Transaction, 'fromBEEF').mockImplementation(() => {
const mockTx = new MockTransaction()
// Add outputs with lockingScript
mockTx.outputs = [
{
lockingScript: {
// Ensure this matches what PushDrop.decode expects to work with
toHex: () => 'some script'
}
}
]
// Add the toBEEF method
mockTx.toBEEF = () => []
return mockTx
})
// Add this to your test alongside the Transaction.fromBEEF mock
jest.spyOn(MockedBSV_SDK.PushDrop, 'decode').mockReturnValue({
fields: [
// Values that will decrypt to the expected values for domain, expiry, and basket
Utils.toArray('encoded-domain'),
Utils.toArray('encoded-expiry'),
Utils.toArray('encoded-basket')
]
})
// You'll also need to mock the decryptPermissionTokenField method
// to handle these encoded values
jest.spyOn(manager as any, 'decryptPermissionTokenField').mockImplementation(field => {
if (field === 'encoded-domain') return new Uint8Array([...Buffer.from('example.com')])
if (field === 'encoded-expiry') return new Uint8Array([...Buffer.from('1735689600')])
if (field === 'encoded-basket') return new Uint8Array([...Buffer.from('protocol-permission')])
return new Uint8Array()
})
// 1) Setup the underlying wallet to initially return the old token in listOutputs
const oldToken: PermissionToken = {
tx: [],
txid: 'aaaa1111',
outputIndex: 0,
outputScript: 'some script',
satoshis: 1,
originator: 'example.com',
expiry: 999999,
basketName: 'myBasket'
}
// We mock listOutputs so that it returns the old token before revocation
underlying.listOutputs.mockResolvedValueOnce({
totalOutputs: 1,
outputs: [
{
outpoint: 'aaaa1111.0',
lockingScript: 'some script',
satoshis: 1,
tags: ['originator example.com', 'basket myBasket']
}
]
})
// Confirm the manager sees it in listBasketAccess
const tokensBefore = await manager.listBasketAccess({
originator: 'example.com'
})
expect(tokensBefore).toHaveLength(1)
expect(tokensBefore[0].txid).toBe('aaaa1111')
// 2) Revoke the token
await manager.revokePermission(oldToken)
// 3) After revocation, mock the underlying wallet to show zero outputs
underlying.listOutputs.mockResolvedValue({
totalOutputs: 0,
outputs: []
})
const tokensAfter = await manager.listBasketAccess({
originator: 'example.com'
})
expect(tokensAfter).toHaveLength(0)
})
})
})