Skip to content

Commit a7e3278

Browse files
Merge pull request #9191 from BitGo/CSHLD-1146
fix(sdk-coin-sui): keep token coin type on addr-balance rebuild
2 parents 0533b68 + c139f7f commit a7e3278

2 files changed

Lines changed: 53 additions & 1 deletion

File tree

modules/sdk-coin-sui/src/lib/tokenTransferBuilder.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,14 @@ import { SuiTransaction, SuiTransactionType, TokenTransferProgrammableTransactio
55
import { Transaction } from './transaction';
66
import { TransactionBuilder } from './transactionBuilder';
77
import { TokenTransferTransaction } from './tokenTransferTransaction';
8-
import { SuiObjectRef } from './mystenlab/types';
8+
import { normalizeSuiAddress, SuiObjectRef } from './mystenlab/types';
99
import utils from './utils';
1010
import {
1111
Inputs,
1212
TransactionBlock as ProgrammingTransactionBlockBuilder,
1313
TransactionArgument,
1414
} from './mystenlab/builder';
15+
import { TypeTagSerializer } from './mystenlab/txn-data-serializers/type-tag-serializer';
1516
import BigNumber from 'bignumber.js';
1617

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

29+
/**
30+
* Coin type recovered from a deserialized transaction (the `redeem_funds` type argument /
31+
* `BalanceWithdrawal` type). When set it takes precedence over the coin type derived from the
32+
* coin config, so re-building a transaction (e.g. to attach a TSS signature before broadcast)
33+
* preserves the exact coin type it was signed with even when the builder was constructed with
34+
* the parent chain config rather than the token config.
35+
*/
36+
protected _tokenCoinTypeOverride?: string;
37+
2838
constructor(_coinConfig: Readonly<CoinConfig>) {
2939
super(_coinConfig);
3040
this._transaction = new TokenTransferTransaction(_coinConfig);
@@ -38,6 +48,9 @@ export class TokenTransferBuilder extends TransactionBuilder<TokenTransferProgra
3848
* The full coin type string derived from the coin config (e.g. `0xabc::my_token::MY_TOKEN`).
3949
*/
4050
private get tokenCoinType(): string {
51+
if (this._tokenCoinTypeOverride) {
52+
return this._tokenCoinTypeOverride;
53+
}
4154
const config = this._coinConfig as SuiCoin;
4255
return `${config.packageId}::${config.module}::${config.symbol}`;
4356
}
@@ -113,6 +126,19 @@ export class TokenTransferBuilder extends TransactionBuilder<TokenTransferProgra
113126
if (withdrawalInput) {
114127
const bw = withdrawalInput.BalanceWithdrawal ?? withdrawalInput.value?.BalanceWithdrawal;
115128
this._fundsInAddressBalance = new BigNumber(String(bw.reservation?.MaxAmountU64 ?? bw.amount));
129+
// Recover the coin type from the withdrawal's TypeTag so that re-building uses the exact
130+
// coin type the transaction was created (and signed) with. Without this, buildSuiTransaction
131+
// would re-derive the coin type from this._coinConfig, which is the parent chain config
132+
// (no packageId/module/symbol) when the builder is constructed via the chain — producing an
133+
// `undefined::undefined::undefined` coin type and a signature that fails on-chain verification.
134+
const withdrawalTypeTag = bw?.typeArg?.Balance;
135+
if (withdrawalTypeTag) {
136+
// BCS decodes the struct address without the `0x` prefix; normalize it so the recovered
137+
// coin type matches the config-derived form (`0x<addr>::module::name`). The serialized
138+
// bytes are identical either way since parseFromStr re-normalizes the address on encode.
139+
const [address, ...rest] = TypeTagSerializer.tagToString(withdrawalTypeTag).split('::');
140+
this._tokenCoinTypeOverride = [normalizeSuiAddress(address), ...rest].join('::');
141+
}
116142
}
117143

118144
if (txData.inputObjects && txData.inputObjects.length > 0) {

modules/sdk-coin-sui/test/unit/transactionBuilder/tokenTransferBuilder.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,32 @@ describe('Sui Token Transfer Builder', () => {
180180
rebuiltTx.toBroadcastFormat().should.equal(rawTx);
181181
});
182182

183+
it('should preserve the token coin type when rebuilt with the parent chain config', async function () {
184+
// Reproduces the production broadcast path: the transaction is created with the token config
185+
// (tsui:deep) but re-assembled for broadcast by a builder constructed with the parent CHAIN
186+
// config (tsui, no packageId/module/symbol). Before the fix, buildSuiTransaction re-derived
187+
// the coin type from the chain config and produced `undefined::undefined::undefined`, changing
188+
// the tx bytes so the already-computed signature failed on-chain verification.
189+
const txBuilder = factory.getTokenTransferBuilder();
190+
txBuilder.type(SuiTransactionType.TokenTransfer);
191+
txBuilder.sender(testData.sender.address);
192+
txBuilder.send([{ address: testData.recipients[0].address, amount: '1000' }]);
193+
txBuilder.gasData(testData.gasData);
194+
txBuilder.fundsInAddressBalance(FUNDS_IN_ADDRESS_BALANCE);
195+
const rawTx = (await txBuilder.build()).toBroadcastFormat();
196+
// Rebuild using the CHAIN config, not the token config.
197+
const chainFactory = getBuilderFactory('tsui');
198+
const rebuilder = chainFactory.from(rawTx);
199+
rebuilder.addSignature({ pub: testData.sender.publicKey }, Buffer.from(testData.sender.signatureHex));
200+
const rebuiltTx = await rebuilder.build();
201+
// Bytes must be unchanged so the signature still verifies.
202+
rebuiltTx.toBroadcastFormat().should.equal(rawTx);
203+
const rebuiltProgrammableTx = (rebuiltTx as SuiTransaction<TokenTransferProgrammableTransaction>).suiTransaction
204+
.tx;
205+
(rebuiltProgrammableTx.transactions[0] as any).target.should.equal('0x2::coin::redeem_funds');
206+
(rebuiltProgrammableTx.transactions[0] as any).typeArguments[0].should.equal(TOKEN_COIN_TYPE);
207+
});
208+
183209
it('should build a token transfer with coin objects + address balance', async function () {
184210
const numberOfInputObjects = 3;
185211
const inputObjects = testData.generateObjects(numberOfInputObjects);

0 commit comments

Comments
 (0)