@mysten/sui
Version:
Sui TypeScript API(Work in Progress)
488 lines (412 loc) • 14.2 kB
text/typescript
// Copyright (c) Mysten Labs, Inc.
// SPDX-License-Identifier: Apache-2.0
import { parse } from 'valibot';
import type { BcsType } from '../bcs/index.js';
import { bcs } from '../bcs/index.js';
import type { SuiClient } from '../client/client.js';
import { normalizeSuiAddress, normalizeSuiObjectId, SUI_TYPE_ARG } from '../utils/index.js';
import { ObjectRef } from './data/internal.js';
import type { Argument, CallArg, Command, OpenMoveTypeSignature } from './data/internal.js';
import { Inputs } from './Inputs.js';
import { getPureBcsSchema, isTxContext, normalizedTypeToMoveTypeSignature } from './serializer.js';
import type { TransactionDataBuilder } from './TransactionData.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;
export interface BuildTransactionOptions {
client?: SuiClient;
onlyTransactionKind?: boolean;
}
export interface SerializeTransactionOptions extends BuildTransactionOptions {
supportedIntents?: string[];
}
export type TransactionPlugin = (
transactionData: TransactionDataBuilder,
options: BuildTransactionOptions,
next: () => Promise<void>,
) => Promise<void>;
export async function resolveTransactionData(
transactionData: TransactionDataBuilder,
options: BuildTransactionOptions,
next: () => Promise<void>,
) {
await normalizeInputs(transactionData, options);
await resolveObjectReferences(transactionData, options);
if (!options.onlyTransactionKind) {
await setGasPrice(transactionData, options);
await setGasBudget(transactionData, options);
await setGasPayment(transactionData, options);
}
await validate(transactionData);
return await next();
}
async function setGasPrice(
transactionData: TransactionDataBuilder,
options: BuildTransactionOptions,
) {
if (!transactionData.gasConfig.price) {
transactionData.gasConfig.price = String(await getClient(options).getReferenceGasPrice());
}
}
async function setGasBudget(
transactionData: TransactionDataBuilder,
options: BuildTransactionOptions,
) {
if (transactionData.gasConfig.budget) {
return;
}
const dryRunResult = await getClient(options).dryRunTransactionBlock({
transactionBlock: transactionData.build({
overrides: {
gasData: {
budget: String(MAX_GAS),
payment: [],
},
},
}),
});
if (dryRunResult.effects.status.status !== 'success') {
throw new Error(
`Dry run failed, could not automatically determine a budget: ${dryRunResult.effects.status.error}`,
{ cause: dryRunResult },
);
}
const safeOverhead = GAS_SAFE_OVERHEAD * BigInt(transactionData.gasConfig.price || 1n);
const baseComputationCostWithOverhead =
BigInt(dryRunResult.effects.gasUsed.computationCost) + safeOverhead;
const gasBudget =
baseComputationCostWithOverhead +
BigInt(dryRunResult.effects.gasUsed.storageCost) -
BigInt(dryRunResult.effects.gasUsed.storageRebate);
transactionData.gasConfig.budget = String(
gasBudget > baseComputationCostWithOverhead ? gasBudget : baseComputationCostWithOverhead,
);
}
// The current default is just picking _all_ coins we can which may not be ideal.
async function setGasPayment(
transactionData: TransactionDataBuilder,
options: BuildTransactionOptions,
) {
if (!transactionData.gasConfig.payment) {
const coins = await getClient(options).getCoins({
owner: transactionData.gasConfig.owner || transactionData.sender!,
coinType: SUI_TYPE_ARG,
});
const paymentCoins = coins.data
// Filter out coins that are also used as input:
.filter((coin) => {
const matchingInput = transactionData.inputs.find((input) => {
if (input.Object?.ImmOrOwnedObject) {
return coin.coinObjectId === input.Object.ImmOrOwnedObject.objectId;
}
return false;
});
return !matchingInput;
})
.map((coin) => ({
objectId: coin.coinObjectId,
digest: coin.digest,
version: coin.version,
}));
if (!paymentCoins.length) {
throw new Error('No valid gas coins found for the transaction.');
}
transactionData.gasConfig.payment = paymentCoins.map((payment) => parse(ObjectRef, payment));
}
}
async function resolveObjectReferences(
transactionData: TransactionDataBuilder,
options: BuildTransactionOptions,
) {
// 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((chunk) =>
getClient(options).multiGetObjects({
ids: chunk,
options: { showOwner: true },
}),
),
)
).flat();
const responsesById = new Map(
dedupedIds.map((id, index) => {
return [id, resolved[index]];
}),
);
const invalidObjects = Array.from(responsesById)
.filter(([_, obj]) => obj.error)
.map(([_, obj]) => JSON.stringify(obj.error));
if (invalidObjects.length) {
throw new Error(`The following input objects are invalid: ${invalidObjects.join(', ')}`);
}
const objects = resolved.map((object) => {
if (object.error || !object.data) {
throw new Error(`Failed to fetch object: ${object.error}`);
}
const owner = object.data.owner;
const initialSharedVersion =
owner && typeof owner === 'object' && 'Shared' in owner
? owner.Shared.initial_shared_version
: null;
return {
objectId: object.data.objectId,
digest: object.data.digest,
version: object.data.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: 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,
options: BuildTransactionOptions,
) {
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,
);
if (needsResolution) {
const functionName = `${command.MoveCall.package}::${command.MoveCall.module}::${command.MoveCall.function}`;
moveFunctionsToResolve.add(functionName);
moveCallsToResolve.push(command.MoveCall);
}
}
// Special handling for values that where previously encoded using the wellKnownEncoding pattern.
// This should only happen when transaction data was hydrated from an old version of the SDK
switch (command.$kind) {
case 'SplitCoins':
command.SplitCoins.amounts.forEach((amount) => {
normalizeRawArgument(amount, bcs.U64, transactionData);
});
break;
case 'TransferObjects':
normalizeRawArgument(command.TransferObjects.address, bcs.Address, transactionData);
break;
}
});
const moveFunctionParameters = new Map<string, OpenMoveTypeSignature[]>();
if (moveFunctionsToResolve.size > 0) {
const client = getClient(options);
await Promise.all(
[...moveFunctionsToResolve].map(async (functionName) => {
const [packageId, moduleId, functionId] = functionName.split('::');
const def = await client.getNormalizedMoveFunction({
package: packageId,
module: moduleId,
function: functionId,
});
moveFunctionParameters.set(
functionName,
def.parameters.map((param) => normalizedTypeToMoveTypeSignature(param)),
);
}),
);
}
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 validate(transactionData: TransactionDataBuilder) {
transactionData.inputs.forEach((input, index) => {
if (input.$kind !== 'Object' && input.$kind !== 'Pure') {
throw new Error(
`Input at index ${index} has not been resolved. Expected a Pure or Object input, but found ${JSON.stringify(
input,
)}`,
);
}
});
}
function normalizeRawArgument(
arg: Argument,
schema: BcsType<any>,
transactionData: TransactionDataBuilder,
) {
if (arg.$kind !== 'Input') {
return;
}
const input = transactionData.inputs[arg.Input];
if (input.$kind !== 'UnresolvedPure') {
return;
}
transactionData.inputs[arg.Input] = Inputs.Pure(schema.serialize(input.UnresolvedPure.value));
}
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].ref !== '&' || usedAsMutable;
}
if (tx.$kind === 'MakeMoveVec' || tx.$kind === 'MergeCoins' || tx.$kind === 'SplitCoins') {
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;
}
function isReceivingType(type: OpenMoveTypeSignature): boolean {
if (typeof type.body !== 'object' || !('datatype' in type.body)) {
return false;
}
return (
type.body.datatype.package === '0x2' &&
type.body.datatype.module === 'transfer' &&
type.body.datatype.type === 'Receiving'
);
}
export function getClient(options: BuildTransactionOptions): SuiClient {
if (!options.client) {
throw new Error(
`No provider passed to Transaction#build, but transaction data was not sufficient to build offline.`,
);
}
return options.client;
}
function chunk<T>(arr: T[], size: number): T[][] {
return Array.from({ length: Math.ceil(arr.length / size) }, (_, i) =>
arr.slice(i * size, i * size + size),
);
}