From 64d513d51792a5fd5cb43860bee99ec1ead1a632 Mon Sep 17 00:00:00 2001 From: Simon Tregunna Date: Thu, 22 Jan 2026 12:58:50 +0000 Subject: [PATCH] feat(space): add direct blockchain space creation module Add new Space module for creating and managing spaces directly on-chain without going through the HTTP API. This provides more reliable space creation by interacting with the SpaceRegistry contract directly. New functions: - createPersonalSpace(): Creates EOA (personal) space via registerSpaceId() - publishEditToSpace(): Records IPFS CID on-chain via enter() - getSpaceId(): Looks up space ID for a wallet address Also includes: - Utility functions for parsing space IDs (hex/uuid conversion) - Constants for space-related contract interactions - Comprehensive test coverage (25 new tests) --- index.ts | 10 ++- src/space/constants.test.ts | 65 ++++++++++++++ src/space/constants.ts | 32 +++++++ src/space/create-personal-space.ts | 131 ++++++++++++++++++++++++++++ src/space/get-space-id.ts | 85 ++++++++++++++++++ src/space/index.ts | 96 +++++++++++++++++++++ src/space/publish-edit-to-space.ts | 133 +++++++++++++++++++++++++++++ src/space/types.ts | 93 ++++++++++++++++++++ src/space/utils.test.ts | 98 +++++++++++++++++++++ src/space/utils.ts | 80 +++++++++++++++++ 10 files changed, 821 insertions(+), 2 deletions(-) create mode 100644 src/space/constants.test.ts create mode 100644 src/space/constants.ts create mode 100644 src/space/create-personal-space.ts create mode 100644 src/space/get-space-id.ts create mode 100644 src/space/index.ts create mode 100644 src/space/publish-edit-to-space.ts create mode 100644 src/space/types.ts create mode 100644 src/space/utils.test.ts create mode 100644 src/space/utils.ts diff --git a/index.ts b/index.ts index a7ed021..70e4d3e 100644 --- a/index.ts +++ b/index.ts @@ -44,15 +44,21 @@ export { Position } from './src/position.js'; * Ranks allow ordering or scoring entities within a collection. */ export * as Rank from './src/ranks/index.js'; - /** * This module provides utility functions for working with Graph URIs in TypeScript. * * @since 0.0.6 */ export { GraphUrl } from './src/scheme.js'; - export { getSmartAccountWalletClient, getWalletClient } from './src/smart-wallet.js'; +/** + * This module provides functions for creating and managing spaces + * directly on the blockchain. + * + * @since 0.34.0 + */ +export * from './src/space/index.js'; +export { Space } from './src/space/index.js'; /** * Provides ids for commonly used entities across the Knowledge Graph. diff --git a/src/space/constants.test.ts b/src/space/constants.test.ts new file mode 100644 index 0000000..0f50ee0 --- /dev/null +++ b/src/space/constants.test.ts @@ -0,0 +1,65 @@ +import { keccak256, toHex } from 'viem'; +import { describe, expect, it } from 'vitest'; + +import { + EDITS_PUBLISHED_ACTION, + EMPTY_SPACE_ID, + EMPTY_TOPIC, + EOA_SPACE_TYPE, + ZERO_ADDRESS, +} from './constants.js'; + +describe('Space constants', () => { + describe('EMPTY_SPACE_ID', () => { + it('should be 32 hex characters (bytes16)', () => { + // Remove 0x prefix and check length + expect(EMPTY_SPACE_ID.slice(2)).toHaveLength(32); + }); + + it('should be all zeros', () => { + expect(EMPTY_SPACE_ID).toBe('0x00000000000000000000000000000000'); + }); + }); + + describe('ZERO_ADDRESS', () => { + it('should be 40 hex characters (20 bytes)', () => { + expect(ZERO_ADDRESS.slice(2)).toHaveLength(40); + }); + + it('should be all zeros', () => { + expect(ZERO_ADDRESS).toBe('0x0000000000000000000000000000000000000000'); + }); + }); + + describe('EMPTY_TOPIC', () => { + it('should be 64 hex characters (bytes32)', () => { + expect(EMPTY_TOPIC.slice(2)).toHaveLength(64); + }); + + it('should be all zeros', () => { + expect(EMPTY_TOPIC).toBe('0x0000000000000000000000000000000000000000000000000000000000000000'); + }); + }); + + describe('EDITS_PUBLISHED_ACTION', () => { + it('should be keccak256 hash of "GOVERNANCE.EDITS_PUBLISHED"', () => { + const expected = keccak256(toHex('GOVERNANCE.EDITS_PUBLISHED')); + expect(EDITS_PUBLISHED_ACTION).toBe(expected); + }); + + it('should be 64 hex characters (bytes32)', () => { + expect(EDITS_PUBLISHED_ACTION.slice(2)).toHaveLength(64); + }); + }); + + describe('EOA_SPACE_TYPE', () => { + it('should be keccak256 hash of "EOA_SPACE"', () => { + const expected = keccak256(toHex('EOA_SPACE')); + expect(EOA_SPACE_TYPE).toBe(expected); + }); + + it('should be 64 hex characters (bytes32)', () => { + expect(EOA_SPACE_TYPE.slice(2)).toHaveLength(64); + }); + }); +}); diff --git a/src/space/constants.ts b/src/space/constants.ts new file mode 100644 index 0000000..a973618 --- /dev/null +++ b/src/space/constants.ts @@ -0,0 +1,32 @@ +import { type Hex, keccak256, toHex } from 'viem'; + +/** + * Empty space ID - represents an unregistered space (bytes16 zero) + */ +export const EMPTY_SPACE_ID = '0x00000000000000000000000000000000' as Hex; + +/** + * Zero address - represents an invalid or unset address + */ +export const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' as Hex; + +/** + * Empty topic for enter() calls (bytes32 zero) + */ +export const EMPTY_TOPIC = '0x0000000000000000000000000000000000000000000000000000000000000000' as Hex; + +/** + * Action hash for GOVERNANCE.EDITS_PUBLISHED + * Used when publishing edits via SpaceRegistry.enter() + */ +export const EDITS_PUBLISHED_ACTION = keccak256(toHex('GOVERNANCE.EDITS_PUBLISHED')); + +/** + * Space type identifier for EOA (personal) spaces + */ +export const EOA_SPACE_TYPE = keccak256(toHex('EOA_SPACE')); + +/** + * Default version string for space registration + */ +export const DEFAULT_SPACE_VERSION = '1.0.0'; diff --git a/src/space/create-personal-space.ts b/src/space/create-personal-space.ts new file mode 100644 index 0000000..c3932e8 --- /dev/null +++ b/src/space/create-personal-space.ts @@ -0,0 +1,131 @@ +import { createPublicClient, encodeAbiParameters, encodeFunctionData, type Hex, http } from 'viem'; + +import { TESTNET } from '../../contracts.js'; +import { SpaceRegistryAbi } from '../abis/index.js'; +import { DEFAULT_SPACE_VERSION, EMPTY_SPACE_ID, EOA_SPACE_TYPE } from './constants.js'; +import type { CreatePersonalSpaceParams, CreatePersonalSpaceResult, Network } from './types.js'; + +/** + * RPC URLs for each network + */ +const RPC_URLS: Record = { + MAINNET: 'https://rpc.geo.network', // TODO: Confirm mainnet RPC + TESTNET: 'https://rpc-geo-test-zc16z3tcvf.t.conduit.xyz', +}; + +/** + * Get the SpaceRegistry address for a network + */ +function getSpaceRegistryAddress(network: Network): Hex { + if (network === 'TESTNET') { + return TESTNET.SPACE_REGISTRY_ADDRESS as Hex; + } + // TODO: Add mainnet address when available + throw new Error('Mainnet SpaceRegistry address not yet configured'); +} + +/** + * Creates a personal (EOA) space for the caller's address. + * + * A personal space is owned by a single wallet address. Only the owner + * can publish edits to it directly - no voting required. + * + * This function: + * 1. Checks if the address already has a space + * 2. If not, registers a new space on-chain + * 3. Returns the space ID and transaction hash + * + * @example + * ```typescript + * import { createPersonalSpace } from '@graphprotocol/grc-20'; + * import { createWalletClient, http } from 'viem'; + * import { privateKeyToAccount } from 'viem/accounts'; + * + * const account = privateKeyToAccount('0x...'); + * const walletClient = createWalletClient({ + * account, + * transport: http('https://rpc-geo-test...'), + * }); + * + * const { spaceId, txHash } = await createPersonalSpace({ + * walletClient, + * network: 'TESTNET', + * }); + * + * console.log('Created space:', spaceId); + * ``` + * + * @param params - {@link CreatePersonalSpaceParams} + * @returns The created space ID and transaction hash + * @throws Error if the wallet already has a space registered + */ +export async function createPersonalSpace({ + walletClient, + publicClient: providedPublicClient, + network = 'TESTNET', +}: CreatePersonalSpaceParams): Promise { + const account = walletClient.account; + if (!account) { + throw new Error('Wallet client must have an account'); + } + + const spaceRegistryAddress = getSpaceRegistryAddress(network); + const rpcUrl = RPC_URLS[network]; + + // Create public client if not provided + const publicClient = + providedPublicClient ?? + createPublicClient({ + transport: http(rpcUrl), + }); + + // Check if the address already has a space + const existingSpaceId = (await publicClient.readContract({ + address: spaceRegistryAddress, + abi: SpaceRegistryAbi, + functionName: 'addressToSpaceId', + args: [account.address], + })) as Hex; + + if (existingSpaceId.toLowerCase() !== EMPTY_SPACE_ID.toLowerCase()) { + throw new Error( + `Address ${account.address} already has a space: ${existingSpaceId}. ` + 'Use getSpaceId() to retrieve it.', + ); + } + + // Encode the version parameter + const encodedVersion = encodeAbiParameters([{ type: 'string' }], [DEFAULT_SPACE_VERSION]); + + // Build the calldata + const calldata = encodeFunctionData({ + abi: SpaceRegistryAbi, + functionName: 'registerSpaceId', + args: [EOA_SPACE_TYPE, encodedVersion], + }); + + // Send the transaction + const txHash = await walletClient.sendTransaction({ + account, + chain: walletClient.chain ?? null, + to: spaceRegistryAddress, + data: calldata, + value: 0n, + }); + + // Wait for the transaction to be mined + await publicClient.waitForTransactionReceipt({ hash: txHash }); + + // Fetch the newly created space ID + const spaceId = (await publicClient.readContract({ + address: spaceRegistryAddress, + abi: SpaceRegistryAbi, + functionName: 'addressToSpaceId', + args: [account.address], + })) as Hex; + + if (spaceId.toLowerCase() === EMPTY_SPACE_ID.toLowerCase()) { + throw new Error('Space registration failed - space ID is empty after transaction'); + } + + return { spaceId, txHash }; +} diff --git a/src/space/get-space-id.ts b/src/space/get-space-id.ts new file mode 100644 index 0000000..f990427 --- /dev/null +++ b/src/space/get-space-id.ts @@ -0,0 +1,85 @@ +import { createPublicClient, type Hex, http } from 'viem'; + +import { TESTNET } from '../../contracts.js'; +import { SpaceRegistryAbi } from '../abis/index.js'; +import { EMPTY_SPACE_ID } from './constants.js'; +import type { Network } from './types.js'; + +/** + * RPC URLs for each network + */ +const RPC_URLS: Record = { + MAINNET: 'https://rpc.geo.network', // TODO: Confirm mainnet RPC + TESTNET: 'https://rpc-geo-test-zc16z3tcvf.t.conduit.xyz', +}; + +/** + * Get the SpaceRegistry address for a network + */ +function getSpaceRegistryAddress(network: Network): Hex { + if (network === 'TESTNET') { + return TESTNET.SPACE_REGISTRY_ADDRESS as Hex; + } + // TODO: Add mainnet address when available + throw new Error('Mainnet SpaceRegistry address not yet configured'); +} + +/** + * Parameters for getting a space ID + */ +export type GetSpaceIdParams = { + /** + * The wallet address to look up + */ + address: Hex; + + /** + * Which network to use + * @default 'TESTNET' + */ + network?: Network; +}; + +/** + * Gets the space ID for a given wallet address. + * + * @example + * ```typescript + * import { getSpaceId } from '@graphprotocol/grc-20'; + * + * const spaceId = await getSpaceId({ + * address: '0x1234...', + * network: 'TESTNET', + * }); + * + * if (spaceId) { + * console.log('User has space:', spaceId); + * } else { + * console.log('User has no space - they need to create one'); + * } + * ``` + * + * @param params - {@link GetSpaceIdParams} + * @returns The space ID in bytes16 hex format, or null if the address has no space + */ +export async function getSpaceId({ address, network = 'TESTNET' }: GetSpaceIdParams): Promise { + const spaceRegistryAddress = getSpaceRegistryAddress(network); + const rpcUrl = RPC_URLS[network]; + + const publicClient = createPublicClient({ + transport: http(rpcUrl), + }); + + const spaceId = (await publicClient.readContract({ + address: spaceRegistryAddress, + abi: SpaceRegistryAbi, + functionName: 'addressToSpaceId', + args: [address], + })) as Hex; + + if (spaceId.toLowerCase() === EMPTY_SPACE_ID.toLowerCase()) { + return null; + } + + return spaceId; +} diff --git a/src/space/index.ts b/src/space/index.ts new file mode 100644 index 0000000..45e8151 --- /dev/null +++ b/src/space/index.ts @@ -0,0 +1,96 @@ +/** + * Space module for GRC-20 + * + * Provides functions for creating and managing spaces directly on the blockchain. + * This replaces the deprecated API-based approach with direct smart contract calls. + * + * @example + * ```typescript + * import { Space } from '@graphprotocol/grc-20'; + * + * // Check if user has a space + * const existingSpaceId = await Space.getSpaceId({ + * address: walletAddress, + * network: 'TESTNET', + * }); + * + * // Create a personal space (if they don't have one) + * if (!existingSpaceId) { + * const { spaceId } = await Space.createPersonal({ + * walletClient, + * network: 'TESTNET', + * }); + * } + * + * // Publish edits to a space + * await Space.publishEdit({ + * walletClient, + * spaceId, + * cid: 'bafybeig...', + * network: 'TESTNET', + * }); + * ``` + * + * @module + */ + +// Constants (for advanced usage) +export { + EDITS_PUBLISHED_ACTION, + EMPTY_SPACE_ID, + EMPTY_TOPIC, + EOA_SPACE_TYPE, + ZERO_ADDRESS, +} from './constants.js'; +// Main functions +export { createPersonalSpace } from './create-personal-space.js'; +export type { GetSpaceIdParams } from './get-space-id.js'; +export { getSpaceId } from './get-space-id.js'; +export { publishEditToSpace } from './publish-edit-to-space.js'; +// Types +export type { + CreatePersonalSpaceParams, + CreatePersonalSpaceResult, + Network, + PublishEditToSpaceParams, + PublishEditToSpaceResult, +} from './types.js'; +// Utilities +export { hexToFormattedUuid, hexToUuid, parseSpaceId } from './utils.js'; + +// Convenient namespace export +import { createPersonalSpace } from './create-personal-space.js'; +import { getSpaceId } from './get-space-id.js'; +import { publishEditToSpace } from './publish-edit-to-space.js'; + +/** + * Space namespace providing all space-related functions. + * + * @example + * ```typescript + * import { Space } from '@graphprotocol/grc-20'; + * + * await Space.createPersonal({ walletClient }); + * await Space.publishEdit({ walletClient, spaceId, cid }); + * await Space.getSpaceId({ address }); + * ``` + */ +export const Space = { + /** + * Create a personal (EOA) space for the wallet. + * @see {@link createPersonalSpace} + */ + createPersonal: createPersonalSpace, + + /** + * Publish edits to a space by recording the IPFS CID on-chain. + * @see {@link publishEditToSpace} + */ + publishEdit: publishEditToSpace, + + /** + * Get the space ID for a wallet address. + * @see {@link getSpaceId} + */ + getSpaceId, +}; diff --git a/src/space/publish-edit-to-space.ts b/src/space/publish-edit-to-space.ts new file mode 100644 index 0000000..7447632 --- /dev/null +++ b/src/space/publish-edit-to-space.ts @@ -0,0 +1,133 @@ +import { createPublicClient, encodeAbiParameters, encodeFunctionData, type Hex, http } from 'viem'; + +import { TESTNET } from '../../contracts.js'; +import { SpaceRegistryAbi } from '../abis/index.js'; +import { EDITS_PUBLISHED_ACTION, EMPTY_TOPIC, ZERO_ADDRESS } from './constants.js'; +import type { Network, PublishEditToSpaceParams, PublishEditToSpaceResult } from './types.js'; +import { parseSpaceId } from './utils.js'; + +/** + * RPC URLs for each network + */ +const RPC_URLS: Record = { + MAINNET: 'https://rpc.geo.network', // TODO: Confirm mainnet RPC + TESTNET: 'https://rpc-geo-test-zc16z3tcvf.t.conduit.xyz', +}; + +/** + * Get the SpaceRegistry address for a network + */ +function getSpaceRegistryAddress(network: Network): Hex { + if (network === 'TESTNET') { + return TESTNET.SPACE_REGISTRY_ADDRESS as Hex; + } + // TODO: Add mainnet address when available + throw new Error('Mainnet SpaceRegistry address not yet configured'); +} + +/** + * Publishes edits to a space by recording the IPFS CID on-chain. + * + * This function: + * 1. Verifies the space exists in the registry + * 2. Encodes the CID as calldata + * 3. Calls SpaceRegistry.enter() to record the publication + * + * After the transaction is confirmed, indexers will: + * 1. See the Action event emitted by the contract + * 2. Fetch the operations from IPFS using the CID + * 3. Process the operations into queryable data + * + * @example + * ```typescript + * import { Ipfs, publishEditToSpace } from '@graphprotocol/grc-20'; + * + * // First, build your operations and upload to IPFS + * const { cid } = await Ipfs.publishEdit({ + * name: 'Update my profile', + * ops: myOperations, + * author: walletAddress, + * network: 'TESTNET', + * }); + * + * // Then record the CID on-chain + * const { txHash } = await publishEditToSpace({ + * walletClient, + * spaceId: 'my-space-id', + * cid, + * network: 'TESTNET', + * }); + * + * console.log('Published! TX:', txHash); + * ``` + * + * @param params - {@link PublishEditToSpaceParams} + * @returns The transaction hash and normalized space ID + * @throws Error if the space is not registered + */ +export async function publishEditToSpace({ + walletClient, + publicClient: providedPublicClient, + spaceId, + cid, + network = 'TESTNET', +}: PublishEditToSpaceParams): Promise { + const account = walletClient.account; + if (!account) { + throw new Error('Wallet client must have an account'); + } + + const spaceRegistryAddress = getSpaceRegistryAddress(network); + const rpcUrl = RPC_URLS[network]; + + // Create public client if not provided + const publicClient = + providedPublicClient ?? + createPublicClient({ + transport: http(rpcUrl), + }); + + // Normalize the space ID to bytes16 hex format + const spaceIdHex = parseSpaceId(spaceId); + + // Verify the space is registered + const spaceAddress = (await publicClient.readContract({ + address: spaceRegistryAddress, + abi: SpaceRegistryAbi, + functionName: 'spaceIdToAddress', + args: [spaceIdHex], + })) as Hex; + + if (spaceAddress.toLowerCase() === ZERO_ADDRESS.toLowerCase()) { + throw new Error(`Space ${spaceId} is not registered`); + } + + // Encode the CID as the data parameter + const encodedCid = encodeAbiParameters([{ type: 'string' }], [cid]); + + // Build the calldata for enter() + // For edits, fromSpaceId and toSpaceId are the same (editing your own space) + const calldata = encodeFunctionData({ + abi: SpaceRegistryAbi, + functionName: 'enter', + args: [ + spaceIdHex, // fromSpaceId: who is publishing + spaceIdHex, // toSpaceId: which space is being edited + EDITS_PUBLISHED_ACTION, // action: "edits were published" + EMPTY_TOPIC, // topic: none + encodedCid, // data: the IPFS CID + '0x', // signature: not needed for owner + ], + }); + + // Send the transaction + const txHash = await walletClient.sendTransaction({ + account, + chain: walletClient.chain ?? null, + to: spaceRegistryAddress, + data: calldata, + value: 0n, + }); + + return { txHash, spaceId: spaceIdHex }; +} diff --git a/src/space/types.ts b/src/space/types.ts new file mode 100644 index 0000000..7a862d1 --- /dev/null +++ b/src/space/types.ts @@ -0,0 +1,93 @@ +import type { Hex, PublicClient, WalletClient } from 'viem'; + +/** + * Supported networks for GRC-20 space operations + */ +export type Network = 'MAINNET' | 'TESTNET'; + +/** + * Parameters for creating a personal space + */ +export type CreatePersonalSpaceParams = { + /** + * Wallet client for signing transactions. + * Can be a standard viem WalletClient or a Smart Account client. + */ + walletClient: WalletClient; + + /** + * Public client for reading contract state. + * If not provided, one will be created using the wallet client's chain. + */ + publicClient?: PublicClient; + + /** + * Which network to use. + * @default 'TESTNET' + */ + network?: Network; +}; + +/** + * Parameters for publishing edits to a space + */ +export type PublishEditToSpaceParams = { + /** + * Wallet client for signing transactions. + */ + walletClient: WalletClient; + + /** + * Public client for reading contract state. + * If not provided, one will be created. + */ + publicClient?: PublicClient; + + /** + * The space ID to publish to. + * Accepts UUID format (with or without dashes) or bytes16 hex format. + */ + spaceId: string; + + /** + * The IPFS CID containing the operations. + * This should be the result of Ipfs.publishEdit(). + */ + cid: string; + + /** + * Which network to use. + * @default 'TESTNET' + */ + network?: Network; +}; + +/** + * Result from creating a personal space + */ +export type CreatePersonalSpaceResult = { + /** + * The created space ID in bytes16 hex format + */ + spaceId: Hex; + + /** + * The transaction hash for the space creation + */ + txHash: Hex; +}; + +/** + * Result from publishing edits + */ +export type PublishEditToSpaceResult = { + /** + * The transaction hash + */ + txHash: Hex; + + /** + * The normalized space ID in bytes16 hex format + */ + spaceId: Hex; +}; diff --git a/src/space/utils.test.ts b/src/space/utils.test.ts new file mode 100644 index 0000000..bd56c9f --- /dev/null +++ b/src/space/utils.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest'; + +import { hexToFormattedUuid, hexToUuid, parseSpaceId } from './utils.js'; + +describe('parseSpaceId', () => { + const validHex = '0x550e8400e29b41d4a716446655440000'; + const validUuidNoDashes = '550e8400e29b41d4a716446655440000'; + const validUuidWithDashes = '550e8400-e29b-41d4-a716-446655440000'; + + it('should parse hex format with 0x prefix', () => { + const result = parseSpaceId(validHex); + expect(result).toBe('0x550e8400e29b41d4a716446655440000'); + }); + + it('should parse UUID format without dashes', () => { + const result = parseSpaceId(validUuidNoDashes); + expect(result).toBe('0x550e8400e29b41d4a716446655440000'); + }); + + it('should parse UUID format with dashes', () => { + const result = parseSpaceId(validUuidWithDashes); + expect(result).toBe('0x550e8400e29b41d4a716446655440000'); + }); + + it('should normalize to lowercase', () => { + const result = parseSpaceId('0x550E8400E29B41D4A716446655440000'); + expect(result).toBe('0x550e8400e29b41d4a716446655440000'); + }); + + it('should throw for invalid length (too short)', () => { + expect(() => parseSpaceId('0x550e8400')).toThrow('Invalid space ID length'); + }); + + it('should throw for invalid length (too long)', () => { + expect(() => parseSpaceId('0x550e8400e29b41d4a716446655440000aaaa')).toThrow('Invalid space ID length'); + }); + + it('should throw for non-hex characters', () => { + expect(() => parseSpaceId('0xZZZe8400e29b41d4a716446655440000')).toThrow('non-hex characters'); + }); + + it('should handle empty space ID (all zeros)', () => { + const result = parseSpaceId('0x00000000000000000000000000000000'); + expect(result).toBe('0x00000000000000000000000000000000'); + }); +}); + +describe('hexToUuid', () => { + it('should convert hex to UUID without dashes', () => { + const result = hexToUuid('0x550e8400e29b41d4a716446655440000'); + expect(result).toBe('550e8400e29b41d4a716446655440000'); + }); + + it('should handle longer hex strings (truncate to 32 chars)', () => { + // bytes16 is 32 hex chars, anything after is ignored + const result = hexToUuid('0x550e8400e29b41d4a716446655440000ffffffff'); + expect(result).toBe('550e8400e29b41d4a716446655440000'); + }); + + it('should normalize to lowercase', () => { + const result = hexToUuid('0x550E8400E29B41D4A716446655440000'); + expect(result).toBe('550e8400e29b41d4a716446655440000'); + }); +}); + +describe('hexToFormattedUuid', () => { + it('should convert hex to UUID with dashes', () => { + const result = hexToFormattedUuid('0x550e8400e29b41d4a716446655440000'); + expect(result).toBe('550e8400-e29b-41d4-a716-446655440000'); + }); + + it('should produce standard UUID format (8-4-4-4-12)', () => { + const result = hexToFormattedUuid('0x550e8400e29b41d4a716446655440000'); + const parts = result.split('-'); + expect(parts).toHaveLength(5); + expect(parts[0]).toHaveLength(8); + expect(parts[1]).toHaveLength(4); + expect(parts[2]).toHaveLength(4); + expect(parts[3]).toHaveLength(4); + expect(parts[4]).toHaveLength(12); + }); +}); + +describe('round-trip conversion', () => { + it('should round-trip from hex to uuid and back', () => { + const original = '0x550e8400e29b41d4a716446655440000'; + const uuid = hexToUuid(original); + const backToHex = parseSpaceId(uuid); + expect(backToHex).toBe(original); + }); + + it('should round-trip from formatted uuid', () => { + const original = '0x550e8400e29b41d4a716446655440000'; + const formattedUuid = hexToFormattedUuid(original); + const backToHex = parseSpaceId(formattedUuid); + expect(backToHex).toBe(original); + }); +}); diff --git a/src/space/utils.ts b/src/space/utils.ts new file mode 100644 index 0000000..6dba396 --- /dev/null +++ b/src/space/utils.ts @@ -0,0 +1,80 @@ +import type { Hex } from 'viem'; + +/** + * Parses a space ID from various formats to bytes16 hex. + * + * Accepts: + * - UUID with dashes: "550e8400-e29b-41d4-a716-446655440000" + * - UUID without dashes: "550e8400e29b41d4a716446655440000" + * - Hex with 0x prefix: "0x550e8400e29b41d4a716446655440000" + * + * @param spaceId - The space ID in any supported format + * @returns bytes16 hex string (0x + 32 hex chars) + * @throws Error if the space ID format is invalid + * + * @example + * ```typescript + * parseSpaceId('550e8400-e29b-41d4-a716-446655440000'); + * // Returns: '0x550e8400e29b41d4a716446655440000' + * + * parseSpaceId('0x550e8400e29b41d4a716446655440000'); + * // Returns: '0x550e8400e29b41d4a716446655440000' + * ``` + */ +export function parseSpaceId(spaceId: string): Hex { + // Remove dashes if present (UUID format) + let normalized = spaceId.replace(/-/g, ''); + + // Remove 0x prefix if present + if (normalized.startsWith('0x')) { + normalized = normalized.slice(2); + } + + // Validate length (should be 32 hex characters for bytes16) + if (normalized.length !== 32) { + throw new Error( + `Invalid space ID length: expected 32 hex chars, got ${normalized.length}. ` + `Input: "${spaceId}"`, + ); + } + + // Validate hex characters + if (!/^[0-9a-fA-F]+$/.test(normalized)) { + throw new Error(`Invalid space ID: contains non-hex characters. Input: "${spaceId}"`); + } + + return `0x${normalized.toLowerCase()}` as Hex; +} + +/** + * Converts a bytes16 hex space ID to a UUID string (without dashes). + * + * @param hex - The space ID in bytes16 hex format + * @returns UUID string without dashes + * + * @example + * ```typescript + * hexToUuid('0x550e8400e29b41d4a716446655440000'); + * // Returns: '550e8400e29b41d4a716446655440000' + * ``` + */ +export function hexToUuid(hex: Hex): string { + // Remove 0x prefix and return first 32 chars (bytes16) + return hex.slice(2, 34).toLowerCase(); +} + +/** + * Converts a bytes16 hex space ID to a formatted UUID string (with dashes). + * + * @param hex - The space ID in bytes16 hex format + * @returns UUID string with dashes (8-4-4-4-12 format) + * + * @example + * ```typescript + * hexToFormattedUuid('0x550e8400e29b41d4a716446655440000'); + * // Returns: '550e8400-e29b-41d4-a716-446655440000' + * ``` + */ +export function hexToFormattedUuid(hex: Hex): string { + const uuid = hexToUuid(hex); + return `${uuid.slice(0, 8)}-${uuid.slice(8, 12)}-${uuid.slice(12, 16)}-${uuid.slice(16, 20)}-${uuid.slice(20)}`; +}