@mysten/sui
Version:
Sui TypeScript API
555 lines (473 loc) • 16.5 kB
text/typescript
// Copyright (c) Mysten Labs, Inc.
// SPDX-License-Identifier: Apache-2.0
import {
normalizeSuiAddress,
normalizeSuiObjectId,
normalizeStructTag,
SUI_TYPE_ARG,
} from '../utils/index.js';
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 { Inputs } from '../transactions/Inputs.js';
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';
// The maximum objects that can be fetched at once using multiGetObjects.
const MAX_OBJECTS_PER_FETCH = 50;
// An amount of gas (in gas units) that is added to transactions as an overhead to ensure transactions do not fail.
const GAS_SAFE_OVERHEAD = 1000n;
const MAX_GAS = 50_000_000_000;
function getClient(options: BuildTransactionOptions): ClientWithCoreApi {
if (!options.client) {
throw new Error(
`No sui client passed to Transaction#build, but transaction data was not sufficient to build offline.`,
);
}
return options.client;
}
export async function coreClientResolveTransactionPlugin(
transactionData: TransactionDataBuilder,
options: BuildTransactionOptions,
next: () => Promise<void>,
) {
const client = getClient(options);
const needsGasPrice = !options.onlyTransactionKind && !transactionData.gasData.price;
const needsPayment = !options.onlyTransactionKind && !transactionData.gasData.payment;
const gasPayer = transactionData.gasData.owner ?? transactionData.sender;
let usesGasCoin = false;
let withdrawals = 0n;
transactionData.mapArguments((arg) => {
if (arg.$kind === 'GasCoin') usesGasCoin = true;
return arg;
});
const normalizedGasPayer = gasPayer ? normalizeSuiAddress(gasPayer) : null;
for (const input of transactionData.inputs) {
if (input.$kind !== 'FundsWithdrawal' || !normalizedGasPayer) continue;
if (normalizeStructTag(input.FundsWithdrawal.typeArg.Balance) !== SUI_TYPE_ARG) continue;
const withdrawalOwner = input.FundsWithdrawal.withdrawFrom.Sender
? transactionData.sender
: gasPayer;
if (
withdrawalOwner &&
normalizeSuiAddress(withdrawalOwner) === normalizedGasPayer &&
input.FundsWithdrawal.reservation.$kind === 'MaxAmountU64'
) {
withdrawals += BigInt(input.FundsWithdrawal.reservation.MaxAmountU64);
}
}
const needsSystemState = needsGasPrice || (needsPayment && usesGasCoin);
const [, systemStateResult, balanceResult, coinsResult, protocolConfigResult, chainIdResult] =
await Promise.all([
normalizeInputs(transactionData, client),
needsSystemState ? client.core.getCurrentSystemState() : null,
needsPayment && gasPayer ? client.core.getBalance({ owner: gasPayer }) : null,
needsPayment && gasPayer
? client.core.listCoins({ owner: gasPayer, coinType: SUI_TYPE_ARG })
: null,
needsPayment && usesGasCoin ? client.core.getProtocolConfig() : null,
needsPayment && usesGasCoin ? client.core.getChainIdentifier() : null,
]);
await resolveObjectReferences(transactionData, client);
if (!options.onlyTransactionKind) {
const systemState = systemStateResult?.systemState ?? null;
if (systemState && !transactionData.gasData.price) {
transactionData.gasData.price = systemState.referenceGasPrice;
}
await setGasBudget(transactionData, client);
if (needsPayment) {
if (!balanceResult || !coinsResult) {
throw new Error(
'Could not resolve gas payment: a gas owner or sender must be set to fetch balance and coins.',
);
}
setGasPayment({
transactionData,
balance: balanceResult,
coins: coinsResult,
usesGasCoin,
withdrawals,
protocolConfig: protocolConfigResult?.protocolConfig,
gasPayer: gasPayer!,
chainIdentifier: chainIdResult?.chainIdentifier ?? null,
epoch: systemState?.epoch ?? null,
});
}
if (!transactionData.expiration && transactionData.gasData.payment?.length === 0) {
await setExpiration(
transactionData,
client,
systemState,
chainIdResult?.chainIdentifier ?? null,
);
}
}
return await next();
}
async function setGasBudget(transactionData: TransactionDataBuilder, client: ClientWithCoreApi) {
if (transactionData.gasData.budget) {
return;
}
const simulateResult = await client.core.simulateTransaction({
transaction: transactionData.build({
overrides: {
gasData: {
budget: String(MAX_GAS),
payment: [],
},
},
}),
include: { effects: true },
});
if (simulateResult.$kind === 'FailedTransaction') {
const executionError = simulateResult.FailedTransaction.status.error ?? undefined;
const errorMessage = executionError?.message ?? 'Unknown error';
throw new SimulationError(`Transaction resolution failed: ${errorMessage}`, {
cause: simulateResult,
executionError,
});
}
const gasUsed = simulateResult.Transaction.effects!.gasUsed;
const safeOverhead = GAS_SAFE_OVERHEAD * BigInt(transactionData.gasData.price || 1n);
const baseComputationCostWithOverhead = BigInt(gasUsed.computationCost) + safeOverhead;
const gasBudget =
baseComputationCostWithOverhead + BigInt(gasUsed.storageCost) - BigInt(gasUsed.storageRebate);
transactionData.gasData.budget = String(
gasBudget > baseComputationCostWithOverhead ? gasBudget : baseComputationCostWithOverhead,
);
}
function setGasPayment({
transactionData,
balance,
coins,
usesGasCoin,
withdrawals,
protocolConfig,
gasPayer,
chainIdentifier,
epoch,
}: {
transactionData: TransactionDataBuilder;
balance: SuiClientTypes.GetBalanceResponse;
coins: SuiClientTypes.ListCoinsResponse;
usesGasCoin: boolean;
withdrawals: bigint;
protocolConfig: SuiClientTypes.ProtocolConfig | undefined;
gasPayer: string;
chainIdentifier: string | null;
epoch: string | null;
}) {
const budget = BigInt(transactionData.gasData.budget!);
const addressBalance = BigInt(balance.balance.addressBalance);
if (budget === 0n || (!usesGasCoin && addressBalance >= budget + withdrawals)) {
transactionData.gasData.payment = [];
return;
}
const filteredCoins = coins.objects.filter((coin) => {
const matchingInput = transactionData.inputs.find((input) => {
if (input.Object?.ImmOrOwnedObject) {
return coin.objectId === input.Object.ImmOrOwnedObject.objectId;
}
return false;
});
return !matchingInput;
});
const paymentCoins = filteredCoins.map((coin) => ({
objectId: coin.objectId,
digest: coin.digest,
version: coin.version,
}));
const reservationAmount = addressBalance - withdrawals;
if (
usesGasCoin &&
reservationAmount > 0n &&
protocolConfig?.featureFlags?.['enable_coin_reservation_obj_refs'] &&
chainIdentifier &&
epoch
) {
transactionData.gasData.payment = [
createCoinReservationRef(reservationAmount, gasPayer, chainIdentifier, epoch),
...paymentCoins,
];
} else if (!filteredCoins.length) {
throw new Error('No valid gas coins found for the transaction.');
} else {
transactionData.gasData.payment = paymentCoins;
}
}
interface SystemStateData {
epoch: string;
referenceGasPrice: string;
}
async function setExpiration(
transactionData: TransactionDataBuilder,
client: ClientWithCoreApi,
systemState: SystemStateData | null,
existingChainIdentifier: string | null = null,
) {
const [chainIdentifier, resolvedSystemState] = await Promise.all([
existingChainIdentifier ?? client.core.getChainIdentifier().then((r) => r.chainIdentifier),
systemState ?? client.core.getCurrentSystemState().then((r) => r.systemState),
]);
const currentEpoch = BigInt(resolvedSystemState.epoch);
transactionData.expiration = {
$kind: 'ValidDuring',
ValidDuring: {
minEpoch: String(currentEpoch),
maxEpoch: String(currentEpoch + 1n),
minTimestamp: null,
maxTimestamp: null,
chain: chainIdentifier,
nonce: (Math.random() * 0x100000000) >>> 0,
},
};
}
async function resolveObjectReferences(
transactionData: TransactionDataBuilder,
client: ClientWithCoreApi,
) {
// Keep track of the object references that will need to be resolved at the end of the transaction.
// We keep the input by-reference to avoid needing to re-resolve it:
const objectsToResolve = transactionData.inputs.filter((input) => {
return (
input.UnresolvedObject &&
!(input.UnresolvedObject.version || input.UnresolvedObject?.initialSharedVersion)
);
}) as Extract<CallArg, { UnresolvedObject: unknown }>[];
const dedupedIds = [
...new Set(
objectsToResolve.map((input) => normalizeSuiObjectId(input.UnresolvedObject.objectId)),
),
];
const objectChunks = dedupedIds.length ? chunk(dedupedIds, MAX_OBJECTS_PER_FETCH) : [];
const resolved = (
await Promise.all(
objectChunks.map((chunkIds) =>
client.core.getObjects({
objectIds: chunkIds,
}),
),
)
).flatMap((result) => result.objects);
const responsesById = new Map(
dedupedIds.map((id, index) => {
return [id, resolved[index]];
}),
);
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 objects = resolved.map((object) => {
if (object instanceof Error) {
throw new Error(`Failed to fetch object: ${object.message}`);
}
const owner = object.owner;
const initialSharedVersion =
owner && typeof owner === 'object'
? owner.$kind === 'Shared'
? owner.Shared.initialSharedVersion
: owner.$kind === 'ConsensusAddressOwner'
? owner.ConsensusAddressOwner.startVersion
: null
: null;
return {
objectId: object.objectId,
digest: object.digest,
version: object.version,
initialSharedVersion,
};
});
const objectsById = new Map(
dedupedIds.map((id, index) => {
return [id, objects[index]];
}),
);
for (const [index, input] of transactionData.inputs.entries()) {
if (!input.UnresolvedObject) {
continue;
}
let updated: CallArg | undefined;
const id = normalizeSuiAddress(input.UnresolvedObject.objectId);
const object = objectsById.get(id);
if (input.UnresolvedObject.initialSharedVersion ?? object?.initialSharedVersion) {
updated = Inputs.SharedObjectRef({
objectId: id,
initialSharedVersion:
input.UnresolvedObject.initialSharedVersion || object?.initialSharedVersion!,
mutable: input.UnresolvedObject.mutable || isUsedAsMutable(transactionData, index),
});
} else if (isUsedAsReceiving(transactionData, index)) {
updated = Inputs.ReceivingRef(
{
objectId: id,
digest: input.UnresolvedObject.digest ?? object?.digest!,
version: input.UnresolvedObject.version ?? object?.version!,
}!,
);
}
transactionData.inputs[transactionData.inputs.indexOf(input)] =
updated ??
Inputs.ObjectRef({
objectId: id,
digest: input.UnresolvedObject.digest ?? object?.digest!,
version: input.UnresolvedObject.version ?? object?.version!,
});
}
}
async function normalizeInputs(transactionData: TransactionDataBuilder, client: ClientWithCoreApi) {
const { inputs, commands } = transactionData;
const moveCallsToResolve: Extract<Command, { MoveCall: unknown }>['MoveCall'][] = [];
const moveFunctionsToResolve = new Set<string>();
commands.forEach((command) => {
// Special case move call:
if (command.MoveCall) {
// Determine if any of the arguments require encoding.
// - If they don't, then this is good to go.
// - If they do, then we need to fetch the normalized move module.
// If we already know the argument types, we don't need to resolve them again
if (command.MoveCall._argumentTypes) {
return;
}
const inputs = command.MoveCall.arguments.map((arg) => {
if (arg.$kind === 'Input') {
return transactionData.inputs[arg.Input];
}
return null;
});
const needsResolution = inputs.some(
(input) =>
input?.UnresolvedPure ||
(input?.UnresolvedObject && typeof input?.UnresolvedObject.mutable !== 'boolean'),
);
if (needsResolution) {
const functionName = `${command.MoveCall.package}::${command.MoveCall.module}::${command.MoveCall.function}`;
moveFunctionsToResolve.add(functionName);
moveCallsToResolve.push(command.MoveCall);
}
}
});
const moveFunctionParameters = new Map<string, SuiClientTypes.OpenSignature[]>();
if (moveFunctionsToResolve.size > 0) {
await Promise.all(
[...moveFunctionsToResolve].map(async (functionName) => {
const [packageId, moduleName, name] = functionName.split('::');
const { function: def } = await client.core.getMoveFunction({
packageId,
moduleName,
name,
});
moveFunctionParameters.set(functionName, def.parameters);
}),
);
}
if (moveCallsToResolve.length) {
await Promise.all(
moveCallsToResolve.map(async (moveCall) => {
const parameters = moveFunctionParameters.get(
`${moveCall.package}::${moveCall.module}::${moveCall.function}`,
);
if (!parameters) {
return;
}
// Entry functions can have a mutable reference to an instance of the TxContext
// struct defined in the TxContext module as the last parameter. The caller of
// the function does not need to pass it in as an argument.
const hasTxContext = parameters.length > 0 && isTxContext(parameters.at(-1)!);
const params = hasTxContext ? parameters.slice(0, parameters.length - 1) : parameters;
moveCall._argumentTypes = params;
}),
);
}
commands.forEach((command) => {
if (!command.MoveCall) {
return;
}
const moveCall = command.MoveCall;
const fnName = `${moveCall.package}::${moveCall.module}::${moveCall.function}`;
const params = moveCall._argumentTypes;
if (!params) {
return;
}
if (params.length !== command.MoveCall.arguments.length) {
throw new Error(`Incorrect number of arguments for ${fnName}`);
}
params.forEach((param, i) => {
const arg = moveCall.arguments[i];
if (arg.$kind !== 'Input') return;
const input = inputs[arg.Input];
// Skip if the input is already resolved
if (!input.UnresolvedPure && !input.UnresolvedObject) {
return;
}
const inputValue = input.UnresolvedPure?.value ?? input.UnresolvedObject?.objectId!;
const schema = getPureBcsSchema(param.body);
if (schema) {
arg.type = 'pure';
inputs[inputs.indexOf(input)] = Inputs.Pure(schema.serialize(inputValue));
return;
}
if (typeof inputValue !== 'string') {
throw new Error(
`Expect the argument to be an object id string, got ${JSON.stringify(
inputValue,
null,
2,
)}`,
);
}
arg.type = 'object';
const unresolvedObject: typeof input = input.UnresolvedPure
? {
$kind: 'UnresolvedObject',
UnresolvedObject: {
objectId: inputValue,
},
}
: input;
inputs[arg.Input] = unresolvedObject;
});
});
}
function isUsedAsMutable(transactionData: TransactionDataBuilder, index: number) {
let usedAsMutable = false;
transactionData.getInputUses(index, (arg, tx) => {
if (tx.MoveCall && tx.MoveCall._argumentTypes) {
const argIndex = tx.MoveCall.arguments.indexOf(arg);
usedAsMutable =
tx.MoveCall._argumentTypes[argIndex].reference !== 'immutable' || usedAsMutable;
}
if (
tx.$kind === 'MakeMoveVec' ||
tx.$kind === 'MergeCoins' ||
tx.$kind === 'SplitCoins' ||
tx.$kind === 'TransferObjects'
) {
usedAsMutable = true;
}
});
return usedAsMutable;
}
function isUsedAsReceiving(transactionData: TransactionDataBuilder, index: number) {
let usedAsReceiving = false;
transactionData.getInputUses(index, (arg, tx) => {
if (tx.MoveCall && tx.MoveCall._argumentTypes) {
const argIndex = tx.MoveCall.arguments.indexOf(arg);
usedAsReceiving = isReceivingType(tx.MoveCall._argumentTypes[argIndex]) || usedAsReceiving;
}
});
return usedAsReceiving;
}
const RECEIVING_TYPE =
'0x0000000000000000000000000000000000000000000000000000000000000002::transfer::Receiving';
function isReceivingType(type: SuiClientTypes.OpenSignature): boolean {
if (type.body.$kind !== 'datatype') {
return false;
}
return type.body.datatype.typeName === RECEIVING_TYPE;
}