diff --git a/.changeset/unified-object-errors.md b/.changeset/unified-object-errors.md new file mode 100644 index 000000000..ebe7cbeec --- /dev/null +++ b/.changeset/unified-object-errors.md @@ -0,0 +1,47 @@ +--- +'@mysten/sui': minor +--- + +Unify `ObjectError` across all transports. `ObjectError` now carries a +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. + +`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 +`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. + +`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`, +`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. diff --git a/packages/sui/src/client/core-resolver.ts b/packages/sui/src/client/core-resolver.ts index 826df924d..49ae6e595 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 { 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'; @@ -329,18 +329,17 @@ 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(', ')}`); + const objectErrors = Array.from(responsesById.values()).filter( + (obj): obj is ObjectError => obj instanceof ObjectError, + ); + if (objectErrors.length === 1) { + throw objectErrors[0]; + } + if (objectErrors.length > 1) { + throw new AggregateObjectError(objectErrors); } - const objects = resolved.map((object) => { - if (object instanceof Error) { - throw new Error(`Failed to fetch object: ${object.message}`); - } + const objects = (resolved as Exclude<(typeof resolved)[number], ObjectError>[]).map((object) => { const owner = object.owner; const initialSharedVersion = owner && typeof owner === 'object' 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..8066c5e43 100644 --- a/packages/sui/src/client/errors.ts +++ b/packages/sui/src/client/errors.ts @@ -1,50 +1,93 @@ // 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' | 'deleted' | 'unknown'; + export class ObjectError extends SuiClientError { - code: string; + readonly code: ObjectErrorCode; + readonly objectId: string; - 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}`, - ); + static #formatMessage(code: ObjectErrorCode, objectId: string): string { + switch (code) { + case 'notFound': + return `Object not found: ${objectId}`; 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}`); + return `Object deleted: ${objectId}`; case 'unknown': - default: - return new ObjectError( - response.code, - `Unknown error while loading object${objectId ? ` ${objectId}` : ''}`, - ); + 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 4a011bee9..2e2b0afe7 100644 --- a/packages/sui/src/client/index.ts +++ b/packages/sui/src/client/index.ts @@ -22,7 +22,14 @@ export { type ClientWithCoreApi, }; -export { SimulationError } from './errors.js'; +export { + SuiClientError, + SimulationError, + ObjectError, + AggregateObjectError, + 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..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'; @@ -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 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) ?? + new ObjectError('notFound', rawId, { + transportDetails: { $kind: 'graphql' }, + }) + ); + }) .map((obj) => { if (obj instanceof ObjectError) { return obj; @@ -806,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; } } diff --git a/packages/sui/src/grpc/core.ts b/packages/sui/src/grpc/core.ts index 795fed56f..aad3a80e5 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'. gRPC cannot distinguish 'deleted' from never-existed. +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..249b096b8 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,38 @@ import type { SuiJsonRpcClient } from './client.js'; const MAX_GAS = 50_000_000_000; +function mapJsonRpcObjectErrorCode(wireCode: ObjectResponseError['code']): ObjectErrorCode { + switch (wireCode) { + case 'deleted': + return 'deleted'; + case 'notExists': + 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 +178,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 +229,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 5073ffce8..404f7c458 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 { AggregateObjectError, ObjectError } from '../../../src/client/errors.js'; import { isCoinReservationDigest, parseCoinReservationBalance, @@ -440,3 +441,103 @@ 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'); + }); + + 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 new file mode 100644 index 000000000..54cc5998f --- /dev/null +++ b/packages/sui/test/unit/client/object-error.test.ts @@ -0,0 +1,410 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from 'vitest'; + +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 { + 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'; + +describe('ObjectError', () => { + it('extends SuiClientError and Error', () => { + 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'); + 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'], + ['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).message).toBe(expected); + }); + + it('preserves cause', () => { + const rawError = { code: 'notExists', object_id: '0x123' }; + const err = new ObjectError('notFound', '0x123', { cause: rawError }); + 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'); + 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', { + 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('maps deleted wire code to ObjectError(deleted) 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('deleted'); + 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' }, 'deleted'], + [ + '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); + } + }); +});