Skip to content
Merged
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
28 changes: 27 additions & 1 deletion modules/sdk-coin-sui/src/lib/tokenTransferBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,14 @@ import { SuiTransaction, SuiTransactionType, TokenTransferProgrammableTransactio
import { Transaction } from './transaction';
import { TransactionBuilder } from './transactionBuilder';
import { TokenTransferTransaction } from './tokenTransferTransaction';
import { SuiObjectRef } from './mystenlab/types';
import { normalizeSuiAddress, SuiObjectRef } from './mystenlab/types';
import utils from './utils';
import {
Inputs,
TransactionBlock as ProgrammingTransactionBlockBuilder,
TransactionArgument,
} from './mystenlab/builder';
import { TypeTagSerializer } from './mystenlab/txn-data-serializers/type-tag-serializer';
import BigNumber from 'bignumber.js';

export class TokenTransferBuilder extends TransactionBuilder<TokenTransferProgrammableTransaction> {
Expand All @@ -25,6 +26,15 @@ export class TokenTransferBuilder extends TransactionBuilder<TokenTransferProgra
*/
protected _fundsInAddressBalance: BigNumber = new BigNumber(0);

/**
* Coin type recovered from a deserialized transaction (the `redeem_funds` type argument /
* `BalanceWithdrawal` type). When set it takes precedence over the coin type derived from the
* coin config, so re-building a transaction (e.g. to attach a TSS signature before broadcast)
* preserves the exact coin type it was signed with even when the builder was constructed with
* the parent chain config rather than the token config.
*/
protected _tokenCoinTypeOverride?: string;

constructor(_coinConfig: Readonly<CoinConfig>) {
super(_coinConfig);
this._transaction = new TokenTransferTransaction(_coinConfig);
Expand All @@ -38,6 +48,9 @@ export class TokenTransferBuilder extends TransactionBuilder<TokenTransferProgra
* The full coin type string derived from the coin config (e.g. `0xabc::my_token::MY_TOKEN`).
*/
private get tokenCoinType(): string {
if (this._tokenCoinTypeOverride) {
return this._tokenCoinTypeOverride;
}
const config = this._coinConfig as SuiCoin;
return `${config.packageId}::${config.module}::${config.symbol}`;
}
Expand Down Expand Up @@ -113,6 +126,19 @@ export class TokenTransferBuilder extends TransactionBuilder<TokenTransferProgra
if (withdrawalInput) {
const bw = withdrawalInput.BalanceWithdrawal ?? withdrawalInput.value?.BalanceWithdrawal;
this._fundsInAddressBalance = new BigNumber(String(bw.reservation?.MaxAmountU64 ?? bw.amount));
// Recover the coin type from the withdrawal's TypeTag so that re-building uses the exact
// coin type the transaction was created (and signed) with. Without this, buildSuiTransaction
// would re-derive the coin type from this._coinConfig, which is the parent chain config
// (no packageId/module/symbol) when the builder is constructed via the chain — producing an
// `undefined::undefined::undefined` coin type and a signature that fails on-chain verification.
const withdrawalTypeTag = bw?.typeArg?.Balance;
if (withdrawalTypeTag) {
// BCS decodes the struct address without the `0x` prefix; normalize it so the recovered
// coin type matches the config-derived form (`0x<addr>::module::name`). The serialized
// bytes are identical either way since parseFromStr re-normalizes the address on encode.
const [address, ...rest] = TypeTagSerializer.tagToString(withdrawalTypeTag).split('::');
this._tokenCoinTypeOverride = [normalizeSuiAddress(address), ...rest].join('::');
}
}

if (txData.inputObjects && txData.inputObjects.length > 0) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,32 @@ describe('Sui Token Transfer Builder', () => {
rebuiltTx.toBroadcastFormat().should.equal(rawTx);
});

it('should preserve the token coin type when rebuilt with the parent chain config', async function () {
// Reproduces the production broadcast path: the transaction is created with the token config
// (tsui:deep) but re-assembled for broadcast by a builder constructed with the parent CHAIN
// config (tsui, no packageId/module/symbol). Before the fix, buildSuiTransaction re-derived
// the coin type from the chain config and produced `undefined::undefined::undefined`, changing
// the tx bytes so the already-computed signature failed on-chain verification.
const txBuilder = factory.getTokenTransferBuilder();
txBuilder.type(SuiTransactionType.TokenTransfer);
txBuilder.sender(testData.sender.address);
txBuilder.send([{ address: testData.recipients[0].address, amount: '1000' }]);
txBuilder.gasData(testData.gasData);
txBuilder.fundsInAddressBalance(FUNDS_IN_ADDRESS_BALANCE);
const rawTx = (await txBuilder.build()).toBroadcastFormat();
// Rebuild using the CHAIN config, not the token config.
const chainFactory = getBuilderFactory('tsui');
const rebuilder = chainFactory.from(rawTx);
rebuilder.addSignature({ pub: testData.sender.publicKey }, Buffer.from(testData.sender.signatureHex));
const rebuiltTx = await rebuilder.build();
// Bytes must be unchanged so the signature still verifies.
rebuiltTx.toBroadcastFormat().should.equal(rawTx);
const rebuiltProgrammableTx = (rebuiltTx as SuiTransaction<TokenTransferProgrammableTransaction>).suiTransaction
.tx;
(rebuiltProgrammableTx.transactions[0] as any).target.should.equal('0x2::coin::redeem_funds');
(rebuiltProgrammableTx.transactions[0] as any).typeArguments[0].should.equal(TOKEN_COIN_TYPE);
});

it('should build a token transfer with coin objects + address balance', async function () {
const numberOfInputObjects = 3;
const inputObjects = testData.generateObjects(numberOfInputObjects);
Expand Down
Loading