From eac1157cd5c7ec4d32489a57f28a806467ac5c33 Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Thu, 6 Aug 2026 12:07:12 +0200 Subject: [PATCH 1/9] feat(responses): add canonical action encoding --- src/client/responses.test.ts | 233 +++++++++++++++++++++++++++++++++++ src/client/responses.ts | 134 ++++++++++++++++++++ 2 files changed, 367 insertions(+) create mode 100644 src/client/responses.test.ts create mode 100644 src/client/responses.ts 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..348b599 --- /dev/null +++ b/src/client/responses.ts @@ -0,0 +1,134 @@ +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'), + }; +} + +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 encodeUpvoteEntityResponseCalldata(withSpaceRegistry(context, params)); +} + +export function downvote(context: GeoClientContext, params: ClientResponseParams) { + return encodeDownvoteEntityResponseCalldata(withSpaceRegistry(context, params)); +} + +export function unvote(context: GeoClientContext, params: ClientResponseParams) { + return encodeUnvoteEntityResponseCalldata(withSpaceRegistry(context, params)); +} + +export function agree(context: GeoClientContext, params: ClientResponseParams) { + return encodeEntityResponseCalldata(withSpaceRegistry(context, params), RESPONSE_ACTIONS.agree.hash); +} + +export function disagree(context: GeoClientContext, params: ClientResponseParams) { + return encodeEntityResponseCalldata(withSpaceRegistry(context, params), RESPONSE_ACTIONS.disagree.hash); +} + +export function unagree(context: GeoClientContext, params: ClientResponseParams) { + return encodeEntityResponseCalldata(withSpaceRegistry(context, params), RESPONSE_ACTIONS.unagree.hash); +} + +export function verify(context: GeoClientContext, params: ClientResponseParams) { + return encodeEntityResponseCalldata(withSpaceRegistry(context, params), RESPONSE_ACTIONS.verify.hash); +} + +export function dispute(context: GeoClientContext, params: ClientResponseParams) { + return encodeEntityResponseCalldata(withSpaceRegistry(context, params), RESPONSE_ACTIONS.dispute.hash); +} + +export function unverify(context: GeoClientContext, params: ClientResponseParams) { + return encodeEntityResponseCalldata(withSpaceRegistry(context, params), RESPONSE_ACTIONS.unverify.hash); +} From 74019fdda7251ee30d98166c98a0fbdbf7cf38eb Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Thu, 6 Aug 2026 12:13:45 +0200 Subject: [PATCH 2/9] feat(client): expose canonical response namespace --- src/client.test.ts | 45 ++++++++++ src/client.ts | 48 ++++++++-- src/client/entity-votes.ts | 175 ++++++------------------------------- src/graph/entity-vote.ts | 12 +-- 4 files changed, 121 insertions(+), 159 deletions(-) diff --git a/src/client.test.ts b/src/client.test.ts index 1f0186b..969ec33 100644 --- a/src/client.test.ts +++ b/src/client.test.ts @@ -149,4 +149,49 @@ 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)[]) { + expect(geo.responses[method](params).to).toBe('0x0000000000000000000000000000000000000001'); + } + } 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/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); } From bc6f9748386fe96a33f3d600f4f3ce562a684690 Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Thu, 6 Aug 2026 12:19:02 +0200 Subject: [PATCH 3/9] test(e2e): cover independent response kinds --- src/api-surface.e2e.test.ts | 392 +++++++++++++++++++++++++++--------- 1 file changed, 300 insertions(+), 92 deletions(-) diff --git a/src/api-surface.e2e.test.ts b/src/api-surface.e2e.test.ts index f432837..78fcced 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'; @@ -103,16 +104,43 @@ type ProposalVoteQueryResponse = { }>; }; -type VoteQueryResponse = { - votes: Array<{ - voterId: string; +type VoteKind = 0 | 1 | 2; +type VoteType = 0 | 1; + +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 | null; + 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,23 +289,50 @@ 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 } }`; } +const RESPONSE_SCHEMA_READINESS_QUERY = `query responseSchemaReadiness { + userVotes(first: 1) { + voteKind + voteType + } + votesCounts(first: 1) { + voteKind + positive + negative + } +}`; + function spaceTopicQuery(spaceId: string) { const normalizedSpaceId = spaceId.replaceAll('-', '').toLowerCase(); @@ -515,20 +570,85 @@ 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 missingActions: string[] = []; + + for (const action of REQUIRED_RESPONSE_ACTIONS) { + const isPermissionless = (await context.publicClient.readContract({ + address: e2e.contracts.SPACE_REGISTRY_ADDRESS, + abi: SpaceRegistryAbi, + functionName: 'permissionlessActions', + args: [action.hash], + })) as boolean; + + if (!isPermissionless) { + missingActions.push(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(RESPONSE_SCHEMA_READINESS_QUERY); + } 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 userVotes.voteKind, userVotes.voteType, votesCounts.voteKind, votesCounts.positive, and votesCounts.negative from PR #872.', + `GraphQL validation error: ${String(error)}`, + ].join(' '), + ); + } + throw error; + } + })(); + + return responseEnvironmentPromise; } async function waitForSpaceTopicId(spaceId: string, topicId: string) { @@ -1258,83 +1378,171 @@ 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( + 'submits and indexes a canonical curation response', + async () => { + const context = await getTestContext(); + const entity = await createIndexedEntity(context, uniqueName('E2E New Upvoted Entity')); + const upvote = geo.responses.upvote({ + authorSpaceId: context.authorSpaceId, + spaceId: context.spaceId, + entityId: entity.id, + }); + const { receipt } = await sendTransactionAndWait(context, { + label: 'E2E canonical upvote entity', + to: upvote.to, + calldata: upvote.calldata, + }); + await waitForIndexerBlock(receipt.blockNumber); + await waitForEntityResponse(entity.id, context.authorSpaceId, context.spaceId, 0, { + voteType: 0, + positive: 1, + negative: 0, + }); + }, + TEST_TIMEOUT_MS, + ); - 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, - ); + 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); + const agreed = await sendTransactionAndWait(context, { + label: 'E2E agree with entity', + to: agree.to, + calldata: agree.calldata, + }); + await waitForIndexerBlock(agreed.receipt.blockNumber); + await waitForEntityResponse(entity.id, context.authorSpaceId, context.spaceId, 1, { + voteType: 0, + positive: 1, + negative: 0, + }); - 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 disagree = geo.responses.disagree(params); + const disagreed = await sendTransactionAndWait(context, { + label: 'E2E disagree with entity', + to: disagree.to, + calldata: disagree.calldata, + }); + await waitForIndexerBlock(disagreed.receipt.blockNumber); + await waitForEntityResponse(entity.id, context.authorSpaceId, context.spaceId, 1, { + voteType: 1, + positive: 0, + negative: 1, + }); - 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, - ); + const unagree = geo.responses.unagree(params); + const unagreed = await sendTransactionAndWait(context, { + label: 'E2E clear entity stance', + to: unagree.to, + calldata: unagree.calldata, + }); + await waitForIndexerBlock(unagreed.receipt.blockNumber); + await waitForEntityResponse(entity.id, context.authorSpaceId, context.spaceId, 1, { + voteType: null, + 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); + const upvoted = await sendTransactionAndWait(context, { + label: 'E2E upvote before veracity responses', + to: upvote.to, + calldata: upvote.calldata, + }); + await waitForIndexerBlock(upvoted.receipt.blockNumber); + await waitForEntityResponse(entity.id, context.authorSpaceId, context.spaceId, 0, { + voteType: 0, + positive: 1, + negative: 0, + }); + + const verify = geo.responses.verify(params); + const verified = await sendTransactionAndWait(context, { + label: 'E2E verify entity', + to: verify.to, + calldata: verify.calldata, + }); + await waitForIndexerBlock(verified.receipt.blockNumber); + await waitForEntityResponse(entity.id, context.authorSpaceId, context.spaceId, 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); + const disputed = await sendTransactionAndWait(context, { + label: 'E2E dispute entity', + to: dispute.to, + calldata: dispute.calldata, + }); + await waitForIndexerBlock(disputed.receipt.blockNumber); + await waitForEntityResponse(entity.id, context.authorSpaceId, context.spaceId, 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); + const unverified = await sendTransactionAndWait(context, { + label: 'E2E clear entity veracity', + to: unverify.to, + calldata: unverify.calldata, + }); + await waitForIndexerBlock(unverified.receipt.blockNumber); + await waitForEntityResponse(entity.id, context.authorSpaceId, context.spaceId, 2, { + voteType: null, + 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', From 9b90567ef94b885d275364f951239b193213e90a Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Thu, 6 Aug 2026 12:20:03 +0200 Subject: [PATCH 4/9] docs(responses): document canonical API --- .changeset/bright-doves-respond.md | 5 + README.md | 53 ++- ...6-001-feat-entity-response-actions-plan.md | 308 ++++++++++++++++++ 3 files changed, 361 insertions(+), 5 deletions(-) create mode 100644 .changeset/bright-doves-respond.md create mode 100644 docs/plans/2026-08-06-001-feat-entity-response-actions-plan.md 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..b9d4335 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, @@ -897,6 +926,20 @@ await walletClient.sendTransaction({ }); ``` +Agree, Disagree, Verify, Dispute, and their clear actions require an environment +running the kind-aware gaia schema and a `SpaceRegistry` whose owner has enabled +the six corresponding permissionless action hashes. SDK consumers submit the +returned calldata; they do not need registry-owner authority. + +#### Deprecated `geo.entityVotes` + +`geo.entityVotes` remains functional for compatibility but is deprecated. Use +these replacements in new code: + +- `geo.entityVotes.upvote` → `geo.responses.upvote` +- `geo.entityVotes.downvote` → `geo.responses.downvote` +- `geo.entityVotes.withdraw` → `geo.responses.unvote` + ## Full Publishing Flow With A Sponsored Wallet This example publishes an edit to an existing personal space using a sponsored diff --git a/docs/plans/2026-08-06-001-feat-entity-response-actions-plan.md b/docs/plans/2026-08-06-001-feat-entity-response-actions-plan.md new file mode 100644 index 0000000..f770687 --- /dev/null +++ b/docs/plans/2026-08-06-001-feat-entity-response-actions-plan.md @@ -0,0 +1,308 @@ +--- +title: Entity Response Actions - Plan +type: feat +date: 2026-08-06 +artifact_contract: ce-unified-plan/v1 +artifact_readiness: implementation-ready +product_contract_source: ce-plan-bootstrap +execution: code +deepened: 2026-08-06 +--- + +# Entity Response Actions - Plan + +## Goal Capsule + +- **Objective:** Add curation, stance, and veracity response transactions under the canonical `geo.responses` client namespace while preserving the existing entity-vote API. +- **Authority:** The merged [gaia PR #872](https://github.com/geobrowser/gaia/pull/872) governs response kinds, directions, and clear semantics. The confirmed API and entity-only scope decisions govern the SDK surface. Current SDK conventions govern implementation details. The user-supplied “PRD - New Actions” is supporting context when it does not conflict with the merged PR. +- **Execution profile:** Standard TypeScript feature with public API, compatibility, unit-test, integration-test, documentation, and release-note work. +- **Stop conditions:** Stop and reconcile sources if the deployed action strings or `voteKind` meanings differ from gaia PR #872. Report an external blocker instead of weakening e2e assertions when the target registry lacks the six permissionless registrations or the target API lacks the merged gaia schema. +- **Tail ownership:** SDK implementation includes its changeset and verification. Registry administration, gaia deployment, web-app adoption, and contract-repository documentation remain external. + +--- + +## Product Contract + +### Summary + +The SDK will expose all entity-response actions through `geo.responses`. The namespace will cover curation (`upvote`, `downvote`, `unvote`), stance (`agree`, `disagree`, `unagree`), and veracity (`verify`, `dispute`, `unverify`). The existing `geo.entityVotes` namespace remains available as a deprecated compatibility adapter for `upvote`, `downvote`, and `withdraw`. + +### Problem Frame + +gaia can now index three independent response kinds, but the SDK can emit only the original curation actions. Consumers cannot submit Agree, Disagree, Verify, Dispute, or their kind-specific clears through the supported client API. The contract repository also does not define or initialize these actions, so end-to-end proof must distinguish SDK correctness from missing registry administration or backend deployment. + +### Requirements + +**Canonical response API** + +- R1. `createGeoClient(...)` exposes `geo.responses` with nine explicit methods: `upvote`, `downvote`, `unvote`, `agree`, `disagree`, `unagree`, `verify`, `dispute`, and `unverify`. +- R2. Every canonical method accepts the existing entity-target parameters: author space ID, target space ID, and entity ID. +- R3. The three response kinds remain independent because every method maps to its own protocol action while reusing the existing entity topic and data encoding. + +**Protocol compatibility** + +- R4. Curation calls through `geo.responses` produce byte-for-byte equivalent calldata to the existing `geo.entityVotes` operations. +- R5. All response actions keep object type `0` for entities, encoding version `0`, the current ABI data tuple, and an empty signature. +- R6. Invalid IDs and missing network contract configuration fail before calldata is returned, following current SDK validation behavior. + +**Migration compatibility** + +- R7. `geo.entityVotes.upvote`, `geo.entityVotes.downvote`, and `geo.entityVotes.withdraw` remain callable and delegate to `geo.responses.upvote`, `geo.responses.downvote`, and `geo.responses.unvote` respectively. +- R8. The SDK marks `geo.entityVotes` and its legacy graph helpers as deprecated in type documentation and user documentation without adding runtime warnings. + +**Verification and release** + +- R9. Unit coverage pins all nine action strings to their expected hashes and decodes the resulting `SpaceRegistry.enter()` calldata. +- R10. End-to-end coverage submits all six new actions, verifies gaia’s kind and direction state, and proves that clearing one kind does not alter another kind. +- R11. End-to-end setup fails with an actionable dependency error when the registry registrations or gaia schema are unavailable. +- R12. README documentation presents `geo.responses` as canonical, documents the deprecated namespace, and ships a minor changeset. + +### Key Decisions + +- **One canonical response namespace.** (session-settled: user-directed — chosen over extending `geo.entityVotes`: one namespace keeps curation, stance, and veracity at the same API level.) Governs R1, R4, R7, and R8. +- **Entity targets only.** (session-settled: user-directed — chosen over adding relation-target responses: no current SDK consumer requires relation writes, and adding them would widen the public target model and e2e matrix.) Governs R2 and R5. + +### Acceptance Examples + +- **AE1. Canonical curation:** Given valid IDs and a configured registry, when a caller invokes `geo.responses.upvote`, then the returned target and calldata match `geo.entityVotes.upvote` for the same inputs. Covers R1, R4, and R7. +- **AE2. Stance transition:** Given an entity with no stance response from the user, when the user agrees, disagrees, and then unagrees, then gaia reports positive stance, negative stance, and no current stance response in sequence. Covers R3, R9, and R10. +- **AE3. Veracity independence:** Given a user who already upvoted an entity, when the user verifies and later unverifies it, then the curation response remains while the veracity response appears and disappears independently. Covers R3 and R10. +- **AE4. Environment not ready:** Given a registry where one of the six new actions is not permissionless, when e2e setup runs, then it identifies the missing action and registry before submitting feature transactions. Covers R11. + +### Scope Boundaries + +- The SDK produces transaction targets and calldata; it does not submit transactions from `geo.responses`. +- This plan does not add response read APIs. E2e tests use the existing GraphQL client only to verify indexed effects. +- This plan does not change response topic or data encoding, infer response kind from entity data, or inspect whether an entity is a Claim. +- This plan does not change gaia migrations, indexers, GraphQL schema, web controls, analytics, or ranking behavior. +- This plan does not modify `geo-contracts-foundry` or perform registry-owner transactions. + +#### Deferred to Follow-Up Work + +- Entity-or-relation response targets and a generalized object discriminator. +- Web-app adoption of `geo.responses` and kind-aware reads. +- Contract-repository constants, action documentation, initialization defaults, or deployment scripts for the six new actions. +- Removing `geo.entityVotes` after a separately announced deprecation window. + +### Dependencies + +- The target gaia deployment must include PR #872 so GraphQL exposes `voteKind` and kind-scoped current state. +- The owner of each target `SpaceRegistry` must enable `PERMISSIONLESS.AGREED`, `DISAGREED`, `UNAGREED`, `VERIFIED`, `DISPUTED`, and `UNVERIFIED` with `setPermissionlessAction`. +- No contract implementation upgrade is required. The current registry already supports owner-managed permissionless action hashes. + +### Sources + +- [gaia PR #872: vote-kind storage and indexing source of truth](https://github.com/geobrowser/gaia/pull/872) +- [geo-contracts-foundry action constants on `dev`](https://github.com/geobrowser/geo-contracts-foundry/blob/dev/src/ActionsConstants.sol) +- [geo-contracts-foundry `SpaceRegistry` permissionless-action behavior](https://github.com/geobrowser/geo-contracts-foundry/blob/dev/src/contracts/SpaceRegistry.sol) +- User-supplied “PRD - New Actions”, dated 2026-08-04, as supporting product context. + +--- + +## Planning Contract + +### Key Technical Decisions + +- KTD1. **Use one method per protocol action.** The canonical API maps `upvote`, `downvote`, `unvote`, `agree`, `disagree`, `unagree`, `verify`, `dispute`, and `unverify` one-to-one to the nine action strings from gaia. This avoids a caller-supplied kind/direction combination that can represent invalid pairs. +- KTD2. **Centralize encoding in `responses.ts`.** One internal encoder owns ID normalization, entity topic encoding, response data encoding, `SpaceRegistry.enter()` construction, and the action map. Compatibility layers delegate into it and do not duplicate hashes or ABI logic. +- KTD3. **Keep compatibility compile-time and behavioral.** `geo.entityVotes` remains a typed runtime property with JSDoc deprecation markers. It emits no warning and returns exactly the canonical curation result, matching the repository’s adapter pattern. +- KTD4. **Pin protocol names and bytes independently.** Unit tests derive each action hash from its protocol string and compare it with a fixed expected value from gaia. They also assert pairwise uniqueness so a typo cannot merge response axes. +- KTD5. **Verify current state, not only event acceptance.** E2e tests query kind-filtered `userVotes` and `votesCounts` state after successful transaction receipts. A receipt or raw event alone does not prove the action was registered or that gaia applied kind-scoped overwrite and clear behavior. +- KTD6. **Preflight external readiness without acquiring admin authority.** E2e setup reads `permissionlessActions` for the six new hashes and probes the required GraphQL fields. It reports missing prerequisites rather than impersonating a registry owner or mutating environment configuration. + +### High-Level Technical Design + +The action map is fixed by gaia’s merged implementation. `voteKind` is the indexed discriminator; the SDK continues to send only the action hash and the existing entity payload. + +| SDK method | Protocol action | `voteKind` | Direction | Expected action hash | +|---|---|---:|---|---| +| `upvote` | `PERMISSIONLESS.UPVOTED` | 0 | positive | `0x1fc04a8d9387c7bd1199a2a77c8e531a7a7b11991df5dcc8c9acb6abcb481725` | +| `downvote` | `PERMISSIONLESS.DOWNVOTED` | 0 | negative | `0xde8b897ce7cc541dacb388d5aabb3dc0fb7856920284f41582c15b5fc31a8662` | +| `unvote` | `PERMISSIONLESS.UNVOTED` | 0 | clear | `0x3bd4c337382f79aa5007a91169bb57723b5dd59e6b4bb60d20362bcc0d9d998b` | +| `agree` | `PERMISSIONLESS.AGREED` | 1 | positive | `0xcc1f104e089fb96ad3a3f1e70607f3dda4ed556e810bdc30193f19df474369b9` | +| `disagree` | `PERMISSIONLESS.DISAGREED` | 1 | negative | `0x285c96f1d9b8f9143d333a762cb9fa03e98b3f551a824e99ed14072ca3c51179` | +| `unagree` | `PERMISSIONLESS.UNAGREED` | 1 | clear | `0xa1d2a63f4172ef63617e69ca00a8a5e0e0f886fcd26d742208cc5da02fe32328` | +| `verify` | `PERMISSIONLESS.VERIFIED` | 2 | positive | `0x588446c29505d69d73cba2f34aa402447b77f055539a93aec891beb3fbf3f0fd` | +| `dispute` | `PERMISSIONLESS.DISPUTED` | 2 | negative | `0x839d074bf1854255cda5c35a5c89feb5687db041c8ff22370e8597a58ef7706d` | +| `unverify` | `PERMISSIONLESS.UNVERIFIED` | 2 | clear | `0x9516e48c1d614910098dd6197889f54cb08474c630efdb4cd07bbeee329912c2` | + +```mermaid +flowchart TB + Consumer["SDK consumer"] --> Responses["geo.responses"] + Legacy["geo.entityVotes (deprecated)"] --> Responses + Responses --> Encoder["Shared entity-response encoder"] + Encoder --> Registry["SpaceRegistry.enter"] + Registry --> Action["Anonymous Action event"] + Action --> Gaia["gaia response pipeline and indexer"] + Gaia --> GraphQL["Kind-aware GraphQL state"] + GraphQL --> E2E["SDK e2e assertions"] +``` + +Each kind follows the same independent state machine. Switching kind never transitions or clears another kind. + +```mermaid +stateDiagram-v2 + [*] --> None + None --> Positive: upvote / agree / verify + None --> Negative: downvote / disagree / dispute + Positive --> Negative: negative action + Negative --> Positive: positive action + Positive --> None: unvote / unagree / unverify + Negative --> None: unvote / unagree / unverify +``` + +### Implementation Sequence + +1. Establish the canonical action map, encoder, and exhaustive unit proof. +2. Add the client namespace and route compatibility surfaces through it. +3. Extend the existing API-surface e2e suite once the protocol surface is stable. +4. Update documentation and add the release changeset after names and examples are final. + +### Operational Rollout + +1. Deploy gaia PR #872 and confirm the target GraphQL API exposes kind-aware current responses and counts. +2. Have the registry owner enable the six new action hashes on each target `SpaceRegistry`. +3. Run U3 against each release environment and require the readiness probe plus all state-transition assertions to pass. +4. Release the SDK only after the target environment passes; consumer adoption can follow independently. + +Rolling back the SDK does not require removing the permissionless registrations. gaia already understands the actions, and older SDK clients ignore them. If an incorrect hash was registered, the registry owner should disable that hash, enable the source-of-truth hash, and rerun U3 before release. + +### Risks and Mitigations + +| Risk | Impact | Mitigation | +|---|---|---| +| Action string or hash drift | Transactions are indexed under no recognized response kind. | Pin all nine fixed hashes and live keccak derivations in one table-driven unit suite. | +| Registry registration missing | A receipt can succeed through the non-permissionless path while producing unusable subject data. | Preflight `permissionlessActions` and assert kind-aware indexed state, not receipt status alone. | +| gaia deployment lag | GraphQL queries fail or omit `voteKind`. | Probe schema readiness and report the API origin and missing field before feature flows. | +| Compatibility logic diverges | Existing consumers produce different curation calldata after upgrading. | Make deprecated methods thin delegates and assert byte-for-byte equality. | +| Cross-kind e2e false positives | An earlier curation event satisfies a stance or veracity predicate. | Use unique entities and include `voteKind` in every query and assertion. | +| “Verified” naming collision | Reviewers confuse response verification with subspace verification. | Use `verify`, `veracity`, or qualified response-action names; avoid bare `verified` identifiers. | + +--- + +## Implementation Units + +### U1. Canonical Response Actions and Encoding + +- **Goal:** Create the canonical entity-response action map and shared calldata encoder for all nine methods. +- **Requirements:** R1, R2, R3, R5, R6, R9; AE1, AE2, AE3; KTD1, KTD2, KTD4. +- **Dependencies:** None. +- **Files:** + - Create: `src/client/responses.ts` + - Create: `src/client/responses.test.ts` + - Reference: `src/client/entity-votes.ts` + - Reference: `src/client/entity-votes.test.ts` +- **Approach:** + 1. Define the nine response action strings and derived hashes in the canonical module, grouped by curation, stance, and veracity. + 2. Move the existing ID conversion, entity topic, data tuple, and `enter()` encoding behind one action-parameterized helper. + 3. Expose context-aware operations for the nine canonical method names without accepting arbitrary action hashes. Keep the action-parameterized encoder internal to this module; do not add a new public package subpath or arbitrary-action escape hatch. + 4. Keep entity object type, encoding version, data tuple, and signature unchanged per R5. +- **Patterns to follow:** Mirror `src/client/entity-votes.ts` for `viem` encoding, `assertValid` use, and network address resolution. Mirror the decode-based assertions in `src/client/entity-votes.test.ts`. +- **Test scenarios:** + 1. Each canonical method encodes its exact gaia action hash while every other decoded `enter()` argument stays identical for common inputs. + 2. All nine fixed expected hashes equal a fresh keccak derivation and are pairwise distinct. + 3. Upvote, agree, and verify share the positive direction semantics but retain different action hashes; the same holds for negative and clear groups. + 4. Dashed, raw, and `0x`-prefixed IDs normalize to identical calldata. + 5. Invalid author space, target space, and entity IDs fail before encoding for representative actions from all three kinds. + 6. A context-aware response uses the configured `SPACE_REGISTRY_ADDRESS`; a network without that address fails with the existing contract-configuration error. +- **Verification:** The response test suite decodes every public operation into the expected registry call and fails if any protocol byte or invariant drifts. + +### U2. Client Namespace and Deprecated Compatibility + +- **Goal:** Make `geo.responses` canonical while preserving the existing client and graph curation APIs as delegates. +- **Requirements:** R1, R4, R6, R7, R8; AE1; KTD2, KTD3. +- **Dependencies:** U1. +- **Files:** + - Modify: `src/client.ts` + - Modify: `src/client.test.ts` + - Modify: `src/client/entity-votes.ts` + - Modify: `src/client/entity-votes.test.ts` + - Modify: `src/graph/entity-vote.ts` + - Modify: `src/graph/entity-vote.test.ts` +- **Approach:** + 1. Add the canonical response parameter type and the nine-method `responses` property to the exported `Client` type and `createGeoClient` result. + 2. Retain `EntityVoteParams` as a deprecated type alias when removal would break imported SDK types. + 3. Keep `entityVotes` on the client, mark it deprecated, and route its three methods to the canonical curation methods. + 4. Preserve the context-free curation encoders as deprecated adapters if their current module exports remain reachable to consumers or internal tests. + 5. Update legacy graph helper deprecation guidance and delegation targets to `geo.responses`. +- **Patterns to follow:** Follow the `createGeoClient` namespace construction in `src/client.ts` and the adapter style in `src/graph/entity-vote.ts`. Deprecation stays in JSDoc and README text, with no console side effect. +- **Test scenarios:** + 1. `Object.keys(geo.responses)` contains exactly the nine canonical methods in documented order. + 2. Each canonical method resolves the configured registry address without requiring `fetch`. + 3. Deprecated upvote and downvote results equal their canonical equivalents byte for byte. + 4. Deprecated `withdraw` equals canonical `unvote` byte for byte. + 5. Existing invalid-ID behavior remains observable through `geo.entityVotes` and legacy graph helpers. + 6. TypeScript compilation accepts old client calls and new calls while surfacing deprecation metadata for the old namespace. +- **Verification:** Existing curation tests remain green, new client-surface assertions pass, and the build emits public declarations for both namespaces. + +### U3. Kind-Aware End-to-End Verification + +- **Goal:** Prove the canonical SDK methods execute through `SpaceRegistry` and produce gaia’s independent current-state model. +- **Requirements:** R3, R4, R10, R11; AE1, AE2, AE3, AE4; KTD5, KTD6. +- **Dependencies:** U1, U2, and an environment with gaia PR #872 deployed. +- **Files:** + - Modify: `src/api-surface.e2e.test.ts` + - Reference: `src/e2e-test-environment.ts` + - Reference: `src/e2e-wallet.ts` + - Reference: `src/abis/space-registry.ts` +- **Approach:** + 1. Extend the existing response GraphQL fixtures and wait helpers with `voteKind`, current `userVotes`, and per-kind `votesCounts` fields. + 2. Add an e2e readiness check for the six `permissionlessActions` entries and the merged gaia GraphQL shape. + 3. Exercise canonical curation calls through `geo.responses` while keeping compatibility proof in U2’s deterministic tests. + 4. Use one unique entity for the stance transition and another for the veracity-independence transition so prior runs cannot satisfy predicates. + 5. Reuse transaction receipt checks, indexer alignment checks, and bounded polling already present in the suite. +- **Execution note:** Establish the readiness probe first. A missing external deployment must fail before the suite emits partial response state. +- **Patterns to follow:** Use `sendTransactionAndWait`, `waitFor`, `waitForIndexerBlock`, and the unique-entity helpers in `src/api-surface.e2e.test.ts`. +- **Test scenarios:** + 1. Covers AE1. Canonical upvote is accepted and indexed as kind `0`, positive direction, with the expected curation count. + 2. Covers AE2. Agree creates a kind `1` positive current response; Disagree replaces it with one kind `1` negative response; Unagree removes only the kind `1` current response and returns its counts to zero. + 3. Covers AE3. Upvote followed by Verify leaves both kind `0` and kind `2` current responses; Dispute changes only kind `2`; Unverify removes only kind `2` while curation remains unchanged. + 4. Every successful step is backed by a successful transaction receipt and a kind-filtered indexed-state predicate. + 5. Covers AE4. A missing action registration names the action, network, and registry address without submitting response transactions. + 6. A gaia API without `voteKind`, `positive`, or `negative` fails with an explicit schema-readiness message rather than timing out. +- **Verification:** `pnpm test:e2e` passes against a ready testnet or local-geobrowser environment and proves all six new actions plus curation compatibility. + +### U4. Documentation and Release Packaging + +- **Goal:** Publish the new canonical API and migration guidance as a minor SDK feature. +- **Requirements:** R8, R12; KTD1, KTD3, KTD6. +- **Dependencies:** U2, U3. +- **Files:** + - Modify: `README.md` + - Create: `.changeset/.md` +- **Approach:** + 1. Replace the main entity-vote section with a `geo.responses` section that explains the three response kinds and all nine method names. + 2. Include one transaction-submission example and concise examples for curation, stance, and veracity. + 3. Move `geo.entityVotes` into compatibility guidance, state that it remains functional, and point each method to its canonical replacement. + 4. Document that new action execution requires gaia deployment and registry-owner registration without presenting SDK consumers as registry administrators. + 5. Add a minor changeset for the new public namespace and actions, noting the non-breaking deprecation. +- **Patterns to follow:** Follow the README’s existing client-namespace sections and the repository changeset format from `AGENTS.md`. +- **Test expectation:** None — this unit changes documentation and release metadata only; U2’s build verifies referenced public names. +- **Verification:** Documentation examples match emitted declarations, the compatibility status is unambiguous, and the changeset targets `@geoprotocol/geo-sdk` with a minor release. + +--- + +## Verification Contract + +| Gate | Applies to | Command | Done signal | +|---|---|---|---| +| Focused response tests | U1, U2 | `pnpm test -- src/client/responses.test.ts src/client/entity-votes.test.ts src/client.test.ts src/graph/entity-vote.test.ts` | All protocol, namespace, and compatibility scenarios pass. | +| Full unit suite | U1-U4 | `pnpm test` | No SDK regression outside the response surface. | +| Type and package build | U1, U2, U4 | `pnpm build` | Public declarations contain the canonical and deprecated APIs without TypeScript errors. | +| Repository lint | U1-U4 | `pnpm lint` | Biome reports no errors on code, tests, or documentation it checks. | +| Cross-system e2e | U3 | `pnpm test:e2e` | Transactions succeed and kind-filtered gaia state proves stance, veracity, clear, and independence semantics. | +| Release metadata | U4 | Inspect the new `.changeset/*.md` entry | The package is marked for a minor release with an accurate user-facing summary. | + +The e2e gate applies only after its external readiness probe passes. A missing registration or gaia deployment is a reported release blocker, not grounds to skip or weaken the e2e requirement. + +--- + +## Definition of Done + +- U1 is complete when all nine canonical operations share one encoder and fixed-hash decode tests cover every protocol action. +- U2 is complete when `geo.responses` is the documented canonical runtime and type surface, while `geo.entityVotes` and graph curation helpers remain compatible deprecated adapters. +- U3 is complete when a ready environment passes the stance transition, veracity independence, curation compatibility, and dependency-failure scenarios. +- U4 is complete when README examples, deprecation guidance, operational prerequisites, and a minor changeset agree with the emitted API. +- All Verification Contract gates pass, except that an external readiness failure must remain explicitly reported until the owning deployment or registry action is completed. +- No relation-target implementation, generic arbitrary-action escape hatch, duplicated encoder, runtime deprecation warning, or unrelated cleanup remains in the diff. +- Experimental or abandoned code from implementation attempts is removed before handoff. From eb5a68fe8497f817ad01a6d04d8fe97563609a74 Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Thu, 6 Aug 2026 12:22:03 +0200 Subject: [PATCH 5/9] refactor(responses): simplify shared execution paths --- src/api-surface.e2e.test.ts | 110 +++++++++++++----------------------- src/client/responses.ts | 22 +++++--- 2 files changed, 53 insertions(+), 79 deletions(-) diff --git a/src/api-surface.e2e.test.ts b/src/api-surface.e2e.test.ts index 78fcced..df4b767 100644 --- a/src/api-surface.e2e.test.ts +++ b/src/api-surface.e2e.test.ts @@ -607,20 +607,21 @@ async function waitForEntityResponse( let responseEnvironmentPromise: Promise | undefined; async function ensureResponseEnvironmentReady(context: TestContext) { responseEnvironmentPromise ??= (async () => { - const missingActions: string[] = []; - - for (const action of REQUIRED_RESPONSE_ACTIONS) { - const isPermissionless = (await context.publicClient.readContract({ - address: e2e.contracts.SPACE_REGISTRY_ADDRESS, - abi: SpaceRegistryAbi, - functionName: 'permissionlessActions', - args: [action.hash], - })) as boolean; - - if (!isPermissionless) { - missingActions.push(action.name); - } - } + 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( @@ -651,6 +652,23 @@ async function ensureResponseEnvironmentReady(context: TestContext) { 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) { const normalizedTopicId = topicId.replaceAll('-', '').toLowerCase(); const data = await waitFor( @@ -1394,13 +1412,7 @@ describe.sequential('new API e2e surface', () => { spaceId: context.spaceId, entityId: entity.id, }); - const { receipt } = await sendTransactionAndWait(context, { - label: 'E2E canonical upvote entity', - to: upvote.to, - calldata: upvote.calldata, - }); - await waitForIndexerBlock(receipt.blockNumber); - await waitForEntityResponse(entity.id, context.authorSpaceId, context.spaceId, 0, { + await sendResponseAndWait(context, 'E2E canonical upvote entity', upvote, entity.id, 0, { voteType: 0, positive: 1, negative: 0, @@ -1421,39 +1433,21 @@ describe.sequential('new API e2e surface', () => { }; const agree = geo.responses.agree(params); - const agreed = await sendTransactionAndWait(context, { - label: 'E2E agree with entity', - to: agree.to, - calldata: agree.calldata, - }); - await waitForIndexerBlock(agreed.receipt.blockNumber); - await waitForEntityResponse(entity.id, context.authorSpaceId, context.spaceId, 1, { + await sendResponseAndWait(context, 'E2E agree with entity', agree, entity.id, 1, { voteType: 0, positive: 1, negative: 0, }); const disagree = geo.responses.disagree(params); - const disagreed = await sendTransactionAndWait(context, { - label: 'E2E disagree with entity', - to: disagree.to, - calldata: disagree.calldata, - }); - await waitForIndexerBlock(disagreed.receipt.blockNumber); - await waitForEntityResponse(entity.id, context.authorSpaceId, context.spaceId, 1, { + await sendResponseAndWait(context, 'E2E disagree with entity', disagree, entity.id, 1, { voteType: 1, positive: 0, negative: 1, }); const unagree = geo.responses.unagree(params); - const unagreed = await sendTransactionAndWait(context, { - label: 'E2E clear entity stance', - to: unagree.to, - calldata: unagree.calldata, - }); - await waitForIndexerBlock(unagreed.receipt.blockNumber); - await waitForEntityResponse(entity.id, context.authorSpaceId, context.spaceId, 1, { + await sendResponseAndWait(context, 'E2E clear entity stance', unagree, entity.id, 1, { voteType: null, positive: 0, negative: 0, @@ -1474,26 +1468,14 @@ describe.sequential('new API e2e surface', () => { }; const upvote = geo.responses.upvote(params); - const upvoted = await sendTransactionAndWait(context, { - label: 'E2E upvote before veracity responses', - to: upvote.to, - calldata: upvote.calldata, - }); - await waitForIndexerBlock(upvoted.receipt.blockNumber); - await waitForEntityResponse(entity.id, context.authorSpaceId, context.spaceId, 0, { + await sendResponseAndWait(context, 'E2E upvote before veracity responses', upvote, entity.id, 0, { voteType: 0, positive: 1, negative: 0, }); const verify = geo.responses.verify(params); - const verified = await sendTransactionAndWait(context, { - label: 'E2E verify entity', - to: verify.to, - calldata: verify.calldata, - }); - await waitForIndexerBlock(verified.receipt.blockNumber); - await waitForEntityResponse(entity.id, context.authorSpaceId, context.spaceId, 2, { + await sendResponseAndWait(context, 'E2E verify entity', verify, entity.id, 2, { voteType: 0, positive: 1, negative: 0, @@ -1505,13 +1487,7 @@ describe.sequential('new API e2e surface', () => { }); const dispute = geo.responses.dispute(params); - const disputed = await sendTransactionAndWait(context, { - label: 'E2E dispute entity', - to: dispute.to, - calldata: dispute.calldata, - }); - await waitForIndexerBlock(disputed.receipt.blockNumber); - await waitForEntityResponse(entity.id, context.authorSpaceId, context.spaceId, 2, { + await sendResponseAndWait(context, 'E2E dispute entity', dispute, entity.id, 2, { voteType: 1, positive: 0, negative: 1, @@ -1523,13 +1499,7 @@ describe.sequential('new API e2e surface', () => { }); const unverify = geo.responses.unverify(params); - const unverified = await sendTransactionAndWait(context, { - label: 'E2E clear entity veracity', - to: unverify.to, - calldata: unverify.calldata, - }); - await waitForIndexerBlock(unverified.receipt.blockNumber); - await waitForEntityResponse(entity.id, context.authorSpaceId, context.spaceId, 2, { + await sendResponseAndWait(context, 'E2E clear entity veracity', unverify, entity.id, 2, { voteType: null, positive: 0, negative: 0, diff --git a/src/client/responses.ts b/src/client/responses.ts index 348b599..fcee2bd 100644 --- a/src/client/responses.ts +++ b/src/client/responses.ts @@ -85,6 +85,10 @@ function withSpaceRegistry(context: GeoClientContext, params: ClientResponsePara }; } +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); } @@ -98,37 +102,37 @@ export function encodeUnvoteEntityResponseCalldata(params: ResponseCalldataParam } export function upvote(context: GeoClientContext, params: ClientResponseParams) { - return encodeUpvoteEntityResponseCalldata(withSpaceRegistry(context, params)); + return respond(context, params, RESPONSE_ACTIONS.upvote.hash); } export function downvote(context: GeoClientContext, params: ClientResponseParams) { - return encodeDownvoteEntityResponseCalldata(withSpaceRegistry(context, params)); + return respond(context, params, RESPONSE_ACTIONS.downvote.hash); } export function unvote(context: GeoClientContext, params: ClientResponseParams) { - return encodeUnvoteEntityResponseCalldata(withSpaceRegistry(context, params)); + return respond(context, params, RESPONSE_ACTIONS.unvote.hash); } export function agree(context: GeoClientContext, params: ClientResponseParams) { - return encodeEntityResponseCalldata(withSpaceRegistry(context, params), RESPONSE_ACTIONS.agree.hash); + return respond(context, params, RESPONSE_ACTIONS.agree.hash); } export function disagree(context: GeoClientContext, params: ClientResponseParams) { - return encodeEntityResponseCalldata(withSpaceRegistry(context, params), RESPONSE_ACTIONS.disagree.hash); + return respond(context, params, RESPONSE_ACTIONS.disagree.hash); } export function unagree(context: GeoClientContext, params: ClientResponseParams) { - return encodeEntityResponseCalldata(withSpaceRegistry(context, params), RESPONSE_ACTIONS.unagree.hash); + return respond(context, params, RESPONSE_ACTIONS.unagree.hash); } export function verify(context: GeoClientContext, params: ClientResponseParams) { - return encodeEntityResponseCalldata(withSpaceRegistry(context, params), RESPONSE_ACTIONS.verify.hash); + return respond(context, params, RESPONSE_ACTIONS.verify.hash); } export function dispute(context: GeoClientContext, params: ClientResponseParams) { - return encodeEntityResponseCalldata(withSpaceRegistry(context, params), RESPONSE_ACTIONS.dispute.hash); + return respond(context, params, RESPONSE_ACTIONS.dispute.hash); } export function unverify(context: GeoClientContext, params: ClientResponseParams) { - return encodeEntityResponseCalldata(withSpaceRegistry(context, params), RESPONSE_ACTIONS.unverify.hash); + return respond(context, params, RESPONSE_ACTIONS.unverify.hash); } From ad6223e4f216c205ab4ba9fde9d9ea61515a9f58 Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Thu, 6 Aug 2026 12:37:23 +0200 Subject: [PATCH 6/9] fix(responses): harden response test coverage --- src/api-surface.e2e.test.ts | 41 ++++++++++++++++++++++--------------- src/client.test.ts | 14 ++++++++++++- 2 files changed, 37 insertions(+), 18 deletions(-) diff --git a/src/api-surface.e2e.test.ts b/src/api-surface.e2e.test.ts index df4b767..014b02c 100644 --- a/src/api-surface.e2e.test.ts +++ b/src/api-surface.e2e.test.ts @@ -13,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); @@ -321,18 +322,6 @@ function responseStateQuery(entityId: string, userId: string, spaceId: string, v }`; } -const RESPONSE_SCHEMA_READINESS_QUERY = `query responseSchemaReadiness { - userVotes(first: 1) { - voteKind - voteType - } - votesCounts(first: 1) { - voteKind - positive - negative - } -}`; - function spaceTopicQuery(spaceId: string) { const normalizedSpaceId = spaceId.replaceAll('-', '').toLowerCase(); @@ -634,13 +623,15 @@ async function ensureResponseEnvironmentReady(context: TestContext) { } try { - await queryGraph(RESPONSE_SCHEMA_READINESS_QUERY); + 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 userVotes.voteKind, userVotes.voteType, votesCounts.voteKind, votesCounts.positive, and votesCounts.negative from PR #872.', + '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(' '), ); @@ -1403,20 +1394,36 @@ describe.sequential('new API e2e surface', () => { }, TEST_TIMEOUT_MS); it( - 'submits and indexes a canonical curation response', + 'transitions and clears canonical curation responses', async () => { const context = await getTestContext(); const entity = await createIndexedEntity(context, uniqueName('E2E New Upvoted Entity')); - const upvote = geo.responses.upvote({ + 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, }); + + const downvote = geo.responses.downvote(params); + await sendResponseAndWait(context, 'E2E canonical downvote entity', downvote, entity.id, 0, { + voteType: 1, + positive: 0, + negative: 1, + }); + + const unvote = geo.responses.unvote(params); + await sendResponseAndWait(context, 'E2E clear canonical entity vote', unvote, entity.id, 0, { + voteType: null, + positive: 0, + negative: 0, + }); }, TEST_TIMEOUT_MS, ); diff --git a/src/client.test.ts b/src/client.test.ts index 969ec33..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'; @@ -175,7 +178,16 @@ describe('createGeoClient', () => { ]); for (const method of Object.keys(geo.responses) as (keyof typeof geo.responses)[]) { - expect(geo.responses[method](params).to).toBe('0x0000000000000000000000000000000000000001'); + 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); From bca24e40c7db98da8c7143fd58756d4cb063904a Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Thu, 6 Aug 2026 14:42:02 +0200 Subject: [PATCH 7/9] improve tests --- src/api-surface.e2e.test.ts | 29 +++++++++++++---------------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/src/api-surface.e2e.test.ts b/src/api-surface.e2e.test.ts index 014b02c..c5856e6 100644 --- a/src/api-surface.e2e.test.ts +++ b/src/api-surface.e2e.test.ts @@ -106,7 +106,7 @@ type ProposalVoteQueryResponse = { }; type VoteKind = 0 | 1 | 2; -type VoteType = 0 | 1; +type VoteType = 0 | 1 | 2; type ResponseStateQueryResponse = { userVotes: Array<{ @@ -128,7 +128,7 @@ type ResponseStateQueryResponse = { }; type ExpectedResponseState = { - voteType: VoteType | null; + voteType: VoteType; positive: number; negative: number; }; @@ -397,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(' '), ); } @@ -421,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); } })(); @@ -1420,7 +1417,7 @@ describe.sequential('new API e2e surface', () => { const unvote = geo.responses.unvote(params); await sendResponseAndWait(context, 'E2E clear canonical entity vote', unvote, entity.id, 0, { - voteType: null, + voteType: 2, positive: 0, negative: 0, }); @@ -1455,7 +1452,7 @@ describe.sequential('new API e2e surface', () => { const unagree = geo.responses.unagree(params); await sendResponseAndWait(context, 'E2E clear entity stance', unagree, entity.id, 1, { - voteType: null, + voteType: 2, positive: 0, negative: 0, }); @@ -1507,7 +1504,7 @@ describe.sequential('new API e2e surface', () => { const unverify = geo.responses.unverify(params); await sendResponseAndWait(context, 'E2E clear entity veracity', unverify, entity.id, 2, { - voteType: null, + voteType: 2, positive: 0, negative: 0, }); From b8fb1540ea0529e9b7d15c35792f3033725add90 Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Thu, 6 Aug 2026 15:00:01 +0200 Subject: [PATCH 8/9] remove plan --- ...6-001-feat-entity-response-actions-plan.md | 308 ------------------ 1 file changed, 308 deletions(-) delete mode 100644 docs/plans/2026-08-06-001-feat-entity-response-actions-plan.md diff --git a/docs/plans/2026-08-06-001-feat-entity-response-actions-plan.md b/docs/plans/2026-08-06-001-feat-entity-response-actions-plan.md deleted file mode 100644 index f770687..0000000 --- a/docs/plans/2026-08-06-001-feat-entity-response-actions-plan.md +++ /dev/null @@ -1,308 +0,0 @@ ---- -title: Entity Response Actions - Plan -type: feat -date: 2026-08-06 -artifact_contract: ce-unified-plan/v1 -artifact_readiness: implementation-ready -product_contract_source: ce-plan-bootstrap -execution: code -deepened: 2026-08-06 ---- - -# Entity Response Actions - Plan - -## Goal Capsule - -- **Objective:** Add curation, stance, and veracity response transactions under the canonical `geo.responses` client namespace while preserving the existing entity-vote API. -- **Authority:** The merged [gaia PR #872](https://github.com/geobrowser/gaia/pull/872) governs response kinds, directions, and clear semantics. The confirmed API and entity-only scope decisions govern the SDK surface. Current SDK conventions govern implementation details. The user-supplied “PRD - New Actions” is supporting context when it does not conflict with the merged PR. -- **Execution profile:** Standard TypeScript feature with public API, compatibility, unit-test, integration-test, documentation, and release-note work. -- **Stop conditions:** Stop and reconcile sources if the deployed action strings or `voteKind` meanings differ from gaia PR #872. Report an external blocker instead of weakening e2e assertions when the target registry lacks the six permissionless registrations or the target API lacks the merged gaia schema. -- **Tail ownership:** SDK implementation includes its changeset and verification. Registry administration, gaia deployment, web-app adoption, and contract-repository documentation remain external. - ---- - -## Product Contract - -### Summary - -The SDK will expose all entity-response actions through `geo.responses`. The namespace will cover curation (`upvote`, `downvote`, `unvote`), stance (`agree`, `disagree`, `unagree`), and veracity (`verify`, `dispute`, `unverify`). The existing `geo.entityVotes` namespace remains available as a deprecated compatibility adapter for `upvote`, `downvote`, and `withdraw`. - -### Problem Frame - -gaia can now index three independent response kinds, but the SDK can emit only the original curation actions. Consumers cannot submit Agree, Disagree, Verify, Dispute, or their kind-specific clears through the supported client API. The contract repository also does not define or initialize these actions, so end-to-end proof must distinguish SDK correctness from missing registry administration or backend deployment. - -### Requirements - -**Canonical response API** - -- R1. `createGeoClient(...)` exposes `geo.responses` with nine explicit methods: `upvote`, `downvote`, `unvote`, `agree`, `disagree`, `unagree`, `verify`, `dispute`, and `unverify`. -- R2. Every canonical method accepts the existing entity-target parameters: author space ID, target space ID, and entity ID. -- R3. The three response kinds remain independent because every method maps to its own protocol action while reusing the existing entity topic and data encoding. - -**Protocol compatibility** - -- R4. Curation calls through `geo.responses` produce byte-for-byte equivalent calldata to the existing `geo.entityVotes` operations. -- R5. All response actions keep object type `0` for entities, encoding version `0`, the current ABI data tuple, and an empty signature. -- R6. Invalid IDs and missing network contract configuration fail before calldata is returned, following current SDK validation behavior. - -**Migration compatibility** - -- R7. `geo.entityVotes.upvote`, `geo.entityVotes.downvote`, and `geo.entityVotes.withdraw` remain callable and delegate to `geo.responses.upvote`, `geo.responses.downvote`, and `geo.responses.unvote` respectively. -- R8. The SDK marks `geo.entityVotes` and its legacy graph helpers as deprecated in type documentation and user documentation without adding runtime warnings. - -**Verification and release** - -- R9. Unit coverage pins all nine action strings to their expected hashes and decodes the resulting `SpaceRegistry.enter()` calldata. -- R10. End-to-end coverage submits all six new actions, verifies gaia’s kind and direction state, and proves that clearing one kind does not alter another kind. -- R11. End-to-end setup fails with an actionable dependency error when the registry registrations or gaia schema are unavailable. -- R12. README documentation presents `geo.responses` as canonical, documents the deprecated namespace, and ships a minor changeset. - -### Key Decisions - -- **One canonical response namespace.** (session-settled: user-directed — chosen over extending `geo.entityVotes`: one namespace keeps curation, stance, and veracity at the same API level.) Governs R1, R4, R7, and R8. -- **Entity targets only.** (session-settled: user-directed — chosen over adding relation-target responses: no current SDK consumer requires relation writes, and adding them would widen the public target model and e2e matrix.) Governs R2 and R5. - -### Acceptance Examples - -- **AE1. Canonical curation:** Given valid IDs and a configured registry, when a caller invokes `geo.responses.upvote`, then the returned target and calldata match `geo.entityVotes.upvote` for the same inputs. Covers R1, R4, and R7. -- **AE2. Stance transition:** Given an entity with no stance response from the user, when the user agrees, disagrees, and then unagrees, then gaia reports positive stance, negative stance, and no current stance response in sequence. Covers R3, R9, and R10. -- **AE3. Veracity independence:** Given a user who already upvoted an entity, when the user verifies and later unverifies it, then the curation response remains while the veracity response appears and disappears independently. Covers R3 and R10. -- **AE4. Environment not ready:** Given a registry where one of the six new actions is not permissionless, when e2e setup runs, then it identifies the missing action and registry before submitting feature transactions. Covers R11. - -### Scope Boundaries - -- The SDK produces transaction targets and calldata; it does not submit transactions from `geo.responses`. -- This plan does not add response read APIs. E2e tests use the existing GraphQL client only to verify indexed effects. -- This plan does not change response topic or data encoding, infer response kind from entity data, or inspect whether an entity is a Claim. -- This plan does not change gaia migrations, indexers, GraphQL schema, web controls, analytics, or ranking behavior. -- This plan does not modify `geo-contracts-foundry` or perform registry-owner transactions. - -#### Deferred to Follow-Up Work - -- Entity-or-relation response targets and a generalized object discriminator. -- Web-app adoption of `geo.responses` and kind-aware reads. -- Contract-repository constants, action documentation, initialization defaults, or deployment scripts for the six new actions. -- Removing `geo.entityVotes` after a separately announced deprecation window. - -### Dependencies - -- The target gaia deployment must include PR #872 so GraphQL exposes `voteKind` and kind-scoped current state. -- The owner of each target `SpaceRegistry` must enable `PERMISSIONLESS.AGREED`, `DISAGREED`, `UNAGREED`, `VERIFIED`, `DISPUTED`, and `UNVERIFIED` with `setPermissionlessAction`. -- No contract implementation upgrade is required. The current registry already supports owner-managed permissionless action hashes. - -### Sources - -- [gaia PR #872: vote-kind storage and indexing source of truth](https://github.com/geobrowser/gaia/pull/872) -- [geo-contracts-foundry action constants on `dev`](https://github.com/geobrowser/geo-contracts-foundry/blob/dev/src/ActionsConstants.sol) -- [geo-contracts-foundry `SpaceRegistry` permissionless-action behavior](https://github.com/geobrowser/geo-contracts-foundry/blob/dev/src/contracts/SpaceRegistry.sol) -- User-supplied “PRD - New Actions”, dated 2026-08-04, as supporting product context. - ---- - -## Planning Contract - -### Key Technical Decisions - -- KTD1. **Use one method per protocol action.** The canonical API maps `upvote`, `downvote`, `unvote`, `agree`, `disagree`, `unagree`, `verify`, `dispute`, and `unverify` one-to-one to the nine action strings from gaia. This avoids a caller-supplied kind/direction combination that can represent invalid pairs. -- KTD2. **Centralize encoding in `responses.ts`.** One internal encoder owns ID normalization, entity topic encoding, response data encoding, `SpaceRegistry.enter()` construction, and the action map. Compatibility layers delegate into it and do not duplicate hashes or ABI logic. -- KTD3. **Keep compatibility compile-time and behavioral.** `geo.entityVotes` remains a typed runtime property with JSDoc deprecation markers. It emits no warning and returns exactly the canonical curation result, matching the repository’s adapter pattern. -- KTD4. **Pin protocol names and bytes independently.** Unit tests derive each action hash from its protocol string and compare it with a fixed expected value from gaia. They also assert pairwise uniqueness so a typo cannot merge response axes. -- KTD5. **Verify current state, not only event acceptance.** E2e tests query kind-filtered `userVotes` and `votesCounts` state after successful transaction receipts. A receipt or raw event alone does not prove the action was registered or that gaia applied kind-scoped overwrite and clear behavior. -- KTD6. **Preflight external readiness without acquiring admin authority.** E2e setup reads `permissionlessActions` for the six new hashes and probes the required GraphQL fields. It reports missing prerequisites rather than impersonating a registry owner or mutating environment configuration. - -### High-Level Technical Design - -The action map is fixed by gaia’s merged implementation. `voteKind` is the indexed discriminator; the SDK continues to send only the action hash and the existing entity payload. - -| SDK method | Protocol action | `voteKind` | Direction | Expected action hash | -|---|---|---:|---|---| -| `upvote` | `PERMISSIONLESS.UPVOTED` | 0 | positive | `0x1fc04a8d9387c7bd1199a2a77c8e531a7a7b11991df5dcc8c9acb6abcb481725` | -| `downvote` | `PERMISSIONLESS.DOWNVOTED` | 0 | negative | `0xde8b897ce7cc541dacb388d5aabb3dc0fb7856920284f41582c15b5fc31a8662` | -| `unvote` | `PERMISSIONLESS.UNVOTED` | 0 | clear | `0x3bd4c337382f79aa5007a91169bb57723b5dd59e6b4bb60d20362bcc0d9d998b` | -| `agree` | `PERMISSIONLESS.AGREED` | 1 | positive | `0xcc1f104e089fb96ad3a3f1e70607f3dda4ed556e810bdc30193f19df474369b9` | -| `disagree` | `PERMISSIONLESS.DISAGREED` | 1 | negative | `0x285c96f1d9b8f9143d333a762cb9fa03e98b3f551a824e99ed14072ca3c51179` | -| `unagree` | `PERMISSIONLESS.UNAGREED` | 1 | clear | `0xa1d2a63f4172ef63617e69ca00a8a5e0e0f886fcd26d742208cc5da02fe32328` | -| `verify` | `PERMISSIONLESS.VERIFIED` | 2 | positive | `0x588446c29505d69d73cba2f34aa402447b77f055539a93aec891beb3fbf3f0fd` | -| `dispute` | `PERMISSIONLESS.DISPUTED` | 2 | negative | `0x839d074bf1854255cda5c35a5c89feb5687db041c8ff22370e8597a58ef7706d` | -| `unverify` | `PERMISSIONLESS.UNVERIFIED` | 2 | clear | `0x9516e48c1d614910098dd6197889f54cb08474c630efdb4cd07bbeee329912c2` | - -```mermaid -flowchart TB - Consumer["SDK consumer"] --> Responses["geo.responses"] - Legacy["geo.entityVotes (deprecated)"] --> Responses - Responses --> Encoder["Shared entity-response encoder"] - Encoder --> Registry["SpaceRegistry.enter"] - Registry --> Action["Anonymous Action event"] - Action --> Gaia["gaia response pipeline and indexer"] - Gaia --> GraphQL["Kind-aware GraphQL state"] - GraphQL --> E2E["SDK e2e assertions"] -``` - -Each kind follows the same independent state machine. Switching kind never transitions or clears another kind. - -```mermaid -stateDiagram-v2 - [*] --> None - None --> Positive: upvote / agree / verify - None --> Negative: downvote / disagree / dispute - Positive --> Negative: negative action - Negative --> Positive: positive action - Positive --> None: unvote / unagree / unverify - Negative --> None: unvote / unagree / unverify -``` - -### Implementation Sequence - -1. Establish the canonical action map, encoder, and exhaustive unit proof. -2. Add the client namespace and route compatibility surfaces through it. -3. Extend the existing API-surface e2e suite once the protocol surface is stable. -4. Update documentation and add the release changeset after names and examples are final. - -### Operational Rollout - -1. Deploy gaia PR #872 and confirm the target GraphQL API exposes kind-aware current responses and counts. -2. Have the registry owner enable the six new action hashes on each target `SpaceRegistry`. -3. Run U3 against each release environment and require the readiness probe plus all state-transition assertions to pass. -4. Release the SDK only after the target environment passes; consumer adoption can follow independently. - -Rolling back the SDK does not require removing the permissionless registrations. gaia already understands the actions, and older SDK clients ignore them. If an incorrect hash was registered, the registry owner should disable that hash, enable the source-of-truth hash, and rerun U3 before release. - -### Risks and Mitigations - -| Risk | Impact | Mitigation | -|---|---|---| -| Action string or hash drift | Transactions are indexed under no recognized response kind. | Pin all nine fixed hashes and live keccak derivations in one table-driven unit suite. | -| Registry registration missing | A receipt can succeed through the non-permissionless path while producing unusable subject data. | Preflight `permissionlessActions` and assert kind-aware indexed state, not receipt status alone. | -| gaia deployment lag | GraphQL queries fail or omit `voteKind`. | Probe schema readiness and report the API origin and missing field before feature flows. | -| Compatibility logic diverges | Existing consumers produce different curation calldata after upgrading. | Make deprecated methods thin delegates and assert byte-for-byte equality. | -| Cross-kind e2e false positives | An earlier curation event satisfies a stance or veracity predicate. | Use unique entities and include `voteKind` in every query and assertion. | -| “Verified” naming collision | Reviewers confuse response verification with subspace verification. | Use `verify`, `veracity`, or qualified response-action names; avoid bare `verified` identifiers. | - ---- - -## Implementation Units - -### U1. Canonical Response Actions and Encoding - -- **Goal:** Create the canonical entity-response action map and shared calldata encoder for all nine methods. -- **Requirements:** R1, R2, R3, R5, R6, R9; AE1, AE2, AE3; KTD1, KTD2, KTD4. -- **Dependencies:** None. -- **Files:** - - Create: `src/client/responses.ts` - - Create: `src/client/responses.test.ts` - - Reference: `src/client/entity-votes.ts` - - Reference: `src/client/entity-votes.test.ts` -- **Approach:** - 1. Define the nine response action strings and derived hashes in the canonical module, grouped by curation, stance, and veracity. - 2. Move the existing ID conversion, entity topic, data tuple, and `enter()` encoding behind one action-parameterized helper. - 3. Expose context-aware operations for the nine canonical method names without accepting arbitrary action hashes. Keep the action-parameterized encoder internal to this module; do not add a new public package subpath or arbitrary-action escape hatch. - 4. Keep entity object type, encoding version, data tuple, and signature unchanged per R5. -- **Patterns to follow:** Mirror `src/client/entity-votes.ts` for `viem` encoding, `assertValid` use, and network address resolution. Mirror the decode-based assertions in `src/client/entity-votes.test.ts`. -- **Test scenarios:** - 1. Each canonical method encodes its exact gaia action hash while every other decoded `enter()` argument stays identical for common inputs. - 2. All nine fixed expected hashes equal a fresh keccak derivation and are pairwise distinct. - 3. Upvote, agree, and verify share the positive direction semantics but retain different action hashes; the same holds for negative and clear groups. - 4. Dashed, raw, and `0x`-prefixed IDs normalize to identical calldata. - 5. Invalid author space, target space, and entity IDs fail before encoding for representative actions from all three kinds. - 6. A context-aware response uses the configured `SPACE_REGISTRY_ADDRESS`; a network without that address fails with the existing contract-configuration error. -- **Verification:** The response test suite decodes every public operation into the expected registry call and fails if any protocol byte or invariant drifts. - -### U2. Client Namespace and Deprecated Compatibility - -- **Goal:** Make `geo.responses` canonical while preserving the existing client and graph curation APIs as delegates. -- **Requirements:** R1, R4, R6, R7, R8; AE1; KTD2, KTD3. -- **Dependencies:** U1. -- **Files:** - - Modify: `src/client.ts` - - Modify: `src/client.test.ts` - - Modify: `src/client/entity-votes.ts` - - Modify: `src/client/entity-votes.test.ts` - - Modify: `src/graph/entity-vote.ts` - - Modify: `src/graph/entity-vote.test.ts` -- **Approach:** - 1. Add the canonical response parameter type and the nine-method `responses` property to the exported `Client` type and `createGeoClient` result. - 2. Retain `EntityVoteParams` as a deprecated type alias when removal would break imported SDK types. - 3. Keep `entityVotes` on the client, mark it deprecated, and route its three methods to the canonical curation methods. - 4. Preserve the context-free curation encoders as deprecated adapters if their current module exports remain reachable to consumers or internal tests. - 5. Update legacy graph helper deprecation guidance and delegation targets to `geo.responses`. -- **Patterns to follow:** Follow the `createGeoClient` namespace construction in `src/client.ts` and the adapter style in `src/graph/entity-vote.ts`. Deprecation stays in JSDoc and README text, with no console side effect. -- **Test scenarios:** - 1. `Object.keys(geo.responses)` contains exactly the nine canonical methods in documented order. - 2. Each canonical method resolves the configured registry address without requiring `fetch`. - 3. Deprecated upvote and downvote results equal their canonical equivalents byte for byte. - 4. Deprecated `withdraw` equals canonical `unvote` byte for byte. - 5. Existing invalid-ID behavior remains observable through `geo.entityVotes` and legacy graph helpers. - 6. TypeScript compilation accepts old client calls and new calls while surfacing deprecation metadata for the old namespace. -- **Verification:** Existing curation tests remain green, new client-surface assertions pass, and the build emits public declarations for both namespaces. - -### U3. Kind-Aware End-to-End Verification - -- **Goal:** Prove the canonical SDK methods execute through `SpaceRegistry` and produce gaia’s independent current-state model. -- **Requirements:** R3, R4, R10, R11; AE1, AE2, AE3, AE4; KTD5, KTD6. -- **Dependencies:** U1, U2, and an environment with gaia PR #872 deployed. -- **Files:** - - Modify: `src/api-surface.e2e.test.ts` - - Reference: `src/e2e-test-environment.ts` - - Reference: `src/e2e-wallet.ts` - - Reference: `src/abis/space-registry.ts` -- **Approach:** - 1. Extend the existing response GraphQL fixtures and wait helpers with `voteKind`, current `userVotes`, and per-kind `votesCounts` fields. - 2. Add an e2e readiness check for the six `permissionlessActions` entries and the merged gaia GraphQL shape. - 3. Exercise canonical curation calls through `geo.responses` while keeping compatibility proof in U2’s deterministic tests. - 4. Use one unique entity for the stance transition and another for the veracity-independence transition so prior runs cannot satisfy predicates. - 5. Reuse transaction receipt checks, indexer alignment checks, and bounded polling already present in the suite. -- **Execution note:** Establish the readiness probe first. A missing external deployment must fail before the suite emits partial response state. -- **Patterns to follow:** Use `sendTransactionAndWait`, `waitFor`, `waitForIndexerBlock`, and the unique-entity helpers in `src/api-surface.e2e.test.ts`. -- **Test scenarios:** - 1. Covers AE1. Canonical upvote is accepted and indexed as kind `0`, positive direction, with the expected curation count. - 2. Covers AE2. Agree creates a kind `1` positive current response; Disagree replaces it with one kind `1` negative response; Unagree removes only the kind `1` current response and returns its counts to zero. - 3. Covers AE3. Upvote followed by Verify leaves both kind `0` and kind `2` current responses; Dispute changes only kind `2`; Unverify removes only kind `2` while curation remains unchanged. - 4. Every successful step is backed by a successful transaction receipt and a kind-filtered indexed-state predicate. - 5. Covers AE4. A missing action registration names the action, network, and registry address without submitting response transactions. - 6. A gaia API without `voteKind`, `positive`, or `negative` fails with an explicit schema-readiness message rather than timing out. -- **Verification:** `pnpm test:e2e` passes against a ready testnet or local-geobrowser environment and proves all six new actions plus curation compatibility. - -### U4. Documentation and Release Packaging - -- **Goal:** Publish the new canonical API and migration guidance as a minor SDK feature. -- **Requirements:** R8, R12; KTD1, KTD3, KTD6. -- **Dependencies:** U2, U3. -- **Files:** - - Modify: `README.md` - - Create: `.changeset/.md` -- **Approach:** - 1. Replace the main entity-vote section with a `geo.responses` section that explains the three response kinds and all nine method names. - 2. Include one transaction-submission example and concise examples for curation, stance, and veracity. - 3. Move `geo.entityVotes` into compatibility guidance, state that it remains functional, and point each method to its canonical replacement. - 4. Document that new action execution requires gaia deployment and registry-owner registration without presenting SDK consumers as registry administrators. - 5. Add a minor changeset for the new public namespace and actions, noting the non-breaking deprecation. -- **Patterns to follow:** Follow the README’s existing client-namespace sections and the repository changeset format from `AGENTS.md`. -- **Test expectation:** None — this unit changes documentation and release metadata only; U2’s build verifies referenced public names. -- **Verification:** Documentation examples match emitted declarations, the compatibility status is unambiguous, and the changeset targets `@geoprotocol/geo-sdk` with a minor release. - ---- - -## Verification Contract - -| Gate | Applies to | Command | Done signal | -|---|---|---|---| -| Focused response tests | U1, U2 | `pnpm test -- src/client/responses.test.ts src/client/entity-votes.test.ts src/client.test.ts src/graph/entity-vote.test.ts` | All protocol, namespace, and compatibility scenarios pass. | -| Full unit suite | U1-U4 | `pnpm test` | No SDK regression outside the response surface. | -| Type and package build | U1, U2, U4 | `pnpm build` | Public declarations contain the canonical and deprecated APIs without TypeScript errors. | -| Repository lint | U1-U4 | `pnpm lint` | Biome reports no errors on code, tests, or documentation it checks. | -| Cross-system e2e | U3 | `pnpm test:e2e` | Transactions succeed and kind-filtered gaia state proves stance, veracity, clear, and independence semantics. | -| Release metadata | U4 | Inspect the new `.changeset/*.md` entry | The package is marked for a minor release with an accurate user-facing summary. | - -The e2e gate applies only after its external readiness probe passes. A missing registration or gaia deployment is a reported release blocker, not grounds to skip or weaken the e2e requirement. - ---- - -## Definition of Done - -- U1 is complete when all nine canonical operations share one encoder and fixed-hash decode tests cover every protocol action. -- U2 is complete when `geo.responses` is the documented canonical runtime and type surface, while `geo.entityVotes` and graph curation helpers remain compatible deprecated adapters. -- U3 is complete when a ready environment passes the stance transition, veracity independence, curation compatibility, and dependency-failure scenarios. -- U4 is complete when README examples, deprecation guidance, operational prerequisites, and a minor changeset agree with the emitted API. -- All Verification Contract gates pass, except that an external readiness failure must remain explicitly reported until the owning deployment or registry action is completed. -- No relation-target implementation, generic arbitrary-action escape hatch, duplicated encoder, runtime deprecation warning, or unrelated cleanup remains in the diff. -- Experimental or abandoned code from implementation attempts is removed before handoff. From 148974a61f9fca69600397eabec7aad51b0e3c08 Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Thu, 6 Aug 2026 15:03:49 +0200 Subject: [PATCH 9/9] remove unnecessary information --- README.md | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/README.md b/README.md index b9d4335..76090f6 100644 --- a/README.md +++ b/README.md @@ -926,20 +926,6 @@ await walletClient.sendTransaction({ }); ``` -Agree, Disagree, Verify, Dispute, and their clear actions require an environment -running the kind-aware gaia schema and a `SpaceRegistry` whose owner has enabled -the six corresponding permissionless action hashes. SDK consumers submit the -returned calldata; they do not need registry-owner authority. - -#### Deprecated `geo.entityVotes` - -`geo.entityVotes` remains functional for compatibility but is deprecated. Use -these replacements in new code: - -- `geo.entityVotes.upvote` → `geo.responses.upvote` -- `geo.entityVotes.downvote` → `geo.responses.downvote` -- `geo.entityVotes.withdraw` → `geo.responses.unvote` - ## Full Publishing Flow With A Sponsored Wallet This example publishes an edit to an existing personal space using a sponsored