Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/add-event-json-include.md
Original file line number Diff line number Diff line change
@@ -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).
5 changes: 5 additions & 0 deletions .changeset/fix-gas-payment-address-balance.md
Original file line number Diff line number Diff line change
@@ -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.
132 changes: 121 additions & 11 deletions packages/sui/src/client/core-resolver.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
Expand All @@ -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<SUI>) 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(
Expand Down Expand Up @@ -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;

Expand All @@ -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);
}
Expand All @@ -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;
Expand All @@ -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.');
}
Expand Down
8 changes: 8 additions & 0 deletions packages/sui/src/client/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> | null;
}

export interface MoveAbort {
Expand Down
1 change: 1 addition & 0 deletions packages/sui/src/graphql/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -894,6 +894,7 @@ function parseTransaction<Include extends SuiClientTypes.TransactionInclude = {}
sender: event.sender?.address!,
eventType,
bcs: event.contents?.bcs ? fromBase64(event.contents.bcs) : new Uint8Array(),
json: (event.contents?.json as Record<string, unknown>) ?? null,
};
}) ?? [])
: undefined) as SuiClientTypes.Transaction<Include>['events'],
Expand Down
Loading