前端在Sui上构建交易的实战

100%解决你在Sui上构建交易的困惑,纯干货!

我个人作为从Evm系转战Sui系一个月不到的前端开发者,在这期间踩了不少坑,网上的很多教材大多点到即止或者说没有说到重点,大部分普通开发者很难得其要领,因此在这里我把自己真实开发过程中遇到的问题以及自己的思考分享出来,希望能帮助到更多在Sui生态上Build的开发者小伙伴。


何为交易?

简单来说Sui有自己的Coin对象模型:

  • 在 Sui 中,每个代币都是一个独立的对象(Object)
  • 每个 Coin 对象都有其唯一的 ID 和余额
  • 这与以太坊等链上的账户余额模型不同,以太坊只需要更新账户余额数字即可

想象你有一堆现金:

  • 以太坊模型:就像银行账户,你只需要知道总余额,转账时直接修改数字
  • Sui 模型:就像你钱包里的现金,每张钞票都是独立的实体,有自己的编号
  • 如果你要支付 150 元,但只有 200 元的钞票,你需要:
    1. 先找银行把 200 元换成 150 元和 50 元
    2. 用 150 元支付
    3. 把剩下的 50 元放回钱包

前端构建交易的核心 —— 拆分合并代币

接下来我会以一个最常见的例子进行教学:添加流动性。

AddLiquidity的合约函数长这样:

entry public add_liquidity<Ty0>
(Arg0: &PackageMark,  // 常量-包标记,用于标识合约
Arg1: &mut Vault // 常量- 金库对象,用于存储流动性
, Arg2: &Clock,  // 常量 - 时钟对象,用于时间相关操作
Arg3: &mut Coin<Ty0> // 代币对象,泛型参数Ty0表示代币类型
, Arg4: u256,  // 添加的流动性数量(带精度) (比如deposit_coin为0x2::sui::SUI, 0x2::sui::SUI 精度为9。deposit_amount为1e9, 则添加流动性为1 SUI;如果deposit_amount为0.1e9, 则添加流动性为0.1 SUI; )
Arg5: &mut TxContext) // 交易上下文 

注意一点:合约函数接受参数的类型顺序是有要求的,构造交易时必须严格按照合约函数里定义的参数顺序传入。

首先三个常量类型我们可以直接定义:

export interface BaseParams {
  /** The package mark object ID */
  packageMarkId: string;
  /** The vault object ID */
  vault: string;
  /** The clock object ID : 0x6 */
  clockObj: string;
}

然后问题就来了,我需要一个代币对象作为流动性添加进去。按照前面对于交易的认知,如果我需要把Sui作为这个函数的参数添加进去,那么我就必须拆分出等于添加的流动性数量的Sui,然后获取这个拆分出来的SuiCoin对象作为add_liquidity的参数传入。同时考虑到良好的用户体验,拆分和调用add_liquidity应当在一笔交易内完成。

拆分这里需要用到SplitCoins方法,相关文档在https://sdk.mystenlabs.com/typescript/transaction-building/basics

这里需要注意的一点是:如果你拆分的是Sui,因为gasFee的支付就是用的SUi,所以SplitCoins的第一个参数必须是tx.gas;如果你拆分的是其余的代币,那么SplitCoins的第一个参数就得是需要拆分的代币对象。因此如果是第二种情况的话,前端还得获取需要拆分的代币对象。

下面的代码涉及到数字处理我都是用了BigNumber.js这个库https://github.com/MikeMcl/bignumber.js#readme以及@mysten/sui这个库https://sdk.mystenlabs.com/typescript

这里我自己写了一个工具函数,用于区分Sui以及其他token的情况,每次在构造交易前调用可以拆分出指定数量的代币作为一个代币对象入参:

import { BigNumber } from 'bignumber.js';

/**
   * Get exact amount of coins for transaction, handling both SUI and other tokens.
   * For SUI tokens, splits from gas coin.
   * For other tokens, merges all coins first then splits to exact amount.
   *
   * @param options - The options for getting exact coin amount
   * @param options.txb - The transaction builder instance
   * @param options.address - The wallet address to get coins from
   * @param options.coinType - The type of coin to get (e.g., '0x2::sui::SUI')
   * @param options.amt - The amount of coins needed (bigint)
   * @returns A transaction command for splitting coins to exact amount
   * @throws {NoCoinFoundError} When no coins of specified type are found
   * @throws {InsufficientBalanceError} When total balance is less than requested amount
   *
   * @example
   * ```typescript
   * const txb = new Transaction();
   * const result = await getExactlyCoinAmount({
   *   txb,
   *   address: '0x123...',
   *   coinType: '0x2::sui::SUI',
   *   amt: new BigNumber(1000000)
   * });
   * ```
   */
   async getExactlyCoinAmount(options: {
    txb: Transaction;
    address: string;
    coinType: string;
    amt: BigNumber;
  }) {
    let { txb, address, coinType, amt } = options;

    const isSUI =
      coinType === '0x0000000000000000000000000000000000000000000000000000000000000002::sui::SUI' || coinType === '0x2::sui::SUI';

    const coins = await getCoinsByType(coinType, address);
    if (coins.length === 0) throw new NoCoinFoundError();

    const balances = coins.reduce(
      (total, coin) => total.plus(new BigNumber(coin.balance)),
      new BigNumber(0),
    );

    if (balances.lt(amt)) throw new InsufficientBalanceError();

    if (isSUI)
      return txb.splitCoins(txb.gas, [
        amt.integerValue(BigNumber.ROUND_FLOOR).toString(),
      ]);

    // other coin
    // step1: merge all coin
    // step2: split to exact amount
    // TODO: no other coin to do the test
    const [primaryCoin, ...mergedCoin] = coins.map((coin) =>
      txb.object(coin.coinObjectId),
    );

    if (mergedCoin.length) txb.mergeCoins(primaryCoin, mergedCoin);

    return txb.splitCoins(primaryCoin, [
      amt.integerValue(BigNumber.ROUND_FLOOR).toString(),
    ]);
  }

用到的辅助函数有:

  import { SuiClient, getFullnodeUrl } from '@mysten/sui/client';
  /**
   * Get all coin objects of a specific type for a given account.
   *
   * @param coinType Coin type identifier
   * @param address Wallet address
   * @returns Array of coin objects
   * @throws {WalletAccountNotFoundError} If address is not provided
   *
   * @example
   * ```typescript
   * const coins = await getCoinsByType('0x2::sui::SUI', '0x123...');
   * ```
   */
   async getCoinsByType(coinType: string, address: string) {
    if (!address) throw new WalletAccountNotFoundError();
    const client = new SuiClient({
        url: getFullnodeUrl("testnet"),
      }
    const coins = await client.getCoins({
      owner: address,
      coinType,
    });
    return coins.data;
  }

同时考虑到我这里的数量需要带上对应币的精度,SUI是1e9,但是其他代币精度不尽相同,因此需要获取代币的metadata,拿到精度信息(或者后端传的接口里给你返回了那就直接用):

  import { SuiClient, getFullnodeUrl } from '@mysten/sui/client';
  /**
   * Get metadata for a specific coin type.
   *
   * @param coinType Coin type identifier (e.g., '0x2::sui::SUI')
   * @returns Coin metadata object
   * @throws {NoMetadataFoundError} If metadata is not found
   *
   * @example
   * ```typescript
   * const metadata = await utilsService.getCoinMetadata('0x2::sui::SUI');
   * ```
   */
  async getCoinMetadata(coinType: string) {
      const client = new SuiClient({
        url: getFullnodeUrl("testnet"),
      }
    const coinMetadata = await client.getCoinMetadata({
      coinType,
    });
    if (!coinMetadata) throw new NoMetadataFoundError();
    return coinMetadata; // number,example:sui decimals is 9
  }
  /**
   * Get coin decimals, either from cache or by fetching from chain
   * @param coinType The coin type to get decimals for
   * @param decimals Optional cached decimals value
   * @returns The number of decimals for the coin
   */
  public async getCoinDecimals(
    coinType: string,
    decimals?: number | string,
  ): Promise<number> {
    if (decimals) return Number(decimals);
    const coinMetadata = await getCoinMetadata(coinType);
    return coinMetadata.decimals;
  }
  import { BigNumber } from 'bignumber.js';
  /**
   * Convert a user-friendly amount to chain amount based on decimals
   * @param amount The amount to convert (can be string, number)
   * @param decimals The number of decimals for the coin
   * @returns The amount in chain format (BigNumber)
   */
   convertToChainAmount(
    amount: string | number,
    decimals: number,
  ): BigNumber {
    const bn = new BigNumber(amount);
    if (bn.isNaN() || bn.isZero() || bn.isNegative())
      throw new AmountTooSmallError();

    const chainAmount = bn.times(new BigNumber(10).pow(decimals));
    if (chainAmount.isZero()) throw new AmountTooSmallError();

    return chainAmount.integerValue(BigNumber.ROUND_FLOOR);
  }

我构造添加流动性交易入参的类型如下:

export interface BaseParams {
  /** The package mark object ID */
  packageMarkId: string;
  /** The vault object ID */
  vault: string;
  /** The clock object ID : 0x6 */
  clockObj: string;
}
export interface AddLiquidityParams extends BaseParams = {
  /** The amount of tokens to deposit */
  depositAmount: string | number;
  /** Type arguments for add liquidity operation = [deposit_token_type: string] */
  typeArguments: AddLiquidityTypeArgs;
}

export type AddLiquidityCtx = {
  caller: string;
  depositCoinDecimals?: number | string; // 如果后端接口提供了,直接使用,没提供的话getCoinDecimals会调用rpc计算
};

我的整个添加流动性的交易构造如下:

  /**
   * Builds a transaction for adding liquidity to a vault
   * @param params - Parameters for adding liquidity
   * @param params.depositAmount - The amount of tokens to deposit
   * @param params.vault - The vault ID to add liquidity to
   * @param params.clockObj - The clock object ID
   * @param params.typeArguments - Type arguments for the transaction
   * @param params.packageMarkId - The package mark ID
   *
   * @param ctx - Context parameters
   * @param ctx.caller - The address of the caller
   * @param ctx.depositCoinDecimals - Optional the number of decimals for the token
   * @param ptx - Optional existing transaction to append to
   * @returns A Transaction object for adding liquidity
   */

 async buildAddLiquidityTx(
    params: AddLiquidityParams,
    ctx: AddLiquidityCtx,
    ptx?: Transaction,
  ): Promise<Transaction> {
    const { depositAmount, vault, clockObj, typeArguments, packageMarkId } =
      params;
    const { caller, depositCoinDecimals } = ctx;
    const tx = ptx ?? new Transaction();
    /** 获取入参代币的精度 */
    const _depositCoinDecimals = await getCoinDecimals(
      typeArguments[0],
      depositCoinDecimals,
    );
    /** 计算考虑精度后的数量,这样depositAmount只需要传入用户可读可理解的数量即可,比如0.1个sui就传0.1 */
    const _amt = convertToChainAmount(
      depositAmount,
      _depositCoinDecimals,
    );
    /** 拆分出数量正好适用于交易的Coin对象 */
    const _depositCoin = await getExactlyCoinAmount({
      txb: tx,
      address: caller,
      coinType: typeArguments[0],
      amt: _amt,
    });

    tx.moveCall({
      target: `${packageId}::vault::add_liquidity`,
      /** 按照合约函数顺序依次传入参数 */
      arguments: [
        tx.object(packageMarkId),
        tx.object(vault),
        tx.object(clockObj),
        _depositCoin,
        tx.pure.u256(_amt.toString()),
      ],
      typeArguments,
    });
    return tx;
  }

走到这里看起来是大功告成了,我们拆分了添加流动性需要的具体数量的Coin对象,我们处理了精度问题,我们入参也是按照合约函数的顺序入参,接下来让我们发起交易吧(后续交易发起就使用伪代码了):

const addLiquidityParams = {
// ... 还有三个常量省略了
  depositAmount: '1000',
  typeArguments: ['0x2::sui::SUI'],
};
const tx = await buildAddLiquidityTx(addLiquidityParams, {
  caller: walletAddress,
}); // 构造交易需要的tx
const result = await tx.execute(); // 使用钱包sdk发起交易,这里是伪代码

然而交易还是失败了,区块链浏览器报错—— UnusedValueWithoutDrop { result_idx: 0, secondary_idx: 0 }

这个错误是 Move 语言中的一个常见错误:UnusedValueWithoutDrop 表示在 Move 函数中创建了一个值但没有使用它。

那么在我们这个例子里什么值创建了但是没使用呢?答案就是我们拆分出来的那个Coin对象没有被合约函数使用,因为coin是没有drop能力的object,需要我们自己显式处理:

// 运用 tx.transferObjects([coin], caller);
async buildAddLiquidityTx(
    params: AddLiquidityParams,
    ctx: AddLiquidityCtx,
    ptx?: Transaction,
  ): Promise<Transaction> {
    const { depositAmount, vault, clockObj, typeArguments, packageMarkId } =
      params;
    const { caller, depositCoinDecimals } = ctx;
    const tx = ptx ?? new Transaction();
    /** 获取入参代币的精度 */
    const _depositCoinDecimals = await getCoinDecimals(
      typeArguments[0],
      depositCoinDecimals,
    );
    /** 计算考虑精度后的数量,这样depositAmount只需要传入用户可读可理解的数量即可,比如0.1个sui就传0.1 */
    const _amt = convertToChainAmount(
      depositAmount,
      _depositCoinDecimals,
    );
    /** 拆分出数量正好适用于交易的Coin对象 */
    const _depositCoin = await getExactlyCoinAmount({
      txb: tx,
      address: caller,
      coinType: typeArguments[0],
      amt: _amt,
    });

    tx.moveCall({
      target: `${packageId}::vault::add_liquidity`,
      /** 按照合约函数顺序依次传入参数 */
      arguments: [
        tx.object(packageMarkId),
        tx.object(vault),
        tx.object(clockObj),
        _depositCoin,
        tx.pure.u256(_amt.toString()),
      ],
      typeArguments,
    });
    tx.transferObjects([_depositCoin], caller) // 避免出现未使用的变量
    return tx;
  }

至此,完成了我从Evm系前端开发者转到Sui系前端开发者的第一笔交易,希望我的这篇教程对大家有帮助,以后我也会持续在Sui生态上学习进步!

Make Sui Great Again!

点赞 2
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
IannnnJJJJ
IannnnJJJJ
0xf42f...172e
唱跳Rap写代码