Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
65 changes: 65 additions & 0 deletions src/space/constants.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
32 changes: 32 additions & 0 deletions src/space/constants.ts
Original file line number Diff line number Diff line change
@@ -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';
131 changes: 131 additions & 0 deletions src/space/create-personal-space.ts
Original file line number Diff line number Diff line change
@@ -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<Network, string> = {
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<CreatePersonalSpaceResult> {
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 };
}
85 changes: 85 additions & 0 deletions src/space/get-space-id.ts
Original file line number Diff line number Diff line change
@@ -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<Network, string> = {
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<Hex | null> {
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;
}
Loading