diff --git a/.changeset/add-event-json-include.md b/.changeset/add-event-json-include.md new file mode 100644 index 000000000..da16d3943 --- /dev/null +++ b/.changeset/add-event-json-include.md @@ -0,0 +1,5 @@ +--- +'@mysten/sui': minor +--- + +Add `json` field to event data in transaction responses. When `events: true` is included, each event now contains a `json` field with the JSON representation of the event's Move struct data (or `null` if unavailable). Supported across all three transports (gRPC, GraphQL, JSON-RPC). diff --git a/.changeset/fix-gas-payment-address-balance.md b/.changeset/fix-gas-payment-address-balance.md new file mode 100644 index 000000000..d83b2369e --- /dev/null +++ b/.changeset/fix-gas-payment-address-balance.md @@ -0,0 +1,5 @@ +--- +'@mysten/sui': patch +--- + +Fix gas payment resolution for transactions that use `tx.gas` with address balance. When a transaction uses `Argument::GasCoin` (e.g. `tx.splitCoins(tx.gas, [...])`) and the sender's SUI is held as address balance, the SDK now constructs a coin reservation reference and includes it in the gas payment so the validator can draw gas from address balance. This fixes "No valid gas coins found" errors for accounts whose SUI is entirely in address balance. diff --git a/packages/sui/src/client/core-resolver.ts b/packages/sui/src/client/core-resolver.ts index ce87d6414..b7b10f5bd 100644 --- a/packages/sui/src/client/core-resolver.ts +++ b/packages/sui/src/client/core-resolver.ts @@ -1,6 +1,7 @@ // Copyright (c) Mysten Labs, Inc. // SPDX-License-Identifier: Apache-2.0 +import { fromBase58, fromHex, toBase58, toHex } from '@mysten/bcs'; import { parse } from 'valibot'; import { normalizeSuiAddress, normalizeSuiObjectId, SUI_TYPE_ARG } from '../utils/index.js'; @@ -14,6 +15,7 @@ import { getPureBcsSchema, isTxContext } from '../transactions/serializer.js'; import type { TransactionDataBuilder } from '../transactions/TransactionData.js'; import { chunk } from '@mysten/utils'; import type { BuildTransactionOptions } from '../transactions/index.js'; +import { deriveDynamicFieldID } from '../utils/dynamic-fields.js'; // The maximum objects that can be fetched at once using multiGetObjects. const MAX_OBJECTS_PER_FETCH = 50; @@ -22,6 +24,93 @@ const MAX_OBJECTS_PER_FETCH = 50; const GAS_SAFE_OVERHEAD = 1000n; const MAX_GAS = 50_000_000_000; +// The accumulator root object ID (0xacc) which is the parent of all address balance dynamic fields. +// See: sui/crates/sui-types/src/lib.rs +const SUI_ACCUMULATOR_ROOT_OBJECT_ID = + '0x0000000000000000000000000000000000000000000000000000000000000acc'; + +// Magic bytes used to identify a coin reservation digest (last 20 bytes). +// See: sui/crates/sui-types/src/coin_reservation.rs +const COIN_RESERVATION_MAGIC = new Uint8Array([ + 0xac, 0xac, 0xac, 0xac, 0xac, 0xac, 0xac, 0xac, 0xac, 0xac, + 0xac, 0xac, 0xac, 0xac, 0xac, 0xac, 0xac, 0xac, 0xac, 0xac, +]); + +/** + * Constructs a "fake coin" ObjectRef that encodes an address balance reservation for gas payment. + * + * When a transaction uses `tx.gas` (Argument::GasCoin) but the sender's SUI is held as address + * balance rather than individual coin objects, the gas payment must include a reservation reference + * so the validator knows to draw gas from the address balance. + * + * The format is defined in sui/crates/sui-types/src/coin_reservation.rs: + * - objectId: XOR(accumulatorObjectId, chainIdentifier) — masked to prevent cross-chain replay + * - version: "0" (unused by validation; helps old client caching) + * - digest: [amount:u64le][epoch:u32le][magic:20 bytes of 0xac] + */ +function buildCoinReservationRef( + owner: string, + chainIdentifier: string, + epoch: number, + reservationAmount: bigint, +): { objectId: string; version: string; digest: string } { + // Compute the accumulator dynamic field ID for this owner + SUI balance type. + // This mirrors AccumulatorValue::get_field_id(owner, Balance) in Rust. + const keyBytes = fromHex(normalizeSuiAddress(owner).slice(2)); + const normalized0x2 = normalizeSuiAddress('0x2'); + const accumulatorObjectId = deriveDynamicFieldID( + SUI_ACCUMULATOR_ROOT_OBJECT_ID, + { + struct: { + address: normalized0x2, + module: 'accumulator', + name: 'Key', + typeParams: [ + { + struct: { + address: normalized0x2, + module: 'balance', + name: 'Balance', + typeParams: [ + { + struct: { + address: normalized0x2, + module: 'sui', + name: 'SUI', + typeParams: [], + }, + }, + ], + }, + }, + ], + }, + }, + keyBytes, + ); + + // Mask the object ID by XORing with the chain identifier (genesis digest) to prevent replay. + const idBytes = fromHex(normalizeSuiAddress(accumulatorObjectId).slice(2)); + const chainBytes = fromBase58(chainIdentifier); + const maskedId = new Uint8Array(32); + for (let i = 0; i < 32; i++) { + maskedId[i] = idBytes[i] ^ chainBytes[i]; + } + + // Build the 32-byte digest: [reservation_amount:u64le][epoch_id:u32le][magic:20] + const digestBytes = new Uint8Array(32); + const view = new DataView(digestBytes.buffer); + view.setBigUint64(0, reservationAmount, true); + view.setUint32(8, epoch, true); + digestBytes.set(COIN_RESERVATION_MAGIC, 12); + + return { + objectId: `0x${toHex(maskedId)}`, + version: '0', + digest: toBase58(digestBytes), + }; +} + function getClient(options: BuildTransactionOptions): ClientWithCoreApi { if (!options.client) { throw new Error( @@ -112,7 +201,12 @@ async function setGasBudget(transactionData: TransactionDataBuilder, client: Cli // The current default is just picking _all_ coins we can which may not be ideal. async function setGasPayment(transactionData: TransactionDataBuilder, client: ClientWithCoreApi) { if (!transactionData.gasData.payment) { - const gasPayer = transactionData.gasData.owner ?? transactionData.sender!; + const gasPayer = transactionData.gasData.owner ?? transactionData.sender; + if (!gasPayer) { + throw new Error('Either a gas owner or sender must be set to determine gas payment.'); + } + + const normalizedGasPayer = normalizeSuiAddress(gasPayer); let usesGasCoin = false; let withdrawals = 0n; @@ -127,7 +221,7 @@ async function setGasPayment(transactionData: TransactionDataBuilder, client: Cl ? transactionData.sender : gasPayer; - if (withdrawalOwner === gasPayer) { + if (withdrawalOwner && normalizeSuiAddress(withdrawalOwner) === normalizedGasPayer) { if (input.FundsWithdrawal.reservation.$kind === 'MaxAmountU64') { withdrawals += BigInt(input.FundsWithdrawal.reservation.MaxAmountU64); } @@ -139,21 +233,18 @@ async function setGasPayment(transactionData: TransactionDataBuilder, client: Cl }); const [suiBalance, coins] = await Promise.all([ - usesGasCoin || !transactionData.gasData.owner - ? null - : client.core.getBalance({ - owner: transactionData.gasData.owner, - }), + client.core.getBalance({ owner: gasPayer }), client.core.listCoins({ - owner: transactionData.gasData.owner || transactionData.sender!, + owner: gasPayer, coinType: SUI_TYPE_ARG, }), ]); + const addressBalance = BigInt(suiBalance.balance.addressBalance); + if ( - suiBalance?.balance.addressBalance && - BigInt(suiBalance.balance.addressBalance) >= - BigInt(transactionData.gasData.budget || '0') + withdrawals + !usesGasCoin && + addressBalance >= BigInt(transactionData.gasData.budget || '0') + withdrawals ) { transactionData.gasData.payment = []; return; @@ -180,6 +271,25 @@ async function setGasPayment(transactionData: TransactionDataBuilder, client: Cl }), ); + // When the transaction uses tx.gas (Argument::GasCoin) and the sender has address balance, + // we must include a coin reservation ref so the validator can draw gas from address balance. + // This supports old-style transactions (e.g. tx.splitCoins(tx.gas, [amount])) when the + // sender's SUI is held as address balance rather than individual coin objects. + if (usesGasCoin && addressBalance > 0n) { + const [{ systemState }, { chainIdentifier }] = await Promise.all([ + client.core.getCurrentSystemState(), + client.core.getChainIdentifier(), + ]); + const fakeCoin = buildCoinReservationRef( + gasPayer, + chainIdentifier, + Number(systemState.epoch), + BigInt(transactionData.gasData.budget || '0'), + ); + transactionData.gasData.payment = [...paymentCoins, fakeCoin]; + return; + } + if (!paymentCoins.length) { throw new Error('No valid gas coins found for the transaction.'); } diff --git a/packages/sui/src/client/types.ts b/packages/sui/src/client/types.ts index d9505e1a0..e91534ce5 100644 --- a/packages/sui/src/client/types.ts +++ b/packages/sui/src/client/types.ts @@ -847,6 +847,14 @@ export namespace SuiClientTypes { sender: string; eventType: string; bcs: Uint8Array; + /** + * The JSON representation of the event's Move struct data. + * + * **Warning:** The exact shape and field names of this data may vary between different + * API implementations (JSON-RPC vs gRPC or GraphQL). For consistent data across APIs use + * the `bcs` field and parse the BCS data directly. + */ + json: Record | null; } export interface MoveAbort { diff --git a/packages/sui/src/graphql/core.ts b/packages/sui/src/graphql/core.ts index 4d27c1202..80fd9db18 100644 --- a/packages/sui/src/graphql/core.ts +++ b/packages/sui/src/graphql/core.ts @@ -894,6 +894,7 @@ function parseTransaction) ?? null, }; }) ?? []) : undefined) as SuiClientTypes.Transaction['events'], diff --git a/packages/sui/src/graphql/generated/queries.ts b/packages/sui/src/graphql/generated/queries.ts index ca6343237..6fa9c6bd7 100644 --- a/packages/sui/src/graphql/generated/queries.ts +++ b/packages/sui/src/graphql/generated/queries.ts @@ -4744,7 +4744,7 @@ export type SimulateTransactionQueryVariables = Exact<{ }>; -export type SimulateTransactionQuery = { __typename?: 'Query', simulateTransaction: { __typename?: 'SimulationResult', effects?: { __typename?: 'TransactionEffects', transaction?: { __typename?: 'Transaction', digest: string, transactionJson?: unknown | null, transactionBcs?: string | null, signatures: Array<{ __typename?: 'UserSignature', signatureBytes?: string | null }>, effects?: { __typename?: 'TransactionEffects', status?: ExecutionStatus | null, effectsBcs?: string | null, effectsJson?: unknown | null, balanceChangesJson?: unknown | null, executionError?: { __typename?: 'ExecutionError', message: string, abortCode?: string | null, identifier?: string | null, constant?: string | null, sourceLineNumber?: number | null, instructionOffset?: number | null, module?: { __typename?: 'MoveModule', name: string, package?: { __typename?: 'MovePackage', address: string } | null } | null, function?: { __typename?: 'MoveFunction', name: string } | null } | null, epoch?: { __typename?: 'Epoch', epochId: number } | null, objectChanges?: { __typename?: 'ObjectChangeConnection', nodes: Array<{ __typename?: 'ObjectChange', address: string, outputState?: { __typename?: 'Object', asMoveObject?: { __typename?: 'MoveObject', contents?: { __typename?: 'MoveValue', type?: { __typename?: 'MoveType', repr: string } | null } | null } | null } | null }> } | null, events?: { __typename?: 'EventConnection', pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean }, nodes: Array<{ __typename?: 'Event', transactionModule?: { __typename?: 'MoveModule', name: string, package?: { __typename?: 'MovePackage', address: string } | null } | null, sender?: { __typename?: 'Address', address: string } | null, contents?: { __typename?: 'MoveValue', bcs?: string | null, type?: { __typename?: 'MoveType', repr: string } | null } | null }> } | null } | null } | null } | null, outputs?: Array<{ __typename?: 'CommandResult', returnValues?: Array<{ __typename?: 'CommandOutput', value?: { __typename?: 'MoveValue', bcs?: string | null } | null }> | null, mutatedReferences?: Array<{ __typename?: 'CommandOutput', value?: { __typename?: 'MoveValue', bcs?: string | null } | null }> | null }> | null } }; +export type SimulateTransactionQuery = { __typename?: 'Query', simulateTransaction: { __typename?: 'SimulationResult', effects?: { __typename?: 'TransactionEffects', transaction?: { __typename?: 'Transaction', digest: string, transactionJson?: unknown | null, transactionBcs?: string | null, signatures: Array<{ __typename?: 'UserSignature', signatureBytes?: string | null }>, effects?: { __typename?: 'TransactionEffects', status?: ExecutionStatus | null, effectsBcs?: string | null, effectsJson?: unknown | null, balanceChangesJson?: unknown | null, executionError?: { __typename?: 'ExecutionError', message: string, abortCode?: string | null, identifier?: string | null, constant?: string | null, sourceLineNumber?: number | null, instructionOffset?: number | null, module?: { __typename?: 'MoveModule', name: string, package?: { __typename?: 'MovePackage', address: string } | null } | null, function?: { __typename?: 'MoveFunction', name: string } | null } | null, epoch?: { __typename?: 'Epoch', epochId: number } | null, objectChanges?: { __typename?: 'ObjectChangeConnection', nodes: Array<{ __typename?: 'ObjectChange', address: string, outputState?: { __typename?: 'Object', asMoveObject?: { __typename?: 'MoveObject', contents?: { __typename?: 'MoveValue', type?: { __typename?: 'MoveType', repr: string } | null } | null } | null } | null }> } | null, events?: { __typename?: 'EventConnection', pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean }, nodes: Array<{ __typename?: 'Event', transactionModule?: { __typename?: 'MoveModule', name: string, package?: { __typename?: 'MovePackage', address: string } | null } | null, sender?: { __typename?: 'Address', address: string } | null, contents?: { __typename?: 'MoveValue', bcs?: string | null, json?: unknown | null, type?: { __typename?: 'MoveType', repr: string } | null } | null }> } | null } | null } | null } | null, outputs?: Array<{ __typename?: 'CommandResult', returnValues?: Array<{ __typename?: 'CommandOutput', value?: { __typename?: 'MoveValue', bcs?: string | null } | null }> | null, mutatedReferences?: Array<{ __typename?: 'CommandOutput', value?: { __typename?: 'MoveValue', bcs?: string | null } | null }> | null }> | null } }; export type ExecuteTransactionMutationVariables = Exact<{ transactionDataBcs: Scalars['Base64']['input']; @@ -4758,7 +4758,7 @@ export type ExecuteTransactionMutationVariables = Exact<{ }>; -export type ExecuteTransactionMutation = { __typename?: 'Mutation', executeTransaction: { __typename?: 'ExecutionResult', effects?: { __typename?: 'TransactionEffects', transaction?: { __typename?: 'Transaction', digest: string, transactionJson?: unknown | null, transactionBcs?: string | null, signatures: Array<{ __typename?: 'UserSignature', signatureBytes?: string | null }>, effects?: { __typename?: 'TransactionEffects', status?: ExecutionStatus | null, effectsBcs?: string | null, effectsJson?: unknown | null, balanceChangesJson?: unknown | null, executionError?: { __typename?: 'ExecutionError', message: string, abortCode?: string | null, identifier?: string | null, constant?: string | null, sourceLineNumber?: number | null, instructionOffset?: number | null, module?: { __typename?: 'MoveModule', name: string, package?: { __typename?: 'MovePackage', address: string } | null } | null, function?: { __typename?: 'MoveFunction', name: string } | null } | null, epoch?: { __typename?: 'Epoch', epochId: number } | null, objectChanges?: { __typename?: 'ObjectChangeConnection', nodes: Array<{ __typename?: 'ObjectChange', address: string, outputState?: { __typename?: 'Object', asMoveObject?: { __typename?: 'MoveObject', contents?: { __typename?: 'MoveValue', type?: { __typename?: 'MoveType', repr: string } | null } | null } | null } | null }> } | null, events?: { __typename?: 'EventConnection', pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean }, nodes: Array<{ __typename?: 'Event', transactionModule?: { __typename?: 'MoveModule', name: string, package?: { __typename?: 'MovePackage', address: string } | null } | null, sender?: { __typename?: 'Address', address: string } | null, contents?: { __typename?: 'MoveValue', bcs?: string | null, type?: { __typename?: 'MoveType', repr: string } | null } | null }> } | null } | null } | null } | null } }; +export type ExecuteTransactionMutation = { __typename?: 'Mutation', executeTransaction: { __typename?: 'ExecutionResult', effects?: { __typename?: 'TransactionEffects', transaction?: { __typename?: 'Transaction', digest: string, transactionJson?: unknown | null, transactionBcs?: string | null, signatures: Array<{ __typename?: 'UserSignature', signatureBytes?: string | null }>, effects?: { __typename?: 'TransactionEffects', status?: ExecutionStatus | null, effectsBcs?: string | null, effectsJson?: unknown | null, balanceChangesJson?: unknown | null, executionError?: { __typename?: 'ExecutionError', message: string, abortCode?: string | null, identifier?: string | null, constant?: string | null, sourceLineNumber?: number | null, instructionOffset?: number | null, module?: { __typename?: 'MoveModule', name: string, package?: { __typename?: 'MovePackage', address: string } | null } | null, function?: { __typename?: 'MoveFunction', name: string } | null } | null, epoch?: { __typename?: 'Epoch', epochId: number } | null, objectChanges?: { __typename?: 'ObjectChangeConnection', nodes: Array<{ __typename?: 'ObjectChange', address: string, outputState?: { __typename?: 'Object', asMoveObject?: { __typename?: 'MoveObject', contents?: { __typename?: 'MoveValue', type?: { __typename?: 'MoveType', repr: string } | null } | null } | null } | null }> } | null, events?: { __typename?: 'EventConnection', pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean }, nodes: Array<{ __typename?: 'Event', transactionModule?: { __typename?: 'MoveModule', name: string, package?: { __typename?: 'MovePackage', address: string } | null } | null, sender?: { __typename?: 'Address', address: string } | null, contents?: { __typename?: 'MoveValue', bcs?: string | null, json?: unknown | null, type?: { __typename?: 'MoveType', repr: string } | null } | null }> } | null } | null } | null } | null } }; export type GetTransactionBlockQueryVariables = Exact<{ digest: Scalars['String']['input']; @@ -4771,9 +4771,9 @@ export type GetTransactionBlockQueryVariables = Exact<{ }>; -export type GetTransactionBlockQuery = { __typename?: 'Query', transaction?: { __typename?: 'Transaction', digest: string, transactionJson?: unknown | null, transactionBcs?: string | null, signatures: Array<{ __typename?: 'UserSignature', signatureBytes?: string | null }>, effects?: { __typename?: 'TransactionEffects', status?: ExecutionStatus | null, effectsBcs?: string | null, effectsJson?: unknown | null, balanceChangesJson?: unknown | null, executionError?: { __typename?: 'ExecutionError', message: string, abortCode?: string | null, identifier?: string | null, constant?: string | null, sourceLineNumber?: number | null, instructionOffset?: number | null, module?: { __typename?: 'MoveModule', name: string, package?: { __typename?: 'MovePackage', address: string } | null } | null, function?: { __typename?: 'MoveFunction', name: string } | null } | null, epoch?: { __typename?: 'Epoch', epochId: number } | null, objectChanges?: { __typename?: 'ObjectChangeConnection', nodes: Array<{ __typename?: 'ObjectChange', address: string, outputState?: { __typename?: 'Object', asMoveObject?: { __typename?: 'MoveObject', contents?: { __typename?: 'MoveValue', type?: { __typename?: 'MoveType', repr: string } | null } | null } | null } | null }> } | null, events?: { __typename?: 'EventConnection', pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean }, nodes: Array<{ __typename?: 'Event', transactionModule?: { __typename?: 'MoveModule', name: string, package?: { __typename?: 'MovePackage', address: string } | null } | null, sender?: { __typename?: 'Address', address: string } | null, contents?: { __typename?: 'MoveValue', bcs?: string | null, type?: { __typename?: 'MoveType', repr: string } | null } | null }> } | null } | null } | null }; +export type GetTransactionBlockQuery = { __typename?: 'Query', transaction?: { __typename?: 'Transaction', digest: string, transactionJson?: unknown | null, transactionBcs?: string | null, signatures: Array<{ __typename?: 'UserSignature', signatureBytes?: string | null }>, effects?: { __typename?: 'TransactionEffects', status?: ExecutionStatus | null, effectsBcs?: string | null, effectsJson?: unknown | null, balanceChangesJson?: unknown | null, executionError?: { __typename?: 'ExecutionError', message: string, abortCode?: string | null, identifier?: string | null, constant?: string | null, sourceLineNumber?: number | null, instructionOffset?: number | null, module?: { __typename?: 'MoveModule', name: string, package?: { __typename?: 'MovePackage', address: string } | null } | null, function?: { __typename?: 'MoveFunction', name: string } | null } | null, epoch?: { __typename?: 'Epoch', epochId: number } | null, objectChanges?: { __typename?: 'ObjectChangeConnection', nodes: Array<{ __typename?: 'ObjectChange', address: string, outputState?: { __typename?: 'Object', asMoveObject?: { __typename?: 'MoveObject', contents?: { __typename?: 'MoveValue', type?: { __typename?: 'MoveType', repr: string } | null } | null } | null } | null }> } | null, events?: { __typename?: 'EventConnection', pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean }, nodes: Array<{ __typename?: 'Event', transactionModule?: { __typename?: 'MoveModule', name: string, package?: { __typename?: 'MovePackage', address: string } | null } | null, sender?: { __typename?: 'Address', address: string } | null, contents?: { __typename?: 'MoveValue', bcs?: string | null, json?: unknown | null, type?: { __typename?: 'MoveType', repr: string } | null } | null }> } | null } | null } | null }; -export type Transaction_FieldsFragment = { __typename?: 'Transaction', digest: string, transactionJson?: unknown | null, transactionBcs?: string | null, signatures: Array<{ __typename?: 'UserSignature', signatureBytes?: string | null }>, effects?: { __typename?: 'TransactionEffects', status?: ExecutionStatus | null, effectsBcs?: string | null, effectsJson?: unknown | null, balanceChangesJson?: unknown | null, executionError?: { __typename?: 'ExecutionError', message: string, abortCode?: string | null, identifier?: string | null, constant?: string | null, sourceLineNumber?: number | null, instructionOffset?: number | null, module?: { __typename?: 'MoveModule', name: string, package?: { __typename?: 'MovePackage', address: string } | null } | null, function?: { __typename?: 'MoveFunction', name: string } | null } | null, epoch?: { __typename?: 'Epoch', epochId: number } | null, objectChanges?: { __typename?: 'ObjectChangeConnection', nodes: Array<{ __typename?: 'ObjectChange', address: string, outputState?: { __typename?: 'Object', asMoveObject?: { __typename?: 'MoveObject', contents?: { __typename?: 'MoveValue', type?: { __typename?: 'MoveType', repr: string } | null } | null } | null } | null }> } | null, events?: { __typename?: 'EventConnection', pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean }, nodes: Array<{ __typename?: 'Event', transactionModule?: { __typename?: 'MoveModule', name: string, package?: { __typename?: 'MovePackage', address: string } | null } | null, sender?: { __typename?: 'Address', address: string } | null, contents?: { __typename?: 'MoveValue', bcs?: string | null, type?: { __typename?: 'MoveType', repr: string } | null } | null }> } | null } | null }; +export type Transaction_FieldsFragment = { __typename?: 'Transaction', digest: string, transactionJson?: unknown | null, transactionBcs?: string | null, signatures: Array<{ __typename?: 'UserSignature', signatureBytes?: string | null }>, effects?: { __typename?: 'TransactionEffects', status?: ExecutionStatus | null, effectsBcs?: string | null, effectsJson?: unknown | null, balanceChangesJson?: unknown | null, executionError?: { __typename?: 'ExecutionError', message: string, abortCode?: string | null, identifier?: string | null, constant?: string | null, sourceLineNumber?: number | null, instructionOffset?: number | null, module?: { __typename?: 'MoveModule', name: string, package?: { __typename?: 'MovePackage', address: string } | null } | null, function?: { __typename?: 'MoveFunction', name: string } | null } | null, epoch?: { __typename?: 'Epoch', epochId: number } | null, objectChanges?: { __typename?: 'ObjectChangeConnection', nodes: Array<{ __typename?: 'ObjectChange', address: string, outputState?: { __typename?: 'Object', asMoveObject?: { __typename?: 'MoveObject', contents?: { __typename?: 'MoveValue', type?: { __typename?: 'MoveType', repr: string } | null } | null } | null } | null }> } | null, events?: { __typename?: 'EventConnection', pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean }, nodes: Array<{ __typename?: 'Event', transactionModule?: { __typename?: 'MoveModule', name: string, package?: { __typename?: 'MovePackage', address: string } | null } | null, sender?: { __typename?: 'Address', address: string } | null, contents?: { __typename?: 'MoveValue', bcs?: string | null, json?: unknown | null, type?: { __typename?: 'MoveType', repr: string } | null } | null }> } | null } | null }; export type ResolveTransactionQueryVariables = Exact<{ transaction: Scalars['JSON']['input']; @@ -4990,6 +4990,7 @@ export const Transaction_FieldsFragmentDoc = new TypedDocumentString(` repr } bcs + json } } } @@ -5384,6 +5385,7 @@ export const SimulateTransactionDocument = new TypedDocumentString(` repr } bcs + json } } } @@ -5467,6 +5469,7 @@ export const ExecuteTransactionDocument = new TypedDocumentString(` repr } bcs + json } } } @@ -5543,6 +5546,7 @@ export const GetTransactionBlockDocument = new TypedDocumentString(` repr } bcs + json } } } diff --git a/packages/sui/src/graphql/queries/transactions.graphql b/packages/sui/src/graphql/queries/transactions.graphql index d139e417b..a66ef719e 100644 --- a/packages/sui/src/graphql/queries/transactions.graphql +++ b/packages/sui/src/graphql/queries/transactions.graphql @@ -136,6 +136,7 @@ fragment TRANSACTION_FIELDS on Transaction { repr } bcs + json } } } diff --git a/packages/sui/src/grpc/core.ts b/packages/sui/src/grpc/core.ts index 71da4c781..35e81050c 100644 --- a/packages/sui/src/grpc/core.ts +++ b/packages/sui/src/grpc/core.ts @@ -1216,6 +1216,7 @@ function parseTransaction) : null, })) ?? []) : undefined) as SuiClientTypes.Transaction['events'], }; diff --git a/packages/sui/src/jsonRpc/core.ts b/packages/sui/src/jsonRpc/core.ts index cca3de46d..cc7d708cb 100644 --- a/packages/sui/src/jsonRpc/core.ts +++ b/packages/sui/src/jsonRpc/core.ts @@ -403,6 +403,7 @@ export class JSONRpcCoreClient extends CoreClient { sender: event.sender, eventType: event.type, bcs: 'bcs' in event ? fromBase64(event.bcs) : new Uint8Array(), + json: (event.parsedJson as Record) ?? null, })) ?? []) : undefined) as SuiClientTypes.Transaction['events'], }; @@ -886,6 +887,7 @@ function parseTransaction) ?? null, })) ?? []) : undefined) as SuiClientTypes.Transaction['events'], }; diff --git a/packages/sui/test/e2e/clients/core/transactions.test.ts b/packages/sui/test/e2e/clients/core/transactions.test.ts index 3f12ddb05..8c51839e8 100644 --- a/packages/sui/test/e2e/clients/core/transactions.test.ts +++ b/packages/sui/test/e2e/clients/core/transactions.test.ts @@ -492,6 +492,12 @@ describe('Core API - Transactions', () => { expect(event?.sender).toBe(testAddress); expect(event?.eventType).toContain('ObjectCreated'); expect(event?.bcs).toBeInstanceOf(Uint8Array); + + // Verify event json field + expect(event?.json).toBeDefined(); + expect(event?.json).not.toBeNull(); + expect(typeof event?.json).toBe('object'); + expect(event?.json).toHaveProperty('value'); }); testWithAllClients( @@ -552,6 +558,12 @@ describe('Core API - Transactions', () => { expect(Array.isArray(getResult.Transaction!.events)).toBe(true); expect(getResult.Transaction!.events?.length).toBeGreaterThan(0); expect(getResult.Transaction!.events?.[0]?.eventType).toContain('ObjectCreated'); + + // Verify event json field + const event = getResult.Transaction!.events?.[0]; + expect(event?.json).toBeDefined(); + expect(event?.json).not.toBeNull(); + expect(event?.json).toHaveProperty('value'); }); testWithAllClients('should include events in simulateTransaction response', async (client) => { @@ -574,6 +586,12 @@ describe('Core API - Transactions', () => { expect(result.Transaction!.events?.length).toBeGreaterThan(0); expect(result.Transaction!.events?.[0]?.packageId).toBe(packageId); expect(result.Transaction!.events?.[0]?.eventType).toContain('ObjectCreated'); + + // Verify event json field + const event = result.Transaction!.events?.[0]; + expect(event?.json).toBeDefined(); + expect(event?.json).not.toBeNull(); + expect(event?.json).toHaveProperty('value'); }); }); diff --git a/packages/sui/test/e2e/clients/core/zklogin.test.ts b/packages/sui/test/e2e/clients/core/zklogin.test.ts index cbce60fce..e2bce744e 100644 --- a/packages/sui/test/e2e/clients/core/zklogin.test.ts +++ b/packages/sui/test/e2e/clients/core/zklogin.test.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { beforeAll, describe, expect } from 'vitest'; -import { setup, TestToolbox, createTestWithAllClients, execSuiTools } from '../../utils/setup.js'; +import { setup, TestToolbox, createTestWithAllClients, execKeytool } from '../../utils/setup.js'; describe('Core API - ZkLogin', () => { let toolbox: TestToolbox; @@ -28,10 +28,8 @@ describe('Core API - ZkLogin', () => { const currentEpoch = Number(epoch.epoch); const maxEpoch = currentEpoch + 10; - // Generate PersonalMessage signature - const pmResult = await execSuiTools([ - 'sui', - 'keytool', + // Generate PersonalMessage signature using --json for reliable parsing + const pmJson = await execKeytool([ 'zk-login-insecure-sign-personal-message', '--data', 'hello', @@ -39,19 +37,15 @@ describe('Core API - ZkLogin', () => { maxEpoch.toString(), ]); - const pmOutput = pmResult.stdout; - const pmSigMatch = pmOutput.match(/│\s*sig\s*│\s*(.+?)\s*│/); - const pmAddressMatch = pmOutput.match(/│\s*address\s*│\s*(.+?)\s*│/); - - if (!pmSigMatch || !pmAddressMatch) { - throw new Error('Failed to generate zkLogin signature: could not parse output'); + if (!pmJson.sig || !pmJson.address) { + throw new Error('Failed to generate zkLogin signature: missing sig or address in output'); } validSignatureCase = { bytes: 'aGVsbG8=', // base64 encoding of "hello" - signature: pmSigMatch[1].trim(), + signature: pmJson.sig as string, intentScope: 'PersonalMessage', - address: pmAddressMatch[1].trim(), + address: pmJson.address as string, }; // Hardcoded TransactionData signature (generated using local keytool) diff --git a/packages/sui/test/e2e/multisig.test.ts b/packages/sui/test/e2e/multisig.test.ts index 259cb5578..e14e83145 100644 --- a/packages/sui/test/e2e/multisig.test.ts +++ b/packages/sui/test/e2e/multisig.test.ts @@ -15,7 +15,7 @@ import { parseSerializedZkLoginSignature, toZkLoginPublicIdentifier, } from '../../src/zklogin/publickey.js'; -import { DEFAULT_RECIPIENT, execSuiTools, setup, setupWithFundedAddress } from './utils/setup.js'; +import { DEFAULT_RECIPIENT, execKeytool, setup, setupWithFundedAddress } from './utils/setup.js'; describe('MultiSig with zklogin signature', () => { it('Execute tx with multisig with 1 sig and 1 zkLogin sig combined', async () => { @@ -26,10 +26,8 @@ describe('MultiSig with zklogin signature', () => { const maxEpoch = currentEpoch + 10; // Generate a zkLogin signature dynamically using sui keytool - // This creates a fresh signature with a valid max epoch - const pmResult = await execSuiTools([ - 'sui', - 'keytool', + // This creates a fresh signature with a valid max epoch, using --json for reliable parsing + const pmJson = await execKeytool([ 'zk-login-insecure-sign-personal-message', '--data', 'hello', @@ -37,15 +35,12 @@ describe('MultiSig with zklogin signature', () => { maxEpoch.toString(), ]); - const pmOutput = pmResult.stdout; - const pmSigMatch = pmOutput.match(/│\s*sig\s*│\s*(.+?)\s*│/); - - if (!pmSigMatch) { - throw new Error('Failed to generate zkLogin signature: could not parse output'); + if (!pmJson.sig) { + throw new Error('Failed to generate zkLogin signature: missing sig in output'); } // Parse the generated zkLogin signature to get the public key and proof details - const tempSig = pmSigMatch[1].trim(); + const tempSig = pmJson.sig as string; const parsedZkLogin = parseSerializedZkLoginSignature(tempSig); // Create ZkLoginPublicIdentifier from the parsed data const pkZklogin = toZkLoginPublicIdentifier( diff --git a/packages/sui/test/e2e/utils/globalSetup.ts b/packages/sui/test/e2e/utils/globalSetup.ts index 3c0b2b36d..a0fdcfb4e 100644 --- a/packages/sui/test/e2e/utils/globalSetup.ts +++ b/packages/sui/test/e2e/utils/globalSetup.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { resolve } from 'path'; -import { GenericContainer, Network, PullPolicy } from 'testcontainers'; +import { GenericContainer, getContainerRuntimeClient, Network, PullPolicy } from 'testcontainers'; import type { TestProject } from 'vitest/node'; import type { PrePublishedPackage } from './prePublish.js'; @@ -21,8 +21,8 @@ declare module 'vitest' { const SUI_TOOLS_TAG = process.env.SUI_TOOLS_TAG || (process.arch === 'arm64' - ? '62564033de7611e1080b1a782e6484114b9e7576-arm64' - : '62564033de7611e1080b1a782e6484114b9e7576'); + ? 'e50c3fe5ae2b3c43a42f9d25743386ba87bb150c-arm64' + : 'e50c3fe5ae2b3c43a42f9d25743386ba87bb150c'); export default async function setup(project: TestProject) { console.log('Starting test containers'); @@ -67,6 +67,27 @@ export default async function setup(project: TestProject) { const graphqlPort = localnet.getMappedPort(9125); const containerId = localnet.getId(); + // Create default sui config so `sui keytool` commands work in the container. + // This must happen once before any tests run to avoid race conditions. + const runtimeClient = await getContainerRuntimeClient(); + const container = runtimeClient.container.getById(containerId); + await runtimeClient.container.exec(container, ['mkdir', '-p', '/root/.sui/sui_config']); + await runtimeClient.container.exec(container, [ + 'bash', + '-c', + `echo '[]' > /root/.sui/sui_config/sui.keystore && cat > /root/.sui/sui_config/client.yaml << 'EOF' +--- +keystore: + File: /root/.sui/sui_config/sui.keystore +envs: + - alias: localnet + rpc: "http://127.0.0.1:9000" + ws: ~ +active_env: localnet +active_address: "0x0000000000000000000000000000000000000000000000000000000000000000" +EOF`, + ]); + project.provide('faucetPort', faucetPort); project.provide('localnetPort', localnetPort); project.provide('graphqlPort', graphqlPort); diff --git a/packages/sui/test/e2e/utils/setup.ts b/packages/sui/test/e2e/utils/setup.ts index e6c39c397..e56eebc15 100644 --- a/packages/sui/test/e2e/utils/setup.ts +++ b/packages/sui/test/e2e/utils/setup.ts @@ -494,6 +494,55 @@ export async function executePaySuiNTimes( const client = await getContainerRuntimeClient(); +/** + * Run a `sui keytool` command with an isolated keystore to avoid concurrent write conflicts. + * Retries on failure since concurrent keytool calls can cause panics in the sui binary. + * Returns the parsed JSON output. + */ +export async function execKeytool( + args: string[], + maxRetries = 3, +): Promise> { + for (let attempt = 0; attempt < maxRetries; attempt++) { + const keystorePath = `/tmp/keystore-${Math.random().toString(36).slice(2)}.keystore`; + await execSuiTools(['bash', '-c', `echo '[]' > ${keystorePath}`]); + const result = await execSuiTools([ + 'sui', + 'keytool', + '--keystore-path', + keystorePath, + '--json', + ...args, + ]); + try { + return parseKeytoolJson(result.stdout); + } catch (e) { + if (attempt === maxRetries - 1) throw e; + // Wait before retrying - concurrent keytool calls can cause crashes + await new Promise((resolve) => setTimeout(resolve, 1000 * (attempt + 1))); + } + } + throw new Error('unreachable'); +} + +/** + * Parse JSON output from `sui keytool --json` commands. + * The output may contain debug lines before the JSON object, so we find the last + * top-level JSON object by looking for `\n{` at the start of a line. + */ +export function parseKeytoolJson(stdout: string): Record { + // Find the last JSON object starting at the beginning of a line + const jsonStart = stdout.lastIndexOf('\n{'); + if (jsonStart !== -1) { + return JSON.parse(stdout.slice(jsonStart + 1)); + } + // If the output starts with '{', try parsing directly + if (stdout.trimStart().startsWith('{')) { + return JSON.parse(stdout.trimStart()); + } + throw new Error(`No JSON object found in keytool output: ${stdout.slice(0, 200)}`); +} + export async function execSuiTools( command: string[], options?: Parameters[2], diff --git a/packages/sui/test/e2e/zklogin-signature.test.ts b/packages/sui/test/e2e/zklogin-signature.test.ts index 131b6ebe5..de1b66794 100644 --- a/packages/sui/test/e2e/zklogin-signature.test.ts +++ b/packages/sui/test/e2e/zklogin-signature.test.ts @@ -10,7 +10,7 @@ import { ZkLoginPublicIdentifier, } from '../../src/zklogin/publickey.js'; import { getZkLoginSignature, parseZkLoginSignature } from '../../src/zklogin/signature.js'; -import { execSuiTools, setup } from './utils/setup.js'; +import { execKeytool, setup } from './utils/setup.js'; const DEFAULT_GRAPHQL_URL = import.meta.env.GRAPHQL_URL ?? 'http://127.0.0.1:9125/graphql'; @@ -80,11 +80,9 @@ describe('zkLogin signature', () => { const currentEpoch = Number(epoch.epoch); const maxEpoch = currentEpoch + 10; - // Generate PersonalMessage signature dynamically + // Generate PersonalMessage signature dynamically using --json for reliable parsing const bytes = 'aGVsbG8='; // the base64 encoding of "hello" - const pmResult = await execSuiTools([ - 'sui', - 'keytool', + const pmJson = await execKeytool([ 'zk-login-insecure-sign-personal-message', '--data', 'hello', @@ -92,14 +90,11 @@ describe('zkLogin signature', () => { maxEpoch.toString(), ]); - const pmOutput = pmResult.stdout; - const pmSigMatch = pmOutput.match(/│\s*sig\s*│\s*(.+?)\s*│/); - - if (!pmSigMatch) { - throw new Error('Failed to generate zkLogin signature: could not parse output'); + if (!pmJson.sig) { + throw new Error('Failed to generate zkLogin signature: missing sig in output'); } - const testSignature = pmSigMatch[1].trim(); + const testSignature = pmJson.sig as string; const parsed = parseSerializedZkLoginSignature(testSignature); const client = new SuiGraphQLClient({ url: DEFAULT_GRAPHQL_URL, @@ -114,9 +109,7 @@ describe('zkLogin signature', () => { expect(res).toBe(true); // Generate signature with max_epoch too large (should fail) - const largePmResult = await execSuiTools([ - 'sui', - 'keytool', + const largePmJson = await execKeytool([ 'zk-login-insecure-sign-personal-message', '--data', 'hello', @@ -124,14 +117,11 @@ describe('zkLogin signature', () => { (currentEpoch + 100).toString(), ]); - const largePmOutput = largePmResult.stdout; - const largePmSigMatch = largePmOutput.match(/│\s*sig\s*│\s*(.+?)\s*│/); - - if (!largePmSigMatch) { - throw new Error('Failed to generate zkLogin signature: could not parse output'); + if (!largePmJson.sig) { + throw new Error('Failed to generate zkLogin signature: missing sig in output'); } - const testSignature2 = largePmSigMatch[1].trim(); + const testSignature2 = largePmJson.sig as string; const parsed2 = parseSerializedZkLoginSignature(testSignature2); const res1 = await pk.verifyPersonalMessage(fromBase64(bytes), parsed2.signature); expect(res1).toBe(false);