@bsv/sdk
Version:
BSV Blockchain Software Development Kit
481 lines (436 loc) • 15.9 kB
text/typescript
import { RegistryClient } from '../RegistryClient'
import { WalletInterface } from '../../wallet/index.js'
import { TopicBroadcaster, LookupResolver } from '../../overlay-tools/index.js'
import { PushDrop } from '../../script/index.js'
import {
DefinitionType,
DefinitionData,
BasketDefinitionData,
ProtocolDefinitionData,
CertificateDefinitionData,
RegistryRecord,
CertificateFieldDescriptor
} from '../types/index.js'
// -------------------- Mocks Setup -------------------- //
// 1) A top-level broadcast mock function
const mockBroadcast = jest.fn().mockResolvedValue('mockBroadcastSuccess')
jest.mock('../../overlay-tools/index.js', () => {
return {
TopicBroadcaster: jest.fn().mockImplementation(() => ({
broadcast: mockBroadcast
})),
LookupResolver: jest.fn().mockImplementation(() => ({
query: jest.fn() // We'll override in tests
}))
}
})
jest.mock('../../script/index.js', () => {
const actualScriptModule = jest.requireActual('../../script/index.js')
return {
...actualScriptModule,
PushDrop: Object.assign(
jest.fn().mockImplementation(() => ({
lock: jest.fn().mockResolvedValue({ toHex: () => 'mockLockingScriptHex' }),
unlock: jest.fn().mockResolvedValue({
sign: jest.fn().mockResolvedValue({
toHex: () => 'mockUnlockingScriptHex'
})
})
})),
{
decode: jest.fn() // We'll override in tests
}
),
LockingScript: {
fromHex: jest.fn().mockImplementation((hex: string) => ({ hex }))
}
}
})
jest.mock('../../transaction/index.js', () => {
return {
Transaction: {
fromAtomicBEEF: jest.fn().mockImplementation((_tx: number[]) => ({
// minimal mock
toHexBEEF: () => 'mockTxHexBEEF',
outputs: [
{ lockingScript: 'mockLockingScriptObject0' },
{ lockingScript: 'mockLockingScriptObject1' },
{ lockingScript: 'mockLockingScriptObject2' }
]
})),
fromBEEF: jest.fn().mockImplementation((_tx: number[]) => ({
outputs: [
{ lockingScript: 'decodedLockScript0' },
{ lockingScript: 'decodedLockScript1' },
{
lockingScript: {
toHex: jest.fn().mockImplementation(() => 'decodedLockScript1AsHex')
}
}
]
}))
}
}
})
jest.mock('../../primitives/index.js', () => {
return {
Utils: {
toArray: jest.fn().mockImplementation((str: string) =>
Array.from(str).map((c) => c.charCodeAt(0))
),
toUTF8: jest.fn().mockImplementation((arr: number[] | string) => {
if (Array.isArray(arr)) {
return arr.map((n) => String.fromCharCode(n)).join('')
}
return arr
})
}
}
})
let walletMock: Partial<WalletInterface>
/**
* Build minimal valid DefinitionData for each type
*/
function buildDefinitionData(type: DefinitionType): DefinitionData {
switch (type) {
case 'basket': {
const data: BasketDefinitionData = {
definitionType: 'basket',
basketID: 'someBasketId',
name: 'Test Basket',
iconURL: 'https://someiconurl.com',
description: 'Basket Description',
documentationURL: 'https://docs.basket.com'
}
return data
}
case 'protocol': {
const data: ProtocolDefinitionData = {
definitionType: 'protocol',
protocolID: [1, 'someProtocolId'],
name: 'Test Protocol',
iconURL: 'https://someiconurl.com',
description: 'Protocol Description',
documentationURL: 'https://docs.protocol.com'
}
return data
}
case 'certificate': {
const fields: Record<string, CertificateFieldDescriptor> = {
myField: {
friendlyName: 'Friendly Field Name',
description: 'some field description',
type: 'text',
fieldIcon: 'https://someiconurl.com/icons/myField.png'
}
}
const data: CertificateDefinitionData = {
definitionType: 'certificate',
type: 'someCertType',
name: 'Test Certificate',
iconURL: 'https://someiconurl.com',
description: 'Certificate Description',
documentationURL: 'https://docs.certificate.com',
fields
}
return data
}
default:
throw new Error(`Invalid test usage: unsupported DefinitionType "${type}"`)
}
}
describe('RegistryClient', () => {
let registryClient: RegistryClient
beforeEach(() => {
walletMock = {
getPublicKey: jest.fn().mockResolvedValue({ publicKey: 'mockPublicKey' }),
createAction: jest.fn().mockResolvedValue({
tx: [1, 2, 3],
signableTransaction: { tx: [1, 2, 3], reference: 'someRef' }
}),
signAction: jest.fn().mockResolvedValue({ tx: [4, 5, 6] }),
listOutputs: jest.fn().mockResolvedValue({ outputs: [] }),
getNetwork: jest.fn().mockResolvedValue({ network: 'main' })
}
registryClient = new RegistryClient(walletMock as WalletInterface)
jest.clearAllMocks()
mockBroadcast.mockClear()
})
// ------------------------------------------------------------------
// registerDefinition
// ------------------------------------------------------------------
describe('registerDefinition', () => {
it('should register a basket definition and broadcast with networkPreset=main', async () => {
const data = buildDefinitionData('basket')
const result = await registryClient.registerDefinition(data)
expect(result).toBe('mockBroadcastSuccess')
// We use partial matching so extra fields (like options) are allowed
expect(walletMock.createAction).toHaveBeenCalledWith(
expect.objectContaining({
description: 'Register a new basket item',
outputs: expect.arrayContaining([
expect.objectContaining({
satoshis: 1,
outputDescription: 'New basket registration token',
basket: 'basketmap',
lockingScript: 'mockLockingScriptHex'
})
])
})
)
expect(TopicBroadcaster).toHaveBeenCalledWith(['tm_basketmap'], {
networkPreset: 'main'
})
expect(mockBroadcast).toHaveBeenCalledTimes(1)
})
it('should register a protocol definition and broadcast with networkPreset=main', async () => {
const data = buildDefinitionData('protocol')
const result = await registryClient.registerDefinition(data)
expect(result).toBe('mockBroadcastSuccess')
expect(walletMock.createAction).toHaveBeenCalledWith(
expect.objectContaining({
description: 'Register a new protocol item',
outputs: expect.arrayContaining([
expect.objectContaining({
satoshis: 1,
outputDescription: 'New protocol registration token',
basket: 'protomap',
lockingScript: 'mockLockingScriptHex'
})
])
})
)
expect(TopicBroadcaster).toHaveBeenCalledWith(['tm_protomap'], {
networkPreset: 'main'
})
expect(mockBroadcast).toHaveBeenCalledTimes(1)
})
it('should register a certificate definition and broadcast with networkPreset=main', async () => {
const data = buildDefinitionData('certificate')
const result = await registryClient.registerDefinition(data)
expect(result).toBe('mockBroadcastSuccess')
expect(walletMock.createAction).toHaveBeenCalledWith(
expect.objectContaining({
description: 'Register a new certificate item',
outputs: expect.arrayContaining([
expect.objectContaining({
satoshis: 1,
outputDescription: 'New certificate registration token',
basket: 'certmap',
lockingScript: 'mockLockingScriptHex'
})
])
})
)
expect(TopicBroadcaster).toHaveBeenCalledWith(['tm_certmap'], {
networkPreset: 'main'
})
expect(mockBroadcast).toHaveBeenCalledTimes(1)
})
it('should throw if createAction returns undefined tx', async () => {
(walletMock.createAction as jest.Mock).mockResolvedValueOnce({
tx: undefined
})
const data = buildDefinitionData('basket')
await expect(registryClient.registerDefinition(data)).rejects.toThrow(
'Failed to create basket registration transaction!'
)
})
it('should throw an error on invalid definition type', async () => {
// We expect "Unsupported definition type" if that’s what your code throws
const invalidData = { definitionType: 'invalidType' } as unknown as DefinitionData
await expect(registryClient.registerDefinition(invalidData)).rejects.toThrow(
'Unsupported definition type'
)
})
})
// ------------------------------------------------------------------
// resolve
// ------------------------------------------------------------------
describe('resolve', () => {
it('should return empty array if resolver does not return output-list', async () => {
; (LookupResolver as jest.Mock).mockImplementation(() => ({
query: jest.fn().mockResolvedValue({ type: 'not-output-list' })
}))
const result = await registryClient.resolve('basket', { name: 'foo' })
expect(result).toEqual([])
})
it('should parse outputs from resolver if type is output-list', async () => {
; (LookupResolver as jest.Mock).mockImplementation(() => ({
query: jest.fn().mockResolvedValue({
type: 'output-list',
outputs: [{ beef: [9, 9, 9], outputIndex: 0 }]
})
}))
// The code expects 7 fields for basket (6 definition fields + 1 extra signature field)
; (PushDrop.decode as jest.Mock).mockReturnValue({
fields: [
[98], // 'b'
[97], // 'a'
[115], // 's'
[107], // 'k'
[101], // 'e'
[116], // 't' => operator
[111] // extra signature field
]
})
// The final field must match the current wallet pubkey => 'mockPublicKey'
; (walletMock.getPublicKey as jest.Mock).mockResolvedValueOnce({
publicKey: 't'
})
const result = await registryClient.resolve('basket', { basketID: 'whatever' })
expect(result).toHaveLength(1)
expect(result[0]).toMatchObject({
definitionType: 'basket',
basketID: 'b',
name: 'a',
iconURL: 's',
description: 'k',
documentationURL: 'e',
registryOperator: 't'
})
})
it('should skip outputs that fail parseLockingScript', async () => {
; (LookupResolver as jest.Mock).mockImplementation(() => ({
query: jest.fn().mockResolvedValue({
type: 'output-list',
outputs: [
{ beef: [1, 1, 1], outputIndex: 0 },
{ beef: [2, 2, 2], outputIndex: 1 }
]
})
}))
// Return empty fields so parseLockingScript fails the length check
; (PushDrop.decode as jest.Mock)
.mockReturnValueOnce({ fields: [] }) // fail
.mockReturnValueOnce({ fields: [] }) // fail again
const result = await registryClient.resolve('basket', { name: 'fooAgain' })
expect(result).toEqual([])
})
})
// ------------------------------------------------------------------
// listOwnRegistryEntries
// ------------------------------------------------------------------
describe('listOwnRegistryEntries', () => {
it('should parse and return registry records from wallet outputs', async () => {
// The wallet returns 3 outputs; only one is spendable
(walletMock.listOutputs as jest.Mock).mockResolvedValue({
outputs: [
{
outpoint: 'abc123.0',
satoshis: 1000,
lockingScript: 'lsHexA',
spendable: false
},
{
outpoint: 'xyz999.1',
satoshis: 500,
lockingScript: 'lsHexB',
spendable: false
},
{
outpoint: 'skipMe.2',
satoshis: 200,
lockingScript: {
toHex: jest.fn(() => 'lsHexC')
},
spendable: true
}
],
BEEF: [0, 1, 2, 3]
});
// Use a mockImplementation to inspect the lockingScript and return appropriate decoded fields.
(PushDrop.decode as jest.Mock).mockImplementation((scriptObj) => {
return {
fields: [
[98], // 'b'
[97], // 'a'
[115], // 's'
[107], // 'k'
[101], // 'e'
[116], // 't'
[111] // extra signature field
]
}
});
(walletMock.getPublicKey as jest.Mock).mockResolvedValue({ publicKey: 't' }); // <-- Semicolon
const records = await registryClient.listOwnRegistryEntries('basket');
expect(walletMock.listOutputs).toHaveBeenCalledWith({
basket: 'basketmap',
include: 'entire transactions'
});
// Only one spendable item should be returned if parsing succeeds.
expect(records).toHaveLength(1);
expect(records[0]).toMatchObject({
definitionType: 'basket',
txid: 'skipMe',
outputIndex: 2,
satoshis: 200,
lockingScript: 'decodedLockScript1AsHex'
});
});
})
// ------------------------------------------------------------------
// revokeOwnRegistryEntry
// ------------------------------------------------------------------
describe('revokeOwnRegistryEntry', () => {
let validRecord: RegistryRecord
beforeEach(() => {
validRecord = {
definitionType: 'basket',
basketID: 'myBasket',
name: 'whatever',
iconURL: 'url',
description: 'desc',
documentationURL: 'docURL',
txid: 'someTxId',
outputIndex: 0,
satoshis: 1000,
lockingScript: 'someLockingScriptHex',
registryOperator: 'mockPublicKey',
beef: [0, 1, 2]
}
})
it('should revoke a record successfully (networkPreset=main)', async () => {
const result = await registryClient.revokeOwnRegistryEntry(validRecord)
expect(result).toBe('mockBroadcastSuccess')
expect(walletMock.createAction).toHaveBeenCalledWith(
expect.objectContaining({
description: 'Revoke basket item: myBasket',
inputs: [
{
outpoint: 'someTxId.0',
unlockingScriptLength: 73,
inputDescription: 'Revoking basket token'
}
]
})
)
expect(TopicBroadcaster).toHaveBeenCalledWith(['tm_basketmap'], {
networkPreset: 'main'
})
expect(mockBroadcast).toHaveBeenCalled()
})
it('should throw if createAction returns no signableTransaction', async () => {
; (walletMock.createAction as jest.Mock).mockResolvedValueOnce({
tx: [1, 2, 3],
signableTransaction: undefined
})
await expect(registryClient.revokeOwnRegistryEntry(validRecord)).rejects.toThrow(
'Failed to create signable transaction.'
)
})
it('should throw if signAction returns no signedTx', async () => {
; (walletMock.signAction as jest.Mock).mockResolvedValueOnce({ tx: undefined })
await expect(registryClient.revokeOwnRegistryEntry(validRecord)).rejects.toThrow(
'Failed to finalize the transaction signature.'
)
})
it('should propagate broadcast errors', async () => {
mockBroadcast.mockRejectedValueOnce(new Error('Broadcast failure!'))
await expect(registryClient.revokeOwnRegistryEntry(validRecord)).rejects.toThrow(
'Broadcast failure!'
)
})
})
})