From 513e0328fa74ae1fcd96593ddc47a088f6d40240 Mon Sep 17 00:00:00 2001 From: Daniel Ahn Date: Thu, 9 Apr 2026 23:05:58 -0700 Subject: [PATCH 1/7] refactor(client): unify ObjectError across all transports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rearchitect ObjectError to be transport-agnostic, satisfying all five principles from the PR #988 review: 1. Consistent errors: same ObjectError class for JSON-RPC, GraphQL, gRPC 2. No transport knowledge needed: error.code works everywhere 3. instanceof works: single class in client/ layer 4. Not coupled to RPC: no transport imports in client/errors.ts 5. Extra info accessible: raw wire payload exposed via error.transportDetails - ObjectErrorCode is the transport-agnostic union 'notFound' | 'unknown' — the only distinctions every transport can reliably produce. JSON-RPC's five ObjectResponseError wire codes are normalized on the way in: notExists / deleted / dynamicFieldNotFound all surface as 'notFound'; displayError / unknown surface as 'unknown'. The raw wire payload remains available on error.transportDetails for consumers that need it. - Keep objectId non-nullable: when no id is derivable from a JSON-RPC response (e.g. a displayError on listOwnedObjects), escalate to the base SuiClientError instead of fabricating a sentinel - Add TransportDetails, a tagged union ({ \$kind: 'jsonRpc' | 'grpc' | 'graphql' }) lifted to SuiClientError so every subclass inherits the transport escape hatch. ObjectError narrows transportDetails from optional to required via 'declare readonly', since every construction site passes it - Narrow GetObjectsResponse.objects from (Object | Error)[] to (Object | ObjectError)[] and update 'instanceof Error' checks in the composed getObject / getDynamicField / core-resolver paths to the tighter 'instanceof ObjectError'. The gRPC unexpected-oneof case throws SuiClientError (programmer error) instead of surfacing it as data or as a plain Error - Fix gRPC returning plain Error instead of ObjectError; map google.rpc.Code.NOT_FOUND to 'notFound', everything else to 'unknown' (NOT_FOUND is hoisted to a named GRPC_CODE_NOT_FOUND constant) - Replace monolithic mapObjectError with two exhaustive helpers (mapJsonRpcObjectErrorCode + extractObjectIdFromResponseError) that fail to typecheck if a new wire code is introduced upstream - Export TransportDetails from @mysten/sui/client - Unit tests cover: ObjectErrorCode canonical messages, all three transport variants, the SuiClientError escalation path in listOwnedObjects, JSON-RPC getObjects error mapping across all five wire codes (including reference identity of the raw wire payload in transportDetails.response), gRPC status code mapping across notFound and unknown branches, and a universal catch(SuiClientError) contract test that pins compatibility across every wire code - E2E tests use testWithAllClients to enforce cross-transport parity on both getObjects (returns ObjectError) and getObject (throws it) Co-Authored-By: Claude Opus 4.7 --- .changeset/unified-object-errors.md | 33 ++ packages/sui/src/client/core-resolver.ts | 19 +- packages/sui/src/client/core.ts | 5 +- packages/sui/src/client/errors.ts | 80 ++-- packages/sui/src/client/index.ts | 8 +- packages/sui/src/client/types.ts | 3 +- packages/sui/src/graphql/core.ts | 17 +- packages/sui/src/grpc/core.ts | 112 ++--- packages/sui/src/jsonRpc/core.ts | 61 ++- .../sui/src/transactions/executor/parallel.ts | 3 +- .../sui/test/e2e/clients/core/objects.test.ts | 28 +- .../test/unit/client/core-resolver.test.ts | 40 ++ .../sui/test/unit/client/object-error.test.ts | 386 ++++++++++++++++++ 13 files changed, 697 insertions(+), 98 deletions(-) create mode 100644 .changeset/unified-object-errors.md create mode 100644 packages/sui/test/unit/client/object-error.test.ts diff --git a/.changeset/unified-object-errors.md b/.changeset/unified-object-errors.md new file mode 100644 index 000000000..acf0cf9c7 --- /dev/null +++ b/.changeset/unified-object-errors.md @@ -0,0 +1,33 @@ +--- +'@mysten/sui': minor +--- + +Unify `ObjectError` across all transports. `ObjectError` now carries a +transport-agnostic `code: 'notFound' | 'unknown'` and a non-nullable `objectId`, +and works identically regardless of whether the client is backed by JSON-RPC, +gRPC, or GraphQL. + +JSON-RPC's five `ObjectResponseError` wire codes (`notExists`, `deleted`, +`dynamicFieldNotFound`, `displayError`, `unknown`) are normalized on the way in: +`notExists`, `deleted`, and `dynamicFieldNotFound` all surface as `'notFound'`; +`displayError` and `unknown` surface as `'unknown'`. The raw wire payload is +still available on `error.transportDetails` for consumers that need the richer +detail. + +Adds `TransportDetails`, a tagged union lifted onto the `SuiClientError` base +class that exposes the raw per-transport payload (JSON-RPC response, gRPC +`google.rpc.Status`, or a GraphQL tag) via `error.transportDetails`. + +`ObjectError.objectId` is always a real object id. In the rare JSON-RPC case +where the server returns an error that identifies no specific object (e.g. a +`displayError` surfaced during `listOwnedObjects`), a base `SuiClientError` +is thrown instead — consumers who catch `SuiClientError` still catch everything. + +Also narrows `GetObjectsResponse.objects` from `(Object | Error)[]` to +`(Object | ObjectError)[]`. Because `ObjectError extends Error`, existing +`instanceof Error` checks continue to work unchanged. + +Newly exports `SuiClientError` (base class), `ObjectError`, `ObjectErrorCode`, +and `TransportDetails` from `@mysten/sui/client`. Use `instanceof SuiClientError` +as the universal catch contract for any error originating from the client; use +`instanceof ObjectError` and switch on `error.code` when you need per-object detail. diff --git a/packages/sui/src/client/core-resolver.ts b/packages/sui/src/client/core-resolver.ts index 142cd2857..22a77290b 100644 --- a/packages/sui/src/client/core-resolver.ts +++ b/packages/sui/src/client/core-resolver.ts @@ -11,7 +11,7 @@ import { createCoinReservationRef } from '../utils/coin-reservation.js'; import type { ClientWithCoreApi } from './core.js'; import type { CallArg, Command } from '../transactions/data/internal.js'; import type { SuiClientTypes } from './types.js'; -import { SimulationError } from './errors.js'; +import { ObjectError, SimulationError } from './errors.js'; import { Inputs } from '../transactions/Inputs.js'; import { getPureBcsSchema, isTxContext } from '../transactions/serializer.js'; import type { TransactionDataBuilder } from '../transactions/TransactionData.js'; @@ -294,17 +294,18 @@ async function resolveObjectReferences( }), ); - const invalidObjects = Array.from(responsesById) - .filter(([_, obj]) => obj instanceof Error) - .map(([_, obj]) => (obj as Error).message); - - if (invalidObjects.length) { - throw new Error(`The following input objects are invalid: ${invalidObjects.join(', ')}`); + // Rethrow the first ObjectError so `instanceof ObjectError` and `error.code` still narrow. + // Multi-invalid aggregation is out of scope; introduce `AggregateObjectError` if needed later. + const firstInvalid = Array.from(responsesById.values()).find( + (obj): obj is ObjectError => obj instanceof ObjectError, + ); + if (firstInvalid) { + throw firstInvalid; } const objects = resolved.map((object) => { - if (object instanceof Error) { - throw new Error(`Failed to fetch object: ${object.message}`); + if (object instanceof ObjectError) { + throw object; } const owner = object.owner; const initialSharedVersion = diff --git a/packages/sui/src/client/core.ts b/packages/sui/src/client/core.ts index bb1139a19..0cea9d766 100644 --- a/packages/sui/src/client/core.ts +++ b/packages/sui/src/client/core.ts @@ -6,6 +6,7 @@ import type { TransactionPlugin } from '../transactions/index.js'; import { deriveDynamicFieldID } from '../utils/dynamic-fields.js'; import { normalizeStructTag, parseStructTag, SUI_ADDRESS_LENGTH } from '../utils/sui-types.js'; import { BaseClient } from './client.js'; +import { ObjectError } from './errors.js'; import type { ClientWithExtensions, SuiClientTypes } from './types.js'; import { MvrClient } from './mvr.js'; import { bcs } from '../bcs/index.js'; @@ -54,7 +55,7 @@ export abstract class CoreClient extends BaseClient implements SuiClientTypes.Tr signal: options.signal, include: options.include, }); - if (result instanceof Error) { + if (result instanceof ObjectError) { throw result; } return { object: result }; @@ -148,7 +149,7 @@ export abstract class CoreClient extends BaseClient implements SuiClientTypes.Tr }, }); - if (fieldObject instanceof Error) { + if (fieldObject instanceof ObjectError) { throw fieldObject; } diff --git a/packages/sui/src/client/errors.ts b/packages/sui/src/client/errors.ts index bcfc497b7..831edce65 100644 --- a/packages/sui/src/client/errors.ts +++ b/packages/sui/src/client/errors.ts @@ -1,50 +1,78 @@ // Copyright (c) Mysten Labs, Inc. // SPDX-License-Identifier: Apache-2.0 -import type { ObjectResponseError } from '../jsonRpc/index.js'; import type { SuiClientTypes } from './types.js'; -export class SuiClientError extends Error {} +/** + * Structured per-transport escape hatch attached to any `SuiClientError`. + */ +export type TransportDetails = + | { + $kind: 'jsonRpc'; + /** The raw `ObjectResponseError` payload from the JSON-RPC response. */ + response: unknown; + } + | { + $kind: 'grpc'; + /** The `google.rpc.Status` attached to the per-object gRPC result. */ + status: { code: number; message: string; details: unknown[] }; + } + | { + /** No wire payload — GraphQL omits missing objects rather than emitting structured errors. */ + $kind: 'graphql'; + }; + +export class SuiClientError extends Error { + readonly transportDetails?: TransportDetails; + + constructor( + message?: string, + options?: { cause?: unknown; transportDetails?: TransportDetails }, + ) { + super(message, { cause: options?.cause }); + this.transportDetails = options?.transportDetails; + } +} export class SimulationError extends SuiClientError { - executionError?: SuiClientTypes.ExecutionError; + readonly executionError?: SuiClientTypes.ExecutionError; constructor( message: string, - options?: { cause?: unknown; executionError?: SuiClientTypes.ExecutionError }, + options?: { + cause?: unknown; + executionError?: SuiClientTypes.ExecutionError; + transportDetails?: TransportDetails; + }, ) { - super(message, { cause: options?.cause }); + super(message, options); this.executionError = options?.executionError; } } +export type ObjectErrorCode = 'notFound' | 'unknown'; + export class ObjectError extends SuiClientError { - code: string; + readonly code: ObjectErrorCode; + readonly objectId: string; + declare readonly transportDetails: TransportDetails; - constructor(code: string, message: string) { - super(message); + constructor( + code: ObjectErrorCode, + objectId: string, + options: { cause?: unknown; transportDetails: TransportDetails }, + ) { + super(ObjectError.#formatMessage(code, objectId), options); this.code = code; + this.objectId = objectId; } - static fromResponse(response: ObjectResponseError, objectId?: string): ObjectError { - switch (response.code) { - case 'notExists': - return new ObjectError(response.code, `Object ${response.object_id} does not exist`); - case 'dynamicFieldNotFound': - return new ObjectError( - response.code, - `Dynamic field not found for object ${response.parent_object_id}`, - ); - case 'deleted': - return new ObjectError(response.code, `Object ${response.object_id} has been deleted`); - case 'displayError': - return new ObjectError(response.code, `Display error: ${response.error}`); + static #formatMessage(code: ObjectErrorCode, objectId: string): string { + switch (code) { + case 'notFound': + return `Object not found: ${objectId}`; case 'unknown': - default: - return new ObjectError( - response.code, - `Unknown error while loading object${objectId ? ` ${objectId}` : ''}`, - ); + return `Unknown object error: ${objectId}`; } } } diff --git a/packages/sui/src/client/index.ts b/packages/sui/src/client/index.ts index 4a011bee9..74aa2b64e 100644 --- a/packages/sui/src/client/index.ts +++ b/packages/sui/src/client/index.ts @@ -22,7 +22,13 @@ export { type ClientWithCoreApi, }; -export { SimulationError } from './errors.js'; +export { + SuiClientError, + SimulationError, + ObjectError, + type ObjectErrorCode, + type TransportDetails, +} from './errors.js'; export { ClientCache, type ClientCacheOptions } from './cache.js'; export { type NamedPackagesOverrides } from './mvr.js'; diff --git a/packages/sui/src/client/types.ts b/packages/sui/src/client/types.ts index f9635bc31..ad5c94113 100644 --- a/packages/sui/src/client/types.ts +++ b/packages/sui/src/client/types.ts @@ -10,6 +10,7 @@ import type { import type { Signer } from '../cryptography/keypair.js'; import type { ClientCache } from './cache.js'; import type { BaseClient } from './client.js'; +import type { ObjectError } from './errors.js'; export type SuiClientRegistration< T extends BaseClient = BaseClient, @@ -141,7 +142,7 @@ export namespace SuiClientTypes { } export interface GetObjectsResponse { - objects: (Object | Error)[]; + objects: (Object | ObjectError)[]; } export interface GetObjectResponse { diff --git a/packages/sui/src/graphql/core.ts b/packages/sui/src/graphql/core.ts index c644948fe..46f7eaa0f 100644 --- a/packages/sui/src/graphql/core.ts +++ b/packages/sui/src/graphql/core.ts @@ -108,12 +108,17 @@ export class GraphQLCoreClient extends CoreClient { ); results.push( ...batch - .map((id) => normalizeSuiAddress(id)) - .map( - (id) => - page.find((obj) => obj?.address === id) ?? - new ObjectError('notFound', `Object ${id} not found`), - ) + .map((rawId) => { + // Normalize for server lookup, but preserve `rawId` on ObjectError so + // `error.objectId` matches what the user passed in. + const normalized = normalizeSuiAddress(rawId); + return ( + page.find((obj) => obj?.address === normalized) ?? + new ObjectError('notFound', rawId, { + transportDetails: { $kind: 'graphql' }, + }) + ); + }) .map((obj) => { if (obj instanceof ObjectError) { return obj; diff --git a/packages/sui/src/grpc/core.ts b/packages/sui/src/grpc/core.ts index 795fed56f..d6a228557 100644 --- a/packages/sui/src/grpc/core.ts +++ b/packages/sui/src/grpc/core.ts @@ -1,8 +1,14 @@ // Copyright (c) Mysten Labs, Inc. // SPDX-License-Identifier: Apache-2.0 -import type { CoreClientOptions, SuiClientTypes } from '../client/index.js'; -import { CoreClient, formatMoveAbortMessage, SimulationError } from '../client/index.js'; +import type { CoreClientOptions, ObjectErrorCode, SuiClientTypes } from '../client/index.js'; +import { + CoreClient, + formatMoveAbortMessage, + ObjectError, + SimulationError, + SuiClientError, +} from '../client/index.js'; import type { SuiGrpcClient } from './client.js'; import type { Owner } from './proto/sui/rpc/v2/owner.js'; import { Owner_OwnerKind } from './proto/sui/rpc/v2/owner.js'; @@ -46,6 +52,10 @@ import { import { Value } from './proto/google/protobuf/struct.js'; import { SimulateTransactionRequest_TransactionChecks } from './proto/sui/rpc/v2/transaction_execution_service.js'; +// google.rpc.Code.NOT_FOUND — the only status we map to ObjectError('notFound'); +// any other code collapses to 'unknown'. +const GRPC_CODE_NOT_FOUND = 5; + export interface GrpcCoreClientOptions extends CoreClientOptions { client: SuiGrpcClient; } @@ -88,51 +98,59 @@ export class GrpcCoreClient extends CoreClient { }); results.push( - ...response.response.objects.map((object): SuiClientTypes.Object | Error => { - if (object.result.oneofKind === 'error') { - // TODO: improve error handling - return new Error(object.result.error.message); - } - - if (object.result.oneofKind !== 'object') { - return new Error('Unexpected result type'); - } - - const bcsContent = object.result.object.contents?.value ?? undefined; - const objectBcs = object.result.object.bcs?.value ?? undefined; - - // Package objects have type "package" which is not a struct tag, so don't normalize it - const objectType = object.result.object.objectType; - const type = - objectType && objectType.includes('::') - ? normalizeStructTag(objectType) - : (objectType ?? ''); - - const jsonContent = options.include?.json - ? object.result.object.json - ? (Value.toJson(object.result.object.json) as Record) - : null - : undefined; - - const displayData = mapDisplayProto( - options.include?.display, - object.result.object.display, - ); - - return { - objectId: object.result.object.objectId!, - version: object.result.object.version?.toString()!, - digest: object.result.object.digest!, - content: bcsContent as SuiClientTypes.Object['content'], - owner: mapOwner(object.result.object.owner)!, - type, - previousTransaction: (object.result.object.previousTransaction ?? - undefined) as SuiClientTypes.Object['previousTransaction'], - objectBcs: objectBcs as SuiClientTypes.Object['objectBcs'], - json: jsonContent as SuiClientTypes.Object['json'], - display: displayData as SuiClientTypes.Object['display'], - }; - }), + ...response.response.objects.map( + (object, idx): SuiClientTypes.Object | ObjectError => { + if (object.result.oneofKind === 'error') { + const status = object.result.error; + const code: ObjectErrorCode = + status.code === GRPC_CODE_NOT_FOUND ? 'notFound' : 'unknown'; + return new ObjectError(code, batch[idx], { + transportDetails: { $kind: 'grpc', status }, + }); + } + + if (object.result.oneofKind !== 'object') { + throw new SuiClientError( + `Unexpected gRPC result kind: "${object.result.oneofKind}" — expected "object" or "error"`, + ); + } + + const bcsContent = object.result.object.contents?.value ?? undefined; + const objectBcs = object.result.object.bcs?.value ?? undefined; + + // Package objects have type "package" which is not a struct tag, so don't normalize it + const objectType = object.result.object.objectType; + const type = + objectType && objectType.includes('::') + ? normalizeStructTag(objectType) + : (objectType ?? ''); + + const jsonContent = options.include?.json + ? object.result.object.json + ? (Value.toJson(object.result.object.json) as Record) + : null + : undefined; + + const displayData = mapDisplayProto( + options.include?.display, + object.result.object.display, + ); + + return { + objectId: object.result.object.objectId!, + version: object.result.object.version?.toString()!, + digest: object.result.object.digest!, + content: bcsContent as SuiClientTypes.Object['content'], + owner: mapOwner(object.result.object.owner)!, + type, + previousTransaction: (object.result.object.previousTransaction ?? + undefined) as SuiClientTypes.Object['previousTransaction'], + objectBcs: objectBcs as SuiClientTypes.Object['objectBcs'], + json: jsonContent as SuiClientTypes.Object['json'], + display: displayData as SuiClientTypes.Object['display'], + }; + }, + ), ); } diff --git a/packages/sui/src/jsonRpc/core.ts b/packages/sui/src/jsonRpc/core.ts index 7eb5c4618..cb7958900 100644 --- a/packages/sui/src/jsonRpc/core.ts +++ b/packages/sui/src/jsonRpc/core.ts @@ -9,6 +9,7 @@ import type { DryRunTransactionBlockResponse, ExecutionStatus as JsonRpcExecutionStatus, ObjectOwner, + ObjectResponseError, SuiMoveAbilitySet, SuiMoveAbort, SuiMoveNormalizedType, @@ -28,7 +29,12 @@ import { deriveDynamicFieldID } from '../utils/dynamic-fields.js'; import { SUI_FRAMEWORK_ADDRESS, SUI_SYSTEM_ADDRESS } from '../utils/constants.js'; import { CoreClient } from '../client/core.js'; import type { SuiClientTypes } from '../client/types.js'; -import { ObjectError } from '../client/errors.js'; +import { + ObjectError, + SuiClientError, + type ObjectErrorCode, + type TransportDetails, +} from '../client/errors.js'; import { formatMoveAbortMessage, parseTransactionBcs, @@ -38,6 +44,37 @@ import type { SuiJsonRpcClient } from './client.js'; const MAX_GAS = 50_000_000_000; +function mapJsonRpcObjectErrorCode(wireCode: ObjectResponseError['code']): ObjectErrorCode { + switch (wireCode) { + case 'notExists': + case 'deleted': + case 'dynamicFieldNotFound': + return 'notFound'; + case 'displayError': + case 'unknown': + return 'unknown'; + default: + wireCode satisfies never; + return 'unknown'; + } +} + +function extractObjectIdFromResponseError(error: ObjectResponseError): string | undefined { + switch (error.code) { + case 'notExists': + case 'deleted': + return error.object_id; + case 'dynamicFieldNotFound': + return error.parent_object_id; + case 'displayError': + case 'unknown': + return undefined; + default: + error satisfies never; + return undefined; + } +} + function parseJsonRpcExecutionStatus( status: JsonRpcExecutionStatus, abortError?: SuiMoveAbort | null, @@ -140,7 +177,11 @@ export class JSONRpcCoreClient extends CoreClient { for (const [idx, object] of objects.entries()) { if (object.error) { - results.push(ObjectError.fromResponse(object.error, batch[idx])); + results.push( + new ObjectError(mapJsonRpcObjectErrorCode(object.error.code), batch[idx], { + transportDetails: { $kind: 'jsonRpc', response: object.error }, + }), + ); } else { results.push(parseObject(object.data!, options.include)); } @@ -187,7 +228,21 @@ export class JSONRpcCoreClient extends CoreClient { return { objects: objects.data.map((result) => { if (result.error) { - throw ObjectError.fromResponse(result.error); + const wireError = result.error; + const transportDetails = { + $kind: 'jsonRpc', + response: wireError, + } satisfies TransportDetails; + const extractedId = extractObjectIdFromResponseError(wireError); + if (extractedId === undefined) { + throw new SuiClientError( + `JSON-RPC object error: no resolvable objectId for wire code '${wireError.code}'`, + { transportDetails }, + ); + } + throw new ObjectError(mapJsonRpcObjectErrorCode(wireError.code), extractedId, { + transportDetails, + }); } return parseObject(result.data!, options.include); diff --git a/packages/sui/src/transactions/executor/parallel.ts b/packages/sui/src/transactions/executor/parallel.ts index 267f9fee1..03b50b564 100644 --- a/packages/sui/src/transactions/executor/parallel.ts +++ b/packages/sui/src/transactions/executor/parallel.ts @@ -5,6 +5,7 @@ import { promiseWithResolvers } from '@mysten/utils'; import type { SuiObjectRef } from '../../bcs/types.js'; import type { ClientWithCoreApi } from '../../client/core.js'; import { coreClientResolveTransactionPlugin } from '../../client/core-resolver.js'; +import { ObjectError } from '../../client/errors.js'; import type { SuiClientTypes } from '../../client/types.js'; import type { Signer } from '../../cryptography/index.js'; import type { ObjectCacheOptions } from '../ObjectCache.js'; @@ -450,7 +451,7 @@ export class ParallelTransactionExecutor { }); refs.push( ...objects - .filter((obj): obj is SuiClientTypes.Object => !(obj instanceof Error)) + .filter((obj): obj is SuiClientTypes.Object => !(obj instanceof ObjectError)) .map((obj) => ({ objectId: obj.objectId, version: obj.version, diff --git a/packages/sui/test/e2e/clients/core/objects.test.ts b/packages/sui/test/e2e/clients/core/objects.test.ts index 258e111f0..e0770773a 100644 --- a/packages/sui/test/e2e/clients/core/objects.test.ts +++ b/packages/sui/test/e2e/clients/core/objects.test.ts @@ -7,6 +7,7 @@ import { Transaction } from '../../../../src/transactions/index.js'; import { setup, TestToolbox, createTestWithAllClients } from '../../utils/setup.js'; import { normalizeSuiAddress, SUI_TYPE_ARG } from '../../../../src/utils/index.js'; import { bcs } from '../../../../src/bcs/index.js'; +import { ObjectError } from '../../../../src/client/errors.js'; const SimpleObject = bcs.struct('SimpleObject', { id: bcs.Address, @@ -104,9 +105,32 @@ describe('Core API - Objects', () => { expect(BigInt(parsed.value)).toBe(42n); }); - testWithAllClients('should throw error for non-existent object', async (client) => { + testWithAllClients( + 'getObjects returns unified ObjectError shape for non-existent object', + async (client, kind) => { + const fakeObjectId = normalizeSuiAddress('0x9999'); + const { objects } = await client.core.getObjects({ objectIds: [fakeObjectId] }); + expect(objects).toHaveLength(1); + const result = objects[0]; + expect(result).toBeInstanceOf(ObjectError); + if (result instanceof ObjectError) { + expect(result.code).toBe('notFound'); + expect(result.objectId).toBe(fakeObjectId); + const kindToDetails = { + jsonrpc: 'jsonRpc', + grpc: 'grpc', + graphql: 'graphql', + } as const; + expect(result.transportDetails?.$kind).toBe(kindToDetails[kind]); + } + }, + ); + + testWithAllClients('getObject throws ObjectError for non-existent object', async (client) => { const fakeObjectId = normalizeSuiAddress('0x9999'); - await expect(client.core.getObject({ objectId: fakeObjectId })).rejects.toThrow(); + await expect(client.core.getObject({ objectId: fakeObjectId })).rejects.toBeInstanceOf( + ObjectError, + ); }); testWithAllClients('should verify owner is correct', async (client) => { diff --git a/packages/sui/test/unit/client/core-resolver.test.ts b/packages/sui/test/unit/client/core-resolver.test.ts index 49407d115..9b4e19d36 100644 --- a/packages/sui/test/unit/client/core-resolver.test.ts +++ b/packages/sui/test/unit/client/core-resolver.test.ts @@ -7,6 +7,7 @@ import { describe, expect, it, vi } from 'vitest'; import { Transaction } from '../../../src/transactions/index.js'; import { Inputs } from '../../../src/transactions/Inputs.js'; import { coreClientResolveTransactionPlugin } from '../../../src/client/core-resolver.js'; +import { ObjectError } from '../../../src/client/errors.js'; import { isCoinReservationDigest, parseCoinReservationBalance, @@ -413,3 +414,42 @@ describe('Chain identifier fetch gating', () => { expect(client.core.getChainIdentifier).not.toHaveBeenCalled(); }); }); + +describe('coreClientResolveTransactionPlugin object error propagation', () => { + it('rethrows the original ObjectError instance when an unresolved input is missing', async () => { + // Regression test for the case where `resolveObjectReferences` previously flattened + // `ObjectError` results from `client.core.getObjects` into a plain `new Error(...)`. + // The contract the PR unifies — `instanceof ObjectError` works consistently across + // the call sites — must hold through the transaction resolver path as well, not + // only through `CoreClient.getObject` direct calls. + const missingId = '0x' + '9'.repeat(64); + const wireError = { code: 'notExists' as const, object_id: missingId }; + const objErr = new ObjectError('notFound', missingId, { + transportDetails: { $kind: 'jsonRpc', response: wireError }, + }); + + const client = createMockClient(); + client.core.getObjects = vi.fn().mockResolvedValue({ objects: [objErr] }); + + const tx = new Transaction(); + tx.setSender('0x' + '2'.repeat(64)); + tx.setGasBudget(10000000); + tx.setGasPayment([]); // skip gas payment resolution + tx.object(missingId); + + let thrown: unknown; + try { + await tx.build({ client: client as any }); + } catch (e) { + thrown = e; + } + + // Reference identity — the resolver must rethrow the exact instance the + // core client produced, preserving `code`, `objectId`, and `transportDetails`. + expect(thrown).toBe(objErr); + expect(thrown).toBeInstanceOf(ObjectError); + expect((thrown as ObjectError).code).toBe('notFound'); + expect((thrown as ObjectError).objectId).toBe(missingId); + expect((thrown as ObjectError).transportDetails.$kind).toBe('jsonRpc'); + }); +}); diff --git a/packages/sui/test/unit/client/object-error.test.ts b/packages/sui/test/unit/client/object-error.test.ts new file mode 100644 index 000000000..a039a0f30 --- /dev/null +++ b/packages/sui/test/unit/client/object-error.test.ts @@ -0,0 +1,386 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from 'vitest'; + +import type { TransportDetails } from '../../../src/client/errors.js'; +import { ObjectError, SimulationError, SuiClientError } from '../../../src/client/errors.js'; +import { JSONRpcCoreClient } from '../../../src/jsonRpc/core.js'; +import type { SuiJsonRpcClient } from '../../../src/jsonRpc/client.js'; +import type { + ObjectResponseError, + PaginatedObjectsResponse, +} from '../../../src/jsonRpc/types/generated.js'; +import { GrpcCoreClient } from '../../../src/grpc/core.js'; +import type { SuiGrpcClient } from '../../../src/grpc/client.js'; +import { GraphQLCoreClient } from '../../../src/graphql/core.js'; +import type { SuiGraphQLClient } from '../../../src/graphql/client.js'; + +// `transportDetails` is required on every `ObjectError` in practice; tests that +// don't care which transport produced the error use this minimal graphql tag. +const ANY_TRANSPORT: TransportDetails = { $kind: 'graphql' }; + +describe('ObjectError', () => { + it('extends SuiClientError and Error', () => { + const err = new ObjectError('notFound', '0x123', { transportDetails: ANY_TRANSPORT }); + expect(err).toBeInstanceOf(Error); + expect(err).toBeInstanceOf(SuiClientError); + expect(err).toBeInstanceOf(ObjectError); + }); + + it('sets code and objectId from constructor', () => { + const err = new ObjectError('notFound', '0xabc', { transportDetails: ANY_TRANSPORT }); + expect(err.code).toBe('notFound'); + expect(err.objectId).toBe('0xabc'); + }); + + it.each([ + ['notFound', '0x1', 'Object not found: 0x1'], + ['unknown', '0x2', 'Unknown object error: 0x2'], + ] as const)('generates canonical message for code=%s', (code, id, expected) => { + expect(new ObjectError(code, id, { transportDetails: ANY_TRANSPORT }).message).toBe(expected); + }); + + it('preserves cause', () => { + const rawError = { code: 'notExists', object_id: '0x123' }; + const err = new ObjectError('notFound', '0x123', { + cause: rawError, + transportDetails: ANY_TRANSPORT, + }); + expect(err.cause).toBe(rawError); + }); + + it('carries transportDetails for JSON-RPC payloads', () => { + const response = { code: 'notExists', object_id: '0x1' }; + const err = new ObjectError('notFound', '0x1', { + transportDetails: { $kind: 'jsonRpc', response }, + }); + expect(err.transportDetails?.$kind).toBe('jsonRpc'); + if (err.transportDetails?.$kind === 'jsonRpc') { + expect(err.transportDetails.response).toBe(response); + } + }); + + it('carries transportDetails for gRPC status payloads', () => { + const status = { code: 5, message: 'not found', details: [] }; + const err = new ObjectError('notFound', '0x1', { + transportDetails: { $kind: 'grpc', status }, + }); + expect(err.transportDetails?.$kind).toBe('grpc'); + if (err.transportDetails?.$kind === 'grpc') { + expect(err.transportDetails.status.code).toBe(5); + } + }); + + it('carries transportDetails for GraphQL payloads', () => { + const err = new ObjectError('notFound', '0x1', { + transportDetails: { $kind: 'graphql' }, + }); + expect(err.transportDetails?.$kind).toBe('graphql'); + }); + + it('is distinguishable from SimulationError', () => { + const objErr = new ObjectError('notFound', '0x1', { transportDetails: ANY_TRANSPORT }); + const simErr = new SimulationError('sim failed'); + expect(objErr).not.toBeInstanceOf(SimulationError); + expect(simErr).not.toBeInstanceOf(ObjectError); + }); +}); + +describe('SuiClientError', () => { + it('carries transportDetails on the base class', () => { + const err = new SuiClientError('boom', { + transportDetails: { $kind: 'graphql' }, + }); + expect(err).toBeInstanceOf(Error); + expect(err).toBeInstanceOf(SuiClientError); + expect(err.transportDetails?.$kind).toBe('graphql'); + }); + + it('preserves cause independently of transportDetails', () => { + const rawError = new Error('wire failure'); + const err = new SuiClientError('boom', { + cause: rawError, + transportDetails: { $kind: 'jsonRpc', response: { code: 'unknown' } }, + }); + expect(err.cause).toBe(rawError); + expect(err.transportDetails?.$kind).toBe('jsonRpc'); + }); +}); + +describe('JSONRpcCoreClient.listOwnedObjects error escalation', () => { + const owner = '0x' + '1'.repeat(64); + + function createMockCore(response: PaginatedObjectsResponse) { + const mockJsonRpcClient = { + network: 'testnet' as const, + getOwnedObjects: vi.fn().mockResolvedValue(response), + }; + return new JSONRpcCoreClient({ + jsonRpcClient: mockJsonRpcClient as unknown as SuiJsonRpcClient, + }); + } + + async function captureThrow(core: JSONRpcCoreClient): Promise { + try { + await core.listOwnedObjects({ owner }); + } catch (e) { + return e as SuiClientError; + } + throw new Error('expected listOwnedObjects to throw'); + } + + it('escalates displayError (no object id in wire) to base SuiClientError', async () => { + const wireError: ObjectResponseError = { code: 'displayError', error: 'boom' }; + const core = createMockCore({ + data: [{ error: wireError }], + hasNextPage: false, + nextCursor: null, + }); + const err = await captureThrow(core); + expect(err).toBeInstanceOf(SuiClientError); + expect(err).not.toBeInstanceOf(ObjectError); + expect(err.message).toContain('displayError'); + expect(err.transportDetails?.$kind).toBe('jsonRpc'); + if (err.transportDetails?.$kind === 'jsonRpc') { + expect(err.transportDetails.response).toBe(wireError); + } + }); + + it('escalates unknown wire code (no object id) to base SuiClientError', async () => { + const wireError: ObjectResponseError = { code: 'unknown' }; + const core = createMockCore({ + data: [{ error: wireError }], + hasNextPage: false, + nextCursor: null, + }); + const err = await captureThrow(core); + expect(err).toBeInstanceOf(SuiClientError); + expect(err).not.toBeInstanceOf(ObjectError); + expect(err.transportDetails?.$kind).toBe('jsonRpc'); + }); + + it('throws ObjectError(notFound) with extracted object_id for notExists wire code', async () => { + const wireError: ObjectResponseError = { code: 'notExists', object_id: '0xdead' }; + const core = createMockCore({ + data: [{ error: wireError }], + hasNextPage: false, + nextCursor: null, + }); + const err = await captureThrow(core); + expect(err).toBeInstanceOf(ObjectError); + if (err instanceof ObjectError) { + expect(err.code).toBe('notFound'); + expect(err.objectId).toBe('0xdead'); + expect(err.transportDetails?.$kind).toBe('jsonRpc'); + } + }); + + it('normalizes dynamicFieldNotFound wire code to ObjectError(notFound) with parent_object_id', async () => { + const wireError: ObjectResponseError = { + code: 'dynamicFieldNotFound', + parent_object_id: '0xfeed', + }; + const core = createMockCore({ + data: [{ error: wireError }], + hasNextPage: false, + nextCursor: null, + }); + const err = await captureThrow(core); + expect(err).toBeInstanceOf(ObjectError); + if (err instanceof ObjectError) { + expect(err.code).toBe('notFound'); + expect(err.objectId).toBe('0xfeed'); + } + }); + + it('normalizes deleted wire code to ObjectError(notFound) with object_id', async () => { + const wireError: ObjectResponseError = { + code: 'deleted', + object_id: '0xbeef', + digest: '0xd', + version: '1', + }; + const core = createMockCore({ + data: [{ error: wireError }], + hasNextPage: false, + nextCursor: null, + }); + const err = await captureThrow(core); + expect(err).toBeInstanceOf(ObjectError); + if (err instanceof ObjectError) { + expect(err.code).toBe('notFound'); + expect(err.objectId).toBe('0xbeef'); + } + }); + + it('every wire code is catchable via instanceof SuiClientError (universal catch contract)', async () => { + const cases: ObjectResponseError[] = [ + { code: 'notExists', object_id: '0x1' }, + { code: 'dynamicFieldNotFound', parent_object_id: '0x2' }, + { code: 'deleted', object_id: '0x3', digest: '0xd', version: '1' }, + { code: 'displayError', error: 'boom' }, + { code: 'unknown' }, + ]; + for (const wireError of cases) { + const core = createMockCore({ + data: [{ error: wireError }], + hasNextPage: false, + nextCursor: null, + }); + const err = await captureThrow(core); + expect(err, `wire code=${wireError.code}`).toBeInstanceOf(SuiClientError); + expect(err.transportDetails?.$kind).toBe('jsonRpc'); + } + }); +}); + +describe('JSONRpcCoreClient.getObjects error mapping', () => { + function createMockCore(results: Array<{ error?: ObjectResponseError; data?: unknown }>) { + const mockJsonRpcClient = { + network: 'testnet' as const, + multiGetObjects: vi.fn().mockResolvedValue(results), + }; + return new JSONRpcCoreClient({ + jsonRpcClient: mockJsonRpcClient as unknown as SuiJsonRpcClient, + }); + } + + it.each([ + ['notExists', { code: 'notExists', object_id: '0xaaa' }, 'notFound'], + ['deleted', { code: 'deleted', object_id: '0xbbb', digest: '0xd', version: '1' }, 'notFound'], + [ + 'dynamicFieldNotFound', + { code: 'dynamicFieldNotFound', parent_object_id: '0xccc' }, + 'notFound', + ], + ['displayError', { code: 'displayError', error: 'boom' }, 'unknown'], + ['unknown', { code: 'unknown' }, 'unknown'], + ] as const)( + 'returns ObjectError for wire code=%s with mapped ObjectErrorCode', + async (_name, wireError, expectedCode) => { + const core = createMockCore([{ error: wireError as ObjectResponseError }]); + const { objects } = await core.getObjects({ objectIds: ['0xtarget'] }); + expect(objects).toHaveLength(1); + const err = objects[0]; + expect(err).toBeInstanceOf(ObjectError); + if (err instanceof ObjectError) { + expect(err.code).toBe(expectedCode); + // getObjects uses the input id (batch[idx]), not the extracted wire id, + // so every returned ObjectError has a known objectId regardless of wire code. + expect(err.objectId).toBe('0xtarget'); + expect(err.transportDetails.$kind).toBe('jsonRpc'); + } + }, + ); + + it('attaches the exact wire error as transportDetails.response (reference identity)', async () => { + const wireError: ObjectResponseError = { code: 'notExists', object_id: '0xaaa' }; + const core = createMockCore([{ error: wireError }]); + const { objects } = await core.getObjects({ objectIds: ['0xtarget'] }); + const err = objects[0]; + expect(err).toBeInstanceOf(ObjectError); + if (err instanceof ObjectError && err.transportDetails.$kind === 'jsonRpc') { + // Reference identity, not just structural equality — the raw wire payload + // round-trips through the mapper without copy, spread, or transformation. + expect(err.transportDetails.response).toBe(wireError); + } + }); +}); + +describe('GrpcCoreClient.getObjects error mapping', () => { + function createMockCore(statusCode: number) { + const mockGrpcClient = { + ledgerService: { + batchGetObjects: vi.fn().mockResolvedValue({ + response: { + objects: [ + { + result: { + oneofKind: 'error' as const, + error: { code: statusCode, message: 'x', details: [] }, + }, + }, + ], + }, + }), + }, + }; + return new GrpcCoreClient({ + client: mockGrpcClient as unknown as SuiGrpcClient, + network: 'testnet', + } as never); + } + + it.each([ + [5, 'notFound'], // google.rpc.Code.NOT_FOUND + [13, 'unknown'], // INTERNAL + [7, 'unknown'], // PERMISSION_DENIED + [0, 'unknown'], // OK (nonsensical but must not map to notFound) + ] as const)( + 'maps gRPC status code %i to ObjectErrorCode=%s', + async (statusCode, expectedCode) => { + const core = createMockCore(statusCode); + const { objects } = await core.getObjects({ objectIds: ['0xtarget'] }); + expect(objects).toHaveLength(1); + const err = objects[0]; + expect(err).toBeInstanceOf(ObjectError); + if (err instanceof ObjectError) { + expect(err.code).toBe(expectedCode); + expect(err.objectId).toBe('0xtarget'); + expect(err.transportDetails.$kind).toBe('grpc'); + if (err.transportDetails.$kind === 'grpc') { + expect(err.transportDetails.status.code).toBe(statusCode); + } + } + }, + ); +}); + +describe('GraphQLCoreClient.getObjects error mapping', () => { + // Regression test for cross-transport objectId parity: GraphQL used to pre-normalize + // the input ids before constructing `ObjectError`, so a consumer that passed + // `'0x9999'` saw `error.objectId === '0x000...9999'` on GraphQL but + // `error.objectId === '0x9999'` on JSON-RPC/gRPC. The fix preserves the raw input id + // at the error construction site so all three transports round-trip identically. + function createMockCore() { + const mockGraphQLClient = { + network: 'testnet' as const, + query: vi.fn().mockResolvedValue({ + data: { multiGetObjects: [] }, // empty page — every requested id surfaces as notFound + errors: undefined, + }), + }; + return new GraphQLCoreClient({ + graphqlClient: mockGraphQLClient as unknown as SuiGraphQLClient, + }); + } + + it('preserves the raw input id on ObjectError for an unnormalized input', async () => { + const core = createMockCore(); + const { objects } = await core.getObjects({ objectIds: ['0x9999'] }); + expect(objects).toHaveLength(1); + const err = objects[0]; + expect(err).toBeInstanceOf(ObjectError); + if (err instanceof ObjectError) { + // The raw 6-char input — NOT the 66-char normalized form. + // This pins cross-transport parity: JSON-RPC and gRPC already use `batch[idx]` + // at construction, and GraphQL now does too. + expect(err.objectId).toBe('0x9999'); + expect(err.code).toBe('notFound'); + expect(err.transportDetails.$kind).toBe('graphql'); + } + }); + + it('preserves the raw input id on ObjectError for a pre-normalized input', async () => { + const normalized = '0x' + '0'.repeat(60) + '9999'; + const core = createMockCore(); + const { objects } = await core.getObjects({ objectIds: [normalized] }); + expect(objects).toHaveLength(1); + const err = objects[0]; + expect(err).toBeInstanceOf(ObjectError); + if (err instanceof ObjectError) { + expect(err.objectId).toBe(normalized); + } + }); +}); From 0425ade6cb883627ee5af91edb05715073b3b38d Mon Sep 17 00:00:00 2001 From: Daniel Ahn Date: Sat, 18 Apr 2026 20:46:34 -0700 Subject: [PATCH 2/7] refactor(client): collapse GraphQL ObjectError comment to single idiomatic line Co-Authored-By: Claude Opus 4.7 --- packages/sui/src/graphql/core.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/sui/src/graphql/core.ts b/packages/sui/src/graphql/core.ts index 46f7eaa0f..6df209972 100644 --- a/packages/sui/src/graphql/core.ts +++ b/packages/sui/src/graphql/core.ts @@ -109,8 +109,7 @@ export class GraphQLCoreClient extends CoreClient { results.push( ...batch .map((rawId) => { - // Normalize for server lookup, but preserve `rawId` on ObjectError so - // `error.objectId` matches what the user passed in. + // Normalize for lookup, but echo rawId back on ObjectError const normalized = normalizeSuiAddress(rawId); return ( page.find((obj) => obj?.address === normalized) ?? From 81d9c70d87ce07aa8c06a5a4a8de8fcd5ea6ebb6 Mon Sep 17 00:00:00 2001 From: Daniel Ahn Date: Sat, 18 Apr 2026 21:27:14 -0700 Subject: [PATCH 3/7] fix(sui/client): add 'deleted' code, AggregateObjectError, honest optional transportDetails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ObjectErrorCode now distinguishes 'deleted' from 'notFound' (JSON-RPC wire level); gRPC/GraphQL cannot distinguish, so both collapse to 'notFound'. - ObjectError's options arg is optional; the base-class transportDetails field is honestly optional (no more 'declare' narrowing) so consumers can construct ObjectError without ceremony. - AggregateObjectError extends SuiClientError — wraps multiple ObjectErrors in a single throwable, exposed via .errors, catchable via the universal contract. - grpc/core.ts: one-line note clarifying gRPC's NOT_FOUND can't distinguish deleted from never-existed (intentional collapse to 'notFound'). --- packages/sui/src/client/errors.ts | 21 ++++++++++++++++++--- packages/sui/src/client/index.ts | 1 + packages/sui/src/grpc/core.ts | 2 +- packages/sui/src/jsonRpc/core.ts | 3 ++- 4 files changed, 22 insertions(+), 5 deletions(-) diff --git a/packages/sui/src/client/errors.ts b/packages/sui/src/client/errors.ts index 831edce65..8066c5e43 100644 --- a/packages/sui/src/client/errors.ts +++ b/packages/sui/src/client/errors.ts @@ -50,17 +50,16 @@ export class SimulationError extends SuiClientError { } } -export type ObjectErrorCode = 'notFound' | 'unknown'; +export type ObjectErrorCode = 'notFound' | 'deleted' | 'unknown'; export class ObjectError extends SuiClientError { readonly code: ObjectErrorCode; readonly objectId: string; - declare readonly transportDetails: TransportDetails; constructor( code: ObjectErrorCode, objectId: string, - options: { cause?: unknown; transportDetails: TransportDetails }, + options?: { cause?: unknown; transportDetails?: TransportDetails }, ) { super(ObjectError.#formatMessage(code, objectId), options); this.code = code; @@ -71,8 +70,24 @@ export class ObjectError extends SuiClientError { switch (code) { case 'notFound': return `Object not found: ${objectId}`; + case 'deleted': + return `Object deleted: ${objectId}`; case 'unknown': return `Unknown object error: ${objectId}`; } } } + +export class AggregateObjectError extends SuiClientError { + readonly errors: ObjectError[]; + + constructor(errors: ObjectError[], options?: { cause?: unknown }) { + super(AggregateObjectError.#formatMessage(errors), options); + this.errors = errors; + } + + static #formatMessage(errors: ObjectError[]): string { + const ids = errors.map((e) => e.objectId).join(', '); + return `${errors.length} object errors: ${ids}`; + } +} diff --git a/packages/sui/src/client/index.ts b/packages/sui/src/client/index.ts index 74aa2b64e..2e2b0afe7 100644 --- a/packages/sui/src/client/index.ts +++ b/packages/sui/src/client/index.ts @@ -26,6 +26,7 @@ export { SuiClientError, SimulationError, ObjectError, + AggregateObjectError, type ObjectErrorCode, type TransportDetails, } from './errors.js'; diff --git a/packages/sui/src/grpc/core.ts b/packages/sui/src/grpc/core.ts index d6a228557..aad3a80e5 100644 --- a/packages/sui/src/grpc/core.ts +++ b/packages/sui/src/grpc/core.ts @@ -53,7 +53,7 @@ import { Value } from './proto/google/protobuf/struct.js'; import { SimulateTransactionRequest_TransactionChecks } from './proto/sui/rpc/v2/transaction_execution_service.js'; // google.rpc.Code.NOT_FOUND — the only status we map to ObjectError('notFound'); -// any other code collapses to 'unknown'. +// any other code collapses to 'unknown'. gRPC cannot distinguish 'deleted' from never-existed. const GRPC_CODE_NOT_FOUND = 5; export interface GrpcCoreClientOptions extends CoreClientOptions { diff --git a/packages/sui/src/jsonRpc/core.ts b/packages/sui/src/jsonRpc/core.ts index cb7958900..249b096b8 100644 --- a/packages/sui/src/jsonRpc/core.ts +++ b/packages/sui/src/jsonRpc/core.ts @@ -46,8 +46,9 @@ const MAX_GAS = 50_000_000_000; function mapJsonRpcObjectErrorCode(wireCode: ObjectResponseError['code']): ObjectErrorCode { switch (wireCode) { - case 'notExists': case 'deleted': + return 'deleted'; + case 'notExists': case 'dynamicFieldNotFound': return 'notFound'; case 'displayError': From 75507c0cce010d47dbd290d88a9c8bbde61de05a Mon Sep 17 00:00:00 2001 From: Daniel Ahn Date: Sat, 18 Apr 2026 21:27:21 -0700 Subject: [PATCH 4/7] fix(sui/client): aggregate multi-object errors via AggregateObjectError resolveObjectReferences previously either flattened multi-object errors to a plain Error("invalid: \...") string or rethrew only the first. With the new AggregateObjectError type, the resolver now: - throws the bare ObjectError when exactly one input is invalid (so `instanceof ObjectError` and `.code` still narrow) - throws AggregateObjectError when >1 inputs are invalid, carrying all ObjectErrors on .errors Consumers that catch SuiClientError catch both cases uniformly. --- packages/sui/src/client/core-resolver.ts | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/packages/sui/src/client/core-resolver.ts b/packages/sui/src/client/core-resolver.ts index 22a77290b..cd0265772 100644 --- a/packages/sui/src/client/core-resolver.ts +++ b/packages/sui/src/client/core-resolver.ts @@ -11,7 +11,7 @@ import { createCoinReservationRef } from '../utils/coin-reservation.js'; import type { ClientWithCoreApi } from './core.js'; import type { CallArg, Command } from '../transactions/data/internal.js'; import type { SuiClientTypes } from './types.js'; -import { ObjectError, SimulationError } from './errors.js'; +import { AggregateObjectError, ObjectError, SimulationError } from './errors.js'; import { Inputs } from '../transactions/Inputs.js'; import { getPureBcsSchema, isTxContext } from '../transactions/serializer.js'; import type { TransactionDataBuilder } from '../transactions/TransactionData.js'; @@ -294,19 +294,17 @@ async function resolveObjectReferences( }), ); - // Rethrow the first ObjectError so `instanceof ObjectError` and `error.code` still narrow. - // Multi-invalid aggregation is out of scope; introduce `AggregateObjectError` if needed later. - const firstInvalid = Array.from(responsesById.values()).find( + const objectErrors = Array.from(responsesById.values()).filter( (obj): obj is ObjectError => obj instanceof ObjectError, ); - if (firstInvalid) { - throw firstInvalid; + if (objectErrors.length === 1) { + throw objectErrors[0]; + } + if (objectErrors.length > 1) { + throw new AggregateObjectError(objectErrors); } - const objects = resolved.map((object) => { - if (object instanceof ObjectError) { - throw object; - } + const objects = (resolved as Exclude<(typeof resolved)[number], ObjectError>[]).map((object) => { const owner = object.owner; const initialSharedVersion = owner && typeof owner === 'object' From cd49677c351309d19216e9dad7a42b46fca9a9f1 Mon Sep 17 00:00:00 2001 From: Daniel Ahn Date: Sat, 18 Apr 2026 21:27:28 -0700 Subject: [PATCH 5/7] fix(sui/graphql): make instanceof SuiClientError universal GraphQLResponseError previously extended the global Error, so the multi-error path (`throw new AggregateError(errorInstances)`) and any single-error throw could escape `instanceof SuiClientError`. That broke the universal catch contract for consumers switching on error type. - GraphQLResponseError now extends SuiClientError and attaches transportDetails: { $kind: 'graphql' }. - handleGraphQLErrors wraps the AggregateError in a SuiClientError with transportDetails: { $kind: 'graphql' } and the AggregateError as cause. Also adds a one-line comment documenting that GraphQL omits absent objects without saying why, so it cannot distinguish 'deleted' from never-existed. --- packages/sui/src/graphql/core.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/sui/src/graphql/core.ts b/packages/sui/src/graphql/core.ts index 6df209972..be273d6e3 100644 --- a/packages/sui/src/graphql/core.ts +++ b/packages/sui/src/graphql/core.ts @@ -34,7 +34,7 @@ import { VerifyZkLoginSignatureDocument, ZkLoginIntentScope, } from './generated/queries.js'; -import { ObjectError, SimulationError } from '../client/errors.js'; +import { ObjectError, SimulationError, SuiClientError } from '../client/errors.js'; import { chunk, fromBase64, toBase64 } from '@mysten/utils'; import { normalizeSuiAddress } from '../utils/sui-types.js'; import { formatMoveAbortMessage, parseTransactionEffectsBcs } from '../client/utils.js'; @@ -109,7 +109,8 @@ export class GraphQLCoreClient extends CoreClient { results.push( ...batch .map((rawId) => { - // Normalize for lookup, but echo rawId back on ObjectError + // Normalize for lookup, but echo rawId back on ObjectError. + // GraphQL omits absent objects without saying why — cannot distinguish 'deleted'. const normalized = normalizeSuiAddress(rawId); return ( page.find((obj) => obj?.address === normalized) ?? @@ -810,14 +811,17 @@ function handleGraphQLErrors(errors: GraphQLResponseErrors | undefined): void { throw errorInstances[0]; } - throw new AggregateError(errorInstances); + throw new SuiClientError(`GraphQL response returned ${errorInstances.length} errors`, { + cause: new AggregateError(errorInstances), + transportDetails: { $kind: 'graphql' }, + }); } -class GraphQLResponseError extends Error { +class GraphQLResponseError extends SuiClientError { locations?: Array<{ line: number; column: number }>; constructor(error: GraphQLResponseErrors[0]) { - super(error.message); + super(error.message, { transportDetails: { $kind: 'graphql' } }); this.locations = error.locations; } } From 8e294f941a2925093fc6ec67d5c3945f4f3dcae7 Mon Sep 17 00:00:00 2001 From: Daniel Ahn Date: Sat, 18 Apr 2026 21:27:35 -0700 Subject: [PATCH 6/7] test(sui/client): cover new ObjectError contract, aggregate, and graphql catch - ObjectError: construct with no options, generates canonical messages for 'deleted' / 'notFound' / 'unknown'. - AggregateObjectError: extends SuiClientError, exposes .errors, summarizes child ids in the message. - JSONRpc listOwnedObjects: 'deleted' wire code maps to ObjectError('deleted'); displayError/unknown still escalate to bare SuiClientError so ObjectError. objectId stays non-empty. - JSONRpc getObjects: 'deleted' wire code produces ObjectError('deleted'). - core-resolver: multi-invalid input throws AggregateObjectError with reference identity preserved on each .errors[i]. - graphql: GraphQLResponseError is instanceof SuiClientError (universal catch). --- .../test/unit/client/core-resolver.test.ts | 65 ++++++++++++++++- .../sui/test/unit/client/object-error.test.ts | 70 +++++++++++++------ 2 files changed, 110 insertions(+), 25 deletions(-) diff --git a/packages/sui/test/unit/client/core-resolver.test.ts b/packages/sui/test/unit/client/core-resolver.test.ts index 9b4e19d36..ac9d6d448 100644 --- a/packages/sui/test/unit/client/core-resolver.test.ts +++ b/packages/sui/test/unit/client/core-resolver.test.ts @@ -7,7 +7,7 @@ import { describe, expect, it, vi } from 'vitest'; import { Transaction } from '../../../src/transactions/index.js'; import { Inputs } from '../../../src/transactions/Inputs.js'; import { coreClientResolveTransactionPlugin } from '../../../src/client/core-resolver.js'; -import { ObjectError } from '../../../src/client/errors.js'; +import { AggregateObjectError, ObjectError } from '../../../src/client/errors.js'; import { isCoinReservationDigest, parseCoinReservationBalance, @@ -450,6 +450,67 @@ describe('coreClientResolveTransactionPlugin object error propagation', () => { expect(thrown).toBeInstanceOf(ObjectError); expect((thrown as ObjectError).code).toBe('notFound'); expect((thrown as ObjectError).objectId).toBe(missingId); - expect((thrown as ObjectError).transportDetails.$kind).toBe('jsonRpc'); + expect((thrown as ObjectError).transportDetails?.$kind).toBe('jsonRpc'); + }); + + it('aggregates multiple ObjectErrors into AggregateObjectError', async () => { + const id1 = '0x' + '8'.repeat(64); + const id2 = '0x' + '9'.repeat(64); + const err1 = new ObjectError('notFound', id1, { + transportDetails: { $kind: 'jsonRpc', response: { code: 'notExists', object_id: id1 } }, + }); + const err2 = new ObjectError('deleted', id2, { + transportDetails: { $kind: 'jsonRpc', response: { code: 'deleted', object_id: id2 } }, + }); + + const client = createMockClient(); + client.core.getObjects = vi.fn().mockResolvedValue({ objects: [err1, err2] }); + + const tx = new Transaction(); + tx.setSender('0x' + '2'.repeat(64)); + tx.setGasBudget(10000000); + tx.setGasPayment([]); + tx.object(id1); + tx.object(id2); + + let thrown: unknown; + try { + await tx.build({ client: client as any }); + } catch (e) { + thrown = e; + } + + expect(thrown).toBeInstanceOf(AggregateObjectError); + expect((thrown as AggregateObjectError).errors).toEqual([err1, err2]); + expect((thrown as AggregateObjectError).errors[0]).toBe(err1); + expect((thrown as AggregateObjectError).errors[1]).toBe(err2); + }); +}); + +describe('GraphQLResponseError catch contract', () => { + it('extends SuiClientError so it is catchable via the universal catch', async () => { + const { GraphQLCoreClient } = await import('../../../src/graphql/core.js'); + const { SuiClientError } = await import('../../../src/client/errors.js'); + const mockGraphQLClient = { + network: 'testnet' as const, + query: vi.fn().mockResolvedValue({ + data: null, + errors: [{ message: 'Field x not found', locations: [{ line: 1, column: 5 }] }], + }), + }; + const core = new GraphQLCoreClient({ + graphqlClient: mockGraphQLClient as any, + }); + + let thrown: unknown; + try { + await core.getObjects({ objectIds: ['0x1'] }); + } catch (e) { + thrown = e; + } + + expect(thrown).toBeInstanceOf(SuiClientError); + expect((thrown as InstanceType).message).toContain('Field x not found'); + expect((thrown as InstanceType).transportDetails?.$kind).toBe('graphql'); }); }); diff --git a/packages/sui/test/unit/client/object-error.test.ts b/packages/sui/test/unit/client/object-error.test.ts index a039a0f30..54cc5998f 100644 --- a/packages/sui/test/unit/client/object-error.test.ts +++ b/packages/sui/test/unit/client/object-error.test.ts @@ -3,8 +3,12 @@ import { describe, expect, it, vi } from 'vitest'; -import type { TransportDetails } from '../../../src/client/errors.js'; -import { ObjectError, SimulationError, SuiClientError } from '../../../src/client/errors.js'; +import { + AggregateObjectError, + ObjectError, + SimulationError, + SuiClientError, +} from '../../../src/client/errors.js'; import { JSONRpcCoreClient } from '../../../src/jsonRpc/core.js'; import type { SuiJsonRpcClient } from '../../../src/jsonRpc/client.js'; import type { @@ -16,37 +20,36 @@ import type { SuiGrpcClient } from '../../../src/grpc/client.js'; import { GraphQLCoreClient } from '../../../src/graphql/core.js'; import type { SuiGraphQLClient } from '../../../src/graphql/client.js'; -// `transportDetails` is required on every `ObjectError` in practice; tests that -// don't care which transport produced the error use this minimal graphql tag. -const ANY_TRANSPORT: TransportDetails = { $kind: 'graphql' }; - describe('ObjectError', () => { it('extends SuiClientError and Error', () => { - const err = new ObjectError('notFound', '0x123', { transportDetails: ANY_TRANSPORT }); + const err = new ObjectError('notFound', '0x123'); expect(err).toBeInstanceOf(Error); expect(err).toBeInstanceOf(SuiClientError); expect(err).toBeInstanceOf(ObjectError); }); it('sets code and objectId from constructor', () => { - const err = new ObjectError('notFound', '0xabc', { transportDetails: ANY_TRANSPORT }); + const err = new ObjectError('notFound', '0xabc'); expect(err.code).toBe('notFound'); expect(err.objectId).toBe('0xabc'); }); + it('constructs with no options arg', () => { + const err = new ObjectError('notFound', '0x1'); + expect(err.transportDetails).toBeUndefined(); + }); + it.each([ ['notFound', '0x1', 'Object not found: 0x1'], - ['unknown', '0x2', 'Unknown object error: 0x2'], + ['deleted', '0x2', 'Object deleted: 0x2'], + ['unknown', '0x3', 'Unknown object error: 0x3'], ] as const)('generates canonical message for code=%s', (code, id, expected) => { - expect(new ObjectError(code, id, { transportDetails: ANY_TRANSPORT }).message).toBe(expected); + expect(new ObjectError(code, id).message).toBe(expected); }); it('preserves cause', () => { const rawError = { code: 'notExists', object_id: '0x123' }; - const err = new ObjectError('notFound', '0x123', { - cause: rawError, - transportDetails: ANY_TRANSPORT, - }); + const err = new ObjectError('notFound', '0x123', { cause: rawError }); expect(err.cause).toBe(rawError); }); @@ -80,13 +83,34 @@ describe('ObjectError', () => { }); it('is distinguishable from SimulationError', () => { - const objErr = new ObjectError('notFound', '0x1', { transportDetails: ANY_TRANSPORT }); + const objErr = new ObjectError('notFound', '0x1'); const simErr = new SimulationError('sim failed'); expect(objErr).not.toBeInstanceOf(SimulationError); expect(simErr).not.toBeInstanceOf(ObjectError); }); }); +describe('AggregateObjectError', () => { + it('extends SuiClientError and exposes errors[]', () => { + const errs = [new ObjectError('notFound', '0x1'), new ObjectError('deleted', '0x2')]; + const agg = new AggregateObjectError(errs); + expect(agg).toBeInstanceOf(Error); + expect(agg).toBeInstanceOf(SuiClientError); + expect(agg).toBeInstanceOf(AggregateObjectError); + expect(agg.errors).toBe(errs); + }); + + it('summarizes child object ids in the message', () => { + const agg = new AggregateObjectError([ + new ObjectError('notFound', '0x1'), + new ObjectError('deleted', '0x2'), + ]); + expect(agg.message).toContain('2 object errors'); + expect(agg.message).toContain('0x1'); + expect(agg.message).toContain('0x2'); + }); +}); + describe('SuiClientError', () => { it('carries transportDetails on the base class', () => { const err = new SuiClientError('boom', { @@ -194,7 +218,7 @@ describe('JSONRpcCoreClient.listOwnedObjects error escalation', () => { } }); - it('normalizes deleted wire code to ObjectError(notFound) with object_id', async () => { + it('maps deleted wire code to ObjectError(deleted) with object_id', async () => { const wireError: ObjectResponseError = { code: 'deleted', object_id: '0xbeef', @@ -209,7 +233,7 @@ describe('JSONRpcCoreClient.listOwnedObjects error escalation', () => { const err = await captureThrow(core); expect(err).toBeInstanceOf(ObjectError); if (err instanceof ObjectError) { - expect(err.code).toBe('notFound'); + expect(err.code).toBe('deleted'); expect(err.objectId).toBe('0xbeef'); } }); @@ -248,7 +272,7 @@ describe('JSONRpcCoreClient.getObjects error mapping', () => { it.each([ ['notExists', { code: 'notExists', object_id: '0xaaa' }, 'notFound'], - ['deleted', { code: 'deleted', object_id: '0xbbb', digest: '0xd', version: '1' }, 'notFound'], + ['deleted', { code: 'deleted', object_id: '0xbbb', digest: '0xd', version: '1' }, 'deleted'], [ 'dynamicFieldNotFound', { code: 'dynamicFieldNotFound', parent_object_id: '0xccc' }, @@ -269,7 +293,7 @@ describe('JSONRpcCoreClient.getObjects error mapping', () => { // getObjects uses the input id (batch[idx]), not the extracted wire id, // so every returned ObjectError has a known objectId regardless of wire code. expect(err.objectId).toBe('0xtarget'); - expect(err.transportDetails.$kind).toBe('jsonRpc'); + expect(err.transportDetails?.$kind).toBe('jsonRpc'); } }, ); @@ -280,7 +304,7 @@ describe('JSONRpcCoreClient.getObjects error mapping', () => { const { objects } = await core.getObjects({ objectIds: ['0xtarget'] }); const err = objects[0]; expect(err).toBeInstanceOf(ObjectError); - if (err instanceof ObjectError && err.transportDetails.$kind === 'jsonRpc') { + if (err instanceof ObjectError && err.transportDetails?.$kind === 'jsonRpc') { // Reference identity, not just structural equality — the raw wire payload // round-trips through the mapper without copy, spread, or transformation. expect(err.transportDetails.response).toBe(wireError); @@ -328,8 +352,8 @@ describe('GrpcCoreClient.getObjects error mapping', () => { if (err instanceof ObjectError) { expect(err.code).toBe(expectedCode); expect(err.objectId).toBe('0xtarget'); - expect(err.transportDetails.$kind).toBe('grpc'); - if (err.transportDetails.$kind === 'grpc') { + expect(err.transportDetails?.$kind).toBe('grpc'); + if (err.transportDetails?.$kind === 'grpc') { expect(err.transportDetails.status.code).toBe(statusCode); } } @@ -368,7 +392,7 @@ describe('GraphQLCoreClient.getObjects error mapping', () => { // at construction, and GraphQL now does too. expect(err.objectId).toBe('0x9999'); expect(err.code).toBe('notFound'); - expect(err.transportDetails.$kind).toBe('graphql'); + expect(err.transportDetails?.$kind).toBe('graphql'); } }); From 6a142c2c9a2f57983ba965a36e3c9e45225664d7 Mon Sep 17 00:00:00 2001 From: Daniel Ahn Date: Sat, 18 Apr 2026 21:27:39 -0700 Subject: [PATCH 7/7] changeset: document unified ObjectError contract + AggregateObjectError --- .changeset/unified-object-errors.md | 40 +++++++++++++++++++---------- 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/.changeset/unified-object-errors.md b/.changeset/unified-object-errors.md index acf0cf9c7..ebe7cbeec 100644 --- a/.changeset/unified-object-errors.md +++ b/.changeset/unified-object-errors.md @@ -3,16 +3,24 @@ --- Unify `ObjectError` across all transports. `ObjectError` now carries a -transport-agnostic `code: 'notFound' | 'unknown'` and a non-nullable `objectId`, -and works identically regardless of whether the client is backed by JSON-RPC, -gRPC, or GraphQL. +transport-agnostic `code: 'notFound' | 'deleted' | 'unknown'` and a non-nullable +`objectId`, and works identically regardless of whether the client is backed by +JSON-RPC, gRPC, or GraphQL. -JSON-RPC's five `ObjectResponseError` wire codes (`notExists`, `deleted`, -`dynamicFieldNotFound`, `displayError`, `unknown`) are normalized on the way in: -`notExists`, `deleted`, and `dynamicFieldNotFound` all surface as `'notFound'`; -`displayError` and `unknown` surface as `'unknown'`. The raw wire payload is -still available on `error.transportDetails` for consumers that need the richer -detail. +`ObjectError`'s constructor `options` arg is now optional — consumers can +construct `ObjectError` directly without ceremony, and the base-class +`transportDetails` field is honestly optional. + +JSON-RPC distinguishes `'deleted'` from never-existed at the wire level, so it +maps `deleted` → `'deleted'` and `notExists`/`dynamicFieldNotFound` → `'notFound'`. +gRPC and GraphQL cannot distinguish (gRPC's `NOT_FOUND` is not specific; GraphQL +omits absent objects without saying why), so both collapse to `'notFound'`. The +raw wire payload is preserved on `error.transportDetails` for consumers who need +to discriminate further. + +When `client.core.getObjects` resolves multiple invalid ids in a transaction, +the new `AggregateObjectError extends SuiClientError` is thrown with all +errors on `.errors`. A single invalid id still throws the bare `ObjectError`. Adds `TransportDetails`, a tagged union lifted onto the `SuiClientError` base class that exposes the raw per-transport payload (JSON-RPC response, gRPC @@ -23,11 +31,17 @@ where the server returns an error that identifies no specific object (e.g. a `displayError` surfaced during `listOwnedObjects`), a base `SuiClientError` is thrown instead — consumers who catch `SuiClientError` still catch everything. +`GraphQLResponseError` now extends `SuiClientError`, and the multi-error path +wraps the aggregate in `SuiClientError` with `transportDetails: { $kind: 'graphql' }` +on the cause. `instanceof SuiClientError` is now genuinely universal across +all three transports. + Also narrows `GetObjectsResponse.objects` from `(Object | Error)[]` to `(Object | ObjectError)[]`. Because `ObjectError extends Error`, existing `instanceof Error` checks continue to work unchanged. -Newly exports `SuiClientError` (base class), `ObjectError`, `ObjectErrorCode`, -and `TransportDetails` from `@mysten/sui/client`. Use `instanceof SuiClientError` -as the universal catch contract for any error originating from the client; use -`instanceof ObjectError` and switch on `error.code` when you need per-object detail. +Newly exports `SuiClientError` (base class), `ObjectError`, +`AggregateObjectError`, `ObjectErrorCode`, and `TransportDetails` from +`@mysten/sui/client`. Use `instanceof SuiClientError` as the universal catch +contract for any error originating from the client; use `instanceof ObjectError` +and switch on `error.code` when you need per-object detail.