diff --git a/.changeset/bright-doves-respond.md b/.changeset/bright-doves-respond.md new file mode 100644 index 0000000..731192b --- /dev/null +++ b/.changeset/bright-doves-respond.md @@ -0,0 +1,5 @@ +--- +"@geoprotocol/geo-sdk": minor +--- + +Add the canonical `geo.responses` namespace for curation, stance, and veracity actions while keeping `geo.entityVotes` as a deprecated compatibility API. diff --git a/README.md b/README.md index 4916ee0..76090f6 100644 --- a/README.md +++ b/README.md @@ -864,24 +864,53 @@ await walletClient.sendTransaction({ }); ``` -### `geo.entityVotes` +### `geo.responses` -Upvote, downvote, or withdraw a vote on an entity: +Respond to an entity on three independent axes: + +- Curation: `upvote`, `downvote`, and `unvote` +- Stance: `agree`, `disagree`, and `unagree` +- Veracity: `verify`, `dispute`, and `unverify` + +Each clear method removes only its matching response kind. For example, +`unverify` does not remove an upvote or agreement from the same user. + +Create curation response calldata: ```ts -const upvote = geo.entityVotes.upvote({ +const upvote = geo.responses.upvote({ authorSpaceId, spaceId, entityId, }); -const downvote = geo.entityVotes.downvote({ +const downvote = geo.responses.downvote({ authorSpaceId, spaceId, entityId, }); -const withdraw = geo.entityVotes.withdraw({ +const unvote = geo.responses.unvote({ + authorSpaceId, + spaceId, + entityId, +}); +``` + +Create stance response calldata: + +```ts +const agree = geo.responses.agree({ authorSpaceId, spaceId, entityId }); +const disagree = geo.responses.disagree({ authorSpaceId, spaceId, entityId }); +const unagree = geo.responses.unagree({ authorSpaceId, spaceId, entityId }); +``` + +Create veracity response calldata: + +```ts +const verify = geo.responses.verify({ authorSpaceId, spaceId, entityId }); +const dispute = geo.responses.dispute({ authorSpaceId, spaceId, entityId }); +const unverify = geo.responses.unverify({ authorSpaceId, spaceId, entityId, diff --git a/src/api-surface.e2e.test.ts b/src/api-surface.e2e.test.ts index f432837..c5856e6 100644 --- a/src/api-surface.e2e.test.ts +++ b/src/api-surface.e2e.test.ts @@ -1,9 +1,10 @@ import type { CreateRelation, Op } from '@geoprotocol/grc-20'; import type { Hex } from 'viem'; -import { describe, expect, it } from 'vitest'; +import { beforeAll, describe, expect, it } from 'vitest'; import { createGeoClient, Ops } from '../index.js'; import { SpaceRegistryAbi } from './abis/index.js'; +import { RESPONSE_ACTIONS } from './client/responses.js'; import { DESCRIPTION_PROPERTY, RELATION_TYPE, REPLY_TO_PROPERTY } from './core/ids/system.js'; import { createE2ETestEnvironment, type E2ETestEnvironment } from './e2e-test-environment.js'; import { createE2EWalletSetup, type E2EPublicClient, type E2EWalletSetup } from './e2e-wallet.js'; @@ -12,6 +13,7 @@ import { generate, toGrcId } from './id-utils.js'; const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' as Hex; const EMPTY_SPACE_ID = '0x00000000000000000000000000000000' as Hex; +const RESPONSE_SCHEMA_PROBE_ENTITY_ID = '00000000000000000000000000000000'; const INDEXER_TIMEOUT_MS = 120_000; const TEST_TIMEOUT_MS = 600_000; const replyToGrcId = toGrcId(REPLY_TO_PROPERTY); @@ -103,16 +105,43 @@ type ProposalVoteQueryResponse = { }>; }; -type VoteQueryResponse = { - votes: Array<{ - voterId: string; +type VoteKind = 0 | 1 | 2; +type VoteType = 0 | 1 | 2; + +type ResponseStateQueryResponse = { + userVotes: Array<{ + userId: string; + objectId: string; + objectType: number; + spaceId: string; + voteType: VoteType; + voteKind: VoteKind; + }>; + votesCounts: Array<{ objectId: string; objectType: number; spaceId: string; - vote: number; + voteKind: VoteKind; + positive: string | number; + negative: string | number; }>; }; +type ExpectedResponseState = { + voteType: VoteType; + positive: number; + negative: number; +}; + +const REQUIRED_RESPONSE_ACTIONS = [ + RESPONSE_ACTIONS.agree, + RESPONSE_ACTIONS.disagree, + RESPONSE_ACTIONS.unagree, + RESPONSE_ACTIONS.verify, + RESPONSE_ACTIONS.dispute, + RESPONSE_ACTIONS.unverify, +] as const; + type SpaceTopicQueryResponse = { spaces: Array<{ topicId: string | null; @@ -261,19 +290,34 @@ function proposalVoteQuery(proposalId: string, voterId: string, spaceId: string) }`; } -function entityVoteQuery(entityId: string, voterId: string, spaceId: string) { - return `query entityVote { - votes(condition: { - voterId: ${JSON.stringify(voterId.replaceAll('-', ''))} +function responseStateQuery(entityId: string, userId: string, spaceId: string, voteKind: VoteKind) { + return `query responseState { + userVotes(condition: { + userId: ${JSON.stringify(userId.replaceAll('-', ''))} objectId: ${JSON.stringify(entityId.replaceAll('-', ''))} objectType: 0 spaceId: ${JSON.stringify(spaceId.replaceAll('-', ''))} + voteKind: ${voteKind} }) { - voterId + userId objectId objectType spaceId - vote + voteType + voteKind + } + votesCounts(condition: { + objectId: ${JSON.stringify(entityId.replaceAll('-', ''))} + objectType: 0 + spaceId: ${JSON.stringify(spaceId.replaceAll('-', ''))} + voteKind: ${voteKind} + }) { + objectId + objectType + spaceId + voteKind + positive + negative } }`; } @@ -353,15 +397,17 @@ async function ensureIndexerTracksConfiguredRegistry(context: TestContext) { to: setTopic.to, calldata: setTopic.calldata, }); - const metas = await waitForIndexerBlock(receipt.blockNumber); - const indexedTopicId = await readSpaceTopicId(context.spaceId); - - if (indexedTopicId !== canaryTopicId) { + await waitForIndexerBlock(receipt.blockNumber); + try { + await waitForSpaceTopicId(context.spaceId, canaryTopicId); + } catch (error) { + const [metas, indexedTopicId] = await Promise.all([readIndexerMetas(), readSpaceTopicId(context.spaceId)]); throw new Error( [ `Configured API ${e2e.apiOrigin} is not indexing actions from configured SPACE_REGISTRY_ADDRESS ${e2e.contracts.SPACE_REGISTRY_ADDRESS}.`, - `A TOPIC_SET canary emitted at block ${receipt.blockNumber.toString()} with topic ${canaryTopicId}, and the API indexed past that block (${JSON.stringify(metas)}), but the API still returned topic ${indexedTopicId}.`, + `A TOPIC_SET canary emitted at block ${receipt.blockNumber.toString()} with topic ${canaryTopicId}, and the API indexed through that block (${JSON.stringify(metas)}), but the API still returned topic ${indexedTopicId}.`, 'Set GEO_E2E_API_ORIGIN to an API that indexes the configured contracts, or update the testnet indexer before running indexer-backed e2e tests.', + `Topic wait error: ${String(error)}`, ].join(' '), ); } @@ -377,12 +423,7 @@ async function ensureIndexerTracksConfiguredRegistry(context: TestContext) { calldata: restoreTopic.calldata, }); await waitForIndexerBlock(restore.receipt.blockNumber); - const restoredTopicId = await readSpaceTopicId(context.spaceId); - if (restoredTopicId !== previousTopicId) { - throw new Error( - `Configured API ${e2e.apiOrigin} indexed the canary topic but did not index the restore topic ${previousTopicId}. Last indexed topic: ${restoredTopicId}.`, - ); - } + await waitForSpaceTopicId(context.spaceId, previousTopicId); } })(); @@ -515,20 +556,105 @@ async function waitForProposalVote( expect(data.proposalVotes.map(proposalVote => proposalVote.vote)).toContain(vote); } -async function waitForEntityVote( +async function waitForEntityResponse( entityId: string, - voterId: string, + userId: string, spaceId: string, - predicate: (votes: VoteQueryResponse['votes']) => boolean, + voteKind: VoteKind, + expected: ExpectedResponseState, ) { const data = await waitFor( - `entity vote for ${entityId}`, - () => queryGraph(entityVoteQuery(entityId, voterId, spaceId)), - value => predicate(value.votes), + `entity response kind ${voteKind} for ${entityId}`, + () => queryGraph(responseStateQuery(entityId, userId, spaceId, voteKind)), + value => { + const userVote = value.userVotes[0]; + const count = value.votesCounts[0]; + + return ( + value.userVotes.every(vote => vote.voteKind === voteKind) && + value.votesCounts.every(votesCount => votesCount.voteKind === voteKind) && + (userVote?.voteType ?? null) === expected.voteType && + count !== undefined && + Number(count.positive) === expected.positive && + Number(count.negative) === expected.negative + ); + }, ); - expect(predicate(data.votes)).toBe(true); - return data.votes; + expect(data.userVotes.every(vote => vote.voteKind === voteKind)).toBe(true); + expect(data.votesCounts.every(votesCount => votesCount.voteKind === voteKind)).toBe(true); + expect(data.userVotes[0]?.voteType ?? null).toBe(expected.voteType); + expect(Number(data.votesCounts[0]?.positive)).toBe(expected.positive); + expect(Number(data.votesCounts[0]?.negative)).toBe(expected.negative); + + return data; +} + +let responseEnvironmentPromise: Promise | undefined; +async function ensureResponseEnvironmentReady(context: TestContext) { + responseEnvironmentPromise ??= (async () => { + const registrations = await Promise.all( + REQUIRED_RESPONSE_ACTIONS.map(async action => { + const isPermissionless = (await context.publicClient.readContract({ + address: e2e.contracts.SPACE_REGISTRY_ADDRESS, + abi: SpaceRegistryAbi, + functionName: 'permissionlessActions', + args: [action.hash], + })) as boolean; + + return { action, isPermissionless }; + }), + ); + const missingActions = registrations + .filter(({ isPermissionless }) => !isPermissionless) + .map(({ action }) => action.name); + + if (missingActions.length > 0) { + throw new Error( + [ + `Response e2e prerequisites are missing on Geo network ${e2e.network.name} (${e2e.network.id}).`, + `SpaceRegistry ${e2e.contracts.SPACE_REGISTRY_ADDRESS} has not enabled: ${missingActions.join(', ')}.`, + 'A registry owner must call setPermissionlessAction for every missing source-of-truth action before response transactions can be tested.', + ].join(' '), + ); + } + + try { + await queryGraph( + responseStateQuery(RESPONSE_SCHEMA_PROBE_ENTITY_ID, context.authorSpaceId, context.spaceId, 0), + ); + } catch (error) { + if (error instanceof GraphQlRequestError && error.hasValidationError()) { + throw new Error( + [ + `Response e2e prerequisites are missing from API ${e2e.apiOrigin}.`, + 'The gaia GraphQL schema must expose kind-filtered userVotes and votesCounts, including voteKind, voteType, positive, and negative from PR #872.', + `GraphQL validation error: ${String(error)}`, + ].join(' '), + ); + } + throw error; + } + })(); + + return responseEnvironmentPromise; +} + +async function sendResponseAndWait( + context: TestContext, + label: string, + response: { to: `0x${string}`; calldata: `0x${string}` }, + entityId: string, + voteKind: VoteKind, + expected: ExpectedResponseState, +) { + const { receipt } = await sendTransactionAndWait(context, { + label, + to: response.to, + calldata: response.calldata, + }); + await waitForIndexerBlock(receipt.blockNumber); + return waitForEntityResponse(entityId, context.authorSpaceId, context.spaceId, voteKind, expected); } async function waitForSpaceTopicId(spaceId: string, topicId: string) { @@ -1258,83 +1384,139 @@ describe.sequential('new API e2e surface', () => { TEST_TIMEOUT_MS, ); - it( - 'geo.entityVotes.upvote submits and indexes an upvote', - async () => { + describe.sequential('geo.responses', () => { + beforeAll(async () => { const context = await getTestContext(); - const entity = await createIndexedEntity(context, uniqueName('E2E New Upvoted Entity')); - const upvote = geo.entityVotes.upvote({ - authorSpaceId: context.authorSpaceId, - spaceId: context.spaceId, - entityId: entity.id, - }); - await sendTransactionAndWait(context, { - label: 'E2E new API upvote entity', - to: upvote.to, - calldata: upvote.calldata, - }); - await waitForEntityVote(entity.id, context.authorSpaceId, context.spaceId, votes => - votes.some(vote => vote.vote === 0), - ); - }, - TEST_TIMEOUT_MS, - ); + await ensureResponseEnvironmentReady(context); + }, TEST_TIMEOUT_MS); + + it( + 'transitions and clears canonical curation responses', + async () => { + const context = await getTestContext(); + const entity = await createIndexedEntity(context, uniqueName('E2E New Upvoted Entity')); + const params = { + authorSpaceId: context.authorSpaceId, + spaceId: context.spaceId, + entityId: entity.id, + }; + + const upvote = geo.responses.upvote(params); + await sendResponseAndWait(context, 'E2E canonical upvote entity', upvote, entity.id, 0, { + voteType: 0, + positive: 1, + negative: 0, + }); - it( - 'geo.entityVotes.downvote submits and indexes a downvote', - async () => { - const context = await getTestContext(); - const entity = await createIndexedEntity(context, uniqueName('E2E New Downvoted Entity')); - const downvote = geo.entityVotes.downvote({ - authorSpaceId: context.authorSpaceId, - spaceId: context.spaceId, - entityId: entity.id, - }); - await sendTransactionAndWait(context, { - label: 'E2E new API downvote entity', - to: downvote.to, - calldata: downvote.calldata, - }); - await waitForEntityVote(entity.id, context.authorSpaceId, context.spaceId, votes => - votes.some(vote => vote.vote === 1), - ); - }, - TEST_TIMEOUT_MS, - ); + const downvote = geo.responses.downvote(params); + await sendResponseAndWait(context, 'E2E canonical downvote entity', downvote, entity.id, 0, { + voteType: 1, + positive: 0, + negative: 1, + }); - it( - 'geo.entityVotes.withdraw removes an indexed entity vote', - async () => { - const context = await getTestContext(); - const entity = await createIndexedEntity(context, uniqueName('E2E New Vote Withdraw Entity')); - const upvote = geo.entityVotes.upvote({ - authorSpaceId: context.authorSpaceId, - spaceId: context.spaceId, - entityId: entity.id, - }); - await sendTransactionAndWait(context, { - label: 'E2E new API upvote before withdraw', - to: upvote.to, - calldata: upvote.calldata, - }); - await waitForEntityVote(entity.id, context.authorSpaceId, context.spaceId, votes => votes.length > 0); + const unvote = geo.responses.unvote(params); + await sendResponseAndWait(context, 'E2E clear canonical entity vote', unvote, entity.id, 0, { + voteType: 2, + positive: 0, + negative: 0, + }); + }, + TEST_TIMEOUT_MS, + ); - const withdraw = geo.entityVotes.withdraw({ - authorSpaceId: context.authorSpaceId, - spaceId: context.spaceId, - entityId: entity.id, - }); - await sendTransactionAndWait(context, { - label: 'E2E new API withdraw entity vote', - to: withdraw.to, - calldata: withdraw.calldata, - }); - await waitForEntityVote(entity.id, context.authorSpaceId, context.spaceId, votes => - votes.some(vote => vote.vote === 2), - ); - }, - TEST_TIMEOUT_MS, - ); + it( + 'transitions and clears stance independently', + async () => { + const context = await getTestContext(); + const entity = await createIndexedEntity(context, uniqueName('E2E New Stance Entity')); + const params = { + authorSpaceId: context.authorSpaceId, + spaceId: context.spaceId, + entityId: entity.id, + }; + + const agree = geo.responses.agree(params); + await sendResponseAndWait(context, 'E2E agree with entity', agree, entity.id, 1, { + voteType: 0, + positive: 1, + negative: 0, + }); + + const disagree = geo.responses.disagree(params); + await sendResponseAndWait(context, 'E2E disagree with entity', disagree, entity.id, 1, { + voteType: 1, + positive: 0, + negative: 1, + }); + + const unagree = geo.responses.unagree(params); + await sendResponseAndWait(context, 'E2E clear entity stance', unagree, entity.id, 1, { + voteType: 2, + positive: 0, + negative: 0, + }); + }, + TEST_TIMEOUT_MS, + ); + + it( + 'transitions and clears veracity without changing curation', + async () => { + const context = await getTestContext(); + const entity = await createIndexedEntity(context, uniqueName('E2E New Veracity Entity')); + const params = { + authorSpaceId: context.authorSpaceId, + spaceId: context.spaceId, + entityId: entity.id, + }; + + const upvote = geo.responses.upvote(params); + await sendResponseAndWait(context, 'E2E upvote before veracity responses', upvote, entity.id, 0, { + voteType: 0, + positive: 1, + negative: 0, + }); + + const verify = geo.responses.verify(params); + await sendResponseAndWait(context, 'E2E verify entity', verify, entity.id, 2, { + voteType: 0, + positive: 1, + negative: 0, + }); + await waitForEntityResponse(entity.id, context.authorSpaceId, context.spaceId, 0, { + voteType: 0, + positive: 1, + negative: 0, + }); + + const dispute = geo.responses.dispute(params); + await sendResponseAndWait(context, 'E2E dispute entity', dispute, entity.id, 2, { + voteType: 1, + positive: 0, + negative: 1, + }); + await waitForEntityResponse(entity.id, context.authorSpaceId, context.spaceId, 0, { + voteType: 0, + positive: 1, + negative: 0, + }); + + const unverify = geo.responses.unverify(params); + await sendResponseAndWait(context, 'E2E clear entity veracity', unverify, entity.id, 2, { + voteType: 2, + positive: 0, + negative: 0, + }); + await waitForEntityResponse(entity.id, context.authorSpaceId, context.spaceId, 0, { + voteType: 0, + positive: 1, + negative: 0, + }); + }, + TEST_TIMEOUT_MS, + ); + }); it( 'geo.daoSpaces.create creates an indexed DAO space', diff --git a/src/client.test.ts b/src/client.test.ts index 1f0186b..f56817d 100644 --- a/src/client.test.ts +++ b/src/client.test.ts @@ -1,4 +1,7 @@ +import { decodeFunctionData } from 'viem'; import { describe, expect, it, vi } from 'vitest'; +import { SpaceRegistryAbi } from './abis/index.js'; +import { RESPONSE_ACTIONS } from './client/responses.js'; import { createGeoClient } from './client.js'; import { defineGeoNetworkConfig, GeoTestnetConfig } from './networks.js'; import * as Ops from './ops/index.js'; @@ -149,4 +152,58 @@ describe('createGeoClient', () => { expect(result.to).toBe('0x0000000000000000000000000000000000000001'); expect(result.calldata).toMatch(/^0x/); }); + + it('exposes canonical response helpers without fetch', () => { + const originalFetch = globalThis.fetch; + vi.stubGlobal('fetch', undefined); + + try { + const geo = createGeoClient({ network: customNetwork() }); + const params = { + authorSpaceId: '0eed5491b917cf58b33ac81255fe7ae9', + spaceId: 'abcdef12345678901234567890abcdef', + entityId: '11111111111111111111111111111111', + }; + + expect(Object.keys(geo.responses)).toEqual([ + 'upvote', + 'downvote', + 'unvote', + 'agree', + 'disagree', + 'unagree', + 'verify', + 'dispute', + 'unverify', + ]); + + for (const method of Object.keys(geo.responses) as (keyof typeof geo.responses)[]) { + const result = geo.responses[method](params); + const decoded = decodeFunctionData({ + abi: SpaceRegistryAbi, + data: result.calldata, + }); + const [, , action] = decoded.args as readonly `0x${string}`[]; + + expect(result.to).toBe('0x0000000000000000000000000000000000000001'); + expect(decoded.functionName).toBe('enter'); + expect(action).toBe(RESPONSE_ACTIONS[method].hash); + } + } finally { + vi.stubGlobal('fetch', originalFetch); + } + }); + + it('keeps entityVotes as byte-for-byte compatible response aliases', () => { + const geo = createGeoClient({ network: customNetwork() }); + const params = { + authorSpaceId: '0eed5491b917cf58b33ac81255fe7ae9', + spaceId: 'abcdef12345678901234567890abcdef', + entityId: '11111111111111111111111111111111', + }; + + expect(geo.entityVotes.upvote(params)).toEqual(geo.responses.upvote(params)); + expect(geo.entityVotes.downvote(params)).toEqual(geo.responses.downvote(params)); + expect(geo.entityVotes.withdraw(params)).toEqual(geo.responses.unvote(params)); + }); }); diff --git a/src/client.ts b/src/client.ts index dfd51f6..3831481 100644 --- a/src/client.ts +++ b/src/client.ts @@ -9,6 +9,7 @@ import * as EntityVotes from './client/entity-votes.js'; import * as PersonalSpaces from './client/personal-spaces.js'; import type { UpdateRankClientParams } from './client/ranks.js'; import * as Ranks from './client/ranks.js'; +import * as Responses from './client/responses.js'; import type { VotingSettingsInput } from './encodings/get-create-dao-space-calldata.js'; import type { Id } from './id.js'; import { defineGeoNetworkConfig } from './networks.js'; @@ -230,11 +231,10 @@ export type ProposeUpdateVotingSettingsParams = Omit DaoSpaces.executeProposal(context, params), }, - /** Entity vote transaction helpers. */ + /** Entity response transaction helpers. */ + responses: { + /** Builds calldata for upvoting an entity. */ + upvote: (params: ResponseParams) => Responses.upvote(context, params), + /** Builds calldata for downvoting an entity. */ + downvote: (params: ResponseParams) => Responses.downvote(context, params), + /** Builds calldata for clearing an entity vote. */ + unvote: (params: ResponseParams) => Responses.unvote(context, params), + /** Builds calldata for agreeing with an entity. */ + agree: (params: ResponseParams) => Responses.agree(context, params), + /** Builds calldata for disagreeing with an entity. */ + disagree: (params: ResponseParams) => Responses.disagree(context, params), + /** Builds calldata for clearing an entity agreement. */ + unagree: (params: ResponseParams) => Responses.unagree(context, params), + /** Builds calldata for verifying an entity. */ + verify: (params: ResponseParams) => Responses.verify(context, params), + /** Builds calldata for disputing an entity. */ + dispute: (params: ResponseParams) => Responses.dispute(context, params), + /** Builds calldata for clearing an entity verification response. */ + unverify: (params: ResponseParams) => Responses.unverify(context, params), + }, + /** @deprecated Use `responses`. */ entityVotes: { /** * Builds calldata for upvoting an entity. diff --git a/src/client/entity-votes.ts b/src/client/entity-votes.ts index 0425d3e..1089406 100644 --- a/src/client/entity-votes.ts +++ b/src/client/entity-votes.ts @@ -1,200 +1,81 @@ -import { encodeAbiParameters, encodeFunctionData, keccak256, toHex } from 'viem'; -import { SpaceRegistryAbi } from '../abis/index.js'; import type { Id } from '../id.js'; import { assertValid } from '../id-utils.js'; -import { requireGeoContract } from '../networks.js'; import type { GeoClientContext } from './context.js'; +import * as Responses from './responses.js'; -const EMPTY_SIGNATURE = '0x' as const; -const ENTITY_OBJECT_TYPE = '00000000'; -const ENTITY_VOTE_VERSION = 0; -const UPVOTED_ACTION = keccak256(toHex('PERMISSIONLESS.UPVOTED')); -const DOWNVOTED_ACTION = keccak256(toHex('PERMISSIONLESS.DOWNVOTED')); -const UNVOTED_ACTION = keccak256(toHex('PERMISSIONLESS.UNVOTED')); +/** @deprecated Use `ClientResponseParams` from the canonical response helpers. */ +export type ClientEntityVoteParams = Responses.ClientResponseParams; -type EntityVoteAction = typeof UPVOTED_ACTION | typeof DOWNVOTED_ACTION | typeof UNVOTED_ACTION; +/** @deprecated Use `ResponseCalldataParams` from the canonical response helpers. */ +export type EntityVoteCalldataParams = Responses.ResponseCalldataParams; -export type ClientEntityVoteParams = { - authorSpaceId: Id | string; - spaceId: Id | string; - entityId: Id | string; -}; - -export type EntityVoteCalldataParams = ClientEntityVoteParams & { - spaceRegistryAddress: `0x${string}`; -}; - -function idToBytes16(id: Id | string, sourceHint: string): `0x${string}` { +function validateEntityVoteId(id: Id | string, sourceHint: string) { const normalized = id.startsWith('0x') ? id.slice(2) : id.replaceAll('-', ''); assertValid(normalized, sourceHint); - - return `0x${normalized.toLowerCase()}` as `0x${string}`; -} - -function encodeEntityVoteTopic(entityId: Id | string): `0x${string}` { - const normalizedEntityId = idToBytes16(entityId, '`entityId` in entity vote').slice(2); - - return `0x${ENTITY_OBJECT_TYPE}${normalizedEntityId}${'0'.repeat(24)}` as `0x${string}`; -} - -function encodeEntityVoteData(authorSpaceId: `0x${string}`, spaceId: `0x${string}`): `0x${string}` { - return encodeAbiParameters( - [{ type: 'uint16' }, { type: 'bytes16' }, { type: 'bytes16' }], - [ENTITY_VOTE_VERSION, authorSpaceId, spaceId], - ); } -function encodeEntityVoteCalldata(params: EntityVoteCalldataParams, action: EntityVoteAction) { - const authorSpaceId = idToBytes16(params.authorSpaceId, '`authorSpaceId` in entity vote'); - const spaceId = idToBytes16(params.spaceId, '`spaceId` in entity vote'); - const topic = encodeEntityVoteTopic(params.entityId); - const data = encodeEntityVoteData(authorSpaceId, spaceId); - - const calldata = encodeFunctionData({ - abi: SpaceRegistryAbi, - functionName: 'enter', - args: [authorSpaceId, spaceId, action, topic, data, EMPTY_SIGNATURE], - }); - - return { - to: params.spaceRegistryAddress, - calldata, - }; -} - -function withSpaceRegistry(context: GeoClientContext, params: ClientEntityVoteParams): EntityVoteCalldataParams { - return { - ...params, - spaceRegistryAddress: requireGeoContract(context.network, 'SPACE_REGISTRY_ADDRESS'), - }; +function validateEntityVoteParams(params: ClientEntityVoteParams) { + validateEntityVoteId(params.authorSpaceId, '`authorSpaceId` in entity vote'); + validateEntityVoteId(params.spaceId, '`spaceId` in entity vote'); + validateEntityVoteId(params.entityId, '`entityId` in entity vote'); } /** * Encodes upvote calldata. * - * Use this pure helper when you already have the target space registry address. - * Use `geo.entityVotes.upvote(...)` when the address should come from the - * configured network. - * - * @example - * ```ts - * const tx = encodeUpvoteEntityCalldata({ - * authorSpaceId, - * spaceId, - * entityId, - * spaceRegistryAddress, - * }); - * ``` - * - * @param params Author space, target space, entity ID, and space registry address. - * @returns Target registry address and encoded calldata. - * @throws When any supplied ID is invalid. + * @deprecated Use `geo.responses.upvote(...)` through `createGeoClient`. */ export function encodeUpvoteEntityCalldata(params: EntityVoteCalldataParams) { - return encodeEntityVoteCalldata(params, UPVOTED_ACTION); + validateEntityVoteParams(params); + return Responses.encodeUpvoteEntityResponseCalldata(params); } /** * Encodes downvote calldata. * - * @example - * ```ts - * const tx = encodeDownvoteEntityCalldata({ - * authorSpaceId, - * spaceId, - * entityId, - * spaceRegistryAddress, - * }); - * ``` - * - * @param params Author space, target space, entity ID, and space registry address. - * @returns Target registry address and encoded calldata. - * @throws When any supplied ID is invalid. + * @deprecated Use `geo.responses.downvote(...)` through `createGeoClient`. */ export function encodeDownvoteEntityCalldata(params: EntityVoteCalldataParams) { - return encodeEntityVoteCalldata(params, DOWNVOTED_ACTION); + validateEntityVoteParams(params); + return Responses.encodeDownvoteEntityResponseCalldata(params); } /** * Encodes vote-withdrawal calldata. * - * @example - * ```ts - * const tx = encodeWithdrawEntityVoteCalldata({ - * authorSpaceId, - * spaceId, - * entityId, - * spaceRegistryAddress, - * }); - * ``` - * - * @param params Author space, target space, entity ID, and space registry address. - * @returns Target registry address and encoded calldata. - * @throws When any supplied ID is invalid. + * @deprecated Use `geo.responses.unvote(...)` through `createGeoClient`. */ export function encodeWithdrawEntityVoteCalldata(params: EntityVoteCalldataParams) { - return encodeEntityVoteCalldata(params, UNVOTED_ACTION); + validateEntityVoteParams(params); + return Responses.encodeUnvoteEntityResponseCalldata(params); } /** * Builds calldata for upvoting an entity using the configured space registry. * - * @example - * ```ts - * const tx = geo.entityVotes.upvote({ - * authorSpaceId, - * spaceId, - * entityId, - * }); - * ``` - * - * @param context Client context containing the target network configuration. - * @param params Author space, target space, and entity ID. - * @returns Target registry address and encoded calldata. - * @throws When IDs are invalid or the configured network is missing `SPACE_REGISTRY_ADDRESS`. + * @deprecated Use `geo.responses.upvote(...)`. */ export function upvote(context: GeoClientContext, params: ClientEntityVoteParams) { - return encodeUpvoteEntityCalldata(withSpaceRegistry(context, params)); + validateEntityVoteParams(params); + return Responses.upvote(context, params); } /** * Builds calldata for downvoting an entity using the configured space registry. * - * @example - * ```ts - * const tx = geo.entityVotes.downvote({ - * authorSpaceId, - * spaceId, - * entityId, - * }); - * ``` - * - * @param context Client context containing the target network configuration. - * @param params Author space, target space, and entity ID. - * @returns Target registry address and encoded calldata. - * @throws When IDs are invalid or the configured network is missing `SPACE_REGISTRY_ADDRESS`. + * @deprecated Use `geo.responses.downvote(...)`. */ export function downvote(context: GeoClientContext, params: ClientEntityVoteParams) { - return encodeDownvoteEntityCalldata(withSpaceRegistry(context, params)); + validateEntityVoteParams(params); + return Responses.downvote(context, params); } /** * Builds calldata for withdrawing an entity vote using the configured space registry. * - * @example - * ```ts - * const tx = geo.entityVotes.withdraw({ - * authorSpaceId, - * spaceId, - * entityId, - * }); - * ``` - * - * @param context Client context containing the target network configuration. - * @param params Author space, target space, and entity ID. - * @returns Target registry address and encoded calldata. - * @throws When IDs are invalid or the configured network is missing `SPACE_REGISTRY_ADDRESS`. + * @deprecated Use `geo.responses.unvote(...)`. */ export function withdraw(context: GeoClientContext, params: ClientEntityVoteParams) { - return encodeWithdrawEntityVoteCalldata(withSpaceRegistry(context, params)); + validateEntityVoteParams(params); + return Responses.unvote(context, params); } diff --git a/src/client/responses.test.ts b/src/client/responses.test.ts new file mode 100644 index 0000000..c864ce5 --- /dev/null +++ b/src/client/responses.test.ts @@ -0,0 +1,233 @@ +import { decodeAbiParameters, decodeFunctionData, keccak256, toHex } from 'viem'; +import { describe, expect, it } from 'vitest'; +import { SpaceRegistryAbi } from '../abis/index.js'; +import { defineGeoNetworkConfig } from '../networks.js'; +import type { GeoClientContext } from './context.js'; +import { + agree, + disagree, + dispute, + downvote, + RESPONSE_ACTIONS, + unagree, + unverify, + unvote, + upvote, + verify, +} from './responses.js'; + +const AUTHOR_SPACE_ID = '0eed5491b917cf58b33ac81255fe7ae9'; +const SPACE_ID = 'abcdef12345678901234567890abcdef'; +const ENTITY_ID = '11111111111111111111111111111111'; +const SPACE_REGISTRY_ADDRESS = '0x0000000000000000000000000000000000000001' as const; + +const EXPECTED_ACTIONS = { + upvote: { + name: 'PERMISSIONLESS.UPVOTED', + hash: '0x1fc04a8d9387c7bd1199a2a77c8e531a7a7b11991df5dcc8c9acb6abcb481725', + }, + downvote: { + name: 'PERMISSIONLESS.DOWNVOTED', + hash: '0xde8b897ce7cc541dacb388d5aabb3dc0fb7856920284f41582c15b5fc31a8662', + }, + unvote: { + name: 'PERMISSIONLESS.UNVOTED', + hash: '0x3bd4c337382f79aa5007a91169bb57723b5dd59e6b4bb60d20362bcc0d9d998b', + }, + agree: { + name: 'PERMISSIONLESS.AGREED', + hash: '0xcc1f104e089fb96ad3a3f1e70607f3dda4ed556e810bdc30193f19df474369b9', + }, + disagree: { + name: 'PERMISSIONLESS.DISAGREED', + hash: '0x285c96f1d9b8f9143d333a762cb9fa03e98b3f551a824e99ed14072ca3c51179', + }, + unagree: { + name: 'PERMISSIONLESS.UNAGREED', + hash: '0xa1d2a63f4172ef63617e69ca00a8a5e0e0f886fcd26d742208cc5da02fe32328', + }, + verify: { + name: 'PERMISSIONLESS.VERIFIED', + hash: '0x588446c29505d69d73cba2f34aa402447b77f055539a93aec891beb3fbf3f0fd', + }, + dispute: { + name: 'PERMISSIONLESS.DISPUTED', + hash: '0x839d074bf1854255cda5c35a5c89feb5687db041c8ff22370e8597a58ef7706d', + }, + unverify: { + name: 'PERMISSIONLESS.UNVERIFIED', + hash: '0x9516e48c1d614910098dd6197889f54cb08474c630efdb4cd07bbeee329912c2', + }, +} as const; + +const OPERATIONS = { + upvote, + downvote, + unvote, + agree, + disagree, + unagree, + verify, + dispute, + unverify, +} as const; + +function testContext(): GeoClientContext { + return { + network: defineGeoNetworkConfig({ + id: 'LOCAL', + name: 'Local Geo', + apiOrigin: 'http://localhost:3000', + contracts: { + SPACE_REGISTRY_ADDRESS, + }, + }), + }; +} + +function decodeResponse(calldata: `0x${string}`) { + const decoded = decodeFunctionData({ + abi: SpaceRegistryAbi, + data: calldata, + }); + expect(decoded.functionName).toBe('enter'); + + const [fromSpaceId, toSpaceId, action, topic, data, signature] = decoded.args as [ + `0x${string}`, + `0x${string}`, + `0x${string}`, + `0x${string}`, + `0x${string}`, + `0x${string}`, + ]; + const [version, authorSpaceId, spaceId] = decodeAbiParameters( + [{ type: 'uint16' }, { type: 'bytes16' }, { type: 'bytes16' }], + data, + ); + + return { + fromSpaceId, + toSpaceId, + action, + topic, + signature, + version, + authorSpaceId, + spaceId, + }; +} + +describe('client response helpers', () => { + it('pins every protocol action name and hash', () => { + expect(RESPONSE_ACTIONS).toEqual(EXPECTED_ACTIONS); + + const hashes = Object.values(RESPONSE_ACTIONS).map(({ name, hash }) => { + expect(hash).toBe(keccak256(toHex(name))); + return hash; + }); + + expect(new Set(hashes).size).toBe(hashes.length); + }); + + it('encodes all nine response actions with the same entity payload', () => { + const context = testContext(); + const params = { + authorSpaceId: AUTHOR_SPACE_ID, + spaceId: SPACE_ID, + entityId: ENTITY_ID, + }; + + for (const [method, operation] of Object.entries(OPERATIONS)) { + const result = operation(context, params); + const decoded = decodeResponse(result.calldata); + + expect(result.to).toBe(SPACE_REGISTRY_ADDRESS); + expect(decoded.fromSpaceId).toBe(`0x${AUTHOR_SPACE_ID}`); + expect(decoded.toSpaceId).toBe(`0x${SPACE_ID}`); + expect(decoded.action).toBe(EXPECTED_ACTIONS[method as keyof typeof EXPECTED_ACTIONS].hash); + expect(decoded.topic).toBe(`0x00000000${ENTITY_ID}${'0'.repeat(24)}`); + expect(decoded.signature).toBe('0x'); + expect(decoded.version).toBe(0); + expect(decoded.authorSpaceId).toBe(`0x${AUTHOR_SPACE_ID}`); + expect(decoded.spaceId).toBe(`0x${SPACE_ID}`); + } + }); + + it('keeps positive, negative, and clear actions distinct across response kinds', () => { + expect( + new Set([RESPONSE_ACTIONS.upvote.hash, RESPONSE_ACTIONS.agree.hash, RESPONSE_ACTIONS.verify.hash]).size, + ).toBe(3); + expect( + new Set([RESPONSE_ACTIONS.downvote.hash, RESPONSE_ACTIONS.disagree.hash, RESPONSE_ACTIONS.dispute.hash]).size, + ).toBe(3); + expect( + new Set([RESPONSE_ACTIONS.unvote.hash, RESPONSE_ACTIONS.unagree.hash, RESPONSE_ACTIONS.unverify.hash]).size, + ).toBe(3); + }); + + it('normalizes dashed, raw, and 0x-prefixed ids', () => { + const context = testContext(); + const raw = upvote(context, { + authorSpaceId: AUTHOR_SPACE_ID, + spaceId: SPACE_ID, + entityId: ENTITY_ID, + }); + const dashed = upvote(context, { + authorSpaceId: '0eed5491-b917-cf58-b33a-c81255fe7ae9', + spaceId: 'abcdef12-3456-7890-1234-567890abcdef', + entityId: '11111111-1111-1111-1111-111111111111', + }); + const prefixed = upvote(context, { + authorSpaceId: `0x${AUTHOR_SPACE_ID}`, + spaceId: `0x${SPACE_ID}`, + entityId: `0x${ENTITY_ID}`, + }); + + expect(dashed).toEqual(raw); + expect(prefixed).toEqual(raw); + }); + + it('rejects invalid ids for representative response kinds', () => { + const context = testContext(); + + expect(() => + agree(context, { + authorSpaceId: 'invalid', + spaceId: SPACE_ID, + entityId: ENTITY_ID, + }), + ).toThrow('Invalid id: "invalid" for `authorSpaceId` in entity response'); + expect(() => + dispute(context, { + authorSpaceId: AUTHOR_SPACE_ID, + spaceId: 'invalid', + entityId: ENTITY_ID, + }), + ).toThrow('Invalid id: "invalid" for `spaceId` in entity response'); + expect(() => + unverify(context, { + authorSpaceId: AUTHOR_SPACE_ID, + spaceId: SPACE_ID, + entityId: 'invalid', + }), + ).toThrow('Invalid id: "invalid" for `entityId` in entity response'); + }); + + it('requires a configured space registry address', () => { + const context: GeoClientContext = { + network: defineGeoNetworkConfig({ + id: 'LOCAL', + name: 'Local Geo', + apiOrigin: 'http://localhost:3000', + }), + }; + + expect(() => + verify(context, { + authorSpaceId: AUTHOR_SPACE_ID, + spaceId: SPACE_ID, + entityId: ENTITY_ID, + }), + ).toThrow('Geo network "Local Geo" is missing required contract address SPACE_REGISTRY_ADDRESS'); + }); +}); diff --git a/src/client/responses.ts b/src/client/responses.ts new file mode 100644 index 0000000..fcee2bd --- /dev/null +++ b/src/client/responses.ts @@ -0,0 +1,138 @@ +import { encodeAbiParameters, encodeFunctionData, keccak256, toHex } from 'viem'; +import { SpaceRegistryAbi } from '../abis/index.js'; +import type { Id } from '../id.js'; +import { assertValid } from '../id-utils.js'; +import { requireGeoContract } from '../networks.js'; +import type { GeoClientContext } from './context.js'; + +const EMPTY_SIGNATURE = '0x' as const; +const ENTITY_OBJECT_TYPE = '00000000'; +const ENTITY_RESPONSE_VERSION = 0; + +function responseAction(name: string) { + return { + name, + hash: keccak256(toHex(name)), + } as const; +} + +export const RESPONSE_ACTIONS = { + upvote: responseAction('PERMISSIONLESS.UPVOTED'), + downvote: responseAction('PERMISSIONLESS.DOWNVOTED'), + unvote: responseAction('PERMISSIONLESS.UNVOTED'), + agree: responseAction('PERMISSIONLESS.AGREED'), + disagree: responseAction('PERMISSIONLESS.DISAGREED'), + unagree: responseAction('PERMISSIONLESS.UNAGREED'), + verify: responseAction('PERMISSIONLESS.VERIFIED'), + dispute: responseAction('PERMISSIONLESS.DISPUTED'), + unverify: responseAction('PERMISSIONLESS.UNVERIFIED'), +} as const; + +type ResponseAction = (typeof RESPONSE_ACTIONS)[keyof typeof RESPONSE_ACTIONS]['hash']; + +export type ClientResponseParams = { + authorSpaceId: Id | string; + spaceId: Id | string; + entityId: Id | string; +}; + +export type ResponseCalldataParams = ClientResponseParams & { + spaceRegistryAddress: `0x${string}`; +}; + +function idToBytes16(id: Id | string, sourceHint: string): `0x${string}` { + const normalized = id.startsWith('0x') ? id.slice(2) : id.replaceAll('-', ''); + assertValid(normalized, sourceHint); + + return `0x${normalized.toLowerCase()}` as `0x${string}`; +} + +function encodeEntityResponseTopic(entityId: Id | string): `0x${string}` { + const normalizedEntityId = idToBytes16(entityId, '`entityId` in entity response').slice(2); + + return `0x${ENTITY_OBJECT_TYPE}${normalizedEntityId}${'0'.repeat(24)}` as `0x${string}`; +} + +function encodeEntityResponseData(authorSpaceId: `0x${string}`, spaceId: `0x${string}`): `0x${string}` { + return encodeAbiParameters( + [{ type: 'uint16' }, { type: 'bytes16' }, { type: 'bytes16' }], + [ENTITY_RESPONSE_VERSION, authorSpaceId, spaceId], + ); +} + +function encodeEntityResponseCalldata(params: ResponseCalldataParams, action: ResponseAction) { + const authorSpaceId = idToBytes16(params.authorSpaceId, '`authorSpaceId` in entity response'); + const spaceId = idToBytes16(params.spaceId, '`spaceId` in entity response'); + const topic = encodeEntityResponseTopic(params.entityId); + const data = encodeEntityResponseData(authorSpaceId, spaceId); + + const calldata = encodeFunctionData({ + abi: SpaceRegistryAbi, + functionName: 'enter', + args: [authorSpaceId, spaceId, action, topic, data, EMPTY_SIGNATURE], + }); + + return { + to: params.spaceRegistryAddress, + calldata, + }; +} + +function withSpaceRegistry(context: GeoClientContext, params: ClientResponseParams): ResponseCalldataParams { + return { + ...params, + spaceRegistryAddress: requireGeoContract(context.network, 'SPACE_REGISTRY_ADDRESS'), + }; +} + +function respond(context: GeoClientContext, params: ClientResponseParams, action: ResponseAction) { + return encodeEntityResponseCalldata(withSpaceRegistry(context, params), action); +} + +export function encodeUpvoteEntityResponseCalldata(params: ResponseCalldataParams) { + return encodeEntityResponseCalldata(params, RESPONSE_ACTIONS.upvote.hash); +} + +export function encodeDownvoteEntityResponseCalldata(params: ResponseCalldataParams) { + return encodeEntityResponseCalldata(params, RESPONSE_ACTIONS.downvote.hash); +} + +export function encodeUnvoteEntityResponseCalldata(params: ResponseCalldataParams) { + return encodeEntityResponseCalldata(params, RESPONSE_ACTIONS.unvote.hash); +} + +export function upvote(context: GeoClientContext, params: ClientResponseParams) { + return respond(context, params, RESPONSE_ACTIONS.upvote.hash); +} + +export function downvote(context: GeoClientContext, params: ClientResponseParams) { + return respond(context, params, RESPONSE_ACTIONS.downvote.hash); +} + +export function unvote(context: GeoClientContext, params: ClientResponseParams) { + return respond(context, params, RESPONSE_ACTIONS.unvote.hash); +} + +export function agree(context: GeoClientContext, params: ClientResponseParams) { + return respond(context, params, RESPONSE_ACTIONS.agree.hash); +} + +export function disagree(context: GeoClientContext, params: ClientResponseParams) { + return respond(context, params, RESPONSE_ACTIONS.disagree.hash); +} + +export function unagree(context: GeoClientContext, params: ClientResponseParams) { + return respond(context, params, RESPONSE_ACTIONS.unagree.hash); +} + +export function verify(context: GeoClientContext, params: ClientResponseParams) { + return respond(context, params, RESPONSE_ACTIONS.verify.hash); +} + +export function dispute(context: GeoClientContext, params: ClientResponseParams) { + return respond(context, params, RESPONSE_ACTIONS.dispute.hash); +} + +export function unverify(context: GeoClientContext, params: ClientResponseParams) { + return respond(context, params, RESPONSE_ACTIONS.unverify.hash); +} diff --git a/src/graph/entity-vote.ts b/src/graph/entity-vote.ts index 908c226..47853a7 100644 --- a/src/graph/entity-vote.ts +++ b/src/graph/entity-vote.ts @@ -30,38 +30,38 @@ function validateEntityVoteId(id: Id | string, sourceHint: string) { /** * Creates calldata for upvoting an entity. * - * @deprecated Use `createGeoClient({ network }).entityVotes.upvote(...)`. + * @deprecated Use `createGeoClient({ network }).responses.upvote(...)`. */ export function upvoteEntity(params: EntityVoteParams): EntityVoteResult { const { network = 'TESTNET', ...args } = params; validateEntityVoteId(args.authorSpaceId, '`authorSpaceId` in entity vote'); validateEntityVoteId(args.spaceId, '`spaceId` in entity vote'); validateEntityVoteId(args.entityId, '`entityId` in entity vote'); - return createGeoClient({ network: resolveGeoNetwork(network) }).entityVotes.upvote(args); + return createGeoClient({ network: resolveGeoNetwork(network) }).responses.upvote(args); } /** * Creates calldata for downvoting an entity. * - * @deprecated Use `createGeoClient({ network }).entityVotes.downvote(...)`. + * @deprecated Use `createGeoClient({ network }).responses.downvote(...)`. */ export function downvoteEntity(params: EntityVoteParams): EntityVoteResult { const { network = 'TESTNET', ...args } = params; validateEntityVoteId(args.authorSpaceId, '`authorSpaceId` in entity vote'); validateEntityVoteId(args.spaceId, '`spaceId` in entity vote'); validateEntityVoteId(args.entityId, '`entityId` in entity vote'); - return createGeoClient({ network: resolveGeoNetwork(network) }).entityVotes.downvote(args); + return createGeoClient({ network: resolveGeoNetwork(network) }).responses.downvote(args); } /** * Creates calldata for withdrawing the author's vote on an entity. * - * @deprecated Use `createGeoClient({ network }).entityVotes.withdraw(...)`. + * @deprecated Use `createGeoClient({ network }).responses.unvote(...)`. */ export function withdrawEntityVote(params: EntityVoteParams): EntityVoteResult { const { network = 'TESTNET', ...args } = params; validateEntityVoteId(args.authorSpaceId, '`authorSpaceId` in entity vote'); validateEntityVoteId(args.spaceId, '`spaceId` in entity vote'); validateEntityVoteId(args.entityId, '`entityId` in entity vote'); - return createGeoClient({ network: resolveGeoNetwork(network) }).entityVotes.withdraw(args); + return createGeoClient({ network: resolveGeoNetwork(network) }).responses.unvote(args); }