From 74839afb6d786f8b824ff7918e51b4248f214899 Mon Sep 17 00:00:00 2001 From: ccp_raudur Date: Tue, 16 Jun 2026 09:45:17 +0000 Subject: [PATCH 1/6] feat(dapp-kit): stream inventory updates via gRPC checkpoints Replace event polling with SubscribeCheckpoints for ItemMinted/Burned events, with optimistic UI updates, staggered GraphQL refetch, and 10s poll fallback. Co-authored-by: Cursor --- packages/libs/dapp-kit/config/dapp-kit.ts | 11 +- .../providers/SmartObjectProvider.tsx | 131 +- .../providers/__tests__/eventRefresh.test.ts | 1053 +++++++++++++++++ .../libs/dapp-kit/providers/eventRefresh.ts | 762 ++++++++++++ .../utils/__tests__/inventory.test.ts | 54 + .../utils/__tests__/inventoryEventBcs.test.ts | 49 + .../utils/__tests__/transforms.test.ts | 53 +- packages/libs/dapp-kit/utils/constants.ts | 21 + packages/libs/dapp-kit/utils/inventory.ts | 52 + .../libs/dapp-kit/utils/inventoryEventBcs.ts | 74 ++ packages/libs/dapp-kit/utils/transforms.ts | 3 +- 11 files changed, 2253 insertions(+), 10 deletions(-) create mode 100644 packages/libs/dapp-kit/providers/__tests__/eventRefresh.test.ts create mode 100644 packages/libs/dapp-kit/providers/eventRefresh.ts create mode 100644 packages/libs/dapp-kit/utils/__tests__/inventory.test.ts create mode 100644 packages/libs/dapp-kit/utils/__tests__/inventoryEventBcs.test.ts create mode 100644 packages/libs/dapp-kit/utils/inventory.ts create mode 100644 packages/libs/dapp-kit/utils/inventoryEventBcs.ts diff --git a/packages/libs/dapp-kit/config/dapp-kit.ts b/packages/libs/dapp-kit/config/dapp-kit.ts index c7f9658..8a7f4a9 100644 --- a/packages/libs/dapp-kit/config/dapp-kit.ts +++ b/packages/libs/dapp-kit/config/dapp-kit.ts @@ -1,13 +1,10 @@ import { createDAppKit } from '@mysten/dapp-kit-react' import { SuiGrpcClient } from '@mysten/sui/grpc' -const GRPC_URLS = { - testnet: 'https://fullnode.testnet.sui.io:443', - devnet: 'https://fullnode.devnet.sui.io:443', -} +import { SUI_GRPC_URLS } from '../utils/constants' -type SupportedNetwork = keyof typeof GRPC_URLS -const SUPPORTED_NETWORKS = Object.keys(GRPC_URLS) as SupportedNetwork[] +type SupportedNetwork = keyof typeof SUI_GRPC_URLS +const SUPPORTED_NETWORKS = Object.keys(SUI_GRPC_URLS) as SupportedNetwork[] /** DApp Kit instance for Sui wallet and network. @category Config */ export const dAppKit = createDAppKit({ @@ -15,7 +12,7 @@ export const dAppKit = createDAppKit({ createClient(network) { return new SuiGrpcClient({ network, - baseUrl: GRPC_URLS[network as keyof typeof GRPC_URLS], + baseUrl: SUI_GRPC_URLS[network as keyof typeof SUI_GRPC_URLS], }) }, }) diff --git a/packages/libs/dapp-kit/providers/SmartObjectProvider.tsx b/packages/libs/dapp-kit/providers/SmartObjectProvider.tsx index 814daa4..051b2fd 100644 --- a/packages/libs/dapp-kit/providers/SmartObjectProvider.tsx +++ b/packages/libs/dapp-kit/providers/SmartObjectProvider.tsx @@ -17,6 +17,15 @@ import { } from '../utils' import { DEFAULT_TENANT, POLLING_INTERVAL } from '../utils/constants' import { getDatahubGameInfo } from '../utils/datahub' +import { areInventoryItemListsEqual } from '../utils/inventory' +import { + applyInventoryEventToAssembly, + createEventRefetchScheduler, + type EventUnsubscribe, + getInventoryEventTypes, + isRelevantAssemblyInventoryEvent, + subscribeToInventoryEvents, +} from './eventRefresh' const log = createLogger() @@ -37,6 +46,40 @@ export const SmartObjectContext = createContext({ refetch: async () => {}, }) +function preserveStorageInventoryItemsWhenEqual( + currentAssembly: AssemblyType | null, + nextAssembly: AssemblyType | null, +): AssemblyType | null { + if ( + !currentAssembly || + !nextAssembly || + currentAssembly.type !== Assemblies.SmartStorageUnit || + nextAssembly.type !== Assemblies.SmartStorageUnit + ) { + return nextAssembly + } + + if ( + !areInventoryItemListsEqual( + currentAssembly.storage.mainInventory.items, + nextAssembly.storage.mainInventory.items, + ) + ) { + return nextAssembly + } + + return { + ...nextAssembly, + storage: { + ...nextAssembly.storage, + mainInventory: { + ...nextAssembly.storage.mainInventory, + items: currentAssembly.storage.mainInventory.items, + }, + }, + } +} + /** * SmartObjectProvider component provides context for smart objects data. * It uses GraphQL queries to fetch objects on the Sui blockchain @@ -152,7 +195,13 @@ const SmartObjectProvider = ({ children }: { children: ReactNode }) => { }, ) - setAssembly(transformed) + setAssembly((currentAssembly) => { + if (isInitialFetch) return transformed + return preserveStorageInventoryItemsWhenEqual( + currentAssembly, + transformed, + ) + }) // Transform and set assemblyOwner: owner of the assembly if (characterInfo) { @@ -251,6 +300,86 @@ const SmartObjectProvider = ({ children }: { children: ReactNode }) => { fetchObjectData, ]) + // Listen for inventory events via gRPC checkpoint stream. GraphQL polling above + // remains the fallback/source of truth if stream events are missed or unavailable. + useEffect(() => { + if (!selectedObjectId || !isConnected) return + + const input: FetchObjectDataInput = isObjectIdDirect + ? { objectId: selectedObjectId } + : { itemId: selectedObjectId, selectedTenant } + const eventTypes = getInventoryEventTypes() + const abortController = new AbortController() + const triggerRefetch = createEventRefetchScheduler( + () => fetchObjectData(input, false), + undefined, + (error) => { + log.warn( + '[DappKit] SmartObjectProvider: Event-triggered refetch failed:', + error, + ) + }, + ) + let unsubscribe: EventUnsubscribe | null = null + + subscribeToInventoryEvents({ + eventTypes, + signal: abortController.signal, + onGap: () => { + triggerRefetch() + }, + onEvents: (events) => { + const relevantEvents = events.filter((event) => + isRelevantAssemblyInventoryEvent(event, { + eventTypes, + ...(isObjectIdDirect ? { objectId: selectedObjectId } : {}), + ...(!isObjectIdDirect ? { itemId: selectedObjectId } : {}), + tenant: selectedTenant, + }), + ) + + if (relevantEvents.length > 0) { + setAssembly((currentAssembly) => + relevantEvents.reduce( + (updatedAssembly, event) => + applyInventoryEventToAssembly(updatedAssembly, event), + currentAssembly, + ), + ) + triggerRefetch() + } + }, + }) + .then((eventUnsubscribe) => { + if (abortController.signal.aborted) { + void eventUnsubscribe() + return + } + unsubscribe = eventUnsubscribe + }) + .catch((error) => { + if (abortController.signal.aborted) return + log.warn( + '[DappKit] SmartObjectProvider: Inventory checkpoint stream unavailable; polling remains active:', + error, + ) + }) + + return () => { + abortController.abort() + triggerRefetch.cancel() + if (unsubscribe) { + void unsubscribe() + } + } + }, [ + selectedObjectId, + selectedTenant, + isObjectIdDirect, + isConnected, + fetchObjectData, + ]) + const handleRefetch = useCallback(async () => { if (!selectedObjectId) return const input: FetchObjectDataInput = isObjectIdDirect diff --git a/packages/libs/dapp-kit/providers/__tests__/eventRefresh.test.ts b/packages/libs/dapp-kit/providers/__tests__/eventRefresh.test.ts new file mode 100644 index 0000000..2b9c4d7 --- /dev/null +++ b/packages/libs/dapp-kit/providers/__tests__/eventRefresh.test.ts @@ -0,0 +1,1053 @@ +import { describe, expect, it, vi } from 'vitest' +import { Assemblies, type AssemblyType } from '../../types' +import { + applyInventoryEventToAssembly, + type CheckpointStreamMessage, + createEventRefetchScheduler, + createInventoryCheckpointStream, + extractInventoryEventsFromCheckpoint, + getInventoryEventTypes, + isRelevantAssemblyInventoryEvent, +} from '../eventRefresh' + +const PACKAGE_ID = + '0x28b497559d65ab320d9da4613bf2498d5946b2c0ae3597ccfda3072ce127448c' +const ASSEMBLY_OBJECT_ID = + '0x34d08b4e1afe6a4babcc0642d6a676160df6b777b49214d5c964b4e874cc951b' + +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 +} + +function createStorageAssembly( + quantity = 20, +): AssemblyType { + return { + type: Assemblies.SmartStorageUnit, + storage: { + mainInventory: { + capacity: '1000000', + usedCapacity: '1000', + items: [ + { + id: 'existing-item', + item_id: 'existing-item', + location: { location_hash: 'main' }, + quantity, + tenant: 'stillness', + type_id: 77810, + name: 'Existing Item', + }, + ], + }, + ephemeralInventories: [], + }, + } as unknown as AssemblyType +} + +function expectStorageAssembly( + assembly: AssemblyType | null, +): AssemblyType { + expect(assembly?.type).toBe(Assemblies.SmartStorageUnit) + return assembly as AssemblyType +} + +function createUnsortedStorageAssembly(): AssemblyType { + const assembly = createStorageAssembly() + assembly.storage.mainInventory.items = [ + { + id: 'type-88082', + item_id: 'type-88082', + location: { location_hash: 'main' }, + quantity: 10, + tenant: 'stillness', + type_id: 88082, + name: 'High Type', + }, + { + id: 'type-77810', + item_id: 'type-77810', + location: { location_hash: 'main' }, + quantity: 20, + tenant: 'stillness', + type_id: 77810, + name: 'Low Type', + }, + ] + return assembly +} + +describe('event refresh helpers', () => { + it('subscribes to inventory burn and mint events', () => { + expect(getInventoryEventTypes(PACKAGE_ID)).toEqual([ + `${PACKAGE_ID}::inventory::ItemBurnedEvent`, + `${PACKAGE_ID}::inventory::ItemMintedEvent`, + ]) + }) + + it('matches inventory events for the selected Sui assembly object id', () => { + const event = { + type: `${PACKAGE_ID}::inventory::ItemBurnedEvent`, + parsedJson: { + assembly_id: ASSEMBLY_OBJECT_ID, + assembly_key: { + item_id: '1000001842554', + tenant: 'stillness', + }, + }, + } + + expect( + isRelevantAssemblyInventoryEvent(event, { + objectId: ASSEMBLY_OBJECT_ID, + eventTypes: getInventoryEventTypes(PACKAGE_ID), + }), + ).toBe(true) + }) + + it('matches inventory events for the selected itemId and tenant', () => { + const event = { + type: `${PACKAGE_ID}::inventory::ItemBurnedEvent`, + parsedJson: { + assembly_id: ASSEMBLY_OBJECT_ID, + assembly_key: { + item_id: '1000001842554', + tenant: 'stillness', + }, + }, + } + + expect( + isRelevantAssemblyInventoryEvent(event, { + itemId: '1000001842554', + tenant: 'stillness', + eventTypes: getInventoryEventTypes(PACKAGE_ID), + }), + ).toBe(true) + }) + + it('matches minted inventory events for deposits', () => { + const event = { + type: `${PACKAGE_ID}::inventory::ItemMintedEvent`, + parsedJson: { + assembly_id: ASSEMBLY_OBJECT_ID, + assembly_key: { + item_id: '1000001842554', + tenant: 'stillness', + }, + character_id: + '0xa60609a1b94ffca8ed2daf4963a2b9deffce23de76ef9f3d040d7250edb7b2c7', + character_key: { + item_id: '2112077441', + tenant: 'stillness', + }, + item_id: '0', + quantity: 500, + type_id: '77810', + }, + } + + expect( + isRelevantAssemblyInventoryEvent(event, { + itemId: '1000001842554', + tenant: 'stillness', + eventTypes: getInventoryEventTypes(PACKAGE_ID), + }), + ).toBe(true) + }) + + it('ignores inventory events for other assemblies or tenants', () => { + const event = { + type: `${PACKAGE_ID}::inventory::ItemBurnedEvent`, + parsedJson: { + assembly_id: ASSEMBLY_OBJECT_ID, + assembly_key: { + item_id: '1000001842554', + tenant: 'stillness', + }, + }, + } + + expect( + isRelevantAssemblyInventoryEvent(event, { + itemId: '1000001842554', + tenant: 'nebula', + eventTypes: getInventoryEventTypes(PACKAGE_ID), + }), + ).toBe(false) + expect( + isRelevantAssemblyInventoryEvent(event, { + objectId: '0x111', + eventTypes: getInventoryEventTypes(PACKAGE_ID), + }), + ).toBe(false) + }) + + it('ignores non-inventory event types', () => { + const event = { + type: `${PACKAGE_ID}::storage_unit::SomeOtherEvent`, + parsedJson: { + assembly_id: ASSEMBLY_OBJECT_ID, + }, + } + + expect( + isRelevantAssemblyInventoryEvent(event, { + objectId: ASSEMBLY_OBJECT_ID, + eventTypes: getInventoryEventTypes(PACKAGE_ID), + }), + ).toBe(false) + }) + + it('optimistically adds minted item quantities by type id', () => { + const assembly = createStorageAssembly() + const event = { + type: `${PACKAGE_ID}::inventory::ItemMintedEvent`, + parsedJson: { + quantity: 500, + type_id: '77810', + }, + } + + const updated = expectStorageAssembly( + applyInventoryEventToAssembly(assembly, event), + ) + + expect(updated.storage.mainInventory.items).toEqual([ + expect.objectContaining({ type_id: 77810, quantity: 520 }), + ]) + expect(assembly.storage.mainInventory.items[0]?.quantity).toBe(20) + }) + + it('merges duplicate rows when minting an existing item type', () => { + const assembly = createStorageAssembly() + assembly.storage.mainInventory.items = [ + { + id: 'existing-item', + item_id: 'existing-item', + location: { location_hash: 'main' }, + quantity: 20, + tenant: 'stillness', + type_id: 77810, + name: 'Existing Item', + }, + { + id: 'optimistic-77810', + item_id: '0', + location: { location_hash: '' }, + quantity: 500, + tenant: 'stillness', + type_id: 77810, + name: 'Type 77810', + }, + ] + const event = { + type: `${PACKAGE_ID}::inventory::ItemMintedEvent`, + parsedJson: { + quantity: 10, + type_id: '77810', + }, + } + + const updated = expectStorageAssembly( + applyInventoryEventToAssembly(assembly, event), + ) + + expect(updated.storage.mainInventory.items).toHaveLength(1) + expect(updated.storage.mainInventory.items[0]).toEqual( + expect.objectContaining({ + id: 'existing-item', + name: 'Existing Item', + quantity: 530, + type_id: 77810, + }), + ) + }) + + it('optimistically subtracts burned item quantities by type id', () => { + const assembly = createStorageAssembly(20) + const event = { + type: `${PACKAGE_ID}::inventory::ItemBurnedEvent`, + parsedJson: { + quantity: 5, + type_id: '77810', + }, + } + + const updated = expectStorageAssembly( + applyInventoryEventToAssembly(assembly, event), + ) + + expect(updated.storage.mainInventory.items).toEqual([ + expect.objectContaining({ type_id: 77810, quantity: 15 }), + ]) + }) + + it('optimistically removes burned item rows when quantity reaches zero', () => { + const assembly = createStorageAssembly(20) + const event = { + type: `${PACKAGE_ID}::inventory::ItemBurnedEvent`, + parsedJson: { + quantity: 20, + type_id: '77810', + }, + } + + const updated = expectStorageAssembly( + applyInventoryEventToAssembly(assembly, event), + ) + + expect(updated.storage.mainInventory.items).toEqual([]) + }) + + it('optimistically removes burned item rows when GraphQL item ids are strings', () => { + const assembly = createStorageAssembly(20) + assembly.storage.mainInventory.items = [ + { + id: 'string-item', + item_id: 'string-item', + location: { location_hash: 'main' }, + quantity: '20', + tenant: 'stillness', + type_id: '77810', + name: 'String Item', + }, + ] as unknown as typeof assembly.storage.mainInventory.items + const event = { + type: `${PACKAGE_ID}::inventory::ItemBurnedEvent`, + parsedJson: { + quantity: 20, + type_id: '77810', + }, + } + + const updated = expectStorageAssembly( + applyInventoryEventToAssembly(assembly, event), + ) + + expect(updated.storage.mainInventory.items).toEqual([]) + }) + + it('optimistically adds a placeholder row for a new minted item type', () => { + const assembly = createStorageAssembly() + const event = { + type: `${PACKAGE_ID}::inventory::ItemMintedEvent`, + parsedJson: { + assembly_key: { + item_id: '1000001842554', + tenant: 'stillness', + }, + item_id: '0', + quantity: 500, + type_id: '88082', + }, + } + + const updated = expectStorageAssembly( + applyInventoryEventToAssembly(assembly, event), + ) + + expect(updated.storage.mainInventory.items).toEqual([ + expect.objectContaining({ + item_id: '0', + quantity: 500, + tenant: 'stillness', + type_id: 88082, + name: 'Type 88082', + }), + expect.objectContaining({ type_id: 77810, quantity: 20 }), + ]) + }) + + it('orders optimistically updated inventory rows by quantity', () => { + const assembly = createUnsortedStorageAssembly() + const event = { + type: `${PACKAGE_ID}::inventory::ItemMintedEvent`, + parsedJson: { + assembly_key: { + item_id: '1000001842554', + tenant: 'stillness', + }, + item_id: '0', + quantity: 500, + type_id: '82128', + }, + } + + const updated = expectStorageAssembly( + applyInventoryEventToAssembly(assembly, event), + ) + + expect( + updated.storage.mainInventory.items.map((item) => item.type_id), + ).toEqual([82128, 77810, 88082]) + }) + + it('schedules multiple event-driven refetch attempts', async () => { + vi.useFakeTimers() + const refetch = vi.fn().mockResolvedValue(undefined) + const scheduledRefetch = createEventRefetchScheduler(refetch, [100, 300]) + + scheduledRefetch() + + await vi.advanceTimersByTimeAsync(99) + expect(refetch).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(1) + expect(refetch).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(200) + expect(refetch).toHaveBeenCalledTimes(2) + + vi.useRealTimers() + }) + + it('reschedules pending event-driven refetch attempts for event bursts', async () => { + vi.useFakeTimers() + const refetch = vi.fn().mockResolvedValue(undefined) + const scheduledRefetch = createEventRefetchScheduler(refetch, [100, 300]) + + scheduledRefetch() + await vi.advanceTimersByTimeAsync(50) + scheduledRefetch() + + await vi.advanceTimersByTimeAsync(99) + expect(refetch).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(1) + expect(refetch).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(200) + expect(refetch).toHaveBeenCalledTimes(2) + + vi.useRealTimers() + }) + + it('streams inventory events without replaying the first checkpoint on startup', async () => { + const historicalCheckpoint: CheckpointStreamMessage = { + checkpoint: { + sequenceNumber: 1, + transactions: [ + { + digest: 'old', + events: { + events: [ + { + eventType: `${PACKAGE_ID}::inventory::ItemBurnedEvent`, + json: { + kind: { + oneofKind: 'structValue', + structValue: { + fields: { + assembly_id: { + kind: { + oneofKind: 'stringValue', + stringValue: ASSEMBLY_OBJECT_ID, + }, + }, + }, + }, + }, + }, + }, + ], + }, + }, + ], + }, + } + const liveCheckpoint: CheckpointStreamMessage = { + checkpoint: { + sequenceNumber: 2, + transactions: [ + { + digest: 'new', + events: { + events: [ + { + eventType: `${PACKAGE_ID}::inventory::ItemBurnedEvent`, + json: { + kind: { + oneofKind: 'structValue', + structValue: { + fields: { + assembly_id: { + kind: { + oneofKind: 'stringValue', + stringValue: ASSEMBLY_OBJECT_ID, + }, + }, + }, + }, + }, + }, + }, + ], + }, + }, + ], + }, + } + const onEvents = vi.fn() + + let releaseStream: (() => void) | undefined + const cancel = vi.fn(() => { + releaseStream?.() + }) + + function subscribeCheckpoints() { + return { + responses: (async function* () { + yield historicalCheckpoint + yield liveCheckpoint + await new Promise((resolve) => { + releaseStream = resolve + }) + })(), + cancel, + } + } + + const stop = createInventoryCheckpointStream({ + eventTypes: getInventoryEventTypes(PACKAGE_ID), + idleMs: 0, + maxSessionMs: 0, + onEvents, + subscribeCheckpoints, + }) + + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(onEvents).toHaveBeenCalledTimes(1) + expect(onEvents).toHaveBeenCalledWith([ + expect.objectContaining({ + id: { txDigest: 'new', eventSeq: '0' }, + type: `${PACKAGE_ID}::inventory::ItemBurnedEvent`, + }), + ]) + + await stop() + }) + + it('delivers inventory events from a checkpoint as one batch', async () => { + const checkpoint: CheckpointStreamMessage = { + checkpoint: { + sequenceNumber: 2, + transactions: [ + { + digest: 'new', + events: { + events: [ + { + eventType: `${PACKAGE_ID}::inventory::ItemBurnedEvent`, + json: { + kind: { + oneofKind: 'structValue', + structValue: { + fields: { + assembly_id: { + kind: { + oneofKind: 'stringValue', + stringValue: ASSEMBLY_OBJECT_ID, + }, + }, + }, + }, + }, + }, + }, + { + eventType: `${PACKAGE_ID}::inventory::ItemMintedEvent`, + json: { + kind: { + oneofKind: 'structValue', + structValue: { + fields: { + assembly_id: { + kind: { + oneofKind: 'stringValue', + stringValue: ASSEMBLY_OBJECT_ID, + }, + }, + }, + }, + }, + }, + }, + { + eventType: `${PACKAGE_ID}::storage_unit::SomeOtherEvent`, + json: { + kind: { + oneofKind: 'structValue', + structValue: { + fields: { + assembly_id: { + kind: { + oneofKind: 'stringValue', + stringValue: ASSEMBLY_OBJECT_ID, + }, + }, + }, + }, + }, + }, + }, + ], + }, + }, + ], + }, + } + const onEvents = vi.fn() + + let releaseStream: (() => void) | undefined + const cancel = vi.fn(() => { + releaseStream?.() + }) + + function subscribeCheckpoints() { + return { + responses: (async function* () { + yield { checkpoint: { sequenceNumber: 1, transactions: [] } } + yield checkpoint + await new Promise((resolve) => { + releaseStream = resolve + }) + })(), + cancel, + } + } + + const stop = createInventoryCheckpointStream({ + eventTypes: getInventoryEventTypes(PACKAGE_ID), + idleMs: 0, + maxSessionMs: 0, + onEvents, + subscribeCheckpoints, + }) + + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(onEvents).toHaveBeenCalledTimes(1) + expect(onEvents).toHaveBeenCalledWith([ + expect.objectContaining({ + type: `${PACKAGE_ID}::inventory::ItemBurnedEvent`, + }), + expect.objectContaining({ + type: `${PACKAGE_ID}::inventory::ItemMintedEvent`, + }), + ]) + + await stop() + }) + + it('reconnects after stream errors', async () => { + vi.useFakeTimers() + const onError = vi.fn() + const onEvents = vi.fn() + let session = 0 + + let releaseStream: (() => void) | undefined + + const stop = createInventoryCheckpointStream({ + eventTypes: getInventoryEventTypes(PACKAGE_ID), + reconnectMs: 100, + idleMs: 0, + maxSessionMs: 0, + onError, + onEvents, + subscribeCheckpoints: () => { + session += 1 + if (session === 1) { + return { + cancel: vi.fn(), + responses: (async function* () { + yield { checkpoint: { sequenceNumber: 1, transactions: [] } } + throw new Error('network error') + })(), + } + } + + return { + cancel: vi.fn(() => { + releaseStream?.() + }), + responses: (async function* () { + yield { + checkpoint: { + sequenceNumber: 2, + transactions: [ + { + digest: 'reconnected', + events: { + events: [ + { + eventType: `${PACKAGE_ID}::inventory::ItemBurnedEvent`, + json: { + kind: { + oneofKind: 'structValue', + structValue: { + fields: { + assembly_id: { + kind: { + oneofKind: 'stringValue', + stringValue: ASSEMBLY_OBJECT_ID, + }, + }, + }, + }, + }, + }, + }, + ], + }, + }, + ], + }, + } + await new Promise((resolve) => { + releaseStream = resolve + }) + })(), + } + }, + }) + + await vi.advanceTimersByTimeAsync(0) + expect(onError).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(100) + expect(onEvents).toHaveBeenCalledTimes(1) + + await stop() + vi.useRealTimers() + }) + + it('rotates sessions before the public fullnode stream timeout', async () => { + vi.useFakeTimers() + let session = 0 + let releaseStream: (() => void) | undefined + + const stop = createInventoryCheckpointStream({ + eventTypes: getInventoryEventTypes(PACKAGE_ID), + maxSessionMs: 100, + reconnectMs: 50, + idleMs: 0, + subscribeCheckpoints: () => { + session += 1 + return { + cancel: vi.fn(), + responses: (async function* () { + yield { checkpoint: { sequenceNumber: session, transactions: [] } } + await new Promise((resolve) => { + releaseStream = resolve + }) + })(), + } + }, + }) + + await vi.advanceTimersByTimeAsync(0) + expect(session).toBe(1) + + await vi.advanceTimersByTimeAsync(100) + await vi.advanceTimersByTimeAsync(50) + expect(session).toBe(2) + + releaseStream?.() + await stop() + vi.useRealTimers() + }) + + it('reconnects when a checkpoint read goes idle', async () => { + vi.useFakeTimers() + const onError = vi.fn() + let session = 0 + let releaseStream: (() => void) | undefined + + const stop = createInventoryCheckpointStream({ + eventTypes: getInventoryEventTypes(PACKAGE_ID), + idleMs: 100, + maxSessionMs: 0, + reconnectMs: 50, + onError, + subscribeCheckpoints: () => { + session += 1 + return { + cancel: vi.fn(), + responses: (async function* () { + yield { + checkpoint: { + sequenceNumber: session === 1 ? 1 : 3, + transactions: [], + }, + } + if (session === 1) { + yield { checkpoint: { sequenceNumber: 2, transactions: [] } } + } + await new Promise((resolve) => { + releaseStream = resolve + }) + })(), + } + }, + }) + + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(100) + expect(onError).toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(50) + expect(session).toBe(2) + + releaseStream?.() + await stop() + vi.useRealTimers() + }) + + it('backfills through onGap when checkpoint sequence jumps', async () => { + const onGap = vi.fn() + const onEvents = vi.fn() + const checkpoint: CheckpointStreamMessage = { + checkpoint: { + sequenceNumber: 5, + transactions: [ + { + digest: 'gap', + events: { + events: [ + { + eventType: `${PACKAGE_ID}::inventory::ItemBurnedEvent`, + json: { + kind: { + oneofKind: 'structValue', + structValue: { + fields: { + assembly_id: { + kind: { + oneofKind: 'stringValue', + stringValue: ASSEMBLY_OBJECT_ID, + }, + }, + }, + }, + }, + }, + }, + ], + }, + }, + ], + }, + } + + let releaseStream: (() => void) | undefined + const cancel = vi.fn(() => { + releaseStream?.() + }) + + const stop = createInventoryCheckpointStream({ + eventTypes: getInventoryEventTypes(PACKAGE_ID), + idleMs: 0, + maxSessionMs: 0, + reconnectMs: 0, + onGap, + onEvents, + subscribeCheckpoints: () => ({ + cancel, + responses: (async function* () { + yield { checkpoint: { sequenceNumber: 1, transactions: [] } } + yield checkpoint + await new Promise((resolve) => { + releaseStream = resolve + }) + })(), + }), + }) + + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(onGap).toHaveBeenCalledWith(1, 5) + expect(onEvents).toHaveBeenCalledTimes(1) + + releaseStream?.() + await stop() + }) + + it('deduplicates inventory events across reconnects', async () => { + const onEvents = vi.fn() + const checkpoint: CheckpointStreamMessage = { + checkpoint: { + sequenceNumber: 2, + transactions: [ + { + digest: 'shared', + events: { + events: [ + { + eventType: `${PACKAGE_ID}::inventory::ItemBurnedEvent`, + json: { + kind: { + oneofKind: 'structValue', + structValue: { + fields: { + assembly_id: { + kind: { + oneofKind: 'stringValue', + stringValue: ASSEMBLY_OBJECT_ID, + }, + }, + }, + }, + }, + }, + }, + ], + }, + }, + ], + }, + } + + let releaseStream: (() => void) | undefined + const cancel = vi.fn(() => { + releaseStream?.() + }) + + let session = 0 + const stop = createInventoryCheckpointStream({ + eventTypes: getInventoryEventTypes(PACKAGE_ID), + idleMs: 0, + maxSessionMs: 0, + reconnectMs: 0, + onEvents, + subscribeCheckpoints: () => { + session += 1 + return { + cancel, + responses: (async function* () { + if (session === 1) { + yield { checkpoint: { sequenceNumber: 1, transactions: [] } } + yield checkpoint + return + } + yield { checkpoint: { sequenceNumber: 3, transactions: [] } } + yield checkpoint + await new Promise((resolve) => { + releaseStream = resolve + }) + })(), + } + }, + }) + + await new Promise((resolve) => setTimeout(resolve, 0)) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(onEvents).toHaveBeenCalledTimes(1) + + releaseStream?.() + await stop() + }) + + it('extracts inventory events from checkpoint transactions', () => { + const events = extractInventoryEventsFromCheckpoint( + { + transactions: [ + { + digest: 'abc123', + events: { + events: [ + { + eventType: `${PACKAGE_ID}::inventory::ItemMintedEvent`, + json: { + kind: { + oneofKind: 'structValue', + structValue: { + fields: { + quantity: { + kind: { + oneofKind: 'numberValue', + numberValue: 500, + }, + }, + type_id: { + kind: { + oneofKind: 'stringValue', + stringValue: '77810', + }, + }, + }, + }, + }, + }, + }, + ], + }, + }, + ], + }, + getInventoryEventTypes(PACKAGE_ID), + ) + + expect(events).toEqual([ + { + id: { txDigest: 'abc123', eventSeq: '0' }, + type: `${PACKAGE_ID}::inventory::ItemMintedEvent`, + parsedJson: { + quantity: 500, + type_id: '77810', + }, + }, + ]) + }) + + it('extracts inventory events from gRPC BCS bytes when json is absent', () => { + const events = extractInventoryEventsFromCheckpoint( + { + transactions: [ + { + digest: 'abc123', + events: { + events: [ + { + eventType: `${PACKAGE_ID}::inventory::ItemMintedEvent`, + contents: { + value: hexToBytes( + '34d08b4e1afe6a4babcc0642d6a676160df6b777b49214d5c964b4e874cc951b7a2dc1d4e8000000097374696c6c6e657373a60609a1b94ffca8ed2daf4963a2b9deffce23de76ef9f3d040d7250edb7b2c781bee37d00000000097374696c6c6e6573730000000000000000f22f010000000000f4010000', + ), + }, + }, + ], + }, + }, + ], + }, + getInventoryEventTypes(PACKAGE_ID), + ) + + expect(events).toEqual([ + { + id: { txDigest: 'abc123', eventSeq: '0' }, + type: `${PACKAGE_ID}::inventory::ItemMintedEvent`, + parsedJson: { + assembly_id: + '0x34d08b4e1afe6a4babcc0642d6a676160df6b777b49214d5c964b4e874cc951b', + assembly_key: { + item_id: '1000001842554', + tenant: 'stillness', + }, + character_id: + '0xa60609a1b94ffca8ed2daf4963a2b9deffce23de76ef9f3d040d7250edb7b2c7', + character_key: { + item_id: '2112077441', + tenant: 'stillness', + }, + item_id: '0', + quantity: 500, + type_id: '77810', + }, + }, + ]) + }) +}) diff --git a/packages/libs/dapp-kit/providers/eventRefresh.ts b/packages/libs/dapp-kit/providers/eventRefresh.ts new file mode 100644 index 0000000..34dd9fa --- /dev/null +++ b/packages/libs/dapp-kit/providers/eventRefresh.ts @@ -0,0 +1,762 @@ +import { SuiGrpcClient } from '@mysten/sui/grpc' +import type { SuiEvent } from '@mysten/sui/jsonRpc' + +import { Assemblies, type AssemblyType, type InventoryItem } from '../types' +import { + DEFAULT_GRAPHQL_NETWORK, + getEveWorldPackageId, + getSuiGrpcBaseUrl, +} from '../utils/constants' +import { sortInventoryItemsByQuantity } from '../utils/inventory' +import { + decodeInventoryEventBcs, + inventoryEventBcsToParsedJson, +} from '../utils/inventoryEventBcs' +import { createLogger } from '../utils/logger' + +const log = createLogger() + +const INVENTORY_EVENT_NAMES = ['ItemBurnedEvent', 'ItemMintedEvent'] as const +const EVENT_REFETCH_DELAYS_MS = [250, 1500, 3500] as const +const CHECKPOINT_STREAM_RECONNECT_MS = 1_000 +// Rotate before the public fullnode ~30s stream cutoff. +const CHECKPOINT_STREAM_MAX_SESSION_MS = 28_000 +// Backup if a session stops yielding without closing cleanly. +const CHECKPOINT_STREAM_IDLE_MS = 35_000 +const CHECKPOINT_STREAM_READ_MASK_PATHS = [ + 'transactions.digest', + 'transactions.events', + 'sequence_number', +] as const + +type AssemblyEventKey = { + item_id?: string | number + tenant?: string +} + +type AssemblyEventPayload = { + assembly_id?: string + assembly_key?: AssemblyEventKey + item_id?: string + quantity?: number + type_id?: string | number +} + +type ProtobufValue = { + kind?: { + oneofKind?: string + nullValue?: unknown + numberValue?: number + stringValue?: string + boolValue?: boolean + structValue?: ProtobufStruct + listValue?: { values?: ProtobufValue[] } + } +} + +type ProtobufStruct = { + fields?: Record +} + +export type CheckpointStreamTransaction = { + digest?: string + events?: { + events?: Array<{ + eventType?: string + event_type?: string + json?: ProtobufValue + contents?: { + value?: Uint8Array + } + }> + } +} + +export type CheckpointStreamMessage = { + checkpoint?: { + sequenceNumber?: number | bigint + sequence_number?: number | bigint + transactions?: CheckpointStreamTransaction[] + } +} + +export type AssemblyEventTarget = { + eventTypes: readonly string[] + itemId?: string + objectId?: string + tenant?: string +} + +export type EventUnsubscribe = () => Promise +export type ScheduledRefetch = (() => void) & { cancel: () => void } +export type InventoryEvent = Pick +export type InventoryEventBatchHandler = (events: InventoryEvent[]) => void +export type CheckpointGapHandler = ( + lastCheckpoint: number, + nextCheckpoint: number, +) => void +export type CheckpointStreamSession = { + cancel: () => void + responses: AsyncIterable +} +export type SubscribeCheckpoints = (request: { + readMask: { paths: readonly string[] } +}) => CheckpointStreamSession + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function protobufValueToJson(value: ProtobufValue | undefined): unknown { + const kind = value?.kind + if (!kind?.oneofKind) return null + + switch (kind.oneofKind) { + case 'nullValue': + return null + case 'numberValue': + return kind.numberValue + case 'stringValue': + return kind.stringValue + case 'boolValue': + return kind.boolValue + case 'structValue': + return protobufStructToJson(kind.structValue) + case 'listValue': + return (kind.listValue?.values ?? []).map((entry) => + protobufValueToJson(entry), + ) + default: + return null + } +} + +function protobufStructToJson( + struct: ProtobufStruct | undefined, +): Record { + const result: Record = {} + + Object.entries(struct?.fields ?? {}).forEach(([key, value]) => { + result[key] = protobufValueToJson(value) + }) + + return result +} + +function parseAssemblyEventPayload( + event: Pick, +): AssemblyEventPayload | null { + const parsedJson = event.parsedJson + if (!isRecord(parsedJson)) return null + + const assemblyKeyRaw = parsedJson['assembly_key'] + let assemblyKey: AssemblyEventKey | undefined + if (isRecord(assemblyKeyRaw)) { + const itemId = assemblyKeyRaw['item_id'] + const tenant = assemblyKeyRaw['tenant'] + if ( + (typeof itemId === 'string' || typeof itemId === 'number') && + typeof tenant === 'string' + ) { + assemblyKey = { item_id: itemId, tenant } + } + } + + const payload: AssemblyEventPayload = {} + + if (typeof parsedJson['assembly_id'] === 'string') { + payload.assembly_id = parsedJson['assembly_id'] + } + if (assemblyKey) { + payload.assembly_key = assemblyKey + } + if (typeof parsedJson['item_id'] === 'string') { + payload.item_id = parsedJson['item_id'] + } + if (typeof parsedJson['quantity'] === 'number') { + payload.quantity = parsedJson['quantity'] + } + if ( + typeof parsedJson['type_id'] === 'string' || + typeof parsedJson['type_id'] === 'number' + ) { + payload.type_id = parsedJson['type_id'] + } + + return payload +} + +function normalizeObjectId(value: string | undefined) { + return value?.toLowerCase() +} + +function parseInventoryEventPayloadFromStream(event: { + json?: ProtobufValue + contents?: { + value?: Uint8Array + } +}): Record | null { + const parsedJsonFromProtobuf = protobufValueToJson(event.json) + if (isRecord(parsedJsonFromProtobuf)) { + return parsedJsonFromProtobuf + } + + const bcsBytes = event.contents?.value + if (!bcsBytes) return null + + try { + return inventoryEventBcsToParsedJson(decodeInventoryEventBcs(bcsBytes)) + } catch { + return null + } +} + +function wait(ms: number, signal?: AbortSignal) { + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + signal?.removeEventListener('abort', onAbort) + resolve() + }, ms) + + const onAbort = () => { + clearTimeout(timeoutId) + reject(signal?.reason) + } + + if (signal?.aborted) { + clearTimeout(timeoutId) + reject(signal.reason) + return + } + + signal?.addEventListener('abort', onAbort, { once: true }) + }) +} + +export function getInventoryEventTypes( + packageId = getEveWorldPackageId(), +): string[] { + return INVENTORY_EVENT_NAMES.map( + (eventName) => `${packageId}::inventory::${eventName}`, + ) +} + +export function isRelevantAssemblyInventoryEvent( + event: Pick, + target: AssemblyEventTarget, +): boolean { + if (!target.eventTypes.includes(event.type)) return false + + const payload = parseAssemblyEventPayload(event) + if (!payload) return false + + if ( + target.objectId && + normalizeObjectId(payload.assembly_id) === + normalizeObjectId(target.objectId) + ) { + return true + } + + if (!target.itemId || !target.tenant) return false + + return ( + String(payload.assembly_key?.item_id ?? '') === target.itemId && + payload.assembly_key?.tenant === target.tenant + ) +} + +function parseInventoryEventDelta( + event: Pick, +) { + const payload = parseAssemblyEventPayload(event) + if (!payload) return null + + const typeId = Number(payload.type_id) + const quantity = Number(payload.quantity) + if (!Number.isFinite(typeId) || !Number.isFinite(quantity) || quantity <= 0) { + return null + } + + if (event.type.endsWith('::inventory::ItemMintedEvent')) { + return { + itemId: payload.item_id ?? String(typeId), + quantity, + tenant: payload.assembly_key?.tenant ?? '', + typeId, + operation: 'add' as const, + } + } + + if (event.type.endsWith('::inventory::ItemBurnedEvent')) { + return { + itemId: payload.item_id ?? String(typeId), + quantity, + tenant: payload.assembly_key?.tenant ?? '', + typeId, + operation: 'subtract' as const, + } + } + + return null +} + +function toFiniteNumber(value: unknown, fallback = 0): number { + const numberValue = Number(value) + return Number.isFinite(numberValue) ? numberValue : fallback +} + +function isOptimisticInventoryItem(item: InventoryItem): boolean { + return item.id.startsWith('optimistic-') || item.name.startsWith('Type ') +} + +function mergeInventoryItemsByTypeId(items: InventoryItem[]): InventoryItem[] { + const itemsByTypeId = new Map() + + items.forEach((item) => { + const typeId = toFiniteNumber(item.type_id, Number.NaN) + if (!Number.isFinite(typeId)) return + + const existingItem = itemsByTypeId.get(typeId) + if (!existingItem) { + itemsByTypeId.set(typeId, { + ...item, + quantity: toFiniteNumber(item.quantity), + type_id: typeId, + }) + return + } + + const preferredItem = + isOptimisticInventoryItem(existingItem) && + !isOptimisticInventoryItem(item) + ? item + : existingItem + + const quantity = + toFiniteNumber(existingItem.quantity) + toFiniteNumber(item.quantity) + if (quantity <= 0) { + itemsByTypeId.delete(typeId) + return + } + + itemsByTypeId.set(typeId, { + ...preferredItem, + quantity, + type_id: typeId, + }) + }) + + return Array.from(itemsByTypeId.values()) +} + +export function applyInventoryEventToAssembly( + assembly: AssemblyType | null, + event: Pick, +): AssemblyType | null { + if (!assembly || assembly.type !== Assemblies.SmartStorageUnit) { + return assembly + } + + const delta = parseInventoryEventDelta(event) + if (!delta) return assembly + + const items = assembly.storage.mainInventory.items ?? [] + const itemIndex = items.findIndex( + (item) => toFiniteNumber(item.type_id) === delta.typeId, + ) + + if (itemIndex === -1 && delta.operation === 'subtract') { + return assembly + } + + const nextItems = + itemIndex === -1 + ? [ + ...items, + { + id: `optimistic-${delta.typeId}`, + item_id: delta.itemId, + location: { location_hash: '' }, + quantity: delta.quantity, + tenant: delta.tenant, + type_id: delta.typeId, + name: `Type ${delta.typeId}`, + } satisfies InventoryItem, + ] + : items.flatMap((item, index) => { + if (index !== itemIndex) return [item] + + const quantity = + delta.operation === 'add' + ? toFiniteNumber(item.quantity) + delta.quantity + : Math.max(0, toFiniteNumber(item.quantity) - delta.quantity) + + if (quantity === 0) return [] + + return [ + { + ...item, + quantity, + }, + ] + }) + + return { + ...assembly, + storage: { + ...assembly.storage, + mainInventory: { + ...assembly.storage.mainInventory, + items: sortInventoryItemsByQuantity( + mergeInventoryItemsByTypeId(nextItems), + ), + }, + }, + } +} + +export function createEventRefetchScheduler( + refetch: () => Promise, + delaysMs: readonly number[] = EVENT_REFETCH_DELAYS_MS, + onError?: (error: unknown) => void, +): ScheduledRefetch { + let timeouts: ReturnType[] = [] + + const scheduledRefetch = () => { + scheduledRefetch.cancel() + + timeouts = delaysMs.map((delayMs) => { + const timeoutId = setTimeout(() => { + timeouts = timeouts.filter((timeout) => timeout !== timeoutId) + refetch().catch((error) => onError?.(error)) + }, delayMs) + return timeoutId + }) + } + + scheduledRefetch.cancel = () => { + for (const timeout of timeouts) { + clearTimeout(timeout) + } + timeouts = [] + } + + return scheduledRefetch +} + +function getStreamEventType(event: { + eventType?: string + event_type?: string +}) { + return event.eventType ?? event.event_type ?? '' +} + +function getCheckpointSequenceNumber( + checkpoint: CheckpointStreamMessage['checkpoint'], +) { + const sequenceNumber = + checkpoint?.sequenceNumber ?? checkpoint?.sequence_number + if (typeof sequenceNumber === 'bigint') { + return Number(sequenceNumber) + } + return typeof sequenceNumber === 'number' && Number.isFinite(sequenceNumber) + ? sequenceNumber + : null +} + +async function readNextCheckpointMessage( + iterator: AsyncIterator, + session: CheckpointStreamSession, + idleMs: number, + signal?: AbortSignal, +): Promise> { + const pendingNext = iterator.next() + + if (idleMs <= 0 || !Number.isFinite(idleMs)) { + return pendingNext + } + + try { + return await Promise.race([ + pendingNext, + wait(idleMs, signal).then(() => { + session.cancel() + throw new Error('checkpoint stream idle timeout') + }), + ]) + } catch (error) { + session.cancel() + throw error + } +} + +export function extractInventoryEventsFromCheckpoint( + checkpoint: CheckpointStreamMessage['checkpoint'], + eventTypes: readonly string[], +): InventoryEvent[] { + const inventoryEvents: InventoryEvent[] = [] + const checkpointSequence = getCheckpointSequenceNumber(checkpoint) + + ;(checkpoint?.transactions ?? []).forEach((transaction, txIndex) => { + const txDigest = + transaction.digest ?? + (checkpointSequence != null + ? `checkpoint-${checkpointSequence}-${txIndex}` + : `checkpoint-tx-${txIndex}`) + + ;(transaction.events?.events ?? []).forEach((event, eventSeq) => { + const type = getStreamEventType(event) + if (!eventTypes.includes(type)) return + + const parsedJson = parseInventoryEventPayloadFromStream(event) + if (!parsedJson) return + + inventoryEvents.push({ + id: { txDigest, eventSeq: String(eventSeq) }, + type, + parsedJson, + }) + }) + }) + + return inventoryEvents +} + +function getInventoryEventId( + event: InventoryEvent, + checkpointSequence?: number | null, +) { + if (event.id.txDigest) { + return `${event.id.txDigest}:${event.id.eventSeq}` + } + + return `checkpoint-${checkpointSequence ?? 'unknown'}:${event.id.eventSeq}:${event.type}` +} + +function collectUnseenInventoryEvents( + events: InventoryEvent[], + seenEventIds: Set, + checkpointSequence?: number | null, +) { + return events.filter((event) => { + const eventId = getInventoryEventId(event, checkpointSequence) + if (seenEventIds.has(eventId)) return false + seenEventIds.add(eventId) + return true + }) +} + +export function createInventoryCheckpointStream({ + eventTypes, + idleMs = CHECKPOINT_STREAM_IDLE_MS, + maxSessionMs = CHECKPOINT_STREAM_MAX_SESSION_MS, + onError, + onEvents, + onGap, + reconnectMs = CHECKPOINT_STREAM_RECONNECT_MS, + signal, + subscribeCheckpoints, +}: { + eventTypes: readonly string[] + idleMs?: number + maxSessionMs?: number + onError?: (error: unknown) => void + onEvents?: InventoryEventBatchHandler + onGap?: CheckpointGapHandler + reconnectMs?: number + signal?: AbortSignal + subscribeCheckpoints: SubscribeCheckpoints +}): EventUnsubscribe { + let stopped = false + let streamTask: Promise | null = null + let activeSession: CheckpointStreamSession | null = null + + const run = async () => { + const seenEventIds = new Set() + let lastCheckpointSequence: number | null = null + let isInitialSession = true + + while (!stopped && !signal?.aborted) { + let session: CheckpointStreamSession | null = null + + try { + session = subscribeCheckpoints({ + readMask: { paths: CHECKPOINT_STREAM_READ_MASK_PATHS }, + }) + activeSession = session + + const iterator = session.responses[Symbol.asyncIterator]() + const sessionStartedAt = Date.now() + + while (!stopped && !signal?.aborted) { + const elapsedMs = Date.now() - sessionStartedAt + const hasRotationLimit = maxSessionMs > 0 + const hasIdleLimit = idleMs > 0 + const msUntilRotation = hasRotationLimit + ? maxSessionMs - elapsedMs + : Number.POSITIVE_INFINITY + const readTimeoutMs = + hasRotationLimit || hasIdleLimit + ? Math.min( + hasIdleLimit ? idleMs : Number.POSITIVE_INFINITY, + msUntilRotation, + ) + : null + + if (readTimeoutMs != null && readTimeoutMs <= 0) { + break + } + + let nextCheckpoint: IteratorResult + + try { + nextCheckpoint = await readNextCheckpointMessage( + iterator, + session, + readTimeoutMs ?? 0, + signal, + ) + } catch (error) { + if (stopped || signal?.aborted) return + + if ( + hasRotationLimit && + Date.now() - sessionStartedAt >= maxSessionMs + ) { + break + } + + onError?.(error) + break + } + + if (nextCheckpoint.done) { + break + } + + if (!('value' in nextCheckpoint) || !nextCheckpoint.value) continue + + const sequenceNumber = getCheckpointSequenceNumber( + nextCheckpoint.value.checkpoint, + ) + + if (isInitialSession) { + isInitialSession = false + if (sequenceNumber != null) { + lastCheckpointSequence = sequenceNumber + } + continue + } + + if ( + lastCheckpointSequence != null && + sequenceNumber != null && + sequenceNumber > lastCheckpointSequence + 1 + ) { + onGap?.(lastCheckpointSequence, sequenceNumber) + } + + const inventoryEvents = collectUnseenInventoryEvents( + extractInventoryEventsFromCheckpoint( + nextCheckpoint.value.checkpoint, + eventTypes, + ), + seenEventIds, + sequenceNumber, + ) + + if (inventoryEvents.length > 0) { + onEvents?.(inventoryEvents) + } + + if (sequenceNumber != null) { + lastCheckpointSequence = sequenceNumber + } + } + } catch (error) { + if (stopped || signal?.aborted) return + + onError?.(error) + } finally { + session?.cancel() + if (activeSession === session) { + activeSession = null + } + } + + if (stopped || signal?.aborted) return + + try { + await wait(reconnectMs, signal) + } catch { + return + } + } + } + + streamTask = run().catch((error) => { + log.error( + '[DappKit] Inventory checkpoint stream task exited unexpectedly:', + error, + ) + }) + + return async () => { + stopped = true + activeSession?.cancel() + await streamTask + } +} + +export async function subscribeToInventoryEvents({ + eventTypes, + network = DEFAULT_GRAPHQL_NETWORK, + onEvents, + onGap, + signal, +}: { + eventTypes: readonly string[] + network?: string + onEvents?: InventoryEventBatchHandler + onGap?: CheckpointGapHandler + signal?: AbortSignal +}): Promise { + const unsubscribe = createInventoryCheckpointStream({ + eventTypes, + ...(onEvents !== undefined ? { onEvents } : {}), + ...(onGap !== undefined ? { onGap } : {}), + ...(signal !== undefined ? { signal } : {}), + onError: (error) => { + log.warn('[DappKit] Inventory checkpoint stream error:', error) + }, + subscribeCheckpoints: (request) => { + const abortController = new AbortController() + const grpcClient = new SuiGrpcClient({ + network, + baseUrl: getSuiGrpcBaseUrl(network), + }) + const call = grpcClient.subscriptionService.subscribeCheckpoints( + { + readMask: { + paths: [...request.readMask.paths], + }, + }, + { abort: abortController.signal }, + ) + + return { + responses: call.responses as AsyncIterable, + cancel: () => { + abortController.abort() + }, + } + }, + }) + + signal?.addEventListener('abort', () => { + void unsubscribe() + }) + + return unsubscribe +} diff --git a/packages/libs/dapp-kit/utils/__tests__/inventory.test.ts b/packages/libs/dapp-kit/utils/__tests__/inventory.test.ts new file mode 100644 index 0000000..911c935 --- /dev/null +++ b/packages/libs/dapp-kit/utils/__tests__/inventory.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest' + +import type { InventoryItem } from '../../types' +import { areInventoryItemListsEqual } from '../inventory' + +function createInventoryItem(typeId: number, quantity: number): InventoryItem { + return { + id: `item-${typeId}`, + item_id: `item-${typeId}`, + location: { location_hash: 'main' }, + name: `Item ${typeId}`, + quantity, + tenant: 'stillness', + type_id: typeId, + } +} + +describe('inventory utilities', () => { + it('treats inventory lists as equal when type quantities match in different orders', () => { + expect( + areInventoryItemListsEqual( + [createInventoryItem(77810, 20), createInventoryItem(82128, 500)], + [createInventoryItem(82128, 500), createInventoryItem(77810, 20)], + ), + ).toBe(true) + }) + + it('treats duplicate item rows as equal to their merged quantity', () => { + expect( + areInventoryItemListsEqual( + [createInventoryItem(77810, 20), createInventoryItem(77810, 30)], + [createInventoryItem(77810, 50)], + ), + ).toBe(true) + }) + + it('detects quantity changes', () => { + expect( + areInventoryItemListsEqual( + [createInventoryItem(77810, 20)], + [createInventoryItem(77810, 19)], + ), + ).toBe(false) + }) + + it('detects item type changes', () => { + expect( + areInventoryItemListsEqual( + [createInventoryItem(77810, 20)], + [createInventoryItem(82128, 20)], + ), + ).toBe(false) + }) +}) diff --git a/packages/libs/dapp-kit/utils/__tests__/inventoryEventBcs.test.ts b/packages/libs/dapp-kit/utils/__tests__/inventoryEventBcs.test.ts new file mode 100644 index 0000000..a211941 --- /dev/null +++ b/packages/libs/dapp-kit/utils/__tests__/inventoryEventBcs.test.ts @@ -0,0 +1,49 @@ +import { + getJsonRpcFullnodeUrl, + JsonRpcHTTPTransport, +} from '@mysten/sui/jsonRpc' +import { fromBase64 } from '@mysten/sui/utils' +import { describe, expect, it } from 'vitest' + +import { + decodeInventoryEventBcs, + inventoryEventBcsToParsedJson, +} from '../inventoryEventBcs' + +const PACKAGE_ID = + '0x28b497559d65ab320d9da4613bf2498d5946b2c0ae3597ccfda3072ce127448c' + +describe('inventoryEventBcs', () => { + it('decodes mint and burn inventory events from chain BCS bytes', async () => { + const transport = new JsonRpcHTTPTransport({ + url: getJsonRpcFullnodeUrl('testnet'), + }) + + for (const eventName of ['ItemMintedEvent', 'ItemBurnedEvent'] as const) { + const result = await transport.request<{ + data: Array<{ + parsedJson: Record + bcs: string + bcsEncoding: 'base64' | 'base58' + }> + }>({ + method: 'suix_queryEvents', + params: [ + { MoveEventType: `${PACKAGE_ID}::inventory::${eventName}` }, + null, + 1, + true, + ], + }) + + const event = result.data[0] + if (!event) { + throw new Error(`No ${eventName} events returned from testnet`) + } + const bytes = fromBase64(event.bcs) + const decoded = decodeInventoryEventBcs(bytes) + + expect(inventoryEventBcsToParsedJson(decoded)).toEqual(event.parsedJson) + } + }) +}) diff --git a/packages/libs/dapp-kit/utils/__tests__/transforms.test.ts b/packages/libs/dapp-kit/utils/__tests__/transforms.test.ts index 0dc61cb..8568a97 100644 --- a/packages/libs/dapp-kit/utils/__tests__/transforms.test.ts +++ b/packages/libs/dapp-kit/utils/__tests__/transforms.test.ts @@ -19,7 +19,7 @@ import type { DynamicFieldNode, MoveObjectData, } from '../../graphql/types' -import { Assemblies, type DatahubGameInfo, State } from '../../types' +import { Assemblies, type DatahubGameInfo, State, type InventoryItem } from '../../types' import { getEnergyConfig, getEnergyUsageForType } from '../config' import { getDatahubGameInfo } from '../datahub' import { transformToAssembly, transformToCharacter } from '../transforms' @@ -202,6 +202,57 @@ describe('transformToAssembly — SmartStorageUnit', () => { expect(result?.storage.mainInventory.capacity).toBeUndefined() expect(result?.storage.mainInventory.usedCapacity).toBeUndefined() }) + + it('orders storage inventory items by quantity from GraphQL data', async () => { + function createInventoryItem(typeId: number, quantity: number): InventoryItem { + return { + id: `item-${typeId}`, + item_id: `item-${typeId}`, + location: { location_hash: 'main' }, + name: `Item ${typeId}`, + quantity, + tenant: 'stillness', + type_id: typeId, + } + } + + const inventoryKey = 'main-inventory' + const inventoryDynField: DynamicFieldNode = { + name: { json: inventoryKey, type: { repr: '0x::String' } }, + contents: { + json: { + key: inventoryKey, + value: { + max_capacity: '1000000', + used_capacity: '1000', + items: { + contents: [ + { key: 'low', value: createInventoryItem(77810, 20) }, + { key: 'high', value: createInventoryItem(82128, 500) }, + { key: 'mid', value: createInventoryItem(88082, 50) }, + ], + }, + }, + }, + type: { layout: '' }, + }, + } + + const moveObj = makeMoveObject( + makeRawJson({ inventory_keys: [inventoryKey] }), + SSU_TYPE, + [inventoryDynField], + ) + + const result = (await transformToAssembly('0x1', moveObj)) as Extract< + Awaited>, + { type: Assemblies.SmartStorageUnit } + > + + expect(result?.storage.mainInventory.items.map((item) => item.type_id)).toEqual( + [82128, 88082, 77810], + ) + }) }) // ============================================================================ diff --git a/packages/libs/dapp-kit/utils/constants.ts b/packages/libs/dapp-kit/utils/constants.ts index 44ebed3..76c2e16 100644 --- a/packages/libs/dapp-kit/utils/constants.ts +++ b/packages/libs/dapp-kit/utils/constants.ts @@ -110,6 +110,27 @@ export const GRAPHQL_ENDPOINTS: Record = { mainnet: 'https://graphql.mainnet.sui.io/graphql', } +/** gRPC base URLs for each Sui network. + * @category Constants + */ +export const SUI_GRPC_URLS: Record = { + testnet: 'https://fullnode.testnet.sui.io:443', + devnet: 'https://fullnode.devnet.sui.io:443', + mainnet: 'https://fullnode.mainnet.sui.io:443', +} + +/** + * Get the Sui gRPC base URL for the given network. + * Unknown values fall back to testnet. + * @category Utilities + */ +export function getSuiGrpcBaseUrl( + env: string = DEFAULT_GRAPHQL_NETWORK, +): string { + const network = isSuiGraphqlNetwork(env) ? env : DEFAULT_GRAPHQL_NETWORK + return SUI_GRPC_URLS[network] +} + /** Polling interval in milliseconds (10 seconds). * @category Constants */ diff --git a/packages/libs/dapp-kit/utils/inventory.ts b/packages/libs/dapp-kit/utils/inventory.ts new file mode 100644 index 0000000..ac0f9f3 --- /dev/null +++ b/packages/libs/dapp-kit/utils/inventory.ts @@ -0,0 +1,52 @@ +import type { InventoryItem } from '../types' + +function toFiniteNumber(value: unknown, fallback = 0): number { + const numberValue = Number(value) + return Number.isFinite(numberValue) ? numberValue : fallback +} + +export function sortInventoryItemsByQuantity( + items: InventoryItem[] | undefined, +): InventoryItem[] { + return [...(items ?? [])].sort((a, b) => { + const quantityDifference = + toFiniteNumber(b.quantity) - toFiniteNumber(a.quantity) + if (quantityDifference !== 0) return quantityDifference + return toFiniteNumber(a.type_id) - toFiniteNumber(b.type_id) + }) +} + +function getInventoryItemSignatures(items: InventoryItem[] | undefined) { + const quantityByTypeId = new Map() + + ;(items ?? []).forEach((item) => { + const typeId = toFiniteNumber(item.type_id, Number.NaN) + if (!Number.isFinite(typeId)) return + + quantityByTypeId.set( + typeId, + (quantityByTypeId.get(typeId) ?? 0) + toFiniteNumber(item.quantity), + ) + }) + + return Array.from(quantityByTypeId.entries()) + .filter(([, quantity]) => quantity > 0) + .sort(([leftTypeId], [rightTypeId]) => leftTypeId - rightTypeId) +} + +export function areInventoryItemListsEqual( + leftItems: InventoryItem[] | undefined, + rightItems: InventoryItem[] | undefined, +): boolean { + const leftSignatures = getInventoryItemSignatures(leftItems) + const rightSignatures = getInventoryItemSignatures(rightItems) + + if (leftSignatures.length !== rightSignatures.length) return false + + return leftSignatures.every(([leftTypeId, leftQuantity], index) => { + const rightSignature = rightSignatures[index] + if (!rightSignature) return false + const [rightTypeId, rightQuantity] = rightSignature + return leftTypeId === rightTypeId && leftQuantity === rightQuantity + }) +} diff --git a/packages/libs/dapp-kit/utils/inventoryEventBcs.ts b/packages/libs/dapp-kit/utils/inventoryEventBcs.ts new file mode 100644 index 0000000..b8d5d89 --- /dev/null +++ b/packages/libs/dapp-kit/utils/inventoryEventBcs.ts @@ -0,0 +1,74 @@ +import { bcs } from '@mysten/sui/bcs' + +const BcsObjectId = bcs.fixedArray(32, bcs.u8()).transform({ + input: (value: number[]) => value, + output: (value: number[]) => + `0x${value.map((byte) => byte.toString(16).padStart(2, '0')).join('')}`, +}) + +const TenantKey = bcs.struct('TenantKey', { + item_id: bcs.u64(), + tenant: bcs.string(), +}) + +const InventoryMoveEvent = bcs.struct('InventoryMoveEvent', { + assembly_id: BcsObjectId, + assembly_key: TenantKey, + character_id: BcsObjectId, + character_key: TenantKey, + item_id: bcs.u64(), + type_id: bcs.u64(), + 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( + bytes: Uint8Array, +): DecodedInventoryMoveEvent { + const decoded = InventoryMoveEvent.parse(bytes) + + return { + assembly_id: decoded.assembly_id, + assembly_key: { + item_id: String(decoded.assembly_key.item_id), + tenant: decoded.assembly_key.tenant, + }, + character_id: decoded.character_id, + character_key: { + item_id: String(decoded.character_key.item_id), + tenant: decoded.character_key.tenant, + }, + item_id: String(decoded.item_id), + type_id: String(decoded.type_id), + 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/transforms.ts b/packages/libs/dapp-kit/utils/transforms.ts index 4e64ede..3126228 100644 --- a/packages/libs/dapp-kit/utils/transforms.ts +++ b/packages/libs/dapp-kit/utils/transforms.ts @@ -16,6 +16,7 @@ import { } from '../types' import { getEnergyConfig, getEnergyUsageForType } from './config' import { getDatahubGameInfo } from './datahub' +import { sortInventoryItemsByQuantity } from './inventory' import { createLogger } from './logger' import { getAssemblyType, parseStatus } from './mapping' @@ -156,7 +157,7 @@ export async function transformToAssembly( mainInventory: { capacity: inventoryData?.value?.max_capacity, usedCapacity: inventoryData?.value?.used_capacity, - items: inventoryItems, + items: sortInventoryItemsByQuantity(inventoryItems), }, ephemeralInventories: [], }, From 510fa37505786a94567b02f8f711c718aa9cdea4 Mon Sep 17 00:00:00 2001 From: ccp_raudur Date: Thu, 18 Jun 2026 13:26:01 +0000 Subject: [PATCH 2/6] feat: optimistic inventory used-capacity updates and whole-number capacity bar --- .../providers/SmartObjectProvider.tsx | 168 ++++++++++++------ .../providers/__tests__/eventRefresh.test.ts | 98 +++++++++- .../libs/dapp-kit/providers/eventRefresh.ts | 54 +++++- .../utils/__tests__/inventory.test.ts | 97 +++++++++- .../utils/__tests__/transforms.test.ts | 18 +- packages/libs/dapp-kit/utils/inventory.ts | 139 ++++++++++++++- .../ui-components/components/EveLinearBar.tsx | 11 +- .../ui-components/modules/InventoryView.tsx | 11 +- 8 files changed, 525 insertions(+), 71 deletions(-) diff --git a/packages/libs/dapp-kit/providers/SmartObjectProvider.tsx b/packages/libs/dapp-kit/providers/SmartObjectProvider.tsx index 051b2fd..ccb2bd1 100644 --- a/packages/libs/dapp-kit/providers/SmartObjectProvider.tsx +++ b/packages/libs/dapp-kit/providers/SmartObjectProvider.tsx @@ -11,17 +11,28 @@ import { } from '../types' import { createLogger, + getEveWorldPackageId, getObjectId, transformToAssembly, transformToCharacter, } from '../utils' -import { DEFAULT_TENANT, POLLING_INTERVAL } from '../utils/constants' +import { + DEFAULT_TENANT, + POLLING_INTERVAL, + TENANT_CONFIG, + TenantId, +} from '../utils/constants' import { getDatahubGameInfo } from '../utils/datahub' -import { areInventoryItemListsEqual } from '../utils/inventory' +import { + getInventoryTypeVolumeM3, + mergeSmartStorageInventoryFromRefetch, + setInventoryTypeVolumeM3, +} from '../utils/inventory' import { applyInventoryEventToAssembly, createEventRefetchScheduler, type EventUnsubscribe, + getInventoryEventTarget, getInventoryEventTypes, isRelevantAssemblyInventoryEvent, subscribeToInventoryEvents, @@ -29,6 +40,18 @@ import { const log = createLogger() +function getInventoryStreamEventTypes(tenant: string): string[] { + const packageIds = new Set([getEveWorldPackageId()]) + const tenantPackageId = TENANT_CONFIG[tenant as TenantId]?.packageId + if (tenantPackageId) { + packageIds.add(tenantPackageId) + } + + return [...packageIds].flatMap((packageId) => + getInventoryEventTypes(packageId), + ) +} + /** Input for fetching object data: either itemId + tenant (derive object ID) or a Sui object ID directly. * @category Types */ @@ -46,37 +69,30 @@ export const SmartObjectContext = createContext({ refetch: async () => {}, }) -function preserveStorageInventoryItemsWhenEqual( - currentAssembly: AssemblyType | null, - nextAssembly: AssemblyType | null, -): AssemblyType | null { - if ( - !currentAssembly || - !nextAssembly || - currentAssembly.type !== Assemblies.SmartStorageUnit || - nextAssembly.type !== Assemblies.SmartStorageUnit - ) { - return nextAssembly - } +function primeInventoryTypeVolumes(assembly: AssemblyType | null) { + if (!assembly || assembly.type !== Assemblies.SmartStorageUnit) return - if ( - !areInventoryItemListsEqual( - currentAssembly.storage.mainInventory.items, - nextAssembly.storage.mainInventory.items, - ) - ) { - return nextAssembly + const typeIds = new Set() + for (const item of assembly.storage.mainInventory.items ?? []) { + const typeId = Number(item.type_id) + if (Number.isFinite(typeId)) typeIds.add(typeId) + } + for (const ephemeral of assembly.storage.ephemeralInventories ?? []) { + for (const item of ephemeral.ephemeralInventoryItems ?? []) { + const typeId = Number(item.type_id) + if (Number.isFinite(typeId)) typeIds.add(typeId) + } } - return { - ...nextAssembly, - storage: { - ...nextAssembly.storage, - mainInventory: { - ...nextAssembly.storage.mainInventory, - items: currentAssembly.storage.mainInventory.items, - }, - }, + for (const typeId of typeIds) { + // getDatahubGameInfo is uncached and this runs on every poll/refetch; + // skip type volumes already in the cache. + if (getInventoryTypeVolumeM3(typeId) !== undefined) continue + void getDatahubGameInfo(typeId) + .then((info) => { + setInventoryTypeVolumeM3(typeId, info.volume) + }) + .catch(() => {}) } } @@ -106,6 +122,10 @@ const SmartObjectProvider = ({ children }: { children: ReactNode }) => { const pollingRef = useRef | null>(null) const lastDataHashRef = useRef(null) + const lastConfirmedInventorySignatureRef = useRef(null) + const assemblyRef = useRef | null>(null) + + assemblyRef.current = assembly const { isConnected } = useConnection() @@ -195,12 +215,29 @@ const SmartObjectProvider = ({ children }: { children: ReactNode }) => { }, ) + primeInventoryTypeVolumes(transformed) + setAssembly((currentAssembly) => { - if (isInitialFetch) return transformed - return preserveStorageInventoryItemsWhenEqual( - currentAssembly, - transformed, - ) + if (transformed?.type !== Assemblies.SmartStorageUnit) { + return transformed + } + + // Merge against the prior storage state only on refetches; an + // initial fetch (or a type change) has no optimistic state to keep. + const previousStorage = + !isInitialFetch && + currentAssembly?.type === Assemblies.SmartStorageUnit + ? currentAssembly + : null + + const { assembly, inventorySignature } = + mergeSmartStorageInventoryFromRefetch( + previousStorage, + transformed, + lastConfirmedInventorySignatureRef.current, + ) + lastConfirmedInventorySignatureRef.current = inventorySignature + return assembly }) // Transform and set assemblyOwner: owner of the assembly @@ -291,6 +328,7 @@ const SmartObjectProvider = ({ children }: { children: ReactNode }) => { log.info('[DappKit] SmartObjectProvider: Stopped polling') } lastDataHashRef.current = null + lastConfirmedInventorySignatureRef.current = null } }, [ selectedObjectId, @@ -303,12 +341,12 @@ const SmartObjectProvider = ({ children }: { children: ReactNode }) => { // Listen for inventory events via gRPC checkpoint stream. GraphQL polling above // remains the fallback/source of truth if stream events are missed or unavailable. useEffect(() => { - if (!selectedObjectId || !isConnected) return + if (!selectedObjectId) return const input: FetchObjectDataInput = isObjectIdDirect ? { objectId: selectedObjectId } : { itemId: selectedObjectId, selectedTenant } - const eventTypes = getInventoryEventTypes() + const eventTypes = getInventoryStreamEventTypes(selectedTenant) const abortController = new AbortController() const triggerRefetch = createEventRefetchScheduler( () => fetchObjectData(input, false), @@ -322,6 +360,11 @@ const SmartObjectProvider = ({ children }: { children: ReactNode }) => { ) let unsubscribe: EventUnsubscribe | null = null + log.debug( + '[DappKit] SmartObjectProvider: Starting inventory checkpoint stream', + { eventTypes, selectedObjectId, selectedTenant }, + ) + subscribeToInventoryEvents({ eventTypes, signal: abortController.signal, @@ -329,23 +372,43 @@ const SmartObjectProvider = ({ children }: { children: ReactNode }) => { triggerRefetch() }, onEvents: (events) => { + const eventTarget = getInventoryEventTarget({ + assembly: assemblyRef.current, + eventTypes, + isObjectIdDirect, + selectedObjectId, + selectedTenant, + }) const relevantEvents = events.filter((event) => - isRelevantAssemblyInventoryEvent(event, { - eventTypes, - ...(isObjectIdDirect ? { objectId: selectedObjectId } : {}), - ...(!isObjectIdDirect ? { itemId: selectedObjectId } : {}), - tenant: selectedTenant, - }), + isRelevantAssemblyInventoryEvent(event, eventTarget), ) + if (events.length > 0 && relevantEvents.length === 0) { + log.debug( + '[DappKit] SmartObjectProvider: Ignoring inventory stream events for other assemblies', + { count: events.length, eventTarget }, + ) + } + if (relevantEvents.length > 0) { - setAssembly((currentAssembly) => - relevantEvents.reduce( + log.info( + '[DappKit] SmartObjectProvider: Applying inventory stream events', + { count: relevantEvents.length }, + ) + setAssembly((currentAssembly) => { + if ( + !currentAssembly || + currentAssembly.type !== Assemblies.SmartStorageUnit + ) { + return currentAssembly + } + + return relevantEvents.reduce | null>( (updatedAssembly, event) => applyInventoryEventToAssembly(updatedAssembly, event), currentAssembly, - ), - ) + ) + }) triggerRefetch() } }, @@ -356,6 +419,9 @@ const SmartObjectProvider = ({ children }: { children: ReactNode }) => { return } unsubscribe = eventUnsubscribe + log.debug( + '[DappKit] SmartObjectProvider: Inventory checkpoint stream active', + ) }) .catch((error) => { if (abortController.signal.aborted) return @@ -372,13 +438,7 @@ const SmartObjectProvider = ({ children }: { children: ReactNode }) => { void unsubscribe() } } - }, [ - selectedObjectId, - selectedTenant, - isObjectIdDirect, - isConnected, - fetchObjectData, - ]) + }, [selectedObjectId, selectedTenant, isObjectIdDirect, fetchObjectData]) const handleRefetch = useCallback(async () => { if (!selectedObjectId) return diff --git a/packages/libs/dapp-kit/providers/__tests__/eventRefresh.test.ts b/packages/libs/dapp-kit/providers/__tests__/eventRefresh.test.ts index 2b9c4d7..3730655 100644 --- a/packages/libs/dapp-kit/providers/__tests__/eventRefresh.test.ts +++ b/packages/libs/dapp-kit/providers/__tests__/eventRefresh.test.ts @@ -1,11 +1,16 @@ -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { Assemblies, type AssemblyType } from '../../types' +import { + clearInventoryTypeVolumeM3Cache, + setInventoryTypeVolumeM3, +} from '../../utils/inventory' import { applyInventoryEventToAssembly, type CheckpointStreamMessage, createEventRefetchScheduler, createInventoryCheckpointStream, extractInventoryEventsFromCheckpoint, + getInventoryEventTarget, getInventoryEventTypes, isRelevantAssemblyInventoryEvent, } from '../eventRefresh' @@ -82,6 +87,10 @@ function createUnsortedStorageAssembly(): AssemblyType { + beforeEach(() => { + clearInventoryTypeVolumeM3Cache() + }) + it('subscribes to inventory burn and mint events', () => { expect(getInventoryEventTypes(PACKAGE_ID)).toEqual([ `${PACKAGE_ID}::inventory::ItemBurnedEvent`, @@ -130,6 +139,75 @@ describe('event refresh helpers', () => { ).toBe(true) }) + it('prefers the loaded assembly object id when building event targets', () => { + const assembly = createStorageAssembly() + assembly.id = ASSEMBLY_OBJECT_ID + assembly.item_id = 1000001842554 + + expect( + getInventoryEventTarget({ + assembly, + eventTypes: getInventoryEventTypes(PACKAGE_ID), + isObjectIdDirect: false, + selectedObjectId: 'wrong-item-id', + selectedTenant: 'stillness', + }), + ).toEqual({ + eventTypes: getInventoryEventTypes(PACKAGE_ID), + objectId: ASSEMBLY_OBJECT_ID, + }) + }) + + it('falls back to the assembly item id and tenant when no object id is loaded', () => { + const assembly = createStorageAssembly() + assembly.item_id = 1000001842554 + + expect( + getInventoryEventTarget({ + assembly, + eventTypes: getInventoryEventTypes(PACKAGE_ID), + isObjectIdDirect: false, + selectedObjectId: 'selected-item-id', + selectedTenant: 'stillness', + }), + ).toEqual({ + eventTypes: getInventoryEventTypes(PACKAGE_ID), + itemId: '1000001842554', + tenant: 'stillness', + }) + }) + + it('targets the direct object id when no assembly is loaded yet', () => { + expect( + getInventoryEventTarget({ + assembly: null, + eventTypes: getInventoryEventTypes(PACKAGE_ID), + isObjectIdDirect: true, + selectedObjectId: ASSEMBLY_OBJECT_ID, + selectedTenant: 'stillness', + }), + ).toEqual({ + eventTypes: getInventoryEventTypes(PACKAGE_ID), + objectId: ASSEMBLY_OBJECT_ID, + }) + }) + + it('targets the selected item id and tenant when no assembly is loaded yet', () => { + expect( + getInventoryEventTarget({ + assembly: null, + eventTypes: getInventoryEventTypes(PACKAGE_ID), + isObjectIdDirect: false, + selectedObjectId: '1000001842554', + selectedTenant: 'stillness', + }), + ).toEqual({ + eventTypes: getInventoryEventTypes(PACKAGE_ID), + itemId: '1000001842554', + tenant: 'stillness', + }) + }) + it('matches minted inventory events for deposits', () => { const event = { type: `${PACKAGE_ID}::inventory::ItemMintedEvent`, @@ -223,6 +301,24 @@ describe('event refresh helpers', () => { expect(assembly.storage.mainInventory.items[0]?.quantity).toBe(20) }) + it('optimistically updates used capacity when type volume is cached', () => { + setInventoryTypeVolumeM3(77810, 0.1) + const assembly = createStorageAssembly() + const event = { + type: `${PACKAGE_ID}::inventory::ItemMintedEvent`, + parsedJson: { + quantity: 10, + type_id: '77810', + }, + } + + const updated = expectStorageAssembly( + applyInventoryEventToAssembly(assembly, event), + ) + + expect(updated.storage.mainInventory.usedCapacity).toBe('2000') + }) + it('merges duplicate rows when minting an existing item type', () => { const assembly = createStorageAssembly() assembly.storage.mainInventory.items = [ diff --git a/packages/libs/dapp-kit/providers/eventRefresh.ts b/packages/libs/dapp-kit/providers/eventRefresh.ts index 34dd9fa..a74ed29 100644 --- a/packages/libs/dapp-kit/providers/eventRefresh.ts +++ b/packages/libs/dapp-kit/providers/eventRefresh.ts @@ -7,7 +7,10 @@ import { getEveWorldPackageId, getSuiGrpcBaseUrl, } from '../utils/constants' -import { sortInventoryItemsByQuantity } from '../utils/inventory' +import { + adjustInventoryUsedCapacity, + sortInventoryItemsByQuantity, +} from '../utils/inventory' import { decodeInventoryEventBcs, inventoryEventBcsToParsedJson, @@ -260,12 +263,53 @@ export function isRelevantAssemblyInventoryEvent( if (!target.itemId || !target.tenant) return false + const eventItemId = String(payload.assembly_key?.item_id ?? '') + const targetItemId = String(target.itemId) + return ( - String(payload.assembly_key?.item_id ?? '') === target.itemId && + eventItemId === targetItemId && payload.assembly_key?.tenant === target.tenant ) } +export function getInventoryEventTarget({ + assembly, + eventTypes, + isObjectIdDirect, + selectedObjectId, + selectedTenant, +}: { + assembly: AssemblyType | null + eventTypes: readonly string[] + isObjectIdDirect: boolean + selectedObjectId: string + selectedTenant: string +}): AssemblyEventTarget { + if (assembly?.type === Assemblies.SmartStorageUnit) { + if (assembly.id) { + return { eventTypes, objectId: assembly.id } + } + + if (assembly.item_id) { + return { + eventTypes, + itemId: String(assembly.item_id), + tenant: selectedTenant, + } + } + } + + if (isObjectIdDirect) { + return { eventTypes, objectId: selectedObjectId } + } + + return { + eventTypes, + itemId: selectedObjectId, + tenant: selectedTenant, + } +} + function parseInventoryEventDelta( event: Pick, ) { @@ -408,6 +452,12 @@ export function applyInventoryEventToAssembly( ...assembly.storage, mainInventory: { ...assembly.storage.mainInventory, + usedCapacity: adjustInventoryUsedCapacity( + assembly.storage.mainInventory.usedCapacity, + delta.quantity, + delta.typeId, + delta.operation, + ), items: sortInventoryItemsByQuantity( mergeInventoryItemsByTypeId(nextItems), ), diff --git a/packages/libs/dapp-kit/utils/__tests__/inventory.test.ts b/packages/libs/dapp-kit/utils/__tests__/inventory.test.ts index 911c935..0787548 100644 --- a/packages/libs/dapp-kit/utils/__tests__/inventory.test.ts +++ b/packages/libs/dapp-kit/utils/__tests__/inventory.test.ts @@ -1,7 +1,15 @@ -import { describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it } from 'vitest' import type { InventoryItem } from '../../types' -import { areInventoryItemListsEqual } from '../inventory' +import { Assemblies, type AssemblyType } from '../../types' +import { + adjustInventoryUsedCapacity, + areInventoryItemListsEqual, + clearInventoryTypeVolumeM3Cache, + getInventoryQuantityVolumeDm3, + mergeSmartStorageInventoryFromRefetch, + setInventoryTypeVolumeM3, +} from '../inventory' function createInventoryItem(typeId: number, quantity: number): InventoryItem { return { @@ -15,7 +23,92 @@ function createInventoryItem(typeId: number, quantity: number): InventoryItem { } } +function createStorageAssembly( + quantity = 20, + usedCapacity = '1000', +): AssemblyType { + return { + type: Assemblies.SmartStorageUnit, + storage: { + mainInventory: { + capacity: '1000000', + usedCapacity, + items: [createInventoryItem(77810, quantity)], + }, + ephemeralInventories: [], + }, + } as unknown as AssemblyType +} + describe('inventory utilities', () => { + beforeEach(() => { + clearInventoryTypeVolumeM3Cache() + }) + + it('adjusts used capacity from cached type volume on mint and burn', () => { + setInventoryTypeVolumeM3(77810, 0.1) + + expect(getInventoryQuantityVolumeDm3(10, 77810)).toBe(1000) + expect(adjustInventoryUsedCapacity('1000', 10, 77810, 'add')).toBe('2000') + expect(adjustInventoryUsedCapacity('2000', 10, 77810, 'subtract')).toBe( + '1000', + ) + }) + + it('floors used capacity at zero when burning more than is tracked', () => { + setInventoryTypeVolumeM3(77810, 0.1) + + expect(adjustInventoryUsedCapacity('500', 10, 77810, 'subtract')).toBe('0') + }) + + it('leaves used capacity unchanged when type volume is unknown', () => { + expect(adjustInventoryUsedCapacity('1000', 10, 77810, 'add')).toBe('1000') + }) + + it('keeps optimistic inventory when refetch only moved ancillary fields', () => { + const current = createStorageAssembly(520, '2000') + const stale = createStorageAssembly(20, '1500') + const lastConfirmed = mergeSmartStorageInventoryFromRefetch( + null, + stale, + null, + ).inventorySignature + + const merged = mergeSmartStorageInventoryFromRefetch( + current, + stale, + lastConfirmed, + ) + + expect(merged.assembly).toBe(current) + expect(merged.assembly.storage.mainInventory.items).toEqual( + current.storage.mainInventory.items, + ) + expect(merged.assembly.storage.mainInventory.usedCapacity).toBe('2000') + expect(merged.inventorySignature).toBe(lastConfirmed) + }) + + it('accepts refetch inventory when indexer catches up', () => { + const current = createStorageAssembly(520, '2000') + const fresh = createStorageAssembly(520, '2000') + const lastConfirmed = mergeSmartStorageInventoryFromRefetch( + null, + createStorageAssembly(20, '1000'), + null, + ).inventorySignature + + const merged = mergeSmartStorageInventoryFromRefetch( + current, + fresh, + lastConfirmed, + ) + + expect(merged.assembly.storage.mainInventory.items).toEqual( + fresh.storage.mainInventory.items, + ) + expect(merged.inventorySignature).not.toBe(lastConfirmed) + }) + it('treats inventory lists as equal when type quantities match in different orders', () => { expect( areInventoryItemListsEqual( diff --git a/packages/libs/dapp-kit/utils/__tests__/transforms.test.ts b/packages/libs/dapp-kit/utils/__tests__/transforms.test.ts index 8568a97..801d294 100644 --- a/packages/libs/dapp-kit/utils/__tests__/transforms.test.ts +++ b/packages/libs/dapp-kit/utils/__tests__/transforms.test.ts @@ -19,7 +19,12 @@ import type { DynamicFieldNode, MoveObjectData, } from '../../graphql/types' -import { Assemblies, type DatahubGameInfo, State, type InventoryItem } from '../../types' +import { + Assemblies, + type DatahubGameInfo, + type InventoryItem, + State, +} from '../../types' import { getEnergyConfig, getEnergyUsageForType } from '../config' import { getDatahubGameInfo } from '../datahub' import { transformToAssembly, transformToCharacter } from '../transforms' @@ -204,7 +209,10 @@ describe('transformToAssembly — SmartStorageUnit', () => { }) it('orders storage inventory items by quantity from GraphQL data', async () => { - function createInventoryItem(typeId: number, quantity: number): InventoryItem { + function createInventoryItem( + typeId: number, + quantity: number, + ): InventoryItem { return { id: `item-${typeId}`, item_id: `item-${typeId}`, @@ -249,9 +257,9 @@ describe('transformToAssembly — SmartStorageUnit', () => { { type: Assemblies.SmartStorageUnit } > - expect(result?.storage.mainInventory.items.map((item) => item.type_id)).toEqual( - [82128, 88082, 77810], - ) + expect( + result?.storage.mainInventory.items.map((item) => item.type_id), + ).toEqual([82128, 88082, 77810]) }) }) diff --git a/packages/libs/dapp-kit/utils/inventory.ts b/packages/libs/dapp-kit/utils/inventory.ts index ac0f9f3..0886aed 100644 --- a/packages/libs/dapp-kit/utils/inventory.ts +++ b/packages/libs/dapp-kit/utils/inventory.ts @@ -1,10 +1,71 @@ -import type { InventoryItem } from '../types' +import type { Assemblies, AssemblyType, InventoryItem } from '../types' + +const typeVolumeM3ById = new Map() function toFiniteNumber(value: unknown, fallback = 0): number { const numberValue = Number(value) return Number.isFinite(numberValue) ? numberValue : fallback } +/** Cache Datahub volume (m³ per unit) for optimistic used-capacity updates. */ +export function setInventoryTypeVolumeM3(typeId: number, volumeM3: number) { + if (!Number.isFinite(typeId) || !Number.isFinite(volumeM3) || volumeM3 < 0) { + return + } + typeVolumeM3ById.set(typeId, volumeM3) +} + +export function getInventoryTypeVolumeM3(typeId: number): number | undefined { + return typeVolumeM3ById.get(typeId) +} + +/** Clear cached volumes (for tests). */ +export function clearInventoryTypeVolumeM3Cache() { + typeVolumeM3ById.clear() +} + +/** On-chain used_capacity is litres (dm³); Datahub volume is m³ per unit. */ +export function getInventoryQuantityVolumeDm3( + quantity: number, + typeId: number, +): number | null { + const volumeM3 = getInventoryTypeVolumeM3(typeId) + if (volumeM3 === undefined) return null + return Math.round(quantity * volumeM3 * 1000) +} + +export function adjustInventoryUsedCapacity( + usedCapacity: string, + quantity: number, + typeId: number, + operation: 'add' | 'subtract', +): string +export function adjustInventoryUsedCapacity( + usedCapacity: string | undefined, + quantity: number, + typeId: number, + operation: 'add' | 'subtract', +): string | undefined +export function adjustInventoryUsedCapacity( + usedCapacity: string | undefined, + quantity: number, + typeId: number, + operation: 'add' | 'subtract', +): string | undefined { + const volumeDeltaDm3 = getInventoryQuantityVolumeDm3(quantity, typeId) + if (volumeDeltaDm3 === null || usedCapacity === undefined) { + return usedCapacity + } + + const currentUsedCapacity = toFiniteNumber(usedCapacity) + const nextUsedCapacity = + operation === 'add' + ? currentUsedCapacity + volumeDeltaDm3 + : Math.max(0, currentUsedCapacity - volumeDeltaDm3) + + return String(nextUsedCapacity) +} + export function sortInventoryItemsByQuantity( items: InventoryItem[] | undefined, ): InventoryItem[] { @@ -34,6 +95,12 @@ function getInventoryItemSignatures(items: InventoryItem[] | undefined) { .sort(([leftTypeId], [rightTypeId]) => leftTypeId - rightTypeId) } +function getInventoryQuantitySignature( + items: InventoryItem[] | undefined, +): string { + return JSON.stringify(getInventoryItemSignatures(items)) +} + export function areInventoryItemListsEqual( leftItems: InventoryItem[] | undefined, rightItems: InventoryItem[] | undefined, @@ -50,3 +117,73 @@ export function areInventoryItemListsEqual( return leftTypeId === rightTypeId && leftQuantity === rightQuantity }) } + +function preserveStorageInventoryItemsWhenEqual( + currentAssembly: AssemblyType, + nextAssembly: AssemblyType, +): AssemblyType { + if ( + !areInventoryItemListsEqual( + currentAssembly.storage.mainInventory.items, + nextAssembly.storage.mainInventory.items, + ) + ) { + return nextAssembly + } + + return { + ...nextAssembly, + storage: { + ...nextAssembly.storage, + mainInventory: { + ...nextAssembly.storage.mainInventory, + items: currentAssembly.storage.mainInventory.items, + usedCapacity: + currentAssembly.storage.mainInventory.usedCapacity ?? + nextAssembly.storage.mainInventory.usedCapacity, + }, + }, + } +} + +/** + * Merge a GraphQL refetch into optimistic storage state without clobbering + * stream-updated items when the indexer has only moved ancillary fields first + * (e.g. used_capacity) while item quantities are still stale. + */ +export function mergeSmartStorageInventoryFromRefetch( + currentAssembly: AssemblyType | null, + nextAssembly: AssemblyType, + lastConfirmedInventorySignature: string | null, +): { + assembly: AssemblyType + inventorySignature: string +} { + const nextSignature = getInventoryQuantitySignature( + nextAssembly.storage.mainInventory.items, + ) + + if ( + currentAssembly && + lastConfirmedInventorySignature !== null && + nextSignature === lastConfirmedInventorySignature && + !areInventoryItemListsEqual( + currentAssembly.storage.mainInventory.items, + nextAssembly.storage.mainInventory.items, + ) + ) { + return { + assembly: currentAssembly, + inventorySignature: lastConfirmedInventorySignature, + } + } + + const mergedAssembly = currentAssembly + ? preserveStorageInventoryItemsWhenEqual(currentAssembly, nextAssembly) + : nextAssembly + + return { + assembly: mergedAssembly, + inventorySignature: nextSignature, + } +} diff --git a/packages/libs/ui-components/components/EveLinearBar.tsx b/packages/libs/ui-components/components/EveLinearBar.tsx index 8ea9745..825f655 100644 --- a/packages/libs/ui-components/components/EveLinearBar.tsx +++ b/packages/libs/ui-components/components/EveLinearBar.tsx @@ -5,16 +5,23 @@ const EveLinearBar = React.memo( nominator, denominator, label, + wholeNumbers = false, }: { nominator: number denominator: number label?: string + wholeNumbers?: boolean }) => { const percentage = denominator === 0 && nominator === 0 ? '0%' : `${(nominator / denominator) * 100}%` + const displayNominator = wholeNumbers ? Math.round(nominator) : nominator + const displayDenominator = wholeNumbers + ? Math.round(denominator) + : denominator + return (
@@ -25,8 +32,8 @@ const EveLinearBar = React.memo( />
- {Intl.NumberFormat().format(nominator)} /{' '} - {Intl.NumberFormat().format(denominator)} {label} + {Intl.NumberFormat().format(displayNominator)} /{' '} + {Intl.NumberFormat().format(displayDenominator)} {label}
) diff --git a/packages/libs/ui-components/modules/InventoryView.tsx b/packages/libs/ui-components/modules/InventoryView.tsx index 1f1b29b..ba90a0b 100644 --- a/packages/libs/ui-components/modules/InventoryView.tsx +++ b/packages/libs/ui-components/modules/InventoryView.tsx @@ -59,7 +59,9 @@ const InventoryView = React.memo( getDatahubGameInfo(typeId).then((info) => [typeId, info] as const), ), ).then((results) => { - if (!cancelled) setItemDetailsMap(new Map(results)) + if (!cancelled) { + setItemDetailsMap(new Map(results)) + } }) return () => { cancelled = true @@ -108,11 +110,11 @@ const InventoryView = React.memo( {!inventoryItems || inventoryItems.length === 0 ? (
Empty
) : ( - inventoryItems?.map((item, index) => ( + inventoryItems?.map((item) => ( )) )} @@ -121,7 +123,8 @@ const InventoryView = React.memo( ) : ( From c65ab65a7da6874d501d42dac25d4ae8f0967c6c Mon Sep 17 00:00:00 2001 From: ccp_raudur Date: Thu, 18 Jun 2026 13:46:46 +0000 Subject: [PATCH 3/6] fix: address PR review feedback on inventory stream and capacity bar --- .../providers/SmartObjectProvider.tsx | 10 ++- .../libs/dapp-kit/providers/eventRefresh.ts | 10 ++- .../utils/__tests__/inventoryEventBcs.test.ts | 71 +++++++++---------- .../ui-components/components/EveLinearBar.tsx | 4 +- .../ui-components/modules/InventoryView.tsx | 2 +- 5 files changed, 54 insertions(+), 43 deletions(-) diff --git a/packages/libs/dapp-kit/providers/SmartObjectProvider.tsx b/packages/libs/dapp-kit/providers/SmartObjectProvider.tsx index ccb2bd1..2a0ff14 100644 --- a/packages/libs/dapp-kit/providers/SmartObjectProvider.tsx +++ b/packages/libs/dapp-kit/providers/SmartObjectProvider.tsx @@ -341,7 +341,7 @@ const SmartObjectProvider = ({ children }: { children: ReactNode }) => { // Listen for inventory events via gRPC checkpoint stream. GraphQL polling above // remains the fallback/source of truth if stream events are missed or unavailable. useEffect(() => { - if (!selectedObjectId) return + if (!selectedObjectId || !isConnected) return const input: FetchObjectDataInput = isObjectIdDirect ? { objectId: selectedObjectId } @@ -438,7 +438,13 @@ const SmartObjectProvider = ({ children }: { children: ReactNode }) => { void unsubscribe() } } - }, [selectedObjectId, selectedTenant, isObjectIdDirect, fetchObjectData]) + }, [ + selectedObjectId, + selectedTenant, + isObjectIdDirect, + isConnected, + fetchObjectData, + ]) const handleRefetch = useCallback(async () => { if (!selectedObjectId) return diff --git a/packages/libs/dapp-kit/providers/eventRefresh.ts b/packages/libs/dapp-kit/providers/eventRefresh.ts index a74ed29..104861d 100644 --- a/packages/libs/dapp-kit/providers/eventRefresh.ts +++ b/packages/libs/dapp-kit/providers/eventRefresh.ts @@ -26,6 +26,8 @@ const CHECKPOINT_STREAM_RECONNECT_MS = 1_000 const CHECKPOINT_STREAM_MAX_SESSION_MS = 28_000 // Backup if a session stops yielding without closing cleanly. const CHECKPOINT_STREAM_IDLE_MS = 35_000 +// Bound the dedupe set so long-lived sessions don't grow it unbounded. +const CHECKPOINT_STREAM_MAX_SEEN_EVENTS = 5_000 const CHECKPOINT_STREAM_READ_MASK_PATHS = [ 'transactions.digest', 'transactions.events', @@ -351,7 +353,7 @@ function toFiniteNumber(value: unknown, fallback = 0): number { } function isOptimisticInventoryItem(item: InventoryItem): boolean { - return item.id.startsWith('optimistic-') || item.name.startsWith('Type ') + return item.id.startsWith('optimistic-') } function mergeInventoryItemsByTypeId(items: InventoryItem[]): InventoryItem[] { @@ -593,6 +595,12 @@ function collectUnseenInventoryEvents( const eventId = getInventoryEventId(event, checkpointSequence) if (seenEventIds.has(eventId)) return false seenEventIds.add(eventId) + // Evict oldest ids (insertion order) once the set exceeds its cap. + while (seenEventIds.size > CHECKPOINT_STREAM_MAX_SEEN_EVENTS) { + const oldest = seenEventIds.values().next().value + if (oldest === undefined) break + seenEventIds.delete(oldest) + } return true }) } diff --git a/packages/libs/dapp-kit/utils/__tests__/inventoryEventBcs.test.ts b/packages/libs/dapp-kit/utils/__tests__/inventoryEventBcs.test.ts index a211941..a9edd16 100644 --- a/packages/libs/dapp-kit/utils/__tests__/inventoryEventBcs.test.ts +++ b/packages/libs/dapp-kit/utils/__tests__/inventoryEventBcs.test.ts @@ -1,8 +1,3 @@ -import { - getJsonRpcFullnodeUrl, - JsonRpcHTTPTransport, -} from '@mysten/sui/jsonRpc' -import { fromBase64 } from '@mysten/sui/utils' import { describe, expect, it } from 'vitest' import { @@ -10,40 +5,42 @@ import { inventoryEventBcsToParsedJson, } from '../inventoryEventBcs' -const PACKAGE_ID = - '0x28b497559d65ab320d9da4613bf2498d5946b2c0ae3597ccfda3072ce127448c' - -describe('inventoryEventBcs', () => { - it('decodes mint and burn inventory events from chain BCS bytes', async () => { - const transport = new JsonRpcHTTPTransport({ - url: getJsonRpcFullnodeUrl('testnet'), - }) +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 +} - for (const eventName of ['ItemMintedEvent', 'ItemBurnedEvent'] as const) { - const result = await transport.request<{ - data: Array<{ - parsedJson: Record - bcs: string - bcsEncoding: 'base64' | 'base58' - }> - }>({ - method: 'suix_queryEvents', - params: [ - { MoveEventType: `${PACKAGE_ID}::inventory::${eventName}` }, - null, - 1, - true, - ], - }) +// Real ItemMintedEvent BCS bytes captured from testnet. Mint and burn share the +// InventoryMoveEvent struct, so this vector exercises the full decode path +// without a live RPC call. +const INVENTORY_MOVE_EVENT_BCS_HEX = + '34d08b4e1afe6a4babcc0642d6a676160df6b777b49214d5c964b4e874cc951b7a2dc1d4e8000000097374696c6c6e657373a60609a1b94ffca8ed2daf4963a2b9deffce23de76ef9f3d040d7250edb7b2c781bee37d00000000097374696c6c6e6573730000000000000000f22f010000000000f4010000' - const event = result.data[0] - if (!event) { - throw new Error(`No ${eventName} events returned from testnet`) - } - const bytes = fromBase64(event.bcs) - const decoded = decodeInventoryEventBcs(bytes) +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(event.parsedJson) - } + expect(inventoryEventBcsToParsedJson(decoded)).toEqual({ + assembly_id: + '0x34d08b4e1afe6a4babcc0642d6a676160df6b777b49214d5c964b4e874cc951b', + assembly_key: { + item_id: '1000001842554', + tenant: 'stillness', + }, + character_id: + '0xa60609a1b94ffca8ed2daf4963a2b9deffce23de76ef9f3d040d7250edb7b2c7', + character_key: { + item_id: '2112077441', + tenant: 'stillness', + }, + item_id: '0', + quantity: 500, + type_id: '77810', + }) }) }) diff --git a/packages/libs/ui-components/components/EveLinearBar.tsx b/packages/libs/ui-components/components/EveLinearBar.tsx index 825f655..2ebbe04 100644 --- a/packages/libs/ui-components/components/EveLinearBar.tsx +++ b/packages/libs/ui-components/components/EveLinearBar.tsx @@ -13,9 +13,9 @@ const EveLinearBar = React.memo( wholeNumbers?: boolean }) => { const percentage = - denominator === 0 && nominator === 0 + denominator <= 0 ? '0%' - : `${(nominator / denominator) * 100}%` + : `${Math.min(100, Math.max(0, (nominator / denominator) * 100))}%` const displayNominator = wholeNumbers ? Math.round(nominator) : nominator const displayDenominator = wholeNumbers diff --git a/packages/libs/ui-components/modules/InventoryView.tsx b/packages/libs/ui-components/modules/InventoryView.tsx index ba90a0b..628ceb2 100644 --- a/packages/libs/ui-components/modules/InventoryView.tsx +++ b/packages/libs/ui-components/modules/InventoryView.tsx @@ -114,7 +114,7 @@ const InventoryView = React.memo( )) )} From 6309bb8e2c4ddd56bc1a06b5bdec0bf7335ce294 Mon Sep 17 00:00:00 2001 From: ccp sondheim Date: Wed, 24 Jun 2026 12:26:22 +0000 Subject: [PATCH 4/6] cleanup --- .../providers/SmartObjectProvider.tsx | 26 +- .../libs/dapp-kit/providers/eventRefresh.ts | 820 ------------------ .../utils/__tests__/inventory.test.ts | 2 - .../dapp-kit/utils/__tests__/mapping.test.ts | 2 +- packages/libs/dapp-kit/utils/constants.ts | 1 - .../events}/__tests__/eventRefresh.test.ts | 12 +- .../dapp-kit/utils/events/checkpointStream.ts | 420 +++++++++ .../dapp-kit/utils/events/eventRefresh.ts | 98 +++ .../utils/events/inventoryEventHandlers.ts | 274 ++++++ packages/libs/dapp-kit/utils/inventory.ts | 122 ++- .../libs/dapp-kit/utils/inventoryEventBcs.ts | 2 + 11 files changed, 872 insertions(+), 907 deletions(-) delete mode 100644 packages/libs/dapp-kit/providers/eventRefresh.ts rename packages/libs/dapp-kit/{providers => utils/events}/__tests__/eventRefresh.test.ts (99%) create mode 100644 packages/libs/dapp-kit/utils/events/checkpointStream.ts create mode 100644 packages/libs/dapp-kit/utils/events/eventRefresh.ts create mode 100644 packages/libs/dapp-kit/utils/events/inventoryEventHandlers.ts diff --git a/packages/libs/dapp-kit/providers/SmartObjectProvider.tsx b/packages/libs/dapp-kit/providers/SmartObjectProvider.tsx index 2a0ff14..288e5f6 100644 --- a/packages/libs/dapp-kit/providers/SmartObjectProvider.tsx +++ b/packages/libs/dapp-kit/providers/SmartObjectProvider.tsx @@ -1,3 +1,4 @@ +import { TENANT_CONFIG, TenantId } from '@evefrontier/wallet-core/tenant' import type { ReactNode } from 'react' import { createContext, useCallback, useEffect, useRef, useState } from 'react' import { getAssemblyWithOwner, type MoveObjectData } from '../graphql' @@ -16,27 +17,24 @@ import { transformToAssembly, transformToCharacter, } from '../utils' -import { - DEFAULT_TENANT, - POLLING_INTERVAL, - TENANT_CONFIG, - TenantId, -} from '../utils/constants' +import { DEFAULT_TENANT, POLLING_INTERVAL } from '../utils/constants' import { getDatahubGameInfo } from '../utils/datahub' +import type { EventUnsubscribe } from '../utils/events/checkpointStream' import { - getInventoryTypeVolumeM3, - mergeSmartStorageInventoryFromRefetch, - setInventoryTypeVolumeM3, -} from '../utils/inventory' + createEventRefetchScheduler, + subscribeToInventoryEvents, +} from '../utils/events/eventRefresh' import { applyInventoryEventToAssembly, - createEventRefetchScheduler, - type EventUnsubscribe, getInventoryEventTarget, getInventoryEventTypes, isRelevantAssemblyInventoryEvent, - subscribeToInventoryEvents, -} from './eventRefresh' +} from '../utils/events/inventoryEventHandlers' +import { + getInventoryTypeVolumeM3, + mergeSmartStorageInventoryFromRefetch, + setInventoryTypeVolumeM3, +} from '../utils/inventory' const log = createLogger() diff --git a/packages/libs/dapp-kit/providers/eventRefresh.ts b/packages/libs/dapp-kit/providers/eventRefresh.ts deleted file mode 100644 index 104861d..0000000 --- a/packages/libs/dapp-kit/providers/eventRefresh.ts +++ /dev/null @@ -1,820 +0,0 @@ -import { SuiGrpcClient } from '@mysten/sui/grpc' -import type { SuiEvent } from '@mysten/sui/jsonRpc' - -import { Assemblies, type AssemblyType, type InventoryItem } from '../types' -import { - DEFAULT_GRAPHQL_NETWORK, - getEveWorldPackageId, - getSuiGrpcBaseUrl, -} from '../utils/constants' -import { - adjustInventoryUsedCapacity, - sortInventoryItemsByQuantity, -} from '../utils/inventory' -import { - decodeInventoryEventBcs, - inventoryEventBcsToParsedJson, -} from '../utils/inventoryEventBcs' -import { createLogger } from '../utils/logger' - -const log = createLogger() - -const INVENTORY_EVENT_NAMES = ['ItemBurnedEvent', 'ItemMintedEvent'] as const -const EVENT_REFETCH_DELAYS_MS = [250, 1500, 3500] as const -const CHECKPOINT_STREAM_RECONNECT_MS = 1_000 -// Rotate before the public fullnode ~30s stream cutoff. -const CHECKPOINT_STREAM_MAX_SESSION_MS = 28_000 -// Backup if a session stops yielding without closing cleanly. -const CHECKPOINT_STREAM_IDLE_MS = 35_000 -// Bound the dedupe set so long-lived sessions don't grow it unbounded. -const CHECKPOINT_STREAM_MAX_SEEN_EVENTS = 5_000 -const CHECKPOINT_STREAM_READ_MASK_PATHS = [ - 'transactions.digest', - 'transactions.events', - 'sequence_number', -] as const - -type AssemblyEventKey = { - item_id?: string | number - tenant?: string -} - -type AssemblyEventPayload = { - assembly_id?: string - assembly_key?: AssemblyEventKey - item_id?: string - quantity?: number - type_id?: string | number -} - -type ProtobufValue = { - kind?: { - oneofKind?: string - nullValue?: unknown - numberValue?: number - stringValue?: string - boolValue?: boolean - structValue?: ProtobufStruct - listValue?: { values?: ProtobufValue[] } - } -} - -type ProtobufStruct = { - fields?: Record -} - -export type CheckpointStreamTransaction = { - digest?: string - events?: { - events?: Array<{ - eventType?: string - event_type?: string - json?: ProtobufValue - contents?: { - value?: Uint8Array - } - }> - } -} - -export type CheckpointStreamMessage = { - checkpoint?: { - sequenceNumber?: number | bigint - sequence_number?: number | bigint - transactions?: CheckpointStreamTransaction[] - } -} - -export type AssemblyEventTarget = { - eventTypes: readonly string[] - itemId?: string - objectId?: string - tenant?: string -} - -export type EventUnsubscribe = () => Promise -export type ScheduledRefetch = (() => void) & { cancel: () => void } -export type InventoryEvent = Pick -export type InventoryEventBatchHandler = (events: InventoryEvent[]) => void -export type CheckpointGapHandler = ( - lastCheckpoint: number, - nextCheckpoint: number, -) => void -export type CheckpointStreamSession = { - cancel: () => void - responses: AsyncIterable -} -export type SubscribeCheckpoints = (request: { - readMask: { paths: readonly string[] } -}) => CheckpointStreamSession - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null -} - -function protobufValueToJson(value: ProtobufValue | undefined): unknown { - const kind = value?.kind - if (!kind?.oneofKind) return null - - switch (kind.oneofKind) { - case 'nullValue': - return null - case 'numberValue': - return kind.numberValue - case 'stringValue': - return kind.stringValue - case 'boolValue': - return kind.boolValue - case 'structValue': - return protobufStructToJson(kind.structValue) - case 'listValue': - return (kind.listValue?.values ?? []).map((entry) => - protobufValueToJson(entry), - ) - default: - return null - } -} - -function protobufStructToJson( - struct: ProtobufStruct | undefined, -): Record { - const result: Record = {} - - Object.entries(struct?.fields ?? {}).forEach(([key, value]) => { - result[key] = protobufValueToJson(value) - }) - - return result -} - -function parseAssemblyEventPayload( - event: Pick, -): AssemblyEventPayload | null { - const parsedJson = event.parsedJson - if (!isRecord(parsedJson)) return null - - const assemblyKeyRaw = parsedJson['assembly_key'] - let assemblyKey: AssemblyEventKey | undefined - if (isRecord(assemblyKeyRaw)) { - const itemId = assemblyKeyRaw['item_id'] - const tenant = assemblyKeyRaw['tenant'] - if ( - (typeof itemId === 'string' || typeof itemId === 'number') && - typeof tenant === 'string' - ) { - assemblyKey = { item_id: itemId, tenant } - } - } - - const payload: AssemblyEventPayload = {} - - if (typeof parsedJson['assembly_id'] === 'string') { - payload.assembly_id = parsedJson['assembly_id'] - } - if (assemblyKey) { - payload.assembly_key = assemblyKey - } - if (typeof parsedJson['item_id'] === 'string') { - payload.item_id = parsedJson['item_id'] - } - if (typeof parsedJson['quantity'] === 'number') { - payload.quantity = parsedJson['quantity'] - } - if ( - typeof parsedJson['type_id'] === 'string' || - typeof parsedJson['type_id'] === 'number' - ) { - payload.type_id = parsedJson['type_id'] - } - - return payload -} - -function normalizeObjectId(value: string | undefined) { - return value?.toLowerCase() -} - -function parseInventoryEventPayloadFromStream(event: { - json?: ProtobufValue - contents?: { - value?: Uint8Array - } -}): Record | null { - const parsedJsonFromProtobuf = protobufValueToJson(event.json) - if (isRecord(parsedJsonFromProtobuf)) { - return parsedJsonFromProtobuf - } - - const bcsBytes = event.contents?.value - if (!bcsBytes) return null - - try { - return inventoryEventBcsToParsedJson(decodeInventoryEventBcs(bcsBytes)) - } catch { - return null - } -} - -function wait(ms: number, signal?: AbortSignal) { - return new Promise((resolve, reject) => { - const timeoutId = setTimeout(() => { - signal?.removeEventListener('abort', onAbort) - resolve() - }, ms) - - const onAbort = () => { - clearTimeout(timeoutId) - reject(signal?.reason) - } - - if (signal?.aborted) { - clearTimeout(timeoutId) - reject(signal.reason) - return - } - - signal?.addEventListener('abort', onAbort, { once: true }) - }) -} - -export function getInventoryEventTypes( - packageId = getEveWorldPackageId(), -): string[] { - return INVENTORY_EVENT_NAMES.map( - (eventName) => `${packageId}::inventory::${eventName}`, - ) -} - -export function isRelevantAssemblyInventoryEvent( - event: Pick, - target: AssemblyEventTarget, -): boolean { - if (!target.eventTypes.includes(event.type)) return false - - const payload = parseAssemblyEventPayload(event) - if (!payload) return false - - if ( - target.objectId && - normalizeObjectId(payload.assembly_id) === - normalizeObjectId(target.objectId) - ) { - return true - } - - if (!target.itemId || !target.tenant) return false - - const eventItemId = String(payload.assembly_key?.item_id ?? '') - const targetItemId = String(target.itemId) - - return ( - eventItemId === targetItemId && - payload.assembly_key?.tenant === target.tenant - ) -} - -export function getInventoryEventTarget({ - assembly, - eventTypes, - isObjectIdDirect, - selectedObjectId, - selectedTenant, -}: { - assembly: AssemblyType | null - eventTypes: readonly string[] - isObjectIdDirect: boolean - selectedObjectId: string - selectedTenant: string -}): AssemblyEventTarget { - if (assembly?.type === Assemblies.SmartStorageUnit) { - if (assembly.id) { - return { eventTypes, objectId: assembly.id } - } - - if (assembly.item_id) { - return { - eventTypes, - itemId: String(assembly.item_id), - tenant: selectedTenant, - } - } - } - - if (isObjectIdDirect) { - return { eventTypes, objectId: selectedObjectId } - } - - return { - eventTypes, - itemId: selectedObjectId, - tenant: selectedTenant, - } -} - -function parseInventoryEventDelta( - event: Pick, -) { - const payload = parseAssemblyEventPayload(event) - if (!payload) return null - - const typeId = Number(payload.type_id) - const quantity = Number(payload.quantity) - if (!Number.isFinite(typeId) || !Number.isFinite(quantity) || quantity <= 0) { - return null - } - - if (event.type.endsWith('::inventory::ItemMintedEvent')) { - return { - itemId: payload.item_id ?? String(typeId), - quantity, - tenant: payload.assembly_key?.tenant ?? '', - typeId, - operation: 'add' as const, - } - } - - if (event.type.endsWith('::inventory::ItemBurnedEvent')) { - return { - itemId: payload.item_id ?? String(typeId), - quantity, - tenant: payload.assembly_key?.tenant ?? '', - typeId, - operation: 'subtract' as const, - } - } - - return null -} - -function toFiniteNumber(value: unknown, fallback = 0): number { - const numberValue = Number(value) - return Number.isFinite(numberValue) ? numberValue : fallback -} - -function isOptimisticInventoryItem(item: InventoryItem): boolean { - return item.id.startsWith('optimistic-') -} - -function mergeInventoryItemsByTypeId(items: InventoryItem[]): InventoryItem[] { - const itemsByTypeId = new Map() - - items.forEach((item) => { - const typeId = toFiniteNumber(item.type_id, Number.NaN) - if (!Number.isFinite(typeId)) return - - const existingItem = itemsByTypeId.get(typeId) - if (!existingItem) { - itemsByTypeId.set(typeId, { - ...item, - quantity: toFiniteNumber(item.quantity), - type_id: typeId, - }) - return - } - - const preferredItem = - isOptimisticInventoryItem(existingItem) && - !isOptimisticInventoryItem(item) - ? item - : existingItem - - const quantity = - toFiniteNumber(existingItem.quantity) + toFiniteNumber(item.quantity) - if (quantity <= 0) { - itemsByTypeId.delete(typeId) - return - } - - itemsByTypeId.set(typeId, { - ...preferredItem, - quantity, - type_id: typeId, - }) - }) - - return Array.from(itemsByTypeId.values()) -} - -export function applyInventoryEventToAssembly( - assembly: AssemblyType | null, - event: Pick, -): AssemblyType | null { - if (!assembly || assembly.type !== Assemblies.SmartStorageUnit) { - return assembly - } - - const delta = parseInventoryEventDelta(event) - if (!delta) return assembly - - const items = assembly.storage.mainInventory.items ?? [] - const itemIndex = items.findIndex( - (item) => toFiniteNumber(item.type_id) === delta.typeId, - ) - - if (itemIndex === -1 && delta.operation === 'subtract') { - return assembly - } - - const nextItems = - itemIndex === -1 - ? [ - ...items, - { - id: `optimistic-${delta.typeId}`, - item_id: delta.itemId, - location: { location_hash: '' }, - quantity: delta.quantity, - tenant: delta.tenant, - type_id: delta.typeId, - name: `Type ${delta.typeId}`, - } satisfies InventoryItem, - ] - : items.flatMap((item, index) => { - if (index !== itemIndex) return [item] - - const quantity = - delta.operation === 'add' - ? toFiniteNumber(item.quantity) + delta.quantity - : Math.max(0, toFiniteNumber(item.quantity) - delta.quantity) - - if (quantity === 0) return [] - - return [ - { - ...item, - quantity, - }, - ] - }) - - return { - ...assembly, - storage: { - ...assembly.storage, - mainInventory: { - ...assembly.storage.mainInventory, - usedCapacity: adjustInventoryUsedCapacity( - assembly.storage.mainInventory.usedCapacity, - delta.quantity, - delta.typeId, - delta.operation, - ), - items: sortInventoryItemsByQuantity( - mergeInventoryItemsByTypeId(nextItems), - ), - }, - }, - } -} - -export function createEventRefetchScheduler( - refetch: () => Promise, - delaysMs: readonly number[] = EVENT_REFETCH_DELAYS_MS, - onError?: (error: unknown) => void, -): ScheduledRefetch { - let timeouts: ReturnType[] = [] - - const scheduledRefetch = () => { - scheduledRefetch.cancel() - - timeouts = delaysMs.map((delayMs) => { - const timeoutId = setTimeout(() => { - timeouts = timeouts.filter((timeout) => timeout !== timeoutId) - refetch().catch((error) => onError?.(error)) - }, delayMs) - return timeoutId - }) - } - - scheduledRefetch.cancel = () => { - for (const timeout of timeouts) { - clearTimeout(timeout) - } - timeouts = [] - } - - return scheduledRefetch -} - -function getStreamEventType(event: { - eventType?: string - event_type?: string -}) { - return event.eventType ?? event.event_type ?? '' -} - -function getCheckpointSequenceNumber( - checkpoint: CheckpointStreamMessage['checkpoint'], -) { - const sequenceNumber = - checkpoint?.sequenceNumber ?? checkpoint?.sequence_number - if (typeof sequenceNumber === 'bigint') { - return Number(sequenceNumber) - } - return typeof sequenceNumber === 'number' && Number.isFinite(sequenceNumber) - ? sequenceNumber - : null -} - -async function readNextCheckpointMessage( - iterator: AsyncIterator, - session: CheckpointStreamSession, - idleMs: number, - signal?: AbortSignal, -): Promise> { - const pendingNext = iterator.next() - - if (idleMs <= 0 || !Number.isFinite(idleMs)) { - return pendingNext - } - - try { - return await Promise.race([ - pendingNext, - wait(idleMs, signal).then(() => { - session.cancel() - throw new Error('checkpoint stream idle timeout') - }), - ]) - } catch (error) { - session.cancel() - throw error - } -} - -export function extractInventoryEventsFromCheckpoint( - checkpoint: CheckpointStreamMessage['checkpoint'], - eventTypes: readonly string[], -): InventoryEvent[] { - const inventoryEvents: InventoryEvent[] = [] - const checkpointSequence = getCheckpointSequenceNumber(checkpoint) - - ;(checkpoint?.transactions ?? []).forEach((transaction, txIndex) => { - const txDigest = - transaction.digest ?? - (checkpointSequence != null - ? `checkpoint-${checkpointSequence}-${txIndex}` - : `checkpoint-tx-${txIndex}`) - - ;(transaction.events?.events ?? []).forEach((event, eventSeq) => { - const type = getStreamEventType(event) - if (!eventTypes.includes(type)) return - - const parsedJson = parseInventoryEventPayloadFromStream(event) - if (!parsedJson) return - - inventoryEvents.push({ - id: { txDigest, eventSeq: String(eventSeq) }, - type, - parsedJson, - }) - }) - }) - - return inventoryEvents -} - -function getInventoryEventId( - event: InventoryEvent, - checkpointSequence?: number | null, -) { - if (event.id.txDigest) { - return `${event.id.txDigest}:${event.id.eventSeq}` - } - - return `checkpoint-${checkpointSequence ?? 'unknown'}:${event.id.eventSeq}:${event.type}` -} - -function collectUnseenInventoryEvents( - events: InventoryEvent[], - seenEventIds: Set, - checkpointSequence?: number | null, -) { - return events.filter((event) => { - const eventId = getInventoryEventId(event, checkpointSequence) - if (seenEventIds.has(eventId)) return false - seenEventIds.add(eventId) - // Evict oldest ids (insertion order) once the set exceeds its cap. - while (seenEventIds.size > CHECKPOINT_STREAM_MAX_SEEN_EVENTS) { - const oldest = seenEventIds.values().next().value - if (oldest === undefined) break - seenEventIds.delete(oldest) - } - return true - }) -} - -export function createInventoryCheckpointStream({ - eventTypes, - idleMs = CHECKPOINT_STREAM_IDLE_MS, - maxSessionMs = CHECKPOINT_STREAM_MAX_SESSION_MS, - onError, - onEvents, - onGap, - reconnectMs = CHECKPOINT_STREAM_RECONNECT_MS, - signal, - subscribeCheckpoints, -}: { - eventTypes: readonly string[] - idleMs?: number - maxSessionMs?: number - onError?: (error: unknown) => void - onEvents?: InventoryEventBatchHandler - onGap?: CheckpointGapHandler - reconnectMs?: number - signal?: AbortSignal - subscribeCheckpoints: SubscribeCheckpoints -}): EventUnsubscribe { - let stopped = false - let streamTask: Promise | null = null - let activeSession: CheckpointStreamSession | null = null - - const run = async () => { - const seenEventIds = new Set() - let lastCheckpointSequence: number | null = null - let isInitialSession = true - - while (!stopped && !signal?.aborted) { - let session: CheckpointStreamSession | null = null - - try { - session = subscribeCheckpoints({ - readMask: { paths: CHECKPOINT_STREAM_READ_MASK_PATHS }, - }) - activeSession = session - - const iterator = session.responses[Symbol.asyncIterator]() - const sessionStartedAt = Date.now() - - while (!stopped && !signal?.aborted) { - const elapsedMs = Date.now() - sessionStartedAt - const hasRotationLimit = maxSessionMs > 0 - const hasIdleLimit = idleMs > 0 - const msUntilRotation = hasRotationLimit - ? maxSessionMs - elapsedMs - : Number.POSITIVE_INFINITY - const readTimeoutMs = - hasRotationLimit || hasIdleLimit - ? Math.min( - hasIdleLimit ? idleMs : Number.POSITIVE_INFINITY, - msUntilRotation, - ) - : null - - if (readTimeoutMs != null && readTimeoutMs <= 0) { - break - } - - let nextCheckpoint: IteratorResult - - try { - nextCheckpoint = await readNextCheckpointMessage( - iterator, - session, - readTimeoutMs ?? 0, - signal, - ) - } catch (error) { - if (stopped || signal?.aborted) return - - if ( - hasRotationLimit && - Date.now() - sessionStartedAt >= maxSessionMs - ) { - break - } - - onError?.(error) - break - } - - if (nextCheckpoint.done) { - break - } - - if (!('value' in nextCheckpoint) || !nextCheckpoint.value) continue - - const sequenceNumber = getCheckpointSequenceNumber( - nextCheckpoint.value.checkpoint, - ) - - if (isInitialSession) { - isInitialSession = false - if (sequenceNumber != null) { - lastCheckpointSequence = sequenceNumber - } - continue - } - - if ( - lastCheckpointSequence != null && - sequenceNumber != null && - sequenceNumber > lastCheckpointSequence + 1 - ) { - onGap?.(lastCheckpointSequence, sequenceNumber) - } - - const inventoryEvents = collectUnseenInventoryEvents( - extractInventoryEventsFromCheckpoint( - nextCheckpoint.value.checkpoint, - eventTypes, - ), - seenEventIds, - sequenceNumber, - ) - - if (inventoryEvents.length > 0) { - onEvents?.(inventoryEvents) - } - - if (sequenceNumber != null) { - lastCheckpointSequence = sequenceNumber - } - } - } catch (error) { - if (stopped || signal?.aborted) return - - onError?.(error) - } finally { - session?.cancel() - if (activeSession === session) { - activeSession = null - } - } - - if (stopped || signal?.aborted) return - - try { - await wait(reconnectMs, signal) - } catch { - return - } - } - } - - streamTask = run().catch((error) => { - log.error( - '[DappKit] Inventory checkpoint stream task exited unexpectedly:', - error, - ) - }) - - return async () => { - stopped = true - activeSession?.cancel() - await streamTask - } -} - -export async function subscribeToInventoryEvents({ - eventTypes, - network = DEFAULT_GRAPHQL_NETWORK, - onEvents, - onGap, - signal, -}: { - eventTypes: readonly string[] - network?: string - onEvents?: InventoryEventBatchHandler - onGap?: CheckpointGapHandler - signal?: AbortSignal -}): Promise { - const unsubscribe = createInventoryCheckpointStream({ - eventTypes, - ...(onEvents !== undefined ? { onEvents } : {}), - ...(onGap !== undefined ? { onGap } : {}), - ...(signal !== undefined ? { signal } : {}), - onError: (error) => { - log.warn('[DappKit] Inventory checkpoint stream error:', error) - }, - subscribeCheckpoints: (request) => { - const abortController = new AbortController() - const grpcClient = new SuiGrpcClient({ - network, - baseUrl: getSuiGrpcBaseUrl(network), - }) - const call = grpcClient.subscriptionService.subscribeCheckpoints( - { - readMask: { - paths: [...request.readMask.paths], - }, - }, - { abort: abortController.signal }, - ) - - return { - responses: call.responses as AsyncIterable, - cancel: () => { - abortController.abort() - }, - } - }, - }) - - signal?.addEventListener('abort', () => { - void unsubscribe() - }) - - return unsubscribe -} diff --git a/packages/libs/dapp-kit/utils/__tests__/inventory.test.ts b/packages/libs/dapp-kit/utils/__tests__/inventory.test.ts index 0787548..21b1c58 100644 --- a/packages/libs/dapp-kit/utils/__tests__/inventory.test.ts +++ b/packages/libs/dapp-kit/utils/__tests__/inventory.test.ts @@ -6,7 +6,6 @@ import { adjustInventoryUsedCapacity, areInventoryItemListsEqual, clearInventoryTypeVolumeM3Cache, - getInventoryQuantityVolumeDm3, mergeSmartStorageInventoryFromRefetch, setInventoryTypeVolumeM3, } from '../inventory' @@ -48,7 +47,6 @@ describe('inventory utilities', () => { it('adjusts used capacity from cached type volume on mint and burn', () => { setInventoryTypeVolumeM3(77810, 0.1) - expect(getInventoryQuantityVolumeDm3(10, 77810)).toBe(1000) expect(adjustInventoryUsedCapacity('1000', 10, 77810, 'add')).toBe('2000') expect(adjustInventoryUsedCapacity('2000', 10, 77810, 'subtract')).toBe( '1000', diff --git a/packages/libs/dapp-kit/utils/__tests__/mapping.test.ts b/packages/libs/dapp-kit/utils/__tests__/mapping.test.ts index 307d366..892df8d 100644 --- a/packages/libs/dapp-kit/utils/__tests__/mapping.test.ts +++ b/packages/libs/dapp-kit/utils/__tests__/mapping.test.ts @@ -2,7 +2,6 @@ import { bcs } from '@mysten/sui/bcs' import { deriveObjectID } from '@mysten/sui/utils' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Assemblies, State } from '../../types' -import { TENANT_CONFIG, TenantId } from '../constants' import { getAssemblyType, parseStatus } from '../mapping' // Mock env vars for testing @@ -14,6 +13,7 @@ vi.mock('../../graphql/client', () => ({ getSingletonObjectByType: vi.fn(), })) +import { TENANT_CONFIG, TenantId } from '@evefrontier/wallet-core/tenant' import { getSingletonObjectByType } from '../../graphql/client' describe('mapping utilities', () => { diff --git a/packages/libs/dapp-kit/utils/constants.ts b/packages/libs/dapp-kit/utils/constants.ts index 76c2e16..daa0ae1 100644 --- a/packages/libs/dapp-kit/utils/constants.ts +++ b/packages/libs/dapp-kit/utils/constants.ts @@ -12,7 +12,6 @@ export { export { DEFAULT_TENANT, EVE_PACKAGE_ID_BY_TENANT, - TENANT_CONFIG, TenantId, } from '@evefrontier/wallet-core/tenant' diff --git a/packages/libs/dapp-kit/providers/__tests__/eventRefresh.test.ts b/packages/libs/dapp-kit/utils/events/__tests__/eventRefresh.test.ts similarity index 99% rename from packages/libs/dapp-kit/providers/__tests__/eventRefresh.test.ts rename to packages/libs/dapp-kit/utils/events/__tests__/eventRefresh.test.ts index 3730655..c15b1f8 100644 --- a/packages/libs/dapp-kit/providers/__tests__/eventRefresh.test.ts +++ b/packages/libs/dapp-kit/utils/events/__tests__/eventRefresh.test.ts @@ -1,19 +1,21 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { Assemblies, type AssemblyType } from '../../types' +import { Assemblies, type AssemblyType } from '../../../types' import { clearInventoryTypeVolumeM3Cache, setInventoryTypeVolumeM3, -} from '../../utils/inventory' +} from '../../inventory' import { - applyInventoryEventToAssembly, type CheckpointStreamMessage, - createEventRefetchScheduler, createInventoryCheckpointStream, extractInventoryEventsFromCheckpoint, +} from '../checkpointStream' +import { createEventRefetchScheduler } from '../eventRefresh' +import { + applyInventoryEventToAssembly, getInventoryEventTarget, getInventoryEventTypes, isRelevantAssemblyInventoryEvent, -} from '../eventRefresh' +} from '../inventoryEventHandlers' const PACKAGE_ID = '0x28b497559d65ab320d9da4613bf2498d5946b2c0ae3597ccfda3072ce127448c' diff --git a/packages/libs/dapp-kit/utils/events/checkpointStream.ts b/packages/libs/dapp-kit/utils/events/checkpointStream.ts new file mode 100644 index 0000000..2f23cea --- /dev/null +++ b/packages/libs/dapp-kit/utils/events/checkpointStream.ts @@ -0,0 +1,420 @@ +import type { SuiEvent } from '@mysten/sui/jsonRpc' + +import { + decodeInventoryEventBcs, + inventoryEventBcsToParsedJson, +} from '../inventoryEventBcs' +import { createLogger } from '../logger' + +const CHECKPOINT_STREAM_RECONNECT_MS = 1_000 +// Rotate before the public fullnode ~30s stream cutoff. +const CHECKPOINT_STREAM_MAX_SESSION_MS = 28_000 +// Backup if a session stops yielding without closing cleanly. +const CHECKPOINT_STREAM_IDLE_MS = 35_000 +// Bound the dedupe set so long-lived sessions don't grow it unbounded. +const CHECKPOINT_STREAM_MAX_SEEN_EVENTS = 5_000 +const CHECKPOINT_STREAM_READ_MASK_PATHS = [ + 'transactions.digest', + 'transactions.events', + 'sequence_number', +] as const + +type ProtobufValue = { + kind?: { + oneofKind?: string + nullValue?: unknown + numberValue?: number + stringValue?: string + boolValue?: boolean + structValue?: ProtobufStruct + listValue?: { values?: ProtobufValue[] } + } +} + +type ProtobufStruct = { + fields?: Record +} + +type StreamState = { + isInitialSession: boolean + lastCheckpointSequence: number | null + seenEventIds: Set +} + +export type CheckpointStreamTransaction = { + digest?: string + events?: { + events?: Array<{ + eventType?: string + event_type?: string + json?: ProtobufValue + contents?: { + value?: Uint8Array + } + }> + } +} + +export type CheckpointStreamMessage = { + checkpoint?: { + sequenceNumber?: number | bigint + sequence_number?: number | bigint + transactions?: CheckpointStreamTransaction[] + } +} + +export type EventUnsubscribe = () => Promise +export type InventoryEvent = Pick +export type InventoryEventBatchHandler = (events: InventoryEvent[]) => void +export type CheckpointGapHandler = ( + lastCheckpoint: number, + nextCheckpoint: number, +) => void +export type CheckpointStreamSession = { + cancel: () => void + responses: AsyncIterable +} +export type SubscribeCheckpoints = (request: { + readMask: { paths: readonly string[] } +}) => CheckpointStreamSession + +const log = createLogger() + +function protobufValueToJson(value: ProtobufValue | undefined): unknown { + const kind = value?.kind + if (!kind?.oneofKind) return null + if (kind.oneofKind === 'nullValue') return null + if (kind.oneofKind === 'structValue') + return protobufStructToJson(kind.structValue) + if (kind.oneofKind === 'listValue') { + return (kind.listValue?.values ?? []).map(protobufValueToJson) + } + // Scalar kinds: numberValue, stringValue, boolValue + return (kind as Record)[kind.oneofKind] ?? null +} + +function protobufStructToJson( + struct: ProtobufStruct | undefined, +): Record { + return Object.fromEntries( + Object.entries(struct?.fields ?? {}).map(([key, value]) => [ + key, + protobufValueToJson(value), + ]), + ) +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function parseInventoryEventPayloadFromStream(event: { + json?: ProtobufValue + contents?: { value?: Uint8Array } +}): Record | null { + const fromProtobuf = protobufValueToJson(event.json) + if (isRecord(fromProtobuf)) return fromProtobuf + + const bcsBytes = event.contents?.value + if (!bcsBytes) return null + + try { + return inventoryEventBcsToParsedJson(decodeInventoryEventBcs(bcsBytes)) + } catch { + return null + } +} + +function wait(ms: number, signal?: AbortSignal) { + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + signal?.removeEventListener('abort', onAbort) + resolve() + }, ms) + + const onAbort = () => { + clearTimeout(timeoutId) + reject(signal?.reason) + } + + if (signal?.aborted) { + clearTimeout(timeoutId) + reject(signal.reason) + return + } + + signal?.addEventListener('abort', onAbort, { once: true }) + }) +} + +function getStreamEventType(event: { + eventType?: string + event_type?: string +}) { + return event.eventType ?? event.event_type ?? '' +} + +function getCheckpointSequenceNumber( + checkpoint: CheckpointStreamMessage['checkpoint'], +): number | null { + const seq = checkpoint?.sequenceNumber ?? checkpoint?.sequence_number + if (typeof seq === 'bigint') return Number(seq) + return typeof seq === 'number' && Number.isFinite(seq) ? seq : null +} + +function getReadTimeoutMs( + maxSessionMs: number, + idleMs: number, + elapsedMs: number, +): number | null { + if (maxSessionMs <= 0 && idleMs <= 0) return null + const msUntilRotation = + maxSessionMs > 0 ? maxSessionMs - elapsedMs : Number.POSITIVE_INFINITY + return Math.min( + idleMs > 0 ? idleMs : Number.POSITIVE_INFINITY, + msUntilRotation, + ) +} + +async function readNextCheckpointMessage( + iterator: AsyncIterator, + session: CheckpointStreamSession, + timeoutMs: number, + signal?: AbortSignal, +): Promise> { + if (timeoutMs <= 0 || !Number.isFinite(timeoutMs)) { + return iterator.next() + } + + const timeout = wait(timeoutMs, signal).then(() => { + session.cancel() + throw new Error('checkpoint stream idle timeout') + }) + + return Promise.race([iterator.next(), timeout]) +} + +function getInventoryEventId( + event: InventoryEvent, + checkpointSequence?: number | null, +) { + if (event.id.txDigest) { + return `${event.id.txDigest}:${event.id.eventSeq}` + } + return `checkpoint-${checkpointSequence ?? 'unknown'}:${event.id.eventSeq}:${event.type}` +} + +function collectUnseenInventoryEvents( + events: InventoryEvent[], + seenEventIds: Set, + checkpointSequence?: number | null, +) { + return events.filter((event) => { + const eventId = getInventoryEventId(event, checkpointSequence) + if (seenEventIds.has(eventId)) return false + seenEventIds.add(eventId) + // Evict oldest ids (insertion order) once the set exceeds its cap. + while (seenEventIds.size > CHECKPOINT_STREAM_MAX_SEEN_EVENTS) { + const oldest = seenEventIds.values().next().value + if (oldest === undefined) break + seenEventIds.delete(oldest) + } + return true + }) +} + +function processCheckpointMessage( + message: CheckpointStreamMessage, + state: StreamState, + eventTypes: readonly string[], + onEvents: InventoryEventBatchHandler | undefined, + onGap: CheckpointGapHandler | undefined, +): void { + const sequenceNumber = getCheckpointSequenceNumber(message.checkpoint) + + if (state.isInitialSession) { + state.isInitialSession = false + if (sequenceNumber != null) state.lastCheckpointSequence = sequenceNumber + return + } + + if ( + state.lastCheckpointSequence != null && + sequenceNumber != null && + sequenceNumber > state.lastCheckpointSequence + 1 + ) { + onGap?.(state.lastCheckpointSequence, sequenceNumber) + } + + const events = collectUnseenInventoryEvents( + extractInventoryEventsFromCheckpoint(message.checkpoint, eventTypes), + state.seenEventIds, + sequenceNumber, + ) + if (events.length > 0) onEvents?.(events) + if (sequenceNumber != null) state.lastCheckpointSequence = sequenceNumber +} + +async function runSession( + session: CheckpointStreamSession, + state: StreamState, + config: { + eventTypes: readonly string[] + idleMs: number + maxSessionMs: number + isStopped: () => boolean + onError: ((error: unknown) => void) | undefined + onEvents: InventoryEventBatchHandler | undefined + onGap: CheckpointGapHandler | undefined + signal: AbortSignal | undefined + }, +): Promise { + const { + eventTypes, + idleMs, + maxSessionMs, + isStopped, + onError, + onEvents, + onGap, + signal, + } = config + const iterator = session.responses[Symbol.asyncIterator]() + const sessionStartedAt = Date.now() + + while (!isStopped() && !signal?.aborted) { + const timeoutMs = getReadTimeoutMs( + maxSessionMs, + idleMs, + Date.now() - sessionStartedAt, + ) + if (timeoutMs != null && timeoutMs <= 0) return + + let result: IteratorResult + try { + result = await readNextCheckpointMessage( + iterator, + session, + timeoutMs ?? 0, + signal, + ) + } catch (error) { + if (isStopped() || signal?.aborted) return + if (maxSessionMs > 0 && Date.now() - sessionStartedAt >= maxSessionMs) + return + onError?.(error) + return + } + + if (result.done || !result.value) return + + processCheckpointMessage(result.value, state, eventTypes, onEvents, onGap) + } +} + +export function extractInventoryEventsFromCheckpoint( + checkpoint: CheckpointStreamMessage['checkpoint'], + eventTypes: readonly string[], +): InventoryEvent[] { + const checkpointSequence = getCheckpointSequenceNumber(checkpoint) + + return (checkpoint?.transactions ?? []).flatMap((transaction, txIndex) => { + const txDigest = + transaction.digest ?? + (checkpointSequence != null + ? `checkpoint-${checkpointSequence}-${txIndex}` + : `checkpoint-tx-${txIndex}`) + + return (transaction.events?.events ?? []).flatMap((event, eventSeq) => { + const type = getStreamEventType(event) + if (!eventTypes.includes(type)) return [] + + const parsedJson = parseInventoryEventPayloadFromStream(event) + if (!parsedJson) return [] + + return [ + { id: { txDigest, eventSeq: String(eventSeq) }, type, parsedJson }, + ] + }) + }) +} + +export function createInventoryCheckpointStream({ + eventTypes, + idleMs = CHECKPOINT_STREAM_IDLE_MS, + maxSessionMs = CHECKPOINT_STREAM_MAX_SESSION_MS, + onError, + onEvents, + onGap, + reconnectMs = CHECKPOINT_STREAM_RECONNECT_MS, + signal, + subscribeCheckpoints, +}: { + eventTypes: readonly string[] + idleMs?: number + maxSessionMs?: number + onError?: (error: unknown) => void + onEvents?: InventoryEventBatchHandler + onGap?: CheckpointGapHandler + reconnectMs?: number + signal?: AbortSignal + subscribeCheckpoints: SubscribeCheckpoints +}): EventUnsubscribe { + let stopped = false + let activeSession: CheckpointStreamSession | null = null + + const run = async () => { + const state: StreamState = { + isInitialSession: true, + lastCheckpointSequence: null, + seenEventIds: new Set(), + } + + while (!stopped && !signal?.aborted) { + const session = subscribeCheckpoints({ + readMask: { paths: CHECKPOINT_STREAM_READ_MASK_PATHS }, + }) + activeSession = session + + try { + await runSession(session, state, { + eventTypes, + idleMs, + maxSessionMs, + isStopped: () => stopped, + onError, + onEvents, + onGap, + signal, + }) + } catch (error) { + if (stopped || signal?.aborted) return + onError?.(error) + } finally { + session.cancel() + if (activeSession === session) activeSession = null + } + + if (stopped || signal?.aborted) return + + try { + await wait(reconnectMs, signal) + } catch { + return + } + } + } + + const streamTask = run().catch((error) => { + log.error( + '[DappKit] Inventory checkpoint stream task exited unexpectedly:', + error, + ) + }) + + return async () => { + stopped = true + activeSession?.cancel() + await streamTask + } +} diff --git a/packages/libs/dapp-kit/utils/events/eventRefresh.ts b/packages/libs/dapp-kit/utils/events/eventRefresh.ts new file mode 100644 index 0000000..ef0c805 --- /dev/null +++ b/packages/libs/dapp-kit/utils/events/eventRefresh.ts @@ -0,0 +1,98 @@ +import { SuiGrpcClient } from '@mysten/sui/grpc' + +import { DEFAULT_GRAPHQL_NETWORK, getSuiGrpcBaseUrl } from '../constants' +import { createLogger } from '../logger' +import { + type CheckpointGapHandler, + type CheckpointStreamMessage, + createInventoryCheckpointStream, + type EventUnsubscribe, + type InventoryEventBatchHandler, +} from './checkpointStream' + +const log = createLogger() + +const EVENT_REFETCH_DELAYS_MS = [250, 1500, 3500] as const + +export type ScheduledRefetch = (() => void) & { cancel: () => void } + +export function createEventRefetchScheduler( + refetch: () => Promise, + delaysMs: readonly number[] = EVENT_REFETCH_DELAYS_MS, + onError?: (error: unknown) => void, +): ScheduledRefetch { + let timeouts: ReturnType[] = [] + + const scheduledRefetch = () => { + scheduledRefetch.cancel() + + timeouts = delaysMs.map((delayMs) => { + const timeoutId = setTimeout(() => { + timeouts = timeouts.filter((timeout) => timeout !== timeoutId) + refetch().catch((error) => onError?.(error)) + }, delayMs) + return timeoutId + }) + } + + scheduledRefetch.cancel = () => { + for (const timeout of timeouts) { + clearTimeout(timeout) + } + timeouts = [] + } + + return scheduledRefetch +} + +export async function subscribeToInventoryEvents({ + eventTypes, + network = DEFAULT_GRAPHQL_NETWORK, + onEvents, + onGap, + signal, +}: { + eventTypes: readonly string[] + network?: string + onEvents?: InventoryEventBatchHandler + onGap?: CheckpointGapHandler + signal?: AbortSignal +}): Promise { + const unsubscribe = createInventoryCheckpointStream({ + eventTypes, + ...(onEvents !== undefined ? { onEvents } : {}), + ...(onGap !== undefined ? { onGap } : {}), + ...(signal !== undefined ? { signal } : {}), + onError: (error) => { + log.warn('[DappKit] Inventory checkpoint stream error:', error) + }, + subscribeCheckpoints: (request) => { + const abortController = new AbortController() + const grpcClient = new SuiGrpcClient({ + network, + baseUrl: getSuiGrpcBaseUrl(network), + }) + const call = grpcClient.subscriptionService.subscribeCheckpoints( + { + readMask: { + paths: [...request.readMask.paths], + }, + }, + { abort: abortController.signal }, + ) + + return { + responses: call.responses as AsyncIterable, + cancel: () => { + abortController.abort() + }, + } + }, + }) + + signal?.addEventListener('abort', () => { + void unsubscribe() + }) + + return unsubscribe +} diff --git a/packages/libs/dapp-kit/utils/events/inventoryEventHandlers.ts b/packages/libs/dapp-kit/utils/events/inventoryEventHandlers.ts new file mode 100644 index 0000000..0e045c2 --- /dev/null +++ b/packages/libs/dapp-kit/utils/events/inventoryEventHandlers.ts @@ -0,0 +1,274 @@ +import type { SuiEvent } from '@mysten/sui/jsonRpc' + +import { Assemblies, type AssemblyType, type InventoryItem } from '../../types' +import { getEveWorldPackageId } from '../constants' +import { + adjustInventoryUsedCapacity, + sortInventoryItemsByQuantity, +} from '../inventory' + +const INVENTORY_EVENT_NAMES = ['ItemBurnedEvent', 'ItemMintedEvent'] as const + +type AssemblyEventKey = { + item_id?: string | number + tenant?: string +} + +type AssemblyEventPayload = { + assembly_id?: string + assembly_key?: AssemblyEventKey + item_id?: string + quantity?: number + type_id?: string | number +} + +export type AssemblyEventTarget = { + eventTypes: readonly string[] + itemId?: string + objectId?: string + tenant?: string +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function toFiniteNumber(value: unknown, fallback = 0): number { + const numberValue = Number(value) + return Number.isFinite(numberValue) ? numberValue : fallback +} + +function parseAssemblyEventKey(raw: unknown): AssemblyEventKey | undefined { + if (!isRecord(raw)) return undefined + const { item_id, tenant } = raw + if ( + (typeof item_id === 'string' || typeof item_id === 'number') && + typeof tenant === 'string' + ) { + return { item_id, tenant } + } + return undefined +} + +function parseAssemblyEventPayload( + event: Pick, +): AssemblyEventPayload | null { + const json = event.parsedJson + if (!isRecord(json)) return null + + const payload: AssemblyEventPayload = {} + + if (typeof json['assembly_id'] === 'string') + payload.assembly_id = json['assembly_id'] + if (typeof json['item_id'] === 'string') payload.item_id = json['item_id'] + if (typeof json['quantity'] === 'number') payload.quantity = json['quantity'] + if ( + typeof json['type_id'] === 'string' || + typeof json['type_id'] === 'number' + ) { + payload.type_id = json['type_id'] + } + + const assemblyKey = parseAssemblyEventKey(json['assembly_key']) + if (assemblyKey) payload.assembly_key = assemblyKey + + return payload +} + +function normalizeObjectId(value: string | undefined) { + return value?.toLowerCase() +} + +function parseInventoryEventDelta( + event: Pick, +) { + const payload = parseAssemblyEventPayload(event) + if (!payload) return null + + const typeId = Number(payload.type_id) + const quantity = Number(payload.quantity) + if (!Number.isFinite(typeId) || !Number.isFinite(quantity) || quantity <= 0) + return null + + const operation = event.type.endsWith('::inventory::ItemMintedEvent') + ? ('add' as const) + : event.type.endsWith('::inventory::ItemBurnedEvent') + ? ('subtract' as const) + : null + + if (!operation) return null + + return { + itemId: payload.item_id ?? String(typeId), + quantity, + tenant: payload.assembly_key?.tenant ?? '', + typeId, + operation, + } +} + +function isOptimisticInventoryItem(item: InventoryItem): boolean { + return item.id.startsWith('optimistic-') +} + +function mergeInventoryItemsByTypeId(items: InventoryItem[]): InventoryItem[] { + const itemsByTypeId = new Map() + + items.forEach((item) => { + const typeId = toFiniteNumber(item.type_id, Number.NaN) + if (!Number.isFinite(typeId)) return + + const existing = itemsByTypeId.get(typeId) + if (!existing) { + itemsByTypeId.set(typeId, { + ...item, + quantity: toFiniteNumber(item.quantity), + type_id: typeId, + }) + return + } + + const quantity = + toFiniteNumber(existing.quantity) + toFiniteNumber(item.quantity) + if (quantity <= 0) { + itemsByTypeId.delete(typeId) + return + } + + const preferred = + isOptimisticInventoryItem(existing) && !isOptimisticInventoryItem(item) + ? item + : existing + itemsByTypeId.set(typeId, { ...preferred, quantity, type_id: typeId }) + }) + + return Array.from(itemsByTypeId.values()) +} + +function computeNextItems( + items: InventoryItem[], + delta: NonNullable>, +): InventoryItem[] { + const itemIndex = items.findIndex( + (item) => toFiniteNumber(item.type_id) === delta.typeId, + ) + + if (itemIndex === -1) { + if (delta.operation === 'subtract') return items + return [ + ...items, + { + id: `optimistic-${delta.typeId}`, + item_id: delta.itemId, + location: { location_hash: '' }, + quantity: delta.quantity, + tenant: delta.tenant, + type_id: delta.typeId, + name: `Type ${delta.typeId}`, + } satisfies InventoryItem, + ] + } + + return items.flatMap((item, index) => { + if (index !== itemIndex) return [item] + const quantity = + delta.operation === 'add' + ? toFiniteNumber(item.quantity) + delta.quantity + : Math.max(0, toFiniteNumber(item.quantity) - delta.quantity) + return quantity === 0 ? [] : [{ ...item, quantity }] + }) +} + +export function getInventoryEventTypes( + packageId = getEveWorldPackageId(), +): string[] { + return INVENTORY_EVENT_NAMES.map( + (eventName) => `${packageId}::inventory::${eventName}`, + ) +} + +export function isRelevantAssemblyInventoryEvent( + event: Pick, + target: AssemblyEventTarget, +): boolean { + if (!target.eventTypes.includes(event.type)) return false + + const payload = parseAssemblyEventPayload(event) + if (!payload) return false + + if ( + target.objectId && + normalizeObjectId(payload.assembly_id) === + normalizeObjectId(target.objectId) + ) { + return true + } + + if (!target.itemId || !target.tenant) return false + + return ( + String(payload.assembly_key?.item_id ?? '') === String(target.itemId) && + payload.assembly_key?.tenant === target.tenant + ) +} + +export function getInventoryEventTarget({ + assembly, + eventTypes, + isObjectIdDirect, + selectedObjectId, + selectedTenant, +}: { + assembly: AssemblyType | null + eventTypes: readonly string[] + isObjectIdDirect: boolean + selectedObjectId: string + selectedTenant: string +}): AssemblyEventTarget { + if (assembly?.type === Assemblies.SmartStorageUnit) { + if (assembly.id) return { eventTypes, objectId: assembly.id } + if (assembly.item_id) { + return { + eventTypes, + itemId: String(assembly.item_id), + tenant: selectedTenant, + } + } + } + + if (isObjectIdDirect) return { eventTypes, objectId: selectedObjectId } + + return { eventTypes, itemId: selectedObjectId, tenant: selectedTenant } +} + +export function applyInventoryEventToAssembly( + assembly: AssemblyType | null, + event: Pick, +): AssemblyType | null { + if (!assembly || assembly.type !== Assemblies.SmartStorageUnit) + return assembly + + const delta = parseInventoryEventDelta(event) + if (!delta) return assembly + + const items = assembly.storage.mainInventory.items ?? [] + + return { + ...assembly, + storage: { + ...assembly.storage, + mainInventory: { + ...assembly.storage.mainInventory, + usedCapacity: adjustInventoryUsedCapacity( + assembly.storage.mainInventory.usedCapacity, + delta.quantity, + delta.typeId, + delta.operation, + ), + items: sortInventoryItemsByQuantity( + mergeInventoryItemsByTypeId(computeNextItems(items, delta)), + ), + }, + }, + } +} diff --git a/packages/libs/dapp-kit/utils/inventory.ts b/packages/libs/dapp-kit/utils/inventory.ts index 0886aed..563ffb4 100644 --- a/packages/libs/dapp-kit/utils/inventory.ts +++ b/packages/libs/dapp-kit/utils/inventory.ts @@ -7,6 +7,58 @@ function toFiniteNumber(value: unknown, fallback = 0): number { return Number.isFinite(numberValue) ? numberValue : fallback } +function getInventoryItemSignatures(items: InventoryItem[] | undefined) { + const quantityByTypeId = new Map() + + ;(items ?? []).forEach((item) => { + const typeId = toFiniteNumber(item.type_id, Number.NaN) + if (!Number.isFinite(typeId)) return + + quantityByTypeId.set( + typeId, + (quantityByTypeId.get(typeId) ?? 0) + toFiniteNumber(item.quantity), + ) + }) + + return Array.from(quantityByTypeId.entries()) + .filter(([, quantity]) => quantity > 0) + .sort(([leftTypeId], [rightTypeId]) => leftTypeId - rightTypeId) +} + +function getInventoryQuantitySignature( + items: InventoryItem[] | undefined, +): string { + return JSON.stringify(getInventoryItemSignatures(items)) +} + +function preserveStorageInventoryItemsWhenEqual( + currentAssembly: AssemblyType, + nextAssembly: AssemblyType, +): AssemblyType { + if ( + !areInventoryItemListsEqual( + currentAssembly.storage.mainInventory.items, + nextAssembly.storage.mainInventory.items, + ) + ) { + return nextAssembly + } + + return { + ...nextAssembly, + storage: { + ...nextAssembly.storage, + mainInventory: { + ...nextAssembly.storage.mainInventory, + items: currentAssembly.storage.mainInventory.items, + usedCapacity: + currentAssembly.storage.mainInventory.usedCapacity ?? + nextAssembly.storage.mainInventory.usedCapacity, + }, + }, + } +} + /** Cache Datahub volume (m³ per unit) for optimistic used-capacity updates. */ export function setInventoryTypeVolumeM3(typeId: number, volumeM3: number) { if (!Number.isFinite(typeId) || !Number.isFinite(volumeM3) || volumeM3 < 0) { @@ -24,38 +76,32 @@ export function clearInventoryTypeVolumeM3Cache() { typeVolumeM3ById.clear() } -/** On-chain used_capacity is litres (dm³); Datahub volume is m³ per unit. */ -export function getInventoryQuantityVolumeDm3( - quantity: number, - typeId: number, -): number | null { - const volumeM3 = getInventoryTypeVolumeM3(typeId) - if (volumeM3 === undefined) return null - return Math.round(quantity * volumeM3 * 1000) -} - export function adjustInventoryUsedCapacity( usedCapacity: string, quantity: number, typeId: number, operation: 'add' | 'subtract', ): string + export function adjustInventoryUsedCapacity( usedCapacity: string | undefined, quantity: number, typeId: number, operation: 'add' | 'subtract', ): string | undefined + export function adjustInventoryUsedCapacity( usedCapacity: string | undefined, quantity: number, typeId: number, operation: 'add' | 'subtract', ): string | undefined { - const volumeDeltaDm3 = getInventoryQuantityVolumeDm3(quantity, typeId) - if (volumeDeltaDm3 === null || usedCapacity === undefined) { + const volumeM3 = getInventoryTypeVolumeM3(typeId) + if (volumeM3 === undefined || usedCapacity === undefined) { return usedCapacity } + // On-chain used_capacity is dm³ (litres); Datahub volume is m³ per unit. + const volumeDeltaDm3 = Math.round(quantity * volumeM3 * 1000) const currentUsedCapacity = toFiniteNumber(usedCapacity) const nextUsedCapacity = @@ -77,30 +123,6 @@ export function sortInventoryItemsByQuantity( }) } -function getInventoryItemSignatures(items: InventoryItem[] | undefined) { - const quantityByTypeId = new Map() - - ;(items ?? []).forEach((item) => { - const typeId = toFiniteNumber(item.type_id, Number.NaN) - if (!Number.isFinite(typeId)) return - - quantityByTypeId.set( - typeId, - (quantityByTypeId.get(typeId) ?? 0) + toFiniteNumber(item.quantity), - ) - }) - - return Array.from(quantityByTypeId.entries()) - .filter(([, quantity]) => quantity > 0) - .sort(([leftTypeId], [rightTypeId]) => leftTypeId - rightTypeId) -} - -function getInventoryQuantitySignature( - items: InventoryItem[] | undefined, -): string { - return JSON.stringify(getInventoryItemSignatures(items)) -} - export function areInventoryItemListsEqual( leftItems: InventoryItem[] | undefined, rightItems: InventoryItem[] | undefined, @@ -118,34 +140,6 @@ export function areInventoryItemListsEqual( }) } -function preserveStorageInventoryItemsWhenEqual( - currentAssembly: AssemblyType, - nextAssembly: AssemblyType, -): AssemblyType { - if ( - !areInventoryItemListsEqual( - currentAssembly.storage.mainInventory.items, - nextAssembly.storage.mainInventory.items, - ) - ) { - return nextAssembly - } - - return { - ...nextAssembly, - storage: { - ...nextAssembly.storage, - mainInventory: { - ...nextAssembly.storage.mainInventory, - items: currentAssembly.storage.mainInventory.items, - usedCapacity: - currentAssembly.storage.mainInventory.usedCapacity ?? - nextAssembly.storage.mainInventory.usedCapacity, - }, - }, - } -} - /** * Merge a GraphQL refetch into optimistic storage state without clobbering * stream-updated items when the indexer has only moved ancillary fields first diff --git a/packages/libs/dapp-kit/utils/inventoryEventBcs.ts b/packages/libs/dapp-kit/utils/inventoryEventBcs.ts index b8d5d89..910a704 100644 --- a/packages/libs/dapp-kit/utils/inventoryEventBcs.ts +++ b/packages/libs/dapp-kit/utils/inventoryEventBcs.ts @@ -21,6 +21,8 @@ const InventoryMoveEvent = bcs.struct('InventoryMoveEvent', { quantity: bcs.u32(), }) +// ---------------------------------------------------------------------------- + export type DecodedInventoryMoveEvent = { assembly_id: string assembly_key: { From aa93046fe2d5a68c3f0f90afae8205b8d97d9ad4 Mon Sep 17 00:00:00 2001 From: ccp sondheim Date: Wed, 24 Jun 2026 12:43:47 +0000 Subject: [PATCH 5/6] abstract shared functions --- packages/apps/assembly/CHANGELOG.md | 8 ++++++++ packages/apps/assembly/package.json | 2 +- packages/libs/dapp-kit/CHANGELOG.md | 6 ++++++ packages/libs/dapp-kit/package.json | 2 +- .../dapp-kit/utils/events/checkpointStream.ts | 5 +---- .../utils/events/inventoryEventHandlers.ts | 5 +---- packages/libs/dapp-kit/utils/index.ts | 17 +---------------- packages/libs/dapp-kit/utils/utils.ts | 4 ++++ 8 files changed, 23 insertions(+), 26 deletions(-) diff --git a/packages/apps/assembly/CHANGELOG.md b/packages/apps/assembly/CHANGELOG.md index 111e728..54117ca 100644 --- a/packages/apps/assembly/CHANGELOG.md +++ b/packages/apps/assembly/CHANGELOG.md @@ -1,5 +1,13 @@ # @eveworld/assembly +## 0.3.3 + +### Patch Changes + +- Updated dependencies + - @evefrontier/dapp-kit@0.1.11 + - @eveworld/ui-components@0.2.1 + ## 0.3.2 ### Patch Changes diff --git a/packages/apps/assembly/package.json b/packages/apps/assembly/package.json index 0b38a36..f08c774 100644 --- a/packages/apps/assembly/package.json +++ b/packages/apps/assembly/package.json @@ -1,7 +1,7 @@ { "name": "@eveworld/assembly", "private": true, - "version": "0.3.2", + "version": "0.3.3", "type": "module", "scripts": { "dev": "vite", diff --git a/packages/libs/dapp-kit/CHANGELOG.md b/packages/libs/dapp-kit/CHANGELOG.md index c537067..2387e34 100644 --- a/packages/libs/dapp-kit/CHANGELOG.md +++ b/packages/libs/dapp-kit/CHANGELOG.md @@ -1,5 +1,11 @@ # @evefrontier/dapp-kit +## 0.1.11 + +### Patch Changes + +- use optimistic updates for inventory + ## 0.1.10 ### Patch Changes diff --git a/packages/libs/dapp-kit/package.json b/packages/libs/dapp-kit/package.json index 203a6bc..6e32e27 100644 --- a/packages/libs/dapp-kit/package.json +++ b/packages/libs/dapp-kit/package.json @@ -1,6 +1,6 @@ { "name": "@evefrontier/dapp-kit", - "version": "0.1.10", + "version": "0.1.11", "description": "React SDK for EVE Frontier dApps on Sui", "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/packages/libs/dapp-kit/utils/events/checkpointStream.ts b/packages/libs/dapp-kit/utils/events/checkpointStream.ts index 2f23cea..d83982c 100644 --- a/packages/libs/dapp-kit/utils/events/checkpointStream.ts +++ b/packages/libs/dapp-kit/utils/events/checkpointStream.ts @@ -5,6 +5,7 @@ import { inventoryEventBcsToParsedJson, } from '../inventoryEventBcs' import { createLogger } from '../logger' +import { isRecord } from '../utils' const CHECKPOINT_STREAM_RECONNECT_MS = 1_000 // Rotate before the public fullnode ~30s stream cutoff. @@ -104,10 +105,6 @@ function protobufStructToJson( ) } -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null -} - function parseInventoryEventPayloadFromStream(event: { json?: ProtobufValue contents?: { value?: Uint8Array } diff --git a/packages/libs/dapp-kit/utils/events/inventoryEventHandlers.ts b/packages/libs/dapp-kit/utils/events/inventoryEventHandlers.ts index 0e045c2..531115a 100644 --- a/packages/libs/dapp-kit/utils/events/inventoryEventHandlers.ts +++ b/packages/libs/dapp-kit/utils/events/inventoryEventHandlers.ts @@ -6,6 +6,7 @@ import { adjustInventoryUsedCapacity, sortInventoryItemsByQuantity, } from '../inventory' +import { isRecord } from '../utils' const INVENTORY_EVENT_NAMES = ['ItemBurnedEvent', 'ItemMintedEvent'] as const @@ -29,10 +30,6 @@ export type AssemblyEventTarget = { tenant?: string } -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null -} - function toFiniteNumber(value: unknown, fallback = 0): number { const numberValue = Number(value) return Number.isFinite(numberValue) ? numberValue : fallback diff --git a/packages/libs/dapp-kit/utils/index.ts b/packages/libs/dapp-kit/utils/index.ts index 1ffbf12..391ee35 100644 --- a/packages/libs/dapp-kit/utils/index.ts +++ b/packages/libs/dapp-kit/utils/index.ts @@ -25,19 +25,4 @@ export { export type { TransformOptions } from './transforms' export { transformToAssembly, transformToCharacter } from './transforms' // General utilities -export { - abbreviateAddress, - assertAssemblyType, - clickToCopy, - findOwnerByAddress, - formatDuration, - formatM3, - getCommonItems, - getDappUrl, - getEnv, - getTxUrl, - getVolumeM3, - isOwner, - parseURL, - removeTrailingZeros, -} from './utils' +export * from './utils' diff --git a/packages/libs/dapp-kit/utils/utils.ts b/packages/libs/dapp-kit/utils/utils.ts index e84c5db..82612f8 100644 --- a/packages/libs/dapp-kit/utils/utils.ts +++ b/packages/libs/dapp-kit/utils/utils.ts @@ -268,3 +268,7 @@ export const formatDuration = (seconds: number): string => { return `${formattedSeconds}s` } } + +export const isRecord = (value: unknown): value is Record => { + return typeof value === 'object' && value !== null +} From a0440a8f820d7b3905f8852159af8d7edcb058bb Mon Sep 17 00:00:00 2001 From: ccp sondheim Date: Wed, 24 Jun 2026 14:12:46 +0000 Subject: [PATCH 6/6] feat: optimistic updating for nwn fuel --- .../providers/SmartObjectProvider.tsx | 117 ++++++++---- .../__tests__/fuelEventHandlers.test.ts | 180 ++++++++++++++++++ .../utils/events/fuelEventHandlers.ts | 77 ++++++++ .../utils/events/inventoryEventHandlers.ts | 6 +- packages/libs/dapp-kit/utils/utils.ts | 4 + 5 files changed, 346 insertions(+), 38 deletions(-) create mode 100644 packages/libs/dapp-kit/utils/events/__tests__/fuelEventHandlers.test.ts create mode 100644 packages/libs/dapp-kit/utils/events/fuelEventHandlers.ts diff --git a/packages/libs/dapp-kit/providers/SmartObjectProvider.tsx b/packages/libs/dapp-kit/providers/SmartObjectProvider.tsx index 288e5f6..8646f38 100644 --- a/packages/libs/dapp-kit/providers/SmartObjectProvider.tsx +++ b/packages/libs/dapp-kit/providers/SmartObjectProvider.tsx @@ -24,6 +24,12 @@ import { createEventRefetchScheduler, subscribeToInventoryEvents, } from '../utils/events/eventRefresh' +import { + applyFuelEventToAssembly, + getFuelEventTarget, + getFuelEventType, + isRelevantFuelEvent, +} from '../utils/events/fuelEventHandlers' import { applyInventoryEventToAssembly, getInventoryEventTarget, @@ -38,16 +44,14 @@ import { const log = createLogger() -function getInventoryStreamEventTypes(tenant: string): string[] { +function getStreamEventTypes( + tenant: string, + getTypes: (packageId: string) => string[], +): string[] { const packageIds = new Set([getEveWorldPackageId()]) const tenantPackageId = TENANT_CONFIG[tenant as TenantId]?.packageId - if (tenantPackageId) { - packageIds.add(tenantPackageId) - } - - return [...packageIds].flatMap((packageId) => - getInventoryEventTypes(packageId), - ) + if (tenantPackageId) packageIds.add(tenantPackageId) + return [...packageIds].flatMap(getTypes) } /** Input for fetching object data: either itemId + tenant (derive object ID) or a Sui object ID directly. @@ -121,6 +125,7 @@ const SmartObjectProvider = ({ children }: { children: ReactNode }) => { const pollingRef = useRef | null>(null) const lastDataHashRef = useRef(null) const lastConfirmedInventorySignatureRef = useRef(null) + const lastConfirmedFuelSignatureRef = useRef(null) const assemblyRef = useRef | null>(null) assemblyRef.current = assembly @@ -216,6 +221,26 @@ const SmartObjectProvider = ({ children }: { children: ReactNode }) => { primeInventoryTypeVolumes(transformed) setAssembly((currentAssembly) => { + if (transformed?.type === Assemblies.NetworkNode) { + const { quantity, isBurning } = transformed.networkNode.fuel + const nextSignature = `${quantity}:${isBurning}` + + // On a refetch, if the indexer hasn't caught up yet it returns the + // same quantity+isBurning we already confirmed — keep the optimistic + // state in that case rather than reverting it. + if ( + !isInitialFetch && + currentAssembly?.type === Assemblies.NetworkNode && + lastConfirmedFuelSignatureRef.current !== null && + nextSignature === lastConfirmedFuelSignatureRef.current + ) { + return currentAssembly + } + + lastConfirmedFuelSignatureRef.current = nextSignature + return transformed + } + if (transformed?.type !== Assemblies.SmartStorageUnit) { return transformed } @@ -327,6 +352,7 @@ const SmartObjectProvider = ({ children }: { children: ReactNode }) => { } lastDataHashRef.current = null lastConfirmedInventorySignatureRef.current = null + lastConfirmedFuelSignatureRef.current = null } }, [ selectedObjectId, @@ -344,7 +370,14 @@ const SmartObjectProvider = ({ children }: { children: ReactNode }) => { const input: FetchObjectDataInput = isObjectIdDirect ? { objectId: selectedObjectId } : { itemId: selectedObjectId, selectedTenant } - const eventTypes = getInventoryStreamEventTypes(selectedTenant) + const inventoryEventTypes = getStreamEventTypes( + selectedTenant, + getInventoryEventTypes, + ) + const fuelEventTypes = getStreamEventTypes(selectedTenant, (pkg) => [ + getFuelEventType(pkg), + ]) + const allEventTypes = [...inventoryEventTypes, ...fuelEventTypes] const abortController = new AbortController() const triggerRefetch = createEventRefetchScheduler( () => fetchObjectData(input, false), @@ -359,53 +392,71 @@ const SmartObjectProvider = ({ children }: { children: ReactNode }) => { let unsubscribe: EventUnsubscribe | null = null log.debug( - '[DappKit] SmartObjectProvider: Starting inventory checkpoint stream', - { eventTypes, selectedObjectId, selectedTenant }, + '[DappKit] SmartObjectProvider: Starting assembly checkpoint stream', + { allEventTypes, selectedObjectId, selectedTenant }, ) subscribeToInventoryEvents({ - eventTypes, + eventTypes: allEventTypes, signal: abortController.signal, onGap: () => { triggerRefetch() }, onEvents: (events) => { - const eventTarget = getInventoryEventTarget({ + const inventoryEventTarget = getInventoryEventTarget({ assembly: assemblyRef.current, - eventTypes, + eventTypes: inventoryEventTypes, isObjectIdDirect, selectedObjectId, selectedTenant, }) - const relevantEvents = events.filter((event) => - isRelevantAssemblyInventoryEvent(event, eventTarget), + const fuelEventTarget = getFuelEventTarget( + assemblyRef.current, + fuelEventTypes, ) - if (events.length > 0 && relevantEvents.length === 0) { + const relevantInventoryEvents = events.filter((event) => + isRelevantAssemblyInventoryEvent(event, inventoryEventTarget), + ) + const relevantFuelEvents = fuelEventTarget + ? events.filter((event) => + isRelevantFuelEvent(event, fuelEventTarget), + ) + : [] + + if ( + events.length > 0 && + relevantInventoryEvents.length === 0 && + relevantFuelEvents.length === 0 + ) { log.debug( - '[DappKit] SmartObjectProvider: Ignoring inventory stream events for other assemblies', - { count: events.length, eventTarget }, + '[DappKit] SmartObjectProvider: Ignoring stream events for other assemblies', + { count: events.length, inventoryEventTarget, fuelEventTarget }, ) } - if (relevantEvents.length > 0) { + if ( + relevantInventoryEvents.length > 0 || + relevantFuelEvents.length > 0 + ) { log.info( - '[DappKit] SmartObjectProvider: Applying inventory stream events', - { count: relevantEvents.length }, + '[DappKit] SmartObjectProvider: Applying assembly stream events', + { + inventoryCount: relevantInventoryEvents.length, + fuelCount: relevantFuelEvents.length, + }, ) setAssembly((currentAssembly) => { - if ( - !currentAssembly || - currentAssembly.type !== Assemblies.SmartStorageUnit - ) { - return currentAssembly - } - - return relevantEvents.reduce | null>( - (updatedAssembly, event) => - applyInventoryEventToAssembly(updatedAssembly, event), - currentAssembly, + let next: AssemblyType | null = currentAssembly + next = relevantInventoryEvents.reduce( + (asm, event) => applyInventoryEventToAssembly(asm, event), + next, + ) + next = relevantFuelEvents.reduce( + (asm, event) => applyFuelEventToAssembly(asm, event), + next, ) + return next }) triggerRefetch() } diff --git a/packages/libs/dapp-kit/utils/events/__tests__/fuelEventHandlers.test.ts b/packages/libs/dapp-kit/utils/events/__tests__/fuelEventHandlers.test.ts new file mode 100644 index 0000000..45797a8 --- /dev/null +++ b/packages/libs/dapp-kit/utils/events/__tests__/fuelEventHandlers.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from 'vitest' +import { Assemblies, type AssemblyType } from '../../../types' +import { + applyFuelEventToAssembly, + getFuelEventTarget, + getFuelEventType, + isRelevantFuelEvent, +} from '../fuelEventHandlers' + +const PACKAGE_ID = + '0x28b497559d65ab320d9da4613bf2498d5946b2c0ae3597ccfda3072ce127448c' +const NODE_OBJECT_ID = + '0x34d08b4e1afe6a4babcc0642d6a676160df6b777b49214d5c964b4e874cc951b' +const OTHER_OBJECT_ID = + '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' + +const FUEL_EVENT_TYPE = `${PACKAGE_ID}::fuel::FuelEvent` + +function createNetworkNodeAssembly( + quantity = 10, + isBurning = false, +): AssemblyType { + return { + type: Assemblies.NetworkNode, + id: NODE_OBJECT_ID, + networkNode: { + fuel: { + quantity, + isBurning, + burnTimeInMs: 60_000, + burnStartTime: 0, + lastUpdated: 0, + maxCapacity: 100, + previousCycleElapsedTime: 0, + unitVolume: 1, + typeId: 77810, + }, + energyProduction: 0, + energyMaxCapacity: 0, + totalReservedEnergy: 0, + linkedAssemblies: [], + }, + } as unknown as AssemblyType +} + +function createFuelEvent( + assemblyId: string, + newQuantity: number, + isBurning: boolean, +) { + return { + type: FUEL_EVENT_TYPE, + parsedJson: { + assembly_id: assemblyId, + assembly_key: { item_id: '1', tenant: 'stillness' }, + type_id: '77810', + old_quantity: '10', + new_quantity: String(newQuantity), + is_burning: isBurning, + action: 0, + }, + } +} + +describe('getFuelEventType', () => { + it('returns the correct event type string', () => { + expect(getFuelEventType(PACKAGE_ID)).toBe(`${PACKAGE_ID}::fuel::FuelEvent`) + }) +}) + +describe('getFuelEventTarget', () => { + it('returns target for a NetworkNode assembly', () => { + const assembly = createNetworkNodeAssembly() + const target = getFuelEventTarget(assembly, [FUEL_EVENT_TYPE]) + expect(target).toEqual({ + eventTypes: [FUEL_EVENT_TYPE], + objectId: NODE_OBJECT_ID, + }) + }) + + it('returns null for a non-NetworkNode assembly', () => { + const assembly = { + type: Assemblies.SmartStorageUnit, + id: NODE_OBJECT_ID, + } as unknown as AssemblyType + expect(getFuelEventTarget(assembly, [FUEL_EVENT_TYPE])).toBeNull() + }) + + it('returns null for null assembly', () => { + expect(getFuelEventTarget(null, [FUEL_EVENT_TYPE])).toBeNull() + }) +}) + +describe('isRelevantFuelEvent', () => { + const target = { eventTypes: [FUEL_EVENT_TYPE], objectId: NODE_OBJECT_ID } + + it('matches an event for the correct assembly', () => { + const event = createFuelEvent(NODE_OBJECT_ID, 5, false) + expect(isRelevantFuelEvent(event, target)).toBe(true) + }) + + it('rejects an event for a different assembly', () => { + const event = createFuelEvent(OTHER_OBJECT_ID, 5, false) + expect(isRelevantFuelEvent(event, target)).toBe(false) + }) + + it('rejects an event with a different type', () => { + const event = { + type: `${PACKAGE_ID}::inventory::ItemMintedEvent`, + parsedJson: { assembly_id: NODE_OBJECT_ID }, + } + expect(isRelevantFuelEvent(event, target)).toBe(false) + }) + + it('matches case-insensitively on assembly_id', () => { + const event = createFuelEvent(NODE_OBJECT_ID.toUpperCase(), 5, false) + expect(isRelevantFuelEvent(event, target)).toBe(true) + }) +}) + +describe('applyFuelEventToAssembly', () => { + it('updates quantity and isBurning from the event', () => { + const assembly = createNetworkNodeAssembly(10, false) + const event = createFuelEvent(NODE_OBJECT_ID, 15, true) + + const result = applyFuelEventToAssembly(assembly, event) + + expect(result?.type).toBe(Assemblies.NetworkNode) + if (result?.type !== Assemblies.NetworkNode) return + + expect(result.networkNode.fuel.quantity).toBe(15) + expect(result.networkNode.fuel.isBurning).toBe(true) + }) + + it('reflects a burn event reducing quantity to zero', () => { + const assembly = createNetworkNodeAssembly(1, true) + const event = createFuelEvent(NODE_OBJECT_ID, 0, false) + + const result = applyFuelEventToAssembly(assembly, event) + + if (result?.type !== Assemblies.NetworkNode) return + expect(result.networkNode.fuel.quantity).toBe(0) + expect(result.networkNode.fuel.isBurning).toBe(false) + }) + + it('preserves other fuel fields unchanged', () => { + const assembly = createNetworkNodeAssembly() + const event = createFuelEvent(NODE_OBJECT_ID, 5, false) + + const result = applyFuelEventToAssembly(assembly, event) + + if (result?.type !== Assemblies.NetworkNode) return + expect(result.networkNode.fuel.burnTimeInMs).toBe(60_000) + expect(result.networkNode.fuel.typeId).toBe(77810) + }) + + it('is a no-op for non-NetworkNode assemblies', () => { + const assembly = { + type: Assemblies.SmartStorageUnit, + id: NODE_OBJECT_ID, + } as unknown as AssemblyType + const event = createFuelEvent(NODE_OBJECT_ID, 5, false) + + expect(applyFuelEventToAssembly(assembly, event)).toBe(assembly) + }) + + it('is a no-op for null assembly', () => { + const event = createFuelEvent(NODE_OBJECT_ID, 5, false) + expect(applyFuelEventToAssembly(null, event)).toBeNull() + }) + + it('is a no-op when new_quantity is missing', () => { + const assembly = createNetworkNodeAssembly(10, false) + const event = { + type: FUEL_EVENT_TYPE, + parsedJson: { assembly_id: NODE_OBJECT_ID }, + } + expect(applyFuelEventToAssembly(assembly, event)).toBe(assembly) + }) +}) diff --git a/packages/libs/dapp-kit/utils/events/fuelEventHandlers.ts b/packages/libs/dapp-kit/utils/events/fuelEventHandlers.ts new file mode 100644 index 0000000..5e22243 --- /dev/null +++ b/packages/libs/dapp-kit/utils/events/fuelEventHandlers.ts @@ -0,0 +1,77 @@ +import type { SuiEvent } from '@mysten/sui/jsonRpc' + +import { Assemblies, type AssemblyType } from '../../types' +import { getEveWorldPackageId } from '../constants' +import { isRecord, normalizeObjectId } from '../utils' + +type FuelEventPayload = { + assembly_id?: string + assembly_key?: { item_id?: string | number; tenant?: string } + type_id?: string | number + old_quantity?: string | number + new_quantity?: string | number + is_burning?: boolean + action?: unknown +} + +export type FuelEventTarget = { + eventTypes: readonly string[] + objectId: string +} + +function parseFuelEventPayload( + event: Pick, +): FuelEventPayload | null { + if (!isRecord(event.parsedJson)) return null + return event.parsedJson as FuelEventPayload +} + +export function getFuelEventType(packageId = getEveWorldPackageId()): string { + return `${packageId}::fuel::FuelEvent` +} + +export function getFuelEventTarget( + assembly: AssemblyType | null, + eventTypes: readonly string[], +): FuelEventTarget | null { + if (assembly?.type !== Assemblies.NetworkNode) return null + if (!assembly.id) return null + return { eventTypes, objectId: assembly.id } +} + +export function isRelevantFuelEvent( + event: Pick, + target: FuelEventTarget, +): boolean { + if (!target.eventTypes.includes(event.type)) return false + const payload = parseFuelEventPayload(event) + return ( + normalizeObjectId(payload?.assembly_id) === + normalizeObjectId(target.objectId) + ) +} + +export function applyFuelEventToAssembly( + assembly: AssemblyType | null, + event: Pick, +): AssemblyType | null { + if (assembly?.type !== Assemblies.NetworkNode) return assembly + + const payload = parseFuelEventPayload(event) + if (!payload) return assembly + + const newQuantity = Number(payload.new_quantity) + if (!Number.isFinite(newQuantity) || newQuantity < 0) return assembly + + return { + ...assembly, + networkNode: { + ...assembly.networkNode, + fuel: { + ...assembly.networkNode.fuel, + quantity: newQuantity, + isBurning: Boolean(payload.is_burning), + }, + }, + } +} diff --git a/packages/libs/dapp-kit/utils/events/inventoryEventHandlers.ts b/packages/libs/dapp-kit/utils/events/inventoryEventHandlers.ts index 531115a..ae18502 100644 --- a/packages/libs/dapp-kit/utils/events/inventoryEventHandlers.ts +++ b/packages/libs/dapp-kit/utils/events/inventoryEventHandlers.ts @@ -6,7 +6,7 @@ import { adjustInventoryUsedCapacity, sortInventoryItemsByQuantity, } from '../inventory' -import { isRecord } from '../utils' +import { isRecord, normalizeObjectId } from '../utils' const INVENTORY_EVENT_NAMES = ['ItemBurnedEvent', 'ItemMintedEvent'] as const @@ -72,10 +72,6 @@ function parseAssemblyEventPayload( return payload } -function normalizeObjectId(value: string | undefined) { - return value?.toLowerCase() -} - function parseInventoryEventDelta( event: Pick, ) { diff --git a/packages/libs/dapp-kit/utils/utils.ts b/packages/libs/dapp-kit/utils/utils.ts index 82612f8..100784b 100644 --- a/packages/libs/dapp-kit/utils/utils.ts +++ b/packages/libs/dapp-kit/utils/utils.ts @@ -272,3 +272,7 @@ export const formatDuration = (seconds: number): string => { export const isRecord = (value: unknown): value is Record => { return typeof value === 'object' && value !== null } + +export function normalizeObjectId(value: string | undefined): string { + return value?.toLowerCase() ?? '' +}