From 237a9a83a76f83e351b39629bde8c3b930bf7e5d Mon Sep 17 00:00:00 2001 From: ccp sondheim Date: Thu, 25 Jun 2026 16:06:37 +0000 Subject: [PATCH 1/2] feat: optimistic status updates --- .../providers/SmartObjectProvider.tsx | 80 ++++++++++++++++--- .../utils/__tests__/fuelEventBcs.test.ts | 22 ++--- .../utils/__tests__/inventoryEventBcs.test.ts | 13 +-- .../utils/__tests__/statusEventBcs.test.ts | 66 +++++++++++++++ .../events/__tests__/eventRefresh.test.ts | 5 +- .../dapp-kit/utils/events/checkpointStream.ts | 15 +--- .../dapp-kit/utils/events/eventBcsRegistry.ts | 28 +++++++ .../dapp-kit/utils/events/fuelEventBcs.ts | 30 +------ .../utils/events/inventoryEventBcs.ts | 38 +-------- .../dapp-kit/utils/events/statusEventBcs.ts | 38 +++++++++ .../utils/events/statusEventHandlers.ts | 68 ++++++++++++++++ 11 files changed, 296 insertions(+), 107 deletions(-) create mode 100644 packages/libs/dapp-kit/utils/__tests__/statusEventBcs.test.ts create mode 100644 packages/libs/dapp-kit/utils/events/eventBcsRegistry.ts create mode 100644 packages/libs/dapp-kit/utils/events/statusEventBcs.ts create mode 100644 packages/libs/dapp-kit/utils/events/statusEventHandlers.ts diff --git a/packages/libs/dapp-kit/providers/SmartObjectProvider.tsx b/packages/libs/dapp-kit/providers/SmartObjectProvider.tsx index 8646f38..2e241f0 100644 --- a/packages/libs/dapp-kit/providers/SmartObjectProvider.tsx +++ b/packages/libs/dapp-kit/providers/SmartObjectProvider.tsx @@ -36,6 +36,12 @@ import { getInventoryEventTypes, isRelevantAssemblyInventoryEvent, } from '../utils/events/inventoryEventHandlers' +import { + applyStatusEventToAssembly, + getStatusEventTarget, + getStatusEventType, + isRelevantStatusEvent, +} from '../utils/events/statusEventHandlers' import { getInventoryTypeVolumeM3, mergeSmartStorageInventoryFromRefetch, @@ -126,6 +132,7 @@ const SmartObjectProvider = ({ children }: { children: ReactNode }) => { const lastDataHashRef = useRef(null) const lastConfirmedInventorySignatureRef = useRef(null) const lastConfirmedFuelSignatureRef = useRef(null) + const lastConfirmedStateRef = useRef(null) const assemblyRef = useRef | null>(null) assemblyRef.current = assembly @@ -221,8 +228,26 @@ const SmartObjectProvider = ({ children }: { children: ReactNode }) => { primeInventoryTypeVolumes(transformed) setAssembly((currentAssembly) => { - if (transformed?.type === Assemblies.NetworkNode) { - const { quantity, isBurning } = transformed.networkNode.fuel + if (transformed === null) return null + + // State stale-refetch protection (all assembly types). + // If the indexer returns the same state we last confirmed, keep + // the optimistic state rather than reverting it. + let next: typeof transformed = transformed + const nextState = next.state + if ( + !isInitialFetch && + currentAssembly !== null && + lastConfirmedStateRef.current !== null && + nextState === lastConfirmedStateRef.current + ) { + next = { ...next, state: currentAssembly.state } + } else { + lastConfirmedStateRef.current = nextState + } + + if (next?.type === Assemblies.NetworkNode) { + const { quantity, isBurning } = next.networkNode.fuel const nextSignature = `${quantity}:${isBurning}` // On a refetch, if the indexer hasn't caught up yet it returns the @@ -238,11 +263,11 @@ const SmartObjectProvider = ({ children }: { children: ReactNode }) => { } lastConfirmedFuelSignatureRef.current = nextSignature - return transformed + return next } - if (transformed?.type !== Assemblies.SmartStorageUnit) { - return transformed + if (next?.type !== Assemblies.SmartStorageUnit) { + return next } // Merge against the prior storage state only on refetches; an @@ -256,7 +281,7 @@ const SmartObjectProvider = ({ children }: { children: ReactNode }) => { const { assembly, inventorySignature } = mergeSmartStorageInventoryFromRefetch( previousStorage, - transformed, + next, lastConfirmedInventorySignatureRef.current, ) lastConfirmedInventorySignatureRef.current = inventorySignature @@ -353,6 +378,7 @@ const SmartObjectProvider = ({ children }: { children: ReactNode }) => { lastDataHashRef.current = null lastConfirmedInventorySignatureRef.current = null lastConfirmedFuelSignatureRef.current = null + lastConfirmedStateRef.current = null } }, [ selectedObjectId, @@ -377,7 +403,15 @@ const SmartObjectProvider = ({ children }: { children: ReactNode }) => { const fuelEventTypes = getStreamEventTypes(selectedTenant, (pkg) => [ getFuelEventType(pkg), ]) - const allEventTypes = [...inventoryEventTypes, ...fuelEventTypes] + const statusEventTypes = getStreamEventTypes(selectedTenant, (pkg) => [ + getStatusEventType(pkg), + ]) + + const allEventTypes = [ + ...inventoryEventTypes, + ...fuelEventTypes, + ...statusEventTypes, + ] const abortController = new AbortController() const triggerRefetch = createEventRefetchScheduler( () => fetchObjectData(input, false), @@ -414,6 +448,10 @@ const SmartObjectProvider = ({ children }: { children: ReactNode }) => { assemblyRef.current, fuelEventTypes, ) + const statusEventTarget = getStatusEventTarget( + assemblyRef.current, + statusEventTypes, + ) const relevantInventoryEvents = events.filter((event) => isRelevantAssemblyInventoryEvent(event, inventoryEventTarget), @@ -423,27 +461,40 @@ const SmartObjectProvider = ({ children }: { children: ReactNode }) => { isRelevantFuelEvent(event, fuelEventTarget), ) : [] + const relevantStatusEvents = statusEventTarget + ? events.filter((event) => + isRelevantStatusEvent(event, statusEventTarget), + ) + : [] if ( events.length > 0 && relevantInventoryEvents.length === 0 && - relevantFuelEvents.length === 0 + relevantFuelEvents.length === 0 && + relevantStatusEvents.length === 0 ) { log.debug( '[DappKit] SmartObjectProvider: Ignoring stream events for other assemblies', - { count: events.length, inventoryEventTarget, fuelEventTarget }, + { + count: events.length, + inventoryEventTarget, + fuelEventTarget, + statusEventTarget, + }, ) } if ( relevantInventoryEvents.length > 0 || - relevantFuelEvents.length > 0 + relevantFuelEvents.length > 0 || + relevantStatusEvents.length > 0 ) { log.info( '[DappKit] SmartObjectProvider: Applying assembly stream events', { inventoryCount: relevantInventoryEvents.length, fuelCount: relevantFuelEvents.length, + statusCount: relevantStatusEvents.length, }, ) setAssembly((currentAssembly) => { @@ -456,6 +507,15 @@ const SmartObjectProvider = ({ children }: { children: ReactNode }) => { (asm, event) => applyFuelEventToAssembly(asm, event), next, ) + next = relevantStatusEvents.reduce( + (asm, event) => applyStatusEventToAssembly(asm, event), + next, + ) + // Update confirmed state so the stale-refetch guard tracks the + // new optimistic value rather than reverting it on the next poll. + if (next?.state !== undefined) { + lastConfirmedStateRef.current = next.state + } return next }) triggerRefetch() diff --git a/packages/libs/dapp-kit/utils/__tests__/fuelEventBcs.test.ts b/packages/libs/dapp-kit/utils/__tests__/fuelEventBcs.test.ts index 44aa286..df25427 100644 --- a/packages/libs/dapp-kit/utils/__tests__/fuelEventBcs.test.ts +++ b/packages/libs/dapp-kit/utils/__tests__/fuelEventBcs.test.ts @@ -1,9 +1,6 @@ import { describe, expect, it } from 'vitest' -import { - decodeFuelEventBcs, - fuelEventBcsToParsedJson, -} from '../events/fuelEventBcs' +import { decodeFuelEventBcsToJson } from '../events/fuelEventBcs' function hexToBytes(hex: string): Uint8Array { const bytes = new Uint8Array(hex.length / 2) @@ -31,9 +28,9 @@ const FUEL_EVENT_BCS_HEX = describe('fuelEventBcs', () => { it('decodes a FuelEvent from BCS bytes', () => { - const decoded = decodeFuelEventBcs(hexToBytes(FUEL_EVENT_BCS_HEX)) - - expect(fuelEventBcsToParsedJson(decoded)).toMatchObject({ + expect( + decodeFuelEventBcsToJson(hexToBytes(FUEL_EVENT_BCS_HEX)), + ).toMatchObject({ assembly_id: '0x34d08b4e1afe6a4babcc0642d6a676160df6b777b49214d5c964b4e874cc951b', assembly_key: { @@ -48,13 +45,16 @@ describe('fuelEventBcs', () => { }) it('decodes the action variant kind', () => { - const decoded = decodeFuelEventBcs(hexToBytes(FUEL_EVENT_BCS_HEX)) - const json = fuelEventBcsToParsedJson(decoded) + const json = decodeFuelEventBcsToJson(hexToBytes(FUEL_EVENT_BCS_HEX)) // bcs.enum returns a discriminated union: { $kind: '', : true } - expect((json.action as Record)?.$kind).toBe('WITHDRAWN') + expect((json['action'] as Record)?.['$kind']).toBe( + 'WITHDRAWN', + ) }) it('throws on malformed BCS bytes', () => { - expect(() => decodeFuelEventBcs(new Uint8Array([0x00, 0x01]))).toThrow() + expect(() => + decodeFuelEventBcsToJson(new Uint8Array([0x00, 0x01])), + ).toThrow() }) }) diff --git a/packages/libs/dapp-kit/utils/__tests__/inventoryEventBcs.test.ts b/packages/libs/dapp-kit/utils/__tests__/inventoryEventBcs.test.ts index 5f13e53..d39369d 100644 --- a/packages/libs/dapp-kit/utils/__tests__/inventoryEventBcs.test.ts +++ b/packages/libs/dapp-kit/utils/__tests__/inventoryEventBcs.test.ts @@ -1,9 +1,6 @@ import { describe, expect, it } from 'vitest' -import { - decodeInventoryEventBcs, - inventoryEventBcsToParsedJson, -} from '../events/inventoryEventBcs' +import { decodeInventoryEventBcsToJson } from '../events/inventoryEventBcs' function hexToBytes(hex: string): Uint8Array { const bytes = new Uint8Array(hex.length / 2) @@ -21,11 +18,9 @@ const INVENTORY_MOVE_EVENT_BCS_HEX = describe('inventoryEventBcs', () => { it('decodes an inventory move event from chain BCS bytes', () => { - const decoded = decodeInventoryEventBcs( - hexToBytes(INVENTORY_MOVE_EVENT_BCS_HEX), - ) - - expect(inventoryEventBcsToParsedJson(decoded)).toEqual({ + expect( + decodeInventoryEventBcsToJson(hexToBytes(INVENTORY_MOVE_EVENT_BCS_HEX)), + ).toEqual({ assembly_id: '0x34d08b4e1afe6a4babcc0642d6a676160df6b777b49214d5c964b4e874cc951b', assembly_key: { diff --git a/packages/libs/dapp-kit/utils/__tests__/statusEventBcs.test.ts b/packages/libs/dapp-kit/utils/__tests__/statusEventBcs.test.ts new file mode 100644 index 0000000..099b426 --- /dev/null +++ b/packages/libs/dapp-kit/utils/__tests__/statusEventBcs.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' + +import { decodeStatusChangedEventBcsToJson } from '../events/statusEventBcs' + +function hexToBytes(hex: string): Uint8Array { + const bytes = new Uint8Array(hex.length / 2) + for (let index = 0; index < bytes.length; index += 1) { + bytes[index] = Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16) + } + return bytes +} + +// Manually constructed StatusChangedEvent BCS bytes matching the on-chain Move struct: +// assembly_id: 0x34d08b4e...cc951b (32 bytes) +// assembly_key: { item_id: 1 (u64 LE), tenant: "stillness" } +// status: ONLINE (variant index 2) +// action: ANCHORED (variant index 0) +const STATUS_CHANGED_EVENT_BCS_HEX = + '34d08b4e1afe6a4babcc0642d6a676160df6b777b49214d5c964b4e874cc951b' + + '0100000000000000' + + '09' + + '7374696c6c6e657373' + + '02' + + '00' + +describe('statusEventBcs', () => { + it('decodes a StatusChangedEvent from BCS bytes', () => { + expect( + decodeStatusChangedEventBcsToJson( + hexToBytes(STATUS_CHANGED_EVENT_BCS_HEX), + ), + ).toMatchObject({ + assembly_id: + '0x34d08b4e1afe6a4babcc0642d6a676160df6b777b49214d5c964b4e874cc951b', + assembly_key: { + item_id: '1', + tenant: 'stillness', + }, + }) + }) + + it('decodes the status variant kind', () => { + const json = decodeStatusChangedEventBcsToJson( + hexToBytes(STATUS_CHANGED_EVENT_BCS_HEX), + ) + // bcs.enum returns a discriminated union: { $kind: '', : true } + expect((json['status'] as Record)?.['$kind']).toBe( + 'ONLINE', + ) + }) + + it('decodes the action variant kind', () => { + const json = decodeStatusChangedEventBcsToJson( + hexToBytes(STATUS_CHANGED_EVENT_BCS_HEX), + ) + expect((json['action'] as Record)?.['$kind']).toBe( + 'ANCHORED', + ) + }) + + it('throws on malformed BCS bytes', () => { + expect(() => + decodeStatusChangedEventBcsToJson(new Uint8Array([0x00, 0x01])), + ).toThrow() + }) +}) diff --git a/packages/libs/dapp-kit/utils/events/__tests__/eventRefresh.test.ts b/packages/libs/dapp-kit/utils/events/__tests__/eventRefresh.test.ts index 812b5bf..b8622af 100644 --- a/packages/libs/dapp-kit/utils/events/__tests__/eventRefresh.test.ts +++ b/packages/libs/dapp-kit/utils/events/__tests__/eventRefresh.test.ts @@ -1187,8 +1187,9 @@ describe('event refresh helpers', () => { ) expect(events).toHaveLength(1) - expect(events[0].type).toBe(getFuelEventType(PACKAGE_ID)) - expect(events[0].parsedJson).toMatchObject({ + const fuelEvent = events[0]! + expect(fuelEvent.type).toBe(getFuelEventType(PACKAGE_ID)) + expect(fuelEvent.parsedJson).toMatchObject({ assembly_id: ASSEMBLY_OBJECT_ID, assembly_key: { item_id: '1', tenant: 'stillness' }, type_id: '77810', diff --git a/packages/libs/dapp-kit/utils/events/checkpointStream.ts b/packages/libs/dapp-kit/utils/events/checkpointStream.ts index fa0bc49..8c3124c 100644 --- a/packages/libs/dapp-kit/utils/events/checkpointStream.ts +++ b/packages/libs/dapp-kit/utils/events/checkpointStream.ts @@ -1,11 +1,7 @@ import type { SuiEvent } from '@mysten/sui/jsonRpc' import { createLogger } from '../logger' import { isRecord } from '../utils' -import { decodeFuelEventBcs, fuelEventBcsToParsedJson } from './fuelEventBcs' -import { - decodeInventoryEventBcs, - inventoryEventBcsToParsedJson, -} from './inventoryEventBcs' +import { decodeEventBcsToJson } from './eventBcsRegistry' const CHECKPOINT_STREAM_RECONNECT_MS = 1_000 // Rotate before the public fullnode ~30s stream cutoff. @@ -115,14 +111,7 @@ function parseEventPayloadFromStream( const bcsBytes = event.contents?.value if (!bcsBytes) return null - try { - if (eventType.endsWith('::fuel::FuelEvent')) { - return fuelEventBcsToParsedJson(decodeFuelEventBcs(bcsBytes)) - } - return inventoryEventBcsToParsedJson(decodeInventoryEventBcs(bcsBytes)) - } catch { - return null - } + return decodeEventBcsToJson(bcsBytes, eventType) } function wait(ms: number, signal?: AbortSignal) { diff --git a/packages/libs/dapp-kit/utils/events/eventBcsRegistry.ts b/packages/libs/dapp-kit/utils/events/eventBcsRegistry.ts new file mode 100644 index 0000000..506eb30 --- /dev/null +++ b/packages/libs/dapp-kit/utils/events/eventBcsRegistry.ts @@ -0,0 +1,28 @@ +import { decodeFuelEventBcsToJson } from './fuelEventBcs' +import { decodeInventoryEventBcsToJson } from './inventoryEventBcs' +import { decodeStatusChangedEventBcsToJson } from './statusEventBcs' + +type BcsDecoder = (bytes: Uint8Array) => Record + +const DECODERS_BY_SUFFIX: ReadonlyArray<[suffix: string, decode: BcsDecoder]> = + [ + ['::inventory::ItemMintedEvent', decodeInventoryEventBcsToJson], + ['::inventory::ItemBurnedEvent', decodeInventoryEventBcsToJson], + ['::fuel::FuelEvent', decodeFuelEventBcsToJson], + ['::status::StatusChangedEvent', decodeStatusChangedEventBcsToJson], + ] + +export function decodeEventBcsToJson( + bytes: Uint8Array, + eventType: string, +): Record | null { + const entry = DECODERS_BY_SUFFIX.find(([suffix]) => + eventType.endsWith(suffix), + ) + if (!entry) return null + try { + return entry[1](bytes) + } catch { + return null + } +} diff --git a/packages/libs/dapp-kit/utils/events/fuelEventBcs.ts b/packages/libs/dapp-kit/utils/events/fuelEventBcs.ts index 0e57681..c354ee5 100644 --- a/packages/libs/dapp-kit/utils/events/fuelEventBcs.ts +++ b/packages/libs/dapp-kit/utils/events/fuelEventBcs.ts @@ -21,19 +21,9 @@ const FuelMoveEvent = bcs.struct('FuelEvent', { action: BcsAction, }) -// ---------------------------------------------------------------------------- - -export type DecodedFuelMoveEvent = { - assembly_id: string - assembly_key: { item_id: string; tenant: string } - type_id: string - old_quantity: string - new_quantity: string - is_burning: boolean - action: unknown -} - -export function decodeFuelEventBcs(bytes: Uint8Array): DecodedFuelMoveEvent { +export function decodeFuelEventBcsToJson( + bytes: Uint8Array, +): Record { const decoded = FuelMoveEvent.parse(bytes) return { assembly_id: decoded.assembly_id, @@ -48,17 +38,3 @@ export function decodeFuelEventBcs(bytes: Uint8Array): DecodedFuelMoveEvent { action: decoded.action, } } - -export function fuelEventBcsToParsedJson( - decoded: DecodedFuelMoveEvent, -): Record { - return { - assembly_id: decoded.assembly_id, - assembly_key: decoded.assembly_key, - type_id: decoded.type_id, - old_quantity: decoded.old_quantity, - new_quantity: decoded.new_quantity, - is_burning: decoded.is_burning, - action: decoded.action, - } -} diff --git a/packages/libs/dapp-kit/utils/events/inventoryEventBcs.ts b/packages/libs/dapp-kit/utils/events/inventoryEventBcs.ts index 5c23d71..83ad0e4 100644 --- a/packages/libs/dapp-kit/utils/events/inventoryEventBcs.ts +++ b/packages/libs/dapp-kit/utils/events/inventoryEventBcs.ts @@ -1,4 +1,5 @@ import { bcs } from '@mysten/sui/bcs' + import { BcsObjectId, TenantKey } from './consts' const InventoryMoveEvent = bcs.struct('InventoryMoveEvent', { @@ -11,29 +12,10 @@ const InventoryMoveEvent = bcs.struct('InventoryMoveEvent', { quantity: bcs.u32(), }) -// ---------------------------------------------------------------------------- - -export type DecodedInventoryMoveEvent = { - assembly_id: string - assembly_key: { - item_id: string - tenant: string - } - character_id: string - character_key: { - item_id: string - tenant: string - } - item_id: string - type_id: string - quantity: number -} - -export function decodeInventoryEventBcs( +export function decodeInventoryEventBcsToJson( bytes: Uint8Array, -): DecodedInventoryMoveEvent { +): Record { const decoded = InventoryMoveEvent.parse(bytes) - return { assembly_id: decoded.assembly_id, assembly_key: { @@ -50,17 +32,3 @@ export function decodeInventoryEventBcs( quantity: Number(decoded.quantity), } } - -export function inventoryEventBcsToParsedJson( - decoded: DecodedInventoryMoveEvent, -): Record { - return { - assembly_id: decoded.assembly_id, - assembly_key: decoded.assembly_key, - character_id: decoded.character_id, - character_key: decoded.character_key, - item_id: decoded.item_id, - quantity: decoded.quantity, - type_id: decoded.type_id, - } -} diff --git a/packages/libs/dapp-kit/utils/events/statusEventBcs.ts b/packages/libs/dapp-kit/utils/events/statusEventBcs.ts new file mode 100644 index 0000000..ae79b63 --- /dev/null +++ b/packages/libs/dapp-kit/utils/events/statusEventBcs.ts @@ -0,0 +1,38 @@ +import { bcs } from '@mysten/sui/bcs' + +import { BcsObjectId, TenantKey } from './consts' + +const BcsStatus = bcs.enum('AssemblyStatus', { + NULL: null, + OFFLINE: null, + ONLINE: null, +}) + +const BcsStatusAction = bcs.enum('AssemblyStatusAction', { + ANCHORED: null, + ONLINE: null, + OFFLINE: null, + UNANCHORED: null, +}) + +const StatusChangedMoveEvent = bcs.struct('StatusChangedEvent', { + assembly_id: BcsObjectId, + assembly_key: TenantKey, + status: BcsStatus, + action: BcsStatusAction, +}) + +export function decodeStatusChangedEventBcsToJson( + bytes: Uint8Array, +): Record { + const decoded = StatusChangedMoveEvent.parse(bytes) + return { + assembly_id: decoded.assembly_id, + assembly_key: { + item_id: String(decoded.assembly_key.item_id), + tenant: decoded.assembly_key.tenant, + }, + status: decoded.status, + action: decoded.action, + } +} diff --git a/packages/libs/dapp-kit/utils/events/statusEventHandlers.ts b/packages/libs/dapp-kit/utils/events/statusEventHandlers.ts new file mode 100644 index 0000000..e6a0a44 --- /dev/null +++ b/packages/libs/dapp-kit/utils/events/statusEventHandlers.ts @@ -0,0 +1,68 @@ +import type { SuiEvent } from '@mysten/sui/jsonRpc' + +import { Assemblies, type AssemblyType, State } from '../../types' +import { getEveWorldPackageId } from '../constants' +import { isRecord, normalizeObjectId } from '../utils' + +type StatusEventPayload = { + assembly_id?: string + assembly_key?: { item_id?: string | number; tenant?: string } + status?: unknown + action?: unknown +} + +export type StatusEventTarget = { + eventTypes: readonly string[] + objectId: string +} + +function parseStatusEventPayload( + event: Pick, +): StatusEventPayload | null { + if (!isRecord(event.parsedJson)) return null + return event.parsedJson as StatusEventPayload +} + +function statusKindToState(status: unknown): State | null { + if (!isRecord(status)) return null + const kind = (status as Record)['$kind'] + if (kind === 'ONLINE') return State.ONLINE + if (kind === 'OFFLINE') return State.ANCHORED + return null +} + +export function getStatusEventType(packageId = getEveWorldPackageId()): string { + return `${packageId}::status::StatusChangedEvent` +} + +export function getStatusEventTarget( + assembly: AssemblyType | null, + eventTypes: readonly string[], +): StatusEventTarget | null { + if (!assembly?.id) return null + return { eventTypes, objectId: assembly.id } +} + +export function isRelevantStatusEvent( + event: Pick, + target: StatusEventTarget, +): boolean { + if (!target.eventTypes.includes(event.type)) return false + const payload = parseStatusEventPayload(event) + return ( + normalizeObjectId(payload?.assembly_id) === + normalizeObjectId(target.objectId) + ) +} + +export function applyStatusEventToAssembly( + assembly: AssemblyType | null, + event: Pick, +): AssemblyType | null { + if (!assembly) return assembly + const payload = parseStatusEventPayload(event) + if (!payload) return assembly + const nextState = statusKindToState(payload.status) + if (!nextState) return assembly + return { ...assembly, state: nextState } +} From 7d2995584abf77571e190603ffa1fba603822ca0 Mon Sep 17 00:00:00 2001 From: ccp sondheim Date: Thu, 25 Jun 2026 18:02:13 +0000 Subject: [PATCH 2/2] address pr comments --- .../providers/SmartObjectProvider.tsx | 5 - .../__tests__/statusEventHandlers.test.ts | 139 ++++++++++++++++++ 2 files changed, 139 insertions(+), 5 deletions(-) create mode 100644 packages/libs/dapp-kit/utils/events/__tests__/statusEventHandlers.test.ts diff --git a/packages/libs/dapp-kit/providers/SmartObjectProvider.tsx b/packages/libs/dapp-kit/providers/SmartObjectProvider.tsx index 2e241f0..064eb02 100644 --- a/packages/libs/dapp-kit/providers/SmartObjectProvider.tsx +++ b/packages/libs/dapp-kit/providers/SmartObjectProvider.tsx @@ -511,11 +511,6 @@ const SmartObjectProvider = ({ children }: { children: ReactNode }) => { (asm, event) => applyStatusEventToAssembly(asm, event), next, ) - // Update confirmed state so the stale-refetch guard tracks the - // new optimistic value rather than reverting it on the next poll. - if (next?.state !== undefined) { - lastConfirmedStateRef.current = next.state - } return next }) triggerRefetch() diff --git a/packages/libs/dapp-kit/utils/events/__tests__/statusEventHandlers.test.ts b/packages/libs/dapp-kit/utils/events/__tests__/statusEventHandlers.test.ts new file mode 100644 index 0000000..ebc58ac --- /dev/null +++ b/packages/libs/dapp-kit/utils/events/__tests__/statusEventHandlers.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from 'vitest' +import { Assemblies, type AssemblyType, State } from '../../../types' +import { + applyStatusEventToAssembly, + getStatusEventTarget, + getStatusEventType, + isRelevantStatusEvent, +} from '../statusEventHandlers' + +const PACKAGE_ID = + '0x28b497559d65ab320d9da4613bf2498d5946b2c0ae3597ccfda3072ce127448c' +const NODE_OBJECT_ID = + '0x34d08b4e1afe6a4babcc0642d6a676160df6b777b49214d5c964b4e874cc951b' +const OTHER_OBJECT_ID = + '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' + +const STATUS_EVENT_TYPE = `${PACKAGE_ID}::status::StatusChangedEvent` + +function createAssembly( + state = State.ANCHORED, +): AssemblyType { + return { + type: Assemblies.NetworkNode, + id: NODE_OBJECT_ID, + state, + } as unknown as AssemblyType +} + +function createStatusEvent(assemblyId: string, statusKind: string) { + return { + type: STATUS_EVENT_TYPE, + parsedJson: { + assembly_id: assemblyId, + assembly_key: { item_id: '1', tenant: 'stillness' }, + status: { $kind: statusKind, [statusKind]: true }, + action: { $kind: 'ANCHORED', ANCHORED: true }, + }, + } +} + +describe('getStatusEventType', () => { + it('returns the correct event type string', () => { + expect(getStatusEventType(PACKAGE_ID)).toBe( + `${PACKAGE_ID}::status::StatusChangedEvent`, + ) + }) +}) + +describe('getStatusEventTarget', () => { + it('returns target for an assembly with an id', () => { + const assembly = createAssembly() + const target = getStatusEventTarget(assembly, [STATUS_EVENT_TYPE]) + expect(target).toEqual({ + eventTypes: [STATUS_EVENT_TYPE], + objectId: NODE_OBJECT_ID, + }) + }) + + it('returns null for null assembly', () => { + expect(getStatusEventTarget(null, [STATUS_EVENT_TYPE])).toBeNull() + }) +}) + +describe('isRelevantStatusEvent', () => { + const target = { eventTypes: [STATUS_EVENT_TYPE], objectId: NODE_OBJECT_ID } + + it('matches an event for the correct assembly', () => { + const event = createStatusEvent(NODE_OBJECT_ID, 'ONLINE') + expect(isRelevantStatusEvent(event, target)).toBe(true) + }) + + it('rejects an event for a different assembly', () => { + const event = createStatusEvent(OTHER_OBJECT_ID, 'ONLINE') + expect(isRelevantStatusEvent(event, target)).toBe(false) + }) + + it('rejects an event with a different type', () => { + const event = { + type: `${PACKAGE_ID}::fuel::FuelEvent`, + parsedJson: { assembly_id: NODE_OBJECT_ID }, + } + expect(isRelevantStatusEvent(event, target)).toBe(false) + }) + + it('matches case-insensitively on assembly_id', () => { + const event = createStatusEvent(NODE_OBJECT_ID.toUpperCase(), 'ONLINE') + expect(isRelevantStatusEvent(event, target)).toBe(true) + }) +}) + +describe('applyStatusEventToAssembly', () => { + it('applies ONLINE status to the assembly', () => { + const assembly = createAssembly(State.ANCHORED) + const event = createStatusEvent(NODE_OBJECT_ID, 'ONLINE') + const result = applyStatusEventToAssembly(assembly, event) + expect(result?.state).toBe(State.ONLINE) + }) + + it('applies OFFLINE status as ANCHORED state', () => { + const assembly = createAssembly(State.ONLINE) + const event = createStatusEvent(NODE_OBJECT_ID, 'OFFLINE') + const result = applyStatusEventToAssembly(assembly, event) + expect(result?.state).toBe(State.ANCHORED) + }) + + it('preserves other assembly fields unchanged', () => { + const assembly = createAssembly(State.ANCHORED) + const event = createStatusEvent(NODE_OBJECT_ID, 'ONLINE') + const result = applyStatusEventToAssembly(assembly, event) + expect(result?.type).toBe(Assemblies.NetworkNode) + expect(result?.id).toBe(NODE_OBJECT_ID) + }) + + it('is a no-op for an unknown status kind', () => { + const assembly = createAssembly(State.ANCHORED) + const event = { + type: STATUS_EVENT_TYPE, + parsedJson: { + assembly_id: NODE_OBJECT_ID, + status: { $kind: 'NULL', NULL: true }, + }, + } + expect(applyStatusEventToAssembly(assembly, event)).toBe(assembly) + }) + + it('is a no-op for null assembly', () => { + const event = createStatusEvent(NODE_OBJECT_ID, 'ONLINE') + expect(applyStatusEventToAssembly(null, event)).toBeNull() + }) + + it('is a no-op when status is missing', () => { + const assembly = createAssembly(State.ANCHORED) + const event = { + type: STATUS_EVENT_TYPE, + parsedJson: { assembly_id: NODE_OBJECT_ID }, + } + expect(applyStatusEventToAssembly(assembly, event)).toBe(assembly) + }) +})